diff --git a/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md b/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md index bf000818..ef931d91 100644 --- a/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md +++ b/docs-ai/064-agent-completion-signals/003-s2-dispatch-wait-design.md @@ -20,8 +20,10 @@ provisional S2 command spelling in `002-s1-work-note.md`; S1's shipped `agents s It is the right state/signal wait source but cannot retain a task receipt after surface closure or answer whether a deterministic channel is still live. - `supacode/CLIService/AgentSignalCommandHandler.swift` already attributes cooperative - events through the socket peer's process ancestry. Dispatch completion reuses that trust - boundary rather than focus or caller-provided pane identity. + events to a surface through the socket peer's process ancestry. The current resolver stops + at the pane shell and does not prove which agent-process generation emitted an event. S2 + must preserve the surface trust boundary while adding generation-aware evidence matching; + dispatch completion separately reuses the surface attribution plus launch context. - `supacode/Domain/AgentProfile/AgentProfileLaunchPlan.swift` distinguishes child command environment from surface shell environment. Only the former is safe for a launch-scoped dispatch id. @@ -95,7 +97,10 @@ create --profile --prompt Launch failure removes the pending record and returns the existing typed launch error. The capacity check and id issuance happen before starting the runtime; binding the returned -surface and completing the create response remain one main-actor lifecycle transaction. +surface, an immutable launch-time `TabTarget` snapshot, and the dispatch evidence epoch, +then completing the create response, remain one main-actor lifecycle transaction. The +private target snapshot outlives surface metadata and is the source for every later +completion or wait response; it is not duplicated inside the public receipt tagged union. An unprompted `create --profile` remains an interactive launch without a dispatch. A caller that needs byte-for-byte prompt delivery can create an interactive pane and use the existing @@ -173,14 +178,40 @@ The terminal manager owns a separate dispatch store with a maximum of 256 record - creating a dispatch evicts the oldest terminal record first; - if all 256 records are pending, creation fails before launch with `DISPATCH_CAPACITY_EXCEEDED`; -- succeeded, failed, and gone receipts survive agent and pane closure; +- succeeded, failed, gone, and explicitly abandoned records are terminal and survive agent + and pane closure; - app restart clears the store, after which an old id returns `DISPATCH_NOT_FOUND`; - there is no disk persistence or TTL in S2. Completion is first-write-wins. Retrying the same id with the same outcome and summary is idempotent and returns the original receipt. A later completion with different content returns `DISPATCH_ALREADY_COMPLETED` and cannot mutate the recorded outcome seen by existing -or future waiters. +or future waiters. A completion that loses a race to a committed gone or abandoned state +returns `DISPATCH_ALREADY_TERMINAL`. + +S2 provides an explicit recovery action for an assignment that will never complete: + +```bash +prowl agents dispatch-abandon --dispatch \ + --reason "The worker returned to a shell without reporting completion." +``` + +The exact opaque id and a non-empty reason of at most 32 KiB UTF-8 are required. The command +does not stop the runtime, close its pane, or claim task failure; it only records the +coordinator's decision to stop waiting. The resulting immutable `abandoned` variant carries +`abandoned_at` and `reason`, makes current and future waits fail with +`DISPATCH_ABANDONED`, and becomes eligible for ordinary terminal-record eviction. An +identical retry is idempotent; a different reason or an already completed/gone record returns +`DISPATCH_ALREADY_TERMINAL`. Abandonment and worker completion are main-actor serialized, +and whichever commits first wins. Because abandonment is an explicit local administrative +action rather than a worker report, it is addressed by id and does not require caller-pane +ancestry. The opaque id acts as the local capability, and the retained record supplies the +in-memory audit trail until eviction or app restart. + +This recovery path is intentionally manual. A timeout leaves the receipt pending; detector +removal, a quiet shell, elapsed time, and process-probe ambiguity cannot abandon it. If the +worker may still be running, the coordinator must inspect or stop that worker separately +before choosing to abandon the dispatch. All store operations are main-actor serialized. Wait registration and initial lookup are one operation. A terminal transition stores one immutable receipt snapshot and resumes all @@ -210,6 +241,7 @@ Only the matching receipt can return task success. Idle state, screen content, a - succeeded receipt: successful command with the immutable summary; - failed receipt: nonzero exit and structured `DISPATCH_FAILED`, including the receipt; +- abandoned record: nonzero `DISPATCH_ABANDONED`, including its reason; - exact/high `needs-input`: nonzero `DISPATCH_NEEDS_INPUT`, receipt remains pending; - exact/high stable `turn-ended` without a receipt: nonzero `DISPATCH_INCOMPLETE`, receipt remains pending; @@ -217,12 +249,22 @@ Only the matching receipt can return task success. Idle state, screen content, a completion: `AGENT_GONE` backed by a retained gone record; - timeout: `WAIT_TIMEOUT` with the last observation and evidence. -Before surfacing `DISPATCH_INCOMPLETE`, the waiter gives a 300 ms coalescing grace period for -independently delivered receipt and signal events. This is an event-ordering allowance, not -screen stabilization. `needs-input`, matching `session-end`, and surface closure remain -immediate. A detector `.removed` event is diagnostic only for a pending dispatch: it cannot -write an irreversible gone receipt or end a strict dispatch wait. A completed receipt -returns immediately unless the caller explicitly requests stable screen evidence. +`turn-ended`, matching `session-end`, and surface closure are terminal-adjacent evidence that +can race with `dispatch-complete` over independent socket or lifecycle paths. They therefore +enter one 300 ms coalescing window. During that window a matching completion has priority: +it commits the succeeded/failed receipt and cancels the candidate transition. If the window +expires first, `turn-ended` surfaces `DISPATCH_INCOMPLETE` while leaving the record pending; +matching `session-end` or surface closure commits an immutable gone record. A completion +arriving after a gone record commits is rejected by first-write-wins. `needs-input` remains +immediate because it is an attention state, not an irreversible terminal record. + +Surface closure reaches the dispatch store directly and unconditionally from +`WorktreeTerminalManager.onSurfaceClosed`, independently of whether +`AgentObservationStore` has a record or subscribers. The observation store still receives +its ordinary close publication, but it is not the dispatch lifecycle bridge. A detector +`.removed` event is diagnostic only for a pending dispatch: it cannot write an irreversible +gone receipt or end a strict dispatch wait. A completed receipt returns immediately unless +the caller explicitly requests stable screen evidence. The receipt read is non-destructive: any number of concurrent or later waiters observe the same outcome. Socket-client disconnect or CLI cancellation must cancel the server-side wait @@ -279,19 +321,38 @@ The waiter evaluates semantic evidence, not merely `latestSignal`: | `exit` | active matching `session-end`, or `surfaceClosed` after subscription | stabilized detector removal | allowed for active `session-end` only | Each surface has an evidence epoch minted for the CLI dispatch launch (or the first stable -agent detection on an unpaired surface). The dispatch's first detected runtime and first -`session-start` attach to that epoch; a later stable agent-identity replacement or different -session starts a new epoch. The first non-empty signal session id may bind the current epoch; -a later non-empty mismatched id is retained for diagnostics but cannot match a condition. -Signals without a session id bind to the current epoch at ingestion. - -`turn-ended` activates idle evidence and invalidates blocked evidence; `needs-input` does -the inverse. Later `progress`, `session-start`, or normalized working activity invalidates -both terminal facts without letting heuristic activity itself satisfy a deterministic wait. -`session-end` invalidates both and activates exit. Therefore an old terminal snapshot may -return immediately only when it is from the current epoch and no later activity revision has -invalidated it. The terminal signal itself needs no multi-second delay; the dispatch-only -300 ms grace handles receipt/signal delivery reordering. +agent detection on an unpaired surface). Detection records an `AgentProcessGeneration` as +the agent PID plus its process start time; a stable replacement mints a new epoch even when +the detected agent kind is unchanged. The first generation observed after dispatch launch +attaches to the launch epoch rather than minting another one. + +S2 extends caller resolution to retain the process ancestry walked before the pane shell. +Surface attribution is enough to accept and retain a cooperative signal, but not enough to +let that signal satisfy an epoch-sensitive wait. Such a signal is match-eligible only when +its caller ancestry contains the current `AgentProcessGeneration`. A supplied session id +must additionally match the independently resolved current `AgentSession.id` when one is +available. If no active generation can be proved, the signal remains diagnostic and cannot +resolve generic or dispatch wait. + +The first eligible non-empty session id may bind the current epoch. After an epoch is bound, +only an eligible `session-start` carrying a different non-empty id that also matches the +independently resolved current session may advance to a new session epoch. Every other +mismatched-id event is diagnostic only. A sessionless signal may bind to the current epoch +only when caller generation is proved and that process generation has not crossed a prior +session-id replacement; after such a replacement, sessionless events stay diagnostic until +a new process generation begins. These rules make consecutive `session-start A` / `B` +deterministic and prevent a delayed child of A from satisfying B's waits. They deliberately +prefer losing an optimization over accepting stale evidence. + +An eligible `turn-ended` activates idle evidence and invalidates blocked evidence; an +eligible `needs-input` does the inverse. Later `progress`, `session-start`, or normalized +working activity invalidates both terminal facts without letting heuristic activity itself +satisfy a deterministic wait. +An eligible `session-end` invalidates both and activates exit. Therefore an old terminal +snapshot may return immediately only when it is from the current epoch and no later activity +revision has invalidated it. Diagnostic/unbound signals may appear in error evidence but +never drive a transition. The terminal signal itself needs no multi-second stabilization; +the dispatch-only 300 ms coalescing window handles receipt/signal delivery reordering. An exit-zero heuristic result means only that the requested observable condition matched. It never means the assigned task completed. The bundled `prowl-cli` skill teaches the @@ -323,6 +384,14 @@ capability: - no observed or verified deterministic source means an empty `channels` array. Auto fallback depends specifically on `verified_live` coverage, not array emptiness. +The command continues to enumerate detected-agent rows only. S2 does not turn `agents` into +a live-surface or bare-shell listing. A signal received before detector row creation may be +retained internally, but it is exposed on a later row only if it was already bound to that +row's current evidence epoch; unbound or stale evidence is not retroactively promoted. +Evidence-only surfaces remain addressable through explicit pane targets where the command +supports them, and dispatch state remains addressable by dispatch id. This keeps the JSON +row semantics stable while making the visibility boundary explicit. + S2 extends the observation record with `EvidenceChannelState` keyed by normalized source (`cooperative_cli`, hook runtime, transcript, process, or OSC), independently of `latestSignal`. Each entry records `observed|verified_live`, confidence, covered event kinds, @@ -342,14 +411,16 @@ rendering, and raw-socket fixtures change together. Existing errors omit the opt and remain wire-compatible. `create` adds a dispatch object alongside existing launch information. Wait success includes -the dispatch id, outcome, summary, target metadata, completion timestamp, waited duration, -and observation provenance. `DISPATCH_FAILED`, `DISPATCH_NEEDS_INPUT`, -`DISPATCH_INCOMPLETE`, `AGENT_GONE`, and `WAIT_TIMEOUT` use distinct nonzero errors. +the dispatch id, outcome, summary, immutable launch target metadata, completion timestamp, +waited duration, and observation provenance. `DISPATCH_FAILED`, `DISPATCH_ABANDONED`, +`DISPATCH_NEEDS_INPUT`, `DISPATCH_INCOMPLETE`, `AGENT_GONE`, and `WAIT_TIMEOUT` use distinct +nonzero errors. -New commands use `prowl.cli.agents.dispatch-complete.v1` and -`prowl.cli.agents.wait.v1`; additive `dispatch` and `signals` fields remain in the existing -`prowl.cli.create.v1` and `prowl.cli.agents.v1` responses. The shared dispatch record is a -strict tagged union. A completed variant is: +New commands use `prowl.cli.agents.dispatch-complete.v1`, +`prowl.cli.agents.dispatch-abandon.v1`, and `prowl.cli.agents.wait.v1`; additive `dispatch` +and `signals` fields remain in the existing `prowl.cli.create.v1` and +`prowl.cli.agents.v1` responses. The shared dispatch record is a strict tagged union. A +completed variant is: ```json { @@ -364,13 +435,15 @@ strict tagged union. A completed variant is: `pending` carries only `id/state/created_at`; `completed` additionally requires `outcome/summary/completed_at`; `gone` additionally requires `gone_at` and -`gone_reason: session_end|surface_closed` and carries no outcome or summary. Every variant +`gone_reason: session_end|surface_closed`; `abandoned` additionally requires +`abandoned_at` and `reason`. Gone and abandoned carry no outcome or summary. Every variant uses `additionalProperties: false`. The success payloads are fixed as follows: - dispatch completion: `target` (the existing `#/$defs/target` shape), `receipt` (completed record), and `replayed` (Boolean; true only for an identical retry); +- dispatch abandonment: `target`, `record` (abandoned variant), and `replayed`; - dispatch wait: `mode: dispatch`, `waited_ms`, `target`, `receipt` (completed record), `signals`, and optional `screen`; - generic wait: `mode: condition`, `condition`, `waited_ms`, `target`, `observation`, @@ -379,20 +452,25 @@ The success payloads are fixed as follows: The generic observation requires `status`, `raw_state`, `source`, `confidence`, `at`, and `revision`. A signal channel requires `source`, `state: observed|verified_live`, `confidence`, `events`, and `last_seen_at`, with optional `session_id`; `signals.last` uses -the existing signal payload shape and is omitted when absent. +the existing signal payload shape and is omitted when absent. When `last` is present, the +signals object also requires `last_binding: current|unbound|stale`. Detected-agent rows expose +only `current`; wait error diagnostics may carry the other two values so a coordinator never +mistakes retained but ineligible evidence for a matching event. A present screen object requires `status: captured|unavailable`, `requested_lines`, `source: detection`, and `waited_ms`. Captured evidence additionally requires `text`, `line_count`, and `stabilized`; unavailable evidence carries none of those fields. Optional members are omitted rather than encoded as null. -Error details are strict mode-specific objects. Dispatch errors carry `mode: dispatch`, -`waited_ms`, optional target, the current dispatch record when known, and optional last -observation/signals. Generic errors carry `mode: condition`, `condition`, `waited_ms`, +Error details are strict mode-specific objects. Once `create` has returned a dispatch id, +the private binding always exists, so every known-dispatch error carries `mode: dispatch`, +`waited_ms`, the required immutable launch `target`, the current dispatch record, and +optional last observation/signals. Only lookup failures such as `DISPATCH_NOT_FOUND` lack a +binding and target. Generic errors carry `mode: condition`, `condition`, `waited_ms`, optional target, and optional last observation/signals. A failed receipt is -`DISPATCH_FAILED`, not a success payload. These shapes and their -`additionalProperties: false` schemas ship in the S2 normative command contracts before -handlers turn GREEN. +`DISPATCH_FAILED`, and an abandoned record is `DISPATCH_ABANDONED`; neither is a success +payload. These shapes and their `additionalProperties: false` schemas ship in the S2 +normative command contracts before handlers turn GREEN. ## Implementation boundaries @@ -403,26 +481,37 @@ evidence-channel state beside the existing observation store under parser, renderer, router, schema, and handler coverage through `ProwlCLI/`, `supacode/CLIService/Shared/`, `ProwlCLITests/`, and `supacodeTests/`. +`WorktreeTerminalManager` owns the direct lifecycle fan-out: surface close is delivered to +both observation and dispatch stores even when either store has no existing observation +record. Signal caller resolution also gains a generation-aware result backed by the pane's +current `PaneAgentState` process id and `ProcessDetection.processStartDate`; focus and +caller-claimed ids remain invalid trust sources. + The same PR updates the normative contracts under `docs-ai/013-prowl-cli/contracts/`, the current CLI and agent-detection manuals under `docs/components/`, and the bundled `prowl-cli` skill. S3 runtime adapter hook injection is explicitly excluded. ## Verification plan -- Dispatch store: issuance, binding, both outcomes, identical retry, conflicting retry, - two waiters, completion/lookup/eviction linearization, all-pending capacity, pane closure, - post-eviction not-found, and app-lifetime reset. +- Dispatch store: issuance, binding and immutable target snapshot, both outcomes, identical + retry, conflicting retry, two waiters, completion/lookup/eviction linearization, + all-pending capacity, explicit abandonment and replay, completion-versus-abandonment race, + pane closure, post-eviction not-found, and app-lifetime reset. - Launch: child-only id propagation, no surface-shell leakage, prompt protocol rendering, atomic cleanup on launch failure, unprompted launch parity, and new-CLI/old-app fail-closed behavior when a prompted response omits dispatch. - Completion ingress: missing context, caller ancestry mismatch, wrong pane, validation, summary UTF-8 bounds, and immutable receipt behavior. -- Dispatch wait: already completed, delayed completion, failed outcome, needs input, - terminal signal grace, exact session/surface exit, detector-removal non-terminal behavior, - timeout evidence, cancellation, and concurrency. +- Dispatch wait: already completed, delayed completion, failed and abandoned outcomes, + needs input, terminal signal grace, exact session/surface exit, detector-removal + non-terminal behavior, timeout evidence, cancellation, and concurrency. Deterministic + lifecycle coverage includes create -> no signal/detection/wait -> close -> first wait, + plus two-socket `session-end`/completion and surface-close/completion reverse ordering on + both sides of the 300 ms boundary. - Generic wait: initial snapshot, transition, post-baseline `changed`, exact/high gating, - session/epoch freshness, confidence thresholds, auto heuristic fallback, stable screen - evidence, overflow resnapshot, and target exit. + session/epoch freshness, delayed child A versus replacement B, consecutive + `session-start A`/`B`, sessionless evidence after replacement, confidence thresholds, auto + heuristic fallback, stable screen evidence, overflow resnapshot, and target exit. - Channel registry: per-source preservation, epoch invalidation, observed versus verified-live coverage, and hook observed → cooperative event → heuristic still blocked. - Real transport cancellation: framed wait request followed by direct peer close, plus a @@ -430,6 +519,10 @@ current CLI and agent-detection manuals under `docs/components/`, and the bundle waiting for the 600-second timeout. - Four CLI layers: parser, shared wire models, router/handler, text/JSON rendering, executable schema, raw socket fixtures, and current manuals/skill. +- Closed-surface payloads: succeeded, failed, gone, and abandoned records retain the exact + launch target snapshot after live terminal metadata is removed. +- Capacity recovery: 256 pending records reject another launch; explicitly abandoning one + creates an evictable terminal record and allows the next prompted create without restart. - Required gates before PR: CLI build, smoke and integration tests, format/lint, app tests, app build, and live prompted-profile checks for the paired route and heuristic fallback. @@ -446,7 +539,7 @@ current CLI and agent-detection manuals under `docs/components/`, and the bundle 6. Completion is first-write-wins with idempotent identical retries. 7. Terminal outcomes are explicitly `succeeded` or `failed`. 8. The store is memory-only, bounded to 256 records, and survives pane closure but not an - app restart. + app restart; pending is never evicted or expired automatically. 9. Generic wait uses deterministic evidence when present and honest heuristic auto fallback otherwise. 10. `wait --dispatch` is addressed only by dispatch id. @@ -455,7 +548,18 @@ current CLI and agent-detection manuals under `docs/components/`, and the bundle 12. The prompted CLI create dispatch path has no opt-out in S2; other internal prompt launchers do not inherit it implicitly. 13. Completion accepts only implicit launch context plus verified caller-pane ancestry. -14. `agents.signals` reports only current-epoch observed or verified-live channels. +14. `agents.signals` reports only current-epoch observed or verified-live channels on the + command's existing detected-agent rows; S2 does not enumerate evidence-only surfaces. +15. Surface attribution alone cannot make a signal epoch-sensitive. Sessionless cooperative + evidence must prove the current process generation and becomes diagnostic after a + same-generation session replacement. +16. `turn-ended`, matching `session-end`, and surface closure share a 300 ms receipt-priority + coalescing window; surface closure reaches the dispatch store directly, regardless of + observation-store state. +17. A coordinator may explicitly terminalize an unrecoverable pending assignment with + `dispatch-abandon --dispatch ... --reason ...`; this does not stop or fail the worker. +18. A private dispatch binding retains the immutable launch target snapshot for all later + completion, wait, gone, and abandonment responses. ## External review disposition @@ -464,11 +568,19 @@ closed but several implementation contracts were not. S2 adopts its cancellation deterministic matching/freshness, channel registry, exact-screen grammar, old-app fail-closed, eviction linearization, schema, and scope findings. -The remedies are intentionally narrower than two suggested alternatives: cancellation is +The follow-up review at `f4a31c8a` identified six remaining lifecycle and visibility gaps. +All six are accepted at the contract level: close now fans out directly to the dispatch +store; cooperative evidence gains process-generation proof and an unbound diagnostic state; +terminal evidence uses receipt-priority coalescing; `agents --json` explicitly retains its +detected-row boundary; private bindings retain launch targets; and pending capacity has an +explicit abandonment path. + +The remedies are intentionally narrower than several possible alternatives: cancellation is driven by actual peer EOF rather than an explicit cancel request that cannot survive process -death, and detector `.removed` never creates a dispatch gone receipt, avoiding a brittle -binding to a foreground-probe PID. Exact matching `session-end` or surface closure owns that -terminal transition. +death; `agents` does not begin enumerating bare shells; and detector `.removed` never creates +a dispatch gone receipt or auto-abandons pending work. Only a coalesced, matching +`session-end`, exact surface closure, or explicit coordinator abandonment owns those terminal +transitions. No owner-level product choice remains open for S2. The review-frozen transition, transport, compatibility, and payload rules above are implementation acceptance criteria; internal type