Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .claude/plans/steer-while-running/tasks/01-engine-steer-inbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
id: 01-engine-steer-inbox
title: Engine steer inbox + Step 2a turn-boundary drain
blocked_by: []
status: done
branch: "plan-steer-while-running/01-engine-steer-inbox"
worktree: ""
issue: "512"
retries: 0
last_error: ""
accumulator: feat/steer-while-running
---

# Task brief

Implement the engine core of steer-while-running: a `Run`-scoped, in-memory
steer inbox on the agent loop, drained at the existing Step 2a turn-boundary
seam in `Engine.runLoop` (`engine/agent/loop.go`) — the same provider-legal
point `injectBackgroundNotice`/`drainPendingDelivery` use (history always ends
on a user prompt / tool result / nudge, never inside a tool_use pair). The
drained steer is recorded via `recordContinuation` (`RecordUserPrompt` + the
log-only `EvUserPrompt`), so it replays cleanly, survives
`session.ValidateToolPairing` and compaction, and rehydrates under ADR-0038.
This task is the injection + recording path only; the supersede/cancel
contract, the wire frame, and composition are later tasks. Offline tests via
`engine/adapter/mockllm` + `engine/adapter/memfs`.

## Acceptance criteria

- AC1.1: A steer enqueued while a run is mid-flight is recorded as an ordinary user message at the next turn boundary, appears in `Conversation.Messages`, and is replayed to the model on the following turn.
- verify: `TestSteer_InjectedAtTurnBoundary`
- AC1.2: The steer is recorded via `recordContinuation`, so the durable log captures it (log-only `EvUserPrompt`) and `eventsource.Fold` reconstructs the user turn across rehydration.
- verify: `TestSteer_RecordedAndRehydrated`
- AC1.3: The steer is appended *after* the settled tool results, never inside a `tool_use` pair — `session.ValidateToolPairing` holds on the post-injection history.
- verify: `TestSteer_PreservesToolPairing`
- AC1.4: The injected conversation keeps the message prefix byte-stable within the run (fragments assembled once, conversation appended), so the steer costs no prompt-cache rebuild beyond normal history growth.
- verify: `TestSteer_KeepsPromptPrefixByteStable`
- AC1.5: A run with an empty inbox drives exactly as before (the drain is a no-op) — no behavioural change when steer is unused.
- verify: `TestSteer_EmptyInboxNoOp`
- AC1.6: A steer drained at a boundary where compaction also fires is still replayed **verbatim** to the model on the following turn — the compaction recent-user back-snap keeps the just-drained user message out of the summarized head.
- verify: `TestSteer_SurvivesCompactionBoundary`
- AC1.7: A pending steer that trips the turn-limit / token-budget brake at the next boundary is recorded (durable history) and the run terminates `StopMaxTurns`/`StopBudget` normally; the recorded steer is addressed by the *next* run — it is never silently dropped and never bypasses `BeginTurn`'s bound.
- verify: `TestSteer_BrakeTerminalKeepsRecorded`
- AC1.8: A pending (un-drained) steer is **best-effort, in-memory, and lost with the run** on a process crash/session `Abandon` — the explicit honest contract; it is NOT persisted across restart.
- verify: `TestSteer_PendingSteerLostOnRestart`
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
id: 02-steer-supersede-contract
title: Steer supersede / cancel single-slot contract + drain echo
blocked_by: [01-engine-steer-inbox]
status: done
branch: "plan-steer-while-running/02-steer-supersede-contract"
worktree: ""
issue: "512"
retries: 0
last_error: ""
accumulator: feat/steer-while-running
---

# Task brief

Build the single-slot supersedable contract on the steer inbox from task 01.
At most one steer is pending per run: a second `EnqueueSteer` supersedes the
pending one; `CancelSteer` retracts a pending one; the boundary drain commits
it. Outcomes are an enum (`accepted`/`superseded`/`retracted`/`none_pending`/
`too_late`), not booleans (AGENTS.md "a third provenance ⇒ extract an enum").
The drain emits `EvSteer` carrying the committed text (the authoritative
echo). Engine is authoritative; the client renders the echoed truth. All
transitions safe under concurrent access (run goroutine drains while a wire
handler enqueues) — run under `-race`. Offline (`mockllm`/`memfs`).

## Acceptance criteria

- AC2.1: At most one steer is pending per run; a second `steer` supersedes the first, and only the superseding text is ever drained.
- verify: `TestSteer_SingleSlotSupersede`
- AC2.2: `steer_cancel` on a pending steer retracts it; the run drains nothing and the next turn sees no injected message.
- verify: `TestSteer_CancelRetractsPending`
- AC2.3: A `steer` that arrives *after* the boundary drained (or after the run went terminal) reports `too_late` / not-live — never silently drained into a finished turn.
- verify: `TestSteer_TooLateNotDrained`
- AC2.4: The drain emits `EvSteer` carrying the committed text; on a supersede race the echo reflects the version actually drained.
- verify: `TestSteer_DrainEmitsCommittedEcho`
- AC2.5: Supersede/cancel/drain are safe under concurrent access (the run goroutine drains while a wire handler enqueues) — no data race, no double-drain.
- verify: `TestSteer_InboxConcurrentSafe` (run under `-race`)
- AC2.6: The recorded steer, the streamed `EvSteer` echo, and the message the model sees are the same text — the recorded == streamed == model-view invariant holds.
- verify: `TestInvariant_recorded_streamed_model_view`
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
id: 03-steer-terminal-race-promote
title: Lost terminal race → auto-promote to a fresh follow-up run
blocked_by: [01-engine-steer-inbox]
status: done
branch: "plan-steer-while-running/03-steer-terminal-race-promote"
worktree: ""
issue: "512"
retries: 0
last_error: ""
accumulator: feat/steer-while-running
---

# Task brief

Service/loop behaviour: when a steer arrives for a session whose run is
already terminal (the user's "still running" belief lagged the real state),
auto-promote the steer into a fresh follow-up run through the existing hardened
run-entry funnel — `loadAndReopen` + the lease + recover-if-terminal
(`internal/adapter/server/service.go` `StartRunContent`) — never silently
dropping the text. Promotion reuses the reopen/recover discipline; it never
starts a run on an unrepaired terminal session. A live run → enqueue to its
inbox (task 01). The `too_late`/not-live signal is what the service promotes
on. Note: task 05 owns the wire frame; this task owns the Service routing
decision helper (`IsLive`/state → enqueue vs promote) and its tests, callable
from the wire handler.

## Acceptance criteria

- AC3.1: A steer arriving on a live run is enqueued to that run's inbox (not promoted).
- verify: `TestSteer_LiveRunEnqueues`
- AC3.2: A steer arriving after the run went terminal is promoted to a fresh follow-up run that drives through `loadAndReopen` (recovering the terminal state) and is *not* silently dropped.
- verify: `TestSteer_TerminalRacePromotes`
- AC3.3: Promotion reuses the run-entry funnel (recover-if-completed / interrupt-if-cancelled / recover-if-failed) — it never starts a run on an unrepaired terminal session.
- verify: `TestSteer_PromotionUsesRunEntryFunnel`
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
id: 04-steer-awaiting-queue-only
title: Steer while awaiting an ask — queue-only, drain on resume
blocked_by: [01-engine-steer-inbox]
status: done
branch: "plan-steer-while-running/04-steer-awaiting-queue-only"
worktree: ""
issue: "512"
retries: 0
last_error: ""
accumulator: feat/steer-while-running
---

# Task brief

Loop behaviour: while a run is parked `awaiting` on a permission or plan ask,
the loop is suspended in `PauseForApproval` and is not iterating `runLoop`, so
a steer cannot be drained until the ask resolves. The inbox holds the pending
steer across the parked state; `driveFromAwaiting`/`resumeFromAwaiting`
re-enters `runLoop`, whose Step 2a drain picks it up at the resumed run's
first turn boundary. The ask still requires an explicit verdict — the steer is
purely additive input (the "yes-and"/"no-and" case), never a verdict and never
a bypass. See the awaiting run-entry seam (AGENTS.md) and ADR-0069.

## Acceptance criteria

- AC4.1: A steer submitted while a run is parked `awaiting` is held, not rejected and not delivered early.
- verify: `TestSteer_AwaitingAskIsHeld`
- AC4.2: On verdict-driven resume, the held steer is drained at the resumed run's first turn boundary and replayed to the model.
- verify: `TestSteer_AwaitingResumeDrains`
- AC4.3: The held steer does not resolve, modify, or bypass the pending ask — the ask still requires an explicit verdict.
- verify: `TestSteer_AskStillRequiresVerdict`
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
id: 05-wire-grpc-steer-frame
title: Wire — gRPC Converse steer frame + ServerCapabilities + EvSteer projection
blocked_by: [02-steer-supersede-contract]
status: done
branch: "plan-steer-while-running/05-wire-grpc-steer-frame"
worktree: ""
issue: "512"
retries: 0
last_error: ""
accumulator: feat/steer-while-running
---

# Task brief

The wire surface. `contracts/proto/mecatl/v1/harness.proto`: add `Steer` +
`SteerCancel` messages, a `steer` / `steer_cancel` oneof arm on
`ConverseRequest` (alongside `prompt`/`resume_approval`/`cancel`/
`cancel_child`), a `ServerCapabilities.steer` bit, and the `EvSteer` echo on
the event stream. `task generate` regenerates `contracts/gen` (commit it; do
NOT hand-edit). Engine app: an `Engine.Steer`/`Run.EnqueueSteer` entry point
the gRPC handler drives, and the `EvSteer` projection. Server: the `Converse`
handler routes steer frames to the live run's inbox and relays the outcome to
the client. The capability bit is computed once in `Service.capabilities()`
(the single-composition-intersection invariant) and consistent across every
sink. gRPC-only v1 — no HTTP/SSE, no ACP (both deferred; see plan Out of
scope). This task widens the engine exported surface → `task api:update` +
`engine/CHANGELOG.md` note (Added = minor) is REQUIRED.

## Acceptance criteria

- AC5.1: A client can send a `steer` frame mid-run on the `Converse` stream and observe the injected message + the `EvSteer` echo on the same stream.
- verify: `TestSteer_ConverseFrameRoundTrip`
- AC5.2: `ServerCapabilities` advertises `steer` true when the feature is enabled and false/absent when not; an old server (no field) reads as false. The bit is computed **once** in composition (`Service.capabilities()`) and is consistent across every sink that surfaces it — never recomputed per sink.
- verify: `TestSteer_CapabilityAdvertised`; `TestSteer_CapabilitySingleSource`
- AC5.3: `task generate` keeps `contracts/gen` in sync; the new oneof arm does not change the behaviour of existing arms.
- verify: inspection — `buf generate` output committed; existing Converse control frames unchanged.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
id: 06-composition-and-tui-flip
title: Composition gate + capability flip + mecatui steer mode
blocked_by: [05-wire-grpc-steer-frame]
status: done
branch: "plan-steer-while-running/06-composition-and-tui-flip"
worktree: ""
issue: "512"
retries: 0
last_error: ""
accumulator: feat/steer-while-running
---

# Task brief

The composition gate + the only client-facing piece. The steer inbox is armed
by a posture/run-level knob resolved in `internal/app` (posture ladder), wired
through `app.Build` into the engine deps AND the `ServerCapabilities.steer` bit;
default ON. mecatui reads the `steer` capability: when present, `enter` mid-run
sends a `steer` frame and the queue card reflects the authoritative echoed/acked
state (pending → sent → too-late-promoted); when absent, keep the #228 local
merge-queue byte-identical. The client-side merge (join staged lines with a
blank line) happens BEFORE send so the engine sees one frame; `↑` edits the
combined message; the engine's single-slot supersede is the replace path for an
already-sent-but-un-drained steer (do NOT conflate the two). Golden files shift
for the card → `task test:golden`.

## Acceptance criteria

- AC6.1: With steer enabled (default), the capability is advertised and a mid-run input reaches the model without a separate follow-up run.
- verify: `TestSteer_EnabledByDefaultEndToEnd`
- AC6.2: With steer disabled, the engine inbox is inert and the TUI falls back to the client-side terminal queue unchanged.
- verify: `TestSteer_DisabledFallsBackToLocalQueue`
- AC6.3: The TUI renders the merged pending message as a single item, reflects the echoed/acked state honestly (pending vs sent vs promoted-after-race), and `↑` edits the combined message.
- verify: `TestSteer_TUIRendersAuthoritativeState` (+ `task test:golden` for the card)
- AC6.4: `go run ./cmd/mecademo` still prints a full offline session (no behavioural regression with steer unused).
- verify: demonstration — `go run ./cmd/mecademo`
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
id: 07-wire-routing-and-send-safety-repair
title: Repair — gRPC steer routing single-owner + concurrent-Send hazard + dead exports
blocked_by: [05-wire-grpc-steer-frame]
status: done
branch: "plan-steer-while-running/07-wire-routing-and-send-safety-repair"
worktree: ""
issue: "512"
retries: 0
last_error: ""
accumulator: feat/steer-while-running
repair_of: panel-review ship-blockers
---

# Task brief

Panel-review repair wave (round 1). Fix the ship-blockers the panel found in
the steer wire path. The accumulator `feat/steer-while-running` has tasks 01–06
merged. Branch `plan-steer-while-running/07-wire-routing-and-send-safety-repair`
off the CURRENT HEAD. Commit ONLY this task's files. Do NOT push.

## Ship-blockers to fix

1. **Concurrent `stream.Send` (gRPC stream is not goroutine-safe).** In
`internal/adapter/server/grpc.go`: the original run's `relayRun` (grpc.go:238)
Sends until the run's event channel CLOSES, but `closeSteer()` runs at the top
of `terminate` (`engine/agent/loop.go:2264`, `:2317`) BEFORE `drainChildren` and
the terminal `EvResult` drain. A steer landing in that window → `too_late` →
`handleSteerFrame` spawns `drivePromotedSteer` on a new goroutine (grpc.go:359)
whose `relayRun` (grpc.go:388) ALSO `stream.Send`s while the original relay is
still draining → two goroutines Send on one stream. FIX: serialize all Sends on
the Converse stream behind a single send mutex (or a single-writer lane) shared
by the original relay, the promoted relay, the RecoverNotice pre-send, and the
steer-ack interleave. No two goroutines may Send concurrently. Add a regression
test that a terminal-race steer during the drain window does not race the
original relay (`-race`).

2. **Lost-steer in the terminate window.** Same window: `Service.Steer`'s promote
path calls `StartRunContent` which hits `IsLive(id)` (the original run is still
registered) → `ErrFailedPrecondition`, so the ack is `too_late`/`promoted=false`
and the steer is DROPPED — contradicting the "never silently dropped" contract
(`harness.proto` Steer comment, `SteerAck.promoted`). FIX: make the terminate-
window steer reliably promote (e.g. `drivePromotedSteer` retries/waits until the
original run deregisters, bounded) OR hold the steer until the inbox closes AND
the run is no longer live, then promote. The honest contract: a steer is either
accepted into a live run OR promoted to a follow-up run — never dropped without
a loud ack AND a follow-up run.

3. **Single-owner routing (architecture HIGH).** `handleSteerFrame` calls
`run.EnqueueSteer` directly and only calls `Service.Steer` for the promoted
half; the `SteerCancel` arm calls `run.CancelSteer` directly with NO Service
path. The "live-enqueue vs terminal-race promote" decision has ONE owner:
`Service.Steer`. FIX: move the whole route into `Service.Steer` / a new
`Service.CancelSteer`; keep the grpc handler a dumb frame→Service mapper
(mirroring how `Approve`/`Cancel` route through the Service). The deferred
HTTP/SSE endpoint must be able to reuse this without re-deriving logic.

4. **Dead exported engine vars (api-gate ossification).**
`ErrSteerDisabled`/`ErrSteerPending` in `engine/agent` are exported, in
`engine/api/agent.txt`, CHANGELOG'd, but nothing returns them (their own doc
comments admit it). FIX: delete both vars, regenerate `engine/api/agent.txt` via
`task api:update`, and update `engine/CHANGELOG.md`. (Note: removing an
already-added symbol before release is still Added=minor net — it never shipped
in a release.)

5. **Optional-but-recommended (reuse MEDIUM): `steerInbox` → `chan string` cap 1.**
Replace the hand-rolled `sync.Mutex` + `pending/has/closed` bools in
`engine/agent/steer.go` with a `chan string` (cap 1) + a `chan struct{}` close
signal, using `select` for enqueue/cancel/drain. Shorter, race-free by design,
expresses the single-slot contract in the type system. Only do this if it stays
strictly internal (the exported `SteerOutcome` enum + `EnqueueSteer`/`CancelSteer`
signatures must not change) — otherwise skip and note why.

## Acceptance criteria (map to the plan's contract; the named tests prove them)

- AC-R1: All Converse-stream Sends are serialized; a terminal-race steer during the
drain window never races the original relay. verify: `TestSteer_NoConcurrentSendOnTerminalRace` (`-race`)
- AC-R2: A steer arriving in the terminate window is promoted to a follow-up run
(or accepted), never dropped with a bare `too_late` ack. verify: `TestSteer_TerminateWindowPromotes`
- AC-R3: The grpc handler routes steer + steer_cancel through `Service` (no direct
`run.EnqueueSteer`/`run.CancelSteer` in grpc.go). verify: `TestSteer_ServiceOwnsRouting`
- AC-R4: `ErrSteerDisabled`/`ErrSteerPending` are gone from the exported surface.
verify: `task api:check` green + `git grep -c ErrSteerDisabled engine/` reports 0
- AC-R5: `task lint`, `task test` (both modules, `-race`), `task api:update` all green.
verify: gates

Run `cd engine && go test ./agent/ -run TestSteer -race` and
`go test ./internal/adapter/server/ -run TestSteer -race` from root, then the full
gates. Respect the layering rule; the routing decision stays in the Service layer.
Report the branch, `git log --oneline`, tests added, and which of 1–5 you did NOT
complete (with why).
Loading