Move 13 shipped openspec change docs under openspec/changes/archived/. Add docs/features/git-diff-panel, docs/plans/post-review-backlog, and docs/research/cross-app-contract-ssot.md (the research behind the @boocode/contracts SSOT work). Update BOOCHAT.md, BOOCODER.md, and boocode_roadmap.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
19 KiB
Implementation Decision Log: Post-Review Backlog (F1–F9)
Source of truth: ../scope-brief.md (ground-truth spec stand-in) and synthesis-input.md (the consolidated Round-1 specialist aggregation; its file:line evidence is treated as verbatim). The D-N counter is shared across the Trivial and Full sections below.
Trivial decisions
- D-2: F3 logger threading shape — pass an optional
log?: { debug }param toextractToolCallBlocksfrom its single call site instream-phase.tsexecuteStreamPhase; no interface (one site, one impl). — Referenced in plan: Implementation Approach (F2+F3), Decomposition and Sequencing. - D-6: F7 query shape — read
messages_with_partsWHERE role != 'system'(strips sentinels), paramssession_id+ optionalchat_id+limit(default 50, max 200),ORDER BY created_at ASC, returns{role,content,...}[]. — Referenced in plan: Implementation Approach (F7), External Interfaces. - D-9: Patch-tag-per-unit — each ready item ships as its own sequential patch tag (one batch per coherent unit), not a minor bump; Sam declined v2.8.0 twice. — Referenced in plan: Decomposition and Sequencing, Operational Readiness.
- D-10: F1 Stop-button terminal label — render a muted "Stopped" label (not red, not a toast) for
status='cancelled'. — Referenced in plan: Implementation Approach (F1), Testing Strategy.
Full decisions
D-1: F1 cancel registry shape and finalization-fix scope
- Question: How does a Stop on an external agent task actually abort the running child, and what message-state corruption does wiring that abort newly expose?
- Decision: Add
taskControllers = new Map<string, AbortController>()insidecreateDispatcher;taskControllers.set(taskId, ac)at each of the four run-functions (dispatcher.ts~316/655/991/1248) and.deletein the existing.finally()(dispatcher.ts:117); exportcancelExternalTask(taskId): boolean(idempotent —ac.abort()no-ops when already aborted, so double-Stop and cancel-after-exit are safe). Pass a narrowExternalCancelFn(not the whole dispatcher) intoregisterTaskRoutes, wired inindex.ts:254. In the same batch, fix the two pre-existing finalization bugs this newly makes reachable: (1) the four catch blocks update onlytasksstate and leave the assistantmessagesrowstatus='streaming'(the BooChat 5-min sweep is a different process and cannot recover it); (2) the warm-backend success path writesmessages.status='complete'unconditionally before checking abort (dispatcher.ts~853/1122/1377). Fix via a sharedcancelAndFinalizehelper across all four paths: afterawait backend.prompt(...),if (ac.signal.aborted)→ writestatus='cancelled', publish the terminalmessage_completeframe, emit idle, return; in each catch finalize the message withWHERE status='streaming'(idempotent), mappingAbortError → cancelledvserror → failed. - Rationale: 4-way specialist consensus (on-call B1, behavioral B1, architect A1, junior). The frontend and all four backends already honor the abort signal correctly (PTY
child.killpty-dispatch.ts:159; warm-ACPsession/cancelwarm-acp.ts:318; opencodesession.abortopencode-server.ts:775; claude-sdk interruptclaude-sdk.ts:209); the only missing link is the registry + export + route wiring. Shipping the abort wiring without the finalization fixes would convert a silent no-op into a new bug (cancelled turns recordedcomplete, or messages stuckstreaming), so the two are inseparable in one batch (on-call OCE-001/OCE-002, behavioral B2/B3). - Evidence:
routes/tasks.ts:110-148(cancel route callscancelPendingPermission+inference.cancelnative-only + DB cancelled; never reaches dispatcher);dispatcher.ts:316/655/991/1248(four privateacunreachable);cancelExternalTaskabsent anywhere (synthesis-input C1); finalization bugs atdispatcher.tscatch blocks + ~853/1122/1377 (synthesis-input C2); backend signal honoring cited above (scope-brief F1). - Rejected alternatives:
- Abort wiring only, defer the finalization fixes — rejected because wiring abort makes the
streaming/completecorruption reachable from the UI for the first time; deferring ships a new bug (synthesis-input C2, on-call/behavioral). - Recover stuck messages via the BooChat 5-min sweep — rejected because the sweep runs in a different process (BooChat) and does not see BooCoder's
agent_sessions/tasksfinalization (on-call OCE-001). - Pass the whole dispatcher into
registerTaskRoutes— rejected for over-coupling; a narrowExternalCancelFnis sufficient (architect A1).
- Abort wiring only, defer the finalization fixes — rejected because wiring abort makes the
- Specialist owner: on-call-engineer (resilience) with software-architect (registry shape).
- Revisit criterion: a fifth external backend is added whose abort contract differs from signal-based cancellation, or the warm-vs-one-shot worktree-cleanup split changes.
- Dissent (if any): none.
- Driven by rounds: R1.
- Dependent decisions: D-7 (F1 terminal state), D-8 (F1 frame-extension over new frame type), D-10.
- Referenced in plan: Implementation Approach (F1), Runtime Behavior, On-Call Resilience Posture, Decomposition and Sequencing, RAID Log (R1).
D-3: F2 prune scope — option A (prune-now-minimal), keep the load-bearing guard
- Question: How far does the tool-call-parser prune go — unexport the dead symbols only, or the full flag-gated retirement of the text-scrape fallback?
- Decision: Option A only. KEEP
extractToolCallBlocks+stripToolMarkupand their types (ToolCallExtraction,ParsedCall) — the load-bearing<invoke>-as-text guard. REMOVE only theexportkeyword (not the implementations) from the 8 zero-external-caller symbols:isPlaceholderArgValue,parseXmlToolCall,parseInvokeToolCall,partialXmlOpenerStart, and the 4 constsXML_TOOL_OPEN/CLOSE,INVOKE_TOOL_OPEN/CLOSE. Zero runtime effect; public export surface goes 11 → 4. - Rationale: The TS parser is dormant defense-in-depth but the
<invoke>-as-text path is the only guard for "tool call emitted as plain text" —experimental_repairToolCalldoes not cover that case, and the sidecar--jinjastate is confirmed only by a live probe, not pinned in-repo, so keeping the guard is correct (architect A2, confirms junior OQ-F2c). Unexporting is pure simplification with no behavior change; the relicense already removed the AGPL-dead exports, so there is no license pressure forcing the larger move. - Evidence: live consumers
stream-phase.ts:263-284(extractToolCallBlocks text-delta fallback),tool-phase.ts:122+error-handler.ts:25,106(stripToolMarkup); structured pathstream-phase.ts:285does all real work today; llama-swap--jinjaconfirmed ON by live probe of:8401only (scope-brief F2, synthesis-input C3). - Rejected alternatives:
- Option B — flag-gate the text-scrape fallback, validate native parsing on live qwen3.6 for one release, then delete — rejected (deferred) because there is no evidence qwen3.6 stopped emitting
<invoke>text on live and the sidecar--jinjastate is unconfirmed in-repo; deleting the only plain-text-tool-call guard on that basis is unsafe (architect/test-engineer R1). See Deferred (YAGNI). - Delete the 8 symbols' implementations outright — rejected because three of them (
parseXmlToolCall,parseInvokeToolCall,isPlaceholderArgValue) are called internally by the retainedextractToolCallBlocks; only theexportkeyword is dead.
- Option B — flag-gate the text-scrape fallback, validate native parsing on live qwen3.6 for one release, then delete — rejected (deferred) because there is no evidence qwen3.6 stopped emitting
- Specialist owner: software-architect.
- Revisit criterion: a documented multi-session live probe shows zero text-delta tool calls from qwen3.6 (then Option B reopens).
- Dissent (if any): none.
- Driven by rounds: R1.
- Dependent decisions: D-2 (F3 logger — safe because F2 keeps
extractToolCallBlocks), D-4 (F2 gate test). - Referenced in plan: Implementation Approach (F2+F3), Decomposition and Sequencing, Testing Strategy, RAID Log (R2), Deferred (YAGNI).
D-4: F2 fallback gate test — pin the untested guard before pruning
- Question: The
<invoke>-as-text fallback is currently exercised by no test; how do we prevent the prune from silently removing it? - Decision: Add a gate test before the prune: stub
streamTextto emit a text-delta containing a complete<invoke>block; assert the call lands inresult.toolCallsand the markup is NOT present inresult.content. The test must stay green through the prune and fail ifextractToolCallBlocksis ever removed from the text-delta path. - Rationale: Pruning around an untested load-bearing path risks a silent regression; the gate test converts D-3's "keep the guard" commitment into an enforced invariant (test-engineer T6).
- Evidence: untested fallback at
stream-phase.ts:263-284(synthesis-input C4, test-engineer T6). - Rejected alternatives:
- Prune without the gate test, relying on review — rejected because the fallback has no current coverage, so a future removal would pass CI silently (test-engineer T6).
- Specialist owner: test-engineer.
- Revisit criterion: the fallback path is intentionally retired under Option B (then this test is rewritten, not deleted).
- Dissent (if any): none.
- Driven by rounds: R1.
- Dependent decisions: none.
- Referenced in plan: Testing Strategy, Decomposition and Sequencing.
D-5: F6 stall-timeout via AbortSignal.any; no retry
- Question: How does BooChat detect and recover a hung llama-swap stream server-side, and does it retry?
- Decision: Wrap the
stream-phase.ts:261fullStreamloop with a per-chunk stall deadline. Create a localstallAc = new AbortController(), passeffectiveSignal = AbortSignal.any([signal, stallAc.signal])tostreamText, bump asetTimeout(STALL_TIMEOUT_MS = 90_000)on each chunk, clear it in the existingfinally. At the post-loop check (stream-phase.ts:337) testsignal?.aborted || stallAc.signal.abortedand throwAbortError(→handleAbortOrErrorwritescancelled). No retry atexecuteStreamPhase/streamCompletion. - Rationale: Today a hung stream relies entirely on the frontend 60s
discard_stalewatchdog with zero server-side guard; the 90s server stall-timeout closes that gap and reuses the existing abort/finalize path. Retry is deferred (YAGNI): a retry after a partial stream re-emits already-streamed deltas (state.accumulated+ livedeltaframes are non-idempotent), which is worse than the current behavior; at single-local-instance scale the user re-sending is the correct recovery (on-call, strong). - Evidence:
stream-phase.ts:261fullStream loop,:333abort check,:343usage; frontend 60sdiscard_staleis the only stall guard today (scope-brief F6, synthesis-input C6). - Rejected alternatives:
Promise.raceof the loop against a timeout — rejected in favor ofAbortSignal.any, which threads cancellation throughstreamTextand the existing finalize path cleanly (OQ-F6a, on-call).- Retry/backoff classifier (transient-5xx / stall) — rejected (deferred) because partial-stream re-emit is non-idempotent and llama-swap is a single local instance (synthesis-input YAGNI ledger). See Deferred (YAGNI).
- Specialist owner: on-call-engineer.
- Revisit criterion: llama-swap gains restart-in-place-with-clear-partial, or a second instance is added for failover (then retry reopens).
- Dissent (if any): none.
- Driven by rounds: R1.
- Dependent decisions: none.
- Referenced in plan: Implementation Approach (F6), On-Call Resilience Posture, Testing Strategy, RAID Log (R3), Deferred (YAGNI).
D-7: F1 terminal state for user Stop — cancelled, not failed
- Question: When a user hits Stop, what terminal
messages.statusdoes the finalized assistant message land in? - Decision:
cancelledfor a user-initiated Stop (AbortError);failedonly for a genuine thrown error in the catch path. - Rationale: A user Stop is a deliberate, non-error outcome;
MessageStatusalready includes'cancelled'and the web reducer can map it without a new enum value. DistinguishingAbortError → cancelledvserror → failedkeeps the human-inbox / failure surfaces honest (resolved OQ, on-call/behavioral). - Evidence:
MessageStatusincludes'cancelled'; reducer map pointCoderPane.tsx:299(synthesis-input F1 UX + resolved OQs). - Rejected alternatives:
- Record user Stop as
failed— rejected because it pollutes failure surfaces with deliberate user actions (resolved OQ F1 terminal state).
- Record user Stop as
- Specialist owner: behavioral-analyst.
- Revisit criterion: product decides a user Stop should count against a failure/alerting budget.
- Dissent (if any): none.
- Driven by rounds: R1.
- Dependent decisions: D-8, D-10.
- Referenced in plan: Implementation Approach (F1), Runtime Behavior.
D-8: F1 status surfacing — extend the existing frame, no new frame type
- Question: How does the cancelled terminal state reach the web reducer — a new WS frame type, or an extension of the existing one?
- Decision: Extend the coder
message_completeframe with an optionalstatusfield (Option A — minimal); map it in the reducer (CoderPane.tsx:299). No new frame type, so the cross-appWsFrameparity rule does not force a paired strict-union arm beyond the optional field. - Rationale: Adding a whole new frame type triggers the full cross-app parity dance (server
InferenceFrame/ws-frames.ts+ webWsFrame) for a single optional value already carried on a terminal frame; an optional field on the existing frame is the smaller change with the same observable result (UX agent). - Evidence: reducer map point
CoderPane.tsx:299; cross-app frame parity rule (CLAUDE.md Conventions; scope-brief cross-cutting constraints). - Rejected alternatives:
- New
agent_cancelledframe type — rejected because it forces a paired strict-union arm in two files for a single optional status value (UX agent, Option A vs B).
- New
- Specialist owner: user-experience-designer.
- Revisit criterion: a second distinct terminal sub-state needs carrying that does not fit
message_complete.status. - Dissent (if any): none.
- Driven by rounds: R1.
- Dependent decisions: D-10.
- Referenced in plan: Implementation Approach (F1), External Interfaces.
D-11: F9 retire :9502 SPA — delete the serve block, keep all API/WS routes
- Question: What exactly is removed when retiring the :9502 fallback SPA, and what must stay?
- Decision: Delete the
if (existsSync(webRoot))block inindex.ts(~269-289) which already no-ops when the dist is absent; keep the inline 404 handler ({error:'not found'}). Removeapps/coder/webfrompnpm-workspace.yaml, the coder build step, and the Dockerfile copy; remove the now-unusedfastifyStaticimport (verify it is only used there). KEEP all/api/coder/*REST + WS +/api/health+--mcproutes — CoderPane depends on them. Optionally add a 2-lineGET /redirect-to-BooChat (nofastifyStatic). - Rationale: Sam confirmed "I don't use 9502"; primary UI is CoderPane inside BooChat. OQ-F9a resolved: nothing probes
GET /on :9502 (health is/api/health; the compose healthcheck targets the boocode container, not the host-systemd coder), so 404-or-redirect at/is safe (architect A5, verified). - Evidence: serve block
index.ts~269-289;GET /unprobed (synthesis-input C8, OQ-F9a resolved); scope-brief F9 / DEFERRED #5 removal checklist. - Rejected alternatives:
- Keep the SPA — rejected; Sam greenlit removal and the build step is dead weight (scope-brief F9).
- Remove the REST/WS routes too — rejected because CoderPane inside BooChat depends on every
/api/coder/*route (architect A5).
- Specialist owner: software-architect.
- Revisit criterion: a standalone :9502 UI is ever wanted again (would be a fresh feature, not a revert).
- Dissent (if any): none.
- Driven by rounds: R1.
- Dependent decisions: none.
- Referenced in plan: Implementation Approach (F9), Operational Readiness, Decomposition and Sequencing.
D-12: F4/F5/F8 disposition under the standing override — document as Blocked, do not halt
- Question: The spec-maturity gate tripped on F4/F5/F8; the skill says recommend routing them out. Sam issued a standing override to plan everything. How are they recorded?
- Decision: Proceed with the plan but record F4, F5, F8 in a structurally separate BLOCKED tier with their exact blocking open question(s) and recommended resolution path, rather than halting synthesis. F4 → route to
plan-a-feature(hook-firing-in-unattended-mode premise UNVERIFIED + goose hook mechanism unknown). F5 → SDK capability check (pinned@opencode-ai/sdkexposes no compaction event arm); UI treatment (sentinel-row vs ephemeral-frame) stays disputed until the event is confirmed to exist. F8 → route toplan-a-feature(no line-selection infra exists, diff source ambiguous). These three do NOT block the ready cluster (F1/F2/F3/F6/F7/F9). - Rationale: The spec-maturity gate tripped with ≥5 spec-level findings (C9, C11, C12, C13, OQ-F4b, OQ-F8a) concentrated in F4/F5/F8 across junior, behavioral, and UX. Sam pre-acknowledged the WANT items would be planned more shallowly when choosing scope "everything we discussed," so the honest synthesis records them as Blocked with explicit reopen paths rather than fabricating plan-level resolutions or stalling the ready work.
- Evidence: spec-maturity gate TRIPPED (synthesis-input "Spec-maturity gate"); USER OVERRIDE STANDING (same section); blocking OQs OQ-F4a/F4b, OQ-F5a/F5b, OQ-F8a/b/c (synthesis-input Open Questions); claims C9, C11, C12, C13.
- Rejected alternatives:
- Halt synthesis and route F4/F5/F8 out before any planning — rejected because Sam's standing override directs the plan to proceed and document (synthesis-input gate section).
- Plan F4/F5/F8 at plan-level alongside the ready cluster — rejected because their core premises are unverified (F4) / capability-blocked (F5) / infra-absent (F8); plan-level decisions would rest on unproven assumptions (C9/C11/C13).
- Specialist owner: project-manager.
- Revisit criterion: the named blocking OQ for an item resolves (F4: hooks confirmed to fire unattended + goose format known; F5: SDK compaction event confirmed; F8: diff source chosen + line-selection approach specified) — then that item graduates to its own plan.
- Dissent (if any): none; the gate-trip is acknowledged rather than overridden silently.
- Driven by rounds: R1.
- Dependent decisions: none.
- Referenced in plan: Implementation Approach (Blocked tier), RAID Log (R4, R5, assumptions), Open Items, Deferred (YAGNI).