Skip to content

Wire the workflow runner into TCA and the CLI (063 B3) - #744

Merged
onevcat merged 10 commits into
mainfrom
feat/workflow-runner-wiring-b3
Aug 30, 2026
Merged

Wire the workflow runner into TCA and the CLI (063 B3)#744
onevcat merged 10 commits into
mainfrom
feat/workflow-runner-wiring-b3

Conversation

@onevcat

@onevcat onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Summary

063 B3 — Workflow Runner Wiring. Powers B2's engine on for CLI callers: prowl workflow run / status / done / cancel, the reducer-owned WorkflowRunsFeature, the live boundaries (idle wait, typed lines, profile launch with child-only PROWL_WORKFLOW_*, per-activation watchdogs, notify/close), admission preflight, and the done rendezvous. Record: docs-ai/063-agent-workflows/008-b3-runner-wiring.md (decisions W1–W6, Delivered, Verification).

  • Reducer (WorkflowRunsFeature): one FIFO executor per run performs B2's effects in machine order (instruction file exists before the pointer is typed; run.json writes never overtake each other); idle waits and watchdogs are cancellable observers; late/stale .launched abandons its record and closes the pane; successful launches remember their profile under B2's digest key; a restart scan marks leftover runs interrupted when repositories load.
  • done rendezvous (W1): the reducer answers only when the addressed activation leaves persistingdelivered / provisional succeed; persist failure → WORKFLOW_FAILED; cancel/skip/ended → STEP_NOT_EXPECTING; a disconnected client gets REQUEST_CANCELLED while the run continues.
  • Admission (W2): effective definition (id, then unique name), WORKFLOW_INVALID / WORKFLOW_DISABLED, source rules, bindings in role order (current / pick / launch with --role override → remembered → suggestion → Recommended), one run per pane (PANE_BUSY), pending record refusal (DISPATCH_PENDING), inputs/skips, frozen launch plans, layout + initial record before the reply.
  • Attribution (W3): caller pane's pending dispatch first, --run --step manual, ROLE_MISMATCH unless --force; agents dispatch-complete from a workflow pane → WORKFLOW_DELIVERY_REQUIRED.
  • Live launch (W6): A2 prepare with a placeholder prompt, then AgentProfileLaunchPlan.attachingWorkflow swaps in the kickoff prompt and carries PROWL_WORKFLOW_TOKEN/_RUN/_ROLE as PROWL_LAUNCH_WORKFLOW_<n> carriers the env line unsets for the child (verified from inside the child: only the three PROWL_WORKFLOW_* variables, no PROWL_DISPATCH_* / PROWL_LAUNCH_*).
  • CLI/contracts: prowl.cli.workflow.v1 gains run / status / cancel (run object) and done (run + delivery); executable schema, text renderers, docs/components/cli.md, docs-ai/013-prowl-cli/contracts/workflow.md, the prowl-cli skill recipe.

Found live and fixed in this PR

  • A launched author that never ran dispatch-complete still held its launch record; the self-initiated activation failed roleBusy, the machine fell back to the idle wait, and the run ended in an injection attention while status still advertised a waiting activation. Now: admission refuses such panes with DISPATCH_PENDING, the idle wait ends as a dispatchPending attention instead of spinning, and status reports only the activation done can address.
  • B2 watchdog policy: a turn_grace / idle_grace expiry that had seen activity cleared the flag and scheduled nothing; a freshly launched agent whose first detector working arrives after its hook turn-ended left the watchdog silent for good (no nudge, no attention). The expiry now re-arms the same grace (heuristic idle_grace still re-arms from the detector's next idle level); regression test + updated B2 pins.
  • B2 seam: WorkflowRunEvent.roleUnavailable (blocked / gone / no agent / foreign pending record during the idle wait → the injection-failed attentions).

Verification

  • make check, make build-cli, make test-cli-unit (233), make test-cli-smoke, make test-cli-integration (110), make build-app (0 warnings), make test (full app suite, 0 failures), workflow app suites (121 + 77).
  • Live in an isolated Debug instance (details in the record): b3-review end to end from a launched Claude Code pane — self-initiated brief, done - by caller ancestry, reviewer launched with the protocol block, agents wait --dispatch receipt on the activation, two CLI: dispatch a new task into an existing agent pane (re-dispatch) #733 re-dispatches (author, then reviewer), until on clean, notify, close; WORKFLOW_DELIVERY_REQUIRED refusal; a deliberately provisional delivery (delivery.state: provisional, status attention with the H14 actions, cancel); restart interruption (status from run.json: interrupted); b3-idle watchdog nudge after the fix.

Notes

  • R2a is not released with B3 but without C1 (release plan): attention states are status + cancel only until the panel ships.
  • The launch-pane display_name of a current role is the detected agent token (the app's DetectedAgent.displayName); C1 owns display polish.

https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z

Power B2's engine on for CLI callers: `prowl workflow run / status /
done / cancel`, the reducer-owned `WorkflowRunsFeature` with one ordered
effect executor per run, the live boundaries (idle wait on the #733
evidence rules, typed lines, profile launch with child-only
`PROWL_WORKFLOW_*` carriers, per-activation watchdogs, notify, close),
admission preflight, the `done` rendezvous that answers only after the
output is persisted, the dispatch-complete interception, the restart
scan, and the versioned payload, schema, docs, contract, and skill.

Found live and fixed here: a pane holding a pending dispatch record is
refused at admission (`DISPATCH_PENDING`) and ends an idle wait as
attention instead of looping; `status` reports only the activation
`done` can address; the B2 watchdog re-arms a grace whose expiry saw
activity instead of going silent when a launched agent's first detector
`working` arrives after its hook `turn-ended`.

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
Spell an activation's completion command only to the verified caller pane
that owns it (`run` no longer leaks a launch role's token to the launcher;
a manual or forced `done` gets none), fence a run's effect queue whenever
a transition revokes the in-flight invocation or ends the run so queued
injections stop typing after a cancel (and abandon the record an ignored
injection opened), refuse `agents dispatch-complete` for the activations
of runs that already ended, keep the #733 baseline for the whole idle wait
and let an exact `needs-input` outrank the detector (`WorkflowRoleWaitPolicy`),
reserve a launched pane until the reducer binds it, answer a self-initiated
`run` only once its activation record exists, and refuse duplicate request
ids with `REQUEST_CONFLICT`.

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — round 1 (neighboring pi reviewer)

8 findings (1 P0, 6 P1, 1 P2), all accepted and fixed in 6fb5686:

  • P0 run / done could spell another role's completion command (the token): completion is now spelled only for the verified caller pane's own activation; manual/forced deliveries and worktree-started runs get none. Tests aRunResponseNeverSpellsAnotherRolesCompletion, aManualDeliveryIsAnsweredWithoutTheNextActivationsToken.
  • P1 work queued before a cancel/skip/retry still ran: the run's effect queue is fenced on every revoking or ending transition, fenced effects are dropped one by one, .inject re-checks the fence between opening its record and typing, and an ignored .injectionSucceeded abandons its record. Tests revokingAnInFlightInvocationFencesTheQueue, anIgnoredInjectionAbandonsTheRecordItOpened, WorkflowEffectQueueTests.
  • P1 agents dispatch-complete could complete the not-yet-abandoned record of a cancelled run: refusal now covers terminal sessions. Test deliveryRefusalCoversTerminalRuns.
  • P1 the idle wait rebuilt the CLI: dispatch a new task into an existing agent pane (re-dispatch) #733 baseline every poll (a fresh exact turn-ended never counted while the screen showed working) and let the detector-idle stabilizer outrun an exact needs-input: extracted WorkflowRoleWaitPolicy keeps the arm-time baseline and checks exact needs-input first. WorkflowRoleWaitPolicyTests.
  • P1 a launch without expect left its pane unreserved until .launched reached the reducer: WorkflowPaneReservations, counted busy by admission.
  • P1 a self-initiated run replied before its activation record existed: the reply now goes through the rendezvous and waits for the record. Test aSelfInitiatedRunIsAnsweredOnceItsActivationIsOpen.
  • P2 duplicate request ids: REQUEST_CONFLICT, never enters the reducer. Test aDuplicateRequestIDIsRefusedWithoutEnteringTheReducer.

Round 2 (verification + fresh pass) is in progress.

…rvations

Fence only the effects that act on a pane or the worktree so a later cancel
never drops a delivered step's dispatch completion, evaluate the liveness
guard inside `deliverLine` on the same main-actor turn as the insertion for
every typed line, drop a native action's result when the run left meanwhile,
prune pane reservations against every run that ever bound the pane, and keep
a cancelled waiter's request id in flight until the reducer answers.

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — round 2

5 findings (0 P0, 4 P1, 1 P2), all accepted and fixed; round-1 fixes verified by the reviewer.

  • P1 batch-wide fence dropped already-committed bookkeeping (a delivered step's completeActivation could be fenced away by a later cancel): a fenced batch now skips only pane-/worktree-facing effects (WorkflowRunEffect.isRevocable) and still performs records, logs, completions, abandonments, close, notify. Test onlyPaneAndWorktreeFacingEffectsAreRevocable.
  • P1 check-to-use gap before typing (and no guard on no-expect / nudge lines): the liveness guard now runs inside deliverLine on the same main-actor turn as the insertion for every typed line; a stale answer returns the issuance and types nothing. Test aStaleLineReturnsItsIssuanceAndTypesNothing.
  • P1 running native action outlives a cancel: documented limitation (the handoff store's writes are atomic; the result is dropped when the run left meanwhile — liveness re-checked after execute).
  • P1 reservations pruned only against active bindings: pruned against every run that ever bound the pane. Test reservationsAreReleasedOncePaneWasBoundEvenByAFinishedRun.
  • P2 a cancelled waiter's request id was reusable before the reducer answered: ids stay in flight until resolve. Test aCancelledWaitersRequestIDStaysUnusableUntilTheReducerAnswers.

…rministic fence tests

- `close` is a revocable effect: a cancel that beats a queued close keeps the
  pane, and the live boundary refuses to close a pane another active run has
  bound since (the run id travels with the close). The unconfirmed close is
  recorded as decision W7.
- The executor consults the fence right before a record is issued and as the
  last main-actor operation before a native action starts; the machine's cancel
  logs an action that is still running, whose result is discarded.
- `FencingQueue` raises the real queue's fence on the n-th staleness check and
  resumes the test through a continuation; the fake terminal evaluates the
  guard the reducer supplied. No `Task.sleep` left in the reducer tests, and
  the `send { $0.x == y }` closures now assert.
- Spurious `await`s on synchronous main-actor closures removed from the
  executor.

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — round 3 (verification)

Round-2 fixes verified by the reviewer; 4 new findings (3 P1, 1 P2), all accepted and fixed.

  • P1 — a queued close outlived a cancel and could hit a pane another run had bound. Accepted. close is now a revocable effect (a fence from cancel / skip / retry drops it; the fence a run's own end raises precedes the batch that ends it, so a close in the completing batch still runs), the executor re-checks the fence on the close's own turn, and the live boundary refuses to close a pane an active run other than the caller has bound (WorkflowRunsFeature.State.activeSession(boundTo:), checked on the same main-actor turn as closeSurface). confirmation: .skip stays and is now a recorded decision (008 W7; dsl-spec close row and example updated): Ghostty's protected close asks whenever the pane's process is alive, which an idle agent's process always is — every workflow cleanup would pop a modal from the executor. Tests: aCloseStepRemovesThePaneBeforeTheNextLineIsTyped, aCancelThatBeatsAQueuedCloseKeepsThePane, anActiveRunOwnsAPaneATerminalRunStillLists, onlyPaneAndWorktreeFacingEffectsAreRevocable.
  • P1 — a native action could start after the run was cancelled. Accepted in part. The executor now consults the fence as its last main-actor operation before execute (no suspension between the batch check and that guard: the queue and activation clients are synchronous main-actor closures). What remains is the hop to the action's executor: a cancel that lands in that interval cannot stop the action; it runs to completion, the machine's cancel logs Step '<id>': its native action keeps running; the result will be discarded. (the "classified as in flight" you asked for), and the result is dropped. A lease that prevents startup would need the action's first write to happen on the main actor, which HandoffCoordinator does not do; documented as the limitation. Tests: aFenceBeforeANativeActionStartsRunsNothing, aCancelDuringANativeActionLogsThatItKeepsRunning.
  • P1 — a stale injection could issue a record nobody owns. Accepted as a hardening; the window does not exist on the main actor: openMessage (synchronous bridge), deliverLine (evaluates the guard and inserts on one turn), and the issuance's cancel are one main-actor turn, and for .openActivation the send(.injectionSucceeded) reduces synchronously, so the record is bound or abandoned before any socket request can reach the store. The executor now also checks the fence right before issuance, so a fence raised after the batch check opens no record at all. Test: aFenceBeforeTheIssuanceOpensNoRecord.
  • P2 — the stale-line test forced its answer and slept. Accepted. FencingQueue wraps the real WorkflowEffectQueue and raises the fence on the n-th staleness check (the way a cancel reducing on that turn would), then resumes the test through a continuation; the fake terminal now evaluates the guard the reducer supplied and records its answer. aFenceOnTheTypingTurnReturnsTheIssuanceAndTypesNothing asserts the guard answered false, the issuance was returned, nothing was typed — no Task.sleep.

@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 1dede20 (workflow suites: 105 passed; make check clean; 0 warnings in the changed files). Round 4 (verification) requested.

…on logs, lazy scan clock

- The close boundary closes a pane only when the run is its most recent
  binder, whatever that binder's status: a later run that took the pane and
  ended keeps it (`WorkflowRunsFeature.State.latestBinder(of:)`, W7 amended).
- The machine no longer guesses at cancel whether a native action is running;
  the executor logs what happened ("not started; the run had moved on" from
  the pre-start guard, "finished / failed after the run moved on" after a
  late return). The action runner is injected (`workflowActionExecutor`) so a
  test can hold an action open across a cancel.
- `markInterruptedRuns` reads the clock only for a record it marks: the eager
  `@Dependency(\.date)` read on every `repositoriesChanged` failed the
  AppFeature suites in CI (no clock override there).

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — round 4 (verification)

Round-3 fixes verified by the reviewer (including the fence-before-enqueue ordering that keeps a completing batch's close live); 2 new findings (1 P1, 1 P2), both accepted and fixed.

  • P1 — a completing run's queued close could still hit a pane a later run had bound and kept after ending. Accepted. The ownership check looked at active runs only; a later run that took the pane and then ended (keeping it, as cancel promises) left no owner. The boundary now closes only when the caller is the pane's most recent binder, whatever that binder's status: WorkflowRunsFeature.State.latestBinder(of:) orders bindings by run start (current / pick bind at admission, a launch pane cannot have been held by an earlier run), evaluated on the same main-actor turn as closeSurface. This is the lease you asked for, derived from state that is already kept rather than a new generation counter. 008 W7 amended. Test: theLatestBinderOwnsAPaneWhateverItsStatus (earlier run completed → later run bound → later run cancelled → the pane is still the later run's).
  • P2 — cancel claimed the action "keeps running" even when the pre-start guard had stopped it. Accepted. The machine no longer guesses at cancel time; the executor writes the definitive line: the pre-start guard logs Step '<id>': native action '<action>' not started; the run had moved on., and a late return logs … finished after the run moved on; result discarded. (or … failed after the run moved on (…); ignored.). The action runner is now injected (workflowActionExecutor), so aNativeActionThatOutlivesTheRunIsDiscardedAndLogged holds an action open across a cancel with a gate and asserts the late-finish line and no not started line; aFenceBeforeANativeActionStartsRunsNothing asserts the opposite pair.

Also fixed on the way: the full make test caught AppFeatureTerminalLayoutRestoreTests/scenePhaseInactiveSavesLayoutSnapshotAfterRestoreConsumed failing because markInterruptedRuns read the date dependency for every scan (no clock override in that AppFeature test); the clock is now read only for a record that is actually marked.

@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 5215cd5. The CI failures on every earlier push (the same 9 AppFeature* tests, 52–84 s each) had one cause — markInterruptedRuns read the date dependency on every repositoriesChanged and those AppFeature tests override no clock — which this commit removes (the clock is read only for a record that is marked); the two suites pass locally now. Workflow + AppFeature suites: 134 passed, 0 warnings in the changed files; full make test green apart from AppFeatureCommandPaletteTests/copyPathWritesWorktreePathToPasteboard, which reads the shared system pasteboard and is unrelated to this PR. Round 5 (verification) requested.

… at the batch check

- Pane ownership no longer derives from `run.startedAt` (neither monotonic
  nor total): the reducer records `paneOwners[surfaceID] = runID` when a run
  is admitted and when a launch is taken up, and the close boundary compares
  run ids only.
- A `.runAction` the executor skips at the batch check writes the same
  "not started; the run had moved on" line to the run log as the pre-start
  guard does.

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — round 5 (verification)

Round-4 fixes verified by the reviewer; 2 new findings (1 P1, 1 P2), both accepted and fixed.

  • P1 — startedAt is not a sound binding order. Accepted. Ordering by a wall-clock Date had no tie-breaker and no monotonicity guarantee. The reducer now records the owner itself: WorkflowRunsFeature.State.paneOwners[surfaceID] = runID, written synchronously when a run is admitted (.startedcurrent / pick bind then) and when a launch is taken up (.launched that the machine accepted), never from a clock; the close boundary compares run ids only. latestBinder(of:) is gone. Tests: theMostRecentBindingOwnsAPaneWhateverTheClockSays (the later admission carries an earlier clock reading and still owns the pane after both runs ended) and the launch take-up assertion in aRunPerformsItsEffectsInMachineOrderAndAnswersDoneAfterPersistence.
  • P2 — an action skipped at the batch check left no not started line in log.md. Accepted. The executor's batch check now appends the same definitive line when the effect it skips is a .runAction. Test: anActionSkippedAtTheBatchCheckIsLoggedAsNotStarted (fence on the first check).

@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 41e0db0 (workflow + AppFeature suites: 135 passed, 0 warnings in the changed files). Round 6 (verification) requested.

…a run ever owned

A relaunch drops the old pane from the role's binding before the replacement
is taken up, so pruning reservations against the runs' current bindings kept
that pane reserved for as long as it lived and refused it to later runs as
PANE_BUSY. Admission now prunes against `paneOwners.keys`.

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — round 6 (verification)

Round-5 fixes verified by the reviewer; 1 new finding (P1), accepted and fixed.

  • P1 — a relaunch left its old pane reserved for good. Accepted. makeWorkflowAdmissionEnvironment pruned launch reservations against the runs' current boundSurfaceIDs, and a relaunch clears the old launch binding before the replacement is taken up, so the old pane — still live — stayed reserved and admission refused it as PANE_BUSY. everBound now comes from WorkflowRunsFeature.State.paneOwners.keys, which keeps every pane a run ever bound (admission-time current / pick and every accepted launch, relaunched-away ones included); a reservation therefore ends the moment its launch is taken up. Test: aRelaunchKeepsTheOldPaneAmongTheOwnedOnes (launch → agentGone → relaunch: the old pane stays in paneOwners, the binding holds only the new one).

@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 8bd3fd3 (affected suites: 69 passed, 0 warnings in the changed files). Round 7 (verification) requested.

…runing

The pruning rule moves into `WorkflowPaneReservations.pending(for:isLive:)`
(every pane a run ever owned, from `paneOwners`), and the relaunch test
reserves the first launch pane and asserts the reservation is gone after the
relaunch while the pane still lives. The record carries the final test
figures and the full-suite note.

Claude-Session: https://claude.ai/code/session_01MxJN8e4UKvCgzpqmh4PL7Z
@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review — round 7 (verification)

Round-6 fix verified by the reviewer: no P0/P1 remains. 1 P2, accepted and fixed.

  • P2 — the relaunch regression test did not exercise reservation pruning. Accepted. The pruning rule now lives in WorkflowPaneReservations.pending(for: WorkflowRunsFeature.State, isLive:) (derives everBound from paneOwners.keys; makeWorkflowAdmissionEnvironment calls it), and aRelaunchKeepsTheOldPaneAmongTheOwnedOnes reserves the first launch pane and asserts pending(for:isLive:) is empty after launch → agentGone → relaunch with the old pane still live — the assertion fails under the previous current-bindings rule.

Review summary for the PR: 7 rounds with the neighboring pi reviewer (rounds 3–7 verification rounds), 26 findings in total (1 P0, 16 P1, 9 P2), all accepted and fixed with regression tests; the last round found no P0/P1.

@onevcat

onevcat commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Fixed in cd538aa. Full make test on the previous head 8bd3fd3: 2892 passed, 0 failed; the reservation suites on this head: 49 passed, 0 warnings in the changed files.

@onevcat
onevcat merged commit 299b5a5 into main Aug 30, 2026
2 checks passed
@onevcat
onevcat deleted the feat/workflow-runner-wiring-b3 branch August 30, 2026 07:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant