fix: stale PostToolUse notifications and background task output leak#353
fix: stale PostToolUse notifications and background task output leak#353timvisher-dd wants to merge 30 commits intoagentclientprotocol:mainfrom
Conversation
|
@timvisher-dd how did you testing go/ |
Still working through it! Do you mind if I leave this open in Draft? |
|
All good, thanks for testing! |
8068985 to
07a7bf1
Compare
|
@benbrandt I'd like to do a bit of manual testing tomorrow with this change but I'm confident that it's directionally correct. Does that seem right to you? |
07a7bf1 to
5ed2d93
Compare
a9aa618 to
fa6761d
Compare
|
@benbrandt This is feeling pretty good to me. Definitely give it a critical eye because it's a whack-a-mole style problem but I've been running it all day in this state and haven't seen a spurious Notice or odd shell behavior from agent-shell. |
|
Actually I guess I take that back again. Even on my dev integration branches I still see problems with this every now and then. I'm clearly not understanding the full scope of the interactions. I'll drop this back to draft because it's helpful to remind me to keep pressing on it it but if y'all'd rather it be closed for now I'd be fine with that, too. Sorry for the noise here. (-‸ლ) |
LOL globdammit actually I think the issue was that my local integration branch wasn't including this fix this morning. (ノಥ益ಥ)ノ ┻━┻ |
fa6761d to
c1746d8
Compare
17ec70d to
04c4e76
Compare
|
@benbrandt The latest code here is behaving promisingly. I think I may finally have cracked something. xD |
…g prompt When a background Bash task (run_in_background) completes, the SDK emits a task_notification followed by an internal model turn after the initial result message. prompt() was returning at the first result, leaving the internal turn in the iterator buffer. The next prompt() call would then process stale background task output instead of the user's actual input. Track pending background tasks via task_started messages and peek at the SDK's internal queue after receiving a result. If a task_notification is queued, defer returning and continue consuming the internal turn. This is a pragmatic workaround for the SDK not deferring local_bash background tasks the way it defers local_agent tasks (upstream issue to be filed). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…loop yield Two issues found during E2E harness testing: 1. Only track local_bash tasks in pendingTaskIds — the SDK already defers results for local_agent tasks, so Agent subagent task_started events were inflating the count and the notification never arrives via task_notification (agents complete through the tool flow). 2. Yield to the event loop (setTimeout(0)) before peeking the SDK queue. The task_notification is enqueued asynchronously after the result message; peeking synchronously missed it every time. The yield gives the SDK a tick to process the background task completion. E2E harness: 3/3 iterations now consume the internal turn during Turn A (verified by bg task completion text appearing in Turn A's response and Turn B correctly acting on "yes" by creating summary files). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Two gaps found during E2E harness testing: 1. local_agent task_started must not trigger internal turn detection — the SDK already handles agent task deferral. Without this test, agent task IDs inflated pendingTaskIds and were never resolved. 2. task_notification arriving during the setTimeout(0) yield must still be consumed. The mock simulates the real-world race by pushing internal turn messages into the queue asynchronously. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Codex review flagged that the comment said "microtask yield" but setTimeout(0) is a macrotask. Updated to accurately describe the timing semantics and document the best-effort nature. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Two unit test gaps identified from harness/codex analysis: 1. Warning path: when task_notification never arrives within the setTimeout(0) yield, prompt() should return (not hang) and log a warning with the unresolved task IDs. 2. Failed task_notification: status "failed" should clear pendingTaskIds just like "completed", preventing false-positive internal turn detection on subsequent results. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…ack-to-back internal turns Three remaining gaps from codex review: 1. stopped task_notification status clears pendingTaskIds (same as failed/completed — verifies all terminal statuses work) 2. error_during_execution result with pending bg tasks does NOT drain internal turns (documents known limitation — drain only runs for result/success) 3. Multiple back-to-back background task completions producing two consecutive internal turns are both consumed in a single prompt() call Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…shing Real setTimeout delays (5-50ms) in tests are flaky under CI load and add unnecessary wall-clock time. Replace with: - tools.test.ts: flushMicrotasks() helper that chains Promise.resolve() calls to flush the .then() microtask queue deterministically - bg-task-leak.test.ts: vi.useFakeTimers() + vi.advanceTimersByTimeAsync(0) for the async task_notification test that needs both the production setTimeout(0) yield and the test's deferred push to fire Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…d document depth Two improvements from codex review of the timer fix: 1. Add Promise.resolve() flush before vi.advanceTimersByTimeAsync(0) to ensure the production setTimeout(0) is registered before advancing 2. Document the flushMicrotasks depth (5 iterations) with rationale Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- tools.test.ts: replace unused vi import with afterEach import - bg-task-leak.test.ts: remove unused updates destructuring - bin/test: new script that runs the same checks as CI (format, lint, build, test) against git-tracked files only Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The test helpers (makeResultMessage, makeNormalTurnMessages, makeBgTaskInternalTurnMessages) return untyped SDK message shapes that get spliced together with task_started/task_notification objects. TypeScript inferred strict element types from the literal returns, causing tsc errors when splice inserted objects with different shapes. Explicitly typing returns as any/any[] matches how the mock Query already uses any[] and avoids 11 spurious tsc errors. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Uses yq to extract run steps from .github/workflows/ci.yml so the script stays in sync automatically if CI changes. Skips npm ci (deps already installed locally) and overrides format:check to use git-tracked files only (matching CI's clean checkout behavior). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Three categories of build errors fixed: 1. @anthropic-ai/claude-code/sdk-tools.js: module moved to @anthropic-ai/claude-agent-sdk/sdk-tools.js — update import path 2. ClientCapabilities.auth: ACP extension property not yet in the type definition — cast to any for the two access sites 3. @anthropic-ai/claude-agent-sdk/embed: module only exists in single-file bun builds — add src/embed.d.ts type declaration shim Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
iP() → likely hasRunningDeferrableTasks q4 → likely InputStreamQueue or MessageBuffer Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The SDK ships sdk-tools.d.ts but doesn't export ./sdk-tools in its package.json exports map. With a stale local node_modules (0.2.68), tsc resolved the .js import via the loose .d.ts file. After npm ci installs 0.2.71 (matching package.json), tsc can't resolve the path. Add a declare module shim with a triple-slash reference to the SDK's sdk-tools.d.ts so tsc can resolve the import regardless of the SDK's exports configuration. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
… npm ci The previous version skipped npm ci entirely, assuming local deps were correct. This let a stale SDK version (0.2.68 vs 0.2.71) mask a build error that CI caught. Now bin/test checks if node_modules matches package-lock.json (via npm ls) and runs npm ci if out of sync. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The bg-task-leak tests hung because the mock query never produced a user replay message. In production, session.input.push() causes the SDK to replay the user message through the query iterator, setting promptReplayed = true before the result arrives. Without this, every result was consumed as a "background task result" and the loop blocked forever on the next query.next(). Patch createAgentWithSession so input.push() splices a replay message into the mock queue right before the first result, matching SDK behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…ection promptReplayed depended on the SDK replaying the user message through the query iterator before the result arrived. When the SDK omits or delays the replay, promptReplayed stays false and every result is consumed as a "background task result" — causing the real response to be discarded. Replace with backgroundInitPending: set true on system/init, cleared by any real activity (stream_events, assistant messages, compact_boundary, local_command_output, user replay). If still true when result arrives, it's a background task (init→result with no intervening activity). This matches the approach from timvisher/fix-prompt-replay-hang (d21169d) and doesn't depend on user message replay timing. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The previous approach yielded once with setTimeout(0) and peeked at
queue[0] — a race where task_notification hadn't landed yet caused the
model to respond to stale background task output on the next prompt.
Replace with a poll loop (1s interval, 30s inactivity timeout) that:
- Scans the full SDK queue for task_notification (not just [0])
- Monitors output file growth via fsp.stat as a heartbeat
- Checks session.cancelled each iteration for prompt responsiveness
- Caches output paths from terminal_output even if they arrive before
task_started (earlyOutputPaths Map)
Changes pendingTaskIds from Set<string> to Map<string, {outputPath}>
to carry the output file path per task.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Update delayed-notification test for 1s poll interval (was setTimeout(0))
- Update never-arrives test to use fake timers past 30s timeout
- Add test: task_notification arriving mid-poll (5s delay)
- Add test: cancellation during poll loop returns immediately
- Add test: queue scan detects task_notification behind other messages
- Add test: poll loop with fs.stat mock (timeout path)
- Add vi.mock("node:fs/promises") for controllable stat behavior
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Extend pendingTaskIds entry with taskType, toolUseId, firstSeenAt, and lastActivityAt to carry richer context for diagnostics. Add three log points (stderr only, no ACP sessionUpdate): - [bg-task-poll] entering: fires immediately on poll loop entry - [bg-task-poll] waiting: fires every 30s with inactiveFor/remaining - [bg-task-poll] output activity: fires on each file size change - [bg-task-timeout]: now per-task with full structured fields All logs are throttled to avoid spam. Cancellation still short-circuits before any logging. No behavior changes. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The 30s inactivity timeout was a compromise that could still leak
internal turns into the next prompt if it fired before task_notification
arrived. Remove it entirely — the poll loop now runs indefinitely
(`while (0 < pendingTaskIds.size)`) until all background tasks resolve
or the user cancels.
Logging:
- Single aggregated line on entry and every 30s with all pending tasks
- Each task shows task_id, task_type, tool_use_id, outputPath, inactiveFor
- Line warns: "cancellation risks later prompt contamination"
- Per-task output activity log retained
Tests:
- Remove timeout test (would hang without timeout exit)
- Remove fs.stat mock test (relied on timeout exit)
- Remove unused vi.mock("node:fs/promises") infrastructure
- Add test verifying aggregated log fires immediately + at 30s,
using cancellation as escape hatch
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When a cancel arrived while the SDK was emitting background task init → result sequences, backgroundInitPending consumed the result and looped back to await session.query.next() — but the SDK had already been cancelled and would never produce "our" result. The prompt hung forever, leaving the shell unrecoverable. Move session.cancelled check before backgroundInitPending so cancellation is detected immediately on any result message. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Document secondary benefit: keeping the turn open prevents the idle-then-flood rendering behavior in agent-shell - Check off E2E poll harness test plan item with results Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Covers the scenario where cancellation arrives while the prompt loop is consuming background task init → result cycles. Before the fix (0516bd8), session.cancelled was checked after backgroundInitPending, so the loop re-entered await session.query.next() on a cancelled SDK and hung forever. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
pr.md is a local draft file used by the timvisher_gh workflow and should not be checked in. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
fead2be to
bbc0f2c
Compare
Problem
Two independent bugs caused stale or garbled output in ACP clients (agent-shell):
PostToolUse hook race: The SDK fires
PostToolUsehooks ~42ms before the streaming handler registers callbacks, causing"No onPostToolUseHook found"errors and lostsession/updatenotifications. For subagent child tool uses, the gap is 10-60+ seconds.Background task output leak: The SDK handles background task completion differently depending on task type. For
local_agenttasks (subagents), the SDK internally defers theresultmessage via itsiP()function (minified; likelyhasRunningDeferrableTasks) — the turn stays open until the agent task finishes. But forlocal_bashtasks (run_in_backgroundBash commands), the SDK does not defer — it emitsresultimmediately, then yieldstask_notificationfollowed by an internal model turn after the result.prompt()returned at the firstresult, leaving the internal turn in the iterator buffer. The nextprompt()call processed stale background task output instead of the user's actual input — the model responded to its own background task completion rather than "yes". This asymmetry is why the fix only trackslocal_bashtasks:local_agentis already handled by the SDK. It also points at the ideal upstream fix: extending the SDK'siP()/hasRunningDeferrableTasksto includelocal_bashtasks would eliminate the leak at the source, making the adapter's poll loop, queue peeking, and SDK internal API access unnecessary. The entire Fix 2 is a workaround for this gap.Three contamination layers
The background task leak manifests through three independent mechanisms. All three must be understood to evaluate why the indefinite poll (Fix 2) is the only viable solution:
Layer 1 — Iterator-level internal turns: The SDK yields
task_notification → assistant → result(sometimes withinit/stream_eventinterspersed) through the query iterator after the user turn'sresult. Ifprompt()returns at the firstresult, these leak into the nextprompt()call's iterator buffer. This is what Fix 2 directly addresses.Layer 2 — SDK context injection: The SDK injects
<task-notification>and<system-reminder>content directly into the LLM's conversation history as user-role messages, before the adapter's prompt loop runs. This is a separate mechanism from the iterator — these messages never appear on the ACP wire (confirmed: zerotask_notification,task_started, orlocal_command_outputintraffic.elddebug captures). The model sees system-dominated content, concludes "the user hasn't said anything," responds to the notifications, andend_turns without addressing the real question. Keeping the turn open also fixes this: if the turn never ends while tasks are pending, there is no "next turn" for the SDK to inject stale notifications into.Layer 3 —
local_command_outputforwarding: The adapter'slocal_command_outputhandler (system message case) forwards linter/hook output asagent_message_chunkto ACP clients. This is visible agent text that the user didn't request. Not addressed by this PR — it's a separate contamination vector unrelated to background tasks.Why the user's prompt gets "swallowed"
The user-visible symptom is that they must send the same message twice. Below is a complete walkthrough from the debug logs (
x.acp-debug-20260315-000100/) showing the exact sequence.Step 1: Background task launched during previous turn
The agent launched a codex review as a background bash task. ACP wire traffic shows the task ID and output path in
tool_call_updatenotifications:The turn ends normally (
end_turn). The background task is still running. Note:task_notification,task_started, andlocal_command_outputnever appear intraffic.eld— they are purely SDK-internal.Step 2: User sends their next prompt
Step 3: Adapter consumes stale internal turn (Layer 1 — working)
The
backgroundInitPendingmechanism detects the staleinit → resultcycle and consumes it. Layer 1 is working.Step 4: Model responds — but to the wrong content (Layer 2 — broken)
Despite Layer 1 consuming the iterator-level leak, the SDK has already injected
<task-notification>and system content into the LLM's conversation history as user-role messages. The model's thinking stream reveals it:The model explicitly says "the user hasn't said anything" and "there's no user text" — even though the user's question is present in the
session/promptrequest. The SDK-injected content dominates the context.Step 5: Visible response addresses linter output, not the user's question
The model responds to the linter/system content and
end_turns. The user's actual question about shared code between plan and init is never addressed.What the user sees in agent-shell
The user must send the identical message twice. The first attempt is consumed by the model responding to SDK-injected system content. Only the second attempt gets a real answer.
Observed in this investigation session
This exact pattern reproduced live during the investigation. Three
<task-notification>messages from subagentfindcommands leaked into the conversation. The[bg-task-leak]warning fired:The user's question about the
[bg-task-leak]warning was swallowed — the model responded to the stale<task-notification>instead. The user had to resend.Root Cause Analysis
Bug 1: PostToolUse hook timing
The SDK fires hooks synchronously from its tool execution path, but streaming events that trigger
registerHookCallback()are consumed asynchronously from the query iterator. For fast tools, the hook fires before thecontent_block_startevent is processed (~42ms gap). For subagent child tools, the gap is 10-60+ seconds because messages are relayed only when the subagent finishes.Bug 2: SDK internal turns from background tasks
Reading the minified SDK source revealed the root cause: the SDK's
iP()function (minified; likelyhasRunningDeferrableTasks()orshouldDeferResult()based on usage) checks whether to defer theresultmessage for running background tasks, but only includeslocal_agenttasks — NOTlocal_bashtasks. Similarly, the internal queue classq4(likelyInputStreamQueueorMessageBuffer) is where we peek for bufferedtask_notificationmessages. When a background Bash task completes after the turn'sresult, the SDK emits:Verified: the Claude TUI hides this by rendering internal turns inline, but ACP clients can't — they see the stale output on the next prompt.
Wire-level evidence:
task_notification,task_started, andlocal_command_outputnever appear in ACP wire traffic (traffic.eldcaptures from agent-shell debug sessions). These are purely SDK-internal messages that the adapter sees through the query iterator but that never cross the ACP protocol boundary. This distinction matters for debugging: ACP clients cannot observe or intercept these messages — only the adapter can.Independence Proof
Empirically verified both fixes are independently necessary:
Fix 1: Non-blocking fire-and-stash (tools.ts)
When the hook fires before registration:
{ toolInput, toolResponse }and return{ continue: true }immediatelyregisterHookCallback()runs later, find stash, execute callback, clean upunref()) cleans orphaned entriesHandles both the 42ms race and 10-60s subagent delay with zero blocking.
Fix 2: Indefinite poll loop for internal turn consumption (acp-agent.ts)
After receiving a
result/success, the adapter keeps the turn open indefinitely until all background tasks resolve or the user cancels. This fixes both Layer 1 (iterator-level internal turns are consumed beforeprompt()returns) and Layer 2 (no stale notifications exist to inject into the next turn's context). It also aligns with the turn-based nature of ACP — the agent shouldn't appear idle while background work is pending. The competing Agent Communication Protocol (agentcommunicationprotocol.dev) validates this design: runs stayin-progressuntil terminal state, with no concept of "done but also not done."Secondary benefit: agent-shell rendering continuity
Keeping the turn open also fixes the "idle then flood" rendering behavior observed in agent-shell. Without this fix, the old sequence was: (1) agent finishes main work,
prompt()returnsend_turn, (2) background task continues running, (3) agent-shell seesend_turnand stops rendering agent output, (4) background task output arrives but agent-shell doesn't render it (UI appears idle), (5) user sends next message, (6) agent-shell starts rendering again and floods all buffered content at once. With the indefinite poll loop, the turn stays open while background tasks run, so agent-shell continues to rendersessionUpdatenotifications (tool_call_update, agent_message_chunk) live. This was confirmed by Codex analysis and by the E2E poll harness — Turn A held open ~43s while the background task ran, with output activity logged throughout. The same edge-case gaps apply: error/cancellation paths can still cause prematureend_turn(see Known limitations).Design evolution
The fix went through several iterations based on review feedback:
queue[0]. Race condition:task_notificationcould arrive later.task_notificationarrives or the user cancels. This is the only design that fully prevents contamination.How it works
local_bashbackground tasks viatask_startedmessages (Map with outputPath, taskType, toolUseId, firstSeenAt, lastActivityAt). Ignorelocal_agent— SDK handles those via its internaliP()check.terminal_outputeven beforetask_startedarrives (earlyOutputPaths Map), to handle SDK message ordering variations.result/success, ifpendingTaskIdsis non-empty, enter the poll loop:{ stopReason: "cancelled" })inputStream.queue) for anytask_notification— not justqueue[0], to handle other system messages queued before itfsp.stat()as a per-task heartbeatinactiveForand a warning: "cancellation risks later prompt contamination"task_notificationis found in queue, save the prompt response and continue the outer message loop to consume the internal turn.Key design decisions
task_notificationarrives reintroduces the exact contamination bug we're fixing — and there is no recovery path. Once the turn ends with tasks pending, the SDK will inject<task-notification>into the next turn's conversation context as user-role messages (Layer 2). The adapter cannot intercept or filter SDK-level context injection — it only controls what it pushes viasession.input.push(), not what the SDK adds alongside it. A "pump prompt" strategy (sending a synthetic prompt to consume the notification) fails for the same reason: the pump prompt text faces the same contamination as a real user prompt. The only clean options are: wait for the task (current design), or kill the task on cancel. The user can always cancel.queueArr.some(...)instead of justqueue[0]because the SDK may queue other system messages (e.g.,init) before thetask_notification.earlyOutputPathsMap handles the case whereterminal_output(with the background task ID and output file path) arrives beforetask_started. Whentask_startedfires, it merges the cached path.result/successpath: The poll loop only runs for successful results. Error paths (max_tokens,error_during_execution, etc.) return/throw immediately — this is a documented known limitation. The internal turn still leaks for error results, but this is rare and less impactful.Known limitations
max_tokensor anyerror_*variant, the function returns/throws immediately without waiting for background tasks. Internal turns can still leak in these cases. Documented in theerror_during_executiontest.inputStream.queuewhich is not a public API. Thetask_type === "local_bash"filter relies on an untyped SDK field verified by reading minified source. Both are documented as workarounds pending upstream SDK changes.task_notificationnever arrives (hung task, SDK bug),prompt()blocks forever. The 30s progress logs make this visible, and cancellation is the escape hatch.local_command_outputforwarding (Layer 3): The adapter forwardslocal_command_outputsystem messages asagent_message_chunkto ACP clients. This causes linter/hook output to appear as visible agent text unrelated to the user's question. This is a separate contamination vector not addressed by this PR — it's independent of background tasks and would need its own fix (gating or reclassifying the output).Changes
Source
src/tools.ts: Fire-and-stash mechanism replacing blocking Promise.racesrc/acp-agent.ts: Indefinite poll loop for bg task internal turn consumption, structured logging, earlyOutputPaths ordering fix, pendingTaskIds Map with rich metadata (taskType, toolUseId, firstSeenAt, lastActivityAt)src/embed.d.ts: Type declaration shim for single-file bun build moduleTests (159 pass, 6 skipped, 0 flaky)
src/tests/bg-task-leak.test.ts(16 tests): Full SDK message sequence from real trace datasrc/tests/tools.test.ts(10 new tests): Fire-and-stash contractInfrastructure
bin/test: Local CI mirror — parses.github/workflows/ci.ymlwithyqsrc/tests/authorization.test.ts: Type fix forauthextension propertyE2E Verification
Standalone harness (
x.bg-task-leak-harness.mjs) spawns real ACP agent, launches background Bash tasks via subagents, and validates Turn B responds to "yes" (not stale background output):Codex Review Summary
5 rounds of automated review (Codex CLI
--sandbox read-only). Final findings:toolUseCallbackssweep comment (tools.ts)Risk
Low for fire-and-stash: Happy path unchanged. Stash path replaces 5s block with immediate return.
Medium for internal turn fix: Accesses SDK internals (
inputStream.queue) not in public API. Thetask_type === "local_bash"filter relies on an untyped SDK field verified by reading the minified SDK source. Both are documented as workarounds pending upstream SDK changes. The indefinite poll loop is a deliberate tradeoff: it prevents prompt contamination at the cost of requiring cancellation if a task never resolves. This tradeoff is forced — no timeout-based alternative exists that doesn't reintroduce contamination, because there is no recovery mechanism once the turn ends with pending tasks (the SDK's context injection cannot be intercepted by the adapter).Test plan
npm run lint+npm run test:run— 159 tests pass, 0 lint errors[hook-trace]errors in agent-shell during subagent workloads[bg-task-poll]logs appear in stderr during background task waitsclaude-code-acp-uzw) — verified ~35s background task triggers poll loop, logs output file activity and aggregated summaries to STDERR, consumestask_notificationcleanly, Turn B responds to "yes" without contamination (results inx.poll-harness-results.txt)local_bashresult deferral