Skip to content

refactor(question): collapse Question.ask state machine into tool-call lifecycle - #772

Merged
Astro-Han merged 18 commits into
devfrom
claude/question-tool-pr-b
May 20, 2026
Merged

refactor(question): collapse Question.ask state machine into tool-call lifecycle#772
Astro-Han merged 18 commits into
devfrom
claude/question-tool-pr-b

Conversation

@Astro-Han

@Astro-Han Astro-Han commented May 19, 2026

Copy link
Copy Markdown
Owner

Summary

First-principles refactor of the question tool that collapses the parallel Question.ask/Question.recover state machine into a single tool-call lifecycle driven by ctx.externalResult (the primitive shipped in #764). The question tool now suspends on a Deferred, the dock submits via POST /session/:sessionID/tool/respond, and every legacy concept that propped up the old design — bridge events, recovery clock, fallback refetch, blocker namespace, soft cancel, and the gating flag — is removed.

Why

PR A introduced ctx.externalResult behind PAWWORK_QUESTION_TOOL_EXTERNAL_RESULT to validate the new primitive without disturbing the legacy path. PR B closes the loop: switch the dock to the new selector, gate-flip ON, delete the legacy path. Three originally-planned PRs (route switch, legacy delete, follow-on cleanup) are merged into one so the dev branch never carries both implementations simultaneously.

Related Issue

No issue — direct follow-on to #764 (PR A).

Human Review Status

Pending

Review Focus

  • packages/opencode/src/tool/question.ts — the inline decoder and the snapshot-level duplicate-label guard
  • packages/app/src/pages/session/blockers/running-external-result-question.ts — message-stream selector and the dock/sidebar tree-walk (parent session page surfaces a child agent question; matches sessionPermissionRequest semantics)
  • packages/app/src/pages/layout.tsx — background question OS notification reattached to message.part.updated; dedup pruned on transition out of running; suppression walks ancestors (matches the dock)
  • packages/opencode/src/server/instance/external-result.ts — new GET /external-result route that joins each pending ctx.externalResult Deferred with its session and message+part snapshot. Brief retry covers the register / processor.updateToolCall race window.
  • packages/app/src/context/global-sync/bootstrap.tshydratePendingExternalResults writes the trio into session/message/part stores during the slow bootstrap phase so parent-page reload / cold-open still surfaces a child agent's pending question. Fetch failures swallowed to avoid the project-level reloadFailed toast.
  • packages/app/src/pages/session/composer/session-question-dock.tsx — 404/409/422 toast routing and the void-promise rejection swallow
  • packages/opencode/src/session/prompt.tscancel() signature collapse (no more mode: "soft" | "hard"), and the externalResult abort handler now uses ExternalResult.abortPendingSync so the registry tombstone lands synchronously when the abort signal fires
  • packages/opencode/src/tool/external-result.ts — new abortPendingSync helper plus reordered tombstone-before-yield in resolveIfPending / failIfPending so the pending → resolved transition wins races against any concurrently scheduled Effect
  • packages/app/src/pages/session.tsx, submit.ts, use-session-commands.tsx — every abort call now passes only {sessionID, source}
  • packages/core/src/flag/flag.tsPAWWORK_QUESTION_TOOL_EXTERNAL_RESULT is gone entirely
  • E2E specs in packages/app/e2e/session/session-composer-dock.spec.ts — five legacy-recovery tests deleted; surviving question tests drive the dock through the real tool runner via seedSessionQuestion

Risk Notes

  • The /question, /blocker, /session/:id/question, /__e2e/ask, and /__e2e/publish-asked routes are deleted. Any external client still calling them will 404. There is no known consumer outside this repo.
  • session.abort no longer accepts mode=soft. Callers that passed mode=soft will get a 400 from the query validator. Internal callers (and the SDK) are updated; the abort renderer diagnostic no longer carries mode.
  • Stage 9 (capability split + lint enforcement + 4 isolation fixtures + CI job) from the original 11-stage plan was dropped after discussion: the only background timers that needed gating were deleted in Stage 6, so the lint rule would guard non-existent code. Will land separately when real background code returns.

How To Verify

bun --cwd packages/sdk/js   run typecheck   ok
bun --cwd packages/core     run typecheck   ok
bun --cwd packages/opencode run typecheck   ok
bun --cwd packages/app      run typecheck   ok
bun --cwd packages/ui       run typecheck   ok
cd packages/opencode && bun test            2786 pass / 0 fail
cd packages/app      && bun test            1116 pass / 0 fail
cd packages/core     && bun test            55 pass / 1 unrelated fail (cross-spawn cwd, pre-existing)
cd packages/ui       && bun test            552 pass / 4 unrelated fail (icon-button size, pre-existing)
Fresh-eyes crosscheck round 2: Codex 0 findings; Claude 0 confirmed P0/P1 (both flagged P1s were self-marked as non-issues by the reviewer)
External GPT review surfaced 2 P1s in dock/notification glue (parent page missing child agent question; background question OS notification dropped). Fixed in 0a90193b / 6ed20d87 / 4b439d69 / 06e09a90; round-2 crosscheck on the fixes returned 0 P0/P1 from both reviewers.
Second external GPT review surfaced 1 P1: pending child-agent questions stayed invisible across parent-page reload / cold-open because Stage 6 dropped question.asked from the SSE replay buffer and the new message.part.updated path is not replayable. Fixed in 974241bd by adding GET /external-result + a bootstrap hydrate phase that rebuilds the (session, message, part) trio. Round-2 crosscheck on the fix: Codex 0 findings; Claude 0 P0/P1 (remaining P2/P3 were nit-level or PR-scope-external).
Third external GPT review surfaced 1 P2 (race between abort signal and /tool/respond) and 1 P3 (silent skip when part-flush exceeds the 150ms retry window); review verdict was mergeable with both non-blocking. P2 fixed in e255aa4b by hoisting the registry tombstone out of the microtask queue (sync `abortPendingSync` helper) and reordering tombstone-before-yield in `resolveIfPending` / `failIfPending`. P3 closed without code change: skipped entries remain in the registry so the next hydrate cycle or live SSE recovers the dock; adding a warn for a never-observed path is preventive noise.

Screenshots or Recordings

No visible UI surface changed — the dock still renders the same way; only its submit path moved from /session/:id/question/:id/reply to /session/:id/tool/respond.

Checklist

  • Type label — this PR carries exactly one of `bug`, `enhancement`, `task`, `documentation`. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of `app`, `ui`, `platform`, `harness`, `ci`. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of `P0`, `P1`, `P2`, `P3`. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to `Pending`, `Approved by @`, or `Not required: ` (default is `Pending`; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting `dev`, and my PR title and commit messages use Conventional Commits in English.

Summary by CodeRabbit

  • New Features

    • Child question docks now survive hard page reloads via persistent external-result hydration.
  • Bug Fixes

    • Simplified abort behavior to improve reliability and reduce confusing abort modes.
    • Submit/dismiss flows for question docks handle common HTTP errors with clearer toasts and swallow transient failures.
  • Refactor

    • Notifications for pending tool-question events improved and deduped to reduce noise.
    • Session blocker/recovery behaviors streamlined (less noisy auto-heal activity).
  • Documentation

    • Added localized error strings for session-question error states.

Review Change Stack

Astro-Han added 10 commits May 19, 2026 21:56
Stage 6 of PR B. Tool runner now owns the question lifecycle via
ctx.externalResult, so the client-side recovery state machine is dead
code. Drop the bootstrap.question.list + bootstrap.blocker.list fan-outs,
the `question` / `blocker` slots from State, the event reducer cases for
question.asked / question.replied / question.rejected /
session.blocker.upserted / session.blocker.removed, and the sidebar's
sessionQuestionRequest / sessionQuestionBlockerRequest helpers. Replace
the sidebar's "asking" pip with anyDescendantExternalResultQuestion,
which walks the session tree and reuses the dock's external-result
selector. Composer state drops the now-unused halt option, since
recovery no longer needs to abort sessions.
Stage 7 of PR B. With the question tool now resolved via ctx.externalResult,
abort no longer needs a "soft" / "hard" distinction — the runner always
interrupts the same way, and ExternalResult.abort propagates a typed
failure to any suspended tool. Drop `mode` from:
- the POST /session/:sessionID/abort query schema
- SessionPrompt.cancel options and the exported wrapper
- InterruptMeta (runner)
- MessageV2 assistant abort diagnostics schema
- Export.Snapshot diagnostics.aborts entries
- the renderer sessionAbortDiagnosticEvent helper and all callers
  (submit.ts, keydown.ts, session.tsx, use-session-commands.tsx,
  use-session-page-diagnostics.ts)

The cancel reason collapses from "soft_cancel" / "hard_cancel" to
"cancel". SDK regenerated. Tests updated.
Stage 8 of PR B. The question tool now always uses ctx.externalResult;
no second branch survives the legacy delete, so the flag has nothing
to gate. Drop the Flag declaration and the dynamic getter; update the
question tool comment to remove the stale flag reference.
… hooks

Stage 10 of PR B. The /question/__e2e/ask and /question/__e2e/publish-asked
server hooks were deleted with the legacy Question route in Stage 5, and
sdk.question.list / sdk.question.reply were dropped in the SDK regen in
Stage 5. Rewrite the e2e helpers to drive the dock through the real tool
runner instead:

- seedSessionQuestion now polls session messages for a running `question`
  tool part with state.metadata.externalResultReady === true, returning
  {messageID, callID} the dock submit path uses
- clearSessionDockSeed drops the sdk.question.reject loop; the question
  tool's Deferred is rejected naturally when withDockSession removes the
  session
- Five legacy-recovery tests in session-composer-dock.spec.ts are deleted
  (replay/fallback-refresh/blocker/stale.asked/stale.blocker) since the
  recovery state machine they exercised no longer exists
- The overflow test now arms llm.toolMatch + seedSessionQuestion in place
  of the deleted /question/__e2e/ask hook
- Drop unused helpers e2eAskQuestion, e2ePublishQuestionAsked,
  e2ePublishQuestionBlocker, waitForQuestionSeed
Crosscheck P3 fixes:
- Dock submit/escape paths used `void reply()` / `void reject()`, which
  let mutateAsync's rethrow after onError surface as an unhandled
  promise rejection. Catch and discard at the call site — onError
  already produced the toast.
- The dock selector accepted a running question part whose snapshot
  was missing or had an empty `state.input.questions` array. The dock
  would render with zero questions and submit `payload.answers: []`,
  which the server decoder rejects as a count mismatch. Skip those
  parts until the snapshot arrives intact.
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@Astro-Han has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 30 minutes and 25 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 480a3a20-4d3a-48ee-8495-5e1d6b3368f2

📥 Commits

Reviewing files that changed from the base of the PR and between 974241b and e255aa4.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/v2/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (19)
  • .gitattributes
  • packages/app/src/components/prompt-input/submit.ts
  • packages/app/src/i18n/en.ts
  • packages/app/src/i18n/zh.ts
  • packages/app/src/pages/layout.tsx
  • packages/app/src/pages/session.tsx
  • packages/app/src/pages/session/use-session-commands.tsx
  • packages/opencode/src/session/export.ts
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/processor.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/tool/external-result.ts
  • packages/opencode/test/session/export.test.ts
  • packages/opencode/test/session/llm.test.ts
  • packages/opencode/test/session/message-v2.test.ts
  • packages/opencode/test/session/prompt-effect.test.ts
  • packages/opencode/test/tool/external-result-registry.test.ts
  • packages/ui/script/verify-merge-driver.sh
📝 Walkthrough

Walkthrough

Replace question/blocker flows with external-result hydration and message-part lookup, remove abort mode (use source-only aborts), rewire SessionQuestionDock to session.toolRespond, delete backend question/blocker services, and update tests and diagnostics.

Changes

External-Result Migration and Schema Changes

Layer / File(s) Summary
State and types removal
packages/app/src/context/global-sync/types.ts, child-store.ts, session-cache.ts, event-reducer.ts, bootstrap.test.ts
Remove question/blocker from State and cache shapes; update SDK type imports and test fixtures.
Event replay & notification updates
packages/app/src/context/global-sync/event-reducer.ts, packages/opencode/src/server/event-replay.ts, packages/app/src/pages/layout.tsx
Remove question/blocker event cases, update REPLAYABLE_EVENT_TYPES to permission.*, and move question notifications to message.part lifecycle with externalResultReady gating and descendant suppression/dedup.
Bootstrap external-result hydration
packages/app/src/context/global-sync/bootstrap.ts, bootstrap.test.ts
Add hydratePendingExternalResults; replace sdk.question.list() warmup with sdk.externalResult.list(); batch-merge pending (session,message,part) into store and add tests.
Server external-result endpoint
packages/opencode/src/server/instance/external-result.ts, instance/index.ts
Add GET /external-result to list hydrated pending snapshots; register route and remove question/blocker routes.

Abort Mode Removal and Diagnostic Simplification

Layer / File(s) Summary
Abort signature & call-sites
packages/app/src/components/prompt-input/keydown.ts, submit.ts, packages/opencode/src/session/prompt.ts, packages/opencode/src/server/instance/session.ts, packages/app/src/pages/session.tsx
Remove mode parameter from abort signatures and server query; use source-only abort calls and diagnostics.
Diagnostics schema updates
packages/app/src/context/renderer-diagnostics.ts, packages/opencode/src/session/export.ts, message-v2.ts, packages/opencode/src/effect/runner.ts
Remove mode from diagnostic schemas and exported snapshot diagnostics; preserve source/result fields; update tests accordingly.
Prompt submit diagnostics
packages/app/src/components/prompt-input/submit.ts, submit.test.ts
Replace AbortMode with AbortSource; compute result from SDK abort response; update tests to expect { sessionID, source }.

Question Dock Refactoring to External-Result Flow

Layer / File(s) Summary
Running external-result helpers
packages/app/src/pages/session/blockers/running-external-result-question.ts, use-session-blockers.test.ts
Add DockQuestionRequest and helpers findRunningExternalResultQuestion / findDescendantExternalResultQuestion with unit tests.
Session blockers / composer changes
packages/app/src/pages/session/blockers/use-session-blockers.ts, session-composer-state.ts
Replace fallback/refetch/recovery logic with tree-based external-result lookup; remove recoveringQuestion and halt callback usage.
SessionQuestionDock wiring
packages/app/src/pages/session/composer/session-question-dock.tsx
Accept DockQuestionRequest; submit/dismiss via sdk.client.session.toolRespond with sessionID/messageID/callID; add typed error handling and swallow promise rejections.
Sidebar/request-tree simplification
packages/app/src/pages/layout/sidebar-items.tsx, request-tree.ts
Use descendant external-result lookup for "asking" detection; remove question-specific request helpers.

Backend Question/Blocker Service Removal

Layer / File(s) Summary
Question & blocker removal
packages/opencode/src/question/index.ts, packages/opencode/src/session/blocker.ts, packages/opencode/src/server/instance/question.ts, packages/opencode/src/server/instance/blocker.ts
Trim Question module to prompt/answer schema only; remove SessionBlocker namespace, QuestionRoutes, BlockerRoutes, and related exports.
Session cleanup & llm changes
packages/opencode/src/session/session.ts, processor.ts, tool-failure.ts, llm.ts
Stop clearing Question/SessionBlocker on session teardown; remove Question.RejectedError handling; simplify llm watchdog to rely on ExternalResult.hasPending().
Layer composition cleanup
packages/opencode/src/effect/app-runtime.ts, src/tool/registry.ts
Remove Question/SessionBlocker default layers from AppLayer and ToolRegistry compositions.

Tool execution: Question & Plan external-result

Layer / File(s) Summary
QuestionTool external-result-only
packages/opencode/src/tool/question.ts, packages/core/src/flag/flag.ts
Remove feature-flag branch; always use ctx.externalResult and return explicit metadata.dismissed flag.
PlanExitTool snapshot decoder
packages/opencode/src/tool/plan.ts
Add PlanExitSnapshot and planExitDecoder; use ctx.externalResult, validate answers shape, and handle dismissed/declined/approved flows.

E2E and Tests

Layer / File(s) Summary
seedSessionQuestion & E2E spec changes
packages/app/e2e/actions.ts, packages/app/e2e/session/session-composer-dock.spec.ts
Seed by polling session messages for running external-result parts and return { id, messageID, callID }; remove backend ask/publish/poll helpers; add dock rehydration regression and update overflow test.
Test rewiring & new registry tests
various packages/opencode/test/*
Remove many question/blocker tests, update prompt-effect and snapshot tests to exclude question/blocker layers, replace soft-cancel tests with cancel-interrupt tests, and add ExternalResult.list coverage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

"I nibble at clocks and modes, then hop—
seeds of external results in a fresh plot,
I dig out 'mode' and plant a single 'source',
watch docks rehydrate across a hard reload,
and thump my foot: 'no more soft/hard hops!'" 🐇

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/question-tool-pr-b

@github-actions github-actions Bot added app Application behavior and product flows ui Design system and user interface harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority labels May 19, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested priority: P2 (includes user-path files (packages/app/src/components/prompt-input/keydown.ts, packages/app/src/components/prompt-input/submit.test.ts, packages/app/src/components/prompt-input/submit.ts, packages/app/src/context/global-sync/bootstrap.test.ts, packages/app/src/context/global-sync/bootstrap.ts, packages/app/src/context/global-sync/child-store.ts, packages/app/src/context/global-sync/event-reducer.test.ts, packages/app/src/context/global-sync/event-reducer.ts, packages/app/src/context/global-sync/session-cache.test.ts, packages/app/src/context/global-sync/session-cache.ts, packages/app/src/context/global-sync/types.ts, packages/app/src/context/renderer-diagnostics.ts, packages/app/src/i18n/en.ts, packages/app/src/i18n/zh.ts, packages/app/src/pages/layout.tsx, packages/app/src/pages/layout/sidebar-items.tsx, packages/app/src/pages/session.tsx, packages/app/src/pages/session/blockers/question-fallback.test.ts, packages/app/src/pages/session/blockers/question-fallback.ts, packages/app/src/pages/session/blockers/question-reconcile.test.ts, packages/app/src/pages/session/blockers/question-reconcile.ts, packages/app/src/pages/session/blockers/question-recovery-chain.test.ts, packages/app/src/pages/session/blockers/question-recovery-clock.test.ts, packages/app/src/pages/session/blockers/question-recovery-clock.ts, packages/app/src/pages/session/blockers/question-recovery-reverify.test.ts, packages/app/src/pages/session/blockers/question-recovery-reverify.ts, packages/app/src/pages/session/blockers/question-recovery-snapshot.test.ts, packages/app/src/pages/session/blockers/question-recovery-snapshot.ts, packages/app/src/pages/session/blockers/question-refetch-runner.test.ts, packages/app/src/pages/session/blockers/question-refetch-runner.ts, packages/app/src/pages/session/blockers/request-tree.test.ts, packages/app/src/pages/session/blockers/request-tree.ts, packages/app/src/pages/session/blockers/running-external-result-question.ts, packages/app/src/pages/session/blockers/use-session-blockers.test.ts, packages/app/src/pages/session/blockers/use-session-blockers.ts, packages/app/src/pages/session/composer/session-composer-state.ts, packages/app/src/pages/session/composer/session-question-dock.tsx, packages/app/src/pages/session/use-session-commands.tsx, packages/app/src/pages/session/use-session-page-diagnostics.ts)).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the question tool to use an externalResult primitive, replacing the legacy Question.ask flow. It removes the dedicated Question and SessionBlocker services, along with their associated API routes and tests. The frontend and SDK have been updated to support this new tool response mechanism, and the session abort logic has been simplified by removing the soft/hard mode distinction. I have no feedback to provide as there were no review comments.

@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown

Perf delta summary

Comparator: pass

Profile / Scenario interaction median interaction worst long task max tbt frame gap p95 frame gap max jank count cls status
default / homepage-cold 24 -> 32 (+8) 48 -> 48 (0) 74 -> 62 (-12) 24 -> 12 (-12) 33.3 -> 33.4 (+0.1) 150 -> 133.3 (-16.7) 4 -> 4 (0) 0 -> 0 (0) pass
default / long-session-input-lag 40 -> 48 (+8) 40 -> 64 (+24) 0 -> 0 (0) 0 -> 0 (0) 16.7 -> 16.7 (0) 16.8 -> 16.8 (0) 0 -> 0 (0) 0 -> 0 (0) pass
default / session-streaming-long 48 -> 40 (-8) 64 -> 56 (-8) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 16.8 (0) 16.8 -> 16.8 (0) 0 -> 0 (0) 0 -> 0 (0) pass
default / tool-call-expand 16 -> 16 (0) 16 -> 24 (+8) 0 -> 0 (0) 0 -> 0 (0) 16.7 -> 16.7 (0) 16.7 -> 16.7 (0) 0 -> 0 (0) 0 -> 0 (0) pass
default / tool-default-open-heavy-bash 32 -> 24 (-8) 32 -> 32 (0) 62 -> 61 (-1) 15 -> 12 (-3) 49.9 -> 50 (+0.1) 166.7 -> 133.4 (-33.3) 1 -> 3 (+2) 0 -> 0 (0) pass
default / terminal-side-panel-open 48 -> 48 (0) 48 -> 48 (0) 0 -> 0 (0) 0 -> 0 (0) 50 -> 33.5 (-16.5) 50 -> 50 (0) 0 -> 0 (0) 0 -> 0 (0) pass
default / session-scroll-reading 24 -> 16 (-8) 32 -> 32 (0) 0 -> 0 (0) 0 -> 0 (0) 16.8 -> 16.7 (-0.1) 16.8 -> 16.7 (-0.1) 0 -> 0 (0) 0.505 -> 0.505 (0) warn: cls
low-end / session-scroll-reading-long 64 -> 56 (-8) 64 -> 72 (+8) 62 -> 66 (+4) 17 -> 27 (+10) 16.8 -> 16.8 (0) 66.7 -> 66.7 (0) 2 -> 2 (0) 0.011 -> 0.011 (0) pass
low-end / session-timeline-recompute 120 -> 120 (0) 128 -> 136 (+8) 108 -> 111 (+3) 167 -> 166 (-1) 100 -> 83.4 (-16.6) 166.7 -> 183.4 (+16.7) 4 -> 3 (-1) 0.081 -> 0.081 (0) pass
low-end / concurrent-shimmer-extreme 0 -> 0 (0) 0 -> 0 (0) 0 -> 0 (0) 0 -> 0 (0) 16.7 -> 16.8 (+0.1) 16.8 -> 16.8 (0) 0 -> 0 (0) 0 -> 0 (0) pass

@Astro-Han Astro-Han added the task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work label May 19, 2026
Astro-Han added 4 commits May 20, 2026 00:37
…nt question

The new external-result dock selector only scanned the active session's
messages, so a child agent calling question() left the parent session
page without an answer dock — even though the sidebar pip (which already
walks descendants) signalled "asking". Mirrors sessionPermissionRequest,
which has walked the tree since #419.

- Rename `anyDescendantExternalResultQuestion` -> `findDescendantExternalResultQuestion`
  and return the matching `DockQuestionRequest` (sessionID points at the
  session that actually owns the running part, so /tool/respond hits the
  right Deferred).
- `useSessionBlockers.questionRequest` now uses the walker.
- Sidebar `isAsking` reuses the same walker (=== undefined).
- Tests: parent surfaces own request first, falls back to child request,
  walks grandchildren, returns undefined when no descendant has one.
PR B removed the legacy `question.asked` / `question.replied` listeners
from layout.tsx along with the rest of the legacy Question namespace,
but left the i18n keys orphaned and dropped the system notification for
background-session questions. Reattach it to the new event source: when
a running question tool part's metadata.externalResultReady flips true,
emit a notification for sessions the user is not currently viewing.

- Listen for `message.part.updated`; require part.tool === "question",
  state.status === "running", metadata.externalResultReady === true.
- Dedup by `${directory}:${sessionID}:${partID}`; drop on
  `message.part.removed`. The set keeps a single ready transition from
  re-notifying when the part receives further updates.
- Suppress notification when the question's session (or its parent) is
  the one currently visible — same rule permission notifications use,
  consistent with the dock now walking the session tree.
- Reuse `settings.notifications.agent()` gate.
…une dedup on settle

Round-1 crosscheck flagged two follow-ups on the question notification:

1. dock now walks descendants from the open session, so a grandchild
   question still fires an OS notification even though it is visible
   in-app. Replace the 1-level `parentID === currentSession` check with
   an ancestor walk: any ancestor matching currentSession suppresses.
2. The dedup set previously only shrank on `message.part.removed`,
   which is not always fired when a question part settles. Prune the
   entry when the part transitions out of `running` (completed, error,
   or dismissed), so the set cannot grow unbounded.

Same change applies the ancestor walk to permission notifications too,
mirroring `sessionPermissionRequest`'s tree-walk semantics (already in
place since #419 for the dialog).

Test naming: previous fixture asserted `externalResultReady: false` but
the test name claimed "preparing window" (missing key). Renamed and
added a separate case that omits the metadata key entirely.
@github-actions github-actions Bot removed the task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work label May 19, 2026
@Astro-Han Astro-Han added the task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work label May 19, 2026
@github-actions github-actions Bot removed the task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work label May 19, 2026
@Astro-Han Astro-Han added task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work enhancement New feature or request and removed task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work labels May 19, 2026
Stage 6 dropped question.asked / replied / rejected / session.blocker.*
from the SSE replay buffer because the new metadata.externalResultReady
flag rides on message.part.updated, which is intentionally not in the
buffer (high-volume streaming event). That left no recovery path on
parent-page reload or cold-open: child session messages never hydrate
through the existing route refresh (which only syncs routeSessionID),
and message.part.updated alone cannot rebuild a missing child session
or message in the store. A child agent's pending question dock would
vanish across any reload past the SSE cursor.

Add ExternalResult.list() on the server side, expose it through a new
GET /external-result route that joins each pending entry with its
session and message+part snapshot, and hydrate the trio during the
directory bootstrap slow phase. Brief retry inside the route covers
the register / processor.updateToolCall race so a reload caught in the
millisecond window between Deferred registration and part-row flush
still gets the part. Bootstrap fetch swallows transient errors so a
backend hiccup does not surface the project-level reloadFailed toast.

Adds bun unit coverage on ExternalResult.list() and the hydrate helper,
plus an E2E that creates a parent + child session, seeds a question on
the child, hard-reloads the parent route (fresh SSE, no cursor), and
asserts the dock returns and submit resolves the child question.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/opencode/src/tool/plan.ts (1)

30-60: 💤 Low value

Decoder only validates first question's options - this is intentional but could be fragile.

The planExitDecoder at line 53-58 only validates answers against params.questions[0]. This is correct for the current single-question plan exit prompt, but if additional questions were ever added to the snapshot, subsequent answers would bypass label validation.

Consider adding a comment or assertion to make this single-question assumption explicit.

📝 Suggested comment
 function planExitDecoder(payload: unknown, snapshot: unknown): DecodeResult {
   const params = snapshot as PlanExitSnapshot | null | undefined
   if (!params || !Array.isArray(params.questions)) {
     return { ok: false, error: "internal_snapshot_invalid" }
   }
+  // PlanExitTool always emits exactly one question; this decoder validates
+  // only the first row's labels. If this changes, update validation below.
   if (payload === null || typeof payload !== "object") {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/tool/plan.ts` around lines 30 - 60, The decoder
planExitDecoder currently only validates labels against params.questions[0] (see
params.questions[0] usage) which assumes a single-question snapshot; make that
assumption explicit by either adding a clear comment above planExitDecoder
explaining the single-question contract or adding a runtime assertion that
params.questions.length === 1 (or throw an "internal_snapshot_invalid" if it's
not) so future changes to questions won't silently bypass validation for
subsequent answers; refer to the variables params.questions, trimmed, and
validLabels when placing the comment/assertion.
packages/opencode/src/server/instance/external-result.ts (1)

63-70: 💤 Low value

Consider logging when part remains missing after retries.

The retry loop handles the register→updateToolCall race, but if the part is still missing after 3 attempts, the entry is silently skipped. This could make debugging difficult if parts are consistently missing for unexpected reasons.

🔧 Suggested improvement
           part = message.parts.find((p) => p.type === "tool" && p.callID === snap.callID)
         }
-        if (!part) continue
+        if (!part) {
+          log.warn("external-result pending hydrate: part not found after retries", {
+            sessionID: snap.sessionID,
+            messageID: snap.messageID,
+            callID: snap.callID,
+          })
+          continue
+        }
         out.push({ session, message: message.info, part })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/server/instance/external-result.ts` around lines 63 -
70, The retry loop fetching MessageV2.get({ sessionID, messageID }) retries
finding the tool part (message.parts.find(... callID === snap.callID)) but
silently continues if part is still missing; add a log entry after the final
check (before the "continue") that records the failure, including sessionID,
messageID, snap.callID and number of attempts, using the project's logging
facility (e.g., processLogger or logger) so missing-part races are visible
during debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/opencode/src/server/instance/external-result.ts`:
- Around line 63-70: The retry loop fetching MessageV2.get({ sessionID,
messageID }) retries finding the tool part (message.parts.find(... callID ===
snap.callID)) but silently continues if part is still missing; add a log entry
after the final check (before the "continue") that records the failure,
including sessionID, messageID, snap.callID and number of attempts, using the
project's logging facility (e.g., processLogger or logger) so missing-part races
are visible during debugging.

In `@packages/opencode/src/tool/plan.ts`:
- Around line 30-60: The decoder planExitDecoder currently only validates labels
against params.questions[0] (see params.questions[0] usage) which assumes a
single-question snapshot; make that assumption explicit by either adding a clear
comment above planExitDecoder explaining the single-question contract or adding
a runtime assertion that params.questions.length === 1 (or throw an
"internal_snapshot_invalid" if it's not) so future changes to questions won't
silently bypass validation for subsequent answers; refer to the variables
params.questions, trimmed, and validLabels when placing the comment/assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b8d2d7bc-c727-42ad-ac94-d78a21e8a31b

📥 Commits

Reviewing files that changed from the base of the PR and between bb7f938 and 974241b.

⛔ Files ignored due to path filters (2)
  • packages/sdk/js/src/v2/gen/sdk.gen.ts is excluded by !**/gen/**
  • packages/sdk/js/src/v2/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (79)
  • packages/app/e2e/actions.ts
  • packages/app/e2e/session/session-composer-dock.spec.ts
  • packages/app/src/components/prompt-input/keydown.ts
  • packages/app/src/components/prompt-input/submit.test.ts
  • packages/app/src/components/prompt-input/submit.ts
  • packages/app/src/context/global-sync/bootstrap.test.ts
  • packages/app/src/context/global-sync/bootstrap.ts
  • packages/app/src/context/global-sync/child-store.ts
  • packages/app/src/context/global-sync/event-reducer.test.ts
  • packages/app/src/context/global-sync/event-reducer.ts
  • packages/app/src/context/global-sync/session-cache.test.ts
  • packages/app/src/context/global-sync/session-cache.ts
  • packages/app/src/context/global-sync/types.ts
  • packages/app/src/context/renderer-diagnostics.ts
  • packages/app/src/i18n/en.ts
  • packages/app/src/i18n/zh.ts
  • packages/app/src/pages/layout.tsx
  • packages/app/src/pages/layout/sidebar-items.tsx
  • packages/app/src/pages/session.tsx
  • packages/app/src/pages/session/blockers/question-fallback.test.ts
  • packages/app/src/pages/session/blockers/question-fallback.ts
  • packages/app/src/pages/session/blockers/question-reconcile.test.ts
  • packages/app/src/pages/session/blockers/question-reconcile.ts
  • packages/app/src/pages/session/blockers/question-recovery-chain.test.ts
  • packages/app/src/pages/session/blockers/question-recovery-clock.test.ts
  • packages/app/src/pages/session/blockers/question-recovery-clock.ts
  • packages/app/src/pages/session/blockers/question-recovery-reverify.test.ts
  • packages/app/src/pages/session/blockers/question-recovery-reverify.ts
  • packages/app/src/pages/session/blockers/question-recovery-snapshot.test.ts
  • packages/app/src/pages/session/blockers/question-recovery-snapshot.ts
  • packages/app/src/pages/session/blockers/question-refetch-runner.test.ts
  • packages/app/src/pages/session/blockers/question-refetch-runner.ts
  • packages/app/src/pages/session/blockers/request-tree.test.ts
  • packages/app/src/pages/session/blockers/request-tree.ts
  • packages/app/src/pages/session/blockers/running-external-result-question.ts
  • packages/app/src/pages/session/blockers/use-session-blockers.test.ts
  • packages/app/src/pages/session/blockers/use-session-blockers.ts
  • packages/app/src/pages/session/composer/session-composer-state.ts
  • packages/app/src/pages/session/composer/session-question-dock.tsx
  • packages/app/src/pages/session/use-session-commands.tsx
  • packages/app/src/pages/session/use-session-page-diagnostics.ts
  • packages/core/src/flag/flag.ts
  • packages/opencode/src/effect/app-runtime.ts
  • packages/opencode/src/effect/runner.ts
  • packages/opencode/src/question/index.ts
  • packages/opencode/src/server/event-replay.ts
  • packages/opencode/src/server/instance/blocker.ts
  • packages/opencode/src/server/instance/external-result.ts
  • packages/opencode/src/server/instance/index.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/server/instance/session.ts
  • packages/opencode/src/server/routes/instance/question.ts
  • packages/opencode/src/session/blocker.ts
  • packages/opencode/src/session/export.ts
  • packages/opencode/src/session/llm.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/session/processor.ts
  • packages/opencode/src/session/prompt.ts
  • packages/opencode/src/session/session.ts
  • packages/opencode/src/session/tool-failure.ts
  • packages/opencode/src/tool/external-result.ts
  • packages/opencode/src/tool/plan.ts
  • packages/opencode/src/tool/question.ts
  • packages/opencode/src/tool/registry.ts
  • packages/opencode/test/question/question.test.ts
  • packages/opencode/test/question/schema.test.ts
  • packages/opencode/test/server/event-replay.test.ts
  • packages/opencode/test/server/global-event-replay.test.ts
  • packages/opencode/test/server/pending-interaction-routes.test.ts
  • packages/opencode/test/server/session-actions.test.ts
  • packages/opencode/test/session/export.test.ts
  • packages/opencode/test/session/llm.test.ts
  • packages/opencode/test/session/message-v2.test.ts
  • packages/opencode/test/session/pending-interaction-lifecycle.test.ts
  • packages/opencode/test/session/prompt-effect.test.ts
  • packages/opencode/test/session/snapshot-tool-race.test.ts
  • packages/opencode/test/session/tool-failure.test.ts
  • packages/opencode/test/tool/external-result-registry.test.ts
  • packages/ui/src/components/message-part/tools/question.tsx
💤 Files with no reviewable changes (39)
  • packages/app/src/pages/session/blockers/question-refetch-runner.ts
  • packages/opencode/test/session/pending-interaction-lifecycle.test.ts
  • packages/opencode/test/server/pending-interaction-routes.test.ts
  • packages/opencode/src/session/tool-failure.ts
  • packages/app/src/pages/session/blockers/question-recovery-snapshot.ts
  • packages/app/src/pages/session/blockers/question-recovery-reverify.test.ts
  • packages/app/src/pages/session/blockers/question-refetch-runner.test.ts
  • packages/app/src/pages/session/blockers/question-reconcile.ts
  • packages/opencode/src/session/blocker.ts
  • packages/opencode/src/server/event-replay.ts
  • packages/opencode/src/server/routes/instance/question.ts
  • packages/app/src/pages/session/blockers/question-recovery-chain.test.ts
  • packages/opencode/src/effect/runner.ts
  • packages/opencode/test/question/question.test.ts
  • packages/app/src/pages/session/blockers/question-fallback.test.ts
  • packages/app/src/pages/session/blockers/question-fallback.ts
  • packages/opencode/src/server/instance/blocker.ts
  • packages/app/src/pages/session/blockers/question-recovery-clock.ts
  • packages/opencode/test/session/tool-failure.test.ts
  • packages/app/src/pages/session/blockers/question-recovery-reverify.ts
  • packages/opencode/src/session/session.ts
  • packages/opencode/src/session/message-v2.ts
  • packages/opencode/src/server/instance/question.ts
  • packages/opencode/src/effect/app-runtime.ts
  • packages/opencode/src/tool/registry.ts
  • packages/app/src/pages/session/blockers/question-recovery-clock.test.ts
  • packages/app/src/context/global-sync/session-cache.ts
  • packages/app/src/pages/session/blockers/question-recovery-snapshot.test.ts
  • packages/opencode/test/session/llm.test.ts
  • packages/app/src/pages/session/use-session-page-diagnostics.ts
  • packages/app/src/pages/session/blockers/question-reconcile.test.ts
  • packages/opencode/src/session/export.ts
  • packages/app/src/context/renderer-diagnostics.ts
  • packages/opencode/src/server/instance/session.ts
  • packages/opencode/test/session/snapshot-tool-race.test.ts
  • packages/core/src/flag/flag.ts
  • packages/app/src/context/global-sync/child-store.ts
  • packages/app/src/context/global-sync/event-reducer.ts
  • packages/app/src/context/global-sync/types.ts

@Astro-Han

Copy link
Copy Markdown
Owner Author

@coderabbitai Acknowledged both nitpicks; neither will be acted on in this PR.

  1. plan.ts planExitDecoder single-question assumption — verified that PlanExitTool.execute (plan.ts L77, L98) emits a structurally single-question snapshot and only reads answers[0]. A future extra question would be ignored downstream regardless of whether the decoder validates its label, so the "fragility" the comment guards against has no observable failure mode today. If plan exit ever grows multi-question, the decoder will be widened to loop alongside the consumer.

  2. external-result.ts part-missing log — silent skip is the intended best-effort shape: the next bootstrap pass refetches the snapshot, and a steady-state miss would also produce a downstream SSE message.part.updated that the reducer picks up once the registration/flush race window closes. A round-1 fresh-eyes review already considered the same "log doesn't distinguish" P3 (/tmp/crosscheck.RcQDUa/claude.md); the call there was to keep the route lean and noise-free, and that judgment stands.

No code changes; closing both threads.

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@Astro-Han Understood on both — the reasoning is clear and I won't flag these again in this PR.

  • plan.ts decoder: The single-question structural guarantee from the tool side makes the label-loop concern moot today; widening the decoder alongside the consumer if multi-question ever lands is the right call.
  • external-result.ts silent skip: Best-effort with a downstream SSE catch-up is a reasonable tradeoff for keeping the route lean, and the prior round-1 review reaching the same conclusion confirms it.

Resolving both threads now.


🏁 Script executed:

#!/bin/bash
# List open review comments by coderabbitai on PR `#772`
gh api repos/Astro-Han/pawwork/pulls/772/comments \
  --jq '.[] | select(.user.login == "coderabbitai") | {id: .id, body: .body[0:120]}'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Try fetching all reviews and their comments
gh api repos/Astro-Han/pawwork/pulls/772/reviews \
  --jq '.[] | select(.user.login == "coderabbitai") | {id: .id, state: .state, body: .body[0:120]}'

echo "---issue comments---"
gh api repos/Astro-Han/pawwork/issues/772/comments \
  --jq '.[] | select(.user.login == "coderabbitai") | {id: .id, body: .body[0:120]}'

Length of output: 21


@Astro-Han Both points are well-reasoned — acknowledged and noted. No further action needed from my side on either thread.

  • The single-question structural invariant in PlanExitTool makes the decoder concern a non-issue today, and the natural widening path is clear if that ever changes.
  • The best-effort silent-skip design for external-result.ts is sound given the SSE catch-up and bootstrap refetch safety nets, and the prior review's consistent conclusion reinforces it.

I wasn't able to locate the original inline comments via the API to resolve them programmatically (they may already be resolved or scoped to a different review session), but consider both threads closed on my end.

Astro-Han added 2 commits May 20, 2026 11:12
Upstream history (anomalyco/opencode) is no longer a shared ancestor of
PawWork's dev branch, so `git merge` from upstream is physically blocked
by no-common-ancestor regardless of attributes. The driver only fires for
intra-PawWork 3-way merges, where it silently drops dev's PawWork-internal
changes when feature branches pull dev forward (most recently lost #765's
detectSubmitOwnership additions from PR B's dev merge, leaving #765's new
tests in the worktree without the symbols they import).

The carve-out goal — preserve PawWork UI when re-anchoring on upstream
opencode — now lives wherever the next anchor sync ends up: a one-off
checkout of the listed paths back to HEAD, reviewed as part of that
intentional event. No silent path-based override remains.

- .gitattributes: drop the pawwork-keep-ours block (LF pinning kept)
- packages/ui/script/verify-merge-driver.sh: delete (validates a driver
  that no longer exists)
Resolved SDK gen conflicts by regenerating from the merged OpenAPI source.
SDK now exposes:
- externalResult.list (from PR B)
- RateLimit, command-inline, draft-isolation surfaces from dev

Legacy question/blocker routes stay deleted (PR B's intent preserved).
typecheck + bun test pass on app/opencode/core/ui/sdk.
@Astro-Han

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@Astro-Han

Copy link
Copy Markdown
Owner Author

Third external review came back as mergeable — no P0/P1. Two non-blocking items, both noted as "do not block this PR" by the reviewer themselves. Acknowledged without code changes; reasoning below.

P2 — abort vs /tool/respond race window

This is the same race surfaced in Round 1 and disclosed in this PR's Risk Notes ("A pre-existing race condition in PR A's ctx.externalResult abort wiring (prompt.ts:788 schedules failIfPending asynchronously, so a racing /tool/respond can still resolve a Deferred that was supposed to be aborted) was surfaced by the round-1 review. Out of scope for PR B — will file a follow-up.").

PR B does not change PR A's abortHandler wiring; it only changes how the question tool consumes ctx.externalResult. The race window is unchanged in size or shape. Worst-case observable outcome is one question resolving with the user's answer instead of being aborted — user-visible, not data-corrupting. Tracking as a follow-up against the ExternalResult registry, not blocking this PR.

P3 — GET /external-result silent skip on >150ms part-flush delay

The current 3 × 50ms retry already covers the observed register→updateToolCall flush race. The remaining "exceeds 150ms" path is theoretical: PawWork DB writes flush sub-millisecond in practice, and no test has observed a miss after the retry. If the race ever exceeds the window, the entry stays in ExternalResult registry — the next hydrate cycle (e.g. route change / next reload) will pick it up, and the live SSE message.part.updated after the eventual flush also restores the dock. Not permanently lost.

Adding a warn for a never-fired path adds noise without observability value; if we ever see this in practice we'd add a counter and longer backoff together. Closing the suggestion without changes.

PR A wired the externalResult abort handler via run.promise(failIfPending),
which posts the registry tombstone behind a microtask. A concurrently
scheduled /tool/respond ran its own Effect through the same microtask
queue, and its Effect.gen tick committed Deferred.succeed before the abort
Effect could mark the entry resolved. Net effect: a question the user
intended to abort completed with their last-typed answer.

Closed the race in two places:
- Add `ExternalResult.abortPendingSync` (synchronous transition of the Map
  entry pending → resolved tombstone, returning the Deferred so the caller
  can schedule Deferred.fail asynchronously). The registry now reflects
  the post-abort state the moment the AbortSignal fires, before any other
  Effect tick runs.
- Reorder `entries.set` to land before the Deferred yield inside
  `resolveIfPending` and `failIfPending`. Either end of the race commits
  the tombstone synchronously on its Effect tick 1, so whichever Effect
  schedules first wins deterministically and the loser sees state==="resolved"
  on its own first read.

`prompt.ts`'s abort handler switches from `run.promise(failIfPending(...))`
to `abortPendingSync` + a follow-up `run.promise(Deferred.fail(deferred,...))`.
The Deferred propagation is unchanged; only the registry transition was
hoisted out of the microtask queue.

5 new tests cover: sync transition + returned Deferred identity, ok:false
on missing/resolved entries, respond-after-abort returns already_resolved,
Deferred.fail surfaces as aborted error to the awaiter, and the inverse
race where respond commits the tombstone before a racing abort.

Closes the "out of scope, follow-up" disclaimer in this PR's Risk Notes.
2829 pass / 0 fail across opencode tests.
@Astro-Han

Copy link
Copy Markdown
Owner Author

Reopened P2 in scope after offline discussion (no PR C is planned, so the "follow-up" excuse no longer holds).

Fixed in e255aa4. Root cause: the abort handler at prompt.ts:785 scheduled run.promise(failIfPending), which posted the registry tombstone behind a microtask. A concurrently scheduled /tool/respond ran its own Effect through the same queue and could commit Deferred.succeed before the abort Effect's first tick — so a question the user intended to abort would complete with their last-typed answer.

Two ordering fixes, applied together:

  1. ExternalResult.abortPendingSync — synchronous Map transition from pending → resolved tombstone, returning the Deferred so the caller schedules Deferred.fail asynchronously. The registry now reflects the post-abort state the instant the AbortSignal fires.
  2. Tombstone-before-yield in resolveIfPending and failIfPendingentries.set moves to before the Deferred.{succeed,fail} yield. Either end of the race commits its tombstone on Effect tick 1, so the loser's first synchronous read returns already_resolved deterministically.

prompt.ts abort handler now uses abortPendingSync + a follow-up run.promise(Deferred.fail(deferred,...)).

5 new race tests in external-result-registry.test.ts cover both directions (abort wins, submit wins) and Deferred propagation through the sync helper. 2829 pass / 0 fail across opencode tests.

PR body Risk Notes section updated to drop the disclaimer.

For P3: closed without code change. Rationale already documented in the prior comment — skipped entries stay in the registry, so the next hydrate cycle or live SSE restores the dock; adding a warn for a path that has not fired in tests is preventive noise without observability value.

@Astro-Han

Copy link
Copy Markdown
Owner Author

Third round review verdict acknowledged: mergeable, no P0/P1. Three non-blocking items addressed below.

P2 — plan_exit not surfaced by tool === "question" dock filter

Declined. PlanExitTool is gated by Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" at registry.ts:300. PawWork's desktop-electron forces OPENCODE_CLIENT="desktop" when it spawns the server (packages/desktop-electron/src/main/shell-env.test.ts:28), so plan_exit is never registered in the tool list PawWork ships to the LLM — it cannot be invoked, the dock never sees the part, and the selector filter is moot.

The only path where the LLM can actually call plan_exit is OPENCODE_CLIENT=cli against the upstream-opencode TUI, which has its own responder (Go binary, separate /tool/respond invocation flow). That is not PawWork's UI surface.

Same reasoning as Stage 9 drop in the PR plan: don't write defensive code for an unreachable path. If PawWork ever enables plan mode for the desktop client, the gate and the selector get relaxed in the same PR.

P2 — .gitattributes carve-out + verify-merge-driver.sh removal

Status confirmation, not a finding. Removed intentionally in ff61a4a51 after offline discussion. Rationale (also in the commit body):

The carve-out's only intended scenario was upstream-opencode sync. PawWork's origin/dev no longer shares git history with anomalyco/opencodegit merge-base origin/dev upstream/2.0 returns no common ancestor, so any git merge from upstream is physically blocked regardless of attributes. The driver only fires for intra-PawWork 3-way merges, where it silently drops dev's PawWork-internal feature changes when feature branches pull dev forward. Most recently it dropped #765's detectSubmitOwnership additions during this PR's dev merge, leaving #765's tests in the worktree without the symbols they import. The protection it provided is now zero; the harm is real and recurring.

If we ever re-anchor on upstream-opencode in the future, the carve-out goal moves to a one-off git checkout HEAD -- <carved paths> reviewed as part of that intentional event, which is how every anchor sync would be reviewed anyway.

P3 — GET /external-result silent skip on >150ms part-flush

Declined again (same call as the second-round response). The 3 × 50ms retry covers observed timings; PawWork DB writes flush sub-millisecond in practice. Skipped entries remain in ExternalResult.list(), so the next hydrate cycle (route change, reload) or the live message.part.updated SSE after the eventual flush restores the dock. No permanent loss. Adding a warn for a path that has not fired in any test is preventive noise without observability value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app Application behavior and product flows enhancement New feature or request harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority ui Design system and user interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant