Skip to content

fix(workflow): don't replay a finished run on the next turn - #637

Merged
kalenkevich merged 2 commits into
mainfrom
fix/workflow-stale-rehydration
Aug 11, 2026
Merged

fix(workflow): don't replay a finished run on the next turn#637
kalenkevich merged 2 commits into
mainfrom
fix/workflow-stale-rehydration

Conversation

@kalenkevich

@kalenkevich kalenkevich commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Re-invoking a workflow that already completed does not run it. Every node is fast-forwarded from the previous turn's cached output, so the workflow re-emits its previous answer and the new user message is silently ignored.

Probe on current main, a two-node workflow over two turns:

RUNS:          ["n1(first)", "n2(n1:first)"]      <- nothing ran on turn 2
turn 1 output: "n2:n1:first"
turn 2 output: "n2:n1:first"                      <- replayed; "second" ignored

Any multi-turn conversational workflow answers the first question forever.

Root cause

Rehydration exists to resume a run that paused, but reconstructNodeStates was handed every event in the session (workflow.ts:192), so a finished run looked identical to one waiting to be resumed — cached outputs present, isFastForwardable true, every node skipped.

Why not upstream's fix

google/adk-python scopes this by invocation id:

for event in events:
    if invocation_id and event.invocation_id != invocation_id:
      continue

That works there because a resumed invocation keeps its id. The TypeScript runner mints a fresh invocation id on every turn (Runner.runAsync, runner.ts:299) — there is no invocation-level resumption. A run that spans a pause therefore covers several invocations, and an id filter discards exactly the outputs resume needs.

Measured, not assumed — porting the filter verbatim fixes the replay bug but breaks four existing tests:

FAIL auth_gate_test         > requests credentials, then runs after they are supplied on resume
FAIL dynamic_resume_test    > dedups a completed dynamic node and resumes a waiting one
FAIL resume_test            > resumes an interrupted workflow without re-running completed nodes
FAIL workflow_advanced_test > fast-forwards a completed node that is NOT rerunOnResume

Fix

Delimit runs by pausing rather than by invocation id:

  • an invocation that raised an interrupt ended paused → part of the run in progress;
  • an invocation that emitted node events without raising one ran to completion → it is the boundary, and it and everything earlier is dropped;
  • the current invocation is always kept (in progress by definition, and it carries the function responses that resolve pending interrupts).

A pause is an event carrying one of the three adk_request_* calls, tested via requiresUserInput (#633). Deliberately not longRunningToolIds, which marks any tool declared isLongRunning: a run that merely used one is not waiting on a person, and counting it as a pause brings the replay bug straight back for those workflows.

eventsForCurrentRun applies that at the three places that scan history:

site why
workflow.ts graph rehydration the replay bug itself
dynamic_node_scheduler.ts same staleness for ctx.runNode children
workflow_agent.ts plain-text resume a plain-text resume never writes a resolving functionResponse, so a finished run's interrupt stayed "pending" forever and swallowed later messages

One subtlety

Engine-minted events carry no invocation id — createRequestInputEvent produces an interrupt event that enrichEvent stamps with author, path and branch but no id. Left alone it opens a phantom run and the boundary lands mid-run, re-executing completed nodes. The scan carries the last id seen forward. There is a test for it; arguably the underlying gap is that enrichEvent should stamp invocationId too — happy to do that separately, since it changes event contents.

Tests

New core/test/workflow/rehydration_scope_test.ts:

  • unit coverage of eventsForCurrentRun: completed run dropped; paused run kept; multi-pause run kept across all its invocations; a completed run before a paused one cut correctly; id-less engine event attributed to its surrounding invocation; empty/no-op input
  • a completed run that merely used a long-running tool is dropped, and a run paused on adk_request_confirmation is kept — the two ends of the pause predicate
  • the replay regression end-to-end through the Runner
  • pause → resume → new run on the following turn, asserting the finished run is not replayed

Pauses in the fixtures are built the way the engine builds them (createRequestInputEvent: an adk_request_* call whose id is also a long-running tool id), not as a bare longRunningToolIds, which no real event looks like.

Two other fixtures updated to match how real events look: a hand-built prior event now carries the invocation of the run in progress, and a synthetic pending interrupt now carries the nodeInfo.path the engine always stamps.

unit:core green: 204 files, 2804 tests.

Stacking

#635 has landed and is merged in here. Independent of #636 (disjoint files).

@kalenkevich kalenkevich self-assigned this Aug 6, 2026
@kalenkevich
kalenkevich requested a review from AmaadMartin August 6, 2026 23:10

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnosis is right and the reasoning for not porting upstream's invocation-id filter is sound — I checked runner.ts:299 and the runner does mint a fresh id per turn, so that filter would discard the outputs resume depends on.

I verified the two assumptions the new scoping rests on, and both hold. The turn's user event carries the current invocation id, so currentStart anchors where you expect. enrichEvent (node_runner.ts:363) stamps nodeInfo.path unconditionally, so real interrupt events always carry it — the workflow_agent_test.ts fixture change corrects the fixture rather than papering over a gap.

One real problem in raisedInterrupt, inline. Also note CI has not run here: only cla/google has reported, so the suite result in the description is unverified.

Comment on lines +119 to +124
function raisedInterrupt(event: Event): boolean {
return (
(event.longRunningToolIds?.length ?? 0) > 0 ||
hasRequestInputFunctionCall(event) ||
hasAuthRequestFunctionCall(event)
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a nit. This treats "used a long-running tool" as "paused for a human", and the replay bug comes back for workflows that do the former.

(event.longRunningToolIds?.length ?? 0) > 0 ||

getLongRunningFunctionCalls (agents/functions.ts:57) adds the id of any tool with isLongRunning, not just the HITL ones. A node whose agent calls such a tool emits an event with nodeInfo.path (stamped by enrichEvent) and a non-empty longRunningToolIds. That invocation is recorded paused, so on the next turn its finished run is kept and every node is fast-forwarded — exactly what this PR fixes, for those workflows.

You cannot just delete the clause: adk_request_confirmation has no predicate in core/src/workflow, so this is the only thing covering confirmation pauses today.

Test the three interrupt call names instead. Your #633 adds that predicate as requiresUserInput in agents/user_input_request.ts, where it currently has no caller — this is the caller.

Worth noting the unit tests here only ever mark a pause with longRunningToolIds, never with a real interrupt call, so none of them would catch this.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and the fix is in 0515d1c.

raisedInterrupt is now requiresUserInput(event) from #633, which landed while this sat. That also picks up adk_request_confirmation properly — as you say, it was only ever reachable here through the longRunningToolIds clause, and nothing tested it.

Your last line was the useful part. Swapping the predicate with nothing else changed broke exactly four tests, all of them the fixtures in this file, all of them for the same reason: they build a pause as a bare longRunningToolIds with no adk_request_* call, which no engine event looks like. Every end-to-end test — auth gate, dynamic resume, HITL flow, workflow_advanced — stayed green, which is the confirmation that real interrupt events do carry the call.

So the fixtures now go through a pauseEvent() helper shaped like createRequestInputEvent, and there are two new cases for the gap:

  • a completed run that merely used a long-running tool is dropped (fails on the old predicate);
  • a run paused on adk_request_confirmation is kept.

unit:core green: 204 files, 2804 tests.

Worth noting the same conflation lives one level deeper: node_runner.ts:241 turns every longRunningToolIds entry into a node interrupt, so a node whose agent calls a long-running tool is treated as waiting regardless. That is beyond this diff — I'll file it.

lastInvocationId = event.invocationId;
}
if (invocationId === currentInvocationId) {
currentStart = Math.min(currentStart, i);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, optional. Two guards in here defend against something that cannot happen.

currentStart = Math.min(currentStart, i);
...
return boundary <= 0 ? events : events.slice(boundary);

i only increases, so the first match is already the smallest and Math.min never changes the result. boundary is never negative, and events.slice(0) already returns everything, so the ternary only decides whether the caller gets the original array or a copy — an inconsistency rather than a saving.

if (currentStart === events.length) currentStart = i;
...
return events.slice(boundary);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both taken in 0515d1c. Math.min is now a first-write guard and the ternary is a plain events.slice(boundary).

@AmaadMartin

Copy link
Copy Markdown
Collaborator

CI has now run, which my review said it had not. Both failures are unrelated to this change, so no action needed from you on them.

FAIL integration  tests/integration/a2a/stream/stream_test.ts   CLI exited prematurely with code 1
FAIL integration  tests/integration/app_loader/app_loader_test.ts   Test timed out in 40000ms

Neither touches core/src/workflow, and unit:core is green — 3141 passed, so the suite result in your description holds.

Both belong to the same family: an integration test that spawns a real process and waits on a fixed timeout. The same family is red elsewhere right now. #618, whose diff is an export-condition fix, fails on a2a/input_required plus unsafe_local_code_executor — and that second one also failed on #586, whose diff was two lines of Markdown, then passed on a re-run.

I have re-run the failed jobs here. Worth a separate look at those timeouts, in the spirit of #548, #549 and #550; they are costing every PR a re-run.

My review comment above still stands on its own.

@ScottMansfield

Copy link
Copy Markdown
Member

We should take a step back and reconsider.

I understand a direct port of Python's functionality broke tests, but we should take a closer look at why rather than skip the idea entirely and re-implement a parallel system of pausing and resuming. It only took a few minutes of prompting to get to a spot where we can just re-use invocation ID. The change is much smaller and simpler and all tests pass.

This patch implements the simpler version:

diff --git a/core/src/runner/runner.ts b/core/src/runner/runner.ts
index ba43f2a..c913cc4 100644
--- a/core/src/runner/runner.ts
+++ b/core/src/runner/runner.ts
@@ -32,6 +32,7 @@ import {BasePlugin} from '../plugins/base_plugin.js';
 import {PluginManager} from '../plugins/plugin_manager.js';
 import {BaseSessionService} from '../sessions/base_session_service.js';
 import {CompositeSessionKey, Session} from '../sessions/session.js';
+import {reconstructNodeStates} from '../workflow/utils/rehydration_utils.js';
 import {
   runAsyncGeneratorWithOtelContext,
   tracer,
@@ -296,7 +297,7 @@ export class Runner {
             sessionService: this.sessionService,
             memoryService: this.memoryService,
             credentialService: this.credentialService,
-            invocationId: newInvocationContextId(),
+            invocationId: getInvocationIdForRun(session, newInvocationContextId()),
             agent: this.agent,
             session,
             userContent: newMessage,
@@ -698,3 +699,26 @@ function getAllToolsets(agent: BaseAgent): BaseToolset[] {
   traverse(agent);
   return toolsets;
 }
+
+function getInvocationIdForRun(session: Session, newId: string): string {
+  const pending = new Set<string>();
+  const states = reconstructNodeStates(session.events);
+  for (const node of states.values()) {
+    for (const id of node.interruptIds) {
+      if (!node.resolvedResponses.has(id)) {
+        pending.add(id);
+      }
+    }
+  }
+
+  if (pending.size > 0) {
+    for (let i = session.events.length - 1; i >= 0; i--) {
+      const event = session.events[i];
+      if (event.invocationId) {
+        return event.invocationId;
+      }
+    }
+  }
+  return newId;
+}
+
diff --git a/core/src/workflow/dynamic_node_scheduler.ts b/core/src/workflow/dynamic_node_scheduler.ts
index 7cfbe1e..776a335 100644
--- a/core/src/workflow/dynamic_node_scheduler.ts
+++ b/core/src/workflow/dynamic_node_scheduler.ts
@@ -16,6 +16,7 @@ import {
   ScheduleDynamicNodeOptions,
 } from './schedule_dynamic_node.js';
 import {
+  eventsForCurrentRun,
   isFastForwardable,
   makeFastForwardResult,
   reconstructNodeStatesByPath,
@@ -62,9 +63,9 @@ export class DynamicNodeScheduler implements ScheduleDynamicNode {
 
     // Cross-turn resume: rehydrate this dynamic run from prior session events.
     if (!this.state.runs.has(nodePath)) {
-      const prior = reconstructNodeStatesByPath(ctx.session?.events ?? []).get(
-        nodePath,
-      );
+      const prior = reconstructNodeStatesByPath(
+        eventsForCurrentRun(ctx.session?.events ?? [], ctx.invocationId),
+      ).get(nodePath);
       if (prior && !node.rerunOnResume && isFastForwardable(prior)) {
         // Completed in a prior turn -> return cached output, do not re-execute.
         this.state.runs.set(nodePath, {
diff --git a/core/src/workflow/utils/rehydration_utils.ts b/core/src/workflow/utils/rehydration_utils.ts
index 3b3bf87..1426801 100644
--- a/core/src/workflow/utils/rehydration_utils.ts
+++ b/core/src/workflow/utils/rehydration_utils.ts
@@ -209,3 +209,23 @@ export function unwrapResponse(response: unknown): unknown {
   }
   return response;
 }
+
+/**
+ * Verbatim filter from python adk: drops events with a different invocation ID.
+ */
+export function eventsForCurrentRun(
+  events: Event[],
+  currentInvocationId: string,
+): Event[] {
+  return events.filter((event) => {
+    if (
+      currentInvocationId &&
+      event.invocationId &&
+      event.invocationId !== currentInvocationId
+    ) {
+      return false;
+    }
+    return true;
+  });
+}
+
diff --git a/core/src/workflow/workflow.ts b/core/src/workflow/workflow.ts
index e315291..8882323 100644
--- a/core/src/workflow/workflow.ts
+++ b/core/src/workflow/workflow.ts
@@ -22,6 +22,7 @@ import {NodeStatus} from './node_status.js';
 import {DynamicNodeState} from './schedule_dynamic_node.js';
 import {Trigger} from './trigger.js';
 import {
+  eventsForCurrentRun,
   isFastForwardable,
   makeFastForwardResult,
   reconstructNodeStates,
@@ -190,7 +191,7 @@ export class Workflow extends BaseNode {
     // own direct children (by path) so nested workflows with same-named nodes
     // don't collide.
     const rehydrated = reconstructNodeStates(
-      ctx.session?.events ?? [],
+      eventsForCurrentRun(ctx.session?.events ?? [], ctx.invocationId),
       ctx.nodePath || undefined,
     );
     this.applyResumeInputs(ctx, rehydrated);
diff --git a/core/src/workflow/workflow_agent.ts b/core/src/workflow/workflow_agent.ts
index 9c75c4a..cb16296 100644
--- a/core/src/workflow/workflow_agent.ts
+++ b/core/src/workflow/workflow_agent.ts
@@ -12,7 +12,10 @@ import {AsyncQueue} from '../utils/async_queue.js';
 import {experimental} from '../utils/experimental.js';
 import {isBaseNode, toContent} from './base_node.js';
 import {NodeContext} from './node_context.js';
-import {reconstructNodeStates} from './utils/rehydration_utils.js';
+import {
+  eventsForCurrentRun,
+  reconstructNodeStates,
+} from './utils/rehydration_utils.js';
 import {Workflow, WorkflowConfig} from './workflow.js';
 
 /** Options for a {@link WorkflowAgent}. */
@@ -153,7 +156,9 @@ function resumeInputsFromPlainText(
   const text = parts.map((p) => p.text).join('');
 
   const pending = new Set<string>();
-  for (const node of reconstructNodeStates(ic.session?.events ?? []).values()) {
+  for (const node of reconstructNodeStates(
+    eventsForCurrentRun(ic.session?.events ?? [], ic.invocationId),
+  ).values()) {
     for (const id of node.interruptIds) {
       if (!node.resolvedResponses.has(id)) {
         pending.add(id);

@ScottMansfield ScottMansfield left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above comment with patch

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Thanks for writing the patch out — it made this checkable. I applied it to main verbatim and measured it. It doesn't fix the bug, and the runner half changes invocation-id semantics for every agent, not just workflows. Details below, all reproducible.

It still replays, permanently, after a plain-text resume

Two-node HITL workflow, four turns, replies typed as plain text — what adk run and the dev UI do:

                    YOUR PATCH                       THIS PR
T2 outputs   ["got:yes",   "after:got:yes"]   ["got:yes",   "after:got:yes"]
T3 outputs   ["got:again", "after:got:yes"]   []            (pauses: new run)
T4 outputs   ["got:third", "after:got:yes"]   ["got:third", "after:got:third"]

after is fast-forwarded from turn 2's cache forever. A plain-text resume never writes a resolving functionResponse, so the interrupt reads as unresolved in the event log for the life of the session; getInvocationIdForRun therefore pins one id forever and every later turn is scoped into the finished run. Every event in that session ends up carrying a single invocation id.

That is the case the workflow_agent.ts call site in this PR exists for, and it's why the run is delimited by pausing rather than by unresolved interrupt: turn 2 raised no interrupt, so it closes the run. Detecting pending interrupts — which is what getInvocationIdForRun does — cannot distinguish "still waiting" from "answered without a functionResponse".

With a structured functionResponse resume it fails differently

Replay is fixed there, but turn 3's plain text is silently consumed as the answer to the already-answered interrupt, so the workflow never re-asks:

YOUR PATCH  T3 -> "after:got:again"    (new message eaten by the dead interrupt)
THIS PR     T3 -> pauses and re-asks   (correct: it is a new run)

createRequestInputEvent mints an event with no invocation id, and the verbatim filter keeps id-less events unconditionally. So the dead interrupt leaks into every future turn while its resolving response — which does carry an id — gets filtered out. That's the phantom-run hazard in the PR description; the id filter has no defence against it, which is what the carry-forward in eventsForCurrentRun is for.

"All tests pass" doesn't discriminate here

Confirmed: your patch is green on all 2788 existing unit:core tests. It fails this PR's end-to-end regression the moment that test exists. Nothing on main covered a turn after a completed run — that gap is the bug. The four tests my earlier port broke were real; these two are equally real in the other direction.

The runner change isn't workflow-scoped

getInvocationIdForRun runs for every agent, and reconstructNodeStates keys non-workflow events by author and counts any longRunningToolIds. A plain BaseAgent whose history has one unanswered long-running tool call, three separate turns:

TURN INVOCATION IDS: ["old-invocation","old-invocation","old-invocation"]

One id across three turns, inherited from an earlier one. What keys off per-turn uniqueness:

site effect
runner.ts:498 artifact_${invocationId}_${i} inline artifacts overwrite the previous turn's
code_executor_context.ts:143/159, via code_execution_request_processor.ts:206,330 the code-retry error budget carries across turns
skill_toolset.ts:118 contextKey = invocationId skill cache bleeds between turns
telemetry/tracing.ts:224,300 separate turns merge into one trace
llm_agent_wrapper.ts:259 event.invocationId !== ctx.invocationId a stale turn's event now matches

Not covered by the suite either.

On "a parallel system of pausing and resuming"

That objection is fair and I'd rather not own one. Two things though. The codebase already infers resumption from the event stream instead of from an id — determineAgentForResumption (runner.ts:587) resumes by finding the agent behind the last function response, gated on resumabilityConfig. And the reason python's filter can't be ported isn't incidental: there a resumed invocation keeps its id because the client hands it back, so the id is the run. Here there is no invocation-level resume, so it isn't.

A smaller shape, if you want one

I agree the scan is the least attractive part of this. Rather than infer the boundary we can mark it. WorkflowAgent already emits a final event only when the run completed (workflow_agent.ts:96, interruptIds.length === 0) — a paused run emits none. Make that emission unconditional, tag it, and the helper collapses to:

export function eventsForCurrentRun(events: Event[]): Event[] {
  for (let i = events.length - 1; i >= 0; i--) {
    if (events[i].runCompleted) return events.slice(i + 1);
  }
  return events;
}

No invocation ids, no interrupt predicate, no carry-forward, exact instead of inferred. I prototyped it: identical behaviour on every probe above, and unit:core green apart from this PR's unit tests of the old helper's semantics, which would be rewritten. Persistence is fine — the DB service stores the whole event as JSON through the camel/snake transform, and Vertex round-trips via rawEvent.

It costs a field on Event and an event-stream change (a completion event even when the output is undefined). Workflow is @experimental so that's affordable, but it's a wider blast radius than what's here.

My suggestion: land this one, which is contained in core/src/workflow and touches neither event contents nor invocation ids, and I'll follow up with the marker as a separate PR that deletes most of eventsForCurrentRun. Happy to do it the other way round if you'd rather review the marker version first — say which and I'll push it.

I've pushed Amaad's raisedInterrupt fix in the meantime (0515d1c): the long-running-tool clause is gone, replaced by requiresUserInput from #633. unit:core 204 files, 2804 tests.

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

CI is red here, and it isn't this PR: main is red. Seven workflow-sample integration tests are failing, the same seven, on main's own push run:

Bisected locally, same result: 1961d2c passes those tests, 09e2375 fails them. Nothing in this PR touches the code path.

agent_in_workflow  dynamic_nodes  loop  node_output
parallel_worker    request_input_advanced  route

Cause: the sample harness fingerprints a model call over contents + config (record_replay_model.ts:96), and config carries the system instruction. #616 changes the system instruction for any agent with transfer disabled — which is every one of these samples — so every recorded response misses its lookup, the agent yields nothing, and the consuming node dereferences undefined (Cannot read properties of undefined (reading 'category' | 'grade' | 'title' | 'days')).

Note this is prompt-change brittleness in the harness, not a one-off: any future edit to the system instruction breaks all seven again. Recording keyed on contents alone, or on a config with the system instruction normalized out, would make them survive it. I'll take that separately — it's my harness from #595, and it shouldn't ride on this PR.

Nothing to do here; unit:core is green (204 files, 2804 tests) and the failures are inherited. This PR unblocks when main does.

@AmaadMartin AmaadMartin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Both comments are addressed at head 0515d1c3, and I checked the one way the swap to requiresUserInput could have made things worse — it does not. Details inline.

CI red here is not yours. validation fails on main at 09e23757 with the same seven suites, by name: agent_in_workflow, dynamic_nodes, loop, node_output, parallel_worker, request_input_advanced, route. Same count, same set, and your merge commit pulled that main in. Nothing in this diff can produce Cannot read properties of undefined (reading 'grade') in a recorded-response sample.

Worth raising separately: main is red, and these are the workflow sample suites, so the whole workflow surface is currently unverified on every PR that branches from it. That is a bigger problem than any of the four workflow PRs in flight.

Comment on lines +117 to +124
/**
* Whether an event paused the run for a human: it carries one of the three
* `adk_request_*` calls (input, credential, or tool confirmation).
*
* Deliberately NOT `longRunningToolIds`, which marks any tool declared
* `isLongRunning` — a run that merely used one is not waiting on a person.
*/
function raisedInterrupt(event: Event): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified, including the narrowing risk this swap carries.

requiresUserInput only counts a part once it resolves an interrupt id (functionCall.id ?? args.interruptId ?? args.functionCallId), so a pause whose call carried no id would now go undetected — worse than the false positive it replaces. All three producers set one: createRequestInputEvent uses id: requestInput.interruptId, and both generateAuthEvent and the confirmation branch use generateClientFunctionCallId() (functions.ts:101, :155). So there is no under-detection.

drops a completed run that merely used a long-running tool is the right test — it pins the false positive directly rather than the symptom. And pauseEvent building an adk_request_input call whose id is also a long-running tool id fixes what was actually wrong with the old fixtures: they only ever exercised the catch-all.

@AmaadMartin

Copy link
Copy Markdown
Collaborator

Sharpening what I said about CI, because the cause changes what to do about it.

main's recent validation runs: cedd2a5f red, a98d132f red, 1961d2c3 green, 09e23757 red. So it is not uniformly broken, and there are two separate causes.

The first two are the flake family that has been costing every PR a re-run — unsafe_local_code_executor at cedd2a5f, an a2a integration suite at a98d132f.

The seven workflow suites are different, and they look deterministic. The only change between the green 1961d2c3 and the red 09e23757 is #616, "skip identity preamble when agent transfer is disabled", touching identity_llm_request_processor.ts. These suites replay recorded model responses, so changing the request preamble invalidates the recordings — which matches the symptoms exactly: no response comes back, a node produces no output, and the assertions fail on undefined.

If that is right, the fix is npm run record:samples on main, not anything in this PR. My approval stands either way; this PR cannot produce those failures.

@kalenkevich

Copy link
Copy Markdown
Collaborator Author

Fix for the inherited CI failure is up as #647 — all checks green there, including the seven samples that are red on main. This PR goes green once that lands (or on a re-run after it does).

Re-invoking a workflow that already completed did not run it. Every node was
fast-forwarded from the previous turn's cached output, so the workflow
re-emitted its previous answer and the new user message was ignored:

  turn 1 "first"  -> n1(first), n2 -> "n2:n1:first"
  turn 2 "second" -> (nothing ran) -> "n2:n1:first"

Rehydration exists to resume a run that paused, but `reconstructNodeStates`
was handed every event in the session, so a finished run looked exactly like
one waiting to be resumed.

`google/adk-python` scopes this by invocation id — there a resumed
invocation keeps its id. The TypeScript runner mints a fresh invocation id
for every turn, so a run that spans a pause covers several invocations and an
id filter would discard the outputs resume needs (it breaks the auth-gate,
HITL and dynamic resume paths). Runs are delimited by pausing instead: an
invocation that raised an interrupt ended paused and belongs to the run in
progress; one that emitted node events without raising an interrupt ran to
completion and becomes the boundary.

`eventsForCurrentRun` applies that at the three places that scan history:
the graph rehydration, the dynamic scheduler, and the plain-text resume
scan in WorkflowAgent — the last one mattered because a plain-text resume
never writes a resolving function response, so a finished run's interrupt
stayed "pending" forever and swallowed later messages.

Engine-minted events (a RequestInput interrupt) carry no invocation id, so
the scan carries the last id seen forward; otherwise such an event opens a
phantom run and the boundary lands mid-run.

Two fixtures were updated to match how real events look: a hand-built prior
event now carries the invocation of the run in progress, and a synthetic
pending interrupt now carries the node path the engine always stamps.
`raisedInterrupt` counted any event with `longRunningToolIds`, but that
marks every tool declared `isLongRunning`, not a pause for a human. A run
that called one and then finished was recorded as paused, so the next turn
kept it and fast-forwarded every node — the replay bug this change set
fixes, for exactly those workflows.

Test the three interrupt call names instead, via `requiresUserInput`
(#633), which also covers `adk_request_confirmation` — until now only
reachable through the `longRunningToolIds` clause, and untested.

The unit fixtures marked a pause with a bare `longRunningToolIds` and no
`adk_request_*` call, which no engine event looks like, so none of them
caught this. They now build the interrupt the engine actually emits.
Adds the two cases that were missing: a completed run that merely used a
long-running tool is dropped, and a run paused on a tool confirmation is
kept.

Also drops two guards that defend against nothing: `i` only grows, so
`Math.min` never changes `currentStart`, and `boundary` is never negative,
so the `slice` ternary only chose between the array and a copy of it.
@kalenkevich
kalenkevich force-pushed the fix/workflow-stale-rehydration branch from 0515d1c to 0d43835 Compare August 11, 2026 01:09
@kalenkevich
kalenkevich merged commit 3e4f770 into main Aug 11, 2026
12 checks passed
@kalenkevich
kalenkevich deleted the fix/workflow-stale-rehydration branch August 11, 2026 01:22
@kalenkevich kalenkevich mentioned this pull request Aug 11, 2026
kalenkevich added a commit that referenced this pull request Aug 12, 2026
Review finding: eslint, Prettier, check_license.sh and the new `tsc` step all
read `samples/`, so a syntax, style, license or type error in these 26 files
fails CI. Nothing ran them, which left the failure they are most exposed to
uncovered: a `WorkflowAgent` validates its graph in its constructor, so a
rename or a semantics change in the `@experimental` workflow API can turn a
sample into a load-time error that still type-checks -- and #635, #637 and
#647 all moved that API while this branch was open.

Every sample is now constructed, and the 18 that call no model are also run
end-to-end through a real `InMemoryRunner`. Reuses the existing sample harness
in `offline` mode, which installs the record/replay model over an empty
response set, so an "offline" sample that starts calling a model throws rather
than reaching the network. The 8 model-backed samples are constructed only:
driving them means a checked-in fixture each, and what they add over the
sibling `tests/integration/workflows/` set is prompt wording, not graph shape.

One table drives it, and a guard test asserts the table matches the
directories on disk -- otherwise a new sample silently gets no coverage, which
is the hole this closes.

Checked against all three failures it is meant to catch, rather than assuming
a passing suite means a working one: a duplicate node name (constructor
validation) fails the sample's case, an unregistered new directory fails the
guard, and an LlmAgent spliced into an offline graph fails on the missing
fixture.
kalenkevich added a commit that referenced this pull request Aug 12, 2026
Review finding: eslint, Prettier, check_license.sh and the new `tsc` step all
read `samples/`, so a syntax, style, license or type error in these 26 files
fails CI. Nothing ran them, which left the failure they are most exposed to
uncovered: a `WorkflowAgent` validates its graph in its constructor, so a
rename or a semantics change in the `@experimental` workflow API can turn a
sample into a load-time error that still type-checks -- and #635, #637 and
#647 all moved that API while this branch was open.

Every sample is now constructed, and the 18 that call no model are also run
end-to-end through a real `InMemoryRunner`. Reuses the existing sample harness
in `offline` mode, which installs the record/replay model over an empty
response set, so an "offline" sample that starts calling a model throws rather
than reaching the network. The 8 model-backed samples are constructed only:
driving them means a checked-in fixture each, and what they add over the
sibling `tests/integration/workflows/` set is prompt wording, not graph shape.

One table drives it, and a guard test asserts the table matches the
directories on disk -- otherwise a new sample silently gets no coverage, which
is the hole this closes.

Checked against all three failures it is meant to catch, rather than assuming
a passing suite means a working one: a duplicate node name (constructor
validation) fails the sample's case, an unregistered new directory fails the
guard, and an LlmAgent spliced into an offline graph fails on the missing
fixture.
ScottMansfield pushed a commit that referenced this pull request Aug 12, 2026
…oc snippets (#634)

* docs(workflow): add runnable ports of the graph-workflow doc snippets

The Python snippets on https://adk.dev/graphs/ have no TypeScript counterpart,
and they are fragments: they reference helpers they never define (`condition()`,
`task_A_node`, …), so they cannot be run as written even in Python. A TS reader
has nothing to copy from and no way to check that the concept behaves the way
the page claims.

Adds 26 runnable ports, one directory per snippet, grouped by the docs page it
comes from so a directory maps 1:1 to a section anchor on adk.dev:

  graphs/         get_started, process_pipeline
  routes/         sequence, branches, function_node, fan_out_join,
                  loop_escalation, nested_workflow
  data_handling/  node_output, routing_output, schemas, session_state,
                  structured_access, structured_output, user_message
  dynamic/        get_started, nodes, custom_run_ids, data_handling,
                  human_input, loop_route, parallel_route, sequence_route
  human_input/    get_started, initial_prompt, payload_and_schema

Each fills in the undefined helpers with the smallest plausible implementation
and says so in its header. Where TypeScript genuinely diverges from the Python
API the file comments say why, so a reader porting from the docs is not left
guessing — for example Python's `Event(message=...)` has no TS equivalent, and
a graph's validating schema belongs on the node wrapping an agent rather than
on the agent itself.

18 of the 26 run with no API key, which keeps the concepts (routing, loops,
fan-out/join, dynamic dispatch, human-in-the-loop) explorable offline.

* ci(samples): type-check samples/ in CI

samples/ is not an npm workspace, so "npm run build" never compiled it, and
the lint job uses tseslint's non-type-aware recommended config. That left the
sample sources backing the docs pages with nothing in CI that would catch a
renamed type or a removed export as the @experimental workflow API moves.

Add samples/tsconfig.json (the same extends-the-root pattern core, dev and
integrations use), a "ts:check:samples" script, and a validation.yaml step
that runs it after the build. Scoped to samples rather than the existing
repo-wide "ts:check", which currently reports 288 pre-existing errors across
44 test files.

* docs(workflow): correct two wrong claims in the sample comments

Both were review findings, and both were wrong about the framework rather
than about the samples.

The dynamic HITL sample said the `rerun_on_resume=False` handoff -- "do not
re-run on resume; complete with the human's reply as my output" -- was
implemented for static graph nodes only, so its leaf used a re-entry form
instead: a stable `interruptId` plus a `ctx.resumeInputs[id]` lookup that
returns the reply on the second pass. #635 added that handoff for dynamic
`ctx.runNode` children (`dynamic_node_scheduler.ts:134`, `resumeHandoff`), so
the claim went stale in the same branch that now carries the sample. The leaf
is the doc's `rerun_on_resume=False` one-liner again, which is both the
faithful port and four fewer concepts to explain.

The node_output sample cautioned that a node may emit only ONE event carrying
`output`. Nothing enforces that: `node_runner.ts:234` assigns
`child.output = event.output` for every event, so the last one silently wins
and the successor never sees the rest. That is worth stating precisely,
because the Python page gives two accounts and neither is what happens here --
each `yield` "adds to a list of data objects on the Event" under Node output,
and two yields carrying `Event.output` are "a runtime error" under the
structured-data caution. Recorded as a Python-to-TypeScript difference in the
README rather than only in the sample.

Verified both by running them, not by reading: a node yielding two `output`
events hands the successor the second and raises nothing, and the reworked
HITL leaf pauses on turn 1 and resolves "yes" to "Approved" on turn 2.

* docs(workflow): stop coercing inputs that are already typed as strings

Review finding: the samples were split on how they treat the workflow input.
Eleven files wrapped it in `String(...)`; eight called `.trim()` or
`.toUpperCase()` straight on a parameter already declared `string`.

`extractWorkflowInput` (`workflow_agent.ts:187`) returns the message text for a
text-only turn and the raw `Content` for anything else, so neither form is
sound for a non-text turn -- but they fail differently. `String()` turns a
`Content` into `"[object Object]"` and carries it happily through the graph;
the bare call throws where the mistake is. Keep the one that fails loudly, and
say so in the README so a reader copying a sample knows what it assumes.

Coercion stays where the value genuinely is untyped: `ctx.runNode(...).output`
and a `ctx.resumeInputs[id]` reply are both `unknown`, and the samples that
read them keep converting explicitly at the point of use.

* test(workflow): execute the docs samples instead of only compiling them

Review finding: eslint, Prettier, check_license.sh and the new `tsc` step all
read `samples/`, so a syntax, style, license or type error in these 26 files
fails CI. Nothing ran them, which left the failure they are most exposed to
uncovered: a `WorkflowAgent` validates its graph in its constructor, so a
rename or a semantics change in the `@experimental` workflow API can turn a
sample into a load-time error that still type-checks -- and #635, #637 and
#647 all moved that API while this branch was open.

Every sample is now constructed, and the 18 that call no model are also run
end-to-end through a real `InMemoryRunner`. Reuses the existing sample harness
in `offline` mode, which installs the record/replay model over an empty
response set, so an "offline" sample that starts calling a model throws rather
than reaching the network. The 8 model-backed samples are constructed only:
driving them means a checked-in fixture each, and what they add over the
sibling `tests/integration/workflows/` set is prompt wording, not graph shape.

One table drives it, and a guard test asserts the table matches the
directories on disk -- otherwise a new sample silently gets no coverage, which
is the hole this closes.

Checked against all three failures it is meant to catch, rather than assuming
a passing suite means a working one: a duplicate node name (constructor
validation) fails the sample's case, an unregistered new directory fails the
guard, and an LlmAgent spliced into an offline graph fails on the missing
fixture.

* docs(workflow): restore the state-based counter now that #636 fixed the read

The third stale claim of this review, and the same shape as the other two: a
sample working around a framework bug that has since been fixed on main.

The session_state port carried a "do not read-modify-write ONE key from several
nodes" gotcha, and routed `attempts` along the edges as node output to avoid
it. #636 landed that fix — node reads are now served from a per-invocation
write overlay — so the warning describes a bug that no longer exists and the
workaround is no longer buying anything.

`attempts` goes back to being a state key that one node initializes, another
increments and a third reads, which is what the Python snippet does and what
its inline comment claims it prints. Confirmed against both sides of the fix
rather than assuming: reverting #636's `node_context.ts` makes the third node
read 0, and with it in place the sample prints `attempts state: 1` — the
snippet's own documented output.

Drops the README gotcha section with it, and keeps the surviving half of the
advice — prefer an edge when only the next node needs the value — as guidance
in the sample rather than as a warning about a defect.

* ci(samples): keep samples resolving @google/adk through node_modules

The samples config inherits the root one, so once #648 adds the
`@google/adk` -> `core/src` aliases there, `npm run ts:check:samples`
would start checking the samples against the workspace sources instead of
the published types — the one thing a sample should not do, since a user's
project resolves the package through `node_modules`.

`"paths": {}` pins that, the same reset `core`, `dev` and `integrations`
already carry. No-op against the root config as it stands today: the check
resolves to `core/dist/types/index.d.ts` and passes either way.

* ci(samples): keep the repo-wide type check out of samples/

Fallout from rebasing onto #648, which landed the repo-wide `ts:check` while
this branch was open. The root config names no `include` and excludes only
`node_modules` and `**/dist`, so `tsc --noEmit` now picks up all 26 sample
files — and resolves their `@google/adk` imports through the root `paths`
aliases, against `core/src`.

That is the one resolution a sample must not use, which is the whole point of
the `"paths": {}` reset in `samples/tsconfig.json`: a sample is a consumer of
the published package, so it has to resolve the way a user's project does,
through `node_modules` and against the built types. With both checks running,
the scoped one did that and the repo-wide one quietly did the opposite over the
same files.

Excluding `samples` from the root config leaves one owner. Verified on both
sides: `tsc --noEmit --listFiles` now reports 0 files under `samples/` and
still passes, while `tsc -p samples --listFiles` reports all 26 and resolves
`@google/adk` to `core/dist/types/index.d.ts`.

The `validation.yaml` collision #648 was warned about resolved as both steps,
not one: `ts:check` for the repo, `ts:check:samples` for the samples. The
zizmor hardening on that file (`permissions`, `persist-credentials`, the three
SHA pins) came in with #648, so that commit dropped out of this branch as
already upstream.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
* fix(workflow): don't replay a finished run on the next turn

Re-invoking a workflow that already completed did not run it. Every node was
fast-forwarded from the previous turn's cached output, so the workflow
re-emitted its previous answer and the new user message was ignored:

  turn 1 "first"  -> n1(first), n2 -> "n2:n1:first"
  turn 2 "second" -> (nothing ran) -> "n2:n1:first"

Rehydration exists to resume a run that paused, but `reconstructNodeStates`
was handed every event in the session, so a finished run looked exactly like
one waiting to be resumed.

`google/adk-python` scopes this by invocation id — there a resumed
invocation keeps its id. The TypeScript runner mints a fresh invocation id
for every turn, so a run that spans a pause covers several invocations and an
id filter would discard the outputs resume needs (it breaks the auth-gate,
HITL and dynamic resume paths). Runs are delimited by pausing instead: an
invocation that raised an interrupt ended paused and belongs to the run in
progress; one that emitted node events without raising an interrupt ran to
completion and becomes the boundary.

`eventsForCurrentRun` applies that at the three places that scan history:
the graph rehydration, the dynamic scheduler, and the plain-text resume
scan in WorkflowAgent — the last one mattered because a plain-text resume
never writes a resolving function response, so a finished run's interrupt
stayed "pending" forever and swallowed later messages.

Engine-minted events (a RequestInput interrupt) carry no invocation id, so
the scan carries the last id seen forward; otherwise such an event opens a
phantom run and the boundary lands mid-run.

Two fixtures were updated to match how real events look: a hand-built prior
event now carries the invocation of the run in progress, and a synthetic
pending interrupt now carries the node path the engine always stamps.

* fix(workflow): only a real HITL pause keeps a run open

`raisedInterrupt` counted any event with `longRunningToolIds`, but that
marks every tool declared `isLongRunning`, not a pause for a human. A run
that called one and then finished was recorded as paused, so the next turn
kept it and fast-forwarded every node — the replay bug this change set
fixes, for exactly those workflows.

Test the three interrupt call names instead, via `requiresUserInput`
(google#633), which also covers `adk_request_confirmation` — until now only
reachable through the `longRunningToolIds` clause, and untested.

The unit fixtures marked a pause with a bare `longRunningToolIds` and no
`adk_request_*` call, which no engine event looks like, so none of them
caught this. They now build the interrupt the engine actually emits.
Adds the two cases that were missing: a completed run that merely used a
long-running tool is dropped, and a run paused on a tool confirmation is
kept.

Also drops two guards that defend against nothing: `i` only grows, so
`Math.min` never changes `currentStart`, and `boundary` is never negative,
so the `slice` ternary only chose between the array and a copy of it.
prasanna8585 pushed a commit to prasanna8585/adk-js that referenced this pull request Aug 21, 2026
…oc snippets (google#634)

* docs(workflow): add runnable ports of the graph-workflow doc snippets

The Python snippets on https://adk.dev/graphs/ have no TypeScript counterpart,
and they are fragments: they reference helpers they never define (`condition()`,
`task_A_node`, …), so they cannot be run as written even in Python. A TS reader
has nothing to copy from and no way to check that the concept behaves the way
the page claims.

Adds 26 runnable ports, one directory per snippet, grouped by the docs page it
comes from so a directory maps 1:1 to a section anchor on adk.dev:

  graphs/         get_started, process_pipeline
  routes/         sequence, branches, function_node, fan_out_join,
                  loop_escalation, nested_workflow
  data_handling/  node_output, routing_output, schemas, session_state,
                  structured_access, structured_output, user_message
  dynamic/        get_started, nodes, custom_run_ids, data_handling,
                  human_input, loop_route, parallel_route, sequence_route
  human_input/    get_started, initial_prompt, payload_and_schema

Each fills in the undefined helpers with the smallest plausible implementation
and says so in its header. Where TypeScript genuinely diverges from the Python
API the file comments say why, so a reader porting from the docs is not left
guessing — for example Python's `Event(message=...)` has no TS equivalent, and
a graph's validating schema belongs on the node wrapping an agent rather than
on the agent itself.

18 of the 26 run with no API key, which keeps the concepts (routing, loops,
fan-out/join, dynamic dispatch, human-in-the-loop) explorable offline.

* ci(samples): type-check samples/ in CI

samples/ is not an npm workspace, so "npm run build" never compiled it, and
the lint job uses tseslint's non-type-aware recommended config. That left the
sample sources backing the docs pages with nothing in CI that would catch a
renamed type or a removed export as the @experimental workflow API moves.

Add samples/tsconfig.json (the same extends-the-root pattern core, dev and
integrations use), a "ts:check:samples" script, and a validation.yaml step
that runs it after the build. Scoped to samples rather than the existing
repo-wide "ts:check", which currently reports 288 pre-existing errors across
44 test files.

* docs(workflow): correct two wrong claims in the sample comments

Both were review findings, and both were wrong about the framework rather
than about the samples.

The dynamic HITL sample said the `rerun_on_resume=False` handoff -- "do not
re-run on resume; complete with the human's reply as my output" -- was
implemented for static graph nodes only, so its leaf used a re-entry form
instead: a stable `interruptId` plus a `ctx.resumeInputs[id]` lookup that
returns the reply on the second pass. google#635 added that handoff for dynamic
`ctx.runNode` children (`dynamic_node_scheduler.ts:134`, `resumeHandoff`), so
the claim went stale in the same branch that now carries the sample. The leaf
is the doc's `rerun_on_resume=False` one-liner again, which is both the
faithful port and four fewer concepts to explain.

The node_output sample cautioned that a node may emit only ONE event carrying
`output`. Nothing enforces that: `node_runner.ts:234` assigns
`child.output = event.output` for every event, so the last one silently wins
and the successor never sees the rest. That is worth stating precisely,
because the Python page gives two accounts and neither is what happens here --
each `yield` "adds to a list of data objects on the Event" under Node output,
and two yields carrying `Event.output` are "a runtime error" under the
structured-data caution. Recorded as a Python-to-TypeScript difference in the
README rather than only in the sample.

Verified both by running them, not by reading: a node yielding two `output`
events hands the successor the second and raises nothing, and the reworked
HITL leaf pauses on turn 1 and resolves "yes" to "Approved" on turn 2.

* docs(workflow): stop coercing inputs that are already typed as strings

Review finding: the samples were split on how they treat the workflow input.
Eleven files wrapped it in `String(...)`; eight called `.trim()` or
`.toUpperCase()` straight on a parameter already declared `string`.

`extractWorkflowInput` (`workflow_agent.ts:187`) returns the message text for a
text-only turn and the raw `Content` for anything else, so neither form is
sound for a non-text turn -- but they fail differently. `String()` turns a
`Content` into `"[object Object]"` and carries it happily through the graph;
the bare call throws where the mistake is. Keep the one that fails loudly, and
say so in the README so a reader copying a sample knows what it assumes.

Coercion stays where the value genuinely is untyped: `ctx.runNode(...).output`
and a `ctx.resumeInputs[id]` reply are both `unknown`, and the samples that
read them keep converting explicitly at the point of use.

* test(workflow): execute the docs samples instead of only compiling them

Review finding: eslint, Prettier, check_license.sh and the new `tsc` step all
read `samples/`, so a syntax, style, license or type error in these 26 files
fails CI. Nothing ran them, which left the failure they are most exposed to
uncovered: a `WorkflowAgent` validates its graph in its constructor, so a
rename or a semantics change in the `@experimental` workflow API can turn a
sample into a load-time error that still type-checks -- and google#635, google#637 and
google#647 all moved that API while this branch was open.

Every sample is now constructed, and the 18 that call no model are also run
end-to-end through a real `InMemoryRunner`. Reuses the existing sample harness
in `offline` mode, which installs the record/replay model over an empty
response set, so an "offline" sample that starts calling a model throws rather
than reaching the network. The 8 model-backed samples are constructed only:
driving them means a checked-in fixture each, and what they add over the
sibling `tests/integration/workflows/` set is prompt wording, not graph shape.

One table drives it, and a guard test asserts the table matches the
directories on disk -- otherwise a new sample silently gets no coverage, which
is the hole this closes.

Checked against all three failures it is meant to catch, rather than assuming
a passing suite means a working one: a duplicate node name (constructor
validation) fails the sample's case, an unregistered new directory fails the
guard, and an LlmAgent spliced into an offline graph fails on the missing
fixture.

* docs(workflow): restore the state-based counter now that google#636 fixed the read

The third stale claim of this review, and the same shape as the other two: a
sample working around a framework bug that has since been fixed on main.

The session_state port carried a "do not read-modify-write ONE key from several
nodes" gotcha, and routed `attempts` along the edges as node output to avoid
it. google#636 landed that fix — node reads are now served from a per-invocation
write overlay — so the warning describes a bug that no longer exists and the
workaround is no longer buying anything.

`attempts` goes back to being a state key that one node initializes, another
increments and a third reads, which is what the Python snippet does and what
its inline comment claims it prints. Confirmed against both sides of the fix
rather than assuming: reverting google#636's `node_context.ts` makes the third node
read 0, and with it in place the sample prints `attempts state: 1` — the
snippet's own documented output.

Drops the README gotcha section with it, and keeps the surviving half of the
advice — prefer an edge when only the next node needs the value — as guidance
in the sample rather than as a warning about a defect.

* ci(samples): keep samples resolving @google/adk through node_modules

The samples config inherits the root one, so once google#648 adds the
`@google/adk` -> `core/src` aliases there, `npm run ts:check:samples`
would start checking the samples against the workspace sources instead of
the published types — the one thing a sample should not do, since a user's
project resolves the package through `node_modules`.

`"paths": {}` pins that, the same reset `core`, `dev` and `integrations`
already carry. No-op against the root config as it stands today: the check
resolves to `core/dist/types/index.d.ts` and passes either way.

* ci(samples): keep the repo-wide type check out of samples/

Fallout from rebasing onto google#648, which landed the repo-wide `ts:check` while
this branch was open. The root config names no `include` and excludes only
`node_modules` and `**/dist`, so `tsc --noEmit` now picks up all 26 sample
files — and resolves their `@google/adk` imports through the root `paths`
aliases, against `core/src`.

That is the one resolution a sample must not use, which is the whole point of
the `"paths": {}` reset in `samples/tsconfig.json`: a sample is a consumer of
the published package, so it has to resolve the way a user's project does,
through `node_modules` and against the built types. With both checks running,
the scoped one did that and the repo-wide one quietly did the opposite over the
same files.

Excluding `samples` from the root config leaves one owner. Verified on both
sides: `tsc --noEmit --listFiles` now reports 0 files under `samples/` and
still passes, while `tsc -p samples --listFiles` reports all 26 and resolves
`@google/adk` to `core/dist/types/index.d.ts`.

The `validation.yaml` collision google#648 was warned about resolved as both steps,
not one: `ts:check` for the repo, `ts:check:samples` for the samples. The
zizmor hardening on that file (`permissions`, `persist-credentials`, the three
SHA pins) came in with google#648, so that commit dropped out of this branch as
already upstream.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants