From 4853e69081a89ab4466ab8fe4380866972229647 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Mon, 6 Jul 2026 23:29:00 +0000
Subject: [PATCH 01/80] fix(executors): truthful executor rows, startup
reconciliation, resume-safe cleanup (#1597)
---
REQUIREMENTS.md | 264 ++++++++++++++++++++++++++++++++++++++++
SPEC.md | 311 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 575 insertions(+)
create mode 100644 REQUIREMENTS.md
create mode 100644 SPEC.md
diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md
new file mode 100644
index 0000000000..1055485c0a
--- /dev/null
+++ b/REQUIREMENTS.md
@@ -0,0 +1,264 @@
+# Requirements
+
+> Source: GitHub issue [#1597](https://github.com/kdlbs/kandev/issues/1597)
+> — "Executor-row desync persists on v0.73.0". Follow-up to #1585
+> (partially fixed by #1587); supersedes the closed #1594.
+>
+> Note: the issue's framing is partly out of date. The `ready` status and its
+> promotion path already shipped in v0.73.0 (via #1587), so re-landing #1594 is
+> not the fix. The real defect is architectural — see the problem statement.
+
+## Problem statement §req:problem-statement
+
+An operator runs Kandev **headless as a long-lived service** (`kandev --headless`
+under systemd, Homebrew linux-x64), driving resumable agent-mode sessions over
+many hours and across restarts. The executor is local/standalone (`local_pc`):
+process tree `kandev → kandev __backend → agentctl → claude-agent-acp → claude`.
+
+**The symptom that hurts, in the operator's words:** *pause an agent, and you
+can't resume it.* When the operator stops/pauses a running agent turn and then
+tries to continue by sending a new message, the message does not get through —
+the session still looks "busy"/running, and the only way out is to **cancel and
+restart the whole Kandev service**. (This was reproduced live during this very
+investigation: a session that appeared to be "running" refused a new message
+until the service was restarted.) For a headless server this is severe — a
+routine pause silently wedges a session, and recovery means bouncing the process
+that every other session depends on.
+
+**The mechanism underneath:** Kandev keeps one row per session in the
+`executors_running` table, meant to mirror the live processes. It doesn't. On
+v0.73.0, after normal use, the rows drift from reality and the bad rows pile up:
+
+- **Every row reports `pid = 0`** for local runtimes — live `agentctl`/`claude`
+ processes exist, but no row references them, so nothing can tell a live
+ session from a dead one.
+- **Rows sit at `starting`/`prepared`** with `pid = 0` and `last_seen_at = NULL`,
+ and the accumulating backlog grows across restarts (~50 → 77).
+- Because the table can't be trusted, the machinery that reads it — cancel
+ cleanup, startup reconciliation, and the wedged-session self-heal — makes
+ wrong decisions, which is how a pause can leave a session unresumable.
+
+**Why this started when it did (the architectural root cause):** ADR 0025
+("Runtime Cleanup Uses `executors_running`", accepted 2026-06-22, shipped in
+#1465 / v0.66.0) **promoted `executors_running` to the authoritative durable
+runtime inventory** — the source of truth for "which processes are alive and
+need stopping," used by task cleanup *and* startup reconciliation. Before that,
+those decisions were driven off `task_sessions`. The rows were *always* somewhat
+inaccurate for local runtimes (no real pid, no heartbeat), but **nothing
+load-bearing read those columns**, so it didn't matter. v0.66 made the table
+load-bearing **without giving its liveness columns a trustworthy producer**, so
+the pre-existing drift suddenly had consequences — and it compounds every
+restart.
+
+**Why the table drifts by construction:** it is written *only* at lifecycle
+events, in lockstep with the in-memory store (launch, ready, complete). There is
+**no producer** for a real local liveness handle, no heartbeat on
+`last_seen_at`, and restart reconciliation *preserves* a row's `status` without
+re-checking reality. So whenever a process dies **outside** a hooked event — a
+crash, a kill, a backend restart, an escalated cancel — the row keeps claiming a
+process that is gone, and nothing corrects it.
+
+Current solutions fall short because the v0.73.0 fix (#1587) addressed only the
+turn/session half (no dangling open turns, no sessions stuck `RUNNING`) and the
+`ready` promotion it added is ineffective for the headless/standalone case. The
+executor-row half — truthful liveness, correct cancel/resume, bounded backlog —
+is still broken.
+
+**Scope of this document (one theme, two layers).** The unifying problem is *the
+session lies about being busy*. That manifests at two layers with one root
+cause: the durable `executors_running` table (rows claim a process that is gone)
+and the in-memory turn/session "busy" signal (too coarse to tell "foreground
+generating" from "idle / waiting on background"). This REQUIREMENTS.md owns both;
+`/spec` and `/plan` slice them into shippable pieces.
+
+A confirmed concrete trigger of the "session refuses input" symptom (verified
+via `acpdbg` against the live `claude-acp` agent) is a Claude session that
+launched background work the busy signal failed to recognize — a **Monitor**
+watch or a **run_in_background Bash** shell — leaving the operator locked out
+while the foreground turn was actually idle. This is evidence for the existing
+requirement above, not a new one.
+
+## Success criteria §req:success-criteria
+
+Observable from the app and the live database, after normal multi-hour headless
+use and across restarts:
+
+1. **Pause, then resume, works without restarting the service.** After the
+ operator stops/pauses a running agent turn, sending a new message resumes the
+ same session with its context intact — no wedged "still running" state, no
+ service bounce required.
+2. **A running/ready session's row is truthful.** When a session has finished
+ launching and is between turns, its row reads `ready` (not stuck
+ `starting`/`prepared`) and carries a real, currently-alive liveness handle
+ and endpoint (`agentctl_port`, `last_seen_at`, and a live process reference)
+ that an operator can cross-check against the host — for the local/standalone
+ runtime, not only remote SSH.
+3. **A dead row is distinguishable from a live one** from the row alone, so
+ repair/prune can be automated safely.
+4. **A wedged session recovers on its own** — the existing self-heal path
+ becomes reachable in practice, without a manual restart or waiting on a UI
+ poll to happen to fire.
+5. **The backlog stops growing.** After a clean restart following heavy use, the
+ count of stale rows (rows whose process is dead) trends toward zero rather
+ than accumulating across restarts.
+6. **Restart makes rows true, not just sessions.** Reconciliation repairs live
+ rows to reflect reality and removes only rows whose session is truly
+ finished — it never leaves a row claiming a process that no longer exists.
+7. **No resumable session ever loses its resume ability.** No cleanup — on
+ cancel, on startup, or otherwise — deletes a row that backs a still-open
+ session or holds a `resume_token`; a session waiting for input before a
+ restart is still resumable with full context afterward.
+8. **The session accepts input whenever the foreground turn is idle.** When the
+ foreground agent turn has finished — or is only *waiting on a spawned
+ background task* — the session accepts a new message rather than reporting
+ "running/busy" and dropping or rejecting it. Kandev distinguishes "foreground
+ actively generating" from "idle / waiting on background." *(Same theme as #1:
+ the busy signal must be true.)*
+
+**Scope disposition (2026-07-06).** While this work was in review, upstream
+fixed the live-path root causes independently: #1600 (Monitor callbacks emitted
+content outside a prompt RPC, so the turn never completed and the session
+stayed "busy") and #1602 (resume reused executions whose ACP prompt path was
+dead even though the process looked alive). Criteria **#1, #4, #8** are
+therefore satisfied primarily by those upstream root-cause fixes (plus this
+branch's cancel-path queue drain for #1); this branch delivers **#2, #3, #5,
+#6, #7** (the durable-table half, which upstream did not touch). The
+finer-grained busy signal behind #8's "waiting on a spawned background task"
+clause — and mid-turn steering for agents that support it — is a follow-up
+product feature tracked separately; a working prototype lives on branch
+`archive/1597-full-six-batches`.
+
+**These criteria are the acceptance tests.** Each must be proven by a test that
+reproduces the symptom end-to-end, not merely asserted in isolation. Today's
+suite exercises the underlying mechanisms on happy paths (reconcile,
+cancel-unstick, resume, ready-persist) and in one place codifies the wedge
+itself as expected — `TestResumeSession_LiveAgentReturnsAlreadyRunning` requires
+a live-*looking* session to reject a new message, and
+`TestCancelAgent_LeavesQueuedMessageForManualDrain` requires a paused session to
+leave the next message undelivered. So none of the criteria above is currently
+guarded by a failing test, and some are contradicted by passing ones. The work
+starts by writing red characterization tests for each symptom (backend
+integration for the state model; Playwright e2e for the pause→resume and
+foreground/background operator flows), then fixing to green.
+
+## User stories §req:user-stories
+
+- **As an operator who paused an agent to redirect it**, I want to type a new
+ message and have the agent pick up where it left off, so that pausing is a
+ normal part of the workflow and not a way to permanently wedge a session that
+ forces me to restart the whole server. *(→ §req:success-criteria #1, #4, #7)*
+
+- **As an operator running Kandev headless for days**, I want the
+ `executors_running` table to reflect the processes actually running, so that
+ when the app (or I) read it, we get the truth about what is live and what is
+ dead. *(→ §req:success-criteria #2, #3)*
+
+- **As an operator on a long-lived server**, I want dead executor rows repaired
+ or cleaned up automatically instead of piling up, so that the backlog doesn't
+ grow without bound and hide real problems. *(→ §req:success-criteria #5, #6)*
+
+- **As an operator with sessions waiting for input**, I want those sessions to
+ survive a restart and stay resumable with history intact, and I want cleanup
+ to never delete a row I still need to resume, so that a routine restart or
+ automated cleanup can't cost me a conversation. *(→ §req:success-criteria #7)*
+
+- **As an operator whose agent kicked off background work**, I want to keep
+ talking to the session while that background task runs, so that a long
+ background job doesn't lock me out of the conversation the way a genuinely
+ in-flight turn would. *(→ §req:success-criteria #8)*
+
+## Quality attributes §req:quality-attributes
+
+- **Event-driven correctness first; reconciliation is a redundant backstop.**
+ The row must be made truthful by hooking every lifecycle transition (launch,
+ boot-ready, turn-complete, cancel including escalated cancel, process-exit /
+ crash, stop). A health/verify-against-reality pass exists *only* to catch what
+ events cannot — backend crash, OOM kill, orphaned process, dropped event — and
+ guarantees eventual consistency during those unusual cases. Polling must never
+ be the primary means of updating the table when there is an event to hook.
+- **Truthfulness / durability.** The executor-state table is a source of truth
+ the app relies on (ADR 0025); it must stay consistent with the live processes
+ through normal operation and across restarts.
+- **Data safety with a single source of truth.** Losing the ability to resume a
+ session is unacceptable. The remedy is an ironclad rule about what may be
+ deleted — *not* duplicating `resume_token` into a second table, which would
+ introduce the same divergence risk this effort is removing.
+- **Self-healing under long uptime.** The target is a service that runs for days
+ unattended; recovery from wedged/stale state must be automatic.
+- **Bounded growth.** No unbounded accumulation of dead rows (and, relatedly, no
+ unbounded accumulation of orphaned OS processes — see #1247).
+- **Runtime-aware liveness.** Liveness semantics differ by runtime: an SSH row's
+ process id lives on a remote host. Liveness/prune logic must respect the
+ runtime and never apply local-process checks to a remote row.
+- **No regression** of the v0.73.0 turn/session fix (#1587) or of the SSH
+ executor's existing remote-pid stop path.
+
+## Constraints §req:constraints
+
+- **Environment:** v0.73.0 baseline; Homebrew linux-x64; headless
+ (`kandev --headless`) long-lived systemd service; local/standalone (`local_pc`)
+ executor; Claude Agent SDK over ACP with per-session HTTP MCP.
+- **Do not overload the SSH `pid` semantics.** `executors_running.pid` is today
+ written only by the SSH executor and holds the agentctl pid **on the remote
+ host** (used to stop it over SSH). A local liveness handle must not silently
+ change what that column means for SSH rows; local-liveness checks must never
+ run against remote rows. Prefer an unambiguous local handle over reusing the
+ remote-pid field. *(Operator concern; confirmed in code.)*
+- **Do not duplicate `resume_token`.** Keep one source of truth. Guarantee
+ safety with an invariant instead: a row backing a non-terminal session, or
+ holding a `resume_token`, is **repaired in place, never deleted**; only rows
+ confirmed terminal/dead may be pruned. *(Operator decision.)*
+- **PID scope — real and live, not a per-session redesign.** "Liveness handle
+ populated" means a real, currently-alive process reference sufficient to
+ detect and clean dead rows (the process Kandev already spawns is acceptable);
+ it does **not** require building a new per-session runtime handle. *(Operator
+ decision; most-robust-without-overbuilding.)*
+- **ADR 0025 stays.** `executors_running` remains the authoritative runtime
+ inventory; the fix makes that table trustworthy rather than reverting the
+ decision. A superseding/for-record ADR update may be warranted.
+- **Backend conventions apply:** SQLite column changes via idempotent
+ `ADD COLUMN` migrations; event-publishing and goroutine-ownership rules in
+ `apps/backend/CLAUDE.md` hold; every changed behavior needs regression tests.
+
+## Priorities §req:priorities
+
+Ordered by operator impact. Scope confirmed as the **full** fix (complete the
+event hooks + populate liveness + reconcile), not a single slice.
+
+**Must have**
+
+1. **Characterization tests first (red before green).** Land failing tests that
+ reproduce each symptom — stuck/accumulating rows, `pid=0` for local runtimes,
+ pause→resume wedge, and foreground-idle-while-background-running rejecting
+ input — before changing behavior. Existing tests that codify the wedge
+ (`TestResumeSession_LiveAgentReturnsAlreadyRunning`,
+ `TestCancelAgent_LeavesQueuedMessageForManualDrain`) are updated to the
+ corrected contract. *(§req:success-criteria — all)*
+2. **Pause → resume works without a service restart.** Cancel/pause leaves the
+ session and its row in a state a new message can resume; the operator never
+ has to bounce the service to recover a paused session.
+ *(§req:success-criteria #1, #4, #8)*
+3. **Complete the event hooks so the row is truthful in the common case.** Every
+ lifecycle transition (launch, boot-ready, turn-complete, cancel + escalated
+ cancel, process-exit/crash, stop) leaves the row's `status`, endpoint, and a
+ real local liveness handle (`last_seen_at`, live process reference) correct —
+ for local/standalone, not only SSH. *(§req:success-criteria #2, #3)*
+4. **A truthful, fine-grained busy signal.** The session's "busy" state reflects
+ whether the foreground turn is actively generating, so input is accepted when
+ it should be. *(§req:success-criteria #8)*
+5. **Reconciliation as a redundant backstop.** On startup (and, where events
+ can't cover it, periodically) verify rows against reality: repair live rows,
+ prune only rows confirmed terminal/dead, and **never** delete a row backing a
+ non-terminal session or holding a `resume_token`. The backlog stops growing.
+ *(§req:success-criteria #5, #6, #7)*
+
+**Nice to have**
+
+6. Reduce related orphaned-process accumulation (#1247) by using the real
+ liveness handle to identify and reap dead agent processes. *(Deferred
+ without code: #1247's measured evidence is live idle sessions — the
+ existing idle-instance reaper's job — not dead trees under deleted
+ worktrees, which have not been observed. A working dead-tree reaper
+ lives on branch `archive/1597-full-six-batches` if evidence appears.)*
+7. Record the "table is trustworthy / event-primary, reconcile-redundant"
+ decision as an ADR update alongside/superseding ADR 0025.
diff --git a/SPEC.md b/SPEC.md
new file mode 100644
index 0000000000..b855226f92
--- /dev/null
+++ b/SPEC.md
@@ -0,0 +1,311 @@
+# Specification
+
+> Solution-space document for GitHub issue
+> [#1597](https://github.com/kdlbs/kandev/issues/1597) — "Executor-row
+> desync persists on v0.73.0". Derived from
+> [REQUIREMENTS.md](./REQUIREMENTS.md). Each section cites the
+> `§req:` it satisfies.
+>
+> **One theme, two layers.** The unifying defect is *the session lies
+> about being busy*. It manifests in the durable `executors_running`
+> table (rows claim a process that is gone) and in the live prompt
+> path (a session that looks busy against a turn/process that is
+> actually finished or dead). ADR 0025 promoted `executors_running` to
+> the authoritative runtime inventory without giving its liveness
+> columns a trustworthy producer; this spec makes the table truthful
+> rather than reverting that decision.
+>
+> **Scope note (2026-07-06).** The live-path half was root-caused and
+> fixed upstream while this work was in review: #1600 (Claude Monitor
+> callbacks emitted content outside a prompt RPC, so the turn never
+> completed and the session stayed "busy" forever) and #1602 (resume
+> reused executions whose ACP prompt path was dead even though the
+> process looked alive). This spec therefore owns the durable-table
+> half plus pause→resume consistency. A finer-grained busy signal
+> (accepting input while the foreground turn is idle on background
+> work, and mid-turn steering for agents that support it) is a
+> follow-up product feature tracked separately; a working prototype of
+> the background-idle half lives on branch
+> `archive/1597-full-six-batches`.
+
+## Pause then resume §spec:pause-resume-recovery
+
+*Status: done — pausing a running turn settles the session to
+WAITING_FOR_INPUT and `Service.CancelAgent` now drains the message queue
+directly after reconciling, so a queued message is delivered on resume
+even on the escalated / dead-process cancel path where no `agent.ready`
+event fires (idempotent with the event-driven drain). The resume path
+distinguishes a genuinely-generating foreground turn (reject) from a row
+that looks live but whose process is gone (clean + relaunch via
+`resume_token`), reusing the runtime-aware liveness from
+§spec:truthful-executor-rows. Reachable from the pause button via the
+`agent.cancel` WS action. Corrected contracts:
+`TestCancelAgent_DeliversQueuedMessageOnResume` (replaces the wedge test
+`TestCancelAgent_LeavesQueuedMessageForManualDrain`) and
+`TestResumeSession_StaleExecutionCleansUpAndRetries` (now asserts the
+relaunch reuses the row's `resume_token`); Playwright coverage in
+`pause-resume-recovery.spec.ts`.*
+
+**Problem.** On v0.73.0 an operator who pauses a running agent turn
+cannot resume it: the next message is dropped or rejected because the
+session still looks "busy"/running, and the only recovery is to cancel
+and restart the whole headless service — which every other session
+depends on.
+
+**Behavior.** Pausing is a first-class, reversible step in the
+workflow:
+
+- When the operator stops/pauses a running agent turn, the session
+ settles into a state that accepts input (`WAITING_FOR_INPUT`) and its
+ `executors_running` row is left resumable — the process is not
+ orphaned and the row is not deleted while the session is still open.
+- When the operator then sends a new message, the system resumes the
+ same session with its context intact, using the row's `resume_token`,
+ without a service restart.
+- When a paused session's agent process is actually gone (crash, kill,
+ escalated cancel), the resume path detects the dead process, cleans
+ the stale execution, and relaunches — rather than reporting "already
+ running" against a process that no longer exists.
+- The queued-message contract is corrected: a message sent to a paused
+ session is delivered on resume rather than being stranded for
+ indefinite manual drain.
+
+**Corrected test contracts.** Two existing tests currently codify the
+wedge as expected behavior and are rewritten to the corrected
+contract, red before green:
+
+- `TestResumeSession_LiveAgentReturnsAlreadyRunning` — a session that
+ merely *looks* live must not permanently reject a resume; the
+ contract distinguishes "a foreground turn is genuinely generating"
+ (reject/queue) from "the row looks live but the process is gone"
+ (clean and relaunch). It no longer requires a live-*looking* session
+ to reject a new message unconditionally.
+- `TestCancelAgent_LeavesQueuedMessageForManualDrain` — a paused
+ session's queued message is resumable, not stranded; the test
+ asserts the message is delivered on resume.
+
+**Why.** The pause→resume wedge is the operator's headline pain
+(§req:problem-statement). The single-scalar `RUNNING` gate in
+`checkSessionPromptable` rejects input for any session whose DB state
+reads `RUNNING`, even after its process has died, and cancel does not
+reconcile the row's liveness — so a routine pause silently wedges the
+session. The corrected contract makes "busy" mean "a foreground turn is
+actually generating," which is the precondition every other section in
+this spec establishes.
+
+**Alternatives rejected.** Re-landing #1594 (re-adding the `ready`
+promotion) — rejected: the `ready` status already shipped in v0.73.0
+via #1587 and is ineffective for the headless/standalone case, so it is
+not the fix (§req:problem-statement note). Requiring the operator to
+cancel-then-restart — rejected: it is the very symptom being removed.
+
+**Tradeoffs.** Accepting input against a session that is technically
+still finishing an escalated cancel requires the resume path to probe
+real liveness (see §spec:truthful-executor-rows), adding a liveness
+check on the hot resume path in exchange for correctness.
+
+Satisfies §req:success-criteria #1, #4, #7, #8; §req:user-stories
+(operator who paused to redirect); §req:priorities must-have #1, #2.
+
+## Truthful executor rows §spec:truthful-executor-rows
+
+*Status: done — local/standalone rows carry a real host-local liveness handle
+(`executors_running.local_pid`), populated event-driven on every lifecycle
+transition (launch, boot-ready, turn-complete, cancel, process-exit/crash via
+MarkCompleted), with `status`, `agentctl_port`, and an observed `last_seen_at`.
+Delivered by the foundation batch (#1597).*
+
+**Problem.** For local/standalone (`local_pc`) runtimes every
+`executors_running` row reports `pid = 0` and a `last_seen_at` that
+only reflects the last lifecycle write, never a real liveness check.
+Rows sit stuck at `starting`/`prepared` with no endpoint. Because the
+row cannot distinguish a live session from a dead one, everything that
+reads it — cancel cleanup, reconciliation, self-heal — makes wrong
+decisions.
+
+**Behavior.** A row describes the real process it mirrors, for the
+local/standalone runtime and not only for SSH:
+
+- When a session has finished launching and is between turns, its row
+ reads `ready` (not stuck `starting`/`prepared`) and carries a real,
+ currently-alive local liveness handle and endpoint — a live
+ OS-process reference for the process Kandev spawned, plus
+ `agentctl_port`, `agentctl_url`, and a `last_seen_at` that reflects an
+ actual liveness observation — that an operator can cross-check against
+ the host.
+- Every lifecycle transition leaves the row correct: launch,
+ boot-ready, turn-complete, cancel (including escalated cancel),
+ process-exit / crash, and stop each update the row's `status`,
+ endpoint, and liveness handle. Event hooks are the primary producer;
+ no lifecycle transition leaves the row claiming a process that has
+ exited.
+- A dead row is distinguishable from a live one from the row alone
+ (status plus a liveness handle whose process can be probed), so
+ automated repair/prune can decide safely without external context.
+
+**Why.** ADR 0025 made this table load-bearing but v0.66 never gave its
+liveness columns a producer for local runtimes, so pre-existing drift
+gained consequences (§req:problem-statement). The remedy is
+event-driven correctness first: the row is made truthful by hooking
+every transition, because polling can never be as timely or as cheap as
+the event that already fires (§req:quality-attributes, event-driven
+correctness first). The liveness handle is a real, currently-alive
+process reference sufficient to detect dead rows — the process Kandev
+already spawns is acceptable; it is not a new per-session runtime handle
+(§req:constraints, PID scope).
+
+**Alternatives rejected.** Reusing the SSH `pid` column for a local
+process id — rejected: `executors_running.pid` holds the agentctl PID
+*on the remote host* for SSH rows, and overloading it would silently
+change that column's meaning and invite local-process checks against
+remote rows (§req:constraints; see §spec:runtime-aware-liveness). A
+periodic poll as the primary liveness producer — rejected: events are
+primary, polling is a redundant backstop only (§spec:reconciliation-backstop).
+
+**Tradeoffs.** Recording and probing a local process handle adds a
+column and a liveness call at each hooked transition; accepted because
+untrustworthy rows are the root cause of every downstream wrong
+decision.
+
+Satisfies §req:success-criteria #2, #3; §req:user-stories (operator
+running headless for days); §req:priorities must-have #3.
+
+## Runtime-aware liveness §spec:runtime-aware-liveness
+
+*Status: done — `RowProcessLiveness` judges a row by its `runtime`: local rows
+are probed by `local_pid`; SSH/remote and docker rows return Unknown so a
+host-local process check never runs against a remote pid. The SSH remote-pid
+stop path (`kill -0` over SSH) is unchanged. Delivered by the foundation batch
+(#1597).*
+
+**Problem.** Liveness semantics differ by runtime: an SSH row's process
+id lives on a remote host, while a local row's process lives on the
+Kandev host. A single local-process liveness check applied blindly
+would either corrupt SSH rows or regress the SSH remote-pid stop path.
+
+**Behavior.** Liveness and prune logic branch on the row's `runtime`:
+
+- Local/standalone rows are judged by the local liveness handle
+ (§spec:truthful-executor-rows); local-process existence checks never
+ run against SSH (remote) rows.
+- SSH rows retain their existing meaning: `pid` is the agentctl PID on
+ the remote host, and the remote-pid stop path continues to stop the
+ remote process over SSH unchanged.
+- Reconciliation and pruning evaluate each row against the correct
+ host for its runtime, so a remote row is never pruned because a local
+ process check failed and vice versa.
+
+**Why.** The operator explicitly flagged, and the code confirms, that
+`pid` is SSH-only remote semantics; local liveness must not overload it
+(§req:constraints, do not overload SSH pid). No-regression of the SSH
+executor's remote-pid stop path is a stated quality attribute
+(§req:quality-attributes).
+
+**Alternatives rejected.** A single runtime-agnostic liveness predicate
+— rejected because it cannot be correct for both a local process and a
+remote pid; runtime-awareness is inherent to the problem.
+
+**Tradeoffs.** Per-runtime branching in the liveness/prune paths adds
+conditional complexity; accepted as the minimum required to avoid
+cross-runtime corruption.
+
+Satisfies §req:success-criteria #2, #3; §req:quality-attributes
+(runtime-aware liveness, no regression); §req:constraints (SSH pid).
+
+## Resume-safety invariant §spec:resume-safety-invariant
+
+*Status: done (#1597 Batch 4)*
+
+**Problem.** Losing the ability to resume a session is unacceptable,
+yet multiple cleanup paths (cancel cleanup, startup reconciliation,
+on-demand stale cleanup, task teardown) delete `executors_running`
+rows, and any of them could delete a row that still backs an open,
+resumable session.
+
+**Behavior.** One ironclad rule governs deletion, enforced across every
+cleanup path:
+
+- A row backing a non-terminal session, or holding a `resume_token`, is
+ repaired in place and never deleted. Only rows confirmed
+ terminal/dead may be pruned.
+- A session waiting for input before a restart is still resumable with
+ full context afterward; no cleanup — on cancel, on startup, or
+ otherwise — removes a row it still needs to resume.
+- `resume_token` remains a single source of truth in
+ `executors_running`; it is not duplicated into a second table.
+
+**Why.** Data safety with a single source of truth is a hard
+requirement (§req:quality-attributes). Duplicating `resume_token` into
+a second table would reintroduce exactly the divergence risk this effort
+removes, so the guarantee is expressed as a deletion invariant rather
+than as redundant storage (§req:constraints, do not duplicate
+resume_token).
+
+**Alternatives rejected.** Copying `resume_token` into `task_sessions`
+so a deleted runtime row is survivable — rejected: two writers of the
+same fact is the divergence pattern being eliminated. Deleting rows
+eagerly and relying on relaunch — rejected: it can destroy the only
+handle to a resumable conversation.
+
+**Tradeoffs.** Cleanup paths must classify a row as terminal/dead
+before deleting, adding a check to each path; accepted because an
+erroneous delete costs the operator a conversation.
+
+Satisfies §req:success-criteria #7; §req:quality-attributes (data
+safety); §req:constraints (do not duplicate resume_token);
+§req:user-stories (operator with sessions waiting for input).
+
+## Startup reconciliation §spec:reconciliation-backstop
+
+*Status: done — startup reconciliation repairs live-looking rows whose
+local process is confirmed dead (status=stopped, `local_pid` cleared,
+`resume_token`/worktree preserved) and prunes only rows that fail
+`RowMustBePreserved`. Scoped deliberately to startup: events (the
+lifecycle hooks of §spec:truthful-executor-rows) are the primary
+producer, and every in-app transition — including process exit/crash,
+which the lifecycle manager observes — fires one. A periodic polling
+backstop was built, then removed before merge: it defended against
+failure modes (OOM kill, dropped event) that have not been observed,
+and the live-wedge causes it would have self-healed were fixed at their
+root upstream (#1600 completes idle async ACP turns; #1602 reaps
+prompt-dead executions on resume).*
+
+**Problem.** Nothing reconciled rows against reality at startup:
+the pass preserved a row's `status` without re-checking the process,
+and stale rows were pruned only on-demand per session. So after heavy
+headless use the backlog of dead rows grew across restarts (~50 → 77,
+measured in #1597).
+
+**Behavior.** The startup pass makes rows true:
+
+- On startup the system verifies rows against reality using
+ runtime-aware liveness: rows whose local process is confirmed dead
+ are repaired so they no longer claim a live process, and only rows
+ whose session is confirmed terminal/dead are pruned, subject to the
+ §spec:resume-safety-invariant.
+- Reconciliation never deletes a row backing a non-terminal session or
+ holding a `resume_token`.
+- After a clean restart following heavy use, the count of stale rows
+ (rows whose process is dead) trends toward zero rather than
+ accumulating across restarts.
+
+**Why.** Events are the primary producer; startup reconciliation exists
+because a backend restart is exactly the moment events could not have
+fired for whatever the previous process was doing when it died
+(§req:quality-attributes, event-driven correctness first). ADR 0025
+stays: the table remains the authoritative inventory and is made
+trustworthy rather than reverted (§req:constraints, ADR 0025 stays).
+
+**Alternatives rejected.** Polling as the primary means of updating the
+table — rejected: events are primary and must be hooked wherever they
+exist; polling never updates a row the moment an event could
+(§req:quality-attributes). A periodic in-run backstop pass — built and
+then removed: insurance for undemonstrated failure modes; can be
+restored from branch `archive/1597-full-six-batches` if a stale row is
+ever observed to appear *between* restarts. Reverting ADR 0025 to drive
+cleanup off `task_sessions` again — rejected: it reintroduces multiple
+paths deciding liveness, which ADR 0025 consolidated for good reason.
+
+Satisfies §req:success-criteria #5, #6; §req:quality-attributes
+(bounded growth, truthfulness/durability); §req:priorities must-have
+#5; §req:user-stories (operator on a long-lived server).
From b83115d8798d41cf3508a8631930e91f3ddbb300 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Wed, 15 Jul 2026 18:24:38 +0000
Subject: [PATCH 02/80] test(websocket): stabilize origin connection cleanup
---
.../gateway/websocket/handler_origin_test.go | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/apps/backend/internal/gateway/websocket/handler_origin_test.go b/apps/backend/internal/gateway/websocket/handler_origin_test.go
index c5b63e28c2..656d50b091 100644
--- a/apps/backend/internal/gateway/websocket/handler_origin_test.go
+++ b/apps/backend/internal/gateway/websocket/handler_origin_test.go
@@ -54,15 +54,16 @@ func dialWS(t *testing.T, wsURL, origin string) (*gorillaws.Conn, *http.Response
return conn, resp, err
}
-// waitForNoClients blocks until every connection has unregistered from the hub
-// so goleak sees clean read/write pumps at package teardown.
-func waitForNoClients(t *testing.T, g *Gateway) {
+// waitForClientCount blocks until the hub observes the expected client count.
+// Successful WebSocket dials can return before the handler goroutine has
+// registered the client, so tests must synchronize on the hub before cleanup.
+func waitForClientCount(t *testing.T, g *Gateway, want int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
- for g.Hub.GetClientCount() != 0 {
+ for g.Hub.GetClientCount() != want {
if time.Now().After(deadline) {
- t.Fatalf("hub still has %d client(s)", g.Hub.GetClientCount())
+ t.Fatalf("hub has %d client(s), want %d", g.Hub.GetClientCount(), want)
}
time.Sleep(5 * time.Millisecond)
}
@@ -110,8 +111,9 @@ func TestHandleConnection_AllowsTrustedOrigins(t *testing.T) {
}
t.Fatalf("upgrade with origin %q failed (status %d): %v", origin, status, err)
}
+ waitForClientCount(t, g, 1)
_ = conn.Close()
- waitForNoClients(t, g)
+ waitForClientCount(t, g, 0)
})
}
}
From 8e38117f0764a70752c52ffa56019e0315cd4bb1 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 11 Jul 2026 19:33:29 +0000
Subject: [PATCH 03/80] feat: accept input while a session's foreground turn
waits on background work
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A RUNNING session whose foreground turn is idle — held open only by spawned background work (a subagent Task, a run_in_background shell, or an active Monitor) — now accepts a new operator message instead of rejecting it as "agent is already running". The busy gate is narrowed to the foreground turn via an in-memory per-session activity tracker; recognition keys on normalized payload shape and defaults to busy, so non-Claude agents are unchanged. The substate is surfaced to the composer and a tri-state status indicator, delivered live over a session.activity_changed WS event and read into the boot/REST/WS DTOs (RUNNING-only, not persisted) so a fresh page-load is correct. Also fixes the ACP normalizer to recognize run_in_background:true shells.
Decision recorded in ADR-0035. Mid-turn steering (delivery into an actively-generating turn) is explicitly out of scope.
---
.../backend/cmd/mock-agent/background_test.go | 34 ++
apps/backend/cmd/mock-agent/handler.go | 55 ++
apps/backend/cmd/mock-agent/main.go | 1 +
.../server/adapter/transport/acp/monitor.go | 7 +-
.../server/adapter/transport/acp/normalize.go | 28 +-
.../acp/normalize_background_test.go | 80 +++
.../agentctl/types/streams/monitor_view.go | 45 ++
.../types/streams/monitor_view_test.go | 69 +++
.../backend/internal/backendapp/boot_state.go | 7 +
apps/backend/internal/events/types.go | 7 +
.../gateway/websocket/task_notifications.go | 1 +
.../orchestrator/event_handlers_streaming.go | 69 +++
.../foreground_activity_signal_test.go | 144 +++++
.../foreground_busy_signal_test.go | 576 ++++++++++++++++++
.../prompt_background_acceptance_test.go | 204 +++++++
apps/backend/internal/orchestrator/service.go | 13 +
.../internal/orchestrator/task_operations.go | 19 +
.../internal/orchestrator/turn_activity.go | 222 +++++++
apps/backend/internal/task/dto/dto.go | 40 ++
.../task/dto/foreground_activity_test.go | 87 +++
.../task/handlers/foreground_activity_test.go | 128 ++++
.../internal/task/handlers/task_handlers.go | 11 +-
.../task/handlers/task_http_handlers.go | 12 +-
.../task/handlers/task_ws_handlers.go | 4 +-
apps/backend/pkg/api/v1/task.go | 18 +
apps/backend/pkg/websocket/actions.go | 2 +
.../components/task/session-reopen-menu.tsx | 4 +-
.../web/components/task/sessions-dropdown.tsx | 4 +-
apps/web/e2e/tests/chat/busy-signal.spec.ts | 180 ++++++
.../e2e/tests/chat/mobile-busy-signal.spec.ts | 95 +++
.../domains/session/use-session-state.test.ts | 54 ++
.../domains/session/use-session-state.ts | 22 +-
.../session/session-slice.upsert.test.ts | 33 +
apps/web/lib/types/backend.ts | 31 +
apps/web/lib/types/http.ts | 17 +
apps/web/lib/ui/state-icons.test.tsx | 60 +-
apps/web/lib/ui/state-icons.tsx | 43 +-
.../web/lib/ws/handlers/agent-session.test.ts | 99 +++
apps/web/lib/ws/handlers/agent-session.ts | 32 +
...ine-grained-foreground-idle-busy-signal.md | 45 ++
docs/decisions/INDEX.md | 1 +
41 files changed, 2576 insertions(+), 27 deletions(-)
create mode 100644 apps/backend/cmd/mock-agent/background_test.go
create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_background_test.go
create mode 100644 apps/backend/internal/agentctl/types/streams/monitor_view.go
create mode 100644 apps/backend/internal/agentctl/types/streams/monitor_view_test.go
create mode 100644 apps/backend/internal/orchestrator/foreground_activity_signal_test.go
create mode 100644 apps/backend/internal/orchestrator/foreground_busy_signal_test.go
create mode 100644 apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
create mode 100644 apps/backend/internal/orchestrator/turn_activity.go
create mode 100644 apps/backend/internal/task/dto/foreground_activity_test.go
create mode 100644 apps/backend/internal/task/handlers/foreground_activity_test.go
create mode 100644 apps/web/e2e/tests/chat/busy-signal.spec.ts
create mode 100644 apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
create mode 100644 docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
diff --git a/apps/backend/cmd/mock-agent/background_test.go b/apps/backend/cmd/mock-agent/background_test.go
new file mode 100644
index 0000000000..336b7c0b9d
--- /dev/null
+++ b/apps/backend/cmd/mock-agent/background_test.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "testing"
+ "time"
+)
+
+// TestParseBackgroundDuration pins the /background argument parsing, including
+// the regression Copilot flagged on PR #3: a unit-bearing value like "1m" must
+// not be mangled into "1ms" by the bare-seconds fallback.
+func TestParseBackgroundDuration(t *testing.T) {
+ const def = 8 * time.Second
+ cases := []struct {
+ name string
+ cmd string
+ want time.Duration
+ }{
+ {"no argument uses default", "/background", def},
+ {"bare number is seconds", "/background 12", 12 * time.Second},
+ {"explicit seconds", "/background 20s", 20 * time.Second},
+ {"explicit minutes (regression: not 1ms)", "/background 1m", time.Minute},
+ {"explicit hours", "/background 2h", 2 * time.Hour},
+ {"explicit milliseconds", "/background 500ms", 500 * time.Millisecond},
+ {"unparseable falls back to default", "/background soon", def},
+ {"zero falls back to default", "/background 0", def},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := parseBackgroundDuration(tc.cmd, def); got != tc.want {
+ t.Fatalf("parseBackgroundDuration(%q) = %v, want %v", tc.cmd, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go
index 3df5e6d890..a1aaa48a00 100644
--- a/apps/backend/cmd/mock-agent/handler.go
+++ b/apps/backend/cmd/mock-agent/handler.go
@@ -270,6 +270,8 @@ func handlePrompt(e *emitter, prompt, model string) {
emitMarkdownShowcase(e, model)
case strings.EqualFold(cmd, "/sleep") || strings.HasPrefix(strings.ToLower(cmd), "/sleep "):
emitSleep(e, cmd)
+ case strings.EqualFold(cmd, "/background") || strings.HasPrefix(strings.ToLower(cmd), "/background "):
+ emitBackgroundWork(e, cmd)
default:
emitRandomResponse(e, cmd, model)
}
@@ -291,6 +293,59 @@ func emitSleep(e *emitter, cmd string) {
e.text(fmt.Sprintf("Slept for %s.", d))
}
+// emitBackgroundWork reproduces the fine-grained busy signal window
+// (ADR-0035): the foreground emits a line, spawns a
+// top-level subagent Task that holds the turn open, then goes IDLE for the
+// requested duration (default 8s) with no foreground output — the exact state
+// where the orchestrator narrows the busy gate so the composer accepts input
+// while the session still reads RUNNING. When the hold elapses the subagent
+// completes, the foreground resumes, and the turn ends (→ done).
+func emitBackgroundWork(e *emitter, cmd string) {
+ d := parseBackgroundDuration(cmd, 8*time.Second)
+
+ e.text("Kicking off background work; I'll keep going in the background.")
+
+ taskToolID := nextToolID()
+ e.startSubagentTool(taskToolID,
+ "Background exploration",
+ "Explore the codebase while the foreground stays idle",
+ "general-purpose")
+
+ // Hold the turn open with NO foreground output so the session stays in the
+ // background-idle substate for the whole window.
+ time.Sleep(d)
+
+ e.completeSubagentTool(taskToolID, "Background work finished", subagentResult{
+ agentID: "agent_e2e_background",
+ subagentType: "general-purpose",
+ durationMs: d.Milliseconds(),
+ totalTokens: 4242,
+ toolUseCount: 1,
+ })
+
+ e.text("Background work complete.")
+}
+
+// parseBackgroundDuration reads the optional duration argument of a /background
+// command, returning def when it is absent or unparseable. A value carrying an
+// explicit unit is honored as-is (`1m`, `500ms`, `2h`); a bare number is treated
+// as seconds (`8` → 8s). The explicit-unit parse is tried FIRST: appending "s"
+// to a unit-bearing value like `1m` would otherwise parse as the valid-but-wrong
+// "1ms" (1 millisecond) and never reach the correct interpretation.
+func parseBackgroundDuration(cmd string, def time.Duration) time.Duration {
+ parts := strings.Fields(cmd)
+ if len(parts) < 2 {
+ return def
+ }
+ if parsed, err := time.ParseDuration(parts[1]); err == nil && parsed > 0 {
+ return parsed
+ }
+ if secs, err := time.ParseDuration(parts[1] + "s"); err == nil && secs > 0 {
+ return secs
+ }
+ return def
+}
+
// emitError emits an error message.
func emitError(e *emitter, model string) {
randomDelay(model)
diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go
index 0d77fc3873..ae694d5eb1 100644
--- a/apps/backend/cmd/mock-agent/main.go
+++ b/apps/backend/cmd/mock-agent/main.go
@@ -379,6 +379,7 @@ func mockAvailableCommands() []acp.AvailableCommand {
}
return []acp.AvailableCommand{
{Name: "slow", Description: "Run a slow response (default 5s)", Input: hint("duration (e.g. 10s)")},
+ {Name: "background", Description: "Spawn a subagent and stay foreground-idle (default 8s)", Input: hint("duration (e.g. 8s)")},
{Name: "error", Description: "Simulate an error"},
{Name: "overloaded", Description: "Simulate a transient 529 Overloaded error (fails once, then recovers)"},
{Name: "thinking", Description: "Emit thinking/reasoning blocks"},
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
index 915985dcb5..379f72550b 100644
--- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
@@ -9,8 +9,11 @@ import (
)
// monitorToolName is the literal toolName Claude-acp tags Monitor tool calls with
-// in `_meta.claudeCode.toolName`. Used to recognize Monitor across the lifecycle.
-const monitorToolName = "Monitor"
+// in `_meta.claudeCode.toolName`. Used to recognize Monitor across the lifecycle
+// and, via streams.MonitorSubkind, as the `kind` stamped on the structured
+// Monitor view — so the adapter (producer) and the orchestrator's background-work
+// classifier (consumer, streams.IsActiveMonitor) share one source of truth.
+const monitorToolName = streams.MonitorSubkind
// monitorRegistrationOutputPrefix identifies the rawOutput banner Claude-acp
// emits when a Monitor registers (~1s after start). The wrapper sets status to
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go
index a1bbdb5fff..1c0c71ef4b 100644
--- a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize.go
@@ -297,6 +297,14 @@ func updateShellExecInput(se *streams.ShellExecPayload, inputMap map[string]any)
if desc := shared.GetString(inputMap, "description"); desc != "" && se.Description == "" {
se.Description = desc
}
+ // Claude's Bash tool streams `command` and `run_in_background:true` in a
+ // tool_call_update after an initial tool_call with empty rawInput, so the
+ // background flag must be honored on merge, not only at initial normalize.
+ // Only ever set true — a later foreground update must not clear a flag an
+ // earlier frame already established.
+ if isBackgroundExecInput(inputMap) {
+ se.Background = true
+ }
}
func updateModifyFileInput(mf *streams.ModifyFilePayload, supplemental, inputMap map[string]any) {
@@ -475,13 +483,21 @@ func (n *Normalizer) normalizeExecute(args map[string]any) *streams.NormalizedPa
workDir := shared.GetString(rawInput, "cwd")
timeout := shared.GetInt(rawInput, "max_wait_seconds")
- // Background is true if wait is explicitly false
- background := false
- if wait, ok := rawInput["wait"].(bool); ok && !wait {
- background = true
- }
+ return streams.NewShellExec(command, workDir, "", timeout, isBackgroundExecInput(rawInput))
+}
- return streams.NewShellExec(command, workDir, "", timeout, background)
+// isBackgroundExecInput reports whether an execute/bash rawInput marks the
+// command as background work. Claude's Bash tool signals it with
+// run_in_background:true (and no `wait` field); other agents use wait:false.
+// Either shape counts as background.
+func isBackgroundExecInput(inputMap map[string]any) bool {
+ if wait, ok := inputMap["wait"].(bool); ok && !wait {
+ return true
+ }
+ if bg, ok := inputMap["run_in_background"].(bool); ok && bg {
+ return true
+ }
+ return false
}
// normalizeCodeSearch converts ACP search tool data.
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_background_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_background_test.go
new file mode 100644
index 0000000000..c1f2bb0560
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/normalize_background_test.go
@@ -0,0 +1,80 @@
+package acp
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestNormalizeExecute_BackgroundFlag covers the two ways an execute/bash tool
+// call signals background work at initial-normalize time: Claude's
+// run_in_background:true and the older wait:false shape. Foreground calls
+// (wait:true, or neither field) must stay Background:false.
+func TestNormalizeExecute_BackgroundFlag(t *testing.T) {
+ n := NewNormalizer("")
+ cases := []struct {
+ name string
+ rawInput map[string]any
+ want bool
+ }{
+ {"run_in_background true", map[string]any{"command": "sleep 30", "run_in_background": true}, true},
+ {"run_in_background false", map[string]any{"command": "ls", "run_in_background": false}, false},
+ {"wait false", map[string]any{"command": "sleep 30", "wait": false}, true},
+ {"wait true", map[string]any{"command": "ls", "wait": true}, false},
+ {"neither field", map[string]any{"command": "ls"}, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ payload := n.NormalizeToolCall("execute", map[string]any{
+ "kind": "execute",
+ "raw_input": tc.rawInput,
+ })
+ se := payload.ShellExec()
+ require.NotNil(t, se, "expected a ShellExec payload")
+ require.Equal(t, tc.want, se.Background)
+ })
+ }
+}
+
+// TestUpdateShellExecInput_BackgroundFromUpdate reproduces the exact captured
+// Claude wire shape for a background Bash: an initial tool_call arrives with an
+// empty rawInput (Background:false), then a tool_call_update carries the
+// command plus run_in_background:true. The merge path must honor the flag so
+// ShellExec.Background flips to true — otherwise the orchestrator never sees
+// the session as "waiting on background" and locks the operator out.
+func TestUpdateShellExecInput_BackgroundFromUpdate(t *testing.T) {
+ n := NewNormalizer("")
+
+ // Initial tool_call: kind=execute, empty rawInput.
+ payload := n.NormalizeToolCall("execute", map[string]any{
+ "kind": "execute",
+ "raw_input": map[string]any{},
+ })
+ require.NotNil(t, payload.ShellExec())
+ require.False(t, payload.ShellExec().Background, "initial empty tool_call must not be background yet")
+
+ // tool_call_update: command + run_in_background:true.
+ n.UpdatePayloadInput(payload, map[string]any{
+ "command": "npm run dev",
+ "run_in_background": true,
+ }, nil)
+
+ require.Equal(t, "npm run dev", payload.ShellExec().Command)
+ require.True(t, payload.ShellExec().Background, "run_in_background:true in a tool_call_update must set Background")
+}
+
+// TestUpdateShellExecInput_ForegroundUpdateDoesNotClearBackground guards the
+// merge invariant: once an earlier frame established Background:true, a later
+// update that omits the flag (or carries wait:true) must not clear it.
+func TestUpdateShellExecInput_ForegroundUpdateDoesNotClearBackground(t *testing.T) {
+ n := NewNormalizer("")
+ payload := n.NormalizeToolCall("execute", map[string]any{
+ "kind": "execute",
+ "raw_input": map[string]any{"run_in_background": true},
+ })
+ require.True(t, payload.ShellExec().Background)
+
+ // A later update with no background signal must leave Background:true.
+ n.UpdatePayloadInput(payload, map[string]any{"cwd": "/repo"}, nil)
+ require.True(t, payload.ShellExec().Background, "a later foreground update must not clear an established background flag")
+}
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view.go b/apps/backend/internal/agentctl/types/streams/monitor_view.go
new file mode 100644
index 0000000000..1a0e2031ca
--- /dev/null
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view.go
@@ -0,0 +1,45 @@
+package streams
+
+// MonitorSubkind is the `kind` value the ACP adapter stamps on the structured
+// Monitor view it tucks into a Generic tool payload's Output (see
+// server/adapter/transport/acp/monitor.go). It is the shared contract between
+// the adapter (the producer) and consumers such as the orchestrator's
+// background-work classifier, so neither side has to string-match a tool name.
+const MonitorSubkind = "Monitor"
+
+// Monitor view map keys — the shape acp/monitor.go writes as
+// Generic.Output = {"monitor": {"kind": ..., "ended": ...}}. Kept here next to
+// the predicate that reads them so producer and consumer stay in lockstep.
+const (
+ monitorViewKey = "monitor"
+ monitorViewKindKey = "kind"
+ monitorViewEndedKey = "ended"
+)
+
+// IsActiveMonitor reports whether this payload is a live Claude Monitor watch:
+// a Generic payload carrying the structured Monitor view whose `ended` flag is
+// not set. claude-agent-acp tags Monitor with `_meta.claudeCode.toolName:
+// "Monitor"` and `kind:"other"`, so it normalizes to a Generic payload rather
+// than a dedicated kind (see acp/monitor.go). A Monitor is long-running
+// background work the foreground turn is not actively generating against, so an
+// active one is treated like any other spawned background task by the busy
+// signal. Returns false for a nil payload, a non-Generic payload, a Generic
+// payload with no Monitor view, or a Monitor that has already ended.
+func (p *NormalizedPayload) IsActiveMonitor() bool {
+ if p == nil || p.kind != ToolKindGeneric || p.generic == nil {
+ return false
+ }
+ wrapper, ok := p.generic.Output.(map[string]any)
+ if !ok {
+ return false
+ }
+ view, ok := wrapper[monitorViewKey].(map[string]any)
+ if !ok {
+ return false
+ }
+ if kind, _ := view[monitorViewKindKey].(string); kind != MonitorSubkind {
+ return false
+ }
+ ended, _ := view[monitorViewEndedKey].(bool)
+ return !ended
+}
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
new file mode 100644
index 0000000000..247b89327e
--- /dev/null
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
@@ -0,0 +1,69 @@
+package streams
+
+import "testing"
+
+// monitorView builds a Generic payload shaped exactly like the one
+// acp/monitor.go's monitorOutputWrapper produces, so the predicate is tested
+// against the real wire shape rather than a hand-tuned proxy.
+func monitorView(ended bool) *NormalizedPayload {
+ p := NewGeneric("Monitor", map[string]any{})
+ p.Generic().Output = map[string]any{
+ monitorViewKey: map[string]any{
+ monitorViewKindKey: MonitorSubkind,
+ monitorViewEndedKey: ended,
+ "task_id": "task-1",
+ "command": "gh pr checks --watch",
+ },
+ }
+ return p
+}
+
+func TestIsActiveMonitor(t *testing.T) {
+ cases := []struct {
+ name string
+ payload *NormalizedPayload
+ want bool
+ }{
+ {"active monitor", monitorView(false), true},
+ {"ended monitor", monitorView(true), false},
+ {"nil payload", nil, false},
+ {"non-generic payload", NewShellExec("ls", "", "", 0, false), false},
+ {"generic without monitor view", NewGeneric("SomeTool", map[string]any{"a": 1}), false},
+ {
+ "generic with wrong subkind",
+ func() *NormalizedPayload {
+ p := NewGeneric("Other", map[string]any{})
+ p.Generic().Output = map[string]any{
+ monitorViewKey: map[string]any{monitorViewKindKey: "Something", monitorViewEndedKey: false},
+ }
+ return p
+ }(),
+ false,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := tc.payload.IsActiveMonitor(); got != tc.want {
+ t.Fatalf("IsActiveMonitor() = %v, want %v", got, tc.want)
+ }
+ })
+ }
+}
+
+// TestIsActiveMonitor_SurvivesJSONRoundTrip proves the predicate still works
+// after the payload crosses the agentctl→orchestrator serialization boundary,
+// where Generic.Output decodes back into a map[string]any.
+func TestIsActiveMonitor_SurvivesJSONRoundTrip(t *testing.T) {
+ orig := monitorView(false)
+ data, err := orig.MarshalJSON()
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var got NormalizedPayload
+ if err := got.UnmarshalJSON(data); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !got.IsActiveMonitor() {
+ t.Fatal("active Monitor must remain recognized after a JSON round-trip")
+ }
+}
diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go
index 02468c1579..a2017e2ba0 100644
--- a/apps/backend/internal/backendapp/boot_state.go
+++ b/apps/backend/internal/backendapp/boot_state.go
@@ -975,6 +975,13 @@ func (b bootStateBuilder) addTaskDetailSessionsState(
continue
}
dto := taskdto.FromTaskSession(session)
+ // Mirror the in-memory fine-grained busy substate onto a RUNNING session
+ // so a fresh page-load / second tab sees the accept-input +
+ // working-in-background affordance without waiting for a WS flip
+ // (ADR-0035). No-op for non-RUNNING sessions.
+ if b.p.orchestratorSvc != nil {
+ taskdto.EnrichForegroundActivity(&dto, b.p.orchestratorSvc)
+ }
sessionItems[session.ID] = dto
sessionList = append(sessionList, dto)
if session.TaskEnvironmentID != "" {
diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go
index 5e7b3db62b..5ce51a628e 100644
--- a/apps/backend/internal/events/types.go
+++ b/apps/backend/internal/events/types.go
@@ -58,6 +58,13 @@ const (
// Event types for task sessions
const (
TaskSessionStateChanged = "task_session.state_changed"
+ // TaskSessionActivityChanged fires when a RUNNING session's foreground
+ // turn flips between actively generating and idle-on-background-work,
+ // without any change to the coarse session state. It carries the
+ // fine-grained busy signal (ADR-0035) so the
+ // operator-facing composer and status indicator can distinguish
+ // "generating" from "waiting on spawned background work".
+ TaskSessionActivityChanged = "task_session.activity_changed"
)
// Event types for task plans
diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go
index e50bb426fe..640b8d5831 100644
--- a/apps/backend/internal/gateway/websocket/task_notifications.go
+++ b/apps/backend/internal/gateway/websocket/task_notifications.go
@@ -67,6 +67,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *
b.subscribe(eventBus, events.EnvironmentUpdated, ws.ActionEnvironmentUpdated)
b.subscribe(eventBus, events.EnvironmentDeleted, ws.ActionEnvironmentDeleted)
b.subscribe(eventBus, events.TaskSessionStateChanged, ws.ActionSessionStateChanged)
+ b.subscribe(eventBus, events.TaskSessionActivityChanged, ws.ActionSessionActivityChanged)
b.subscribe(eventBus, events.MessageAdded, ws.ActionSessionMessageAdded)
b.subscribe(eventBus, events.MessageUpdated, ws.ActionSessionMessageUpdated)
b.subscribe(eventBus, events.MessageDeleted, ws.ActionSessionMessageDeleted)
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go
index f3f6214ee7..48002c1835 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go
@@ -256,6 +256,17 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag
// the task to REVIEW) leaves session=RUNNING with task=REVIEW.
s.setSessionRunningForExecution(ctx, payload.TaskID, payload.SessionID, payload.ExecutionID)
}
+
+ // A top-level spawned background task (subagent / run-in-background shell)
+ // holds the turn open while the foreground goes idle. Track it so
+ // checkSessionPromptable can tell "foreground generating" from "waiting on
+ // background". Child tool calls (ParentToolCallID set) are the subagent's
+ // internal work, not a new background task, so they are ignored here.
+ if payload.Data.ParentToolCallID == "" && normalizedIsBackgroundTask(payload.Data.Normalized) {
+ if s.registerBackgroundTask(payload.SessionID, payload.Data.ToolCallID) {
+ s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
+ }
+ }
}
// saveAgentTextIfPresent saves any accumulated agent text as an agent message
@@ -357,6 +368,11 @@ func (s *Service) handleStreamingEventKind(
// handleMessageStreamingEvent handles streaming message events for real-time text updates.
// It creates a new message on first chunk (IsAppend=false) or appends to existing (IsAppend=true).
func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
+ // Streamed foreground output means the agent is actively generating again,
+ // even if a background task is still outstanding — narrows the busy signal.
+ if s.markForegroundGenerating(payload.SessionID) {
+ s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
+ }
s.handleStreamingEventKind(ctx, payload, "message",
s.messageCreator.AppendAgentMessage,
s.messageCreator.CreateAgentMessageStreaming)
@@ -365,6 +381,10 @@ func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *life
// handleThinkingStreamingEvent handles streaming thinking events for real-time reasoning updates.
// It creates a new thinking message on first chunk (IsAppend=false) or appends to existing (IsAppend=true).
func (s *Service) handleThinkingStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
+ // Streamed foreground reasoning means the agent is actively generating again.
+ if s.markForegroundGenerating(payload.SessionID) {
+ s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
+ }
s.handleStreamingEventKind(ctx, payload, "thinking message",
s.messageCreator.AppendThinkingMessage,
s.messageCreator.CreateThinkingMessageStreaming)
@@ -444,8 +464,52 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.
if terminal && status != "cancelled" && turnID != "" {
s.setSessionRunningForExecution(ctx, payload.TaskID, payload.SessionID, payload.ExecutionID)
}
+
+ // Background-work bookkeeping for the finer-grained busy signal.
+ s.trackBackgroundToolUpdate(ctx, payload)
+}
+
+// trackBackgroundToolUpdate maintains the fine-grained busy signal's background
+// hold from a top-level tool_call_update: a terminal status clears the hold and
+// the first recognizable non-terminal frame registers it. Child tool calls
+// (ParentToolCallID set) are a subagent's own internal work, not a new
+// background task, so they never touch the hold.
+func (s *Service) trackBackgroundToolUpdate(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
+ if payload.Data.ParentToolCallID != "" {
+ return
+ }
+ if isTerminalToolUpdateStatus(payload.Data.ToolStatus) {
+ // A finished top-level background task no longer holds the turn open.
+ // Once none remain, the foreground is no longer "waiting on background".
+ // Cleared by tool-call ID membership rather than by re-classifying the
+ // terminal payload: adapters that rebuild Normalized per update (or drop
+ // the Background flag on the terminal frame) would otherwise never match,
+ // leaving the session permanently "not generating" for the rest of the
+ // turn. completeBackgroundTask is a no-op for IDs that were never
+ // registered, so this cannot clear a still-outstanding background task.
+ if s.completeBackgroundTask(payload.SessionID, payload.Data.ToolCallID) {
+ s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
+ }
+ return
+ }
+ if s.hasBackgroundTask(payload.SessionID, payload.Data.ToolCallID) ||
+ !normalizedIsBackgroundTask(payload.Data.Normalized) {
+ return
+ }
+ // Both of Claude's background shapes only become recognizable on a
+ // tool_call_update — the run_in_background flag and command are streamed
+ // after the initial (empty) tool_call, and the Monitor view is seeded on its
+ // registration update — so a non-terminal update is the first frame where the
+ // classifier can see them. Register only on that first recognition:
+ // re-registering on later updates would re-set `yielded` and clobber a
+ // foreground stream that meanwhile marked the turn generating again.
+ if s.registerBackgroundTask(payload.SessionID, payload.Data.ToolCallID) {
+ s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
+ }
}
+// isTerminalToolUpdateStatus reports whether a tool_update status marks the tool call
+// as finished (successfully, in error, or cancelled).
func isTerminalToolUpdateStatus(status string) bool {
switch status {
case agentEventComplete, agentEventCompleted, "success", agentEventError, agentEventFailed, "cancelled":
@@ -805,6 +869,11 @@ func (s *Service) publishTaskSessionStateChanged(
metaKeyAgentProfileID: agentProfileID,
"agent_profile_snapshot": session.AgentProfileSnapshot,
"is_passthrough": session.IsPassthrough,
+ // Carry the fine-grained busy substate on every coarse transition so
+ // the client resets any stale "background" value when a new turn starts
+ // or the turn ends (ADR-0035). Intra-RUNNING flips
+ // ride the dedicated task_session.activity_changed event instead.
+ "foreground_activity": string(s.foregroundActivityValue(sessionID)),
}
if stateUpdatedAt != nil && !stateUpdatedAt.IsZero() {
eventData[metaKeyUpdatedAt] = stateUpdatedAt.Format(time.RFC3339Nano)
diff --git a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
new file mode 100644
index 0000000000..8ecf2ce980
--- /dev/null
+++ b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
@@ -0,0 +1,144 @@
+package orchestrator
+
+import (
+ "context"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/runtime/lifecycle"
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+ "github.com/kandev/kandev/internal/events"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// activityValues returns the foreground_activity payloads of every
+// task_session.activity_changed event recorded on the bus, in publish order.
+// It is the operator-facing WS1 signal the composer and status indicator read.
+func activityValues(eb *recordingEventBus) []string {
+ var vals []string
+ for _, rec := range eb.events {
+ if rec.subject != events.TaskSessionActivityChanged {
+ continue
+ }
+ if data, ok := rec.event.Data.(map[string]interface{}); ok {
+ if v, ok := data["foreground_activity"].(string); ok {
+ vals = append(vals, v)
+ }
+ }
+ }
+ return vals
+}
+
+// TestForegroundActivitySignal_PublishesOnFlips proves the WS1 producer emits
+// the fine-grained busy signal exactly when the foreground/background substate
+// flips — background when the agent yields to a spawned task, generating again
+// when it streams foreground output — so the web composer/status can distinguish
+// the three conditions without a coarse session-state transition.
+func TestForegroundActivitySignal_PublishesOnFlips(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ eb := &recordingEventBus{}
+ svc.eventBus = eb
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-activity"
+ )
+
+ // A top-level subagent tool_call: the foreground yields to background work.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "subagent-1",
+ ToolStatus: "running",
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ })
+
+ // A streamed foreground message: the agent is generating again even though
+ // the subagent is still outstanding.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "message_streaming",
+ MessageID: "m1",
+ Text: "still working on it",
+ },
+ })
+
+ got := activityValues(eb)
+ want := []string{string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating)}
+ if len(got) != len(want) {
+ t.Fatalf("expected activity signal on each flip %v, got %v", want, got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("activity flip %d: expected %q, got %q (all: %v)", i, want[i], got[i], got)
+ }
+ }
+}
+
+// TestForegroundActivitySignal_NoPublishWithoutFlip proves the signal is emitted
+// only on a real substate transition, never per background frame: a second
+// concurrent background task, and completing all-but-the-last, must NOT publish.
+func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ eb := &recordingEventBus{}
+ svc.eventBus = eb
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-activity-2"
+ )
+
+ subagent := func(id string) *lifecycle.AgentStreamEventPayload {
+ return &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: id,
+ ToolStatus: "running",
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ }
+ }
+ terminal := func(id string) *lifecycle.AgentStreamEventPayload {
+ return &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: id,
+ ToolStatus: agentEventComplete,
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ }
+ }
+
+ // First background task flips to background (publish #1). The second does not
+ // flip anything (already yielded) — no publish.
+ svc.handleAgentStreamEvent(context.Background(), subagent("subagent-1"))
+ svc.handleAgentStreamEvent(context.Background(), subagent("subagent-2"))
+
+ // Completing the first while the second is still outstanding does not flip —
+ // no publish. Completing the last flips back to generating (publish #2).
+ svc.handleAgentStreamEvent(context.Background(), terminal("subagent-1"))
+ svc.handleAgentStreamEvent(context.Background(), terminal("subagent-2"))
+
+ got := activityValues(eb)
+ want := []string{string(v1.ForegroundActivityBackground), string(v1.ForegroundActivityGenerating)}
+ if len(got) != len(want) {
+ t.Fatalf("expected exactly one publish per real flip %v, got %v", want, got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("flip %d: expected %q, got %q (all: %v)", i, want[i], got[i], got)
+ }
+ }
+}
diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
new file mode 100644
index 0000000000..15c969dbd8
--- /dev/null
+++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
@@ -0,0 +1,576 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/runtime/lifecycle"
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+ "github.com/kandev/kandev/internal/task/models"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// TestCheckSessionPromptable_BackgroundTaskAcceptsInput is the red
+// characterization test for ADR-0035: a session whose
+// foreground turn is idle while a spawned background task is still running must
+// accept a new message, not be rejected as "already running".
+//
+// Before the fix, checkSessionPromptable rejected ANY RUNNING session with
+// ErrAgentPromptInProgress, so an operator whose agent kicked off background
+// work was locked out of the conversation. After the fix, a RUNNING session
+// with an outstanding background task (and no active foreground generation) is
+// promptable, while a RUNNING session that is genuinely generating in the
+// foreground is still gated.
+func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const sessionID = "session-bg"
+
+ // Baseline: a genuinely-generating foreground turn is still gated.
+ if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("foreground-generating RUNNING session must be gated with ErrAgentPromptInProgress, got: %v", err)
+ }
+
+ // The agent spawns a background task and goes idle in the foreground.
+ svc.registerBackgroundTask(sessionID, "tool-subagent-1")
+
+ // The session must now accept a new message even though its state is RUNNING.
+ if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("RUNNING session waiting only on background work must be promptable, got: %v", err)
+ }
+
+ // Once the background task finishes, the (still open) turn is once again a
+ // genuine foreground turn and input is gated.
+ svc.completeBackgroundTask(sessionID, "tool-subagent-1")
+ if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("after background work completes the RUNNING session must gate input again, got: %v", err)
+ }
+}
+
+// TestTurnActivity_ForegroundBackgroundTransitions locks in the state machine
+// behind isForegroundTurnGenerating.
+// TestForegroundActivity_ExportedValue covers the seam the page-load / list
+// serialization layer depends on (ADR-0035): the exported
+// ForegroundActivity mirror of the in-memory tracker. An untracked session — which
+// includes every session after a backend restart, since a restart ends the turn —
+// must report the safe "generating" default so a stale "you may type" can never be
+// serialized.
+func TestForegroundActivity_ExportedValue(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const s = "session-fa"
+
+ if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityGenerating {
+ t.Fatalf("untracked session must default to generating, got %q", got)
+ }
+
+ svc.registerBackgroundTask(s, "t1")
+ if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityBackground {
+ t.Fatalf("after registering background work, got %q, want background", got)
+ }
+
+ svc.completeBackgroundTask(s, "t1")
+ if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityGenerating {
+ t.Fatalf("after background work finishes, got %q, want generating", got)
+ }
+
+ // clearTurnActivity models a turn-close / restart-adjacent reset back to safe.
+ svc.registerBackgroundTask(s, "t2")
+ svc.clearTurnActivity(s)
+ if got := svc.ForegroundActivity(s); got != v1.ForegroundActivityGenerating {
+ t.Fatalf("after clearTurnActivity, got %q, want generating", got)
+ }
+}
+
+func TestTurnActivity_ForegroundBackgroundTransitions(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const s = "session-x"
+
+ // Absent state defaults to "foreground generating" — preserves the historical
+ // reject-while-RUNNING contract for sessions with no background work.
+ if !svc.isForegroundTurnGenerating(s) {
+ t.Fatal("untracked session must default to foreground-generating")
+ }
+
+ // Register a background task: foreground has yielded.
+ svc.registerBackgroundTask(s, "t1")
+ if svc.isForegroundTurnGenerating(s) {
+ t.Fatal("after registering a background task the foreground must be idle")
+ }
+ svc.completeBackgroundTask(s, "t1")
+
+ // A fresh foreground stream chunk means the agent is generating again.
+ svc.markForegroundGenerating(s)
+ if !svc.isForegroundTurnGenerating(s) {
+ t.Fatal("streamed foreground output must mark the turn as generating again")
+ }
+
+ // Two concurrent background tasks: the foreground is idle until BOTH finish.
+ svc.registerBackgroundTask(s, "t2")
+ svc.registerBackgroundTask(s, "t3")
+ if svc.isForegroundTurnGenerating(s) {
+ t.Fatal("with outstanding background tasks the foreground must be idle")
+ }
+ svc.completeBackgroundTask(s, "t2")
+ if svc.isForegroundTurnGenerating(s) {
+ t.Fatal("one of two background tasks finishing must not resume foreground")
+ }
+ svc.completeBackgroundTask(s, "t3")
+ if !svc.isForegroundTurnGenerating(s) {
+ t.Fatal("with all background tasks finished the foreground default resumes")
+ }
+
+ // Clearing turn activity resets to the default.
+ svc.registerBackgroundTask(s, "t4")
+ svc.clearTurnActivity(s)
+ if !svc.isForegroundTurnGenerating(s) {
+ t.Fatal("clearTurnActivity must reset to the foreground-generating default")
+ }
+}
+
+// TestCompleteTurnClearsBackgroundActivity confirms that closing a turn drops the
+// background-activity tracking so the next turn starts clean.
+func TestCompleteTurnClearsBackgroundActivity(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const s = "session-turn"
+ svc.registerBackgroundTask(s, "t1")
+ if svc.isForegroundTurnGenerating(s) {
+ t.Fatal("precondition: session should be waiting on background work")
+ }
+
+ // completeTurnForSession must clear turn activity (turnService is nil in this
+ // test service, which exercises the early-return path).
+ svc.completeTurnForSession(t.Context(), s)
+ if !svc.isForegroundTurnGenerating(s) {
+ t.Fatal("completeTurnForSession must clear background activity")
+ }
+}
+
+// TestForegroundBusySignal_WiredThroughStreamEvents drives the real agent
+// stream-event dispatch to prove the WS1 producer → WS2 gate wiring end to end:
+// a subagent tool_call arriving on the stream flips the promptable gate from
+// "reject (agent running)" to "accept (only background work outstanding)".
+func TestForegroundBusySignal_WiredThroughStreamEvents(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const (
+ taskID = "task1"
+ sessionID = "session-stream"
+ )
+
+ // Before any background work, a RUNNING session gates input.
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("precondition: RUNNING session should gate input, got: %v", err)
+ }
+
+ // A top-level subagent Task tool_call arrives on the stream — the agent has
+ // spawned background work and yielded the foreground.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "subagent-1",
+ ToolStatus: "running",
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ })
+
+ // The gate now accepts a new message even though the session state is RUNNING.
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("after a background subagent tool_call the session must be promptable, got: %v", err)
+ }
+
+ // A child tool_call from inside the subagent (ParentToolCallID set) is the
+ // subagent's own work, not a new background task, and must not change the
+ // signal.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "child-1",
+ ParentToolCallID: "subagent-1",
+ ToolStatus: "running",
+ Normalized: streams.NewShellExec("ls", "", "", 0, false),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("a subagent-internal child tool_call must not re-gate input, got: %v", err)
+ }
+}
+
+// TestForegroundBusySignal_TerminalToolUpdateReclosesGate proves the completion
+// half of the WS1 -> WS2 wiring: once a background subagent tool_call has
+// opened the promptable gate, a TERMINAL tool_update for that same tool-call ID
+// dispatched through the real stream handler closes the gate again — the
+// background task is done, so an open turn is once again a genuine foreground
+// turn.
+func TestForegroundBusySignal_TerminalToolUpdateReclosesGate(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-stream"
+ )
+
+ // A top-level subagent tool_call opens the gate.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "subagent-1",
+ ToolStatus: "running",
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err)
+ }
+
+ // The subagent's own terminal tool_update arrives on the stream.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "subagent-1",
+ ToolStatus: agentEventComplete,
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ })
+
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("a terminal tool_update for the outstanding background task must re-close the gate, got: %v", err)
+ }
+}
+
+// TestForegroundBusySignal_TerminalToolUpdateReclosesGateByIDNotKind proves the
+// completion clears by tool-call ID membership, not by re-classifying the
+// terminal payload: a terminal tool_update whose Normalized payload is a plain
+// (non-background) tool must still re-close the gate when its ToolCallID
+// matches the registered background task. An adapter that rebuilds Normalized
+// per update (or drops the Background flag on the terminal frame) would
+// otherwise never match on kind, leaving the session permanently "not
+// generating" for the rest of the turn.
+func TestForegroundBusySignal_TerminalToolUpdateReclosesGateByIDNotKind(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-stream"
+ )
+
+ // A top-level subagent tool_call opens the gate.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "subagent-1",
+ ToolStatus: "running",
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err)
+ }
+
+ // The terminal update for the SAME tool-call ID carries a plain, non-background
+ // Normalized payload (e.g. the adapter rebuilt it without the Background flag).
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "subagent-1",
+ ToolStatus: agentEventComplete,
+ Normalized: streams.NewGeneric("SomeTool", nil),
+ },
+ })
+
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("a terminal tool_update matching the registered ID must re-close the gate regardless of its Normalized kind, got: %v", err)
+ }
+}
+
+// TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen proves
+// completeBackgroundTask is a no-op for IDs that were never registered: a
+// terminal tool_update for an unrelated tool-call ID must not spuriously clear
+// the still-outstanding background task.
+func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-stream"
+ )
+
+ // A top-level subagent tool_call opens the gate.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "subagent-1",
+ ToolStatus: "running",
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("precondition: background subagent tool_call should open the gate, got: %v", err)
+ }
+
+ // A terminal tool_update for an ID that was never registered as a background
+ // task arrives — must not clear the still-outstanding "subagent-1" task.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "unregistered-tool",
+ ToolStatus: agentEventComplete,
+ Normalized: streams.NewGeneric("SomeTool", nil),
+ },
+ })
+
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("a terminal update for an unregistered tool-call ID must not re-gate input while background work is still outstanding, got: %v", err)
+ }
+}
+
+// monitorGenericPayload builds the Generic payload the ACP adapter emits for a
+// Claude Monitor: kind=generic with the structured `{monitor:{...}}` view tucked
+// into Output. `ended` toggles whether the watch is still live.
+func monitorGenericPayload(ended bool) *streams.NormalizedPayload {
+ p := streams.NewGeneric("Monitor", map[string]any{})
+ p.Generic().Output = map[string]any{
+ "monitor": map[string]any{
+ "kind": streams.MonitorSubkind,
+ "ended": ended,
+ "task_id": "task-1",
+ "command": "gh pr checks --watch",
+ },
+ }
+ return p
+}
+
+// TestNormalizedIsBackgroundTask pins the predicate that classifies which tool
+// calls represent spawned background work the foreground turn waits on.
+func TestNormalizedIsBackgroundTask(t *testing.T) {
+ cases := []struct {
+ name string
+ n *streams.NormalizedPayload
+ want bool
+ }{
+ {"nil", nil, false},
+ {"subagent task", streams.NewSubagentTask("explore", "find files", "general-purpose"), true},
+ {"background shell", streams.NewShellExec("sleep 30", "", "", 0, true), true},
+ {"foreground shell", streams.NewShellExec("ls", "", "", 0, false), false},
+ {"active monitor", monitorGenericPayload(false), true},
+ {"ended monitor", monitorGenericPayload(true), false},
+ {"read file", streams.NewReadFile("/tmp/x", 0, 0), false},
+ {"generic tool", streams.NewGeneric("SomeTool", nil), false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := normalizedIsBackgroundTask(tc.n); got != tc.want {
+ t.Errorf("normalizedIsBackgroundTask(%s) = %v, want %v", tc.name, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestForegroundBusySignal_BackgroundShellViaUpdate proves Gap 1 end-to-end at
+// the orchestrator boundary: a run_in_background Bash arrives as an initial
+// tool_call with an empty (foreground-looking) payload, then a non-terminal
+// tool_call_update whose Normalized ShellExec carries Background:true. The
+// update must open checkSessionPromptable for a RUNNING session; the terminal
+// update re-closes it. Before the wiring fix the update path never registered
+// background work, so the gate stayed shut for the whole watch.
+func TestForegroundBusySignal_BackgroundShellViaUpdate(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-bgshell"
+ )
+
+ // Initial tool_call: empty, foreground-looking. The gate stays shut.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "bash-1",
+ ToolStatus: "pending",
+ Normalized: streams.NewShellExec("", "", "", 0, false),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("precondition: empty initial tool_call must not open the gate, got: %v", err)
+ }
+
+ // Non-terminal tool_call_update carries the command + run_in_background flag.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "bash-1",
+ ToolStatus: "in_progress",
+ Normalized: streams.NewShellExec("npm run dev", "", "", 0, true),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("a run_in_background shell tool_update must open the gate, got: %v", err)
+ }
+
+ // Terminal update for the same tool-call ID re-closes the gate.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "bash-1",
+ ToolStatus: agentEventComplete,
+ Normalized: streams.NewShellExec("npm run dev", "", "", 0, true),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("terminal shell tool_update must re-close the gate, got: %v", err)
+ }
+}
+
+// TestForegroundBusySignal_MonitorViaUpdate proves Gap 2 end-to-end at the
+// orchestrator boundary: a Claude Monitor normalizes to a Generic payload whose
+// structured view is only seeded on its registration tool_call_update. That
+// non-terminal update must open checkSessionPromptable for a RUNNING session so
+// the operator isn't locked out while the Monitor watches. The terminal update
+// the adapter emits from sweepMonitorsOnPromptEnd (status "complete") must clear
+// the background hold and re-close the gate.
+func TestForegroundBusySignal_MonitorViaUpdate(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-monitor"
+ )
+
+ // Initial Monitor tool_call: Generic payload, view not seeded yet — the
+ // adapter can't recognize the Monitor until the registration banner. The gate
+ // stays shut.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "monitor-1",
+ ToolStatus: "pending",
+ Normalized: streams.NewGeneric("Monitor", map[string]any{}),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("precondition: pre-registration Monitor tool_call must not open the gate, got: %v", err)
+ }
+
+ // Registration tool_call_update: the adapter has seeded the `{monitor:...}`
+ // view and flipped status to in_progress. The gate opens.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "monitor-1",
+ ToolStatus: "in_progress",
+ Normalized: monitorGenericPayload(false),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("an active Monitor registration tool_update must open the gate, got: %v", err)
+ }
+
+ // sweepMonitorsOnPromptEnd emits a terminal "complete" tool_update with the
+ // view marked ended. It must clear the background hold and re-close the gate.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "monitor-1",
+ ToolStatus: agentEventComplete,
+ Normalized: monitorGenericPayload(true),
+ },
+ })
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("a swept/ended Monitor terminal tool_update must re-close the gate, got: %v", err)
+ }
+}
+
+// TestForegroundBusySignal_UpdateDoesNotReYieldAfterForeground guards the
+// register-only-on-first-recognition rule: once a background task is
+// outstanding and the foreground has streamed output (marking the turn
+// generating again), a later non-terminal tool_update for that SAME background
+// task must NOT re-yield the turn — otherwise a background progress frame would
+// spuriously re-open the gate while the foreground is generating.
+func TestForegroundBusySignal_UpdateDoesNotReYieldAfterForeground(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-reyield"
+ )
+
+ bgUpdate := func() {
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "bash-1",
+ ToolStatus: "in_progress",
+ Normalized: streams.NewShellExec("npm run dev", "", "", 0, true),
+ },
+ })
+ }
+
+ // A background run_in_background shell is recognized on its first update.
+ bgUpdate()
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); err != nil {
+ t.Fatalf("precondition: background shell update should open the gate, got: %v", err)
+ }
+
+ // The foreground resumes generating (streamed message chunk).
+ svc.markForegroundGenerating(sessionID)
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("foreground generating again must re-gate input, got: %v", err)
+ }
+
+ // A later non-terminal progress update for the same background task must not
+ // re-yield the turn.
+ bgUpdate()
+ if err := svc.checkSessionPromptable(taskID, sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("a later background progress update must not re-open the gate after foreground resumed, got: %v", err)
+ }
+}
diff --git a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
new file mode 100644
index 0000000000..6128eda2b3
--- /dev/null
+++ b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
@@ -0,0 +1,204 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/runtime/lifecycle"
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+ "github.com/kandev/kandev/internal/orchestrator/executor"
+ "github.com/kandev/kandev/internal/task/models"
+)
+
+// TestPromptTask_BackgroundWorkAcceptsInput is the falsifiable acceptance proof
+// for ADR-0035, driven through
+// the REAL operator entrypoint (PromptTask) rather than checkSessionPromptable
+// in isolation — it reproduces the operator-lockout→fixed transition end to end
+// for both of Claude's background-work wire shapes.
+//
+// The symptom: an operator whose agent launched background work (a Monitor
+// watch or a run_in_background shell) is locked out — PromptTask rejects the
+// next message with ErrAgentPromptInProgress because the session state still
+// reads RUNNING, and the only recovery is a service restart. Both shapes only
+// become recognizable on a tool_call_update (the Monitor registration banner
+// seeds its Generic view; the run_in_background flag and command are streamed
+// after the initial empty tool_call), so this exercises the full producer path:
+// a live stream update → background registration → the prompt gate opening.
+//
+// Why #1600 does not already cover this: the upstream idle-turn completion
+// (#1600) only synthesizes a turn-complete after async content has been idle
+// for a ~5s debounce, and a chatty Monitor re-extends that debounce on every
+// event burst — so the synthetic completion never fires while the watch is
+// active. The monitor case below drives repeated event bursts and asserts the
+// session is STILL RUNNING (the #1600 window is open, not closed) before it
+// sends the mid-burst prompt: without the fine-grained gate, that prompt is
+// rejected even though #1600 is armed. See the RED/GREEN note in the batch plan.
+//
+// Deterministic backend integration harness chosen over Playwright by design:
+// surfacing the fine-grained signal to the web composer is a documented
+// follow-up (Batch 2 — the composer still derives "busy" from state ===
+// RUNNING), so a UI test cannot isolate this fix without a timing-flaky
+// assertion. This harness drives PromptTask against a live-agent mock and
+// asserts the message is forwarded to the agent, which is exactly the
+// acceptance the operator sees.
+func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) {
+ cases := []struct {
+ name string
+ toolCallID string
+ // burst is how many non-terminal tool_call_updates the agent streams
+ // before the operator prompts. >1 models a chatty Monitor whose bursts
+ // keep re-extending #1600's debounce.
+ burst int
+ normalized func() *streams.NormalizedPayload
+ }{
+ {
+ name: "run_in_background shell",
+ toolCallID: "bash-1",
+ burst: 1,
+ normalized: func() *streams.NormalizedPayload {
+ return streams.NewShellExec("npm run dev", "", "", 0, true)
+ },
+ },
+ {
+ name: "chatty monitor watch",
+ toolCallID: "monitor-1",
+ burst: 4,
+ normalized: func() *streams.NormalizedPayload {
+ return monitorGenericPayload(false)
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ repo := setupTestRepo(t)
+ agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo}
+ svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
+ svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session1"
+ )
+ seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
+ session, err := repo.GetTaskSession(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("load session: %v", err)
+ }
+ session.AgentExecutionID = "exec-1"
+ seedExecutorRunning(t, repo, sessionID, taskID, "exec-1")
+ if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
+ t.Fatalf("update session: %v", err)
+ }
+
+ // Lockout: a RUNNING session whose foreground turn is generating rejects
+ // the next message — the exact symptom the operator hits.
+ if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "hey", "", false, nil, false); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("precondition: RUNNING session must reject input with ErrAgentPromptInProgress, got: %v", err)
+ }
+ if len(agentMgr.capturedPrompts) != 0 {
+ t.Fatalf("precondition: rejected prompt must not reach the agent, captured=%d", len(agentMgr.capturedPrompts))
+ }
+
+ // The agent's background work becomes recognizable on non-terminal
+ // tool_call_updates streamed from the live agent. Repeated bursts model
+ // a chatty Monitor re-extending #1600's debounce.
+ for i := 0; i < tc.burst; i++ {
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ ExecutionID: "exec-1",
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: tc.toolCallID,
+ ToolStatus: "in_progress",
+ Normalized: tc.normalized(),
+ },
+ })
+ }
+
+ // The session is still RUNNING: #1600's synthetic turn-complete has not
+ // fired (the bursts kept its debounce alive), so the durable state alone
+ // would keep the operator locked out. This is the window the
+ // fine-grained gate must open.
+ refreshed, err := repo.GetTaskSession(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("reload session: %v", err)
+ }
+ if refreshed.State != models.TaskSessionStateRunning {
+ t.Fatalf("expected session to remain RUNNING while background work is outstanding, got %s", refreshed.State)
+ }
+
+ // Fixed: the same message now goes through — the session accepts input
+ // while only background work is outstanding, and it is forwarded to the
+ // live agent instead of being dropped as "busy".
+ if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false); err != nil {
+ t.Fatalf("session with only background work outstanding must accept input, got: %v", err)
+ }
+ if len(agentMgr.capturedPrompts) != 1 {
+ t.Fatalf("accepted prompt must be forwarded to the agent, captured=%d", len(agentMgr.capturedPrompts))
+ }
+ })
+ }
+}
+
+// TestPromptTask_NonClaudeFramesStayBusy is the explicit non-Claude regression
+// assertion (ADR-0035 "byte-for-byte unchanged" default):
+// a codex/opencode-shaped in-flight tool call is not recognized as background
+// work, so a RUNNING session driving one must keep rejecting operator input
+// exactly as it did before the fine-grained gate existed.
+func TestPromptTask_NonClaudeFramesStayBusy(t *testing.T) {
+ repo := setupTestRepo(t)
+ agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo}
+ svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
+ svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-codex"
+ )
+ seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
+ session, err := repo.GetTaskSession(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("load session: %v", err)
+ }
+ session.AgentExecutionID = "exec-1"
+ seedExecutorRunning(t, repo, sessionID, taskID, "exec-1")
+ if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
+ t.Fatalf("update session: %v", err)
+ }
+
+ // A non-Claude agent streams ordinary foreground tool calls (an edit, a
+ // foreground shell) — none of which normalize to a recognized background
+ // shape. These must never open the gate.
+ frames := []*streams.NormalizedPayload{
+ streams.NewShellExec("go build ./...", "", "", 0, false),
+ streams.NewReadFile("/repo/main.go", 0, 0),
+ streams.NewGeneric("codex_apply_patch", map[string]any{"raw_input": map[string]any{"patch": "..."}}),
+ }
+ for _, n := range frames {
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ ExecutionID: "exec-1",
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "codex-tool",
+ ToolStatus: "in_progress",
+ Normalized: n,
+ },
+ })
+ }
+
+ // The gate is unchanged: a RUNNING session with only unrecognized foreground
+ // work outstanding still rejects input, and nothing reaches the agent.
+ if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "hey", "", false, nil, false); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("unrecognized (non-Claude) work must keep the RUNNING session busy, got: %v", err)
+ }
+ if len(agentMgr.capturedPrompts) != 0 {
+ t.Fatalf("rejected prompt must not reach the agent, captured=%d", len(agentMgr.capturedPrompts))
+ }
+}
diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go
index 1bea8038cf..e7114cbdff 100644
--- a/apps/backend/internal/orchestrator/service.go
+++ b/apps/backend/internal/orchestrator/service.go
@@ -429,6 +429,13 @@ type Service struct {
// in-flight dispatch at all is reason enough to defer.
dispatchingQueued sync.Map
+ // foregroundActivity tracks, per session, whether the open turn is actively
+ // generating in the foreground or only waiting on a spawned background task
+ // (subagent / run-in-background shell). Keyed sessionID -> *turnActivity;
+ // see turn_activity.go. Consulted by checkSessionPromptable so a session
+ // that kicked off background work still accepts operator input.
+ foregroundActivity sync.Map
+
// taskRuntimeStateMu serializes task-state flips derived from session
// runtime state. Without it, a completion/cancel path can check for active
// sibling sessions just before another handler marks one RUNNING, then
@@ -954,6 +961,12 @@ func (s *Service) startTurnForSession(ctx context.Context, sessionID string) str
// query the DB for any open turn and close it. Loops to mop up multiple
// zombies (e.g. left over from before this fix) with a small sanity bound.
func (s *Service) completeTurnForSession(ctx context.Context, sessionID string) {
+ // The foreground/background activity signal is turn-scoped: once the turn is
+ // closed the next foreground prompt starts from the "generating" default.
+ // Clear it before the turnService guard so the reset happens even when no
+ // turn service is wired (tests, minimal configs).
+ s.clearTurnActivity(sessionID)
+
if s.turnService == nil {
return
}
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index 0e85f99279..87cd6b59c0 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -2215,6 +2215,11 @@ func (s *Service) DeleteSession(ctx context.Context, sessionID string) error {
// Same reasoning for the push-detection tracker. Multi-repo sessions
// accumulate one entry per repo; pushTrackerForget walks them all.
s.pushTrackerForget(sessionID)
+ // And the foreground/background turn-activity signal. It is normally cleared
+ // at turn close, but a trailing stream event can re-create the entry after
+ // the last turn completed; forget it here so a deleted session leaves no
+ // orphaned map slot.
+ s.clearTurnActivity(sessionID)
// Auto-promote another session if we deleted the primary
if wasPrimary {
@@ -2695,6 +2700,11 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
}
session = claimedSession
s.startTurnForSession(ctx, sessionID)
+ // A fresh foreground prompt is, by definition, foreground generation — clear
+ // any lingering "waiting on background" state so this turn gates input while
+ // it runs (e.g. when the operator sent this message into a background-idle
+ // session that was just accepted by checkSessionPromptable).
+ s.markForegroundGenerating(sessionID)
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the prompt.
// Prompts can take a long time (minutes) while the WS request may timeout in 15 seconds.
@@ -2829,6 +2839,15 @@ func (s *Service) checkSessionPromptable(taskID, sessionID string, state models.
models.TaskSessionStateIdle:
return nil
case models.TaskSessionStateRunning:
+ // Narrow the busy signal: a session that kicked off background work and
+ // is otherwise idle in the foreground should still accept a new message
+ // rather than reporting "running" and dropping it.
+ if !s.isForegroundTurnGenerating(sessionID) {
+ s.logger.Debug("accepting prompt: foreground turn idle, only background work outstanding",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID))
+ return nil
+ }
s.logger.Warn("rejected prompt while agent is already running",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go
new file mode 100644
index 0000000000..b8ba8d353f
--- /dev/null
+++ b/apps/backend/internal/orchestrator/turn_activity.go
@@ -0,0 +1,222 @@
+package orchestrator
+
+import (
+ "context"
+ "sync"
+
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+ "github.com/kandev/kandev/internal/events"
+ "github.com/kandev/kandev/internal/events/bus"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// turnActivity tracks, in memory, whether a session's open turn is being driven
+// by the foreground agent (actively generating) or is merely held open while a
+// spawned background task runs.
+//
+// It is the finer-grained signal behind checkSessionPromptable: a session whose
+// foreground turn has yielded to background work should accept a new message
+// even though its DB state still reads RUNNING. The single-scalar session state
+// cannot tell "the foreground agent is generating" from "the foreground turn is
+// idle but a spawned background task (a subagent, a run-in-background shell) is
+// still running", which is how a long background job used to lock the operator
+// out of the conversation.
+//
+// The absent/zero state means "foreground generating": callers default to the
+// pre-existing behaviour (reject a new prompt while RUNNING) unless a background
+// task has been explicitly registered for the session, so nothing changes for a
+// session that has no background work outstanding.
+type turnActivity struct {
+ mu sync.Mutex
+ background map[string]struct{} // outstanding background/spawned tool-call IDs
+ yielded bool // foreground handed off to background work
+}
+
+// turnActivityFor returns the per-session activity record, creating it when
+// create is true. Returns nil when the record is absent and create is false.
+func (s *Service) turnActivityFor(sessionID string, create bool) *turnActivity {
+ if v, ok := s.foregroundActivity.Load(sessionID); ok {
+ return v.(*turnActivity)
+ }
+ if !create {
+ return nil
+ }
+ ta := &turnActivity{background: make(map[string]struct{})}
+ actual, _ := s.foregroundActivity.LoadOrStore(sessionID, ta)
+ return actual.(*turnActivity)
+}
+
+// markForegroundGenerating records that the foreground agent produced output
+// (streamed a message/thinking chunk, or a fresh foreground prompt was
+// dispatched), so the turn is once again driven by the foreground even if a
+// background task is still outstanding. It returns true when this call actually
+// flipped the session out of the background-idle substate, so the caller can
+// publish the operator-facing activity signal only on a real transition.
+func (s *Service) markForegroundGenerating(sessionID string) bool {
+ if sessionID == "" {
+ return false
+ }
+ ta := s.turnActivityFor(sessionID, true)
+ ta.mu.Lock()
+ changed := ta.yielded
+ ta.yielded = false
+ ta.mu.Unlock()
+ return changed
+}
+
+// registerBackgroundTask records a spawned background task (a subagent Task or a
+// run-in-background shell). While at least one is outstanding, the foreground
+// turn is treated as "waiting on background" rather than "actively generating".
+// It returns true when this call flipped the session into the background-idle
+// substate (i.e. it was foreground-generating before), so the caller publishes
+// the activity signal only on the first background task, not every one.
+func (s *Service) registerBackgroundTask(sessionID, toolCallID string) bool {
+ if sessionID == "" || toolCallID == "" {
+ return false
+ }
+ ta := s.turnActivityFor(sessionID, true)
+ ta.mu.Lock()
+ changed := !ta.yielded
+ ta.background[toolCallID] = struct{}{}
+ ta.yielded = true
+ ta.mu.Unlock()
+ return changed
+}
+
+// hasBackgroundTask reports whether toolCallID is already tracked as outstanding
+// background work for the session. Used to make the tool_call_update
+// registration path fire only on the first recognizable frame: re-registering
+// on later updates would re-set `yielded` and clobber a foreground stream that
+// marked the turn generating again (see markForegroundGenerating).
+func (s *Service) hasBackgroundTask(sessionID, toolCallID string) bool {
+ ta := s.turnActivityFor(sessionID, false)
+ if ta == nil {
+ return false
+ }
+ ta.mu.Lock()
+ defer ta.mu.Unlock()
+ _, ok := ta.background[toolCallID]
+ return ok
+}
+
+// completeBackgroundTask clears a previously-registered background task. When no
+// background task remains, the foreground turn is no longer "waiting on
+// background". It returns true when clearing this task flipped the session back
+// out of the background-idle substate (the last outstanding task finished),
+// so the caller publishes the activity signal only on that final completion.
+func (s *Service) completeBackgroundTask(sessionID, toolCallID string) bool {
+ if sessionID == "" || toolCallID == "" {
+ return false
+ }
+ ta := s.turnActivityFor(sessionID, false)
+ if ta == nil {
+ return false
+ }
+ ta.mu.Lock()
+ delete(ta.background, toolCallID)
+ changed := false
+ if len(ta.background) == 0 && ta.yielded {
+ ta.yielded = false
+ changed = true
+ }
+ ta.mu.Unlock()
+ return changed
+}
+
+// clearTurnActivity drops all tracked activity for a session. Called from every
+// turn-close path (turn complete, error, cancel, teardown) so a fresh turn
+// starts from the "foreground generating" default.
+func (s *Service) clearTurnActivity(sessionID string) {
+ if sessionID == "" {
+ return
+ }
+ s.foregroundActivity.Delete(sessionID)
+}
+
+// isForegroundTurnGenerating reports whether the session's foreground agent turn
+// is actively generating. It returns true (generating) unless the turn has
+// yielded to an outstanding background task. An untracked session defaults to
+// true, preserving the historical "reject a new prompt while RUNNING" contract.
+func (s *Service) isForegroundTurnGenerating(sessionID string) bool {
+ ta := s.turnActivityFor(sessionID, false)
+ if ta == nil {
+ return true
+ }
+ ta.mu.Lock()
+ defer ta.mu.Unlock()
+ return !ta.yielded
+}
+
+// foregroundActivityValue reports the fine-grained busy substate of a session
+// for the operator-facing signal: "generating" when the foreground turn is
+// actively producing output (the default), "background" when it has yielded to
+// outstanding spawned work. Only meaningful while the session state is RUNNING.
+func (s *Service) foregroundActivityValue(sessionID string) v1.ForegroundActivity {
+ if s.isForegroundTurnGenerating(sessionID) {
+ return v1.ForegroundActivityGenerating
+ }
+ return v1.ForegroundActivityBackground
+}
+
+// ForegroundActivity exposes the in-memory fine-grained busy substate so the
+// page-load / list serialization layer can stamp it onto a RUNNING session's
+// DTO (ADR-0035). This is the same value that drives the
+// live task_session.activity_changed WS event, read straight from the in-memory
+// tracker — the single source of truth. There is no persisted copy: an untracked
+// session (including every session after a backend restart, which ends the turn)
+// reports the safe "generating" default, so a stale "you may type" can never be
+// serialized. Callers must only stamp the result on RUNNING sessions; for every
+// other state the coarse session state already tells the whole story.
+func (s *Service) ForegroundActivity(sessionID string) v1.ForegroundActivity {
+ return s.foregroundActivityValue(sessionID)
+}
+
+// publishForegroundActivityChanged emits the fine-grained busy signal so the
+// web composer and status indicator can distinguish "generating" from "waiting
+// on background work" without a coarse session-state transition. Callers invoke
+// it only when a flip actually happened (the mark/register/complete helpers
+// return that), so it never fires per background frame.
+func (s *Service) publishForegroundActivityChanged(ctx context.Context, taskID, sessionID string) {
+ if s.eventBus == nil || taskID == "" || sessionID == "" {
+ return
+ }
+ eventData := map[string]interface{}{
+ metaKeyTaskID: taskID,
+ metaKeySessionID: sessionID,
+ "foreground_activity": string(s.foregroundActivityValue(sessionID)),
+ }
+ if err := s.eventBus.Publish(ctx, events.TaskSessionActivityChanged,
+ bus.NewEvent(events.TaskSessionActivityChanged, "task-session", eventData)); err != nil {
+ s.logger.Warn("publish task_session.activity_changed failed",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID),
+ zap.Error(err))
+ }
+}
+
+// normalizedIsBackgroundTask reports whether a normalized tool payload represents
+// spawned background work the foreground turn waits on: a subagent Task, a
+// run-in-background shell command, or an active Claude Monitor watch.
+//
+// A create_task tool is deliberately NOT background — it spawns an independent
+// task/session with its own lifecycle and does not hold the spawning turn open.
+func normalizedIsBackgroundTask(n *streams.NormalizedPayload) bool {
+ if n == nil {
+ return false
+ }
+ if n.Kind() == streams.ToolKindSubagentTask {
+ return true
+ }
+ if se := n.ShellExec(); se != nil && se.Background {
+ return true
+ }
+ // A Monitor is a long-running watch the foreground turn is not actively
+ // generating against. It normalizes to a Generic payload, so it is
+ // recognized via the shared streams predicate rather than a tool-name match.
+ if n.IsActiveMonitor() {
+ return true
+ }
+ return false
+}
diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go
index b07a6bb29f..4f1a4776d7 100644
--- a/apps/backend/internal/task/dto/dto.go
+++ b/apps/backend/internal/task/dto/dto.go
@@ -230,6 +230,14 @@ type TaskSessionDTO struct {
IsPassthrough bool `json:"is_passthrough"`
ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
TaskEnvironmentID string `json:"task_environment_id,omitempty"`
+ // ForegroundActivity mirrors the in-memory fine-grained busy substate onto a
+ // RUNNING session so a fresh page-load / second tab sees the accept-input +
+ // working-in-background affordance without waiting for a WS flip
+ // (ADR-0035). Absent for every non-RUNNING session and
+ // whenever no provider is wired; a client treats absent as "generating".
+ // Not persisted — populated at the serialization boundary by
+ // EnrichForegroundActivity, never by FromTaskSession.
+ ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"`
}
// TaskSessionSummaryDTO is a lightweight version of TaskSessionDTO without snapshot fields.
@@ -266,6 +274,10 @@ type TaskSessionSummaryDTO struct {
IsPassthrough bool `json:"is_passthrough"`
ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
TaskEnvironmentID string `json:"task_environment_id,omitempty"`
+ // ForegroundActivity mirrors the in-memory fine-grained busy substate onto a
+ // RUNNING session (ADR-0035); see TaskSessionDTO. Absent
+ // for non-RUNNING sessions; populated by EnrichForegroundActivitySummary.
+ ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"`
// CommandCount is the number of tool_call messages on this session,
// surfaced inline in the timeline entry header ("ran N commands").
// Populated by ListTaskSessions; defaults to 0 for callers that don't
@@ -718,6 +730,34 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
return result
}
+// ForegroundActivityProvider surfaces the in-memory fine-grained busy substate
+// for a session (ADR-0035). It is satisfied by the
+// orchestrator; the serialization layer depends only on this narrow seam so it
+// takes no hard orchestrator dependency and can be faked in tests.
+type ForegroundActivityProvider interface {
+ ForegroundActivity(sessionID string) v1.ForegroundActivity
+}
+
+// EnrichForegroundActivity stamps the live fine-grained busy substate onto a full
+// session DTO. It is a no-op for a non-RUNNING session or a nil provider, so the
+// field is emitted (via omitempty) only where it is meaningful and never fabricated
+// for a session whose coarse state already tells the whole story.
+func EnrichForegroundActivity(dto *TaskSessionDTO, provider ForegroundActivityProvider) {
+ if dto == nil || provider == nil || dto.State != models.TaskSessionStateRunning {
+ return
+ }
+ dto.ForegroundActivity = provider.ForegroundActivity(dto.ID)
+}
+
+// EnrichForegroundActivitySummary is EnrichForegroundActivity for the lightweight
+// summary DTO used by the list endpoints.
+func EnrichForegroundActivitySummary(dto *TaskSessionSummaryDTO, provider ForegroundActivityProvider) {
+ if dto == nil || provider == nil || dto.State != models.TaskSessionStateRunning {
+ return
+ }
+ dto.ForegroundActivity = provider.ForegroundActivity(dto.ID)
+}
+
// WorkflowStepDTO represents a workflow step for API responses
type WorkflowStepDTO struct {
ID string `json:"id"`
diff --git a/apps/backend/internal/task/dto/foreground_activity_test.go b/apps/backend/internal/task/dto/foreground_activity_test.go
new file mode 100644
index 0000000000..7b6c4a99e2
--- /dev/null
+++ b/apps/backend/internal/task/dto/foreground_activity_test.go
@@ -0,0 +1,87 @@
+package dto
+
+import (
+ "testing"
+
+ "github.com/kandev/kandev/internal/task/models"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// fakeForegroundActivityProvider is a stand-in for the orchestrator's in-memory
+// tracker so the serialization layer can be tested without importing it.
+type fakeForegroundActivityProvider struct {
+ value v1.ForegroundActivity
+ called []string
+}
+
+func (f *fakeForegroundActivityProvider) ForegroundActivity(sessionID string) v1.ForegroundActivity {
+ f.called = append(f.called, sessionID)
+ return f.value
+}
+
+func TestEnrichForegroundActivity(t *testing.T) {
+ tests := []struct {
+ name string
+ state models.TaskSessionState
+ provider *fakeForegroundActivityProvider
+ want v1.ForegroundActivity
+ // wantQueried asserts whether the provider was consulted — a non-RUNNING
+ // session must never be queried so we never fabricate a substate.
+ wantQueried bool
+ }{
+ {
+ name: "running background is surfaced",
+ state: models.TaskSessionStateRunning,
+ provider: &fakeForegroundActivityProvider{value: v1.ForegroundActivityBackground},
+ want: v1.ForegroundActivityBackground,
+ wantQueried: true,
+ },
+ {
+ name: "running generating is surfaced",
+ state: models.TaskSessionStateRunning,
+ provider: &fakeForegroundActivityProvider{value: v1.ForegroundActivityGenerating},
+ want: v1.ForegroundActivityGenerating,
+ wantQueried: true,
+ },
+ {
+ name: "non-running is left empty and never queried",
+ state: models.TaskSessionStateWaitingForInput,
+ provider: &fakeForegroundActivityProvider{value: v1.ForegroundActivityBackground},
+ want: "",
+ wantQueried: false,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ dto := &TaskSessionDTO{ID: "s1", State: tc.state}
+ EnrichForegroundActivity(dto, tc.provider)
+ if dto.ForegroundActivity != tc.want {
+ t.Fatalf("full DTO: got %q, want %q", dto.ForegroundActivity, tc.want)
+ }
+ if queried := len(tc.provider.called) > 0; queried != tc.wantQueried {
+ t.Fatalf("full DTO: provider queried=%v, want %v", queried, tc.wantQueried)
+ }
+
+ summary := &TaskSessionSummaryDTO{ID: "s1", State: tc.state}
+ EnrichForegroundActivitySummary(summary, tc.provider)
+ if summary.ForegroundActivity != tc.want {
+ t.Fatalf("summary DTO: got %q, want %q", summary.ForegroundActivity, tc.want)
+ }
+ })
+ }
+}
+
+func TestEnrichForegroundActivity_NilProviderIsNoOp(t *testing.T) {
+ dto := &TaskSessionDTO{ID: "s1", State: models.TaskSessionStateRunning}
+ EnrichForegroundActivity(dto, nil)
+ if dto.ForegroundActivity != "" {
+ t.Fatalf("nil provider must not set a substate, got %q", dto.ForegroundActivity)
+ }
+
+ summary := &TaskSessionSummaryDTO{ID: "s1", State: models.TaskSessionStateRunning}
+ EnrichForegroundActivitySummary(summary, nil)
+ if summary.ForegroundActivity != "" {
+ t.Fatalf("nil provider must not set a substate, got %q", summary.ForegroundActivity)
+ }
+}
diff --git a/apps/backend/internal/task/handlers/foreground_activity_test.go b/apps/backend/internal/task/handlers/foreground_activity_test.go
new file mode 100644
index 0000000000..2d5003000a
--- /dev/null
+++ b/apps/backend/internal/task/handlers/foreground_activity_test.go
@@ -0,0 +1,128 @@
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/kandev/kandev/internal/task/dto"
+ "github.com/kandev/kandev/internal/task/models"
+ "github.com/kandev/kandev/internal/task/service"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// fakeForegroundActivityProvider stands in for the orchestrator's in-memory
+// tracker in handler tests.
+type fakeForegroundActivityProvider struct {
+ value v1.ForegroundActivity
+}
+
+func (f fakeForegroundActivityProvider) ForegroundActivity(string) v1.ForegroundActivity {
+ return f.value
+}
+
+// orchestratorWithActivity satisfies BOTH OrchestratorStarter and
+// dto.ForegroundActivityProvider so NewTaskHandlers derives the provider from it.
+type orchestratorWithActivity struct {
+ captureOrchestrator
+ value v1.ForegroundActivity
+}
+
+func (o *orchestratorWithActivity) ForegroundActivity(string) v1.ForegroundActivity {
+ return o.value
+}
+
+func newSessionHandlerService(t *testing.T, session *models.TaskSession) (*service.Service, *models.TaskSession) {
+ t.Helper()
+ repo := &mockRepository{sessions: map[string]*models.TaskSession{session.ID: session}}
+ svc := service.NewService(service.Repos{
+ Workspaces: repo, Tasks: repo, TaskRepos: repo,
+ Workflows: repo, Messages: repo, Turns: repo,
+ Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
+ Executors: repo, Environments: repo, TaskEnvironments: repo,
+ Reviews: repo,
+ }, nil, newTestLogger(t), service.RepositoryDiscoveryConfig{})
+ return svc, session
+}
+
+func TestNewTaskHandlers_DerivesForegroundActivityProvider(t *testing.T) {
+ // An orchestrator that implements the provider is picked up…
+ withActivity := &orchestratorWithActivity{value: v1.ForegroundActivityBackground}
+ h := NewTaskHandlers(nil, withActivity, nil, nil, newTestLogger(t))
+ require.NotNil(t, h.foregroundActivity, "provider should be derived from the orchestrator")
+ assert.Equal(t, v1.ForegroundActivityBackground, h.foregroundActivity.ForegroundActivity("s"))
+
+ // …and one that does not implement it leaves the field nil (field simply omitted).
+ plain := &captureOrchestrator{}
+ h2 := NewTaskHandlers(nil, plain, nil, nil, newTestLogger(t))
+ assert.Nil(t, h2.foregroundActivity)
+}
+
+func TestHTTPGetTaskSession_StampsForegroundActivityOnRunning(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ svc, _ := newSessionHandlerService(t, &models.TaskSession{
+ ID: "sess-run",
+ State: models.TaskSessionStateRunning,
+ })
+ h := &TaskHandlers{
+ service: svc,
+ foregroundActivity: fakeForegroundActivityProvider{value: v1.ForegroundActivityBackground},
+ logger: newTestLogger(t),
+ }
+
+ resp := doGetTaskSession(t, h, "sess-run")
+ assert.Equal(t, v1.ForegroundActivityBackground, resp.Session.ForegroundActivity,
+ "a RUNNING session must carry the fine-grained busy substate on a fresh fetch")
+}
+
+func TestHTTPGetTaskSession_OmitsForegroundActivityWhenNotRunning(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ svc, _ := newSessionHandlerService(t, &models.TaskSession{
+ ID: "sess-wait",
+ State: models.TaskSessionStateWaitingForInput,
+ })
+ // Even a provider that would report "background" must not leak onto a
+ // non-RUNNING session — the coarse state already tells the whole story.
+ h := &TaskHandlers{
+ service: svc,
+ foregroundActivity: fakeForegroundActivityProvider{value: v1.ForegroundActivityBackground},
+ logger: newTestLogger(t),
+ }
+
+ resp := doGetTaskSession(t, h, "sess-wait")
+ assert.Empty(t, resp.Session.ForegroundActivity,
+ "a non-RUNNING session must not carry a fine-grained busy substate")
+
+ // And confirm the JSON wire actually omits the key (omitempty).
+ rec := getTaskSessionRecorder(t, h, "sess-wait")
+ assert.NotContains(t, rec.Body.String(), "foreground_activity")
+}
+
+func doGetTaskSession(t *testing.T, h *TaskHandlers, id string) dto.GetTaskSessionResponse {
+ t.Helper()
+ rec := getTaskSessionRecorder(t, h, id)
+ require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
+ var resp dto.GetTaskSessionResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ return resp
+}
+
+func getTaskSessionRecorder(t *testing.T, h *TaskHandlers, id string) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodGet, "/task-sessions/"+id, nil).
+ WithContext(context.Background())
+ c.Params = gin.Params{{Key: "id", Value: id}}
+ h.httpGetTaskSession(c)
+ return rec
+}
+
+var _ OrchestratorStarter = (*orchestratorWithActivity)(nil)
+var _ dto.ForegroundActivityProvider = (*orchestratorWithActivity)(nil)
diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go
index 4ea435a36c..230b41bca2 100644
--- a/apps/backend/internal/task/handlers/task_handlers.go
+++ b/apps/backend/internal/task/handlers/task_handlers.go
@@ -33,6 +33,7 @@ type handlerRepo interface {
type TaskHandlers struct {
service *service.Service
orchestrator OrchestratorStarter
+ foregroundActivity dto.ForegroundActivityProvider
repo handlerRepo
planService *service.PlanService
handoffSvc *service.HandoffService
@@ -91,13 +92,21 @@ type OrchestratorStarter interface {
}
func NewTaskHandlers(svc *service.Service, orchestrator OrchestratorStarter, repo handlerRepo, planService *service.PlanService, log *logger.Logger) *TaskHandlers {
- return &TaskHandlers{
+ h := &TaskHandlers{
service: svc,
orchestrator: orchestrator,
repo: repo,
planService: planService,
logger: log.WithFields(zap.String("component", "task-task-handlers")),
}
+ // The orchestrator also surfaces the in-memory fine-grained busy substate
+ // (ADR-0035). Derive the narrow provider from it so the
+ // session-fetch handlers can stamp foreground_activity onto RUNNING sessions
+ // without waiting for a WS flip. Nil (e.g. in tests) simply omits the field.
+ if fa, ok := orchestrator.(dto.ForegroundActivityProvider); ok {
+ h.foregroundActivity = fa
+ }
+ return h
}
func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *service.Service, orchestrator OrchestratorStarter, repo handlerRepo, planService *service.PlanService, log *logger.Logger) *TaskHandlers {
diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go
index f94a185736..9845c273cd 100644
--- a/apps/backend/internal/task/handlers/task_http_handlers.go
+++ b/apps/backend/internal/task/handlers/task_http_handlers.go
@@ -262,7 +262,9 @@ func (h *TaskHandlers) httpListTaskSessions(c *gin.Context) {
sessionDTOs := make([]dto.TaskSessionSummaryDTO, 0, len(sessions))
ids := make([]string, 0, len(sessions))
for _, session := range sessions {
- sessionDTOs = append(sessionDTOs, dto.FromTaskSessionSummary(session))
+ summary := dto.FromTaskSessionSummary(session)
+ dto.EnrichForegroundActivitySummary(&summary, h.foregroundActivity)
+ sessionDTOs = append(sessionDTOs, summary)
ids = append(ids, session.ID)
}
// Resolve the per-session tool_call counts so the frontend can render
@@ -304,8 +306,10 @@ func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) {
handleNotFound(c, h.logger, err, "task session not found")
return
}
+ sessionDTO := dto.FromTaskSession(session)
+ dto.EnrichForegroundActivity(&sessionDTO, h.foregroundActivity)
c.JSON(http.StatusOK, dto.GetTaskSessionResponse{
- Session: dto.FromTaskSession(session),
+ Session: sessionDTO,
})
}
@@ -324,8 +328,10 @@ func (h *TaskHandlers) httpDismissLastAgentError(c *gin.Context) {
handleNotFound(c, h.logger, err, "task session not found")
return
}
+ sessionDTO := dto.FromTaskSession(session)
+ dto.EnrichForegroundActivity(&sessionDTO, h.foregroundActivity)
c.JSON(http.StatusOK, dto.GetTaskSessionResponse{
- Session: dto.FromTaskSession(session),
+ Session: sessionDTO,
})
}
diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go
index d5869fc988..69dc094477 100644
--- a/apps/backend/internal/task/handlers/task_ws_handlers.go
+++ b/apps/backend/internal/task/handlers/task_ws_handlers.go
@@ -37,7 +37,9 @@ func (h *TaskHandlers) doListTaskSessions(ctx context.Context, msg *ws.Message,
}
sessionDTOs := make([]dto.TaskSessionSummaryDTO, 0, len(sessions))
for _, session := range sessions {
- sessionDTOs = append(sessionDTOs, dto.FromTaskSessionSummary(session))
+ summary := dto.FromTaskSessionSummary(session)
+ dto.EnrichForegroundActivitySummary(&summary, h.foregroundActivity)
+ sessionDTOs = append(sessionDTOs, summary)
}
resp := dto.ListTaskSessionSummariesResponse{
Sessions: sessionDTOs,
diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go
index 1c9d8ff74e..2f9395dd0c 100644
--- a/apps/backend/pkg/api/v1/task.go
+++ b/apps/backend/pkg/api/v1/task.go
@@ -43,6 +43,24 @@ const (
TaskSessionStateCancelled TaskSessionState = "CANCELLED"
)
+// ForegroundActivity is the fine-grained busy substate of a RUNNING session
+// (ADR-0035). It distinguishes a foreground turn that is
+// actively generating from one that is idle, held open only by spawned
+// background work (a subagent task, a run-in-background shell, an active
+// Monitor). It is only meaningful while the session state is RUNNING; for every
+// other state the coarse state already tells the whole story.
+type ForegroundActivity string
+
+const (
+ // ForegroundActivityGenerating means the foreground agent is producing
+ // output — the historical "busy" condition; input stays gated.
+ ForegroundActivityGenerating ForegroundActivity = "generating"
+ // ForegroundActivityBackground means the foreground turn has yielded to
+ // outstanding background work; input is accepted even though the session
+ // still reads RUNNING and the "working" affordance stays up.
+ ForegroundActivityBackground ForegroundActivity = "background"
+)
+
// MessageType represents a normalized session message type.
type MessageType string
diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go
index cd95a90e91..b141eb9b49 100644
--- a/apps/backend/pkg/websocket/actions.go
+++ b/apps/backend/pkg/websocket/actions.go
@@ -190,6 +190,8 @@ const (
ActionSessionMessageUpdated = "session.message.updated"
ActionSessionMessageDeleted = "session.message.deleted"
ActionSessionStateChanged = "session.state_changed"
+ ActionSessionActivityChanged = "session.activity_changed"
+ ActionSessionWaitingForInput = "session.waiting_for_input"
ActionSessionAgentctlStarting = "session.agentctl_starting"
ActionSessionAgentctlReady = "session.agentctl_ready"
ActionSessionAgentctlError = "session.agentctl_error"
diff --git a/apps/web/components/task/session-reopen-menu.tsx b/apps/web/components/task/session-reopen-menu.tsx
index 8278ca63c9..5c9fb65de4 100644
--- a/apps/web/components/task/session-reopen-menu.tsx
+++ b/apps/web/components/task/session-reopen-menu.tsx
@@ -128,7 +128,9 @@ export function SessionReopenMenuItems({
{session.state !== "RUNNING" &&
session.state !== "STARTING" &&
session.state !== "WAITING_FOR_INPUT" && (
- {getSessionStateIcon(session.state, "h-3 w-3")}
+
+ {getSessionStateIcon(session.state, "h-3 w-3", session.foreground_activity)}
+
)}
);
diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx
index 34b0339493..c984a6a6b2 100644
--- a/apps/web/components/task/sessions-dropdown.tsx
+++ b/apps/web/components/task/sessions-dropdown.tsx
@@ -432,7 +432,9 @@ function SessionRow({
{STATUS_LABELS[status]}
diff --git a/apps/web/e2e/tests/chat/busy-signal.spec.ts b/apps/web/e2e/tests/chat/busy-signal.spec.ts
new file mode 100644
index 0000000000..99e3cae97a
--- /dev/null
+++ b/apps/web/e2e/tests/chat/busy-signal.spec.ts
@@ -0,0 +1,180 @@
+import { type Page } from "@playwright/test";
+import { test, expect } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { typeWhileBusy } from "../../helpers/type-while-busy";
+import { SessionPage } from "../../pages/session-page";
+
+// ---------------------------------------------------------------------------
+// ADR-0035 — operator-visible surfacing.
+//
+// The mock `/background ` command spawns a top-level subagent Task and then
+// holds the turn open with NO foreground output, so the session sits in the
+// background-idle substate: RUNNING (working affordance up) yet promptable.
+//
+// The distinguishing observable of the background-idle window (b) is that the
+// agent status still reads "running" (the working affordance) WHILE the composer
+// shows its idle/accept placeholder — condition (a), a genuinely generating
+// turn, keeps the "Queue more instructions…" busy placeholder and diverts input
+// to the queue.
+// ---------------------------------------------------------------------------
+
+async function seedTaskAndWaitForIdle(
+ testPage: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+): Promise {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ await testPage.goto(`/t/${task.id}`);
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 30_000 });
+
+ return session;
+}
+
+test.describe("Fine-grained busy signal — composer + status", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("background-idle session shows working AND accepts input, then flips to done", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Busy signal (b)");
+
+ // Spawn background work: foreground yields to a held-open subagent.
+ await session.sendMessage("/background 12s");
+
+ // The turn is working…
+ await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
+
+ // …and once it yields to background work the composer flips to accept-input
+ // (idle placeholder) even though the status still reads "running": the two
+ // independent facts — "you may type" AND "work is still in progress" — are
+ // both visible at once. This is the whole point of the fine-grained signal.
+ await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
+ await expect(session.agentStatus()).toBeVisible();
+
+ // The working affordance must NOT be the done state while background runs.
+ await expect(session.turnComplete()).toHaveCount(0);
+
+ // Once the background subagent completes, the turn ends → done/idle and the
+ // working affordance clears.
+ await expect(session.agentStatus()).not.toBeVisible({ timeout: 40_000 });
+ await expect(session.idleInput()).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("input typed during the background-idle window is sent, not queued", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Busy signal send");
+
+ await session.sendMessage("/background 20s");
+ await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
+
+ // Wait for the background-idle window (accept-input while still working).
+ await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
+ await expect(session.agentStatus()).toBeVisible();
+
+ // Type and submit — the message is accepted and posted, not diverted.
+ const editor = testPage.locator(".tiptap.ProseMirror").first();
+ await editor.click();
+ await editor.fill("are you still working?");
+ const modifier = process.platform === "darwin" ? "Meta" : "Control";
+ await editor.press(`${modifier}+Enter`);
+
+ // It appears in the conversation as a sent user message…
+ await expect(testPage.getByText("are you still working?")).toBeVisible({ timeout: 15_000 });
+ // …and was NOT silently diverted to the queue.
+ await expect(testPage.getByTestId("queue-chip")).not.toBeVisible();
+ });
+
+ test("background-idle substate survives a fresh page reload (boot payload, no WS flip)", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ const session = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Busy signal reload",
+ );
+
+ // Open a long background window so it is still held after the reload lands.
+ await session.sendMessage("/background 20s");
+ await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
+
+ // Reach the background-idle window: accept-input while still working.
+ await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
+ await expect(session.agentStatus()).toBeVisible();
+
+ // Reload: this is a fresh client that loads MID background-window. The
+ // substate is not persisted and no activity_changed WS flip is due (the turn
+ // was already background before the reload), so the only way the composer can
+ // show accept-input + working here is if the boot payload carried the
+ // fine-grained substate. This is the exact gap this batch closes: before it,
+ // a reload showed the coarse "Queue more instructions…" busy affordance until
+ // the next flip — which, for a genuinely idle-on-background turn, never comes.
+ await testPage.reload();
+ await session.waitForLoad();
+
+ await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
+ await expect(session.agentStatus()).toBeVisible();
+ await expect(session.turnComplete()).toHaveCount(0);
+ });
+
+ test("a turn with no background work keeps the composer gated (queues input)", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(60_000);
+
+ const session = await seedTaskAndWaitForIdle(
+ testPage,
+ apiClient,
+ seedData,
+ "Busy signal gated",
+ );
+
+ // A plain slow turn generates in the foreground the whole time — no
+ // recognized background work — so the composer stays gated exactly as today.
+ await session.sendMessage("/slow 10s");
+ await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
+
+ // The accept-input (idle) placeholder must NOT appear while generating.
+ await expect(session.idleInput()).not.toBeVisible();
+
+ const editor = testPage.locator(".tiptap.ProseMirror").first();
+ await typeWhileBusy(testPage, editor, "should queue");
+ const submitBtn = testPage.getByTestId("submit-message-button");
+ await expect(submitBtn).toBeVisible({ timeout: 5_000 });
+ await submitBtn.click();
+
+ // Diverted to the queue, not posted — the historical contract is unchanged.
+ await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 });
+ });
+});
diff --git a/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
new file mode 100644
index 0000000000..0b189a8819
--- /dev/null
+++ b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
@@ -0,0 +1,95 @@
+import { test, expect } from "../../fixtures/test-base";
+import { SessionPage } from "../../pages/session-page";
+
+// Mobile (Pixel 5) coverage for ADR-0035. Filename matches
+// /mobile-.*\.spec\.ts/ so the `mobile-chrome` project picks it up. The composer
+// gating and the working affordance derive from the same shared hooks the
+// desktop path uses, so this asserts the operator-visible outcome holds at
+// mobile width: a background-idle session shows "working" while the composer
+// accepts input, then flips to done once the background task finishes.
+
+test.describe("Mobile fine-grained busy signal", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("background-idle session shows working AND accepts input at mobile width", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ // Drive the background window via the auto-started first turn (the task
+ // description) rather than a sendMessage follow-up: the live turn reliably
+ // reaches the mobile client while it is still running (same approach as
+ // mobile-empty-turn.spec.ts).
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Mobile busy signal",
+ seedData.agentProfileId,
+ {
+ description: "/background 12s",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ // The auto-started turn is working…
+ await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 });
+
+ // …and once it yields to background work the composer flips to accept-input
+ // (idle placeholder) while the status still reads "working" — both facts
+ // visible at once at mobile width.
+ await expect(session.idleInput()).toBeVisible({ timeout: 25_000 });
+ await expect(session.agentStatus()).toBeVisible();
+ await expect(session.turnComplete()).toHaveCount(0);
+
+ // After the background task completes, the turn ends → done/idle.
+ await expect(session.agentStatus()).not.toBeVisible({ timeout: 40_000 });
+ await expect(session.idleInput()).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("background-idle substate survives a reload at mobile width", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(90_000);
+
+ // Long background window so it is still held after the reload lands.
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Mobile busy signal reload",
+ seedData.agentProfileId,
+ {
+ description: "/background 20s",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+
+ // Reach the background-idle window.
+ await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 });
+ await expect(session.idleInput()).toBeVisible({ timeout: 25_000 });
+ await expect(session.agentStatus()).toBeVisible();
+
+ // Reload mid-window: a fresh mobile client. The accept-input + working
+ // affordance must come straight from the boot payload (no persisted value,
+ // no activity_changed WS flip due) — ADR-0035.
+ await testPage.reload();
+ await session.waitForLoad();
+
+ await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
+ await expect(session.agentStatus()).toBeVisible();
+ await expect(session.turnComplete()).toHaveCount(0);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-session-state.test.ts b/apps/web/hooks/domains/session/use-session-state.test.ts
index 2e2ee01585..904db5739e 100644
--- a/apps/web/hooks/domains/session/use-session-state.test.ts
+++ b/apps/web/hooks/domains/session/use-session-state.test.ts
@@ -185,3 +185,57 @@ describe("useSessionState", () => {
});
});
});
+
+describe("useSessionState — fine-grained busy signal (foreground_activity)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockActiveTaskId = null;
+ mockActiveSessionId = null;
+ mockSessionItems = {};
+ mockSession = null;
+ mockTask = null;
+ mockPrepareProgress = {};
+ });
+
+ // ADR-0035. (a) generating gates the composer;
+ // (b) background-idle accepts input but stays working; (c) fully idle.
+ it("(a) RUNNING+generating gates the composer", () => {
+ mockSession = {
+ ...createMockSession("session-1", "task-1", "RUNNING"),
+ foreground_activity: "generating",
+ };
+
+ const { result } = renderHook(() => useSessionState("session-1"));
+
+ // Gated (busy) AND working.
+ expect(result.current.isAgentBusy).toBe(true);
+ expect(result.current.isWorking).toBe(true);
+ });
+
+ it("(b) RUNNING+background accepts input while the working affordance stays up", () => {
+ mockSession = {
+ ...createMockSession("session-1", "task-1", "RUNNING"),
+ foreground_activity: "background",
+ };
+
+ const { result } = renderHook(() => useSessionState("session-1"));
+
+ // Composer enabled (not busy) — the message sends instead of queueing —
+ // yet still working, never "done".
+ expect(result.current.isAgentBusy).toBe(false);
+ expect(result.current.isWorking).toBe(true);
+ });
+
+ it("a stale background substate is ignored once the session leaves RUNNING", () => {
+ mockSession = {
+ ...createMockSession("session-1", "task-1", "COMPLETED"),
+ foreground_activity: "background",
+ };
+
+ const { result } = renderHook(() => useSessionState("session-1"));
+
+ // Fully idle (c): neither working nor busy.
+ expect(result.current.isWorking).toBe(false);
+ expect(result.current.isAgentBusy).toBe(false);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-session-state.ts b/apps/web/hooks/domains/session/use-session-state.ts
index 6b5fb804bd..416f5a7acd 100644
--- a/apps/web/hooks/domains/session/use-session-state.ts
+++ b/apps/web/hooks/domains/session/use-session-state.ts
@@ -3,10 +3,24 @@ import { useSession } from "@/hooks/domains/session/use-session";
import { useTask } from "@/hooks/use-task";
import type { TaskSession } from "@/lib/types/http";
-function deriveSessionFlags(state: TaskSession["state"] | undefined, errorMessage?: string) {
+export function deriveSessionFlags(session: TaskSession | null | undefined) {
+ const state = session?.state;
+ const errorMessage = session?.error_message;
const isStarting = state === "STARTING";
- const isAgentBusy = state === "RUNNING";
- const isWorking = isStarting || isAgentBusy;
+ const isRunning = state === "RUNNING";
+ // ADR-0035. Three conditions, not two:
+ // (a) generating — RUNNING, foreground actively producing output
+ // (b) background-idle — RUNNING, foreground yielded to spawned background work
+ // (c) fully idle — not RUNNING
+ // `isAgentBusy` gates the composer (queue-vs-send): only a foreground-
+ // generating turn (a) blocks input; (b) accepts it. An absent/unknown
+ // substate defaults to generating, preserving the historical
+ // reject-while-RUNNING contract.
+ const isBackgroundIdle = isRunning && session?.foreground_activity === "background";
+ const isAgentBusy = isRunning && !isBackgroundIdle;
+ // `isWorking` drives the spinner/affordance: any live turn (generating OR
+ // background-idle) plus STARTING — it must stay up through (b).
+ const isWorking = isStarting || isRunning;
const isFailed = state === "FAILED" || state === "CANCELLED";
const needsRecovery = state === "WAITING_FOR_INPUT" && !!errorMessage;
return { isStarting, isWorking, isAgentBusy, isFailed, needsRecovery };
@@ -41,7 +55,7 @@ export function useSessionState(sessionId: string | null, options: UseSessionSta
);
const taskDescription = task?.description ?? null;
- const flags = deriveSessionFlags(session?.state, session?.error_message);
+ const flags = deriveSessionFlags(session);
const isPreparingEnvironment = prepareStatus === "preparing";
return {
diff --git a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
index c5f2c78c5c..863d02ebf5 100644
--- a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
+++ b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
@@ -140,6 +140,39 @@ describe("setTaskSessionsForTask preserves WS-seeded fields", () => {
});
});
+// ADR-0035 — a fresh page-load / second tab receives the
+// fine-grained busy substate on the boot payload (and now on the REST/WS session
+// endpoints). Hydration and any subsequent list refresh must not drop it, or the
+// coarse busy affordance would persist until the next WS flip — the exact gap
+// this batch closes.
+describe("setTaskSession preserves foreground_activity across merges", () => {
+ it("keeps a boot-seeded background substate when a later list update omits the field", () => {
+ const store = makeStore();
+
+ // Boot payload seeds the RUNNING session as background-idle.
+ store.getState().setTaskSession(makeSession({ foreground_activity: "background" }));
+ expect(store.getState().taskSessions.items[SESSION_ID].foreground_activity).toBe("background");
+
+ // A later list/get refresh that omits the field (older code path, or a race)
+ // must not clobber the boot value — mergeTaskSession spreads absent keys through.
+ store.getState().setTaskSessionsForTask(TASK_ID, [makeSession({ repository_id: "repo-1" })]);
+
+ const session = store.getState().taskSessions.items[SESSION_ID];
+ expect(session.foreground_activity).toBe("background");
+ expect(session.repository_id).toBe("repo-1");
+ });
+
+ it("applies an explicit substate flip from an enriched update", () => {
+ const store = makeStore();
+
+ store.getState().setTaskSession(makeSession({ foreground_activity: "background" }));
+ // The enriched endpoint now reports the turn is generating again.
+ store.getState().setTaskSession(makeSession({ foreground_activity: "generating" }));
+
+ expect(store.getState().taskSessions.items[SESSION_ID].foreground_activity).toBe("generating");
+ });
+});
+
function makeEntry(overrides: Partial = {}): QueuedMessage {
return {
id: "entry-1",
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index 60f43dcbe5..dbfeca43b0 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -9,6 +9,7 @@ export type { OfficeEventType, OfficeEventPayload } from "./office-events";
import type {
AvailableAgent,
+ ForegroundActivity,
SavedLayout,
SidebarViewApi,
SidebarViewDraftApi,
@@ -247,6 +248,28 @@ export type TaskSessionStateChangedPayload = {
review_status?: string;
// Task environment (for session→environment mapping)
task_environment_id?: string;
+ // Fine-grained busy substate (see ADR-0035), carried on every transition so
+ // the client resets stale values; intra-RUNNING flips arrive on
+ // session.activity_changed instead.
+ foreground_activity?: ForegroundActivity;
+};
+
+/**
+ * Payload for `session.activity_changed` — the fine-grained busy signal
+ * (see ADR-0035). Fires when a RUNNING session's foreground turn flips between
+ * generating and idle-on-background-work, with no coarse state change.
+ */
+export type TaskSessionActivityChangedPayload = {
+ task_id: string;
+ session_id: string;
+ foreground_activity: ForegroundActivity;
+};
+
+export type TaskSessionWaitingForInputPayload = {
+ task_id: string;
+ session_id: string;
+ title: string;
+ body: string;
};
export type TaskSessionNotificationPayload = {
@@ -510,6 +533,14 @@ export type BackendMessageMap = OfficeBackendMessageMap &
"session.turn_finished",
TaskSessionNotificationPayload
>;
+ "session.activity_changed": BackendMessage<
+ "session.activity_changed",
+ TaskSessionActivityChangedPayload
+ >;
+ "session.waiting_for_input": BackendMessage<
+ "session.waiting_for_input",
+ TaskSessionWaitingForInputPayload
+ >;
"session.clarification_requested": BackendMessage<
"session.clarification_requested",
TaskSessionNotificationPayload
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 7443255bdc..1552b0d3ea 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -157,6 +157,17 @@ export type TaskSessionState =
export type TaskPendingAction = "clarification" | "permission";
+/**
+ * Fine-grained busy substate of a RUNNING session (see ADR-0035). Distinguishes
+ * a foreground turn that is actively generating from one that is idle, held open
+ * only by spawned background work (a subagent task, a run-in-background shell, an
+ * active Monitor). Only meaningful while `state === "RUNNING"`; for every other
+ * state the coarse state already tells the whole story. Delivered live over the
+ * `session.activity_changed` WS event and reset on every `session.state_changed`
+ * transition; absent/`null` is treated as "generating" (the safe default).
+ */
+export type ForegroundActivity = "generating" | "background";
+
export type Workflow = {
id: WorkflowId;
workspace_id: WorkspaceId;
@@ -401,6 +412,12 @@ export type TaskSession = {
worktrees?: TaskSessionWorktree[];
task_environment_id?: string;
state: TaskSessionState;
+ /**
+ * Fine-grained busy substate while `state === "RUNNING"`
+ * (ADR-0035). Pushed over `session.activity_changed`;
+ * absent/`null` means the foreground turn is generating (the safe default).
+ */
+ foreground_activity?: ForegroundActivity | null;
error_message?: string;
metadata?: Record | null;
agent_profile_snapshot?: Record | null;
diff --git a/apps/web/lib/ui/state-icons.test.tsx b/apps/web/lib/ui/state-icons.test.tsx
index 043c87726d..94d71d74c8 100644
--- a/apps/web/lib/ui/state-icons.test.tsx
+++ b/apps/web/lib/ui/state-icons.test.tsx
@@ -1,13 +1,24 @@
import { describe, expect, it } from "vitest";
import { isValidElement, type ReactNode } from "react";
-import { IconCheck, IconMessageQuestion } from "@tabler/icons-react";
-import { getTaskStateIcon, shouldShowTaskRunningSpinner } from "./state-icons";
+import {
+ IconCheck,
+ IconCircleCheck,
+ IconCircleFilled,
+ IconLoader2,
+ IconMessageQuestion,
+} from "@tabler/icons-react";
+import { getSessionStateIcon, getTaskStateIcon, shouldShowTaskRunningSpinner } from "./state-icons";
function iconType(node: ReactNode) {
if (!isValidElement(node)) throw new Error("Expected React element");
return node.type;
}
+function iconClassName(node: ReactNode): string {
+ if (!isValidElement(node)) throw new Error("Expected React element");
+ return (node.props as { className?: string }).className ?? "";
+}
+
describe("getTaskStateIcon", () => {
it("uses the question icon for waiting-for-input task state", () => {
expect(iconType(getTaskStateIcon("WAITING_FOR_INPUT"))).toBe(IconMessageQuestion);
@@ -22,6 +33,51 @@ describe("getTaskStateIcon", () => {
});
});
+describe("getSessionStateIcon — fine-grained busy tri-state", () => {
+ // ADR-0035. Three distinguishable conditions:
+ // (a) RUNNING + generating → the established static "running" dot (unchanged)
+ // (b) RUNNING + background → working-in-background spinner, NOT the done check
+ // (c) COMPLETED → done checkmark
+ it("(a) keeps the established static running dot while the foreground is generating", () => {
+ // The fine-grained signal only ADDS a background indicator; the foreground
+ // running affordance is deliberately left as it always was (static dot).
+ const a = getSessionStateIcon("RUNNING", undefined, "generating");
+ expect(iconType(a)).toBe(IconCircleFilled);
+ expect(iconClassName(a)).not.toContain("animate-spin");
+ });
+
+ it("(a) defaults to the running dot when the substate is unknown", () => {
+ // Absent/null substate must preserve the historical RUNNING affordance.
+ expect(iconType(getSessionStateIcon("RUNNING"))).toBe(IconCircleFilled);
+ expect(iconType(getSessionStateIcon("RUNNING", undefined, null))).toBe(IconCircleFilled);
+ });
+
+ it("(b) shows a working spinner — never the done checkmark — while background work runs", () => {
+ const b = getSessionStateIcon("RUNNING", undefined, "background");
+ expect(iconType(b)).toBe(IconLoader2);
+ expect(iconType(b)).not.toBe(IconCircleCheck);
+ expect(iconClassName(b)).toContain("animate-spin");
+ });
+
+ it("(b) is visually distinct from (a) so the operator can tell them apart", () => {
+ const a = iconClassName(getSessionStateIcon("RUNNING", undefined, "generating"));
+ const b = iconClassName(getSessionStateIcon("RUNNING", undefined, "background"));
+ expect(a).not.toBe(b);
+ });
+
+ it("(c) flips to the done checkmark only once the session leaves RUNNING", () => {
+ // The (b)→(c) flip: the coarse state stays RUNNING while background work is
+ // outstanding, so the checkmark appears only after the last task finishes
+ // and the session settles to COMPLETED.
+ expect(iconType(getSessionStateIcon("COMPLETED"))).toBe(IconCircleCheck);
+ // A stale "background" substate must not resurrect a spinner on a terminal
+ // session — the coarse state governs (c).
+ expect(iconType(getSessionStateIcon("COMPLETED", undefined, "background"))).toBe(
+ IconCircleCheck,
+ );
+ });
+});
+
describe("shouldShowTaskRunningSpinner", () => {
it("returns false for non-loading task states without an active session", () => {
expect(shouldShowTaskRunningSpinner("COMPLETED")).toBe(false);
diff --git a/apps/web/lib/ui/state-icons.tsx b/apps/web/lib/ui/state-icons.tsx
index d789a164f1..8a19a4117a 100644
--- a/apps/web/lib/ui/state-icons.tsx
+++ b/apps/web/lib/ui/state-icons.tsx
@@ -3,15 +3,15 @@ import {
IconAlertCircle,
IconAlertTriangle,
IconCheck,
- IconCircleFilled,
IconCircleCheck,
+ IconCircleFilled,
IconClock,
IconLoader2,
IconMessageQuestion,
IconPlayerPause,
IconX,
} from "@tabler/icons-react";
-import type { TaskSessionState, TaskState } from "@/lib/types/http";
+import type { ForegroundActivity, TaskSessionState, TaskState } from "@/lib/types/http";
import { cn } from "@/lib/utils";
type IconConfig = {
@@ -41,6 +41,10 @@ const TASK_STATE_ICONS: Record = {
const SESSION_STATE_ICONS: Record = {
CREATED: { Icon: IconAlertCircle, className: STYLE_MUTED },
STARTING: { Icon: IconLoader2, className: STYLE_LOADING },
+ // (a) generating: the foreground agent is actively producing output. This is
+ // the established "session is running" indicator and is deliberately left
+ // unchanged — the fine-grained busy signal only ADDS a distinct
+ // background-work indicator (below); it does not restyle foreground running.
RUNNING: { Icon: IconCircleFilled, className: "text-emerald-500" },
// Office sessions: agent process torn down, conversation paused. Use the
// pause icon — visually distinct from RUNNING and from terminal states.
@@ -51,6 +55,17 @@ const SESSION_STATE_ICONS: Record = {
CANCELLED: { Icon: IconPlayerPause, className: STYLE_MUTED },
};
+// (b) background-idle: the foreground turn has yielded to spawned background
+// work (ADR-0035). A spinner — the operator can see the
+// agent is not done — visually separate from the static "generating" dot (a) by
+// its motion, and never the done checkmark. The spinner (work in motion) reads
+// as "something is still running in the background" while the foreground is
+// idle; the solid dot stays reserved for the foreground actively generating.
+const SESSION_BACKGROUND_ICON: IconConfig = {
+ Icon: IconLoader2,
+ className: "text-emerald-500 animate-spin",
+};
+
const DEFAULT_TASK_ICON: IconConfig = {
Icon: IconAlertCircle,
className: STYLE_MUTED,
@@ -135,9 +150,25 @@ export function getTaskStateIcon(
return ;
}
-export function getSessionStateIcon(state?: TaskSessionState, className?: string) {
- const config = state
- ? (SESSION_STATE_ICONS[state] ?? DEFAULT_SESSION_ICON)
- : DEFAULT_SESSION_ICON;
+function getSessionStateIconConfig(
+ state?: TaskSessionState,
+ foregroundActivity?: ForegroundActivity | null,
+): IconConfig {
+ // (b) background-idle wins over the default RUNNING (generating) icon: while
+ // the foreground turn waits on spawned background work the session must read
+ // as "working in background", never as done (ADR-0035).
+ if (state === "RUNNING" && foregroundActivity === "background") {
+ return SESSION_BACKGROUND_ICON;
+ }
+ if (!state) return DEFAULT_SESSION_ICON;
+ return SESSION_STATE_ICONS[state] ?? DEFAULT_SESSION_ICON;
+}
+
+export function getSessionStateIcon(
+ state?: TaskSessionState,
+ className?: string,
+ foregroundActivity?: ForegroundActivity | null,
+) {
+ const config = getSessionStateIconConfig(state, foregroundActivity);
return ;
}
diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts
index 8572c1cddb..05fbdf4c12 100644
--- a/apps/web/lib/ws/handlers/agent-session.test.ts
+++ b/apps/web/lib/ws/handlers/agent-session.test.ts
@@ -840,3 +840,102 @@ describe("session.state_changed → agentctl ready fallback", () => {
expect(setSessionAgentctlStatus).not.toHaveBeenCalled();
});
});
+
+describe("session.activity_changed handler — fine-grained busy signal", () => {
+ const ACTIVITY_EVENT = "session.activity_changed";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ function makeActivityMessage(payload: {
+ task_id?: string;
+ session_id?: string;
+ foreground_activity?: string;
+ }) {
+ return { id: "m", type: "notification" as const, action: ACTIVITY_EVENT, payload };
+ }
+
+ it("annotates an existing RUNNING session with the background substate", () => {
+ const upsert = vi.fn();
+ const store = makeStore({
+ taskSessions: { items: { "s-1": { id: "s-1", task_id: "t-1", state: "RUNNING" } } },
+ upsertTaskSessionFromEvent: upsert,
+ });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handler = registerTaskSessionHandlers(store)[ACTIVITY_EVENT] as (msg: any) => void;
+
+ handler(
+ makeActivityMessage({ task_id: "t-1", session_id: "s-1", foreground_activity: "background" }),
+ );
+
+ expect(upsert).toHaveBeenCalledTimes(1);
+ expect(upsert.mock.calls[0][1]).toMatchObject({
+ id: "s-1",
+ state: "RUNNING",
+ foreground_activity: "background",
+ });
+ });
+
+ it("flips back to generating on the next activity event", () => {
+ const upsert = vi.fn();
+ const store = makeStore({
+ taskSessions: { items: { "s-1": { id: "s-1", task_id: "t-1", state: "RUNNING" } } },
+ upsertTaskSessionFromEvent: upsert,
+ });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handler = registerTaskSessionHandlers(store)[ACTIVITY_EVENT] as (msg: any) => void;
+
+ handler(
+ makeActivityMessage({ task_id: "t-1", session_id: "s-1", foreground_activity: "generating" }),
+ );
+
+ expect(upsert.mock.calls[0][1]).toMatchObject({ foreground_activity: "generating" });
+ });
+
+ it("does nothing until the session row exists (state_changed seeds it first)", () => {
+ const upsert = vi.fn();
+ const store = makeStore({
+ taskSessions: { items: {} },
+ upsertTaskSessionFromEvent: upsert,
+ });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const handler = registerTaskSessionHandlers(store)[ACTIVITY_EVENT] as (msg: any) => void;
+
+ handler(
+ makeActivityMessage({ task_id: "t-1", session_id: "s-1", foreground_activity: "background" }),
+ );
+
+ expect(upsert).not.toHaveBeenCalled();
+ });
+});
+
+describe("session.state_changed carries and resets the busy substate", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("merges foreground_activity from the state_changed payload", () => {
+ const upsert = vi.fn();
+ const store = makeStore({
+ taskSessions: { items: { "s-1": { id: "s-1", task_id: "t-1", state: "STARTING" } } },
+ upsertTaskSessionFromEvent: upsert,
+ });
+ const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!;
+
+ handler(
+ makeMessage({
+ task_id: "t-1",
+ session_id: "s-1",
+ new_state: "RUNNING",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ foreground_activity: "generating" as any,
+ }),
+ );
+
+ expect(upsert.mock.calls[0][1]).toMatchObject({
+ state: "RUNNING",
+ foreground_activity: "generating",
+ });
+ });
+});
diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts
index e7d70e742c..80661bf00c 100644
--- a/apps/web/lib/ws/handlers/agent-session.ts
+++ b/apps/web/lib/ws/handlers/agent-session.ts
@@ -208,6 +208,10 @@ function buildSessionUpdate(payload: any): Record {
if (payload.name !== undefined) update.name = payload.name;
if (payload.task_environment_id) update.task_environment_id = payload.task_environment_id;
if (payload.updated_at) update.updated_at = payload.updated_at;
+ // Reset the fine-grained busy substate on every coarse transition so a stale
+ // "background" value can't survive into the next turn (ADR-0035).
+ if (payload.foreground_activity !== undefined)
+ update.foreground_activity = payload.foreground_activity;
return update;
}
@@ -482,6 +486,31 @@ function maybeNotifySessionFailure(store: StoreApi, ctx: SessionFailur
});
}
+/** Apply an intra-RUNNING flip of the fine-grained busy substate: the
+ * foreground turn moved between generating and idle-on-background-work without
+ * any coarse state change (ADR-0035). Annotates the
+ * existing session row so the composer gate and status indicator update; does
+ * nothing until the row exists (state_changed seeds it first). */
+function applyForegroundActivity(
+ store: StoreApi,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ payload: any,
+): void {
+ if (!payload?.task_id || !payload?.session_id) return;
+ const taskId = toTaskId(payload.task_id);
+ const sessionId = toSessionId(payload.session_id);
+ const existing = store.getState().taskSessions.items[sessionId];
+ if (!existing) return;
+ store.getState().upsertTaskSessionFromEvent(taskId, {
+ id: sessionId,
+ task_id: taskId,
+ state: existing.state,
+ started_at: existing.started_at ?? "",
+ updated_at: existing.updated_at ?? "",
+ foreground_activity: payload.foreground_activity ?? null,
+ });
+}
+
export function registerTaskSessionHandlers(store: StoreApi): WsHandlers {
return {
"message.queue.status_changed": (message) => {
@@ -555,6 +584,9 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle
maybeFanOutOfficeRefetch(store, newState, existingSession?.state);
},
+ "session.activity_changed": (message) => {
+ applyForegroundActivity(store, message.payload);
+ },
"session.agentctl_starting": (message) => {
const payload = message.payload;
if (!payload?.session_id) return;
diff --git a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
new file mode 100644
index 0000000000..8f6a68e6a7
--- /dev/null
+++ b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
@@ -0,0 +1,45 @@
+# 0049: Fine-grained foreground-idle busy signal
+
+**Status:** accepted
+**Date:** 2026-07-11
+**Area:** backend, frontend, protocol
+
+## Context
+
+A session's durable state is a single scalar (`RUNNING`, `WAITING_FOR_INPUT`, …). While a turn is `RUNNING`, that scalar cannot distinguish two very different situations:
+
+1. the foreground agent is actively generating output, versus
+2. the foreground turn is idle and only held open by spawned background work — a subagent `Task`, a `run_in_background` shell, or an active Claude `Monitor` watch.
+
+Because both read as `RUNNING`, a session that kicked off a long background job rejected every new operator message as "agent is already running" for the entire duration of that job — locking the operator out of the conversation with no recovery but a restart.
+
+Upstream idle-turn completion narrows but does not close this window: a synthetic turn-complete only fires after async content has been idle for a debounce interval, it never arms while the foreground prompt exchange is still in flight (a genuinely held-open subagent turn), and a chatty Monitor re-extends the debounce on every event burst. The residual lockout windows are real.
+
+Mid-turn steering — delivering a message *into* a turn while the model is actively generating — is explicitly out of scope here; it needs ACP concurrent-prompt support and per-agent capability gating and is tracked separately.
+
+## Decision
+
+Track, per session and **in memory**, whether the open turn is foreground-driven or has yielded to outstanding background work, and narrow the prompt gate (`checkSessionPromptable`) to the foreground only:
+
+- A `RUNNING` session accepts a new prompt when its foreground turn is idle and at least one recognized background task is outstanding; otherwise it keeps rejecting input exactly as before.
+- Recognition keys off the **normalized shape** of the work (subagent task / `run_in_background` shell / active Monitor), not tool-name string matching. The ACP normalizer is corrected to recognize Claude's `run_in_background:true` shell shape (a normalizer bug fix in its own right). A shared `IsActiveMonitor` predicate classifies an active Monitor as background work and an ended one as not.
+- The default is **busy**: any agent whose in-flight frames are not recognized as background work (Codex, OpenCode, and any future agent) preserves today's exact reject-while-`RUNNING` contract. The narrowing is capability-gated, not assumed.
+
+The distinction is surfaced to the operator as a fine-grained substate (`foreground_activity`: `generating` vs `background`) so the UI can communicate two independent facts — "you may type" *and* "work is still in progress" — instead of collapsing them into one busy/done bit:
+
+- The composer gates on foreground-generating rather than coarse `RUNNING`.
+- A tri-state status indicator distinguishes generating / working-in-background / done; the established "running" affordance is unchanged and a distinct indicator is *added* for the background-idle substate — it never reads as "done" while background work runs.
+- The substate is delivered live over a `session.activity_changed` WS event (and carried on `session.state_changed` to reset on every coarse transition), and is also read from the in-memory tracker into the boot payload and the session REST/WS DTOs (`RUNNING`-only) so a fresh page-load or a second tab is immediately correct rather than showing the coarse busy signal until the next flip.
+
+## Consequences
+
+The operator is no longer falsely locked out while background work runs, and the UI truthfully shows "working in the background" as distinct from both "generating" and "done". Accepting input earlier does not make delivery earlier for a held-open subagent turn — that message still waits behind the open exchange, as the queue does today — but out-of-turn work (a Monitor burst, a backgrounded shell after the main turn yielded) is forwarded promptly with no false "already running" rejection.
+
+The signal is best-effort across a backend restart: a restart ends the turn, so the in-memory tracker resets to the safe "generating" default and no stale "you may type" is ever surfaced. Correctness is never traded — only the optimization. Recognition is deliberately Claude-shaped today (subagent task, `run_in_background` shell, active Monitor are Claude features); other agents keep today's behavior.
+
+## Alternatives Considered
+
+- **Rely on upstream synthetic idle-turn completion alone.** Rejected: it structurally cannot arm while the foreground prompt exchange is open, and its debounce re-extends under event bursts, so the held-open and chained-burst windows remain. A falsifiable acceptance test drives a chatty Monitor and confirms a prompt sent during a burst is accepted only with the fine-grained gate.
+- **Drop the `RUNNING` gate / always accept input.** Rejected: it would let a new message race a genuinely-generating foreground turn, risking dropped or reordered messages and regressing non-steering agents.
+- **Persist the foreground/background distinction to the database (like the coarse states).** Rejected: it only matters for a live in-flight turn, so a persisted value becomes a second source of truth that can drift from the gate, survives a restart as a false "you may type" for a session whose turn is gone (needing extra reconciliation to stay safe), and adds write churn to the hot streaming path. In-memory tracking that resets to the safe default on every turn-close is sufficient and simpler; the same in-memory value is read at the serialization boundary to keep fresh page-loads correct without persistence.
+- **Recognize background work by tool-name string matching.** Rejected as brittle across agents and updates; recognition keys on the normalized payload shape so producer and consumer share one contract.
diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md
index dadfdccdd0..b3847c16bf 100644
--- a/docs/decisions/INDEX.md
+++ b/docs/decisions/INDEX.md
@@ -56,6 +56,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0046 | [Settings route save coordinator](0046-settings-route-save-coordinator.md) | accepted | frontend | 2026-07-14 |
| 0047 | [Plugins read conversation content via a capability-gated Host RPC](0047-plugin-host-conversation-reads.md) | accepted | backend, protocol | 2026-07-21 |
| 0048 | [Plugins invoke a settings-selectable utility agent](0048-plugin-host-utility-agent-invoke.md) | accepted | backend, frontend, protocol | 2026-07-21 |
+| 0049 | [Fine-grained foreground-idle busy signal](0049-fine-grained-foreground-idle-busy-signal.md) | accepted | backend, frontend, protocol | 2026-07-11 |
| 2026-07-23-opencode-review-evidence-trust | [Trusted OpenCode Review Evidence](2026-07-23-opencode-review-evidence-trust.md) | accepted | workflow, infra | 2026-07-23 |
| 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | accepted | workflow | 2026-07-23 |
| 2026-07-23-post-commit-hook-aware-verification | [Post-Commit Hook-Aware Verification](2026-07-23-post-commit-hook-aware-verification.md) | accepted | workflow | 2026-07-23 |
From dba66b1eeb9d76ea5945a1a41199c0219e263015 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 11 Jul 2026 20:08:21 +0000
Subject: [PATCH 04/80] fix(orchestrator): address automated review of the
busy-signal PR
- message.add now honors the foreground-idle gate: a RUNNING background-idle session's message is accepted (flows to PromptTask) instead of rejected as busy, so the feature works through the real composer path, not just direct PromptTask (Codex).
- PromptTask publishes activity_changed when it clears the background hold, so the client's stale foreground_activity=background is reset (Greptile/cubic).
- streaming handlers only flip to generating on genuine output, so empty/discarded frames can't spuriously reclose the prompt gate (cubic).
- background tracking runs before the messageCreator nil-guard and skips already-terminal tool_calls, so run-in-background/Monitor still drive the gate and finished calls don't leak (cubic).
- frontend activity_changed guards on RUNNING state + matching task_id; dead foreground_activity arg dropped from the reopen menu (cubic).
---
apps/backend/internal/backendapp/adapters.go | 5 ++
.../orchestrator/event_handlers_streaming.go | 22 ++++++--
.../internal/orchestrator/task_operations.go | 8 ++-
.../task/handlers/message_handlers.go | 17 +++++-
.../task/handlers/message_handlers_test.go | 56 +++++++++++++++++++
.../components/task/session-reopen-menu.tsx | 4 +-
apps/web/lib/ws/handlers/agent-session.ts | 7 +++
7 files changed, 109 insertions(+), 10 deletions(-)
diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go
index e43be01994..d1a96e8243 100644
--- a/apps/backend/internal/backendapp/adapters.go
+++ b/apps/backend/internal/backendapp/adapters.go
@@ -648,6 +648,11 @@ func (w *orchestratorWrapper) StepRequiresCompletionSignal(ctx context.Context,
return w.svc.StepRequiresCompletionSignal(ctx, taskID)
}
+// ForegroundActivity forwards to the orchestrator service (ADR-0035).
+func (w *orchestratorWrapper) ForegroundActivity(sessionID string) v1.ForegroundActivity {
+ return w.svc.ForegroundActivity(sessionID)
+}
+
// messageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface
type messageCreatorAdapter struct {
svc *taskservice.Service
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go
index 48002c1835..2a39957ec7 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go
@@ -261,8 +261,12 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag
// holds the turn open while the foreground goes idle. Track it so
// checkSessionPromptable can tell "foreground generating" from "waiting on
// background". Child tool calls (ParentToolCallID set) are the subagent's
- // internal work, not a new background task, so they are ignored here.
- if payload.Data.ParentToolCallID == "" && normalizedIsBackgroundTask(payload.Data.Normalized) {
+ // internal work, not a new background task, so they are ignored here. A
+ // tool_call that already arrives terminal is not outstanding work — clearing
+ // is driven by tool_update, so registering it would leak into the hold and
+ // never clear.
+ if payload.Data.ParentToolCallID == "" && !isTerminalToolStatus(payload.Data.ToolStatus) &&
+ normalizedIsBackgroundTask(payload.Data.Normalized) {
if s.registerBackgroundTask(payload.SessionID, payload.Data.ToolCallID) {
s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
}
@@ -370,7 +374,9 @@ func (s *Service) handleStreamingEventKind(
func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
// Streamed foreground output means the agent is actively generating again,
// even if a background task is still outstanding — narrows the busy signal.
- if s.markForegroundGenerating(payload.SessionID) {
+ // Only genuine output flips the state: an empty/invalid frame is discarded by
+ // handleStreamingEventKind, so it must not spuriously reclose the prompt gate.
+ if payload.Data.Text != "" && s.markForegroundGenerating(payload.SessionID) {
s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
}
s.handleStreamingEventKind(ctx, payload, "message",
@@ -382,7 +388,9 @@ func (s *Service) handleMessageStreamingEvent(ctx context.Context, payload *life
// It creates a new thinking message on first chunk (IsAppend=false) or appends to existing (IsAppend=true).
func (s *Service) handleThinkingStreamingEvent(ctx context.Context, payload *lifecycle.AgentStreamEventPayload) {
// Streamed foreground reasoning means the agent is actively generating again.
- if s.markForegroundGenerating(payload.SessionID) {
+ // Only genuine output flips the state — an empty/invalid frame is discarded
+ // downstream, so it must not spuriously reclose the prompt gate.
+ if payload.Data.Text != "" && s.markForegroundGenerating(payload.SessionID) {
s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
}
s.handleStreamingEventKind(ctx, payload, "thinking message",
@@ -402,6 +410,12 @@ func (s *Service) handleToolUpdateEvent(ctx context.Context, payload *lifecycle.
return
}
+ // Background-work bookkeeping for the finer-grained busy signal runs
+ // regardless of message persistence — mirrors handleToolCallEvent — so a
+ // run-in-background shell or Monitor watch still drives the prompt gate even
+ // when no messageCreator is wired (tests, minimal configs).
+ s.trackBackgroundToolUpdate(ctx, payload)
+
if s.messageCreator == nil {
return
}
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index 87cd6b59c0..7edc021e58 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -2703,8 +2703,12 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
// A fresh foreground prompt is, by definition, foreground generation — clear
// any lingering "waiting on background" state so this turn gates input while
// it runs (e.g. when the operator sent this message into a background-idle
- // session that was just accepted by checkSessionPromptable).
- s.markForegroundGenerating(sessionID)
+ // session that was just accepted by checkSessionPromptable). Publish the flip:
+ // setSessionRunning above no-ops on an already-RUNNING session, so this is the
+ // only signal that resets the client's stale foreground_activity=background.
+ if s.markForegroundGenerating(sessionID) {
+ s.publishForegroundActivityChanged(ctx, taskID, sessionID)
+ }
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the prompt.
// Prompts can take a long time (minutes) while the WS request may timeout in 15 seconds.
diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go
index 1dcc282340..54b5131f9f 100644
--- a/apps/backend/internal/task/handlers/message_handlers.go
+++ b/apps/backend/internal/task/handlers/message_handlers.go
@@ -32,6 +32,10 @@ type OrchestratorService interface {
StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) error
ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error
StepRequiresCompletionSignal(ctx context.Context, taskID string) bool
+ // ForegroundActivity reports the fine-grained busy substate of a RUNNING
+ // session (ADR-0035): "background" when the foreground turn has yielded to
+ // spawned background work and can accept a new message.
+ ForegroundActivity(sessionID string) v1.ForegroundActivity
}
// MessageHandlers handles WebSocket requests for messages
@@ -297,7 +301,7 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w
}
req.TaskSessionID = sessionResp.Session.ID
}
- if wsErr := h.errorForBlockedMessageSession(msg, sessionResp.Session.State); wsErr != nil {
+ if wsErr := h.errorForBlockedMessageSession(msg, sessionResp.Session.ID, sessionResp.Session.State); wsErr != nil {
return wsErr, nil
}
isCreatedSession := sessionResp.Session.State == models.TaskSessionStateCreated
@@ -410,9 +414,16 @@ func (h *MessageHandlers) resolveSessionAfterTurnStart(
return &dto.GetTaskSessionResponse{Session: dto.FromTaskSession(primary)}, nil
}
-func (h *MessageHandlers) errorForBlockedMessageSession(msg *ws.Message, state models.TaskSessionState) *ws.Message {
+func (h *MessageHandlers) errorForBlockedMessageSession(msg *ws.Message, sessionID string, state models.TaskSessionState) *ws.Message {
switch state {
case models.TaskSessionStateRunning:
+ // A RUNNING session whose foreground turn has yielded to spawned
+ // background work (ADR-0035) accepts a new message: it flows on to
+ // PromptTask, which owns the same foreground-idle gate and forwards it.
+ // Only a foreground-generating turn is blocked here.
+ if h.orchestrator != nil && h.orchestrator.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground {
+ return nil
+ }
wsErr, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "Agent is currently processing. Please wait for the current operation to complete.", nil)
return wsErr
case models.TaskSessionStateFailed, models.TaskSessionStateCancelled, models.TaskSessionStateCompleted:
@@ -452,7 +463,7 @@ func (h *MessageHandlers) checkSessionStateForMessage(ctx context.Context, msg *
}
sessionDTO := dto.FromTaskSession(session)
resp := &dto.GetTaskSessionResponse{Session: sessionDTO}
- if wsErr := h.errorForBlockedMessageSession(msg, sessionDTO.State); wsErr != nil {
+ if wsErr := h.errorForBlockedMessageSession(msg, sessionID, sessionDTO.State); wsErr != nil {
if sessionDTO.State == models.TaskSessionStateRunning {
h.logBlockedRunningSession(sessionID, sessionDTO.State)
}
diff --git a/apps/backend/internal/task/handlers/message_handlers_test.go b/apps/backend/internal/task/handlers/message_handlers_test.go
index b3b660b087..5d84af4300 100644
--- a/apps/backend/internal/task/handlers/message_handlers_test.go
+++ b/apps/backend/internal/task/handlers/message_handlers_test.go
@@ -726,6 +726,10 @@ func (o *switchingTurnStartOrchestrator) ProcessOnTurnStart(context.Context, str
return nil
}
+func (o *switchingTurnStartOrchestrator) ForegroundActivity(string) v1.ForegroundActivity {
+ return v1.ForegroundActivityGenerating
+}
+
func (o *switchingTurnStartOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool {
return false
}
@@ -867,3 +871,55 @@ func TestWSAddMessageFailsWhenSessionReloadAfterOnTurnStartFails(t *testing.T) {
assert.Empty(t, orch.getStartedSession())
assert.Empty(t, orch.getForwardedSession())
}
+
+// fgActivityOrchestrator is a minimal OrchestratorService whose ForegroundActivity
+// is configurable, for testing the ADR-0035 message-add gate.
+type fgActivityOrchestrator struct {
+ activity v1.ForegroundActivity
+}
+
+func (o fgActivityOrchestrator) PromptTask(context.Context, string, string, string, string, bool, []v1.MessageAttachment, bool) (*orchestrator.PromptResult, error) {
+ return &orchestrator.PromptResult{}, nil
+}
+func (o fgActivityOrchestrator) ResumeTaskSession(context.Context, string, string) error { return nil }
+func (o fgActivityOrchestrator) StartCreatedSession(context.Context, string, string, string, string, bool, bool, bool, []v1.MessageAttachment) error {
+ return nil
+}
+func (o fgActivityOrchestrator) ProcessOnTurnStart(context.Context, string, string) error { return nil }
+func (o fgActivityOrchestrator) StepRequiresCompletionSignal(context.Context, string) bool {
+ return false
+}
+func (o fgActivityOrchestrator) ForegroundActivity(string) v1.ForegroundActivity { return o.activity }
+
+// TestErrorForBlockedMessageSession_BackgroundIdleAccepts is the ADR-0035
+// message-add gate: a RUNNING session whose foreground turn has yielded to
+// background work must NOT be blocked at the message.add layer (it flows on to
+// PromptTask), while a foreground-generating RUNNING session stays blocked.
+func TestErrorForBlockedMessageSession_BackgroundIdleAccepts(t *testing.T) {
+ log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
+ require.NoError(t, err)
+ msg := &ws.Message{ID: "1", Action: ws.ActionMessageAdd}
+
+ cases := []struct {
+ name string
+ activity v1.ForegroundActivity
+ state models.TaskSessionState
+ wantBlock bool
+ }{
+ {"running + generating is blocked", v1.ForegroundActivityGenerating, models.TaskSessionStateRunning, true},
+ {"running + background is accepted", v1.ForegroundActivityBackground, models.TaskSessionStateRunning, false},
+ {"waiting is accepted regardless", v1.ForegroundActivityGenerating, models.TaskSessionStateWaitingForInput, false},
+ {"failed stays blocked", v1.ForegroundActivityBackground, models.TaskSessionStateFailed, true},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := &MessageHandlers{orchestrator: fgActivityOrchestrator{activity: tc.activity}, logger: log}
+ got := h.errorForBlockedMessageSession(msg, "s1", tc.state)
+ if tc.wantBlock {
+ assert.NotNil(t, got, "expected the message to be blocked")
+ } else {
+ assert.Nil(t, got, "expected the message to be accepted")
+ }
+ })
+ }
+}
diff --git a/apps/web/components/task/session-reopen-menu.tsx b/apps/web/components/task/session-reopen-menu.tsx
index 5c9fb65de4..7354578e82 100644
--- a/apps/web/components/task/session-reopen-menu.tsx
+++ b/apps/web/components/task/session-reopen-menu.tsx
@@ -129,7 +129,9 @@ export function SessionReopenMenuItems({
session.state !== "STARTING" &&
session.state !== "WAITING_FOR_INPUT" && (
- {getSessionStateIcon(session.state, "h-3 w-3", session.foreground_activity)}
+ {/* This branch only renders for non-RUNNING sessions, so the
+ RUNNING-only foreground_activity substate is irrelevant here. */}
+ {getSessionStateIcon(session.state, "h-3 w-3")}
)}
diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts
index 80661bf00c..236f76d6c2 100644
--- a/apps/web/lib/ws/handlers/agent-session.ts
+++ b/apps/web/lib/ws/handlers/agent-session.ts
@@ -501,6 +501,13 @@ function applyForegroundActivity(
const sessionId = toSessionId(payload.session_id);
const existing = store.getState().taskSessions.items[sessionId];
if (!existing) return;
+ // The substate is only meaningful while the session is RUNNING. Ignoring a
+ // flip for any other coarse state prevents a delayed/out-of-order background
+ // event from reopening the composer after the turn already ended (the coarse
+ // state governs then). Also guard the task binding so a stale or malformed
+ // payload cannot move the session onto another task's list.
+ if (existing.state !== "RUNNING") return;
+ if (existing.task_id && existing.task_id !== taskId) return;
store.getState().upsertTaskSessionFromEvent(taskId, {
id: sessionId,
task_id: taskId,
From 77890a1cd3d42334a91a916d6339cf3ec4c48f8e Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Mon, 13 Jul 2026 21:11:38 +0000
Subject: [PATCH 05/80] fix(orchestrator): close prompt race and pin the
Monitor view contract
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the second round of automated review on #1668.
- PromptTask now atomically claims the foreground turn instead of acting on a
bare read. checkSessionPromptable only reads the substate, and the window
between that read and the point the turn is marked generating spans a session
reload, ensureSessionRunning, and a possibly network-bound model switch — so
two prompts landing in the background-idle window together (a double-send, two
tabs) both passed the gate and both reached executor.Prompt, starting
overlapping turns on one ACP session. Exactly one prompt now wins; the losers
are rejected with ErrAgentPromptInProgress as before. A claim whose prompt then
fails before reaching the agent is released, so a failed send can't leave the
session advertising a foreground it doesn't have (cubic P1).
- IsActiveMonitor gains a provenance check. A Generic payload's Output is
otherwise the agent's own raw tool result, so the predicate now demands the
full view the adapter writes, including the task_id only a real Monitor
registration produces. Note GenericPayload.Name is NOT usable as the
discriminator as suggested — it carries the ACP tool kind, which is "other" for
Monitor, so asserting Name == "Monitor" would have disabled Monitor
recognition outright (cubic P1).
- The Monitor view map keys are exported from streams and consumed by
acp/monitor.go, replacing the duplicated string literals on the producer side,
and a contract test drives real monitorOutputWrapper output through
IsActiveMonitor (incl. across the agentctl→orchestrator JSON boundary) so the
two sides can't drift apart silently (cubic P2).
- Document the deliberate hard-vs-soft ForegroundActivity dependency asymmetry
between MessageHandlers (admission gate — must not silently degrade) and
TaskHandlers (DTO enrichment — safe to omit), and drop a comment that restated
its own state guard (github-actions).
Tests: concurrent-PromptTask regression proving exactly one turn opens (red
without the claim: all 8 prompts were accepted), claim/release unit coverage, and
the producer→consumer Monitor contract tests.
---
.../server/adapter/transport/acp/monitor.go | 40 +--
.../transport/acp/monitor_contract_test.go | 110 +++++++++
.../agentctl/types/streams/monitor_view.go | 48 +++-
.../types/streams/monitor_view_test.go | 64 +++--
.../orchestrator/foreground_claim_test.go | 232 ++++++++++++++++++
.../internal/orchestrator/task_operations.go | 56 ++++-
.../internal/orchestrator/turn_activity.go | 65 +++++
.../task/handlers/message_handlers.go | 11 +
.../components/task/session-reopen-menu.tsx | 6 +-
...ine-grained-foreground-idle-busy-signal.md | 3 +-
10 files changed, 578 insertions(+), 57 deletions(-)
create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
create mode 100644 apps/backend/internal/orchestrator/foreground_claim_test.go
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
index 379f72550b..41ed072f51 100644
--- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
@@ -352,52 +352,54 @@ func readMonitorView(g *streams.GenericPayload) monitorPayloadView {
if !ok {
return monitorPayloadView{Kind: monitorToolName}
}
- raw, ok := wrapper["monitor"].(map[string]any)
+ raw, ok := wrapper[streams.MonitorViewKey].(map[string]any)
if !ok {
return monitorPayloadView{Kind: monitorToolName}
}
view := monitorPayloadView{Kind: monitorToolName}
- if s, ok := raw["task_id"].(string); ok {
+ if s, ok := raw[streams.MonitorViewTaskIDKey].(string); ok {
view.TaskID = s
}
- if s, ok := raw["command"].(string); ok {
+ if s, ok := raw[streams.MonitorViewCommandKey].(string); ok {
view.Command = s
}
- if n, ok := raw["event_count"].(float64); ok {
+ if n, ok := raw[streams.MonitorViewEventCountKey].(float64); ok {
view.EventCount = int(n)
- } else if n, ok := raw["event_count"].(int); ok {
+ } else if n, ok := raw[streams.MonitorViewEventCountKey].(int); ok {
view.EventCount = n
}
- if list, ok := raw["recent_events"].([]any); ok {
+ if list, ok := raw[streams.MonitorViewRecentEventsKey].([]any); ok {
view.RecentEvents = make([]string, 0, len(list))
for _, item := range list {
if s, ok := item.(string); ok {
view.RecentEvents = append(view.RecentEvents, s)
}
}
- } else if list, ok := raw["recent_events"].([]string); ok {
+ } else if list, ok := raw[streams.MonitorViewRecentEventsKey].([]string); ok {
view.RecentEvents = append([]string{}, list...)
}
- if b, ok := raw["ended"].(bool); ok {
+ if b, ok := raw[streams.MonitorViewEndedKey].(bool); ok {
view.Ended = b
}
- if s, ok := raw["end_reason"].(string); ok {
+ if s, ok := raw[streams.MonitorViewEndReasonKey].(string); ok {
view.EndReason = s
}
return view
}
-// monitorOutputWrapper boxes the typed view in the `{monitor: {...}}` shape
-// the frontend monitor card reads.
+// monitorOutputWrapper boxes the typed view in the `{monitor: {...}}` shape the
+// frontend monitor card reads and streams.IsActiveMonitor classifies on. The map
+// keys come from `streams` rather than being spelled out here so producer and
+// consumer cannot drift apart across the package boundary.
func monitorOutputWrapper(view monitorPayloadView) map[string]any {
- return map[string]any{"monitor": map[string]any{
- "kind": view.Kind,
- "task_id": view.TaskID,
- "command": view.Command,
- "event_count": view.EventCount,
- "recent_events": view.RecentEvents,
- "ended": view.Ended,
- "end_reason": view.EndReason,
+ return map[string]any{streams.MonitorViewKey: map[string]any{
+ streams.MonitorViewKindKey: view.Kind,
+ streams.MonitorViewTaskIDKey: view.TaskID,
+ streams.MonitorViewCommandKey: view.Command,
+ streams.MonitorViewEventCountKey: view.EventCount,
+ streams.MonitorViewRecentEventsKey: view.RecentEvents,
+ streams.MonitorViewEndedKey: view.Ended,
+ streams.MonitorViewEndReasonKey: view.EndReason,
}}
}
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
new file mode 100644
index 0000000000..33c85350d4
--- /dev/null
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
@@ -0,0 +1,110 @@
+package acp
+
+import (
+ "testing"
+
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+)
+
+// The Monitor view is a cross-package contract: this file (the producer) writes
+// `Generic.Output = {"monitor": {...}}`, and streams.IsActiveMonitor (the
+// consumer, via the orchestrator's background-work classifier) reads it back to
+// decide whether a RUNNING session's foreground turn has yielded to background
+// work (ADR-0035).
+//
+// The two sides used to spell the map keys out independently, so a rename on
+// either side compiled cleanly and silently reverted Monitor sessions to the
+// coarse busy signal. They now share streams.MonitorView*Key, and these tests
+// pin the round trip through the *real* producer functions rather than a
+// hand-rolled replica of the shape.
+
+// newMonitorGeneric mirrors what the adapter actually has in hand when a Monitor
+// registers: normalizeGeneric built the payload from the ACP tool *kind*, which
+// claude-agent-acp sends as "other" for Monitor (the "Monitor" name only appears
+// in `_meta.claudeCode.toolName`). Starting from this exact payload is what makes
+// the test a contract test — it is why GenericPayload.Name cannot serve as the
+// Monitor discriminator.
+func newMonitorGeneric() *streams.NormalizedPayload {
+ return streams.NewGeneric("other", map[string]any{})
+}
+
+func TestMonitorViewContract_SeededMonitorIsRecognizedAsActive(t *testing.T) {
+ p := newMonitorGeneric()
+ seedMonitorView(p, "task-abc", "gh pr checks --watch")
+
+ if !p.IsActiveMonitor() {
+ t.Fatal("a Monitor view written by seedMonitorView must be recognized as active background work")
+ }
+}
+
+func TestMonitorViewContract_EventsKeepMonitorActive(t *testing.T) {
+ p := newMonitorGeneric()
+ seedMonitorView(p, "task-abc", "gh pr checks --watch")
+ appendMonitorEvent(p, "task-abc", "gh pr checks --watch", "all checks passing")
+
+ if !p.IsActiveMonitor() {
+ t.Fatal("a Monitor that fired an event is still watching and must stay active")
+ }
+}
+
+func TestMonitorViewContract_EndedMonitorIsNotActive(t *testing.T) {
+ p := newMonitorGeneric()
+ seedMonitorView(p, "task-abc", "gh pr checks --watch")
+ markMonitorEnded(p, "exited")
+
+ if p.IsActiveMonitor() {
+ t.Fatal("an ended Monitor must not hold the busy signal open")
+ }
+}
+
+// The payload crosses a process boundary (agentctl → orchestrator) as JSON, so
+// the producer's typed view decodes back into map[string]any before the consumer
+// ever sees it. The contract has to survive that, not just hold in-process.
+func TestMonitorViewContract_SurvivesSerializationToOrchestrator(t *testing.T) {
+ p := newMonitorGeneric()
+ seedMonitorView(p, "task-abc", "gh pr checks --watch")
+
+ data, err := p.MarshalJSON()
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var decoded streams.NormalizedPayload
+ if err := decoded.UnmarshalJSON(data); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if !decoded.IsActiveMonitor() {
+ t.Fatal("the Monitor view must still classify as active after the agentctl→orchestrator round trip")
+ }
+}
+
+// Provenance guard. A Generic payload's Output is otherwise the agent's own raw
+// tool result, assigned verbatim by NormalizeToolResult. An unrelated agent whose
+// tool happens to emit a `monitor` key must NOT trip the background gate — the
+// PR's contract is that any agent we don't recognize keeps the historical
+// reject-while-RUNNING behavior. The adapter only ever publishes an active view
+// with a real task_id (it comes from the registration banner), so a forged-looking
+// view without one is rejected.
+func TestMonitorViewContract_ArbitraryToolOutputIsNotMistakenForAMonitor(t *testing.T) {
+ cases := map[string]any{
+ "monitor-shaped output with no task_id": map[string]any{
+ streams.MonitorViewKey: map[string]any{
+ streams.MonitorViewKindKey: streams.MonitorSubkind,
+ streams.MonitorViewEndedKey: false,
+ },
+ },
+ "unrelated tool result": map[string]any{"status": "ok", "rows": 3},
+ "plain string output": "Monitor started (task 42, timeout 1000ms)",
+ }
+
+ for name, output := range cases {
+ t.Run(name, func(t *testing.T) {
+ p := streams.NewGeneric("other", map[string]any{})
+ p.Generic().Output = output
+
+ if p.IsActiveMonitor() {
+ t.Fatal("non-Monitor tool output must not be classified as background work")
+ }
+ })
+ }
+}
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view.go b/apps/backend/internal/agentctl/types/streams/monitor_view.go
index 1a0e2031ca..6f8106fe3c 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view.go
@@ -8,12 +8,25 @@ package streams
const MonitorSubkind = "Monitor"
// Monitor view map keys — the shape acp/monitor.go writes as
-// Generic.Output = {"monitor": {"kind": ..., "ended": ...}}. Kept here next to
-// the predicate that reads them so producer and consumer stay in lockstep.
+//
+// Generic.Output = {"monitor": {"kind": …, "task_id": …, "ended": …, …}}
+//
+// Exported so the producer (acp/monitor.go's monitorOutputWrapper and
+// readMonitorView) builds and reads the map from these very constants rather
+// than re-typing the literals on its side of the package boundary. That is the
+// point: while the two sides each spelled the strings out, renaming a key in the
+// producer still compiled and silently reverted Monitor sessions to the coarse
+// busy signal. acp's monitor_contract_test.go pins the producer→consumer round
+// trip so the contract can't drift unnoticed again.
const (
- monitorViewKey = "monitor"
- monitorViewKindKey = "kind"
- monitorViewEndedKey = "ended"
+ MonitorViewKey = "monitor"
+ MonitorViewKindKey = "kind"
+ MonitorViewTaskIDKey = "task_id"
+ MonitorViewCommandKey = "command"
+ MonitorViewEventCountKey = "event_count"
+ MonitorViewRecentEventsKey = "recent_events"
+ MonitorViewEndedKey = "ended"
+ MonitorViewEndReasonKey = "end_reason"
)
// IsActiveMonitor reports whether this payload is a live Claude Monitor watch:
@@ -23,8 +36,20 @@ const (
// than a dedicated kind (see acp/monitor.go). A Monitor is long-running
// background work the foreground turn is not actively generating against, so an
// active one is treated like any other spawned background task by the busy
-// signal. Returns false for a nil payload, a non-Generic payload, a Generic
-// payload with no Monitor view, or a Monitor that has already ended.
+// signal.
+//
+// Provenance: a Generic payload's Output is otherwise the agent's *own* raw tool
+// result — normalize.go's NormalizeToolResult assigns it verbatim — so this
+// predicate must not fire for an unrelated tool that merely happens to serialize
+// a `monitor` key. It therefore demands the full shape the adapter writes,
+// including a non-empty `task_id`: that field is only ever populated once the
+// Monitor registration banner yields a real task ID (seedMonitorView), so a view
+// without one did not come off the Monitor path. Note the Generic payload's
+// `Name` is NOT a usable discriminator here — it carries the ACP tool *kind*,
+// which is "other" for Monitor, not "Monitor".
+//
+// Returns false for a nil payload, a non-Generic payload, a Generic payload with
+// no Monitor view, a view carrying no task ID, or a Monitor that has ended.
func (p *NormalizedPayload) IsActiveMonitor() bool {
if p == nil || p.kind != ToolKindGeneric || p.generic == nil {
return false
@@ -33,13 +58,16 @@ func (p *NormalizedPayload) IsActiveMonitor() bool {
if !ok {
return false
}
- view, ok := wrapper[monitorViewKey].(map[string]any)
+ view, ok := wrapper[MonitorViewKey].(map[string]any)
if !ok {
return false
}
- if kind, _ := view[monitorViewKindKey].(string); kind != MonitorSubkind {
+ if kind, _ := view[MonitorViewKindKey].(string); kind != MonitorSubkind {
return false
}
- ended, _ := view[monitorViewEndedKey].(bool)
+ if taskID, _ := view[MonitorViewTaskIDKey].(string); taskID == "" {
+ return false
+ }
+ ended, _ := view[MonitorViewEndedKey].(bool)
return !ended
}
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
index 247b89327e..74e54fe034 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
@@ -2,22 +2,32 @@ package streams
import "testing"
-// monitorView builds a Generic payload shaped exactly like the one
-// acp/monitor.go's monitorOutputWrapper produces, so the predicate is tested
-// against the real wire shape rather than a hand-tuned proxy.
+// monitorView builds a Generic payload shaped like the one acp/monitor.go's
+// monitorOutputWrapper produces. The producer→consumer contract itself is pinned
+// against the *real* producer functions in acp's monitor_contract_test.go; these
+// cases exercise the predicate's own branches.
func monitorView(ended bool) *NormalizedPayload {
- p := NewGeneric("Monitor", map[string]any{})
+ p := NewGeneric("other", map[string]any{})
p.Generic().Output = map[string]any{
- monitorViewKey: map[string]any{
- monitorViewKindKey: MonitorSubkind,
- monitorViewEndedKey: ended,
- "task_id": "task-1",
- "command": "gh pr checks --watch",
+ MonitorViewKey: map[string]any{
+ MonitorViewKindKey: MonitorSubkind,
+ MonitorViewEndedKey: ended,
+ MonitorViewTaskIDKey: "task-1",
+ MonitorViewCommandKey: "gh pr checks --watch",
},
}
return p
}
+// genericWithOutput builds the payload an *arbitrary* agent tool produces:
+// NormalizeToolResult assigns the agent's raw result straight to Generic.Output,
+// so these are the shapes the predicate has to refuse.
+func genericWithOutput(output any) *NormalizedPayload {
+ p := NewGeneric("other", map[string]any{})
+ p.Generic().Output = output
+ return p
+}
+
func TestIsActiveMonitor(t *testing.T) {
cases := []struct {
name string
@@ -31,15 +41,37 @@ func TestIsActiveMonitor(t *testing.T) {
{"generic without monitor view", NewGeneric("SomeTool", map[string]any{"a": 1}), false},
{
"generic with wrong subkind",
- func() *NormalizedPayload {
- p := NewGeneric("Other", map[string]any{})
- p.Generic().Output = map[string]any{
- monitorViewKey: map[string]any{monitorViewKindKey: "Something", monitorViewEndedKey: false},
- }
- return p
- }(),
+ genericWithOutput(map[string]any{
+ MonitorViewKey: map[string]any{MonitorViewKindKey: "Something", MonitorViewEndedKey: false},
+ }),
+ false,
+ },
+ {
+ // Provenance: the adapter only ever publishes an active view once the
+ // registration banner handed it a real task ID. A monitor-shaped blob
+ // without one did not come off the Monitor path — an unrelated agent's
+ // tool result must not relax the busy gate.
+ "monitor-shaped output with no task_id",
+ genericWithOutput(map[string]any{
+ MonitorViewKey: map[string]any{
+ MonitorViewKindKey: MonitorSubkind,
+ MonitorViewEndedKey: false,
+ },
+ }),
+ false,
+ },
+ {
+ "monitor-shaped output with empty task_id",
+ genericWithOutput(map[string]any{
+ MonitorViewKey: map[string]any{
+ MonitorViewKindKey: MonitorSubkind,
+ MonitorViewEndedKey: false,
+ MonitorViewTaskIDKey: "",
+ },
+ }),
false,
},
+ {"generic with string output", genericWithOutput("monitor"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go
new file mode 100644
index 0000000000..857ca511d1
--- /dev/null
+++ b/apps/backend/internal/orchestrator/foreground_claim_test.go
@@ -0,0 +1,232 @@
+package orchestrator
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/runtime/lifecycle"
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
+ "github.com/kandev/kandev/internal/orchestrator/executor"
+ "github.com/kandev/kandev/internal/task/models"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// ADR-0035 narrowed the busy gate so a RUNNING session whose foreground turn has
+// yielded to background work accepts a new prompt. checkSessionPromptable only
+// *reads* that substate, though, and PromptTask does real work between the read
+// and the point the turn is marked generating (session reload, ensureSessionRunning,
+// an optionally network-bound model switch). Two prompts arriving in that window —
+// a double-send, or two tabs on the same session — would both pass the read and
+// both reach executor.Prompt, starting overlapping turns on one ACP session.
+//
+// claimForegroundTurn closes the window: the check and the claim happen under one
+// lock, so exactly one caller wins.
+
+// TestClaimForegroundTurn_OnlyOneConcurrentPromptWins is the regression test for
+// that race. Every claim but one must lose, no matter how many race in at once.
+func TestClaimForegroundTurn_OnlyOneConcurrentPromptWins(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const sessionID = "session-race"
+ const contenders = 32
+
+ // The agent spawned background work and went idle in the foreground: the gate
+ // is open, and every contender below is about to read it as open.
+ svc.registerBackgroundTask(sessionID, "tool-subagent-1")
+
+ var (
+ start sync.WaitGroup
+ done sync.WaitGroup
+ mu sync.Mutex
+ won int
+ )
+ start.Add(1)
+ for range contenders {
+ done.Add(1)
+ go func() {
+ defer done.Done()
+ start.Wait() // release them all into the window together
+ if svc.claimForegroundTurn(sessionID) {
+ mu.Lock()
+ won++
+ mu.Unlock()
+ }
+ }()
+ }
+ start.Done()
+ done.Wait()
+
+ if won != 1 {
+ t.Fatalf("exactly one concurrent prompt may claim the background-idle turn, got %d winners", won)
+ }
+ // The winner drives the turn, so the session now reads as foreground-generating
+ // and every later prompt is gated again.
+ if !svc.isForegroundTurnGenerating(sessionID) {
+ t.Fatal("after the claim the foreground turn must read as generating")
+ }
+}
+
+// TestPromptTask_ConcurrentPromptsIntoBackgroundIdleStartOneTurn is the same
+// regression driven through the REAL operator entrypoint. It is the assertion
+// that actually matters: no matter how many prompts land in the background-idle
+// window at once, exactly one may reach the agent. The rest must be rejected with
+// ErrAgentPromptInProgress — overlapping turns on a single ACP session are the
+// failure this prevents.
+//
+// Note the serial version of this cannot fail: the first PromptTask marks the
+// foreground generating on its way through, so a *subsequent* prompt is gated even
+// without the claim. Only genuine concurrency exposes the check-then-act window,
+// which is wide — a session reload and ensureSessionRunning sit inside it.
+func TestPromptTask_ConcurrentPromptsIntoBackgroundIdleStartOneTurn(t *testing.T) {
+ repo := setupTestRepo(t)
+ agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo}
+ svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
+ svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
+ svc.messageCreator = &mockMessageCreator{}
+
+ const (
+ taskID = "task1"
+ sessionID = "session-concurrent"
+ prompters = 8
+ )
+ seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
+ session, err := repo.GetTaskSession(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("load session: %v", err)
+ }
+ session.AgentExecutionID = "exec-1"
+ seedExecutorRunning(t, repo, sessionID, taskID, "exec-1")
+ if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
+ t.Fatalf("update session: %v", err)
+ }
+
+ // The agent kicks off a run_in_background shell and goes idle in the foreground,
+ // so the gate opens: every prompt below is about to read it as open.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ ExecutionID: "exec-1",
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "tool_update",
+ ToolCallID: "bash-1",
+ ToolStatus: "in_progress",
+ Normalized: streams.NewShellExec("npm run dev", "", "", 0, true),
+ },
+ })
+
+ // The operator double-sends, or two tabs fire at once.
+ var (
+ start sync.WaitGroup
+ done sync.WaitGroup
+ mu sync.Mutex
+ accepted,
+ rejectedBusy int
+ )
+ start.Add(1)
+ for range prompters {
+ done.Add(1)
+ go func() {
+ defer done.Done()
+ start.Wait()
+ _, err := svc.PromptTask(context.Background(), taskID, sessionID, "are you still working?", "", false, nil, false)
+ mu.Lock()
+ defer mu.Unlock()
+ switch {
+ case err == nil:
+ accepted++
+ case errors.Is(err, ErrAgentPromptInProgress):
+ rejectedBusy++
+ default:
+ t.Errorf("unexpected prompt error: %v", err)
+ }
+ }()
+ }
+ start.Done()
+ done.Wait()
+
+ if accepted != 1 {
+ t.Fatalf("exactly one concurrent prompt may open a turn, got %d accepted (%d rejected busy)", accepted, rejectedBusy)
+ }
+ if rejectedBusy != prompters-1 {
+ t.Fatalf("every prompt that lost the claim must be rejected with ErrAgentPromptInProgress, got %d of %d", rejectedBusy, prompters-1)
+ }
+ // The decisive assertion: only one turn was actually started on the agent.
+ agentMgr.mu.Lock()
+ captured := len(agentMgr.capturedPrompts)
+ agentMgr.mu.Unlock()
+ if captured != 1 {
+ t.Fatalf("overlapping turns reached the agent: %d prompts forwarded, want exactly 1", captured)
+ }
+}
+
+// An untracked session has no background work outstanding, so there is nothing to
+// claim: the historical reject-while-RUNNING default must stand.
+func TestClaimForegroundTurn_UntrackedSessionCannotBeClaimed(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ if svc.claimForegroundTurn("session-never-seen") {
+ t.Fatal("a session with no outstanding background work must not be claimable")
+ }
+ if svc.claimForegroundTurn("") {
+ t.Fatal("an empty session ID must not be claimable")
+ }
+}
+
+// A prompt that claims the turn but never reaches the agent (ensureSessionRunning
+// failed, the model switch failed) has to hand the claim back. Otherwise the
+// session sits in RUNNING advertising a generating foreground it does not have,
+// locking the operator out for the rest of the turn — the exact lockout ADR-0035
+// exists to remove.
+func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const sessionID = "session-release"
+ svc.registerBackgroundTask(sessionID, "tool-subagent-1")
+
+ if !svc.claimForegroundTurn(sessionID) {
+ t.Fatal("the first prompt must win the claim")
+ }
+ if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
+ t.Fatalf("a claimed turn reads as generating, got %q", got)
+ }
+
+ // The prompt fails before reaching the agent.
+ svc.releaseForegroundClaim(sessionID)
+
+ // Background work is still outstanding, so the session is background-idle again
+ // and the operator can retry.
+ if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
+ t.Fatalf("a released claim must return the turn to background-idle, got %q", got)
+ }
+ if !svc.claimForegroundTurn(sessionID) {
+ t.Fatal("a retried prompt must be able to claim the released turn")
+ }
+}
+
+// Releasing must not resurrect a background hold that no longer exists: if the
+// last background task finished while the failing prompt was in flight, the turn
+// genuinely is not waiting on anything and the generating default is correct.
+func TestReleaseForegroundClaim_DoesNotReopenGateWithoutBackgroundWork(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const sessionID = "session-release-nobg"
+ svc.registerBackgroundTask(sessionID, "tool-subagent-1")
+ if !svc.claimForegroundTurn(sessionID) {
+ t.Fatal("the prompt must win the claim")
+ }
+
+ // The background task completes while the prompt is still in flight, then the
+ // prompt fails.
+ svc.completeBackgroundTask(sessionID, "tool-subagent-1")
+ svc.releaseForegroundClaim(sessionID)
+
+ if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
+ t.Fatalf("with no background work outstanding the turn must read as generating, got %q", got)
+ }
+}
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index 7edc021e58..29b4115ee1 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -2658,6 +2658,34 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
session = readySession
}
+ // The gate above only reads the substate, so a RUNNING session admitted through
+ // it was admitted because its foreground turn is idle behind background work.
+ // Claim that turn now, atomically: everything between here and executor.Prompt
+ // (session reload, ensureSessionRunning, a possibly network-bound model switch)
+ // is check-then-act window in which a second prompt could otherwise pass the
+ // same read and start an overlapping turn on one ACP session. Losing the claim
+ // means another prompt got there first — reject exactly as before ADR-0036.
+ foregroundClaimed := false
+ if session.State == models.TaskSessionStateRunning {
+ if !s.claimForegroundTurn(sessionID) {
+ s.logger.Warn("rejected prompt: another prompt claimed the background-idle foreground turn first",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID))
+ return nil, fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress)
+ }
+ foregroundClaimed = true
+ }
+ // Hand the claim back if this prompt fails before it reaches the agent —
+ // otherwise the session would advertise a generating foreground it doesn't have
+ // and lock the operator out for the rest of the turn. Once executor.Prompt is
+ // under way the normal turn-close paths (handlePromptError → completeTurnForSession)
+ // own the reset.
+ releaseForegroundClaimOnFailure := func() {
+ if foregroundClaimed {
+ s.releaseForegroundClaim(sessionID)
+ }
+ }
+
// Apply config-mode and plan-mode prompt transforms.
effectivePrompt := s.effectivePromptForSession(sessionID, prompt, planMode, session)
@@ -2666,6 +2694,7 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
_, hadExecutionBeforeEnsure := s.executor.GetExecutionBySession(sessionID)
resumedForPrompt := !hadExecutionBeforeEnsure
if err := s.ensureSessionRunning(ctx, sessionID, session); err != nil {
+ releaseForegroundClaimOnFailure()
return nil, fmt.Errorf("failed to ensure session is running: %w", err)
}
@@ -2682,8 +2711,13 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
effectivePrompt = s.effectivePromptForSession(sessionID, prompt, planMode, session)
}
- // Check if model switching is requested
+ // Check if model switching is requested. A switch that fails never reaches the
+ // agent, so it releases the claim; a switch that succeeds delivered the prompt
+ // itself (trySwitchModel opens the turn) and keeps it.
if result, switched, err := s.trySwitchModel(ctx, taskID, sessionID, model, effectivePrompt, session); switched || err != nil {
+ if err != nil {
+ releaseForegroundClaimOnFailure()
+ }
return result, err
}
@@ -2702,11 +2736,16 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
s.startTurnForSession(ctx, sessionID)
// A fresh foreground prompt is, by definition, foreground generation — clear
// any lingering "waiting on background" state so this turn gates input while
- // it runs (e.g. when the operator sent this message into a background-idle
- // session that was just accepted by checkSessionPromptable). Publish the flip:
- // setSessionRunning above no-ops on an already-RUNNING session, so this is the
- // only signal that resets the client's stale foreground_activity=background.
- if s.markForegroundGenerating(sessionID) {
+ // it runs. Publish the flip: setSessionRunning above no-ops on an already-RUNNING
+ // session, so this is the only signal that resets the client's stale
+ // foreground_activity=background.
+ //
+ // Either side can be the one that flipped it. A prompt into a background-idle
+ // session already flipped it when it claimed the turn above, so
+ // markForegroundGenerating has nothing left to change and reports false —
+ // the claim is the transition, and it still has to be broadcast.
+ flippedToGenerating := s.markForegroundGenerating(sessionID)
+ if flippedToGenerating || foregroundClaimed {
s.publishForegroundActivityChanged(ctx, taskID, sessionID)
}
@@ -2846,6 +2885,11 @@ func (s *Service) checkSessionPromptable(taskID, sessionID string, state models.
// Narrow the busy signal: a session that kicked off background work and
// is otherwise idle in the foreground should still accept a new message
// rather than reporting "running" and dropping it.
+ //
+ // This is a *read*, not a claim — DrainQueuedMessage and the STARTING wait
+ // both call it without going on to drive a turn themselves. PromptTask,
+ // which does, follows a passing read with claimForegroundTurn to close the
+ // check-then-act window against a second concurrent prompt.
if !s.isForegroundTurnGenerating(sessionID) {
s.logger.Debug("accepting prompt: foreground turn idle, only background work outstanding",
zap.String("task_id", taskID),
diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go
index b8ba8d353f..2f0b2b584a 100644
--- a/apps/backend/internal/orchestrator/turn_activity.go
+++ b/apps/backend/internal/orchestrator/turn_activity.go
@@ -139,6 +139,11 @@ func (s *Service) clearTurnActivity(sessionID string) {
// is actively generating. It returns true (generating) unless the turn has
// yielded to an outstanding background task. An untracked session defaults to
// true, preserving the historical "reject a new prompt while RUNNING" contract.
+//
+// This is a pure predicate: checkSessionPromptable and the DTO/WS serializers
+// call it to *report* promptability. A caller that is about to actually drive a
+// turn on the strength of the answer must use claimForegroundTurn instead, or it
+// races every other prompt reading the same window.
func (s *Service) isForegroundTurnGenerating(sessionID string) bool {
ta := s.turnActivityFor(sessionID, false)
if ta == nil {
@@ -149,6 +154,66 @@ func (s *Service) isForegroundTurnGenerating(sessionID string) bool {
return !ta.yielded
}
+// claimForegroundTurn is the check-and-claim half of the background-idle gate.
+// It atomically verifies the session's foreground turn has yielded to background
+// work and, if so, takes it for the caller by flipping it back to generating —
+// under the same lock, so exactly one caller can win.
+//
+// checkSessionPromptable only *reads* the substate, which leaves a wide
+// check-then-act window in PromptTask: between the gate and the point the turn is
+// finally marked generating sit a session reload, ensureSessionRunning, and an
+// optional (network-bound) model switch. Two prompts arriving in that window —
+// a double-send, or two browser tabs onto the same background-idle session —
+// would both pass the read-only gate and both reach executor.Prompt, starting
+// overlapping turns on one ACP session. Claiming closes that window: the first
+// prompt in wins, and every prompt behind it sees a generating foreground and is
+// rejected with ErrAgentPromptInProgress exactly as it would have been before
+// ADR-0035.
+//
+// Returns false for an untracked session — no background work is outstanding, so
+// there is nothing to claim and the historical reject-while-RUNNING default
+// stands.
+func (s *Service) claimForegroundTurn(sessionID string) bool {
+ if sessionID == "" {
+ return false
+ }
+ ta := s.turnActivityFor(sessionID, false)
+ if ta == nil {
+ return false
+ }
+ ta.mu.Lock()
+ defer ta.mu.Unlock()
+ if !ta.yielded {
+ return false
+ }
+ ta.yielded = false
+ return true
+}
+
+// releaseForegroundClaim hands a claimForegroundTurn claim back when the prompt
+// it was taken for never made it to the agent (ensureSessionRunning failed, the
+// model switch failed). Without it the session would sit in RUNNING advertising a
+// generating foreground it does not have, locking the operator out for the rest
+// of the turn — the exact lockout ADR-0035 exists to remove.
+//
+// Only re-yields while background work is genuinely still outstanding: if the
+// last background task completed while the failing prompt was in flight, the turn
+// is no longer waiting on anything and the generating default is correct.
+func (s *Service) releaseForegroundClaim(sessionID string) {
+ if sessionID == "" {
+ return
+ }
+ ta := s.turnActivityFor(sessionID, false)
+ if ta == nil {
+ return
+ }
+ ta.mu.Lock()
+ if len(ta.background) > 0 {
+ ta.yielded = true
+ }
+ ta.mu.Unlock()
+}
+
// foregroundActivityValue reports the fine-grained busy substate of a session
// for the operator-facing signal: "generating" when the foreground turn is
// actively producing output (the default), "background" when it has yielded to
diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go
index 54b5131f9f..2046ce29b7 100644
--- a/apps/backend/internal/task/handlers/message_handlers.go
+++ b/apps/backend/internal/task/handlers/message_handlers.go
@@ -35,6 +35,17 @@ type OrchestratorService interface {
// ForegroundActivity reports the fine-grained busy substate of a RUNNING
// session (ADR-0035): "background" when the foreground turn has yielded to
// spawned background work and can accept a new message.
+ //
+ // Deliberately a hard dependency here, unlike TaskHandlers — which reaches the
+ // same method through an optional type-assertion on OrchestratorStarter and
+ // simply omits the field when it isn't there. The asymmetry is the difference
+ // in blast radius, not an oversight: this is the admission gate, so an
+ // orchestrator that silently lacked the method would fall back to rejecting
+ // every RUNNING session and quietly un-ship the feature. Requiring it makes
+ // that a compile error. TaskHandlers only enriches a DTO with the substate; if
+ // it's absent the client just falls back to the safe "generating" default, so
+ // there a soft dependency costs nothing. Resist "fixing" the inconsistency by
+ // making this one optional.
ForegroundActivity(sessionID string) v1.ForegroundActivity
}
diff --git a/apps/web/components/task/session-reopen-menu.tsx b/apps/web/components/task/session-reopen-menu.tsx
index 7354578e82..8278ca63c9 100644
--- a/apps/web/components/task/session-reopen-menu.tsx
+++ b/apps/web/components/task/session-reopen-menu.tsx
@@ -128,11 +128,7 @@ export function SessionReopenMenuItems({
{session.state !== "RUNNING" &&
session.state !== "STARTING" &&
session.state !== "WAITING_FOR_INPUT" && (
-
- {/* This branch only renders for non-RUNNING sessions, so the
- RUNNING-only foreground_activity substate is irrelevant here. */}
- {getSessionStateIcon(session.state, "h-3 w-3")}
-
+ {getSessionStateIcon(session.state, "h-3 w-3")}
)}
);
diff --git a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
index 8f6a68e6a7..8109f957da 100644
--- a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
+++ b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
@@ -22,8 +22,9 @@ Mid-turn steering — delivering a message *into* a turn while the model is acti
Track, per session and **in memory**, whether the open turn is foreground-driven or has yielded to outstanding background work, and narrow the prompt gate (`checkSessionPromptable`) to the foreground only:
- A `RUNNING` session accepts a new prompt when its foreground turn is idle and at least one recognized background task is outstanding; otherwise it keeps rejecting input exactly as before.
-- Recognition keys off the **normalized shape** of the work (subagent task / `run_in_background` shell / active Monitor), not tool-name string matching. The ACP normalizer is corrected to recognize Claude's `run_in_background:true` shell shape (a normalizer bug fix in its own right). A shared `IsActiveMonitor` predicate classifies an active Monitor as background work and an ended one as not.
+- Recognition keys off the **normalized shape** of the work (subagent task / `run_in_background` shell / active Monitor), not tool-name string matching. The ACP normalizer is corrected to recognize Claude's `run_in_background:true` shell shape (a normalizer bug fix in its own right). A shared `IsActiveMonitor` predicate classifies an active Monitor as background work and an ended one as not. Because a Generic payload's `Output` is otherwise the agent's own raw tool result, that predicate demands the full view the adapter writes — including the `task_id` only a real Monitor registration produces — so an unrelated agent's tool output cannot trip the gate by coincidence. (The payload's `Name` is *not* a usable discriminator: it carries the ACP tool kind, which is `"other"` for Monitor.)
- The default is **busy**: any agent whose in-flight frames are not recognized as background work (Codex, OpenCode, and any future agent) preserves today's exact reject-while-`RUNNING` contract. The narrowing is capability-gated, not assumed.
+- Admission is **check-and-claim, not check-then-act**. The gate is a pure read, so `PromptTask` follows a passing read with an atomic `claimForegroundTurn` before it drives the turn: the check and the flip back to foreground-generating happen under one lock. Without this, two prompts landing in the background-idle window together (a double-send, two tabs) would both pass the read — the window spans a session reload, `ensureSessionRunning`, and a possibly network-bound model switch — and both reach `executor.Prompt`, starting overlapping turns on one ACP session. Exactly one prompt wins; the losers are rejected with `ErrAgentPromptInProgress` just as they were before this ADR. A claim whose prompt then fails before reaching the agent is released, so a failed send cannot leave the session advertising a foreground it does not have.
The distinction is surfaced to the operator as a fine-grained substate (`foreground_activity`: `generating` vs `background`) so the UI can communicate two independent facts — "you may type" *and* "work is still in progress" — instead of collapsing them into one busy/done bit:
From 18c646836661f0635ad8106d38e7f1b9cc151bc1 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Mon, 13 Jul 2026 21:45:18 +0000
Subject: [PATCH 06/80] fix(orchestrator): attest Monitor provenance and make
the foreground claim durable
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the review round on 73ec9346.
- IsActiveMonitor now classifies on adapter attestation, not payload shape. The
previous round tightened the shape check (demanding task_id), but cubic is right
that this was never sufficient: Generic.Output is assigned the agent's raw tool
result verbatim, so *nothing inside it* can vouch for its own origin, however
many fields we demand. The ACP adapter now stamps a typed MonitorPayload as a
sibling of the Generic payload, on the path already gated by ACP
_meta.claudeCode.toolName — metadata the claude-agent-acp wrapper sets and model
tool output cannot reach. Output keeps carrying the view the frontend card
renders; it is a presentation contract, not a trust one. An agent can no longer
relax its own busy gate by emitting a monitor-shaped tool result (cubic P1).
- The foreground claim is now held rather than merely taken, and tracked
independently of background-idle activity. A background tool_call landing
mid-admission called registerBackgroundTask, which re-set `yielded` and reopened
the gate underneath the in-flight prompt — admitting a second one. An in-flight
claim now keeps the session un-promptable until the prompt is dispatched (cubic
P1).
- Releasing a claim no longer stomps a live foreground. If the agent's foreground
began generating for real during preflight, handing the turn back to
background-idle would let a second prompt overlap a live turn; the release
carries the epoch it claimed at and declines when it is stale (cubic P1).
- Publish the substate on the two paths that returned without one: a release
(a client that loaded the page mid-admission was stranded with a disabled
composer) and a successful non-in-place model switch (clients sat at
"background" with an enabled composer while the server had already flipped to
generating and would reject their next send) (coderabbit + cubic P2).
Note the admission window must close *before* executor.Prompt, which blocks for the
whole turn — a claim held across it would keep the session un-promptable for the
entire turn and defeat the feature outright.
Tests: forged-Output rejection (byte-identical to the adapter's own map, minus the
attestation), seed-stamps-attestation guard, background-registration-cannot-reopen,
and stale-release-over-live-foreground.
---
.../server/adapter/transport/acp/monitor.go | 7 ++
.../transport/acp/monitor_contract_test.go | 60 +++++++---
.../agentctl/types/streams/monitor_view.go | 113 +++++++++++-------
.../types/streams/monitor_view_test.go | 113 +++++++++---------
.../agentctl/types/streams/tool_payload.go | 12 ++
.../foreground_busy_signal_test.go | 22 +++-
.../orchestrator/foreground_claim_test.go | 89 ++++++++++++--
.../internal/orchestrator/task_operations.go | 33 ++++-
.../internal/orchestrator/turn_activity.go | 104 +++++++++++++---
...ine-grained-foreground-idle-busy-signal.md | 8 +-
10 files changed, 403 insertions(+), 158 deletions(-)
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
index 41ed072f51..916e8a7bf3 100644
--- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor.go
@@ -291,6 +291,9 @@ func seedMonitorView(payload *streams.NormalizedPayload, taskID, command string)
view.Command = command
}
g.Output = monitorOutputWrapper(view)
+ // Attest, out of band of the agent-shaped Output map, that WE recognized this
+ // as a Monitor — this is what the background-work classifier trusts.
+ payload.SetMonitorIdentity(view.TaskID, view.Ended)
}
// updateMonitorPayloadView mutates the Monitor tool's NormalizedPayload to
@@ -320,6 +323,7 @@ func appendMonitorEvent(payload *streams.NormalizedPayload, taskID, command, bod
view.EventCount++
view.RecentEvents = appendCapped(view.RecentEvents, body, monitorPayloadCap)
g.Output = monitorOutputWrapper(view)
+ payload.SetMonitorIdentity(view.TaskID, view.Ended)
return payload
}
@@ -337,6 +341,9 @@ func markMonitorEnded(payload *streams.NormalizedPayload, reason string) *stream
view.Ended = true
view.EndReason = reason
g.Output = monitorOutputWrapper(view)
+ // Keep the attestation in step with the view: an ended Monitor no longer holds
+ // the busy signal open.
+ payload.SetMonitorIdentity(view.TaskID, true)
return payload
}
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
index 33c85350d4..8d864fa86c 100644
--- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
@@ -78,23 +78,27 @@ func TestMonitorViewContract_SurvivesSerializationToOrchestrator(t *testing.T) {
}
}
-// Provenance guard. A Generic payload's Output is otherwise the agent's own raw
-// tool result, assigned verbatim by NormalizeToolResult. An unrelated agent whose
-// tool happens to emit a `monitor` key must NOT trip the background gate — the
-// PR's contract is that any agent we don't recognize keeps the historical
-// reject-while-RUNNING behavior. The adapter only ever publishes an active view
-// with a real task_id (it comes from the registration banner), so a forged-looking
-// view without one is rejected.
+// Provenance guard, from the producer's side. Generic.Output is the agent's own
+// raw tool result (NormalizeToolResult assigns it verbatim), so an unrelated tool
+// can emit a byte-for-byte perfect Monitor view there. Only the adapter's
+// out-of-band attestation — which it stamps solely on the path gated by ACP
+// `_meta.claudeCode.toolName`, metadata the model cannot reach — counts.
+//
+// This is what keeps ADR-0035's contract honest: an agent we don't recognize can't
+// relax its own busy gate by shaping its tool output.
func TestMonitorViewContract_ArbitraryToolOutputIsNotMistakenForAMonitor(t *testing.T) {
+ // Exactly the map monitorOutputWrapper produces — but written by "the agent"
+ // rather than by the adapter, so no seedMonitorView, and no attestation.
+ forgedButPerfect := monitorOutputWrapper(monitorPayloadView{
+ Kind: monitorToolName,
+ TaskID: "task-abc",
+ Ended: false,
+ })
+
cases := map[string]any{
- "monitor-shaped output with no task_id": map[string]any{
- streams.MonitorViewKey: map[string]any{
- streams.MonitorViewKindKey: streams.MonitorSubkind,
- streams.MonitorViewEndedKey: false,
- },
- },
- "unrelated tool result": map[string]any{"status": "ok", "rows": 3},
- "plain string output": "Monitor started (task 42, timeout 1000ms)",
+ "forged view identical to the adapter's own output": forgedButPerfect,
+ "unrelated tool result": map[string]any{"status": "ok", "rows": 3},
+ "plain string output": "Monitor started (task 42, timeout 1000ms)",
}
for name, output := range cases {
@@ -103,8 +107,32 @@ func TestMonitorViewContract_ArbitraryToolOutputIsNotMistakenForAMonitor(t *test
p.Generic().Output = output
if p.IsActiveMonitor() {
- t.Fatal("non-Monitor tool output must not be classified as background work")
+ t.Fatal("tool output the adapter never attested to must not be classified as background work")
}
})
}
}
+
+// The attestation must actually be stamped by the real producer path — if
+// seedMonitorView ever stopped calling SetMonitorIdentity, IsActiveMonitor would
+// silently return false for every genuine Monitor and quietly un-ship the feature.
+func TestMonitorViewContract_SeedStampsAdapterAttestation(t *testing.T) {
+ p := newMonitorGeneric()
+ seedMonitorView(p, "task-abc", "gh pr checks --watch")
+
+ m := p.Monitor()
+ if m == nil {
+ t.Fatal("seedMonitorView must stamp the adapter's Monitor attestation")
+ }
+ if m.TaskID != "task-abc" {
+ t.Fatalf("attested task ID = %q, want task-abc", m.TaskID)
+ }
+ if m.Ended {
+ t.Fatal("a freshly seeded Monitor is not ended")
+ }
+
+ markMonitorEnded(p, "exited")
+ if !p.Monitor().Ended {
+ t.Fatal("markMonitorEnded must keep the attestation in step with the view")
+ }
+}
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view.go b/apps/backend/internal/agentctl/types/streams/monitor_view.go
index 6f8106fe3c..d4f2c301bf 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view.go
@@ -2,22 +2,21 @@ package streams
// MonitorSubkind is the `kind` value the ACP adapter stamps on the structured
// Monitor view it tucks into a Generic tool payload's Output (see
-// server/adapter/transport/acp/monitor.go). It is the shared contract between
-// the adapter (the producer) and consumers such as the orchestrator's
-// background-work classifier, so neither side has to string-match a tool name.
+// server/adapter/transport/acp/monitor.go).
const MonitorSubkind = "Monitor"
// Monitor view map keys — the shape acp/monitor.go writes as
//
// Generic.Output = {"monitor": {"kind": …, "task_id": …, "ended": …, …}}
//
-// Exported so the producer (acp/monitor.go's monitorOutputWrapper and
-// readMonitorView) builds and reads the map from these very constants rather
-// than re-typing the literals on its side of the package boundary. That is the
-// point: while the two sides each spelled the strings out, renaming a key in the
-// producer still compiled and silently reverted Monitor sessions to the coarse
-// busy signal. acp's monitor_contract_test.go pins the producer→consumer round
-// trip so the contract can't drift unnoticed again.
+// This map is the *presentation* contract: it is what the frontend Monitor card
+// renders from. It is NOT what the background-work classifier reads — see
+// MonitorPayload and IsActiveMonitor below for why.
+//
+// Exported so acp/monitor.go builds and reads the map from these very constants
+// instead of re-typing the literals on its side of the package boundary; while
+// each side spelled the strings out independently, renaming a key on one side
+// still compiled and silently broke the other.
const (
MonitorViewKey = "monitor"
MonitorViewKindKey = "kind"
@@ -29,45 +28,73 @@ const (
MonitorViewEndReasonKey = "end_reason"
)
-// IsActiveMonitor reports whether this payload is a live Claude Monitor watch:
-// a Generic payload carrying the structured Monitor view whose `ended` flag is
-// not set. claude-agent-acp tags Monitor with `_meta.claudeCode.toolName:
-// "Monitor"` and `kind:"other"`, so it normalizes to a Generic payload rather
-// than a dedicated kind (see acp/monitor.go). A Monitor is long-running
-// background work the foreground turn is not actively generating against, so an
-// active one is treated like any other spawned background task by the busy
-// signal.
+// MonitorPayload is adapter-attested Monitor identity: proof that the ACP adapter
+// itself recognized this tool call as a Claude Monitor watch, carried as a typed
+// sibling of GenericPayload rather than as a key inside GenericPayload.Output.
//
-// Provenance: a Generic payload's Output is otherwise the agent's *own* raw tool
-// result — normalize.go's NormalizeToolResult assigns it verbatim — so this
-// predicate must not fire for an unrelated tool that merely happens to serialize
-// a `monitor` key. It therefore demands the full shape the adapter writes,
-// including a non-empty `task_id`: that field is only ever populated once the
-// Monitor registration banner yields a real task ID (seedMonitorView), so a view
-// without one did not come off the Monitor path. Note the Generic payload's
-// `Name` is NOT a usable discriminator here — it carries the ACP tool *kind*,
-// which is "other" for Monitor, not "Monitor".
+// The distinction is the whole point. `Generic.Output` is assigned the agent's
+// raw tool result verbatim (NormalizeToolResult), so any structure *inside* it is
+// agent-shaped data — an unrelated tool whose result happened to serialize a
+// monitor-looking map would be indistinguishable from a real Monitor, no matter
+// how many fields the classifier demanded. This slot, by contrast, is only ever
+// written by the adapter's Monitor recognizer (via SetMonitorIdentity), which
+// gates on ACP `_meta.claudeCode.toolName` — metadata the claude-agent-acp
+// wrapper sets, which model tool output cannot reach. Nothing in the normalize
+// path ever copies agent data here, so a payload carrying it provably came off
+// the Monitor path.
//
-// Returns false for a nil payload, a non-Generic payload, a Generic payload with
-// no Monitor view, a view carrying no task ID, or a Monitor that has ended.
-func (p *NormalizedPayload) IsActiveMonitor() bool {
- if p == nil || p.kind != ToolKindGeneric || p.generic == nil {
- return false
+// It survives the agentctl→orchestrator boundary as its own `monitor` key on the
+// serialized payload, so the classifier on the far side reads the same attestation.
+type MonitorPayload struct {
+ TaskID string `json:"task_id"`
+ Ended bool `json:"ended,omitempty"`
+}
+
+// Monitor returns the adapter-attested Monitor identity, or nil when this payload
+// was not recognized as a Monitor.
+func (p *NormalizedPayload) Monitor() *MonitorPayload {
+ if p == nil {
+ return nil
}
- wrapper, ok := p.generic.Output.(map[string]any)
- if !ok {
- return false
+ return p.monitor
+}
+
+// SetMonitorIdentity records that the ACP adapter recognized this tool call as a
+// Monitor watch, and its current terminal state. Only the adapter's Monitor
+// recognizer may call this — it is the attestation IsActiveMonitor trusts.
+func (p *NormalizedPayload) SetMonitorIdentity(taskID string, ended bool) {
+ if p == nil {
+ return
}
- view, ok := wrapper[MonitorViewKey].(map[string]any)
- if !ok {
- return false
+ if p.monitor == nil {
+ p.monitor = &MonitorPayload{}
}
- if kind, _ := view[MonitorViewKindKey].(string); kind != MonitorSubkind {
- return false
+ if taskID != "" {
+ p.monitor.TaskID = taskID
}
- if taskID, _ := view[MonitorViewTaskIDKey].(string); taskID == "" {
+ p.monitor.Ended = ended
+}
+
+// IsActiveMonitor reports whether this payload is a live Claude Monitor watch.
+// claude-agent-acp tags Monitor with `_meta.claudeCode.toolName: "Monitor"` and
+// `kind:"other"`, so it normalizes to a Generic payload rather than a dedicated
+// kind (see acp/monitor.go). A Monitor is long-running background work the
+// foreground turn is not actively generating against, so an active one is treated
+// like any other spawned background task by the busy signal (ADR-0035).
+//
+// It classifies on the adapter's attestation (MonitorPayload), never on the shape
+// of Generic.Output — that field is the agent's own raw tool result and so can
+// neither prove nor disprove provenance. This is what keeps the ADR-0035 contract
+// honest: an agent we don't recognize cannot relax its own busy gate by emitting a
+// monitor-shaped tool result, and keeps the historical reject-while-RUNNING
+// behavior. (The payload's `Name` is likewise unusable as a discriminator: it
+// carries the ACP tool kind, which is "other" for Monitor, not "Monitor".)
+//
+// Returns false for a nil payload, a payload the adapter never recognized as a
+// Monitor, or a Monitor that has already ended.
+func (p *NormalizedPayload) IsActiveMonitor() bool {
+ if p == nil || p.monitor == nil {
return false
}
- ended, _ := view[MonitorViewEndedKey].(bool)
- return !ended
+ return !p.monitor.Ended
}
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
index 74e54fe034..7c61576405 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
@@ -2,13 +2,20 @@ package streams
import "testing"
-// monitorView builds a Generic payload shaped like the one acp/monitor.go's
-// monitorOutputWrapper produces. The producer→consumer contract itself is pinned
-// against the *real* producer functions in acp's monitor_contract_test.go; these
-// cases exercise the predicate's own branches.
-func monitorView(ended bool) *NormalizedPayload {
+// monitorPayload builds the payload the ACP adapter produces for a recognized
+// Monitor: a Generic payload (Monitor arrives with ACP kind "other") carrying both
+// the presentation view in Output *and* the adapter's out-of-band attestation.
+func monitorPayload(ended bool) *NormalizedPayload {
p := NewGeneric("other", map[string]any{})
- p.Generic().Output = map[string]any{
+ p.Generic().Output = monitorViewOutput(ended)
+ p.SetMonitorIdentity("task-1", ended)
+ return p
+}
+
+// monitorViewOutput is the presentation map the frontend Monitor card renders.
+// On its own it proves nothing about provenance — see the forgery test below.
+func monitorViewOutput(ended bool) map[string]any {
+ return map[string]any{
MonitorViewKey: map[string]any{
MonitorViewKindKey: MonitorSubkind,
MonitorViewEndedKey: ended,
@@ -16,16 +23,6 @@ func monitorView(ended bool) *NormalizedPayload {
MonitorViewCommandKey: "gh pr checks --watch",
},
}
- return p
-}
-
-// genericWithOutput builds the payload an *arbitrary* agent tool produces:
-// NormalizeToolResult assigns the agent's raw result straight to Generic.Output,
-// so these are the shapes the predicate has to refuse.
-func genericWithOutput(output any) *NormalizedPayload {
- p := NewGeneric("other", map[string]any{})
- p.Generic().Output = output
- return p
}
func TestIsActiveMonitor(t *testing.T) {
@@ -34,44 +31,11 @@ func TestIsActiveMonitor(t *testing.T) {
payload *NormalizedPayload
want bool
}{
- {"active monitor", monitorView(false), true},
- {"ended monitor", monitorView(true), false},
+ {"active monitor", monitorPayload(false), true},
+ {"ended monitor", monitorPayload(true), false},
{"nil payload", nil, false},
{"non-generic payload", NewShellExec("ls", "", "", 0, false), false},
- {"generic without monitor view", NewGeneric("SomeTool", map[string]any{"a": 1}), false},
- {
- "generic with wrong subkind",
- genericWithOutput(map[string]any{
- MonitorViewKey: map[string]any{MonitorViewKindKey: "Something", MonitorViewEndedKey: false},
- }),
- false,
- },
- {
- // Provenance: the adapter only ever publishes an active view once the
- // registration banner handed it a real task ID. A monitor-shaped blob
- // without one did not come off the Monitor path — an unrelated agent's
- // tool result must not relax the busy gate.
- "monitor-shaped output with no task_id",
- genericWithOutput(map[string]any{
- MonitorViewKey: map[string]any{
- MonitorViewKindKey: MonitorSubkind,
- MonitorViewEndedKey: false,
- },
- }),
- false,
- },
- {
- "monitor-shaped output with empty task_id",
- genericWithOutput(map[string]any{
- MonitorViewKey: map[string]any{
- MonitorViewKindKey: MonitorSubkind,
- MonitorViewEndedKey: false,
- MonitorViewTaskIDKey: "",
- },
- }),
- false,
- },
- {"generic with string output", genericWithOutput("monitor"), false},
+ {"generic with no monitor identity", NewGeneric("SomeTool", map[string]any{"a": 1}), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -82,12 +46,40 @@ func TestIsActiveMonitor(t *testing.T) {
}
}
-// TestIsActiveMonitor_SurvivesJSONRoundTrip proves the predicate still works
-// after the payload crosses the agentctl→orchestrator serialization boundary,
-// where Generic.Output decodes back into a map[string]any.
-func TestIsActiveMonitor_SurvivesJSONRoundTrip(t *testing.T) {
- orig := monitorView(false)
- data, err := orig.MarshalJSON()
+// The provenance test that matters. Generic.Output is the agent's own raw tool
+// result (NormalizeToolResult assigns it verbatim), so an unrelated tool can emit a
+// *byte-for-byte perfect* Monitor view there. Without the adapter's attestation it
+// must still not be classified as background work — otherwise an agent could relax
+// its own busy gate and slip a second prompt into a live foreground turn, breaking
+// ADR-0035's "unrecognized agents keep reject-while-RUNNING" contract.
+func TestIsActiveMonitor_ForgedOutputWithoutAdapterAttestationIsRejected(t *testing.T) {
+ forged := NewGeneric("other", map[string]any{})
+ forged.Generic().Output = monitorViewOutput(false) // identical to the real thing
+ // …but no SetMonitorIdentity: the adapter never recognized this as a Monitor.
+
+ if forged.IsActiveMonitor() {
+ t.Fatal("a monitor-shaped tool result with no adapter attestation must not be classified as background work")
+ }
+
+ // And it stays rejected across the wire, where a naive shape-matcher would be
+ // fooled by the decoded map.
+ data, err := forged.MarshalJSON()
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var decoded NormalizedPayload
+ if err := decoded.UnmarshalJSON(data); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if decoded.IsActiveMonitor() {
+ t.Fatal("forged monitor output must stay unrecognized after serialization")
+ }
+}
+
+// The attestation is what has to survive the agentctl→orchestrator boundary — the
+// classifier runs on the far side of it.
+func TestIsActiveMonitor_AttestationSurvivesJSONRoundTrip(t *testing.T) {
+ data, err := monitorPayload(false).MarshalJSON()
if err != nil {
t.Fatalf("marshal: %v", err)
}
@@ -96,6 +88,9 @@ func TestIsActiveMonitor_SurvivesJSONRoundTrip(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
if !got.IsActiveMonitor() {
- t.Fatal("active Monitor must remain recognized after a JSON round-trip")
+ t.Fatal("an attested active Monitor must remain recognized after a JSON round-trip")
+ }
+ if got.Monitor() == nil || got.Monitor().TaskID != "task-1" {
+ t.Fatalf("attested task ID must survive the round-trip, got %+v", got.Monitor())
}
}
diff --git a/apps/backend/internal/agentctl/types/streams/tool_payload.go b/apps/backend/internal/agentctl/types/streams/tool_payload.go
index 901ead6836..a7cd9f1559 100644
--- a/apps/backend/internal/agentctl/types/streams/tool_payload.go
+++ b/apps/backend/internal/agentctl/types/streams/tool_payload.go
@@ -54,6 +54,14 @@ type NormalizedPayload struct {
showPlan *ShowPlanPayload
manageTodos *ManageTodosPayload
misc *MiscPayload
+ // monitor is adapter-only provenance, deliberately a sibling of generic
+ // rather than a key inside GenericPayload.Output. Output is assigned the
+ // agent's raw tool result verbatim (see NormalizeToolResult), so anything
+ // carried inside it is agent-shaped data and cannot attest to its own origin.
+ // This slot is written solely by the ACP adapter's Monitor recognizer, which
+ // gates on ACP `_meta.claudeCode.toolName` — set by the claude-agent-acp
+ // wrapper, not by model tool output. IsActiveMonitor classifies on this.
+ monitor *MonitorPayload
}
// --- Getters for NormalizedPayload ---
@@ -86,6 +94,7 @@ func (p *NormalizedPayload) MarshalJSON() ([]byte, error) {
ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"`
ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"`
Misc *MiscPayload `json:"misc,omitempty"`
+ Monitor *MonitorPayload `json:"monitor,omitempty"`
}
return json.Marshal(jsonPayload{
Kind: p.kind,
@@ -100,6 +109,7 @@ func (p *NormalizedPayload) MarshalJSON() ([]byte, error) {
ShowPlan: p.showPlan,
ManageTodos: p.manageTodos,
Misc: p.misc,
+ Monitor: p.monitor,
})
}
@@ -119,12 +129,14 @@ func (p *NormalizedPayload) UnmarshalJSON(data []byte) error {
ShowPlan *ShowPlanPayload `json:"show_plan,omitempty"`
ManageTodos *ManageTodosPayload `json:"manage_todos,omitempty"`
Misc *MiscPayload `json:"misc,omitempty"`
+ Monitor *MonitorPayload `json:"monitor,omitempty"`
}
var jp jsonPayload
if err := json.Unmarshal(data, &jp); err != nil {
return err
}
p.kind = jp.Kind
+ p.monitor = jp.Monitor
p.readFile = jp.ReadFile
p.modifyFile = jp.ModifyFile
p.shellExec = jp.ShellExec
diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
index 15c969dbd8..cec4465157 100644
--- a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
+++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
@@ -357,16 +357,26 @@ func TestForegroundBusySignal_UnregisteredTerminalToolUpdateLeavesGateOpen(t *te
// monitorGenericPayload builds the Generic payload the ACP adapter emits for a
// Claude Monitor: kind=generic with the structured `{monitor:{...}}` view tucked
// into Output. `ended` toggles whether the watch is still live.
+// monitorGenericPayload mirrors what the ACP adapter emits for a recognized
+// Monitor: the presentation view in Generic.Output (what the frontend card reads)
+// AND the adapter's out-of-band attestation (what the background-work classifier
+// reads). Monitor arrives with ACP kind "other", hence the generic name.
+//
+// The attestation is not decoration — IsActiveMonitor classifies on it alone,
+// because Generic.Output is agent-supplied and so can't vouch for its own origin.
+// A fixture that only set the Output map would be a forgery, and would (correctly)
+// no longer register as background work.
func monitorGenericPayload(ended bool) *streams.NormalizedPayload {
- p := streams.NewGeneric("Monitor", map[string]any{})
+ p := streams.NewGeneric("other", map[string]any{})
p.Generic().Output = map[string]any{
- "monitor": map[string]any{
- "kind": streams.MonitorSubkind,
- "ended": ended,
- "task_id": "task-1",
- "command": "gh pr checks --watch",
+ streams.MonitorViewKey: map[string]any{
+ streams.MonitorViewKindKey: streams.MonitorSubkind,
+ streams.MonitorViewEndedKey: ended,
+ streams.MonitorViewTaskIDKey: "task-1",
+ streams.MonitorViewCommandKey: "gh pr checks --watch",
},
}
+ p.SetMonitorIdentity("task-1", ended)
return p
}
diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go
index 857ca511d1..ae39f7ad9a 100644
--- a/apps/backend/internal/orchestrator/foreground_claim_test.go
+++ b/apps/backend/internal/orchestrator/foreground_claim_test.go
@@ -49,7 +49,7 @@ func TestClaimForegroundTurn_OnlyOneConcurrentPromptWins(t *testing.T) {
go func() {
defer done.Done()
start.Wait() // release them all into the window together
- if svc.claimForegroundTurn(sessionID) {
+ if _, ok := svc.claimForegroundTurn(sessionID); ok {
mu.Lock()
won++
mu.Unlock()
@@ -162,16 +162,83 @@ func TestPromptTask_ConcurrentPromptsIntoBackgroundIdleStartOneTurn(t *testing.T
}
}
+// The claim has to be durable against the background set moving underneath it.
+// A background tool_call landing while a prompt is mid-admission calls
+// registerBackgroundTask, which re-sets `yielded` — if promptability were derived
+// from `yielded` alone that would reopen the gate under the in-flight prompt and
+// admit a second one, putting two prompts on one ACP session. The in-flight claim
+// is tracked independently of background-idle activity precisely so it can't.
+func TestClaimForegroundTurn_BackgroundRegistrationCannotReopenTheAdmissionWindow(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const sessionID = "session-reopen"
+ svc.registerBackgroundTask(sessionID, "tool-subagent-1")
+
+ if _, ok := svc.claimForegroundTurn(sessionID); !ok {
+ t.Fatal("the first prompt must win the claim")
+ }
+
+ // The agent spawns a second background task while the first prompt is still in
+ // preflight (reloading the session, ensuring the agent is running, ...).
+ svc.registerBackgroundTask(sessionID, "tool-subagent-2")
+
+ if !svc.isForegroundTurnGenerating(sessionID) {
+ t.Fatal("a session with a prompt in flight must stay un-promptable")
+ }
+ if _, ok := svc.claimForegroundTurn(sessionID); ok {
+ t.Fatal("a new background task must not reopen the admission window under an in-flight prompt")
+ }
+
+ // Once the prompt is handed to the agent, the admission window closes and the
+ // background set governs again — work THIS turn spawned may legitimately yield it.
+ svc.completeForegroundClaim(sessionID)
+ if svc.isForegroundTurnGenerating(sessionID) {
+ t.Fatal("after handoff the outstanding background work must make the turn background-idle again")
+ }
+}
+
+// A release must not stomp a foreground that started generating for real while the
+// failing prompt was in preflight. Handing the turn back to background-idle there
+// would let a second prompt overlap a live turn — the very thing the claim prevents.
+func TestReleaseForegroundClaim_DoesNotReopenGateOverALiveForeground(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const sessionID = "session-stale-release"
+ svc.registerBackgroundTask(sessionID, "tool-subagent-1")
+
+ epoch, ok := svc.claimForegroundTurn(sessionID)
+ if !ok {
+ t.Fatal("the prompt must win the claim")
+ }
+
+ // While the prompt is in preflight, the agent's foreground streams real output:
+ // the turn is genuinely generating again, whatever happens to this prompt.
+ svc.markForegroundGenerating(sessionID)
+
+ // The prompt then fails. Its claim is stale — releasing must not reopen the gate.
+ if svc.releaseForegroundClaim(sessionID, epoch) {
+ t.Fatal("a stale claim must not hand a live generating foreground back to background-idle")
+ }
+ if !svc.isForegroundTurnGenerating(sessionID) {
+ t.Fatal("the foreground is generating; the gate must stay closed")
+ }
+ if _, ok := svc.claimForegroundTurn(sessionID); ok {
+ t.Fatal("no prompt may claim a turn whose foreground is actively generating")
+ }
+}
+
// An untracked session has no background work outstanding, so there is nothing to
// claim: the historical reject-while-RUNNING default must stand.
func TestClaimForegroundTurn_UntrackedSessionCannotBeClaimed(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
- if svc.claimForegroundTurn("session-never-seen") {
+ if _, ok := svc.claimForegroundTurn("session-never-seen"); ok {
t.Fatal("a session with no outstanding background work must not be claimable")
}
- if svc.claimForegroundTurn("") {
+ if _, ok := svc.claimForegroundTurn(""); ok {
t.Fatal("an empty session ID must not be claimable")
}
}
@@ -188,7 +255,8 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
const sessionID = "session-release"
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
- if !svc.claimForegroundTurn(sessionID) {
+ epoch, ok := svc.claimForegroundTurn(sessionID)
+ if !ok {
t.Fatal("the first prompt must win the claim")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
@@ -196,14 +264,16 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
}
// The prompt fails before reaching the agent.
- svc.releaseForegroundClaim(sessionID)
+ if !svc.releaseForegroundClaim(sessionID, epoch) {
+ t.Fatal("releasing a live claim with background work outstanding must reopen the gate")
+ }
// Background work is still outstanding, so the session is background-idle again
// and the operator can retry.
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("a released claim must return the turn to background-idle, got %q", got)
}
- if !svc.claimForegroundTurn(sessionID) {
+ if _, ok := svc.claimForegroundTurn(sessionID); !ok {
t.Fatal("a retried prompt must be able to claim the released turn")
}
}
@@ -217,14 +287,17 @@ func TestReleaseForegroundClaim_DoesNotReopenGateWithoutBackgroundWork(t *testin
const sessionID = "session-release-nobg"
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
- if !svc.claimForegroundTurn(sessionID) {
+ epoch, ok := svc.claimForegroundTurn(sessionID)
+ if !ok {
t.Fatal("the prompt must win the claim")
}
// The background task completes while the prompt is still in flight, then the
// prompt fails.
svc.completeBackgroundTask(sessionID, "tool-subagent-1")
- svc.releaseForegroundClaim(sessionID)
+ if svc.releaseForegroundClaim(sessionID, epoch) {
+ t.Fatal("with no background work outstanding the release must not reopen the gate")
+ }
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
t.Fatalf("with no background work outstanding the turn must read as generating, got %q", got)
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index 29b4115ee1..78b3c768f1 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -2666,23 +2666,31 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
// same read and start an overlapping turn on one ACP session. Losing the claim
// means another prompt got there first — reject exactly as before ADR-0036.
foregroundClaimed := false
+ var foregroundClaimEpoch uint64
if session.State == models.TaskSessionStateRunning {
- if !s.claimForegroundTurn(sessionID) {
+ epoch, ok := s.claimForegroundTurn(sessionID)
+ if !ok {
s.logger.Warn("rejected prompt: another prompt claimed the background-idle foreground turn first",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return nil, fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress)
}
- foregroundClaimed = true
+ foregroundClaimed, foregroundClaimEpoch = true, epoch
}
// Hand the claim back if this prompt fails before it reaches the agent —
// otherwise the session would advertise a generating foreground it doesn't have
// and lock the operator out for the rest of the turn. Once executor.Prompt is
// under way the normal turn-close paths (handlePromptError → completeTurnForSession)
// own the reset.
+ //
+ // Broadcast the restored substate: a client that loaded the page *during* the
+ // admission window read foreground_activity=generating off the DTO, and with no
+ // event it would sit there with a disabled composer even though the turn is
+ // background-idle again. releaseForegroundClaim only reports true when it really
+ // did reopen the gate, so this can't publish a lie.
releaseForegroundClaimOnFailure := func() {
- if foregroundClaimed {
- s.releaseForegroundClaim(sessionID)
+ if foregroundClaimed && s.releaseForegroundClaim(sessionID, foregroundClaimEpoch) {
+ s.publishForegroundActivityChanged(ctx, taskID, sessionID)
}
}
@@ -2713,10 +2721,17 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
// Check if model switching is requested. A switch that fails never reaches the
// agent, so it releases the claim; a switch that succeeds delivered the prompt
- // itself (trySwitchModel opens the turn) and keeps it.
+ // itself (trySwitchModel opens the turn), which spends the claim.
if result, switched, err := s.trySwitchModel(ctx, taskID, sessionID, model, effectivePrompt, session); switched || err != nil {
if err != nil {
releaseForegroundClaimOnFailure()
+ } else if foregroundClaimed {
+ // This return skips the publish below, so a prompt admitted through the
+ // background-idle gate and then delivered by a model switch would leave live
+ // clients showing "background" with an enabled composer while the server has
+ // already flipped to generating and would reject their next send.
+ s.completeForegroundClaim(sessionID)
+ s.publishForegroundActivityChanged(ctx, taskID, sessionID)
}
return result, err
}
@@ -2749,6 +2764,14 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
s.publishForegroundActivityChanged(ctx, taskID, sessionID)
}
+ // The prompt is about to reach the agent, so the admission window closes here.
+ // It has to close *before* executor.Prompt, which blocks for the whole turn: a
+ // claim still held across it would keep the session un-promptable for the entire
+ // turn, defeating the very lockout this feature removes. From here on the
+ // background set governs promptability again — work this turn spawns may
+ // legitimately yield it back to background-idle.
+ s.completeForegroundClaim(sessionID)
+
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the prompt.
// Prompts can take a long time (minutes) while the WS request may timeout in 15 seconds.
// We still want to log and respond, but the prompt should continue regardless.
diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go
index 2f0b2b584a..da914d5493 100644
--- a/apps/backend/internal/orchestrator/turn_activity.go
+++ b/apps/backend/internal/orchestrator/turn_activity.go
@@ -32,6 +32,22 @@ type turnActivity struct {
mu sync.Mutex
background map[string]struct{} // outstanding background/spawned tool-call IDs
yielded bool // foreground handed off to background work
+
+ // promptInFlight marks an admitted prompt that has claimed the foreground turn
+ // but has not yet been handed to the agent. It is deliberately independent of
+ // `yielded`: during that window the session must stay un-promptable no matter
+ // what happens to the background set. Otherwise a background tool_call landing
+ // mid-admission (registerBackgroundTask re-sets `yielded`) would reopen the gate
+ // under the in-flight prompt and let a second one through — two prompts reaching
+ // one ACP session, which is the exact overlap the claim exists to prevent.
+ promptInFlight bool
+ // claimEpoch is bumped by every event that redefines who owns the foreground:
+ // a claim, or genuine foreground output. releaseForegroundClaim carries the
+ // epoch it claimed at and only re-yields when that epoch is still current — so a
+ // prompt that fails *after* the agent's foreground started generating again
+ // cannot hand the turn back to "background-idle" and let a second prompt overlap
+ // a live turn.
+ claimEpoch uint64
}
// turnActivityFor returns the per-session activity record, creating it when
@@ -62,6 +78,10 @@ func (s *Service) markForegroundGenerating(sessionID string) bool {
ta.mu.Lock()
changed := ta.yielded
ta.yielded = false
+ // Real foreground output redefines ownership of the turn: it invalidates any
+ // outstanding claim's epoch, so a prompt that later fails cannot release the
+ // gate back open on top of a foreground that is now genuinely generating.
+ ta.claimEpoch++
ta.mu.Unlock()
return changed
}
@@ -151,6 +171,17 @@ func (s *Service) isForegroundTurnGenerating(sessionID string) bool {
}
ta.mu.Lock()
defer ta.mu.Unlock()
+ return ta.generatingLocked()
+}
+
+// generatingLocked is the single definition of "the foreground turn is busy".
+// An admitted-but-not-yet-dispatched prompt counts as busy in its own right: until
+// it reaches the agent the session must not admit another, regardless of what the
+// background set does underneath it.
+func (ta *turnActivity) generatingLocked() bool {
+ if ta.promptInFlight {
+ return true
+ }
return !ta.yielded
}
@@ -170,24 +201,47 @@ func (s *Service) isForegroundTurnGenerating(sessionID string) bool {
// rejected with ErrAgentPromptInProgress exactly as it would have been before
// ADR-0035.
//
-// Returns false for an untracked session — no background work is outstanding, so
-// there is nothing to claim and the historical reject-while-RUNNING default
-// stands.
-func (s *Service) claimForegroundTurn(sessionID string) bool {
+// The claim is held until the prompt is dispatched (completeForegroundClaim) or
+// handed back (releaseForegroundClaim). It returns the claim's epoch, which the
+// release must present to prove it still owns the turn.
+//
+// Returns ok=false for an untracked session — no background work is outstanding,
+// so there is nothing to claim and the historical reject-while-RUNNING default
+// stands — and for a session that already has a prompt in flight.
+func (s *Service) claimForegroundTurn(sessionID string) (uint64, bool) {
if sessionID == "" {
- return false
+ return 0, false
}
ta := s.turnActivityFor(sessionID, false)
if ta == nil {
- return false
+ return 0, false
}
ta.mu.Lock()
defer ta.mu.Unlock()
- if !ta.yielded {
- return false
+ if ta.generatingLocked() {
+ return 0, false
}
ta.yielded = false
- return true
+ ta.promptInFlight = true
+ ta.claimEpoch++
+ return ta.claimEpoch, true
+}
+
+// completeForegroundClaim ends the admission window: the prompt has been handed to
+// the agent, so this is now an ordinary foreground turn and the background set
+// governs promptability again (a subagent spawned by *this* turn may legitimately
+// yield it back to background-idle).
+func (s *Service) completeForegroundClaim(sessionID string) {
+ if sessionID == "" {
+ return
+ }
+ ta := s.turnActivityFor(sessionID, false)
+ if ta == nil {
+ return
+ }
+ ta.mu.Lock()
+ ta.promptInFlight = false
+ ta.mu.Unlock()
}
// releaseForegroundClaim hands a claimForegroundTurn claim back when the prompt
@@ -196,22 +250,36 @@ func (s *Service) claimForegroundTurn(sessionID string) bool {
// generating foreground it does not have, locking the operator out for the rest
// of the turn — the exact lockout ADR-0035 exists to remove.
//
-// Only re-yields while background work is genuinely still outstanding: if the
-// last background task completed while the failing prompt was in flight, the turn
-// is no longer waiting on anything and the generating default is correct.
-func (s *Service) releaseForegroundClaim(sessionID string) {
+// It reports whether the turn was actually handed back to background-idle, so the
+// caller can broadcast the restored substate. Two things stop a release from
+// opening the gate when it shouldn't:
+//
+// - The epoch. If the agent's foreground streamed real output while this prompt
+// was in preflight, markForegroundGenerating bumped the epoch: the turn is
+// genuinely generating now, and handing it back to background-idle would let a
+// second prompt overlap a live turn.
+// - The background set. If the last background task finished while the failing
+// prompt was in flight, nothing is outstanding and the generating default is
+// correct.
+func (s *Service) releaseForegroundClaim(sessionID string, epoch uint64) bool {
if sessionID == "" {
- return
+ return false
}
ta := s.turnActivityFor(sessionID, false)
if ta == nil {
- return
+ return false
}
ta.mu.Lock()
- if len(ta.background) > 0 {
- ta.yielded = true
+ defer ta.mu.Unlock()
+ if !ta.promptInFlight {
+ return false
}
- ta.mu.Unlock()
+ ta.promptInFlight = false
+ if ta.claimEpoch != epoch || len(ta.background) == 0 {
+ return false
+ }
+ ta.yielded = true
+ return true
}
// foregroundActivityValue reports the fine-grained busy substate of a session
diff --git a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
index 8109f957da..228db69b99 100644
--- a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
+++ b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
@@ -22,9 +22,11 @@ Mid-turn steering — delivering a message *into* a turn while the model is acti
Track, per session and **in memory**, whether the open turn is foreground-driven or has yielded to outstanding background work, and narrow the prompt gate (`checkSessionPromptable`) to the foreground only:
- A `RUNNING` session accepts a new prompt when its foreground turn is idle and at least one recognized background task is outstanding; otherwise it keeps rejecting input exactly as before.
-- Recognition keys off the **normalized shape** of the work (subagent task / `run_in_background` shell / active Monitor), not tool-name string matching. The ACP normalizer is corrected to recognize Claude's `run_in_background:true` shell shape (a normalizer bug fix in its own right). A shared `IsActiveMonitor` predicate classifies an active Monitor as background work and an ended one as not. Because a Generic payload's `Output` is otherwise the agent's own raw tool result, that predicate demands the full view the adapter writes — including the `task_id` only a real Monitor registration produces — so an unrelated agent's tool output cannot trip the gate by coincidence. (The payload's `Name` is *not* a usable discriminator: it carries the ACP tool kind, which is `"other"` for Monitor.)
-- The default is **busy**: any agent whose in-flight frames are not recognized as background work (Codex, OpenCode, and any future agent) preserves today's exact reject-while-`RUNNING` contract. The narrowing is capability-gated, not assumed.
-- Admission is **check-and-claim, not check-then-act**. The gate is a pure read, so `PromptTask` follows a passing read with an atomic `claimForegroundTurn` before it drives the turn: the check and the flip back to foreground-generating happen under one lock. Without this, two prompts landing in the background-idle window together (a double-send, two tabs) would both pass the read — the window spans a session reload, `ensureSessionRunning`, and a possibly network-bound model switch — and both reach `executor.Prompt`, starting overlapping turns on one ACP session. Exactly one prompt wins; the losers are rejected with `ErrAgentPromptInProgress` just as they were before this ADR. A claim whose prompt then fails before reaching the agent is released, so a failed send cannot leave the session advertising a foreground it does not have.
+- Recognition keys off the **normalized shape** of the work (subagent task / `run_in_background` shell / active Monitor), not tool-name string matching. The ACP normalizer is corrected to recognize Claude's `run_in_background:true` shell shape (a normalizer bug fix in its own right).
+- Monitor recognition rests on **adapter attestation, not payload shape**. A Monitor normalizes to a Generic payload, and that payload's `Output` is assigned the agent's raw tool result verbatim — so *nothing carried inside `Output` can vouch for its own origin*, however many fields a classifier demands. The ACP adapter therefore stamps a typed `MonitorPayload` as a **sibling** of the Generic payload, on the path already gated by ACP `_meta.claudeCode.toolName` (metadata the claude-agent-acp wrapper sets, which model tool output cannot reach). `IsActiveMonitor` classifies on that attestation alone. `Output` keeps carrying the view the frontend card renders — it is a presentation contract, not a trust one. (The payload's `Name` is likewise unusable as a discriminator: it carries the ACP tool *kind*, which is `"other"` for Monitor.)
+- The default is **busy**: any agent whose in-flight frames are not recognized as background work (Codex, OpenCode, and any future agent) preserves today's exact reject-while-`RUNNING` contract. The narrowing is capability-gated, not assumed — and, per the point above, an unrecognized agent cannot relax its own gate by shaping its tool output.
+- Admission is **check-and-claim, not check-then-act**. The gate is a pure read, so `PromptTask` follows a passing read with an atomic `claimForegroundTurn` before it drives the turn: the check and the flip back to foreground-generating happen under one lock. Without this, two prompts landing in the background-idle window together (a double-send, two tabs) would both pass the read — the window spans a session reload, `ensureSessionRunning`, and a possibly network-bound model switch — and both reach `executor.Prompt`, starting overlapping turns on one ACP session. Exactly one prompt wins; the losers are rejected with `ErrAgentPromptInProgress` just as they were before this ADR.
+- The claim is **held, not merely taken**, and is tracked independently of background-idle activity. It survives until the prompt is dispatched to the agent, so a background tool call landing mid-admission cannot reopen the gate underneath an in-flight prompt. It is released if the prompt fails before reaching the agent — but only when the claim is still current: if the agent's foreground began generating for real during preflight, releasing would hand a live turn back to "background-idle" and let a second prompt overlap it, so a stale claim declines to reopen the gate. The window must close *before* `executor.Prompt`, which blocks for the whole turn; a claim held across it would keep the session un-promptable for the entire turn and defeat the feature outright. Every transition that changes the substate is broadcast, including a release, so a client that loaded the page mid-admission is not stranded with a stale composer.
The distinction is surfaced to the operator as a fine-grained substate (`foreground_activity`: `generating` vs `background`) so the UI can communicate two independent facts — "you may type" *and* "work is still in progress" — instead of collapsing them into one busy/done bit:
From 80e35001b76b039d4772cf72fb358723b922540b Mon Sep 17 00:00:00 2001
From: Carlos Florencio
Date: Mon, 13 Jul 2026 23:13:14 +0100
Subject: [PATCH 07/80] fix(orchestrator): serialize foreground prompt
admission
---
.../runtime/lifecycle/manager_interaction.go | 10 +
.../agent/runtime/lifecycle/session.go | 136 ++++++++---
.../agent/runtime/lifecycle/session_test.go | 211 ++++++++++++++++++
.../internal/agent/runtime/lifecycle/types.go | 5 +
apps/backend/internal/backendapp/adapters.go | 11 +
.../orchestrator/event_handlers_streaming.go | 18 +-
.../executor/executor_interaction.go | 30 ++-
.../foreground_activity_signal_test.go | 109 +++++++++
.../foreground_busy_signal_test.go | 24 ++
.../orchestrator/foreground_claim_test.go | 67 ++++--
.../internal/orchestrator/task_operations.go | 33 ++-
.../internal/orchestrator/turn_activity.go | 83 +++----
...ine-grained-foreground-idle-busy-signal.md | 4 +-
13 files changed, 631 insertions(+), 110 deletions(-)
diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go
index ed551628ff..af07409b6f 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go
@@ -103,6 +103,16 @@ func (m *Manager) PromptAgent(ctx context.Context, executionID string, prompt st
return result, err
}
+// PromptAgentWithDispatchCallback exposes agentctl acceptance to callers that
+// must keep admission serialized until the queued prompt is actually dispatched.
+func (m *Manager) PromptAgentWithDispatchCallback(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*PromptResult, error) {
+ execution, exists := m.executionStore.Get(executionID)
+ if !exists {
+ return nil, fmt.Errorf("execution %q not found: %w", executionID, ErrExecutionNotFound)
+ }
+ return m.sessionManager.SendPromptWithDispatchCallback(ctx, execution, prompt, true, attachments, dispatchOnly, onDispatched)
+}
+
// cancelWaitTimeout bounds how long CancelAgent waits for the in-flight SendPrompt
// to exit after the in-flight session/prompt RPC has ended (cancel acknowledged).
// Exposed as a var (not const) so tests can shorten it without fake clocks.
diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go
index 91f4996ed6..c37ad79fdd 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/session.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/session.go
@@ -635,11 +635,50 @@ func (sm *SessionManager) SendPrompt(
validateStatus bool,
attachments []v1.MessageAttachment,
dispatchOnly bool,
+) (*PromptResult, error) {
+ return sm.sendPrompt(ctx, execution, prompt, validateStatus, attachments, dispatchOnly, nil)
+}
+
+// SendPromptWithDispatchCallback reports the point at which agentctl accepted
+// the prompt while preserving SendPrompt's completion semantics.
+func (sm *SessionManager) SendPromptWithDispatchCallback(
+ ctx context.Context,
+ execution *AgentExecution,
+ prompt string,
+ validateStatus bool,
+ attachments []v1.MessageAttachment,
+ dispatchOnly bool,
+ onDispatched func(),
+) (*PromptResult, error) {
+ return sm.sendPrompt(ctx, execution, prompt, validateStatus, attachments, dispatchOnly, onDispatched)
+}
+
+func (sm *SessionManager) sendPrompt(
+ ctx context.Context,
+ execution *AgentExecution,
+ prompt string,
+ validateStatus bool,
+ attachments []v1.MessageAttachment,
+ dispatchOnly bool,
+ onDispatched func(),
) (*PromptResult, error) {
if execution.agentctl == nil {
return nil, fmt.Errorf("execution %q has no agentctl client", execution.ID)
}
+ // Agentctl acknowledges prompt dispatch before the adapter's session/prompt
+ // RPC completes. Serialize here as well as in the ACP adapter so two callers
+ // cannot reset the same buffers and race on the shared completion channel.
+ execution.promptMu.Lock()
+ defer execution.promptMu.Unlock()
+
+ // Dispatch-only callers return before the agent completes, but the next prompt
+ // still must not reset shared buffers or consume that earlier completion as its
+ // own. Keep the pending completion as an execution-level barrier.
+ if err := waitForPendingDispatchedPrompt(ctx, execution); err != nil {
+ return nil, err
+ }
+
// Drain any stale signal left in the channel by a prior dispatch-only prompt
// whose completion arrived after SendPrompt returned. Without this, the next
// waitForPromptDone would consume the stale signal and report an immediate
@@ -657,15 +696,30 @@ func (sm *SessionManager) SendPrompt(
defer beginPromptBarrier(execution)()
}
- // Inject session trace context so prompt spans become children of the session span
+ preparedCtx, effectivePrompt, promptGeneration, err := sm.preparePrompt(ctx, execution, prompt, validateStatus, attachments)
+ if err != nil {
+ return nil, err
+ }
+ if err := sm.triggerPrompt(preparedCtx, execution, effectivePrompt, attachments, promptGeneration); err != nil {
+ return nil, err
+ }
+ return sm.finishAcceptedPrompt(preparedCtx, execution, dispatchOnly, onDispatched)
+}
+
+func (sm *SessionManager) preparePrompt(
+ ctx context.Context,
+ execution *AgentExecution,
+ prompt string,
+ validateStatus bool,
+ attachments []v1.MessageAttachment,
+) (context.Context, string, uint64, error) {
if sessionSpan := trace.SpanFromContext(execution.SessionTraceContext()); sessionSpan.SpanContext().IsValid() {
ctx = trace.ContextWithSpan(ctx, sessionSpan)
}
-
// For follow-up prompts, validate status before claiming a new generation.
if validateStatus {
if execution.Status != v1.AgentStatusRunning && execution.Status != v1.AgentStatusReady {
- return nil, fmt.Errorf("execution %q is not ready for prompts (status: %s)", execution.ID, execution.Status)
+ return ctx, "", 0, fmt.Errorf("execution %q is not ready for prompts (status: %s)", execution.ID, execution.Status)
}
}
@@ -677,13 +731,13 @@ func (sm *SessionManager) SendPrompt(
var err error
promptGeneration, err = sm.promptStarter(execution.ID)
if err != nil {
- return nil, err
+ return ctx, "", 0, err
}
case sm.executionStore != nil:
var err error
promptGeneration, err = sm.executionStore.BeginPrompt(execution.ID)
if err != nil {
- return nil, err
+ return ctx, "", 0, err
}
default:
// Tests that construct SessionManager without lifecycle dependencies
@@ -691,54 +745,78 @@ func (sm *SessionManager) SendPrompt(
promptGeneration = beginExecutionPrompt(execution)
}
- // Clear buffers and streaming state before starting prompt
- // This ensures each prompt starts fresh and doesn't append to previous message
execution.messageMu.Lock()
execution.messageBuffer.Reset()
execution.thinkingBuffer.Reset()
- execution.currentMessageID = "" // Clear streaming message ID for new turn
- execution.currentThinkingID = "" // Clear streaming thinking ID for new turn
+ execution.currentMessageID = ""
+ execution.currentThinkingID = ""
execution.messageMu.Unlock()
- // Apply resume context injection if needed (first prompt after resume)
effectivePrompt := sm.buildEffectivePrompt(execution, prompt)
-
sm.logger.Info("sending prompt to agent",
zap.String("execution_id", execution.ID),
zap.Int("prompt_length", len(effectivePrompt)),
zap.Int("attachments_count", len(attachments)))
-
- // Store user prompt to session history for context injection (store original, not with injected context)
if sm.historyManager != nil && execution.historyEnabled && execution.SessionID != "" {
if err := sm.historyManager.AppendUserMessage(execution.SessionID, prompt); err != nil {
sm.logger.Warn("failed to store user message to history", zap.Error(err))
}
}
-
- // Initialize activity timestamp for stall detection
execution.lastActivityAtMu.Lock()
execution.lastActivityAt = time.Now()
execution.lastActivityAtMu.Unlock()
+ return ctx, effectivePrompt, promptGeneration, nil
+}
- // Fire the prompt (returns immediately now — completion comes via WebSocket complete event)
- err := sm.dispatchPrompt(ctx, execution, effectivePrompt, attachments, promptGeneration)
- if err != nil {
- if isCancelReleaseError(err.Error()) {
- sm.logger.Info("prompt trigger abandoned after cancel; requeueing",
- zap.String("execution_id", execution.ID),
- zap.Error(err))
- return nil, fmt.Errorf("failed to trigger prompt: %w: %w", err, ErrCancelEscalated)
- }
- sm.logger.Error("failed to trigger prompt",
- zap.String("execution_id", execution.ID),
- zap.Error(err))
- return nil, fmt.Errorf("failed to trigger prompt: %w", err)
+func (sm *SessionManager) triggerPrompt(
+ ctx context.Context,
+ execution *AgentExecution,
+ prompt string,
+ attachments []v1.MessageAttachment,
+ promptGeneration uint64,
+) error {
+ err := sm.dispatchPrompt(ctx, execution, prompt, attachments, promptGeneration)
+ if err == nil {
+ return nil
+ }
+ if isCancelReleaseError(err.Error()) {
+ sm.logger.Info("prompt trigger abandoned after cancel; requeueing",
+ zap.String("execution_id", execution.ID), zap.Error(err))
+ return fmt.Errorf("failed to trigger prompt: %w: %w", err, ErrCancelEscalated)
}
+ sm.logger.Error("failed to trigger prompt",
+ zap.String("execution_id", execution.ID), zap.Error(err))
+ return fmt.Errorf("failed to trigger prompt: %w", err)
+}
+
+func waitForPendingDispatchedPrompt(ctx context.Context, execution *AgentExecution) error {
+ if !execution.dispatchedPromptPending {
+ return nil
+ }
+ select {
+ case <-execution.promptDoneCh:
+ execution.dispatchedPromptPending = false
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+func (sm *SessionManager) finishAcceptedPrompt(
+ ctx context.Context,
+ execution *AgentExecution,
+ dispatchOnly bool,
+ onDispatched func(),
+) (*PromptResult, error) {
+ if dispatchOnly {
+ execution.dispatchedPromptPending = true
+ }
+ if onDispatched != nil {
+ onDispatched()
+ }
if dispatchOnly {
return &PromptResult{StopReason: PromptStopReasonDispatched}, nil
}
-
// Wait for completion signal from handleAgentEvent(complete) or stream disconnect.
return sm.waitForPromptDone(ctx, execution)
}
diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go
index 2fff3ef9c3..ae294ad09d 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go
@@ -1166,6 +1166,131 @@ func TestSendPrompt_DispatchOnlyReturnsWithoutWaiting(t *testing.T) {
}
}
+func TestSendPrompt_DispatchOnlyBlocksNextPromptUntilItsCompletion(t *testing.T) {
+ mock := newMockAgentServer(t)
+ defer mock.Close()
+
+ firstPromptSeen := make(chan struct{})
+ secondPromptSeen := make(chan struct{})
+ var promptCount int
+ mock.handler = func(msg ws.Message) *ws.Message {
+ if msg.Action == "agent.prompt" {
+ promptCount++
+ switch promptCount {
+ case 1:
+ close(firstPromptSeen)
+ case 2:
+ close(secondPromptSeen)
+ }
+ }
+ return mock.defaultHandler(msg)
+ }
+
+ sm := NewSessionManager(newSessionTestLogger(), make(chan struct{}))
+ client := createTestClient(t, mock.server.URL)
+ defer client.Close()
+ ctx := context.Background()
+ if err := client.StreamUpdates(ctx, func(event agentctl.AgentEvent) {}, nil, nil); err != nil {
+ t.Fatalf("failed to connect stream: %v", err)
+ }
+ waitForWSConnected(t, mock)
+
+ execution := &AgentExecution{
+ ID: "test-exec",
+ TaskID: "test-task",
+ SessionID: "test-session",
+ agentctl: client,
+ promptDoneCh: make(chan PromptCompletionSignal, 1),
+ }
+ if _, err := sm.SendPrompt(ctx, execution, "first", false, nil, true); err != nil {
+ t.Fatalf("dispatch-only prompt failed: %v", err)
+ }
+ select {
+ case <-firstPromptSeen:
+ case <-time.After(2 * time.Second):
+ t.Fatal("first prompt did not reach agentctl")
+ }
+
+ result := make(chan error, 1)
+ go func() {
+ _, err := sm.SendPrompt(ctx, execution, "second", false, nil, false)
+ result <- err
+ }()
+
+ select {
+ case <-secondPromptSeen:
+ t.Fatal("second prompt reached agentctl before the dispatch-only turn completed")
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ execution.promptDoneCh <- PromptCompletionSignal{StopReason: "first-complete"}
+ select {
+ case <-secondPromptSeen:
+ case <-time.After(2 * time.Second):
+ t.Fatal("second prompt did not reach agentctl after the dispatch-only turn completed")
+ }
+ execution.promptDoneCh <- PromptCompletionSignal{StopReason: "second-complete"}
+ select {
+ case err := <-result:
+ if err != nil {
+ t.Fatalf("second prompt failed: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("second prompt did not return")
+ }
+}
+
+func TestSendPromptWithDispatchCallback_NotifiesBeforeCompletion(t *testing.T) {
+ mock := newMockAgentServer(t)
+ defer mock.Close()
+
+ sm := NewSessionManager(newSessionTestLogger(), make(chan struct{}))
+ client := createTestClient(t, mock.server.URL)
+ defer client.Close()
+ ctx := context.Background()
+ if err := client.StreamUpdates(ctx, func(event agentctl.AgentEvent) {}, nil, nil); err != nil {
+ t.Fatalf("failed to connect stream: %v", err)
+ }
+ waitForWSConnected(t, mock)
+
+ execution := &AgentExecution{
+ ID: "test-exec",
+ TaskID: "test-task",
+ SessionID: "test-session",
+ agentctl: client,
+ promptDoneCh: make(chan PromptCompletionSignal, 1),
+ }
+ dispatched := make(chan struct{})
+ result := make(chan error, 1)
+ go func() {
+ _, err := sm.SendPromptWithDispatchCallback(
+ ctx, execution, "hello", false, nil, false, func() { close(dispatched) },
+ )
+ result <- err
+ }()
+
+ select {
+ case <-dispatched:
+ case <-time.After(2 * time.Second):
+ t.Fatal("dispatch callback was not invoked after agentctl accepted the prompt")
+ }
+ select {
+ case err := <-result:
+ t.Fatalf("SendPrompt returned before completion: %v", err)
+ default:
+ }
+
+ execution.promptDoneCh <- PromptCompletionSignal{StopReason: "complete"}
+ select {
+ case err := <-result:
+ if err != nil {
+ t.Fatalf("SendPrompt returned error: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("SendPrompt did not return after completion")
+ }
+}
+
func TestSendPrompt_AdvancesGenerationForEveryDispatch(t *testing.T) {
mock := newMockAgentServer(t)
t.Cleanup(mock.Close)
@@ -1258,6 +1383,92 @@ func TestSendPrompt_DrainsStaleSignalFromPriorDispatchOnly(t *testing.T) {
}
}
+func TestSendPrompt_SerializesCompletionWaitersPerExecution(t *testing.T) {
+ mock := newMockAgentServer(t)
+ defer mock.Close()
+
+ firstPromptSeen := make(chan struct{})
+ secondPromptSeen := make(chan struct{})
+ var promptCount int
+ mock.handler = func(msg ws.Message) *ws.Message {
+ if msg.Action == "agent.prompt" {
+ promptCount++
+ switch promptCount {
+ case 1:
+ close(firstPromptSeen)
+ case 2:
+ close(secondPromptSeen)
+ }
+ }
+ return mock.defaultHandler(msg)
+ }
+
+ log := newSessionTestLogger()
+ sm := NewSessionManager(log, make(chan struct{}))
+ client := createTestClient(t, mock.server.URL)
+ defer client.Close()
+
+ ctx := context.Background()
+ if err := client.StreamUpdates(ctx, func(event agentctl.AgentEvent) {}, nil, nil); err != nil {
+ t.Fatalf("failed to connect stream: %v", err)
+ }
+ waitForWSConnected(t, mock)
+
+ execution := &AgentExecution{
+ ID: "test-exec",
+ TaskID: "test-task",
+ SessionID: "test-session",
+ agentctl: client,
+ promptDoneCh: make(chan PromptCompletionSignal, 1),
+ }
+ results := make(chan error, 2)
+ go func() {
+ _, err := sm.SendPrompt(ctx, execution, "first", false, nil, false)
+ results <- err
+ }()
+
+ select {
+ case <-firstPromptSeen:
+ case <-time.After(2 * time.Second):
+ t.Fatal("first prompt did not reach agentctl")
+ }
+
+ go func() {
+ _, err := sm.SendPrompt(ctx, execution, "second", false, nil, false)
+ results <- err
+ }()
+
+ secondArrivedBeforeFirstCompleted := false
+ select {
+ case <-secondPromptSeen:
+ secondArrivedBeforeFirstCompleted = true
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ execution.promptDoneCh <- PromptCompletionSignal{StopReason: "first-complete"}
+ select {
+ case <-secondPromptSeen:
+ case <-time.After(2 * time.Second):
+ t.Fatal("second prompt did not reach agentctl after first completion")
+ }
+ execution.promptDoneCh <- PromptCompletionSignal{StopReason: "second-complete"}
+
+ for range 2 {
+ select {
+ case err := <-results:
+ if err != nil {
+ t.Fatalf("SendPrompt returned error: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("SendPrompt did not return")
+ }
+ }
+
+ if secondArrivedBeforeFirstCompleted {
+ t.Fatal("second prompt reached agentctl before the first completion was correlated")
+ }
+}
+
func TestWaitForPromptDone_TreatsPromptAbandonedAfterCancelAsCancelEscalated(t *testing.T) {
log := newSessionTestLogger()
sm := NewSessionManager(log, make(chan struct{}))
diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go
index c5caf11724..f11833e0d9 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/types.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/types.go
@@ -146,6 +146,11 @@ type AgentExecution struct {
// Channel signaled by handleAgentEvent(complete) or stream disconnect to unblock SendPrompt.
// Buffered (size 1) so the sender never blocks.
promptDoneCh chan PromptCompletionSignal
+ // promptMu keeps exactly one SendPrompt completion waiter and one set of
+ // response buffers active for an execution. Agentctl accepts prompt requests
+ // asynchronously, so its transport-level gate alone cannot provide this.
+ promptMu sync.Mutex
+ dispatchedPromptPending bool
// Closed when the current SendPrompt returns, so CancelAgent can wait
// for the in-flight prompt to finish before the caller retries.
diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go
index d1a96e8243..88d1a639e9 100644
--- a/apps/backend/internal/backendapp/adapters.go
+++ b/apps/backend/internal/backendapp/adapters.go
@@ -388,6 +388,17 @@ func (a *lifecycleAdapter) PromptAgent(ctx context.Context, agentInstanceID stri
}, nil
}
+func (a *lifecycleAdapter) PromptAgentWithDispatchCallback(ctx context.Context, agentInstanceID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) {
+ result, err := a.mgr.PromptAgentWithDispatchCallback(ctx, agentInstanceID, prompt, attachments, dispatchOnly, onDispatched)
+ if err != nil {
+ return nil, err
+ }
+ return &executor.PromptResult{
+ StopReason: result.StopReason,
+ AgentMessage: result.AgentMessage,
+ }, nil
+}
+
// CancelAgent interrupts the current agent turn without terminating the process.
func (a *lifecycleAdapter) CancelAgent(ctx context.Context, sessionID string) error {
return a.mgr.CancelAgentBySessionID(ctx, sessionID)
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go
index 2a39957ec7..5e1e3dba67 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go
@@ -265,9 +265,12 @@ func (s *Service) handleToolCallEvent(ctx context.Context, payload *lifecycle.Ag
// tool_call that already arrives terminal is not outstanding work — clearing
// is driven by tool_update, so registering it would leak into the hold and
// never clear.
- if payload.Data.ParentToolCallID == "" && !isTerminalToolStatus(payload.Data.ToolStatus) &&
- normalizedIsBackgroundTask(payload.Data.Normalized) {
- if s.registerBackgroundTask(payload.SessionID, payload.Data.ToolCallID) {
+ if payload.Data.ParentToolCallID == "" && !isTerminalToolStatus(payload.Data.ToolStatus) {
+ if normalizedIsBackgroundTask(payload.Data.Normalized) {
+ if s.registerBackgroundTask(payload.SessionID, payload.Data.ToolCallID) {
+ s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
+ }
+ } else if s.markForegroundGenerating(payload.SessionID) {
s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
}
}
@@ -506,8 +509,13 @@ func (s *Service) trackBackgroundToolUpdate(ctx context.Context, payload *lifecy
}
return
}
- if s.hasBackgroundTask(payload.SessionID, payload.Data.ToolCallID) ||
- !normalizedIsBackgroundTask(payload.Data.Normalized) {
+ if s.hasBackgroundTask(payload.SessionID, payload.Data.ToolCallID) {
+ return
+ }
+ if !normalizedIsBackgroundTask(payload.Data.Normalized) {
+ if s.markForegroundGenerating(payload.SessionID) {
+ s.publishForegroundActivityChanged(ctx, payload.TaskID, payload.SessionID)
+ }
return
}
// Both of Claude's background shapes only become recognizable on a
diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go
index 180bb30c6c..84112a7a07 100644
--- a/apps/backend/internal/orchestrator/executor/executor_interaction.go
+++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go
@@ -237,6 +237,20 @@ const stopReasonPassthrough = "passthrough_dispatched"
// Returns PromptResult indicating if the agent needs input
// Attachments (images) are passed to the agent if provided
func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, preloadedSession ...*models.TaskSession) (*PromptResult, error) {
+ return e.prompt(ctx, taskID, sessionID, prompt, attachments, dispatchOnly, nil, preloadedSession...)
+}
+
+// PromptWithDispatchCallback invokes onDispatched after agentctl accepts the
+// prompt but before waiting for the turn to complete.
+func (e *Executor) PromptWithDispatchCallback(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func(), preloadedSession ...*models.TaskSession) (*PromptResult, error) {
+ return e.prompt(ctx, taskID, sessionID, prompt, attachments, dispatchOnly, onDispatched, preloadedSession...)
+}
+
+type promptAgentWithDispatchCallback interface {
+ PromptAgentWithDispatchCallback(context.Context, string, string, []v1.MessageAttachment, bool, func()) (*PromptResult, error)
+}
+
+func (e *Executor) prompt(ctx context.Context, taskID, sessionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func(), preloadedSession ...*models.TaskSession) (*PromptResult, error) {
var session *models.TaskSession
if len(preloadedSession) > 0 && preloadedSession[0] != nil {
session = preloadedSession[0]
@@ -270,10 +284,22 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt
// dispatchOnly is intentionally not forwarded: PTY writes are inherently
// fire-and-forget, so the flag has no analogue in passthrough mode.
if e.agentManager.IsPassthroughSession(ctx, sessionID) {
- return e.promptPassthrough(ctx, taskID, session, prompt, attachments)
+ result, err := e.promptPassthrough(ctx, taskID, session, prompt, attachments)
+ if err == nil && onDispatched != nil {
+ onDispatched()
+ }
+ return result, err
}
- result, err := e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly)
+ var result *PromptResult
+ if notifier, ok := e.agentManager.(promptAgentWithDispatchCallback); ok {
+ result, err = notifier.PromptAgentWithDispatchCallback(ctx, executionID, prompt, attachments, dispatchOnly, onDispatched)
+ } else {
+ result, err = e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly)
+ if err == nil && onDispatched != nil {
+ onDispatched()
+ }
+ }
if err != nil {
if errors.Is(err, lifecycle.ErrExecutionNotFound) {
return nil, ErrExecutionNotFound
diff --git a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
index 8ecf2ce980..2d64dfb3dd 100644
--- a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
+++ b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
@@ -7,6 +7,8 @@ import (
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/events"
+ "github.com/kandev/kandev/internal/orchestrator/executor"
+ "github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -142,3 +144,110 @@ func TestForegroundActivitySignal_NoPublishWithoutFlip(t *testing.T) {
}
}
}
+
+func TestForegroundActivitySignal_ClaimReleasePublishesRestoredBackground(t *testing.T) {
+ repo := setupTestRepo(t)
+ agentMgr := &mockAgentManager{}
+ svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
+ svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
+ eb := &recordingEventBus{}
+ svc.eventBus = eb
+
+ const (
+ taskID = "task1"
+ sessionID = "session-release-signal"
+ )
+ seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
+ svc.registerBackgroundTask(sessionID, "background-1")
+
+ // Admission claims the foreground, but the missing executor row makes
+ // ensureSessionRunning fail before the prompt reaches the agent. Releasing
+ // that claim must tell every client that background-idle was restored.
+ if _, err := svc.PromptTask(context.Background(), taskID, sessionID, "retry", "", false, nil, false); err == nil {
+ t.Fatal("expected prompt preflight to fail without an executor record")
+ }
+
+ got := activityValues(eb)
+ want := []string{string(v1.ForegroundActivityBackground)}
+ if len(got) != len(want) || got[0] != want[0] {
+ t.Fatalf("expected restored background activity broadcast %v, got %v", want, got)
+ }
+}
+
+func TestForegroundActivitySignal_ModelSwitchPublishesClaimedGenerating(t *testing.T) {
+ repo := setupTestRepo(t)
+ agentMgr := &mockAgentManager{
+ isAgentRunning: true,
+ launchAgentFunc: func(context.Context, *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
+ return &executor.LaunchAgentResponse{AgentExecutionID: "exec-2"}, nil
+ },
+ }
+ svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
+ svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
+ eb := &recordingEventBus{}
+ svc.eventBus = eb
+
+ const (
+ taskID = "task1"
+ sessionID = "session-model-switch-signal"
+ )
+ seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
+ session, err := repo.GetTaskSession(context.Background(), sessionID)
+ if err != nil {
+ t.Fatalf("load session: %v", err)
+ }
+ session.AgentProfileSnapshot = map[string]interface{}{"model": "old-model"}
+ if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
+ t.Fatalf("update session: %v", err)
+ }
+ seedExecutorRunning(t, repo, sessionID, taskID, "exec-1")
+ svc.registerBackgroundTask(sessionID, "background-1")
+
+ result, err := svc.PromptTask(context.Background(), taskID, sessionID, "continue", "new-model", false, nil, false)
+ if err != nil {
+ t.Fatalf("model-switch prompt failed: %v", err)
+ }
+ if result == nil || result.StopReason != "model_switched" {
+ t.Fatalf("expected restart-based model switch result, got %#v", result)
+ }
+
+ got := activityValues(eb)
+ want := []string{string(v1.ForegroundActivityGenerating)}
+ if len(got) != len(want) || got[0] != want[0] {
+ t.Fatalf("expected claimed foreground activity broadcast %v, got %v", want, got)
+ }
+}
+
+func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringClaim(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ eb := &recordingEventBus{}
+ svc.eventBus = eb
+
+ const (
+ taskID = "task1"
+ sessionID = "session-dispatch-signal"
+ )
+ svc.registerBackgroundTask(sessionID, "background-1")
+ claim := svc.claimForegroundTurn(sessionID)
+ if claim == nil {
+ t.Fatal("prompt must claim the background-idle turn")
+ }
+ if svc.registerBackgroundTask(sessionID, "background-2") {
+ t.Fatal("background registration must not publish through an active claim")
+ }
+ if s := svc.ForegroundActivity(sessionID); s != v1.ForegroundActivityGenerating {
+ t.Fatalf("active claim must remain generating, got %q", s)
+ }
+
+ if !svc.completeForegroundClaim(claim) {
+ t.Fatal("dispatch must expose the background work registered during admission")
+ }
+ svc.publishForegroundActivityChanged(context.Background(), taskID, sessionID)
+
+ got := activityValues(eb)
+ want := []string{string(v1.ForegroundActivityBackground)}
+ if len(got) != len(want) || got[0] != want[0] {
+ t.Fatalf("expected dispatch-time background activity broadcast %v, got %v", want, got)
+ }
+}
diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
index cec4465157..3fc6accd84 100644
--- a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
+++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
@@ -49,6 +49,30 @@ func TestCheckSessionPromptable_BackgroundTaskAcceptsInput(t *testing.T) {
}
}
+func TestForegroundToolCallClosesBackgroundIdleGate(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ svc.messageCreator = &mockMessageCreator{}
+
+ const sessionID = "session-foreground-tool"
+ svc.registerBackgroundTask(sessionID, "background-1")
+
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: "task1",
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "read-1",
+ ToolStatus: "running",
+ Normalized: streams.NewReadFile("/repo/main.go", 0, 0),
+ },
+ })
+
+ if err := svc.checkSessionPromptable("task1", sessionID, models.TaskSessionStateRunning); !errors.Is(err, ErrAgentPromptInProgress) {
+ t.Fatalf("top-level foreground tool activity must close the prompt gate, got: %v", err)
+ }
+}
+
// TestTurnActivity_ForegroundBackgroundTransitions locks in the state machine
// behind isForegroundTurnGenerating.
// TestForegroundActivity_ExportedValue covers the seam the page-load / list
diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go
index ae39f7ad9a..c95b81bc32 100644
--- a/apps/backend/internal/orchestrator/foreground_claim_test.go
+++ b/apps/backend/internal/orchestrator/foreground_claim_test.go
@@ -49,7 +49,7 @@ func TestClaimForegroundTurn_OnlyOneConcurrentPromptWins(t *testing.T) {
go func() {
defer done.Done()
start.Wait() // release them all into the window together
- if _, ok := svc.claimForegroundTurn(sessionID); ok {
+ if svc.claimForegroundTurn(sessionID) != nil {
mu.Lock()
won++
mu.Unlock()
@@ -175,7 +175,8 @@ func TestClaimForegroundTurn_BackgroundRegistrationCannotReopenTheAdmissionWindo
const sessionID = "session-reopen"
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
- if _, ok := svc.claimForegroundTurn(sessionID); !ok {
+ claim := svc.claimForegroundTurn(sessionID)
+ if claim == nil {
t.Fatal("the first prompt must win the claim")
}
@@ -186,13 +187,13 @@ func TestClaimForegroundTurn_BackgroundRegistrationCannotReopenTheAdmissionWindo
if !svc.isForegroundTurnGenerating(sessionID) {
t.Fatal("a session with a prompt in flight must stay un-promptable")
}
- if _, ok := svc.claimForegroundTurn(sessionID); ok {
+ if svc.claimForegroundTurn(sessionID) != nil {
t.Fatal("a new background task must not reopen the admission window under an in-flight prompt")
}
// Once the prompt is handed to the agent, the admission window closes and the
// background set governs again — work THIS turn spawned may legitimately yield it.
- svc.completeForegroundClaim(sessionID)
+ svc.completeForegroundClaim(claim)
if svc.isForegroundTurnGenerating(sessionID) {
t.Fatal("after handoff the outstanding background work must make the turn background-idle again")
}
@@ -208,8 +209,8 @@ func TestReleaseForegroundClaim_DoesNotReopenGateOverALiveForeground(t *testing.
const sessionID = "session-stale-release"
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
- epoch, ok := svc.claimForegroundTurn(sessionID)
- if !ok {
+ claim := svc.claimForegroundTurn(sessionID)
+ if claim == nil {
t.Fatal("the prompt must win the claim")
}
@@ -218,13 +219,13 @@ func TestReleaseForegroundClaim_DoesNotReopenGateOverALiveForeground(t *testing.
svc.markForegroundGenerating(sessionID)
// The prompt then fails. Its claim is stale — releasing must not reopen the gate.
- if svc.releaseForegroundClaim(sessionID, epoch) {
+ if svc.releaseForegroundClaim(claim) {
t.Fatal("a stale claim must not hand a live generating foreground back to background-idle")
}
if !svc.isForegroundTurnGenerating(sessionID) {
t.Fatal("the foreground is generating; the gate must stay closed")
}
- if _, ok := svc.claimForegroundTurn(sessionID); ok {
+ if svc.claimForegroundTurn(sessionID) != nil {
t.Fatal("no prompt may claim a turn whose foreground is actively generating")
}
}
@@ -235,10 +236,10 @@ func TestClaimForegroundTurn_UntrackedSessionCannotBeClaimed(t *testing.T) {
repo := setupTestRepo(t)
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
- if _, ok := svc.claimForegroundTurn("session-never-seen"); ok {
+ if svc.claimForegroundTurn("session-never-seen") != nil {
t.Fatal("a session with no outstanding background work must not be claimable")
}
- if _, ok := svc.claimForegroundTurn(""); ok {
+ if svc.claimForegroundTurn("") != nil {
t.Fatal("an empty session ID must not be claimable")
}
}
@@ -255,8 +256,8 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
const sessionID = "session-release"
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
- epoch, ok := svc.claimForegroundTurn(sessionID)
- if !ok {
+ claim := svc.claimForegroundTurn(sessionID)
+ if claim == nil {
t.Fatal("the first prompt must win the claim")
}
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityGenerating {
@@ -264,7 +265,7 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
}
// The prompt fails before reaching the agent.
- if !svc.releaseForegroundClaim(sessionID, epoch) {
+ if !svc.releaseForegroundClaim(claim) {
t.Fatal("releasing a live claim with background work outstanding must reopen the gate")
}
@@ -273,7 +274,7 @@ func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
t.Fatalf("a released claim must return the turn to background-idle, got %q", got)
}
- if _, ok := svc.claimForegroundTurn(sessionID); !ok {
+ if svc.claimForegroundTurn(sessionID) == nil {
t.Fatal("a retried prompt must be able to claim the released turn")
}
}
@@ -287,15 +288,15 @@ func TestReleaseForegroundClaim_DoesNotReopenGateWithoutBackgroundWork(t *testin
const sessionID = "session-release-nobg"
svc.registerBackgroundTask(sessionID, "tool-subagent-1")
- epoch, ok := svc.claimForegroundTurn(sessionID)
- if !ok {
+ claim := svc.claimForegroundTurn(sessionID)
+ if claim == nil {
t.Fatal("the prompt must win the claim")
}
// The background task completes while the prompt is still in flight, then the
// prompt fails.
svc.completeBackgroundTask(sessionID, "tool-subagent-1")
- if svc.releaseForegroundClaim(sessionID, epoch) {
+ if svc.releaseForegroundClaim(claim) {
t.Fatal("with no background work outstanding the release must not reopen the gate")
}
@@ -303,3 +304,35 @@ func TestReleaseForegroundClaim_DoesNotReopenGateWithoutBackgroundWork(t *testin
t.Fatalf("with no background work outstanding the turn must read as generating, got %q", got)
}
}
+
+func TestForegroundClaim_StaleTokenCannotCompleteOrReleaseNewClaim(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+
+ const sessionID = "session-claim-generation"
+ svc.registerBackgroundTask(sessionID, "background-1")
+ first := svc.claimForegroundTurn(sessionID)
+ if first == nil {
+ t.Fatal("first prompt must win the claim")
+ }
+ // Work registered during admission becomes visible after agentctl accepts the
+ // prompt, allowing the dispatched turn to yield again.
+ svc.registerBackgroundTask(sessionID, "background-2")
+ if !svc.completeForegroundClaim(first) {
+ t.Fatal("first claim must complete")
+ }
+ second := svc.claimForegroundTurn(sessionID)
+ if second == nil {
+ t.Fatal("second prompt must claim the newly yielded turn")
+ }
+
+ if svc.completeForegroundClaim(first) {
+ t.Fatal("a stale completion must not clear a newer admission")
+ }
+ if svc.releaseForegroundClaim(first) {
+ t.Fatal("a stale release must not clear a newer admission")
+ }
+ if svc.claimForegroundTurn(sessionID) != nil {
+ t.Fatal("the newer claim must remain active after stale token operations")
+ }
+}
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index 78b3c768f1..2615667688 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -2665,17 +2665,15 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
// is check-then-act window in which a second prompt could otherwise pass the
// same read and start an overlapping turn on one ACP session. Losing the claim
// means another prompt got there first — reject exactly as before ADR-0036.
- foregroundClaimed := false
- var foregroundClaimEpoch uint64
+ var foregroundClaim *foregroundClaim
if session.State == models.TaskSessionStateRunning {
- epoch, ok := s.claimForegroundTurn(sessionID)
- if !ok {
+ foregroundClaim = s.claimForegroundTurn(sessionID)
+ if foregroundClaim == nil {
s.logger.Warn("rejected prompt: another prompt claimed the background-idle foreground turn first",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
return nil, fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress)
}
- foregroundClaimed, foregroundClaimEpoch = true, epoch
}
// Hand the claim back if this prompt fails before it reaches the agent —
// otherwise the session would advertise a generating foreground it doesn't have
@@ -2689,7 +2687,7 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
// background-idle again. releaseForegroundClaim only reports true when it really
// did reopen the gate, so this can't publish a lie.
releaseForegroundClaimOnFailure := func() {
- if foregroundClaimed && s.releaseForegroundClaim(sessionID, foregroundClaimEpoch) {
+ if s.releaseForegroundClaim(foregroundClaim) {
s.publishForegroundActivityChanged(ctx, taskID, sessionID)
}
}
@@ -2725,12 +2723,12 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
if result, switched, err := s.trySwitchModel(ctx, taskID, sessionID, model, effectivePrompt, session); switched || err != nil {
if err != nil {
releaseForegroundClaimOnFailure()
- } else if foregroundClaimed {
+ } else if foregroundClaim != nil {
// This return skips the publish below, so a prompt admitted through the
// background-idle gate and then delivered by a model switch would leave live
// clients showing "background" with an enabled composer while the server has
// already flipped to generating and would reject their next send.
- s.completeForegroundClaim(sessionID)
+ s.completeForegroundClaim(foregroundClaim)
s.publishForegroundActivityChanged(ctx, taskID, sessionID)
}
return result, err
@@ -2760,23 +2758,22 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
// markForegroundGenerating has nothing left to change and reports false —
// the claim is the transition, and it still has to be broadcast.
flippedToGenerating := s.markForegroundGenerating(sessionID)
- if flippedToGenerating || foregroundClaimed {
+ if flippedToGenerating || foregroundClaim != nil {
s.publishForegroundActivityChanged(ctx, taskID, sessionID)
}
- // The prompt is about to reach the agent, so the admission window closes here.
- // It has to close *before* executor.Prompt, which blocks for the whole turn: a
- // claim still held across it would keep the session un-promptable for the entire
- // turn, defeating the very lockout this feature removes. From here on the
- // background set governs promptability again — work this turn spawns may
- // legitimately yield it back to background-idle.
- s.completeForegroundClaim(sessionID)
-
// Use context.WithoutCancel to prevent WebSocket request timeout from canceling the prompt.
// Prompts can take a long time (minutes) while the WS request may timeout in 15 seconds.
// We still want to log and respond, but the prompt should continue regardless.
promptCtx := context.WithoutCancel(ctx)
- result, err := s.executor.Prompt(promptCtx, taskID, sessionID, effectivePrompt, attachments, dispatchOnly, session)
+ result, err := s.executor.PromptWithDispatchCallback(
+ promptCtx, taskID, sessionID, effectivePrompt, attachments, dispatchOnly,
+ func() {
+ if s.completeForegroundClaim(foregroundClaim) {
+ s.publishForegroundActivityChanged(promptCtx, taskID, sessionID)
+ }
+ }, session,
+ )
if err != nil {
return s.handlePromptDispatchFailure(ctx, taskID, sessionID, prompt, planMode, resumedForPrompt, attachments, previousSessionState, err)
}
diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go
index da914d5493..6b798d070f 100644
--- a/apps/backend/internal/orchestrator/turn_activity.go
+++ b/apps/backend/internal/orchestrator/turn_activity.go
@@ -40,14 +40,18 @@ type turnActivity struct {
// mid-admission (registerBackgroundTask re-sets `yielded`) would reopen the gate
// under the in-flight prompt and let a second one through — two prompts reaching
// one ACP session, which is the exact overlap the claim exists to prevent.
- promptInFlight bool
- // claimEpoch is bumped by every event that redefines who owns the foreground:
- // a claim, or genuine foreground output. releaseForegroundClaim carries the
- // epoch it claimed at and only re-yields when that epoch is still current — so a
- // prompt that fails *after* the agent's foreground started generating again
- // cannot hand the turn back to "background-idle" and let a second prompt overlap
- // a live turn.
- claimEpoch uint64
+ promptInFlight bool
+ claimGeneration uint64 // identifies the current admission owner
+ foregroundEpoch uint64 // increments on genuine foreground output
+}
+
+// foregroundClaim binds admission to the exact activity record and generation
+// it claimed. The foreground epoch separately detects output that makes a failed
+// prompt's background-idle restoration stale.
+type foregroundClaim struct {
+ activity *turnActivity
+ claimGeneration uint64
+ foregroundEpoch uint64
}
// turnActivityFor returns the per-session activity record, creating it when
@@ -81,7 +85,7 @@ func (s *Service) markForegroundGenerating(sessionID string) bool {
// Real foreground output redefines ownership of the turn: it invalidates any
// outstanding claim's epoch, so a prompt that later fails cannot release the
// gate back open on top of a foreground that is now genuinely generating.
- ta.claimEpoch++
+ ta.foregroundEpoch++
ta.mu.Unlock()
return changed
}
@@ -98,7 +102,7 @@ func (s *Service) registerBackgroundTask(sessionID, toolCallID string) bool {
}
ta := s.turnActivityFor(sessionID, true)
ta.mu.Lock()
- changed := !ta.yielded
+ changed := !ta.yielded && !ta.promptInFlight
ta.background[toolCallID] = struct{}{}
ta.yielded = true
ta.mu.Unlock()
@@ -201,47 +205,53 @@ func (ta *turnActivity) generatingLocked() bool {
// rejected with ErrAgentPromptInProgress exactly as it would have been before
// ADR-0035.
//
-// The claim is held until the prompt is dispatched (completeForegroundClaim) or
-// handed back (releaseForegroundClaim). It returns the claim's epoch, which the
-// release must present to prove it still owns the turn.
+// The claim is held until agentctl accepts the prompt (completeForegroundClaim)
+// or it is handed back (releaseForegroundClaim). The returned token binds both
+// operations to this activity record and admission generation.
//
-// Returns ok=false for an untracked session — no background work is outstanding,
-// so there is nothing to claim and the historical reject-while-RUNNING default
+// Returns nil for an untracked session — no background work is outstanding, so
+// there is nothing to claim and the historical reject-while-RUNNING default
// stands — and for a session that already has a prompt in flight.
-func (s *Service) claimForegroundTurn(sessionID string) (uint64, bool) {
+func (s *Service) claimForegroundTurn(sessionID string) *foregroundClaim {
if sessionID == "" {
- return 0, false
+ return nil
}
ta := s.turnActivityFor(sessionID, false)
if ta == nil {
- return 0, false
+ return nil
}
ta.mu.Lock()
defer ta.mu.Unlock()
if ta.generatingLocked() {
- return 0, false
+ return nil
}
ta.yielded = false
ta.promptInFlight = true
- ta.claimEpoch++
- return ta.claimEpoch, true
+ ta.claimGeneration++
+ return &foregroundClaim{
+ activity: ta,
+ claimGeneration: ta.claimGeneration,
+ foregroundEpoch: ta.foregroundEpoch,
+ }
}
// completeForegroundClaim ends the admission window: the prompt has been handed to
// the agent, so this is now an ordinary foreground turn and the background set
// governs promptability again (a subagent spawned by *this* turn may legitimately
-// yield it back to background-idle).
-func (s *Service) completeForegroundClaim(sessionID string) {
- if sessionID == "" {
- return
- }
- ta := s.turnActivityFor(sessionID, false)
- if ta == nil {
- return
+// yield it back to background-idle). It reports whether background work hidden by
+// the active claim became visible, so the caller can publish that transition.
+func (s *Service) completeForegroundClaim(claim *foregroundClaim) bool {
+ if claim == nil || claim.activity == nil {
+ return false
}
+ ta := claim.activity
ta.mu.Lock()
+ defer ta.mu.Unlock()
+ if !ta.promptInFlight || ta.claimGeneration != claim.claimGeneration {
+ return false
+ }
ta.promptInFlight = false
- ta.mu.Unlock()
+ return ta.yielded
}
// releaseForegroundClaim hands a claimForegroundTurn claim back when the prompt
@@ -261,21 +271,18 @@ func (s *Service) completeForegroundClaim(sessionID string) {
// - The background set. If the last background task finished while the failing
// prompt was in flight, nothing is outstanding and the generating default is
// correct.
-func (s *Service) releaseForegroundClaim(sessionID string, epoch uint64) bool {
- if sessionID == "" {
- return false
- }
- ta := s.turnActivityFor(sessionID, false)
- if ta == nil {
+func (s *Service) releaseForegroundClaim(claim *foregroundClaim) bool {
+ if claim == nil || claim.activity == nil {
return false
}
+ ta := claim.activity
ta.mu.Lock()
defer ta.mu.Unlock()
- if !ta.promptInFlight {
+ if !ta.promptInFlight || ta.claimGeneration != claim.claimGeneration {
return false
}
ta.promptInFlight = false
- if ta.claimEpoch != epoch || len(ta.background) == 0 {
+ if ta.foregroundEpoch != claim.foregroundEpoch || len(ta.background) == 0 {
return false
}
ta.yielded = true
diff --git a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
index 228db69b99..4a6057bcd3 100644
--- a/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
+++ b/docs/decisions/0049-fine-grained-foreground-idle-busy-signal.md
@@ -26,7 +26,9 @@ Track, per session and **in memory**, whether the open turn is foreground-driven
- Monitor recognition rests on **adapter attestation, not payload shape**. A Monitor normalizes to a Generic payload, and that payload's `Output` is assigned the agent's raw tool result verbatim — so *nothing carried inside `Output` can vouch for its own origin*, however many fields a classifier demands. The ACP adapter therefore stamps a typed `MonitorPayload` as a **sibling** of the Generic payload, on the path already gated by ACP `_meta.claudeCode.toolName` (metadata the claude-agent-acp wrapper sets, which model tool output cannot reach). `IsActiveMonitor` classifies on that attestation alone. `Output` keeps carrying the view the frontend card renders — it is a presentation contract, not a trust one. (The payload's `Name` is likewise unusable as a discriminator: it carries the ACP tool *kind*, which is `"other"` for Monitor.)
- The default is **busy**: any agent whose in-flight frames are not recognized as background work (Codex, OpenCode, and any future agent) preserves today's exact reject-while-`RUNNING` contract. The narrowing is capability-gated, not assumed — and, per the point above, an unrecognized agent cannot relax its own gate by shaping its tool output.
- Admission is **check-and-claim, not check-then-act**. The gate is a pure read, so `PromptTask` follows a passing read with an atomic `claimForegroundTurn` before it drives the turn: the check and the flip back to foreground-generating happen under one lock. Without this, two prompts landing in the background-idle window together (a double-send, two tabs) would both pass the read — the window spans a session reload, `ensureSessionRunning`, and a possibly network-bound model switch — and both reach `executor.Prompt`, starting overlapping turns on one ACP session. Exactly one prompt wins; the losers are rejected with `ErrAgentPromptInProgress` just as they were before this ADR.
-- The claim is **held, not merely taken**, and is tracked independently of background-idle activity. It survives until the prompt is dispatched to the agent, so a background tool call landing mid-admission cannot reopen the gate underneath an in-flight prompt. It is released if the prompt fails before reaching the agent — but only when the claim is still current: if the agent's foreground began generating for real during preflight, releasing would hand a live turn back to "background-idle" and let a second prompt overlap it, so a stale claim declines to reopen the gate. The window must close *before* `executor.Prompt`, which blocks for the whole turn; a claim held across it would keep the session un-promptable for the entire turn and defeat the feature outright. Every transition that changes the substate is broadcast, including a release, so a client that loaded the page mid-admission is not stranded with a stale composer.
+- The claim is **held, not merely taken**, and is tracked independently of background-idle activity. It survives until agentctl accepts the prompt, so a background tool call landing while the prompt is in preflight or queued cannot reopen the gate underneath it. The lifecycle adapter reports that exact dispatch boundary before waiting for turn completion. Claim tokens bind the activity record and a monotonically increasing admission generation, so a delayed completion or release cannot mutate a newer claim for the same session. A failed pre-dispatch prompt reopens the gate only if no newer foreground output invalidated its captured foreground epoch. Every transition that changes the substate is broadcast, including a release and background work that becomes visible when a claim completes, so clients cannot remain stranded on the admission-time value.
+- Lifecycle prompt delivery is serialized per agent execution. Agentctl acknowledges transport dispatch before the adapter's prompt RPC completes, so adapter-level queuing alone does not protect lifecycle's shared completion channel and response buffers from concurrent callers. An execution-scoped mutex gives each prompt one waiter and one buffer set; dispatch-only sends leave a pending-completion barrier that the next send must consume before resetting those buffers.
+- Any top-level non-background tool activity marks the foreground as generating again, just like message and thinking frames. This closes the gate as soon as foreground execution resumes even when the next frame is a tool call rather than text.
The distinction is surfaced to the operator as a fine-grained substate (`foreground_activity`: `generating` vs `background`) so the UI can communicate two independent facts — "you may type" *and* "work is still in progress" — instead of collapsing them into one busy/done bit:
From 4ab34433cf7285d7cf3a9ecc249859393d83047e Mon Sep 17 00:00:00 2001
From: Carlos Florencio
Date: Mon, 13 Jul 2026 23:28:00 +0100
Subject: [PATCH 08/80] fix(orchestrator): enforce prompt dispatch callback
contract
---
.../agent/runtime/lifecycle/session.go | 2 +-
.../agent/runtime/lifecycle/session_test.go | 38 +++++++++++++++++++
.../orchestrator/event_handlers_test.go | 7 ++++
.../executor/executor_interaction.go | 11 ++++--
.../executor_passthrough_prompt_test.go | 24 ++++++++++++
5 files changed, 77 insertions(+), 5 deletions(-)
diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go
index c37ad79fdd..2fadb38f54 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/session.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/session.go
@@ -842,7 +842,7 @@ func (sm *SessionManager) dispatchPrompt(
sm.logger.Warn("prompt retry after stream reconnect failed",
zap.String("execution_id", execution.ID),
zap.Error(retryErr))
- return err
+ return retryErr
}
// beginPromptBarrier sets up a completion signal on the execution so CancelAgent
diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go
index ae294ad09d..a7eb338bd0 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go
@@ -1109,6 +1109,44 @@ func TestSendPrompt_RetriesUntilUpdateStreamReconnects(t *testing.T) {
}
}
+func TestSendPrompt_MapsCancelErrorFromReconnectRetry(t *testing.T) {
+ mock := newMockAgentServer(t)
+ defer mock.Close()
+ mock.handler = func(msg ws.Message) *ws.Message {
+ if msg.Action == "agent.prompt" {
+ resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "prompt abandoned after cancel", nil)
+ return resp
+ }
+ return mock.defaultHandler(msg)
+ }
+
+ log := newSessionTestLogger()
+ stopCh := newTestStopCh(t)
+ sm := NewSessionManager(log, stopCh)
+ streamMgr := NewStreamManager(log, StreamCallbacks{
+ OnAgentEvent: func(execution *AgentExecution, event agentctl.AgentEvent) {},
+ }, nil, stopCh)
+ cleanupStreamManager(t, stopCh, streamMgr)
+ sm.SetDependencies(nil, streamMgr, nil, nil)
+
+ client := createTestClient(t, mock.server.URL)
+ defer client.Close()
+ execution := &AgentExecution{
+ ID: "test-exec",
+ TaskID: "test-task",
+ SessionID: "test-session",
+ agentctl: client,
+ promptDoneCh: make(chan PromptCompletionSignal, 1),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ _, err := sm.SendPrompt(ctx, execution, "hello after cancel", false, nil, true)
+ if !errors.Is(err, ErrCancelEscalated) {
+ t.Fatalf("retry-side cancel release must map to ErrCancelEscalated, got: %v", err)
+ }
+}
+
// TestSendPrompt_DispatchOnlyReturnsWithoutWaiting verifies that dispatch-only
// mode returns immediately after agentctl.Prompt succeeds, without blocking on
// the agent's complete event. This is what message_task_kandev relies on so the
diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go
index 1517727c17..f0f1dcb714 100644
--- a/apps/backend/internal/orchestrator/event_handlers_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_test.go
@@ -381,6 +381,13 @@ func (m *mockAgentManager) PromptAgent(ctx context.Context, executionID string,
}
return &executor.PromptResult{}, nil
}
+
+func (m *mockAgentManager) PromptAgentWithDispatchCallback(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) {
+ if onDispatched != nil {
+ onDispatched()
+ }
+ return m.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly)
+}
func (m *mockAgentManager) CancelAgent(_ context.Context, _ string) error {
m.cancelAgentCalls.Add(1)
if m.cancelAgentEntered != nil {
diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go
index 84112a7a07..1eea4b727e 100644
--- a/apps/backend/internal/orchestrator/executor/executor_interaction.go
+++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go
@@ -233,6 +233,8 @@ func (e *Executor) StopByTaskID(ctx context.Context, taskID string, reason strin
// — see promptPassthrough below.
const stopReasonPassthrough = "passthrough_dispatched"
+var ErrPromptDispatchCallbackUnsupported = errors.New("agent manager does not support prompt dispatch callback")
+
// Prompt sends a follow-up prompt to a running agent for a task
// Returns PromptResult indicating if the agent needs input
// Attachments (images) are passed to the agent if provided
@@ -292,13 +294,14 @@ func (e *Executor) prompt(ctx context.Context, taskID, sessionID string, prompt
}
var result *PromptResult
- if notifier, ok := e.agentManager.(promptAgentWithDispatchCallback); ok {
+ if onDispatched != nil {
+ notifier, ok := e.agentManager.(promptAgentWithDispatchCallback)
+ if !ok {
+ return nil, ErrPromptDispatchCallbackUnsupported
+ }
result, err = notifier.PromptAgentWithDispatchCallback(ctx, executionID, prompt, attachments, dispatchOnly, onDispatched)
} else {
result, err = e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly)
- if err == nil && onDispatched != nil {
- onDispatched()
- }
}
if err != nil {
if errors.Is(err, lifecycle.ErrExecutionNotFound) {
diff --git a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go
index b4fc971aa5..41b87b5015 100644
--- a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go
+++ b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go
@@ -522,3 +522,27 @@ func TestExecutor_Prompt_ACPPathUnchanged(t *testing.T) {
t.Errorf("MarkPassthroughRunning must not be called in ACP mode; got %d", got)
}
}
+
+func TestExecutor_PromptWithDispatchCallback_RequiresCapableManager(t *testing.T) {
+ repo := newMockRepository()
+ agentManager := &mockAgentManager{
+ isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return false },
+ }
+ seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
+ exec := newTestExecutor(t, agentManager, repo)
+
+ called := false
+ _, err := exec.PromptWithDispatchCallback(
+ context.Background(), "task-1", "sess-1", "hello", nil, false,
+ func() { called = true },
+ )
+ if !errors.Is(err, ErrPromptDispatchCallbackUnsupported) {
+ t.Fatalf("expected explicit dispatch callback capability error, got: %v", err)
+ }
+ if called {
+ t.Fatal("callback must not run after a fallback PromptAgent completion")
+ }
+ if agentManager.promptAgentCallCount != 0 {
+ t.Fatalf("PromptAgent fallback must not run, got %d calls", agentManager.promptAgentCallCount)
+ }
+}
From 6a88c5c0b8c351c54396f2ba2a0e0973c5f65717 Mon Sep 17 00:00:00 2001
From: Carlos Florencio
Date: Mon, 13 Jul 2026 23:42:13 +0100
Subject: [PATCH 09/80] test(orchestrator): align prompt dispatch callback mock
---
apps/backend/internal/orchestrator/event_handlers_test.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go
index f0f1dcb714..42d85662ee 100644
--- a/apps/backend/internal/orchestrator/event_handlers_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_test.go
@@ -383,10 +383,11 @@ func (m *mockAgentManager) PromptAgent(ctx context.Context, executionID string,
}
func (m *mockAgentManager) PromptAgentWithDispatchCallback(ctx context.Context, executionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool, onDispatched func()) (*executor.PromptResult, error) {
- if onDispatched != nil {
+ result, err := m.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly)
+ if err == nil && onDispatched != nil {
onDispatched()
}
- return m.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly)
+ return result, err
}
func (m *mockAgentManager) CancelAgent(_ context.Context, _ string) error {
m.cancelAgentCalls.Add(1)
From 77c23e745f7af2add1c3095b054030e32edb7654 Mon Sep 17 00:00:00 2001
From: Carlos Florencio
Date: Tue, 14 Jul 2026 13:41:14 +0100
Subject: [PATCH 10/80] fix(orchestrator): reconcile busy signal with prompt
generations
---
apps/backend/cmd/mock-agent/handler.go | 2 +-
.../agent/runtime/lifecycle/session_test.go | 1 +
.../transport/acp/monitor_contract_test.go | 4 +-
.../agentctl/types/streams/monitor_view.go | 4 +-
.../types/streams/monitor_view_test.go | 2 +-
apps/backend/internal/backendapp/adapters.go | 2 +-
.../backend/internal/backendapp/boot_state.go | 2 +-
apps/backend/internal/events/types.go | 2 +-
.../orchestrator/event_handlers_streaming.go | 2 +-
.../foreground_busy_signal_test.go | 4 +-
.../orchestrator/foreground_claim_test.go | 43 +++++-
.../prompt_background_acceptance_test.go | 4 +-
.../internal/orchestrator/task_operations.go | 126 +++++++++---------
.../internal/orchestrator/turn_activity.go | 6 +-
apps/backend/internal/task/dto/dto.go | 6 +-
.../task/handlers/message_handlers.go | 4 +-
.../task/handlers/message_handlers_test.go | 4 +-
.../internal/task/handlers/task_handlers.go | 2 +-
apps/backend/pkg/api/v1/task.go | 2 +-
apps/web/e2e/tests/chat/busy-signal.spec.ts | 2 +-
.../e2e/tests/chat/mobile-busy-signal.spec.ts | 4 +-
.../domains/session/use-session-state.test.ts | 2 +-
.../domains/session/use-session-state.ts | 2 +-
.../session/session-slice.upsert.test.ts | 2 +-
apps/web/lib/types/backend.ts | 4 +-
apps/web/lib/types/http.ts | 4 +-
apps/web/lib/ui/state-icons.test.tsx | 2 +-
apps/web/lib/ui/state-icons.tsx | 4 +-
apps/web/lib/ws/handlers/agent-session.ts | 4 +-
29 files changed, 148 insertions(+), 104 deletions(-)
diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go
index a1aaa48a00..3e4545c169 100644
--- a/apps/backend/cmd/mock-agent/handler.go
+++ b/apps/backend/cmd/mock-agent/handler.go
@@ -294,7 +294,7 @@ func emitSleep(e *emitter, cmd string) {
}
// emitBackgroundWork reproduces the fine-grained busy signal window
-// (ADR-0035): the foreground emits a line, spawns a
+// (ADR-0036): the foreground emits a line, spawns a
// top-level subagent Task that holds the turn open, then goes IDLE for the
// requested duration (default 8s) with no foreground output — the exact state
// where the orchestrator narrows the busy gate so the composer accepts input
diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go
index a7eb338bd0..c942d1d4bf 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go
@@ -1366,6 +1366,7 @@ func TestSendPrompt_AdvancesGenerationForEveryDispatch(t *testing.T) {
if !store.OwnsPromptGeneration(execution.SessionID, execution.ID, 1) {
t.Fatal("initial prompt must own generation 1 even when execution starts running")
}
+ execution.promptDoneCh <- PromptCompletionSignal{StopReason: "initial-complete"}
if _, err := sm.SendPrompt(ctx, execution, "replacement", true, nil, true); err != nil {
t.Fatalf("dispatch replacement prompt: %v", err)
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
index 8d864fa86c..8324716b5f 100644
--- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
@@ -10,7 +10,7 @@ import (
// `Generic.Output = {"monitor": {...}}`, and streams.IsActiveMonitor (the
// consumer, via the orchestrator's background-work classifier) reads it back to
// decide whether a RUNNING session's foreground turn has yielded to background
-// work (ADR-0035).
+// work (ADR-0036).
//
// The two sides used to spell the map keys out independently, so a rename on
// either side compiled cleanly and silently reverted Monitor sessions to the
@@ -84,7 +84,7 @@ func TestMonitorViewContract_SurvivesSerializationToOrchestrator(t *testing.T) {
// out-of-band attestation — which it stamps solely on the path gated by ACP
// `_meta.claudeCode.toolName`, metadata the model cannot reach — counts.
//
-// This is what keeps ADR-0035's contract honest: an agent we don't recognize can't
+// This is what keeps ADR-0036's contract honest: an agent we don't recognize can't
// relax its own busy gate by shaping its tool output.
func TestMonitorViewContract_ArbitraryToolOutputIsNotMistakenForAMonitor(t *testing.T) {
// Exactly the map monitorOutputWrapper produces — but written by "the agent"
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view.go b/apps/backend/internal/agentctl/types/streams/monitor_view.go
index d4f2c301bf..8dd0355e39 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view.go
@@ -80,11 +80,11 @@ func (p *NormalizedPayload) SetMonitorIdentity(taskID string, ended bool) {
// `kind:"other"`, so it normalizes to a Generic payload rather than a dedicated
// kind (see acp/monitor.go). A Monitor is long-running background work the
// foreground turn is not actively generating against, so an active one is treated
-// like any other spawned background task by the busy signal (ADR-0035).
+// like any other spawned background task by the busy signal (ADR-0036).
//
// It classifies on the adapter's attestation (MonitorPayload), never on the shape
// of Generic.Output — that field is the agent's own raw tool result and so can
-// neither prove nor disprove provenance. This is what keeps the ADR-0035 contract
+// neither prove nor disprove provenance. This is what keeps the ADR-0036 contract
// honest: an agent we don't recognize cannot relax its own busy gate by emitting a
// monitor-shaped tool result, and keeps the historical reject-while-RUNNING
// behavior. (The payload's `Name` is likewise unusable as a discriminator: it
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
index 7c61576405..f39684c412 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
@@ -51,7 +51,7 @@ func TestIsActiveMonitor(t *testing.T) {
// *byte-for-byte perfect* Monitor view there. Without the adapter's attestation it
// must still not be classified as background work — otherwise an agent could relax
// its own busy gate and slip a second prompt into a live foreground turn, breaking
-// ADR-0035's "unrecognized agents keep reject-while-RUNNING" contract.
+// ADR-0036's "unrecognized agents keep reject-while-RUNNING" contract.
func TestIsActiveMonitor_ForgedOutputWithoutAdapterAttestationIsRejected(t *testing.T) {
forged := NewGeneric("other", map[string]any{})
forged.Generic().Output = monitorViewOutput(false) // identical to the real thing
diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go
index 88d1a639e9..fc35c28aaf 100644
--- a/apps/backend/internal/backendapp/adapters.go
+++ b/apps/backend/internal/backendapp/adapters.go
@@ -659,7 +659,7 @@ func (w *orchestratorWrapper) StepRequiresCompletionSignal(ctx context.Context,
return w.svc.StepRequiresCompletionSignal(ctx, taskID)
}
-// ForegroundActivity forwards to the orchestrator service (ADR-0035).
+// ForegroundActivity forwards to the orchestrator service (ADR-0036).
func (w *orchestratorWrapper) ForegroundActivity(sessionID string) v1.ForegroundActivity {
return w.svc.ForegroundActivity(sessionID)
}
diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go
index a2017e2ba0..7a20289e12 100644
--- a/apps/backend/internal/backendapp/boot_state.go
+++ b/apps/backend/internal/backendapp/boot_state.go
@@ -978,7 +978,7 @@ func (b bootStateBuilder) addTaskDetailSessionsState(
// Mirror the in-memory fine-grained busy substate onto a RUNNING session
// so a fresh page-load / second tab sees the accept-input +
// working-in-background affordance without waiting for a WS flip
- // (ADR-0035). No-op for non-RUNNING sessions.
+ // (ADR-0036). No-op for non-RUNNING sessions.
if b.p.orchestratorSvc != nil {
taskdto.EnrichForegroundActivity(&dto, b.p.orchestratorSvc)
}
diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go
index 5ce51a628e..7b9ee58203 100644
--- a/apps/backend/internal/events/types.go
+++ b/apps/backend/internal/events/types.go
@@ -61,7 +61,7 @@ const (
// TaskSessionActivityChanged fires when a RUNNING session's foreground
// turn flips between actively generating and idle-on-background-work,
// without any change to the coarse session state. It carries the
- // fine-grained busy signal (ADR-0035) so the
+ // fine-grained busy signal (ADR-0036) so the
// operator-facing composer and status indicator can distinguish
// "generating" from "waiting on spawned background work".
TaskSessionActivityChanged = "task_session.activity_changed"
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go
index 5e1e3dba67..5d66365f6d 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go
@@ -893,7 +893,7 @@ func (s *Service) publishTaskSessionStateChanged(
"is_passthrough": session.IsPassthrough,
// Carry the fine-grained busy substate on every coarse transition so
// the client resets any stale "background" value when a new turn starts
- // or the turn ends (ADR-0035). Intra-RUNNING flips
+ // or the turn ends (ADR-0036). Intra-RUNNING flips
// ride the dedicated task_session.activity_changed event instead.
"foreground_activity": string(s.foregroundActivityValue(sessionID)),
}
diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
index 3fc6accd84..0fcdbd3a53 100644
--- a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
+++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
@@ -12,7 +12,7 @@ import (
)
// TestCheckSessionPromptable_BackgroundTaskAcceptsInput is the red
-// characterization test for ADR-0035: a session whose
+// characterization test for ADR-0036: a session whose
// foreground turn is idle while a spawned background task is still running must
// accept a new message, not be rejected as "already running".
//
@@ -76,7 +76,7 @@ func TestForegroundToolCallClosesBackgroundIdleGate(t *testing.T) {
// TestTurnActivity_ForegroundBackgroundTransitions locks in the state machine
// behind isForegroundTurnGenerating.
// TestForegroundActivity_ExportedValue covers the seam the page-load / list
-// serialization layer depends on (ADR-0035): the exported
+// serialization layer depends on (ADR-0036): the exported
// ForegroundActivity mirror of the in-memory tracker. An untracked session — which
// includes every session after a backend restart, since a restart ends the turn —
// must report the safe "generating" default so a stale "you may type" can never be
diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go
index c95b81bc32..904eae0f1e 100644
--- a/apps/backend/internal/orchestrator/foreground_claim_test.go
+++ b/apps/backend/internal/orchestrator/foreground_claim_test.go
@@ -13,7 +13,7 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
-// ADR-0035 narrowed the busy gate so a RUNNING session whose foreground turn has
+// ADR-0036 narrowed the busy gate so a RUNNING session whose foreground turn has
// yielded to background work accepts a new prompt. checkSessionPromptable only
// *reads* that substate, though, and PromptTask does real work between the read
// and the point the turn is marked generating (session reload, ensureSessionRunning,
@@ -162,6 +162,45 @@ func TestPromptTask_ConcurrentPromptsIntoBackgroundIdleStartOneTurn(t *testing.T
}
}
+// A queued dispatch can lose its ownership token after the initial promptability
+// check but before it claims the session RUNNING. If that happens after the
+// background-idle foreground was claimed, the failed dispatch must reopen the
+// foreground gate so a later prompt is not locked out.
+func TestPromptTask_SupersededQueuedDispatchReleasesForegroundClaim(t *testing.T) {
+ repo := setupTestRepo(t)
+ agentMgr := &mockAgentManager{isAgentRunning: true, repoForExecutionLookup: repo}
+ svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
+ svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
+
+ const (
+ taskID = "task1"
+ sessionID = "session-superseded-queued-dispatch"
+ )
+ seedTaskAndSession(t, repo, taskID, sessionID, models.TaskSessionStateRunning)
+ seedExecutorRunning(t, repo, sessionID, taskID, "exec-1")
+ svc.registerBackgroundTask(sessionID, "background-1")
+
+ _, err := svc.promptTask(
+ context.Background(), taskID, sessionID, "queued prompt", "", false, nil, false, "stale-entry",
+ )
+ if !errors.Is(err, errQueuedDispatchSuperseded) {
+ t.Fatalf("a dispatch without the current ownership token must be superseded, got: %v", err)
+ }
+ if got := svc.ForegroundActivity(sessionID); got != v1.ForegroundActivityBackground {
+ t.Fatalf("a superseded dispatch must restore the background-idle gate, got %q", got)
+ }
+ if svc.claimForegroundTurn(sessionID) == nil {
+ t.Fatal("a later prompt must be able to claim the foreground after the superseded dispatch")
+ }
+
+ agentMgr.mu.Lock()
+ forwarded := len(agentMgr.capturedPrompts)
+ agentMgr.mu.Unlock()
+ if forwarded != 0 {
+ t.Fatalf("a superseded queued dispatch must not reach the agent, captured=%d", forwarded)
+ }
+}
+
// The claim has to be durable against the background set moving underneath it.
// A background tool_call landing while a prompt is mid-admission calls
// registerBackgroundTask, which re-sets `yielded` — if promptability were derived
@@ -247,7 +286,7 @@ func TestClaimForegroundTurn_UntrackedSessionCannotBeClaimed(t *testing.T) {
// A prompt that claims the turn but never reaches the agent (ensureSessionRunning
// failed, the model switch failed) has to hand the claim back. Otherwise the
// session sits in RUNNING advertising a generating foreground it does not have,
-// locking the operator out for the rest of the turn — the exact lockout ADR-0035
+// locking the operator out for the rest of the turn — the exact lockout ADR-0036
// exists to remove.
func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
repo := setupTestRepo(t)
diff --git a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
index 6128eda2b3..2e3f27bbf6 100644
--- a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
+++ b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
@@ -12,7 +12,7 @@ import (
)
// TestPromptTask_BackgroundWorkAcceptsInput is the falsifiable acceptance proof
-// for ADR-0035, driven through
+// for ADR-0036, driven through
// the REAL operator entrypoint (PromptTask) rather than checkSessionPromptable
// in isolation — it reproduces the operator-lockout→fixed transition end to end
// for both of Claude's background-work wire shapes.
@@ -145,7 +145,7 @@ func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) {
}
// TestPromptTask_NonClaudeFramesStayBusy is the explicit non-Claude regression
-// assertion (ADR-0035 "byte-for-byte unchanged" default):
+// assertion (ADR-0036 "byte-for-byte unchanged" default):
// a codex/opencode-shaped in-flight tool call is not recognized as background
// work, so a RUNNING session driving one must keep rejecting operator input
// exactly as it did before the fine-grained gate existed.
diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go
index 2615667688..1a6010532e 100644
--- a/apps/backend/internal/orchestrator/task_operations.go
+++ b/apps/backend/internal/orchestrator/task_operations.go
@@ -2643,53 +2643,13 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
// Only allow prompts when the session is ready for input.
// Reject when the agent is still starting, already processing, or in a terminal state.
- session, err := s.repo.GetTaskSession(ctx, sessionID)
+ session, err := s.loadPromptableSession(ctx, taskID, sessionID)
if err != nil {
- return nil, fmt.Errorf("failed to get session: %w", err)
- }
- if err := s.checkSessionPromptable(taskID, sessionID, session.State); err != nil {
- if !errors.Is(err, ErrSessionNotPromptable) || session.State != models.TaskSessionStateStarting {
- return nil, err
- }
- readySession, waitErr := s.waitForStartingSessionPromptable(ctx, taskID, sessionID)
- if waitErr != nil {
- return nil, waitErr
- }
- session = readySession
- }
-
- // The gate above only reads the substate, so a RUNNING session admitted through
- // it was admitted because its foreground turn is idle behind background work.
- // Claim that turn now, atomically: everything between here and executor.Prompt
- // (session reload, ensureSessionRunning, a possibly network-bound model switch)
- // is check-then-act window in which a second prompt could otherwise pass the
- // same read and start an overlapping turn on one ACP session. Losing the claim
- // means another prompt got there first — reject exactly as before ADR-0036.
- var foregroundClaim *foregroundClaim
- if session.State == models.TaskSessionStateRunning {
- foregroundClaim = s.claimForegroundTurn(sessionID)
- if foregroundClaim == nil {
- s.logger.Warn("rejected prompt: another prompt claimed the background-idle foreground turn first",
- zap.String("task_id", taskID),
- zap.String("session_id", sessionID))
- return nil, fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress)
- }
+ return nil, err
}
- // Hand the claim back if this prompt fails before it reaches the agent —
- // otherwise the session would advertise a generating foreground it doesn't have
- // and lock the operator out for the rest of the turn. Once executor.Prompt is
- // under way the normal turn-close paths (handlePromptError → completeTurnForSession)
- // own the reset.
- //
- // Broadcast the restored substate: a client that loaded the page *during* the
- // admission window read foreground_activity=generating off the DTO, and with no
- // event it would sit there with a disabled composer even though the turn is
- // background-idle again. releaseForegroundClaim only reports true when it really
- // did reopen the gate, so this can't publish a lie.
- releaseForegroundClaimOnFailure := func() {
- if s.releaseForegroundClaim(foregroundClaim) {
- s.publishForegroundActivityChanged(ctx, taskID, sessionID)
- }
+ foregroundClaim, err := s.claimForegroundForPrompt(taskID, sessionID, session)
+ if err != nil {
+ return nil, err
}
// Apply config-mode and plan-mode prompt transforms.
@@ -2700,7 +2660,7 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
_, hadExecutionBeforeEnsure := s.executor.GetExecutionBySession(sessionID)
resumedForPrompt := !hadExecutionBeforeEnsure
if err := s.ensureSessionRunning(ctx, sessionID, session); err != nil {
- releaseForegroundClaimOnFailure()
+ s.releaseForegroundClaimOnFailure(ctx, taskID, sessionID, foregroundClaim)
return nil, fmt.Errorf("failed to ensure session is running: %w", err)
}
@@ -2717,21 +2677,10 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
effectivePrompt = s.effectivePromptForSession(sessionID, prompt, planMode, session)
}
- // Check if model switching is requested. A switch that fails never reaches the
- // agent, so it releases the claim; a switch that succeeds delivered the prompt
- // itself (trySwitchModel opens the turn), which spends the claim.
- if result, switched, err := s.trySwitchModel(ctx, taskID, sessionID, model, effectivePrompt, session); switched || err != nil {
- if err != nil {
- releaseForegroundClaimOnFailure()
- } else if foregroundClaim != nil {
- // This return skips the publish below, so a prompt admitted through the
- // background-idle gate and then delivered by a model switch would leave live
- // clients showing "background" with an enabled composer while the server has
- // already flipped to generating and would reject their next send.
- s.completeForegroundClaim(foregroundClaim)
- s.publishForegroundActivityChanged(ctx, taskID, sessionID)
- }
- return result, err
+ if result, handled, switchErr := s.trySwitchModelForPrompt(
+ ctx, taskID, sessionID, model, effectivePrompt, session, foregroundClaim,
+ ); handled {
+ return result, switchErr
}
previousSessionState := session.State
@@ -2743,6 +2692,7 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
claimedSession, err := s.claimSessionRunningForPrompt(ctx, taskID, sessionID, claimEntryID, session)
if err != nil {
+ s.releaseForegroundClaimOnFailure(ctx, taskID, sessionID, foregroundClaim)
return nil, err
}
session = claimedSession
@@ -2783,6 +2733,60 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom
}, nil
}
+func (s *Service) loadPromptableSession(ctx context.Context, taskID, sessionID string) (*models.TaskSession, error) {
+ session, err := s.repo.GetTaskSession(ctx, sessionID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get session: %w", err)
+ }
+ if promptErr := s.checkSessionPromptable(taskID, sessionID, session.State); promptErr != nil {
+ if !errors.Is(promptErr, ErrSessionNotPromptable) || session.State != models.TaskSessionStateStarting {
+ return nil, promptErr
+ }
+ return s.waitForStartingSessionPromptable(ctx, taskID, sessionID)
+ }
+ return session, nil
+}
+
+// claimForegroundForPrompt closes the gap between reading a RUNNING session's
+// background-idle substate and dispatching its next prompt.
+func (s *Service) claimForegroundForPrompt(taskID, sessionID string, session *models.TaskSession) (*foregroundClaim, error) {
+ if session.State != models.TaskSessionStateRunning {
+ return nil, nil
+ }
+ claim := s.claimForegroundTurn(sessionID)
+ if claim != nil {
+ return claim, nil
+ }
+ s.logger.Warn("rejected prompt: another prompt claimed the background-idle foreground turn first",
+ zap.String("task_id", taskID),
+ zap.String("session_id", sessionID))
+ return nil, fmt.Errorf("%w, please wait for completion", ErrAgentPromptInProgress)
+}
+
+func (s *Service) releaseForegroundClaimOnFailure(ctx context.Context, taskID, sessionID string, claim *foregroundClaim) {
+ if s.releaseForegroundClaim(claim) {
+ s.publishForegroundActivityChanged(ctx, taskID, sessionID)
+ }
+}
+
+// trySwitchModelForPrompt keeps foreground admission consistent when a model
+// switch either dispatches the prompt itself or fails before reaching the agent.
+func (s *Service) trySwitchModelForPrompt(ctx context.Context, taskID, sessionID, model, prompt string, session *models.TaskSession, claim *foregroundClaim) (*PromptResult, bool, error) {
+ result, switched, err := s.trySwitchModel(ctx, taskID, sessionID, model, prompt, session)
+ if !switched && err == nil {
+ return nil, false, nil
+ }
+ if err != nil {
+ s.releaseForegroundClaimOnFailure(ctx, taskID, sessionID, claim)
+ return result, true, err
+ }
+ if claim != nil {
+ s.completeForegroundClaim(claim)
+ s.publishForegroundActivityChanged(ctx, taskID, sessionID)
+ }
+ return result, true, nil
+}
+
// handlePromptDispatchFailure handles a failed executor.Prompt call from
// promptTask. If the failure is the specific "missing execution" error a
// just-resumed session can hit (see promptTask's resumedForPrompt comment),
diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go
index 6b798d070f..9f687898d4 100644
--- a/apps/backend/internal/orchestrator/turn_activity.go
+++ b/apps/backend/internal/orchestrator/turn_activity.go
@@ -203,7 +203,7 @@ func (ta *turnActivity) generatingLocked() bool {
// overlapping turns on one ACP session. Claiming closes that window: the first
// prompt in wins, and every prompt behind it sees a generating foreground and is
// rejected with ErrAgentPromptInProgress exactly as it would have been before
-// ADR-0035.
+// ADR-0036.
//
// The claim is held until agentctl accepts the prompt (completeForegroundClaim)
// or it is handed back (releaseForegroundClaim). The returned token binds both
@@ -258,7 +258,7 @@ func (s *Service) completeForegroundClaim(claim *foregroundClaim) bool {
// it was taken for never made it to the agent (ensureSessionRunning failed, the
// model switch failed). Without it the session would sit in RUNNING advertising a
// generating foreground it does not have, locking the operator out for the rest
-// of the turn — the exact lockout ADR-0035 exists to remove.
+// of the turn — the exact lockout ADR-0036 exists to remove.
//
// It reports whether the turn was actually handed back to background-idle, so the
// caller can broadcast the restored substate. Two things stop a release from
@@ -302,7 +302,7 @@ func (s *Service) foregroundActivityValue(sessionID string) v1.ForegroundActivit
// ForegroundActivity exposes the in-memory fine-grained busy substate so the
// page-load / list serialization layer can stamp it onto a RUNNING session's
-// DTO (ADR-0035). This is the same value that drives the
+// DTO (ADR-0036). This is the same value that drives the
// live task_session.activity_changed WS event, read straight from the in-memory
// tracker — the single source of truth. There is no persisted copy: an untracked
// session (including every session after a backend restart, which ends the turn)
diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go
index 4f1a4776d7..610a3db3c1 100644
--- a/apps/backend/internal/task/dto/dto.go
+++ b/apps/backend/internal/task/dto/dto.go
@@ -233,7 +233,7 @@ type TaskSessionDTO struct {
// ForegroundActivity mirrors the in-memory fine-grained busy substate onto a
// RUNNING session so a fresh page-load / second tab sees the accept-input +
// working-in-background affordance without waiting for a WS flip
- // (ADR-0035). Absent for every non-RUNNING session and
+ // (ADR-0036). Absent for every non-RUNNING session and
// whenever no provider is wired; a client treats absent as "generating".
// Not persisted — populated at the serialization boundary by
// EnrichForegroundActivity, never by FromTaskSession.
@@ -275,7 +275,7 @@ type TaskSessionSummaryDTO struct {
ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
TaskEnvironmentID string `json:"task_environment_id,omitempty"`
// ForegroundActivity mirrors the in-memory fine-grained busy substate onto a
- // RUNNING session (ADR-0035); see TaskSessionDTO. Absent
+ // RUNNING session (ADR-0036); see TaskSessionDTO. Absent
// for non-RUNNING sessions; populated by EnrichForegroundActivitySummary.
ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"`
// CommandCount is the number of tool_call messages on this session,
@@ -731,7 +731,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
}
// ForegroundActivityProvider surfaces the in-memory fine-grained busy substate
-// for a session (ADR-0035). It is satisfied by the
+// for a session (ADR-0036). It is satisfied by the
// orchestrator; the serialization layer depends only on this narrow seam so it
// takes no hard orchestrator dependency and can be faked in tests.
type ForegroundActivityProvider interface {
diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go
index 2046ce29b7..4d4dab809e 100644
--- a/apps/backend/internal/task/handlers/message_handlers.go
+++ b/apps/backend/internal/task/handlers/message_handlers.go
@@ -33,7 +33,7 @@ type OrchestratorService interface {
ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error
StepRequiresCompletionSignal(ctx context.Context, taskID string) bool
// ForegroundActivity reports the fine-grained busy substate of a RUNNING
- // session (ADR-0035): "background" when the foreground turn has yielded to
+ // session (ADR-0036): "background" when the foreground turn has yielded to
// spawned background work and can accept a new message.
//
// Deliberately a hard dependency here, unlike TaskHandlers — which reaches the
@@ -429,7 +429,7 @@ func (h *MessageHandlers) errorForBlockedMessageSession(msg *ws.Message, session
switch state {
case models.TaskSessionStateRunning:
// A RUNNING session whose foreground turn has yielded to spawned
- // background work (ADR-0035) accepts a new message: it flows on to
+ // background work (ADR-0036) accepts a new message: it flows on to
// PromptTask, which owns the same foreground-idle gate and forwards it.
// Only a foreground-generating turn is blocked here.
if h.orchestrator != nil && h.orchestrator.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground {
diff --git a/apps/backend/internal/task/handlers/message_handlers_test.go b/apps/backend/internal/task/handlers/message_handlers_test.go
index 5d84af4300..b0d03ef03c 100644
--- a/apps/backend/internal/task/handlers/message_handlers_test.go
+++ b/apps/backend/internal/task/handlers/message_handlers_test.go
@@ -873,7 +873,7 @@ func TestWSAddMessageFailsWhenSessionReloadAfterOnTurnStartFails(t *testing.T) {
}
// fgActivityOrchestrator is a minimal OrchestratorService whose ForegroundActivity
-// is configurable, for testing the ADR-0035 message-add gate.
+// is configurable, for testing the ADR-0036 message-add gate.
type fgActivityOrchestrator struct {
activity v1.ForegroundActivity
}
@@ -891,7 +891,7 @@ func (o fgActivityOrchestrator) StepRequiresCompletionSignal(context.Context, st
}
func (o fgActivityOrchestrator) ForegroundActivity(string) v1.ForegroundActivity { return o.activity }
-// TestErrorForBlockedMessageSession_BackgroundIdleAccepts is the ADR-0035
+// TestErrorForBlockedMessageSession_BackgroundIdleAccepts is the ADR-0036
// message-add gate: a RUNNING session whose foreground turn has yielded to
// background work must NOT be blocked at the message.add layer (it flows on to
// PromptTask), while a foreground-generating RUNNING session stays blocked.
diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go
index 230b41bca2..8356cf7932 100644
--- a/apps/backend/internal/task/handlers/task_handlers.go
+++ b/apps/backend/internal/task/handlers/task_handlers.go
@@ -100,7 +100,7 @@ func NewTaskHandlers(svc *service.Service, orchestrator OrchestratorStarter, rep
logger: log.WithFields(zap.String("component", "task-task-handlers")),
}
// The orchestrator also surfaces the in-memory fine-grained busy substate
- // (ADR-0035). Derive the narrow provider from it so the
+ // (ADR-0036). Derive the narrow provider from it so the
// session-fetch handlers can stamp foreground_activity onto RUNNING sessions
// without waiting for a WS flip. Nil (e.g. in tests) simply omits the field.
if fa, ok := orchestrator.(dto.ForegroundActivityProvider); ok {
diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go
index 2f9395dd0c..43a6dc4b62 100644
--- a/apps/backend/pkg/api/v1/task.go
+++ b/apps/backend/pkg/api/v1/task.go
@@ -44,7 +44,7 @@ const (
)
// ForegroundActivity is the fine-grained busy substate of a RUNNING session
-// (ADR-0035). It distinguishes a foreground turn that is
+// (ADR-0036). It distinguishes a foreground turn that is
// actively generating from one that is idle, held open only by spawned
// background work (a subagent task, a run-in-background shell, an active
// Monitor). It is only meaningful while the session state is RUNNING; for every
diff --git a/apps/web/e2e/tests/chat/busy-signal.spec.ts b/apps/web/e2e/tests/chat/busy-signal.spec.ts
index 99e3cae97a..fb51dc7065 100644
--- a/apps/web/e2e/tests/chat/busy-signal.spec.ts
+++ b/apps/web/e2e/tests/chat/busy-signal.spec.ts
@@ -6,7 +6,7 @@ import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
// ---------------------------------------------------------------------------
-// ADR-0035 — operator-visible surfacing.
+// ADR-0036 — operator-visible surfacing.
//
// The mock `/background ` command spawns a top-level subagent Task and then
// holds the turn open with NO foreground output, so the session sits in the
diff --git a/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
index 0b189a8819..2f7fa087f3 100644
--- a/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
@@ -1,7 +1,7 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
-// Mobile (Pixel 5) coverage for ADR-0035. Filename matches
+// Mobile (Pixel 5) coverage for ADR-0036. Filename matches
// /mobile-.*\.spec\.ts/ so the `mobile-chrome` project picks it up. The composer
// gating and the working affordance derive from the same shared hooks the
// desktop path uses, so this asserts the operator-visible outcome holds at
@@ -84,7 +84,7 @@ test.describe("Mobile fine-grained busy signal", () => {
// Reload mid-window: a fresh mobile client. The accept-input + working
// affordance must come straight from the boot payload (no persisted value,
- // no activity_changed WS flip due) — ADR-0035.
+ // no activity_changed WS flip due) — ADR-0036.
await testPage.reload();
await session.waitForLoad();
diff --git a/apps/web/hooks/domains/session/use-session-state.test.ts b/apps/web/hooks/domains/session/use-session-state.test.ts
index 904db5739e..89abd5be11 100644
--- a/apps/web/hooks/domains/session/use-session-state.test.ts
+++ b/apps/web/hooks/domains/session/use-session-state.test.ts
@@ -197,7 +197,7 @@ describe("useSessionState — fine-grained busy signal (foreground_activity)", (
mockPrepareProgress = {};
});
- // ADR-0035. (a) generating gates the composer;
+ // ADR-0036. (a) generating gates the composer;
// (b) background-idle accepts input but stays working; (c) fully idle.
it("(a) RUNNING+generating gates the composer", () => {
mockSession = {
diff --git a/apps/web/hooks/domains/session/use-session-state.ts b/apps/web/hooks/domains/session/use-session-state.ts
index 416f5a7acd..b10a427efc 100644
--- a/apps/web/hooks/domains/session/use-session-state.ts
+++ b/apps/web/hooks/domains/session/use-session-state.ts
@@ -8,7 +8,7 @@ export function deriveSessionFlags(session: TaskSession | null | undefined) {
const errorMessage = session?.error_message;
const isStarting = state === "STARTING";
const isRunning = state === "RUNNING";
- // ADR-0035. Three conditions, not two:
+ // ADR-0036. Three conditions, not two:
// (a) generating — RUNNING, foreground actively producing output
// (b) background-idle — RUNNING, foreground yielded to spawned background work
// (c) fully idle — not RUNNING
diff --git a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
index 863d02ebf5..58ec334af5 100644
--- a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
+++ b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
@@ -140,7 +140,7 @@ describe("setTaskSessionsForTask preserves WS-seeded fields", () => {
});
});
-// ADR-0035 — a fresh page-load / second tab receives the
+// ADR-0036 — a fresh page-load / second tab receives the
// fine-grained busy substate on the boot payload (and now on the REST/WS session
// endpoints). Hydration and any subsequent list refresh must not drop it, or the
// coarse busy affordance would persist until the next WS flip — the exact gap
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index dbfeca43b0..492a9ba18f 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -248,7 +248,7 @@ export type TaskSessionStateChangedPayload = {
review_status?: string;
// Task environment (for session→environment mapping)
task_environment_id?: string;
- // Fine-grained busy substate (see ADR-0035), carried on every transition so
+ // Fine-grained busy substate (see ADR-0036), carried on every transition so
// the client resets stale values; intra-RUNNING flips arrive on
// session.activity_changed instead.
foreground_activity?: ForegroundActivity;
@@ -256,7 +256,7 @@ export type TaskSessionStateChangedPayload = {
/**
* Payload for `session.activity_changed` — the fine-grained busy signal
- * (see ADR-0035). Fires when a RUNNING session's foreground turn flips between
+ * (see ADR-0036). Fires when a RUNNING session's foreground turn flips between
* generating and idle-on-background-work, with no coarse state change.
*/
export type TaskSessionActivityChangedPayload = {
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 1552b0d3ea..ab198c25b5 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -158,7 +158,7 @@ export type TaskSessionState =
export type TaskPendingAction = "clarification" | "permission";
/**
- * Fine-grained busy substate of a RUNNING session (see ADR-0035). Distinguishes
+ * Fine-grained busy substate of a RUNNING session (see ADR-0036). Distinguishes
* a foreground turn that is actively generating from one that is idle, held open
* only by spawned background work (a subagent task, a run-in-background shell, an
* active Monitor). Only meaningful while `state === "RUNNING"`; for every other
@@ -414,7 +414,7 @@ export type TaskSession = {
state: TaskSessionState;
/**
* Fine-grained busy substate while `state === "RUNNING"`
- * (ADR-0035). Pushed over `session.activity_changed`;
+ * (ADR-0036). Pushed over `session.activity_changed`;
* absent/`null` means the foreground turn is generating (the safe default).
*/
foreground_activity?: ForegroundActivity | null;
diff --git a/apps/web/lib/ui/state-icons.test.tsx b/apps/web/lib/ui/state-icons.test.tsx
index 94d71d74c8..34da41543b 100644
--- a/apps/web/lib/ui/state-icons.test.tsx
+++ b/apps/web/lib/ui/state-icons.test.tsx
@@ -34,7 +34,7 @@ describe("getTaskStateIcon", () => {
});
describe("getSessionStateIcon — fine-grained busy tri-state", () => {
- // ADR-0035. Three distinguishable conditions:
+ // ADR-0036. Three distinguishable conditions:
// (a) RUNNING + generating → the established static "running" dot (unchanged)
// (b) RUNNING + background → working-in-background spinner, NOT the done check
// (c) COMPLETED → done checkmark
diff --git a/apps/web/lib/ui/state-icons.tsx b/apps/web/lib/ui/state-icons.tsx
index 8a19a4117a..9c1190836f 100644
--- a/apps/web/lib/ui/state-icons.tsx
+++ b/apps/web/lib/ui/state-icons.tsx
@@ -56,7 +56,7 @@ const SESSION_STATE_ICONS: Record = {
};
// (b) background-idle: the foreground turn has yielded to spawned background
-// work (ADR-0035). A spinner — the operator can see the
+// work (ADR-0036). A spinner — the operator can see the
// agent is not done — visually separate from the static "generating" dot (a) by
// its motion, and never the done checkmark. The spinner (work in motion) reads
// as "something is still running in the background" while the foreground is
@@ -156,7 +156,7 @@ function getSessionStateIconConfig(
): IconConfig {
// (b) background-idle wins over the default RUNNING (generating) icon: while
// the foreground turn waits on spawned background work the session must read
- // as "working in background", never as done (ADR-0035).
+ // as "working in background", never as done (ADR-0036).
if (state === "RUNNING" && foregroundActivity === "background") {
return SESSION_BACKGROUND_ICON;
}
diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts
index 236f76d6c2..0fc728e547 100644
--- a/apps/web/lib/ws/handlers/agent-session.ts
+++ b/apps/web/lib/ws/handlers/agent-session.ts
@@ -209,7 +209,7 @@ function buildSessionUpdate(payload: any): Record {
if (payload.task_environment_id) update.task_environment_id = payload.task_environment_id;
if (payload.updated_at) update.updated_at = payload.updated_at;
// Reset the fine-grained busy substate on every coarse transition so a stale
- // "background" value can't survive into the next turn (ADR-0035).
+ // "background" value can't survive into the next turn (ADR-0036).
if (payload.foreground_activity !== undefined)
update.foreground_activity = payload.foreground_activity;
return update;
@@ -488,7 +488,7 @@ function maybeNotifySessionFailure(store: StoreApi, ctx: SessionFailur
/** Apply an intra-RUNNING flip of the fine-grained busy substate: the
* foreground turn moved between generating and idle-on-background-work without
- * any coarse state change (ADR-0035). Annotates the
+ * any coarse state change (ADR-0036). Annotates the
* existing session row so the composer gate and status indicator update; does
* nothing until the row exists (state_changed seeds it first). */
function applyForegroundActivity(
From 6756763a4d425938d1304e4d2acbf6db27e62ea3 Mon Sep 17 00:00:00 2001
From: Carlos Florencio
Date: Tue, 14 Jul 2026 23:20:34 +0100
Subject: [PATCH 11/80] docs: renumber busy-signal ADR references
---
apps/backend/cmd/mock-agent/handler.go | 2 +-
.../server/adapter/transport/acp/monitor_contract_test.go | 4 ++--
.../backend/internal/agentctl/types/streams/monitor_view.go | 4 ++--
.../internal/agentctl/types/streams/monitor_view_test.go | 2 +-
apps/backend/internal/backendapp/adapters.go | 2 +-
apps/backend/internal/backendapp/boot_state.go | 2 +-
apps/backend/internal/events/types.go | 2 +-
.../internal/orchestrator/event_handlers_streaming.go | 2 +-
.../internal/orchestrator/foreground_busy_signal_test.go | 4 ++--
apps/backend/internal/orchestrator/foreground_claim_test.go | 4 ++--
.../orchestrator/prompt_background_acceptance_test.go | 4 ++--
apps/backend/internal/orchestrator/turn_activity.go | 6 +++---
apps/backend/internal/task/dto/dto.go | 6 +++---
apps/backend/internal/task/handlers/message_handlers.go | 4 ++--
.../backend/internal/task/handlers/message_handlers_test.go | 4 ++--
apps/backend/internal/task/handlers/task_handlers.go | 2 +-
apps/backend/pkg/api/v1/task.go | 2 +-
apps/web/e2e/tests/chat/busy-signal.spec.ts | 2 +-
apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts | 4 ++--
apps/web/hooks/domains/session/use-session-state.test.ts | 2 +-
apps/web/hooks/domains/session/use-session-state.ts | 2 +-
.../lib/state/slices/session/session-slice.upsert.test.ts | 2 +-
apps/web/lib/types/backend.ts | 4 ++--
apps/web/lib/types/http.ts | 4 ++--
apps/web/lib/ui/state-icons.test.tsx | 2 +-
apps/web/lib/ui/state-icons.tsx | 4 ++--
apps/web/lib/ws/handlers/agent-session.ts | 4 ++--
27 files changed, 43 insertions(+), 43 deletions(-)
diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go
index 3e4545c169..d3ebcfabc1 100644
--- a/apps/backend/cmd/mock-agent/handler.go
+++ b/apps/backend/cmd/mock-agent/handler.go
@@ -294,7 +294,7 @@ func emitSleep(e *emitter, cmd string) {
}
// emitBackgroundWork reproduces the fine-grained busy signal window
-// (ADR-0036): the foreground emits a line, spawns a
+// (ADR-0038): the foreground emits a line, spawns a
// top-level subagent Task that holds the turn open, then goes IDLE for the
// requested duration (default 8s) with no foreground output — the exact state
// where the orchestrator narrows the busy gate so the composer accepts input
diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
index 8324716b5f..107c7e91f6 100644
--- a/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
+++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/monitor_contract_test.go
@@ -10,7 +10,7 @@ import (
// `Generic.Output = {"monitor": {...}}`, and streams.IsActiveMonitor (the
// consumer, via the orchestrator's background-work classifier) reads it back to
// decide whether a RUNNING session's foreground turn has yielded to background
-// work (ADR-0036).
+// work (ADR-0038).
//
// The two sides used to spell the map keys out independently, so a rename on
// either side compiled cleanly and silently reverted Monitor sessions to the
@@ -84,7 +84,7 @@ func TestMonitorViewContract_SurvivesSerializationToOrchestrator(t *testing.T) {
// out-of-band attestation — which it stamps solely on the path gated by ACP
// `_meta.claudeCode.toolName`, metadata the model cannot reach — counts.
//
-// This is what keeps ADR-0036's contract honest: an agent we don't recognize can't
+// This is what keeps ADR-0038's contract honest: an agent we don't recognize can't
// relax its own busy gate by shaping its tool output.
func TestMonitorViewContract_ArbitraryToolOutputIsNotMistakenForAMonitor(t *testing.T) {
// Exactly the map monitorOutputWrapper produces — but written by "the agent"
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view.go b/apps/backend/internal/agentctl/types/streams/monitor_view.go
index 8dd0355e39..9809589b58 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view.go
@@ -80,11 +80,11 @@ func (p *NormalizedPayload) SetMonitorIdentity(taskID string, ended bool) {
// `kind:"other"`, so it normalizes to a Generic payload rather than a dedicated
// kind (see acp/monitor.go). A Monitor is long-running background work the
// foreground turn is not actively generating against, so an active one is treated
-// like any other spawned background task by the busy signal (ADR-0036).
+// like any other spawned background task by the busy signal (ADR-0038).
//
// It classifies on the adapter's attestation (MonitorPayload), never on the shape
// of Generic.Output — that field is the agent's own raw tool result and so can
-// neither prove nor disprove provenance. This is what keeps the ADR-0036 contract
+// neither prove nor disprove provenance. This is what keeps the ADR-0038 contract
// honest: an agent we don't recognize cannot relax its own busy gate by emitting a
// monitor-shaped tool result, and keeps the historical reject-while-RUNNING
// behavior. (The payload's `Name` is likewise unusable as a discriminator: it
diff --git a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
index f39684c412..f3b3e9fad7 100644
--- a/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
+++ b/apps/backend/internal/agentctl/types/streams/monitor_view_test.go
@@ -51,7 +51,7 @@ func TestIsActiveMonitor(t *testing.T) {
// *byte-for-byte perfect* Monitor view there. Without the adapter's attestation it
// must still not be classified as background work — otherwise an agent could relax
// its own busy gate and slip a second prompt into a live foreground turn, breaking
-// ADR-0036's "unrecognized agents keep reject-while-RUNNING" contract.
+// ADR-0038's "unrecognized agents keep reject-while-RUNNING" contract.
func TestIsActiveMonitor_ForgedOutputWithoutAdapterAttestationIsRejected(t *testing.T) {
forged := NewGeneric("other", map[string]any{})
forged.Generic().Output = monitorViewOutput(false) // identical to the real thing
diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go
index fc35c28aaf..be9a022660 100644
--- a/apps/backend/internal/backendapp/adapters.go
+++ b/apps/backend/internal/backendapp/adapters.go
@@ -659,7 +659,7 @@ func (w *orchestratorWrapper) StepRequiresCompletionSignal(ctx context.Context,
return w.svc.StepRequiresCompletionSignal(ctx, taskID)
}
-// ForegroundActivity forwards to the orchestrator service (ADR-0036).
+// ForegroundActivity forwards to the orchestrator service (ADR-0038).
func (w *orchestratorWrapper) ForegroundActivity(sessionID string) v1.ForegroundActivity {
return w.svc.ForegroundActivity(sessionID)
}
diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go
index 7a20289e12..f7122d86b6 100644
--- a/apps/backend/internal/backendapp/boot_state.go
+++ b/apps/backend/internal/backendapp/boot_state.go
@@ -978,7 +978,7 @@ func (b bootStateBuilder) addTaskDetailSessionsState(
// Mirror the in-memory fine-grained busy substate onto a RUNNING session
// so a fresh page-load / second tab sees the accept-input +
// working-in-background affordance without waiting for a WS flip
- // (ADR-0036). No-op for non-RUNNING sessions.
+ // (ADR-0038). No-op for non-RUNNING sessions.
if b.p.orchestratorSvc != nil {
taskdto.EnrichForegroundActivity(&dto, b.p.orchestratorSvc)
}
diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go
index 7b9ee58203..bbcfcf98b4 100644
--- a/apps/backend/internal/events/types.go
+++ b/apps/backend/internal/events/types.go
@@ -61,7 +61,7 @@ const (
// TaskSessionActivityChanged fires when a RUNNING session's foreground
// turn flips between actively generating and idle-on-background-work,
// without any change to the coarse session state. It carries the
- // fine-grained busy signal (ADR-0036) so the
+ // fine-grained busy signal (ADR-0038) so the
// operator-facing composer and status indicator can distinguish
// "generating" from "waiting on spawned background work".
TaskSessionActivityChanged = "task_session.activity_changed"
diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go
index 5d66365f6d..4d7c65d613 100644
--- a/apps/backend/internal/orchestrator/event_handlers_streaming.go
+++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go
@@ -893,7 +893,7 @@ func (s *Service) publishTaskSessionStateChanged(
"is_passthrough": session.IsPassthrough,
// Carry the fine-grained busy substate on every coarse transition so
// the client resets any stale "background" value when a new turn starts
- // or the turn ends (ADR-0036). Intra-RUNNING flips
+ // or the turn ends (ADR-0038). Intra-RUNNING flips
// ride the dedicated task_session.activity_changed event instead.
"foreground_activity": string(s.foregroundActivityValue(sessionID)),
}
diff --git a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
index 0fcdbd3a53..ded7c68fd0 100644
--- a/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
+++ b/apps/backend/internal/orchestrator/foreground_busy_signal_test.go
@@ -12,7 +12,7 @@ import (
)
// TestCheckSessionPromptable_BackgroundTaskAcceptsInput is the red
-// characterization test for ADR-0036: a session whose
+// characterization test for ADR-0038: a session whose
// foreground turn is idle while a spawned background task is still running must
// accept a new message, not be rejected as "already running".
//
@@ -76,7 +76,7 @@ func TestForegroundToolCallClosesBackgroundIdleGate(t *testing.T) {
// TestTurnActivity_ForegroundBackgroundTransitions locks in the state machine
// behind isForegroundTurnGenerating.
// TestForegroundActivity_ExportedValue covers the seam the page-load / list
-// serialization layer depends on (ADR-0036): the exported
+// serialization layer depends on (ADR-0038): the exported
// ForegroundActivity mirror of the in-memory tracker. An untracked session — which
// includes every session after a backend restart, since a restart ends the turn —
// must report the safe "generating" default so a stale "you may type" can never be
diff --git a/apps/backend/internal/orchestrator/foreground_claim_test.go b/apps/backend/internal/orchestrator/foreground_claim_test.go
index 904eae0f1e..261ae90807 100644
--- a/apps/backend/internal/orchestrator/foreground_claim_test.go
+++ b/apps/backend/internal/orchestrator/foreground_claim_test.go
@@ -13,7 +13,7 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
-// ADR-0036 narrowed the busy gate so a RUNNING session whose foreground turn has
+// ADR-0038 narrowed the busy gate so a RUNNING session whose foreground turn has
// yielded to background work accepts a new prompt. checkSessionPromptable only
// *reads* that substate, though, and PromptTask does real work between the read
// and the point the turn is marked generating (session reload, ensureSessionRunning,
@@ -286,7 +286,7 @@ func TestClaimForegroundTurn_UntrackedSessionCannotBeClaimed(t *testing.T) {
// A prompt that claims the turn but never reaches the agent (ensureSessionRunning
// failed, the model switch failed) has to hand the claim back. Otherwise the
// session sits in RUNNING advertising a generating foreground it does not have,
-// locking the operator out for the rest of the turn — the exact lockout ADR-0036
+// locking the operator out for the rest of the turn — the exact lockout ADR-0038
// exists to remove.
func TestReleaseForegroundClaim_FailedPromptReopensTheGate(t *testing.T) {
repo := setupTestRepo(t)
diff --git a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
index 2e3f27bbf6..e6f932b02e 100644
--- a/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
+++ b/apps/backend/internal/orchestrator/prompt_background_acceptance_test.go
@@ -12,7 +12,7 @@ import (
)
// TestPromptTask_BackgroundWorkAcceptsInput is the falsifiable acceptance proof
-// for ADR-0036, driven through
+// for ADR-0038, driven through
// the REAL operator entrypoint (PromptTask) rather than checkSessionPromptable
// in isolation — it reproduces the operator-lockout→fixed transition end to end
// for both of Claude's background-work wire shapes.
@@ -145,7 +145,7 @@ func TestPromptTask_BackgroundWorkAcceptsInput(t *testing.T) {
}
// TestPromptTask_NonClaudeFramesStayBusy is the explicit non-Claude regression
-// assertion (ADR-0036 "byte-for-byte unchanged" default):
+// assertion (ADR-0038 "byte-for-byte unchanged" default):
// a codex/opencode-shaped in-flight tool call is not recognized as background
// work, so a RUNNING session driving one must keep rejecting operator input
// exactly as it did before the fine-grained gate existed.
diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go
index 9f687898d4..4244dc2730 100644
--- a/apps/backend/internal/orchestrator/turn_activity.go
+++ b/apps/backend/internal/orchestrator/turn_activity.go
@@ -203,7 +203,7 @@ func (ta *turnActivity) generatingLocked() bool {
// overlapping turns on one ACP session. Claiming closes that window: the first
// prompt in wins, and every prompt behind it sees a generating foreground and is
// rejected with ErrAgentPromptInProgress exactly as it would have been before
-// ADR-0036.
+// ADR-0038.
//
// The claim is held until agentctl accepts the prompt (completeForegroundClaim)
// or it is handed back (releaseForegroundClaim). The returned token binds both
@@ -258,7 +258,7 @@ func (s *Service) completeForegroundClaim(claim *foregroundClaim) bool {
// it was taken for never made it to the agent (ensureSessionRunning failed, the
// model switch failed). Without it the session would sit in RUNNING advertising a
// generating foreground it does not have, locking the operator out for the rest
-// of the turn — the exact lockout ADR-0036 exists to remove.
+// of the turn — the exact lockout ADR-0038 exists to remove.
//
// It reports whether the turn was actually handed back to background-idle, so the
// caller can broadcast the restored substate. Two things stop a release from
@@ -302,7 +302,7 @@ func (s *Service) foregroundActivityValue(sessionID string) v1.ForegroundActivit
// ForegroundActivity exposes the in-memory fine-grained busy substate so the
// page-load / list serialization layer can stamp it onto a RUNNING session's
-// DTO (ADR-0036). This is the same value that drives the
+// DTO (ADR-0038). This is the same value that drives the
// live task_session.activity_changed WS event, read straight from the in-memory
// tracker — the single source of truth. There is no persisted copy: an untracked
// session (including every session after a backend restart, which ends the turn)
diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go
index 610a3db3c1..3704a38623 100644
--- a/apps/backend/internal/task/dto/dto.go
+++ b/apps/backend/internal/task/dto/dto.go
@@ -233,7 +233,7 @@ type TaskSessionDTO struct {
// ForegroundActivity mirrors the in-memory fine-grained busy substate onto a
// RUNNING session so a fresh page-load / second tab sees the accept-input +
// working-in-background affordance without waiting for a WS flip
- // (ADR-0036). Absent for every non-RUNNING session and
+ // (ADR-0038). Absent for every non-RUNNING session and
// whenever no provider is wired; a client treats absent as "generating".
// Not persisted — populated at the serialization boundary by
// EnrichForegroundActivity, never by FromTaskSession.
@@ -275,7 +275,7 @@ type TaskSessionSummaryDTO struct {
ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
TaskEnvironmentID string `json:"task_environment_id,omitempty"`
// ForegroundActivity mirrors the in-memory fine-grained busy substate onto a
- // RUNNING session (ADR-0036); see TaskSessionDTO. Absent
+ // RUNNING session (ADR-0038); see TaskSessionDTO. Absent
// for non-RUNNING sessions; populated by EnrichForegroundActivitySummary.
ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"`
// CommandCount is the number of tool_call messages on this session,
@@ -731,7 +731,7 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
}
// ForegroundActivityProvider surfaces the in-memory fine-grained busy substate
-// for a session (ADR-0036). It is satisfied by the
+// for a session (ADR-0038). It is satisfied by the
// orchestrator; the serialization layer depends only on this narrow seam so it
// takes no hard orchestrator dependency and can be faked in tests.
type ForegroundActivityProvider interface {
diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go
index 4d4dab809e..3ca336c496 100644
--- a/apps/backend/internal/task/handlers/message_handlers.go
+++ b/apps/backend/internal/task/handlers/message_handlers.go
@@ -33,7 +33,7 @@ type OrchestratorService interface {
ProcessOnTurnStart(ctx context.Context, taskID, sessionID string) error
StepRequiresCompletionSignal(ctx context.Context, taskID string) bool
// ForegroundActivity reports the fine-grained busy substate of a RUNNING
- // session (ADR-0036): "background" when the foreground turn has yielded to
+ // session (ADR-0038): "background" when the foreground turn has yielded to
// spawned background work and can accept a new message.
//
// Deliberately a hard dependency here, unlike TaskHandlers — which reaches the
@@ -429,7 +429,7 @@ func (h *MessageHandlers) errorForBlockedMessageSession(msg *ws.Message, session
switch state {
case models.TaskSessionStateRunning:
// A RUNNING session whose foreground turn has yielded to spawned
- // background work (ADR-0036) accepts a new message: it flows on to
+ // background work (ADR-0038) accepts a new message: it flows on to
// PromptTask, which owns the same foreground-idle gate and forwards it.
// Only a foreground-generating turn is blocked here.
if h.orchestrator != nil && h.orchestrator.ForegroundActivity(sessionID) == v1.ForegroundActivityBackground {
diff --git a/apps/backend/internal/task/handlers/message_handlers_test.go b/apps/backend/internal/task/handlers/message_handlers_test.go
index b0d03ef03c..758a1e5d41 100644
--- a/apps/backend/internal/task/handlers/message_handlers_test.go
+++ b/apps/backend/internal/task/handlers/message_handlers_test.go
@@ -873,7 +873,7 @@ func TestWSAddMessageFailsWhenSessionReloadAfterOnTurnStartFails(t *testing.T) {
}
// fgActivityOrchestrator is a minimal OrchestratorService whose ForegroundActivity
-// is configurable, for testing the ADR-0036 message-add gate.
+// is configurable, for testing the ADR-0038 message-add gate.
type fgActivityOrchestrator struct {
activity v1.ForegroundActivity
}
@@ -891,7 +891,7 @@ func (o fgActivityOrchestrator) StepRequiresCompletionSignal(context.Context, st
}
func (o fgActivityOrchestrator) ForegroundActivity(string) v1.ForegroundActivity { return o.activity }
-// TestErrorForBlockedMessageSession_BackgroundIdleAccepts is the ADR-0036
+// TestErrorForBlockedMessageSession_BackgroundIdleAccepts is the ADR-0038
// message-add gate: a RUNNING session whose foreground turn has yielded to
// background work must NOT be blocked at the message.add layer (it flows on to
// PromptTask), while a foreground-generating RUNNING session stays blocked.
diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go
index 8356cf7932..9ac29d43de 100644
--- a/apps/backend/internal/task/handlers/task_handlers.go
+++ b/apps/backend/internal/task/handlers/task_handlers.go
@@ -100,7 +100,7 @@ func NewTaskHandlers(svc *service.Service, orchestrator OrchestratorStarter, rep
logger: log.WithFields(zap.String("component", "task-task-handlers")),
}
// The orchestrator also surfaces the in-memory fine-grained busy substate
- // (ADR-0036). Derive the narrow provider from it so the
+ // (ADR-0038). Derive the narrow provider from it so the
// session-fetch handlers can stamp foreground_activity onto RUNNING sessions
// without waiting for a WS flip. Nil (e.g. in tests) simply omits the field.
if fa, ok := orchestrator.(dto.ForegroundActivityProvider); ok {
diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go
index 43a6dc4b62..0d80301cf9 100644
--- a/apps/backend/pkg/api/v1/task.go
+++ b/apps/backend/pkg/api/v1/task.go
@@ -44,7 +44,7 @@ const (
)
// ForegroundActivity is the fine-grained busy substate of a RUNNING session
-// (ADR-0036). It distinguishes a foreground turn that is
+// (ADR-0038). It distinguishes a foreground turn that is
// actively generating from one that is idle, held open only by spawned
// background work (a subagent task, a run-in-background shell, an active
// Monitor). It is only meaningful while the session state is RUNNING; for every
diff --git a/apps/web/e2e/tests/chat/busy-signal.spec.ts b/apps/web/e2e/tests/chat/busy-signal.spec.ts
index fb51dc7065..0aca76c816 100644
--- a/apps/web/e2e/tests/chat/busy-signal.spec.ts
+++ b/apps/web/e2e/tests/chat/busy-signal.spec.ts
@@ -6,7 +6,7 @@ import { typeWhileBusy } from "../../helpers/type-while-busy";
import { SessionPage } from "../../pages/session-page";
// ---------------------------------------------------------------------------
-// ADR-0036 — operator-visible surfacing.
+// ADR-0038 — operator-visible surfacing.
//
// The mock `/background ` command spawns a top-level subagent Task and then
// holds the turn open with NO foreground output, so the session sits in the
diff --git a/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
index 2f7fa087f3..781c2b758c 100644
--- a/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
+++ b/apps/web/e2e/tests/chat/mobile-busy-signal.spec.ts
@@ -1,7 +1,7 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
-// Mobile (Pixel 5) coverage for ADR-0036. Filename matches
+// Mobile (Pixel 5) coverage for ADR-0038. Filename matches
// /mobile-.*\.spec\.ts/ so the `mobile-chrome` project picks it up. The composer
// gating and the working affordance derive from the same shared hooks the
// desktop path uses, so this asserts the operator-visible outcome holds at
@@ -84,7 +84,7 @@ test.describe("Mobile fine-grained busy signal", () => {
// Reload mid-window: a fresh mobile client. The accept-input + working
// affordance must come straight from the boot payload (no persisted value,
- // no activity_changed WS flip due) — ADR-0036.
+ // no activity_changed WS flip due) — ADR-0038.
await testPage.reload();
await session.waitForLoad();
diff --git a/apps/web/hooks/domains/session/use-session-state.test.ts b/apps/web/hooks/domains/session/use-session-state.test.ts
index 89abd5be11..19663d050a 100644
--- a/apps/web/hooks/domains/session/use-session-state.test.ts
+++ b/apps/web/hooks/domains/session/use-session-state.test.ts
@@ -197,7 +197,7 @@ describe("useSessionState — fine-grained busy signal (foreground_activity)", (
mockPrepareProgress = {};
});
- // ADR-0036. (a) generating gates the composer;
+ // ADR-0038. (a) generating gates the composer;
// (b) background-idle accepts input but stays working; (c) fully idle.
it("(a) RUNNING+generating gates the composer", () => {
mockSession = {
diff --git a/apps/web/hooks/domains/session/use-session-state.ts b/apps/web/hooks/domains/session/use-session-state.ts
index b10a427efc..6d0be5998e 100644
--- a/apps/web/hooks/domains/session/use-session-state.ts
+++ b/apps/web/hooks/domains/session/use-session-state.ts
@@ -8,7 +8,7 @@ export function deriveSessionFlags(session: TaskSession | null | undefined) {
const errorMessage = session?.error_message;
const isStarting = state === "STARTING";
const isRunning = state === "RUNNING";
- // ADR-0036. Three conditions, not two:
+ // ADR-0038. Three conditions, not two:
// (a) generating — RUNNING, foreground actively producing output
// (b) background-idle — RUNNING, foreground yielded to spawned background work
// (c) fully idle — not RUNNING
diff --git a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
index 58ec334af5..05dd3098ef 100644
--- a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
+++ b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts
@@ -140,7 +140,7 @@ describe("setTaskSessionsForTask preserves WS-seeded fields", () => {
});
});
-// ADR-0036 — a fresh page-load / second tab receives the
+// ADR-0038 — a fresh page-load / second tab receives the
// fine-grained busy substate on the boot payload (and now on the REST/WS session
// endpoints). Hydration and any subsequent list refresh must not drop it, or the
// coarse busy affordance would persist until the next WS flip — the exact gap
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index 492a9ba18f..18e808012a 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -248,7 +248,7 @@ export type TaskSessionStateChangedPayload = {
review_status?: string;
// Task environment (for session→environment mapping)
task_environment_id?: string;
- // Fine-grained busy substate (see ADR-0036), carried on every transition so
+ // Fine-grained busy substate (see ADR-0038), carried on every transition so
// the client resets stale values; intra-RUNNING flips arrive on
// session.activity_changed instead.
foreground_activity?: ForegroundActivity;
@@ -256,7 +256,7 @@ export type TaskSessionStateChangedPayload = {
/**
* Payload for `session.activity_changed` — the fine-grained busy signal
- * (see ADR-0036). Fires when a RUNNING session's foreground turn flips between
+ * (see ADR-0038). Fires when a RUNNING session's foreground turn flips between
* generating and idle-on-background-work, with no coarse state change.
*/
export type TaskSessionActivityChangedPayload = {
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index ab198c25b5..2aa01499d3 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -158,7 +158,7 @@ export type TaskSessionState =
export type TaskPendingAction = "clarification" | "permission";
/**
- * Fine-grained busy substate of a RUNNING session (see ADR-0036). Distinguishes
+ * Fine-grained busy substate of a RUNNING session (see ADR-0038). Distinguishes
* a foreground turn that is actively generating from one that is idle, held open
* only by spawned background work (a subagent task, a run-in-background shell, an
* active Monitor). Only meaningful while `state === "RUNNING"`; for every other
@@ -414,7 +414,7 @@ export type TaskSession = {
state: TaskSessionState;
/**
* Fine-grained busy substate while `state === "RUNNING"`
- * (ADR-0036). Pushed over `session.activity_changed`;
+ * (ADR-0038). Pushed over `session.activity_changed`;
* absent/`null` means the foreground turn is generating (the safe default).
*/
foreground_activity?: ForegroundActivity | null;
diff --git a/apps/web/lib/ui/state-icons.test.tsx b/apps/web/lib/ui/state-icons.test.tsx
index 34da41543b..f910577bfb 100644
--- a/apps/web/lib/ui/state-icons.test.tsx
+++ b/apps/web/lib/ui/state-icons.test.tsx
@@ -34,7 +34,7 @@ describe("getTaskStateIcon", () => {
});
describe("getSessionStateIcon — fine-grained busy tri-state", () => {
- // ADR-0036. Three distinguishable conditions:
+ // ADR-0038. Three distinguishable conditions:
// (a) RUNNING + generating → the established static "running" dot (unchanged)
// (b) RUNNING + background → working-in-background spinner, NOT the done check
// (c) COMPLETED → done checkmark
diff --git a/apps/web/lib/ui/state-icons.tsx b/apps/web/lib/ui/state-icons.tsx
index 9c1190836f..7028ecfbd1 100644
--- a/apps/web/lib/ui/state-icons.tsx
+++ b/apps/web/lib/ui/state-icons.tsx
@@ -56,7 +56,7 @@ const SESSION_STATE_ICONS: Record = {
};
// (b) background-idle: the foreground turn has yielded to spawned background
-// work (ADR-0036). A spinner — the operator can see the
+// work (ADR-0038). A spinner — the operator can see the
// agent is not done — visually separate from the static "generating" dot (a) by
// its motion, and never the done checkmark. The spinner (work in motion) reads
// as "something is still running in the background" while the foreground is
@@ -156,7 +156,7 @@ function getSessionStateIconConfig(
): IconConfig {
// (b) background-idle wins over the default RUNNING (generating) icon: while
// the foreground turn waits on spawned background work the session must read
- // as "working in background", never as done (ADR-0036).
+ // as "working in background", never as done (ADR-0038).
if (state === "RUNNING" && foregroundActivity === "background") {
return SESSION_BACKGROUND_ICON;
}
diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts
index 0fc728e547..ebcb4b3038 100644
--- a/apps/web/lib/ws/handlers/agent-session.ts
+++ b/apps/web/lib/ws/handlers/agent-session.ts
@@ -209,7 +209,7 @@ function buildSessionUpdate(payload: any): Record {
if (payload.task_environment_id) update.task_environment_id = payload.task_environment_id;
if (payload.updated_at) update.updated_at = payload.updated_at;
// Reset the fine-grained busy substate on every coarse transition so a stale
- // "background" value can't survive into the next turn (ADR-0036).
+ // "background" value can't survive into the next turn (ADR-0038).
if (payload.foreground_activity !== undefined)
update.foreground_activity = payload.foreground_activity;
return update;
@@ -488,7 +488,7 @@ function maybeNotifySessionFailure(store: StoreApi, ctx: SessionFailur
/** Apply an intra-RUNNING flip of the fine-grained busy substate: the
* foreground turn moved between generating and idle-on-background-work without
- * any coarse state change (ADR-0036). Annotates the
+ * any coarse state change (ADR-0038). Annotates the
* existing session row so the composer gate and status indicator update; does
* nothing until the row exists (state_changed seeds it first). */
function applyForegroundActivity(
From c8e427dafa085c5db72727a63fda845bff376e61 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 06:18:44 +0000
Subject: [PATCH 12/80] docs(spec): fine-grained background-running status
indicator
---
.../spec.md | 254 ++++++++++++++++++
1 file changed, 254 insertions(+)
create mode 100644 docs/specs/fine-grained-background-running-status-indicator/spec.md
diff --git a/docs/specs/fine-grained-background-running-status-indicator/spec.md b/docs/specs/fine-grained-background-running-status-indicator/spec.md
new file mode 100644
index 0000000000..d9c0077889
--- /dev/null
+++ b/docs/specs/fine-grained-background-running-status-indicator/spec.md
@@ -0,0 +1,254 @@
+# Fine-grained background-running status indicator
+
+The system already tracks, per session, a fine-grained substate that
+distinguishes an actively-generating foreground turn from a foreground turn that
+has gone idle and is only held open by spawned background work — a subagent, a
+backgrounded shell, or an active monitor (the busy signal recorded in
+ADR-0043). This spec makes the operator-facing status indicators as
+fine-grained as that signal: everywhere a task or session status appears, the
+"still working in the background" condition reads as its own state, distinct
+from both "actively generating" and "done".
+
+Scope note: this effort covers the kanban / task status surfaces the
+requirements enumerate. The Office area (autonomous-agent management) keeps its
+own status vocabulary and is explicitly out of scope here; extending the
+distinction into Office is a possible follow-up, not part of this work.
+
+## Three-state status vocabulary §spec:three-state-vocabulary
+
+*Status: not started*
+
+Every status surface in scope communicates three mutually distinguishable
+conditions for an in-flight unit of work:
+
+- **generating** — the foreground agent is actively producing output. This is
+ the established "it's running" affordance, unchanged.
+- **background-running** — the foreground turn is idle but recognized spawned
+ work (subagent / backgrounded shell / active monitor) is still running.
+- **done** — no work is in progress.
+
+The background-running affordance is visually separable from *both* the
+generating affordance *and* the done affordance, and the separation does not
+rely on hue alone: an operator distinguishes it by shape or motion, so it
+remains legible for color-vision-deficient operators and in a dense scan.
+
+**Decision and the constraint that drove it.** The three states are defined as a
+shared *meaning*, but each surface expresses that meaning within its own
+existing visual language rather than every surface being forced onto one
+identical icon set. This is driven by a hard tension in the requirements: the
+same underlying state must carry the same meaning everywhere, yet the
+established generating affordance must not be restyled — and today there is not
+one established generating look but several (a spinner, a ring-of-dots, a static
+dot, depending on surface). Those two constraints cannot both be fully honored
+if "same meaning" is read as "pixel-identical". The resolution is that each
+surface keeps its current generating and done affordances untouched and *adds* a
+background-running affordance that is distinct on that surface. Consistency is
+guaranteed at the level that matters to the operator — the same underlying
+condition always produces a not-done, not-generating "still working" reading on
+every surface — without a disruptive restyle of indicators operators already
+recognize.
+
+**Additive, not a re-signal.** The background-running affordance communicates
+only "work is still in progress." It deliberately does not signal "you may now
+type" — composer input-gating is a separate concern (ADR-0043) and is not
+represented by this indicator.
+
+**Alternatives considered.**
+
+- *Converge every surface onto one canonical three-state icon set.* Rejected:
+ it delivers the strongest cross-surface identity but visibly restyles several
+ current running indicators (including the session-tab ring-of-dots and the
+ task-level spinner), which both enlarges the change and violates the
+ "generating affordance unchanged" constraint for whichever surfaces get
+ replaced. The consistency gain over "same meaning, per-surface expression" is
+ not worth that blast radius for a change whose goal is truthfulness, not a
+ visual redesign.
+- *Distinguish background only by color* (e.g. a different-hue spinner).
+ Rejected: fails the not-color-alone quality attribute; two spinners that
+ differ only in hue are not reliably distinguishable in a dense scan or for
+ color-vision-deficient operators.
+
+**Tradeoffs.** The background-running icon is not guaranteed to be identical
+pixel-for-pixel across surfaces; the guarantee is semantic (never done, never
+mistaken for generating), not pictorial. This is an accepted cost of leaving the
+established affordances in place.
+
+**User-level test path.** Trigger a session into the background-running
+condition (agent replies, then a monitor or backgrounded shell keeps running).
+On each in-scope surface, confirm the indicator differs from that surface's
+generating indicator and from its done indicator, and that the difference
+survives a grayscale/desaturated view.
+
+§req:success-criteria §req:quality-attributes §req:constraints
+
+## Task-level indicator reflects live background work §spec:task-level-indicator
+
+*Status: not started*
+
+On every at-a-glance task surface — the board / kanban card, the task list rows,
+the graph / swimlane nodes, and the open-task header — a task whose foreground
+turns are idle while spawned background work is still running shows the
+background-running affordance, not a done affordance and not the generating
+affordance. No task-level surface shows "done" while any session the task owns
+still has recognized background work outstanding.
+
+For a task running more than one session at once, its single task-level
+indicator follows **most-active-wins**: it shows generating if any session is
+generating; it shows background-running only when no session is generating but at
+least one has background work running; it falls through to today's behavior
+(done / waiting / failed, per existing rules) only when neither is true. The
+background-running tier is inserted between generating and done; it does not
+redefine how the other task states render.
+
+**Decision and the constraint that drove it.** The task-level three-state
+condition (including the most-active-wins aggregation across a task's sessions)
+is computed on the backend and delivered as part of the task record — alongside
+the existing primary-session status already carried there — so that every
+task-level surface reads one authoritative value. This is driven by how these
+surfaces get their data: the board, list, graph, and header render from the task
+record (initial page payload plus task-level update events) and do not hold the
+full set of a task's sessions for tasks that are not open. Deriving the
+aggregate on the client would therefore be blank or stale for exactly the
+off-screen tasks an operator is scanning. A backend-computed, task-record-borne
+value is correct on first paint, in a second tab, and for tasks the client has
+never expanded — and it keeps the surfaces from disagreeing, because they all
+read the same field rather than each re-deriving it.
+
+**Change from today's aggregation.** Task-level surfaces today variously reflect
+only the *designated primary* session, or a most-active ranking, or the
+most-recent live session — three different derivations feeding different
+surfaces. This capability makes the three-state reading uniformly
+most-active-wins. A consequence the operator will observe: a task whose primary
+session has finished but whose secondary session is still generating or running
+background work now reads as working, where before a primary-only surface could
+have read done. This is the intended behavior — a task is not "done" while any
+of its sessions is still working.
+
+**Alternatives considered.**
+
+- *Derive the task-level aggregate on the client from the session store.*
+ Rejected: the store is not guaranteed to hold every visible task's full
+ session set, so off-screen task rows and cards — the primary scanning surface
+ — would show blank or stale background state and could disagree with the
+ open-task view. It also re-introduces the multi-source-of-truth problem this
+ work is meant to end.
+- *Keep primary-session-only aggregation and just add the background tier to
+ it.* Rejected: it would still falsely read "done" for a task whose primary
+ session finished while a secondary session runs background work, which
+ directly violates the "never done while work continues" success criterion.
+
+**Tradeoffs.** Most-active-wins means a task-level surface can now reflect a
+non-primary session's activity, which is a behavior change some operators may
+notice; it is accepted because it is precisely what makes the indicator
+truthful. Carrying the aggregate on the task record means a task-level update is
+emitted when a session's activity flips between generating and background; this
+added update traffic is accepted as the cost of keeping scanning surfaces live
+and consistent (see §spec:live-propagation-fallback).
+
+**User-level test path.** With a single-session task, drive it
+generating → background-running → done and confirm the board card, task list
+row, graph/swimlane node, and open-task header each reflect all three in turn
+without opening the task. With a two-session task, confirm the task indicator
+shows generating while either session generates, background-running when one
+session is only running background work and none is generating, and done only
+when both are finished.
+
+§req:success-criteria §req:user-stories §req:priorities §req:quality-attributes
+
+## Session-level indicators surface the substate uniformly §spec:session-level-indicator
+
+*Status: not started*
+
+Every surface that shows a per-session status reflects the same three states.
+The session switcher already does. The session-reopen menu — which today shows
+no indicator at all for a running session and drops the fine-grained substate —
+shows the background-running state distinctly from generating and from done. The
+session tab / global "something is running" indicator distinguishes a session
+that is only running background work from one that is actively generating, and
+neither reads as done.
+
+**Decision and the constraint that drove it.** Session-level surfaces read the
+per-session substate that the underlying signal already emits and already places
+on the session record (initial payload plus the per-session activity event), and
+render it through the same three-state vocabulary as every other surface. The
+driving constraint is the requirement's core grievance: the signal is already
+produced end-to-end and one surface renders it correctly, yet the peer surfaces
+ignore or drop it, so the operator cannot trust any single icon. Bringing every
+session-level surface onto the substate it is already being handed removes the
+surface-to-surface disagreement without new mechanism.
+
+**Alternatives considered.**
+
+- *Leave session-level surfaces as-is and only fix task-level ones.* Rejected:
+ the session-reopen menu and session tabs are exactly where an operator lands
+ after scanning, so a task that reads "working" at the board level but "done"
+ (or blank) in the reopen menu re-creates the contradiction one level down.
+
+**Tradeoffs.** The session tab and reopen menu gain a state they did not
+previously render, a small additional visual element in already-dense controls;
+this is accepted because their silence today is precisely the defect.
+
+**User-level test path.** With a session in the background-running condition,
+open the session-reopen menu and the session tab and confirm each shows the
+background-running affordance — distinct from generating and never a done check
+— matching what the session switcher shows for the same session.
+
+§req:success-criteria §req:user-stories §req:constraints
+
+## Live propagation, fresh-load correctness, and safe fallback §spec:live-propagation-fallback
+
+*Status: not started*
+
+As a session moves between generating, background-running, and done, every
+in-scope surface — session-level and task-level — updates promptly without a
+manual refresh. A freshly loaded page and a second tab show the correct state
+immediately rather than a stale value that only corrects on the next transition.
+When the fine-grained substate is unknown or unavailable, no surface falsely
+reads "done" while a turn is still open.
+
+**Decision and the constraint that drove it.** Transitions are pushed live:
+per-session substate flips propagate to session-level surfaces, and because the
+task-level aggregate is carried on the task record, a session's activity flip
+also refreshes the task-level surfaces subscribed to that task. The initial page
+payload and the session/task records carry the current substate so first paint
+and additional tabs are correct without waiting for a transition. This is driven
+by the requirement that a stale "done" that clears only on refresh is itself a
+defect: the indicator's whole value is being trusted at a glance, so it must be
+correct at the moment of the glance, including immediately after load.
+
+**Safe fallback.** The fine-grained substate is in-memory and best-effort by
+design (ADR-0043); after a backend restart the in-memory tracker resets. The
+fallback is chosen so that an unknown substate never resolves to "done" while a
+turn is open: an in-flight session whose substate is unknown reads as working
+(generating), not done, and a task with such a session does not read done.
+Correctness of "not falsely done" is never traded for the optimization of the
+finer distinction.
+
+**Alternatives considered.**
+
+- *Persist the fine-grained substate so it survives restart.* Rejected
+ (consistent with ADR-0043): a persisted copy becomes a second source of truth
+ that can drift, can survive a restart as a false reading for a session whose
+ turn is already gone, and adds write churn on the hot streaming path. The
+ substate only matters for a live in-flight turn; resetting to the safe default
+ on turn-close is sufficient and simpler.
+- *Refresh task-level surfaces only on coarse state changes, not on
+ activity flips.* Rejected: a generating→background flip does not change the
+ coarse state, so the scanning surfaces would keep showing the wrong one of the
+ three states until an unrelated transition, reproducing the stale-until-
+ refresh defect the requirements call out.
+
+**Tradeoffs.** Emitting a task-level refresh on activity flips adds update
+traffic proportional to how chatty background work is (e.g. a bursty monitor);
+this is accepted as the price of live, trustworthy scanning surfaces, and is
+bounded by only re-emitting on an actual change of the aggregated three-state
+value.
+
+**User-level test path.** With a task working in the background, confirm a
+freshly loaded page and a second browser tab both show background-running
+immediately (not done, no manual refresh). Drive a generating→background flip
+and confirm the board card and task list update without a coarse state change.
+Restart the backend mid-work and confirm no surface flips to "done" while the
+session's turn is still open.
+
+§req:success-criteria §req:quality-attributes §req:constraints
From f9a9df861bff35c9ffae15247b40df918ea2ae1f Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 07:58:34 +0000
Subject: [PATCH 13/80] refactor(web): solidify tri-state session icon contract
as single source
Tighten getSessionStateIcon docs to name it the single source every session
surface calls for the background-running affordance, and add a test asserting
the three states differ by icon SHAPE (grayscale-survivable, not hue alone).
Boy-scout: fix stale ADR-0038 comment refs to ADR-0043 in touched files.
---
apps/web/lib/ui/state-icons.test.tsx | 16 +++++++++++++++-
apps/web/lib/ui/state-icons.tsx | 23 +++++++++++++++--------
2 files changed, 30 insertions(+), 9 deletions(-)
diff --git a/apps/web/lib/ui/state-icons.test.tsx b/apps/web/lib/ui/state-icons.test.tsx
index f910577bfb..30d583ffcb 100644
--- a/apps/web/lib/ui/state-icons.test.tsx
+++ b/apps/web/lib/ui/state-icons.test.tsx
@@ -34,7 +34,7 @@ describe("getTaskStateIcon", () => {
});
describe("getSessionStateIcon — fine-grained busy tri-state", () => {
- // ADR-0038. Three distinguishable conditions:
+ // ADR-0043. Three distinguishable conditions:
// (a) RUNNING + generating → the established static "running" dot (unchanged)
// (b) RUNNING + background → working-in-background spinner, NOT the done check
// (c) COMPLETED → done checkmark
@@ -76,6 +76,20 @@ describe("getSessionStateIcon — fine-grained busy tri-state", () => {
IconCircleCheck,
);
});
+
+ it("distinguishes background-running from BOTH generating and done by icon SHAPE, not hue alone", () => {
+ // §req:not-color-alone: the three affordances must be separable in a
+ // grayscale/desaturated scan. Asserting the icon *component* (shape) differs
+ // — independent of className/hue — guarantees the distinction survives for
+ // color-vision-deficient operators. This locks getSessionStateIcon as the
+ // single source every session surface calls for all three states.
+ const generating = iconType(getSessionStateIcon("RUNNING", undefined, "generating"));
+ const background = iconType(getSessionStateIcon("RUNNING", undefined, "background"));
+ const done = iconType(getSessionStateIcon("COMPLETED"));
+ expect(background).not.toBe(generating);
+ expect(background).not.toBe(done);
+ expect(generating).not.toBe(done);
+ });
});
describe("shouldShowTaskRunningSpinner", () => {
diff --git a/apps/web/lib/ui/state-icons.tsx b/apps/web/lib/ui/state-icons.tsx
index 7028ecfbd1..3b655b3192 100644
--- a/apps/web/lib/ui/state-icons.tsx
+++ b/apps/web/lib/ui/state-icons.tsx
@@ -55,12 +55,19 @@ const SESSION_STATE_ICONS: Record = {
CANCELLED: { Icon: IconPlayerPause, className: STYLE_MUTED },
};
-// (b) background-idle: the foreground turn has yielded to spawned background
-// work (ADR-0038). A spinner — the operator can see the
+// (b) background-running: the foreground turn has yielded to spawned background
+// work (ADR-0043). A spinner — the operator can see the
// agent is not done — visually separate from the static "generating" dot (a) by
-// its motion, and never the done checkmark. The spinner (work in motion) reads
-// as "something is still running in the background" while the foreground is
-// idle; the solid dot stays reserved for the foreground actively generating.
+// its motion AND shape, and from the done checkmark (c) by its motion AND shape,
+// so the three read apart even in a grayscale/desaturated scan (not hue alone,
+// per §req:not-color-alone). The spinner (work in motion) reads as "something is
+// still running in the background" while the foreground is idle; the solid dot
+// stays reserved for the foreground actively generating.
+//
+// This is the single source for the background-running affordance: every
+// session-level surface (session switcher, session-reopen menu, sidebar running
+// indicator) renders it by calling getSessionStateIcon with the session's
+// foreground_activity rather than re-deriving its own icon.
const SESSION_BACKGROUND_ICON: IconConfig = {
Icon: IconLoader2,
className: "text-emerald-500 animate-spin",
@@ -154,9 +161,9 @@ function getSessionStateIconConfig(
state?: TaskSessionState,
foregroundActivity?: ForegroundActivity | null,
): IconConfig {
- // (b) background-idle wins over the default RUNNING (generating) icon: while
- // the foreground turn waits on spawned background work the session must read
- // as "working in background", never as done (ADR-0038).
+ // (b) background-running wins over the default RUNNING (generating) icon:
+ // while the foreground turn waits on spawned background work the session must
+ // read as "working in background", never as done (ADR-0043).
if (state === "RUNNING" && foregroundActivity === "background") {
return SESSION_BACKGROUND_ICON;
}
From cf7826a34a3a3caf424bae189d2ee2d6b0a4cf1f Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 07:59:51 +0000
Subject: [PATCH 14/80] fix(web): surface background-running substate in
session-reopen menu
The reopen menu suppressed the state icon for every RUNNING session, so a
session running only background work read as blank (dropped the substate).
Render the shared background-running spinner for RUNNING + background via
getSessionStateIcon, while keeping generating/STARTING/WAITING silent and an
unknown substate falling back to silence, never a done check.
---
.../task/session-reopen-menu.test.tsx | 34 +++++++++++++++++++
.../components/task/session-reopen-menu.tsx | 30 ++++++++++++----
2 files changed, 58 insertions(+), 6 deletions(-)
create mode 100644 apps/web/components/task/session-reopen-menu.test.tsx
diff --git a/apps/web/components/task/session-reopen-menu.test.tsx b/apps/web/components/task/session-reopen-menu.test.tsx
new file mode 100644
index 0000000000..2a367f88d4
--- /dev/null
+++ b/apps/web/components/task/session-reopen-menu.test.tsx
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { shouldShowReopenStateIcon } from "./session-reopen-menu";
+
+describe("shouldShowReopenStateIcon", () => {
+ it("surfaces the icon for a background-running session (RUNNING + background)", () => {
+ // The defect this fixes: a session whose foreground turn is idle while
+ // background work runs previously showed no icon (state dropped). It must
+ // now render — the shared background-running spinner, never a done check.
+ expect(shouldShowReopenStateIcon("RUNNING", "background")).toBe(true);
+ });
+
+ it("keeps a generating RUNNING session icon-less (established silent affordance)", () => {
+ expect(shouldShowReopenStateIcon("RUNNING", "generating")).toBe(false);
+ });
+
+ it("falls back to silence — not done — when a RUNNING substate is unknown", () => {
+ // §req safe-defaults: an unknown substate on a live session must never
+ // resolve to a done affordance. Silence (no icon) is the safe reading here.
+ expect(shouldShowReopenStateIcon("RUNNING", null)).toBe(false);
+ expect(shouldShowReopenStateIcon("RUNNING", undefined)).toBe(false);
+ });
+
+ it("keeps STARTING and WAITING_FOR_INPUT icon-less", () => {
+ expect(shouldShowReopenStateIcon("STARTING", null)).toBe(false);
+ expect(shouldShowReopenStateIcon("WAITING_FOR_INPUT", null)).toBe(false);
+ });
+
+ it("renders the existing icon for terminal / other states", () => {
+ expect(shouldShowReopenStateIcon("COMPLETED", null)).toBe(true);
+ expect(shouldShowReopenStateIcon("FAILED", null)).toBe(true);
+ expect(shouldShowReopenStateIcon("CANCELLED", null)).toBe(true);
+ expect(shouldShowReopenStateIcon("CREATED", null)).toBe(true);
+ });
+});
diff --git a/apps/web/components/task/session-reopen-menu.tsx b/apps/web/components/task/session-reopen-menu.tsx
index 8278ca63c9..f6b6d0a756 100644
--- a/apps/web/components/task/session-reopen-menu.tsx
+++ b/apps/web/components/task/session-reopen-menu.tsx
@@ -14,11 +14,29 @@ import { addSessionPanel } from "@/lib/state/dockview-panel-actions";
import { getSessionStateIcon } from "@/lib/ui/state-icons";
import { AgentLogo } from "@/components/agent-logo";
import { markSessionTabUserActivationIntent } from "@/components/task/session-tab-activation-intent";
-import type { TaskSession } from "@/lib/types/http";
+import type { ForegroundActivity, TaskSession, TaskSessionState } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
type AgentInfo = { label: string; agentName: string };
+/**
+ * Whether the reopen-menu row should render a state icon for `session`.
+ *
+ * Background-running (RUNNING + `background`) is the one running state this menu
+ * surfaces — it must read distinctly (the shared background spinner), never as
+ * done. The other in-flight states keep the established silent affordance: a
+ * generating RUNNING session, STARTING, and WAITING_FOR_INPUT stay icon-less.
+ * Terminal states (COMPLETED / FAILED / CANCELLED) keep their existing icons.
+ * A RUNNING session whose substate is unknown falls back to silence (not done).
+ */
+export function shouldShowReopenStateIcon(
+ state: TaskSessionState,
+ foregroundActivity?: ForegroundActivity | null,
+): boolean {
+ if (state === "RUNNING") return foregroundActivity === "background";
+ return state !== "STARTING" && state !== "WAITING_FOR_INPUT";
+}
+
function resolveAgentInfo(
session: TaskSession,
profilesById: Record,
@@ -125,11 +143,11 @@ export function SessionReopenMenuItems({
)}
{info.label}
{isPrimary && }
- {session.state !== "RUNNING" &&
- session.state !== "STARTING" &&
- session.state !== "WAITING_FOR_INPUT" && (
- {getSessionStateIcon(session.state, "h-3 w-3")}
- )}
+ {shouldShowReopenStateIcon(session.state, session.foreground_activity) && (
+
+ {getSessionStateIcon(session.state, "h-3 w-3", session.foreground_activity)}
+
+ )}
);
})}
From 0760ce361858b21aac7a3e6c4f927e8e2c764e9f Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:07:15 +0000
Subject: [PATCH 15/80] feat(web): show background-running substate in sidebar
task indicator
Thread the most-active session's foreground_activity from the session store
through getSessionInfoForTask -> sidebar item -> TaskSwitcherItem -> TaskItem, and
add a TaskStateIcon branch that renders the shared background-running spinner for
a RUNNING + background session. Distinct by shape from the generating spinner and
never the review/done check; an unknown substate falls back to generating.
Matches the session switcher for the same session.
---
apps/web/components/task/task-item.test.tsx | 70 +++++++++++++++++--
apps/web/components/task/task-item.tsx | 27 ++++++-
.../components/task/task-session-sidebar.tsx | 3 +
apps/web/components/task/task-switcher.tsx | 5 +-
apps/web/lib/utils/session-info.test.ts | 51 ++++++++++++++
apps/web/lib/utils/session-info.ts | 24 +++++--
6 files changed, 165 insertions(+), 15 deletions(-)
diff --git a/apps/web/components/task/task-item.test.tsx b/apps/web/components/task/task-item.test.tsx
index f296449f31..61bdfda86e 100644
--- a/apps/web/components/task/task-item.test.tsx
+++ b/apps/web/components/task/task-item.test.tsx
@@ -7,10 +7,14 @@ import { TooltipProvider } from "@kandev/ui/tooltip";
const REVIEW_ICON_TEST_ID = "task-state-review";
const RUNNING_ICON_TEST_ID = "task-state-running";
+const BACKGROUND_ICON_TEST_ID = "task-state-background-running";
const WAITING_FOR_INPUT_ICON_TEST_ID = "task-state-waiting-for-input";
const PENDING_PERMISSION_ICON_TEST_ID = "task-state-pending-permission";
const AGENT_ERROR_ICON_TEST_ID = "task-agent-error-icon";
const PREPARING_PHASE = "preparing";
+const DATA_LOADING_PHASE = "data-loading-phase";
+const RUNNING_PHASE = "running";
+const YELLOW_SPINNER_CLASS = "text-yellow-500";
const PREPARING_SPINNER_CLASS = "text-muted-foreground/40";
const SPIN_CLASS = "animate-spin";
const SLOW_SPIN_CLASS = "[animation-duration:2s]";
@@ -29,7 +33,7 @@ function renderTaskItem(props: Partial> = {}) {
function expectPreparingSpinner(): void {
const icon = screen.getByTestId(RUNNING_ICON_TEST_ID);
- expect(icon.getAttribute("data-loading-phase")).toBe(PREPARING_PHASE);
+ expect(icon.getAttribute(DATA_LOADING_PHASE)).toBe(PREPARING_PHASE);
expect(icon.classList.contains(PREPARING_SPINNER_CLASS)).toBe(true);
expect(icon.classList.contains(SPIN_CLASS)).toBe(true);
expect(icon.classList.contains(SLOW_SPIN_CLASS)).toBe(true);
@@ -112,8 +116,8 @@ describe("TaskItem status icon", () => {
renderTaskItem({ state: "IN_PROGRESS", sessionState: "CREATED" });
const icon = screen.getByTestId(RUNNING_ICON_TEST_ID);
- expect(icon.getAttribute("data-loading-phase")).toBe("running");
- expect(icon.classList.contains("text-yellow-500")).toBe(true);
+ expect(icon.getAttribute(DATA_LOADING_PHASE)).toBe(RUNNING_PHASE);
+ expect(icon.classList.contains(YELLOW_SPINNER_CLASS)).toBe(true);
expect(icon.classList.contains(SPIN_CLASS)).toBe(true);
});
@@ -128,8 +132,8 @@ describe("TaskItem status icon", () => {
renderTaskItem({ state: "IN_PROGRESS", sessionState: "RUNNING" });
const icon = screen.getByTestId(RUNNING_ICON_TEST_ID);
- expect(icon.getAttribute("data-loading-phase")).toBe("running");
- expect(icon.classList.contains("text-yellow-500")).toBe(true);
+ expect(icon.getAttribute(DATA_LOADING_PHASE)).toBe(RUNNING_PHASE);
+ expect(icon.classList.contains(YELLOW_SPINNER_CLASS)).toBe(true);
expect(icon.classList.contains(SPIN_CLASS)).toBe(true);
expect(icon.classList.contains(PREPARING_SPINNER_CLASS)).toBe(false);
});
@@ -168,3 +172,59 @@ describe("TaskItem actions", () => {
);
});
});
+
+describe("TaskItem background-running indicator", () => {
+ it("shows the background-running affordance (spinner, not a check) for a background-running session", () => {
+ // The session's foreground turn is idle but spawned background work is live.
+ // It must read distinctly from generating and never as the review/done check.
+ renderTaskItem({
+ state: "IN_PROGRESS",
+ sessionState: "RUNNING",
+ sessionForegroundActivity: "background",
+ });
+
+ const icon = screen.getByTestId(BACKGROUND_ICON_TEST_ID);
+ expect(icon.querySelector(`.${SPIN_CLASS}`)).not.toBeNull();
+ expect(screen.queryByTestId(RUNNING_ICON_TEST_ID)).toBeNull();
+ expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).toBeNull();
+ });
+
+ it("never reads done for a background-running session even in a REVIEW column", () => {
+ // §req: no surface shows done while background work runs. RUNNING classifies
+ // as in_progress, so the background branch wins over the review check.
+ renderTaskItem({
+ state: "REVIEW",
+ sessionState: "RUNNING",
+ sessionForegroundActivity: "background",
+ });
+
+ expect(screen.queryByTestId(BACKGROUND_ICON_TEST_ID)).not.toBeNull();
+ expect(screen.queryByTestId(REVIEW_ICON_TEST_ID)).toBeNull();
+ });
+
+ it("falls back to the generating spinner when the RUNNING substate is unknown", () => {
+ // Safe default: an unknown/null substate must render generating, never done
+ // and never the background spinner.
+ renderTaskItem({
+ state: "IN_PROGRESS",
+ sessionState: "RUNNING",
+ sessionForegroundActivity: null,
+ });
+
+ expect(screen.queryByTestId(BACKGROUND_ICON_TEST_ID)).toBeNull();
+ const icon = screen.getByTestId(RUNNING_ICON_TEST_ID);
+ expect(icon.getAttribute(DATA_LOADING_PHASE)).toBe(RUNNING_PHASE);
+ expect(icon.classList.contains(YELLOW_SPINNER_CLASS)).toBe(true);
+ });
+
+ it("keeps the generating spinner for a foreground-generating session", () => {
+ renderTaskItem({
+ state: "IN_PROGRESS",
+ sessionState: "RUNNING",
+ sessionForegroundActivity: "generating",
+ });
+
+ expect(screen.queryByTestId(BACKGROUND_ICON_TEST_ID)).toBeNull();
+ expect(screen.queryByTestId(RUNNING_ICON_TEST_ID)).not.toBeNull();
+ });
+});
diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx
index dfb3ec04af..eb7862a691 100644
--- a/apps/web/components/task/task-item.tsx
+++ b/apps/web/components/task/task-item.tsx
@@ -20,8 +20,12 @@ import { computeRowIndent, resolveRowDepth } from "@/lib/sidebar/row-indent";
import { isDebugUI } from "@/lib/config";
import { useTaskColor } from "@/hooks/use-task-color";
import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors";
-import type { TaskState, TaskSessionState } from "@/lib/types/http";
-import { shouldUseQuestionTaskIcon, shouldUsePermissionTaskIcon } from "@/lib/ui/state-icons";
+import type { ForegroundActivity, TaskState, TaskSessionState } from "@/lib/types/http";
+import {
+ getSessionStateIcon,
+ shouldUseQuestionTaskIcon,
+ shouldUsePermissionTaskIcon,
+} from "@/lib/ui/state-icons";
import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
@@ -37,6 +41,8 @@ type TaskItemProps = {
title: string;
state?: TaskState;
sessionState?: TaskSessionState;
+ /** Fine-grained busy substate (ADR-0043) of the session `sessionState` reflects. */
+ sessionForegroundActivity?: ForegroundActivity | null;
isArchived?: boolean;
isSelected?: boolean;
/** Whether this row is part of an active multi-selection (distinct from the active-task highlight). */
@@ -162,12 +168,14 @@ function taskItemRowClick(
function TaskStateIcon({
sessionState,
state,
+ sessionForegroundActivity,
isInProgress,
hasPendingClarification,
hasPendingPermission,
}: {
sessionState?: TaskSessionState;
state?: TaskState;
+ sessionForegroundActivity?: ForegroundActivity | null;
isInProgress: boolean;
hasPendingClarification?: boolean;
hasPendingPermission?: boolean;
@@ -197,6 +205,19 @@ function TaskStateIcon({
/>
);
}
+ // Background-running: the session is live (RUNNING) but its foreground turn is
+ // idle, held open only by spawned background work. Render the shared
+ // background affordance — distinct by shape from this surface's generating
+ // spinner (IconCircleDashed) and never the review/done check. RUNNING always
+ // classifies as in_progress, so this can never fall through to a done icon; an
+ // unknown/generating substate falls through to the generating spinner below.
+ if (sessionState === "RUNNING" && sessionForegroundActivity === "background") {
+ return (
+
+ {getSessionStateIcon("RUNNING", "h-3.5 w-3.5", "background")}
+
+ );
+ }
if (isInProgress) {
return (
{
);
expect(info.sessionState).toBe("WAITING_FOR_INPUT");
});
+
+ // The substate must be read from the SAME session the state came from — the
+ // most-active one — so the sidebar shows background-running for the session it
+ // reports as RUNNING, not a stale substate from the idle primary.
+ it("carries the most-active session's foreground_activity alongside its state", () => {
+ const info = getSessionInfoForTask(
+ "t1",
+ {
+ t1: [
+ session({ id: "p", is_primary: true, state: "WAITING_FOR_INPUT" }),
+ session({ id: "s", state: "RUNNING", foreground_activity: "background" }),
+ ],
+ },
+ {},
+ );
+ expect(info.sessionState).toBe("RUNNING");
+ expect(info.foregroundActivity).toBe("background");
+ });
+
+ it("reports generating substate for a foreground-generating session", () => {
+ const info = getSessionInfoForTask(
+ "t1",
+ {
+ t1: [
+ session({
+ id: "p",
+ is_primary: true,
+ state: "RUNNING",
+ foreground_activity: "generating",
+ }),
+ ],
+ },
+ {},
+ );
+ expect(info.foregroundActivity).toBe("generating");
+ });
+
+ it("defaults foregroundActivity to null when the picked session omits it", () => {
+ // §req safe-defaults: an absent substate must be an explicit null (unknown),
+ // which downstream resolves to generating — never done.
+ const info = getSessionInfoForTask(
+ "t1",
+ { t1: [session({ id: "p", is_primary: true, state: "RUNNING" })] },
+ {},
+ );
+ expect(info.foregroundActivity).toBeNull();
+ });
+
+ it("returns undefined foregroundActivity when the task has no sessions", () => {
+ expect(getSessionInfoForTask("t1", {}, {}).foregroundActivity).toBeUndefined();
+ });
});
diff --git a/apps/web/lib/utils/session-info.ts b/apps/web/lib/utils/session-info.ts
index b344ba67df..23bda8a81f 100644
--- a/apps/web/lib/utils/session-info.ts
+++ b/apps/web/lib/utils/session-info.ts
@@ -1,9 +1,14 @@
-import type { TaskSession, TaskSessionState } from "@/lib/types/http";
+import type { ForegroundActivity, TaskSession, TaskSessionState } from "@/lib/types/http";
export type SessionInfo = {
diffStats: { additions: number; deletions: number } | undefined;
updatedAt: string | undefined;
sessionState: TaskSessionState | undefined;
+ // Fine-grained busy substate (ADR-0043) of the most-active session, so the
+ // sidebar indicator can distinguish background-running from generating. Paired
+ // with `sessionState` (both read from the same picked session) and left
+ // undefined when no session is present.
+ foregroundActivity?: ForegroundActivity | null;
};
type GitStatusMap = Record<
@@ -56,11 +61,14 @@ function priority(state: TaskSessionState | undefined): number {
return idx === -1 ? SESSION_STATE_PRIORITY.length : idx;
}
-function pickMostActiveState(sessions: TaskSession[]): TaskSessionState | undefined {
- let best: TaskSessionState | undefined;
+// Returns the single most-active session, so its state AND its fine-grained
+// foreground_activity substate are read from the same session (they must agree —
+// the substate only means anything relative to the state it belongs to).
+function pickMostActiveSession(sessions: TaskSession[]): TaskSession | undefined {
+ let best: TaskSession | undefined;
for (const s of sessions) {
const candidate = s.state as TaskSessionState | undefined;
- if (priority(candidate) < priority(best)) best = candidate;
+ if (priority(candidate) < priority(best?.state as TaskSessionState | undefined)) best = s;
}
return best;
}
@@ -83,11 +91,13 @@ export function getSessionInfoForTask(
// Empty string means the session was created from a WS event without timestamps;
// return undefined so callers fall through to task.updatedAt/createdAt instead.
const updatedAt = latestSession.updated_at || undefined;
- const sessionState = pickMostActiveState(sessions);
+ const mostActive = pickMostActiveSession(sessions);
+ const sessionState = mostActive?.state as TaskSessionState | undefined;
+ const foregroundActivity = mostActive?.foreground_activity ?? null;
const envKey = environmentIdBySessionId?.[latestSession.id] ?? latestSession.id;
const gitStatus = gitStatusByEnvId[envKey];
- if (!gitStatus) return { diffStats: undefined, updatedAt, sessionState };
+ if (!gitStatus) return { diffStats: undefined, updatedAt, sessionState, foregroundActivity };
const diffStats = computeDiffStats(gitStatus);
- return { diffStats, updatedAt, sessionState };
+ return { diffStats, updatedAt, sessionState, foregroundActivity };
}
From a5051e68742e573c0781f800293d0a83c005896a Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:17:08 +0000
Subject: [PATCH 16/80] docs(spec): mark session-level-indicator complete,
vocabulary in progress
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This foundation batch delivered the session-level surfaces (reopen menu +
sidebar/session-tab indicator) onto the shared three-state vocabulary. The
vocabulary itself is established as the single source; full cross-surface
completion awaits the task-level surfaces (§spec:task-level-indicator).
---
.../spec.md | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/docs/specs/fine-grained-background-running-status-indicator/spec.md b/docs/specs/fine-grained-background-running-status-indicator/spec.md
index d9c0077889..467f36417d 100644
--- a/docs/specs/fine-grained-background-running-status-indicator/spec.md
+++ b/docs/specs/fine-grained-background-running-status-indicator/spec.md
@@ -16,7 +16,14 @@ distinction into Office is a possible follow-up, not part of this work.
## Three-state status vocabulary §spec:three-state-vocabulary
-*Status: not started*
+*Status: in progress*
+
+
+
Every status surface in scope communicates three mutually distinguishable
conditions for an in-flight unit of work:
@@ -157,7 +164,7 @@ when both are finished.
## Session-level indicators surface the substate uniformly §spec:session-level-indicator
-*Status: not started*
+*Status: complete*
Every surface that shows a per-session status reflects the same three states.
The session switcher already does. The session-reopen menu — which today shows
From 82e0d4ec7b2d1d5ea5fe42d32963cccb67e3a460 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:37:04 +0000
Subject: [PATCH 17/80] feat(task): add task-level foreground-activity
aggregate to TaskDTO
---
apps/backend/internal/task/dto/dto.go | 61 +++++++-----
.../task/dto/foreground_activity_test.go | 95 +++++++++++++++++++
.../internal/task/dto/task_activity.go | 71 ++++++++++++++
3 files changed, 201 insertions(+), 26 deletions(-)
create mode 100644 apps/backend/internal/task/dto/task_activity.go
diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go
index 3704a38623..d004cfb06c 100644
--- a/apps/backend/internal/task/dto/dto.go
+++ b/apps/backend/internal/task/dto/dto.go
@@ -133,32 +133,41 @@ type EnvironmentDTO struct {
}
type TaskDTO struct {
- ID string `json:"id"`
- WorkspaceID string `json:"workspace_id"`
- WorkflowID string `json:"workflow_id"`
- WorkflowStepID string `json:"workflow_step_id"`
- Title string `json:"title"`
- Description string `json:"description"`
- State v1.TaskState `json:"state"`
- Priority string `json:"priority"`
- Repositories []TaskRepositoryDTO `json:"repositories,omitempty"`
- Position int `json:"position"`
- PrimarySessionID *string `json:"primary_session_id,omitempty"`
- SessionCount *int `json:"session_count,omitempty"`
- ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
- PrimaryExecutorID *string `json:"primary_executor_id,omitempty"`
- PrimaryExecutorType *string `json:"primary_executor_type,omitempty"`
- PrimaryExecutorName *string `json:"primary_executor_name,omitempty"`
- PrimaryAgentName *string `json:"primary_agent_name,omitempty"`
- PrimaryWorkingDirectory *string `json:"primary_working_directory,omitempty"`
- PrimarySessionState *string `json:"primary_session_state,omitempty"`
- PrimarySessionPendingAction *string `json:"primary_session_pending_action"`
- IsRemoteExecutor bool `json:"is_remote_executor,omitempty"`
- ParentID string `json:"parent_id,omitempty"`
- ArchivedAt *time.Time `json:"archived_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
+ ID string `json:"id"`
+ WorkspaceID string `json:"workspace_id"`
+ WorkflowID string `json:"workflow_id"`
+ WorkflowStepID string `json:"workflow_step_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ State v1.TaskState `json:"state"`
+ Priority string `json:"priority"`
+ Repositories []TaskRepositoryDTO `json:"repositories,omitempty"`
+ Position int `json:"position"`
+ PrimarySessionID *string `json:"primary_session_id,omitempty"`
+ SessionCount *int `json:"session_count,omitempty"`
+ ReviewStatus models.ReviewStatus `json:"review_status,omitempty"`
+ PrimaryExecutorID *string `json:"primary_executor_id,omitempty"`
+ PrimaryExecutorType *string `json:"primary_executor_type,omitempty"`
+ PrimaryExecutorName *string `json:"primary_executor_name,omitempty"`
+ PrimaryAgentName *string `json:"primary_agent_name,omitempty"`
+ PrimaryWorkingDirectory *string `json:"primary_working_directory,omitempty"`
+ PrimarySessionState *string `json:"primary_session_state,omitempty"`
+ PrimarySessionPendingAction *string `json:"primary_session_pending_action"`
+ // ForegroundActivity is the task-level MOST-ACTIVE-WINS activity aggregate
+ // across the task's sessions (§spec:task-level-indicator): "generating" when
+ // any session is generating, "background" when none is generating but at
+ // least one RUNNING session is holding a turn open for background work, and
+ // empty (omitted) when no session is running — in which case task-level
+ // surfaces fall through to the coarse task state (done / waiting / failed).
+ // Computed on the backend and carried on the task record so every task-level
+ // surface reads one authoritative value; stamped by EnrichTaskForegroundActivity.
+ ForegroundActivity v1.ForegroundActivity `json:"foreground_activity,omitempty"`
+ IsRemoteExecutor bool `json:"is_remote_executor,omitempty"`
+ ParentID string `json:"parent_id,omitempty"`
+ ArchivedAt *time.Time `json:"archived_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Metadata map[string]interface{} `json:"metadata,omitempty"`
// Office extensions
AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"`
diff --git a/apps/backend/internal/task/dto/foreground_activity_test.go b/apps/backend/internal/task/dto/foreground_activity_test.go
index 7b6c4a99e2..0fcffe7355 100644
--- a/apps/backend/internal/task/dto/foreground_activity_test.go
+++ b/apps/backend/internal/task/dto/foreground_activity_test.go
@@ -72,6 +72,101 @@ func TestEnrichForegroundActivity(t *testing.T) {
}
}
+// mapForegroundActivityProvider resolves a per-session activity so multi-session
+// aggregation can be exercised with distinct values per session.
+type mapForegroundActivityProvider struct {
+ byID map[string]v1.ForegroundActivity
+ called []string
+}
+
+func (m *mapForegroundActivityProvider) ForegroundActivity(sessionID string) v1.ForegroundActivity {
+ m.called = append(m.called, sessionID)
+ return m.byID[sessionID]
+}
+
+func TestEnrichTaskForegroundActivity(t *testing.T) {
+ running := models.TaskSessionStateRunning
+ done := models.TaskSessionStateCompleted
+ waiting := models.TaskSessionStateWaitingForInput
+
+ sess := func(id string, state models.TaskSessionState) *models.TaskSession {
+ return &models.TaskSession{ID: id, State: state}
+ }
+
+ tests := []struct {
+ name string
+ sessions []*models.TaskSession
+ byID map[string]v1.ForegroundActivity
+ want v1.ForegroundActivity
+ // wantQueried lists the session IDs the provider must be consulted for —
+ // only RUNNING sessions, never a terminal/waiting one.
+ wantQueried []string
+ }{
+ {
+ name: "any generating wins",
+ sessions: []*models.TaskSession{sess("a", running), sess("b", running)},
+ byID: map[string]v1.ForegroundActivity{"a": v1.ForegroundActivityBackground, "b": v1.ForegroundActivityGenerating},
+ want: v1.ForegroundActivityGenerating,
+ wantQueried: []string{"a", "b"},
+ },
+ {
+ name: "none generating but one background",
+ sessions: []*models.TaskSession{sess("a", running), sess("b", running)},
+ byID: map[string]v1.ForegroundActivity{"a": v1.ForegroundActivityBackground, "b": v1.ForegroundActivityBackground},
+ want: v1.ForegroundActivityBackground,
+ wantQueried: []string{"a", "b"},
+ },
+ {
+ name: "finished primary does not mask a still-working secondary",
+ sessions: []*models.TaskSession{sess("primary", done), sess("secondary", running)},
+ byID: map[string]v1.ForegroundActivity{"secondary": v1.ForegroundActivityBackground},
+ want: v1.ForegroundActivityBackground,
+ wantQueried: []string{"secondary"},
+ },
+ {
+ name: "no running session falls through to empty",
+ sessions: []*models.TaskSession{sess("a", done), sess("b", waiting)},
+ byID: map[string]v1.ForegroundActivity{},
+ want: "",
+ wantQueried: nil,
+ },
+ {
+ name: "nil sessions are skipped",
+ sessions: []*models.TaskSession{nil, sess("a", running)},
+ byID: map[string]v1.ForegroundActivity{"a": v1.ForegroundActivityGenerating},
+ want: v1.ForegroundActivityGenerating,
+ wantQueried: []string{"a"},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ provider := &mapForegroundActivityProvider{byID: tc.byID}
+ dto := &TaskDTO{ID: "t1"}
+ EnrichTaskForegroundActivity(dto, tc.sessions, provider)
+ if dto.ForegroundActivity != tc.want {
+ t.Fatalf("aggregate: got %q, want %q", dto.ForegroundActivity, tc.want)
+ }
+ if len(provider.called) != len(tc.wantQueried) {
+ t.Fatalf("queried %v, want %v", provider.called, tc.wantQueried)
+ }
+ for i, id := range tc.wantQueried {
+ if provider.called[i] != id {
+ t.Fatalf("queried[%d]=%q, want %q (all: %v)", i, provider.called[i], id, provider.called)
+ }
+ }
+ })
+ }
+}
+
+func TestEnrichTaskForegroundActivity_NilProviderIsNoOp(t *testing.T) {
+ dto := &TaskDTO{ID: "t1"}
+ EnrichTaskForegroundActivity(dto, []*models.TaskSession{{ID: "a", State: models.TaskSessionStateRunning}}, nil)
+ if dto.ForegroundActivity != "" {
+ t.Fatalf("nil provider must not set an aggregate, got %q", dto.ForegroundActivity)
+ }
+}
+
func TestEnrichForegroundActivity_NilProviderIsNoOp(t *testing.T) {
dto := &TaskSessionDTO{ID: "s1", State: models.TaskSessionStateRunning}
EnrichForegroundActivity(dto, nil)
diff --git a/apps/backend/internal/task/dto/task_activity.go b/apps/backend/internal/task/dto/task_activity.go
new file mode 100644
index 0000000000..aa789755b8
--- /dev/null
+++ b/apps/backend/internal/task/dto/task_activity.go
@@ -0,0 +1,71 @@
+package dto
+
+import (
+ "github.com/kandev/kandev/internal/task/models"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// sessionActivity is the minimal per-session view the task-level aggregate needs:
+// the coarse session state plus the fine-grained foreground activity resolved
+// for a RUNNING session.
+type sessionActivity struct {
+ State models.TaskSessionState
+ Activity v1.ForegroundActivity
+}
+
+// aggregateForegroundActivity computes the task-level three-state activity from a
+// task's sessions using MOST-ACTIVE-WINS (§spec:task-level-indicator):
+//
+// - "generating" — any session is generating (the foreground agent is producing
+// output);
+// - "background" — none is generating but at least one RUNNING session is
+// holding a turn open for spawned background work;
+// - "" — no session is running, so task-level surfaces fall through to
+// the coarse task state (done / waiting / failed).
+//
+// Only RUNNING sessions carry a meaningful ForegroundActivity; a non-RUNNING
+// session never contributes a busy substate. A finished primary session therefore
+// does not mask a secondary session that is still working — the intended
+// consequence: a task is not "done" while any of its sessions is still working.
+func aggregateForegroundActivity(sessions []sessionActivity) v1.ForegroundActivity {
+ sawBackground := false
+ for _, s := range sessions {
+ if s.State != models.TaskSessionStateRunning {
+ continue
+ }
+ if s.Activity == v1.ForegroundActivityGenerating {
+ return v1.ForegroundActivityGenerating
+ }
+ if s.Activity == v1.ForegroundActivityBackground {
+ sawBackground = true
+ }
+ }
+ if sawBackground {
+ return v1.ForegroundActivityBackground
+ }
+ return ""
+}
+
+// EnrichTaskForegroundActivity stamps the task-level MOST-ACTIVE-WINS activity
+// aggregate onto a TaskDTO from the task's sessions. It is a no-op for a nil DTO
+// or nil provider, so the field is emitted (via omitempty) only where a live
+// activity tracker is wired and never fabricated otherwise. The provider is
+// consulted only for RUNNING sessions, matching the per-session enrich contract:
+// a non-RUNNING session never fabricates a substate.
+func EnrichTaskForegroundActivity(dto *TaskDTO, sessions []*models.TaskSession, provider ForegroundActivityProvider) {
+ if dto == nil || provider == nil {
+ return
+ }
+ acts := make([]sessionActivity, 0, len(sessions))
+ for _, session := range sessions {
+ if session == nil {
+ continue
+ }
+ var activity v1.ForegroundActivity
+ if session.State == models.TaskSessionStateRunning {
+ activity = provider.ForegroundActivity(session.ID)
+ }
+ acts = append(acts, sessionActivity{State: session.State, Activity: activity})
+ }
+ dto.ForegroundActivity = aggregateForegroundActivity(acts)
+}
From 24e509f97364edad041197a2d300b699db469b19 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:50:44 +0000
Subject: [PATCH 18/80] feat(task): compute and emit task-level activity
aggregate live
---
.../backend/internal/backendapp/boot_state.go | 12 +-
.../internal/backendapp/orchestrator.go | 5 +
.../foreground_activity_signal_test.go | 64 ++++++++
apps/backend/internal/orchestrator/service.go | 15 ++
.../internal/orchestrator/turn_activity.go | 4 +
.../internal/task/dto/task_activity.go | 60 ++------
apps/backend/internal/task/service/service.go | 11 ++
.../internal/task/service/service_events.go | 13 ++
.../internal/task/service/service_tasks.go | 1 +
.../internal/task/service/task_activity.go | 108 +++++++++++++
.../task/service/task_activity_test.go | 143 ++++++++++++++++++
apps/backend/pkg/api/v1/task.go | 30 ++++
apps/backend/pkg/api/v1/task_test.go | 25 +++
13 files changed, 440 insertions(+), 51 deletions(-)
create mode 100644 apps/backend/internal/task/service/task_activity.go
create mode 100644 apps/backend/internal/task/service/task_activity_test.go
diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go
index f7122d86b6..b78a43ad15 100644
--- a/apps/backend/internal/backendapp/boot_state.go
+++ b/apps/backend/internal/backendapp/boot_state.go
@@ -723,7 +723,7 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []*
sessionCount = &count
}
info := bootSessionInfo(primaryInfoByTask[task.ID])
- result = append(result, taskdto.FromTaskWithSessionInfo(
+ dto := taskdto.FromTaskWithSessionInfo(
task,
primarySessionID,
sessionCount,
@@ -735,7 +735,15 @@ func (b bootStateBuilder) taskDTOsWithSessionInfo(ctx context.Context, tasks []*
info.workingDirectory,
info.sessionState,
bootPendingActionPtr(info.sessionID, pendingActionsBySession),
- ))
+ )
+ // Stamp the task-level MOST-ACTIVE-WINS activity aggregate so the board
+ // card and task list show the background-running affordance on first paint
+ // / in a second tab, without holding the task's full session set client-side
+ // (§spec:task-level-indicator). No-op when no session is running.
+ if b.p.orchestratorSvc != nil {
+ taskdto.EnrichTaskForegroundActivity(&dto, sessions, b.p.orchestratorSvc)
+ }
+ result = append(result, dto)
}
return result
}
diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go
index 56164b3de8..a68a6a91c1 100644
--- a/apps/backend/internal/backendapp/orchestrator.go
+++ b/apps/backend/internal/backendapp/orchestrator.go
@@ -106,6 +106,11 @@ func provideOrchestrator(
// step moves, and the primary-session-set callback below.
orchestratorSvc.SetTaskEventPublisher(taskSvc)
+ // Let the task service read the live per-session busy substate so it can
+ // compute the task-level MOST-ACTIVE-WINS activity aggregate carried on the
+ // boot payload and task.updated events (§spec:task-level-indicator).
+ taskSvc.SetForegroundActivityProvider(orchestratorSvc)
+
// Publish task.updated when the first session is marked primary so the
// frontend receives primary_session_id for newly created tasks.
orchestratorSvc.SetOnPrimarySessionSet(func(ctx context.Context, taskID, _ string) {
diff --git a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
index 2d64dfb3dd..9383281ae5 100644
--- a/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
+++ b/apps/backend/internal/orchestrator/foreground_activity_signal_test.go
@@ -251,3 +251,67 @@ func TestForegroundActivitySignal_DispatchPublishesBackgroundRegisteredDuringCla
t.Fatalf("expected dispatch-time background activity broadcast %v, got %v", want, got)
}
}
+
+// recordingTaskEvents captures task-level publish calls so the test can assert
+// the per-session activity flip is propagated to the task-level aggregate.
+type recordingTaskEvents struct {
+ activityTaskIDs []string
+}
+
+func (r *recordingTaskEvents) PublishTaskUpdated(context.Context, *models.Task) {}
+
+func (r *recordingTaskEvents) PublishTaskStateChanged(context.Context, *models.Task, v1.TaskState) {
+}
+
+func (r *recordingTaskEvents) PublishTaskActivityIfChanged(_ context.Context, taskID string) {
+ r.activityTaskIDs = append(r.activityTaskIDs, taskID)
+}
+
+// TestForegroundActivitySignal_PropagatesToTaskLevel proves each per-session flip
+// also drives the task-level MOST-ACTIVE-WINS recompute so at-a-glance task
+// surfaces (board card, task list) update live (§spec:task-level-indicator).
+func TestForegroundActivitySignal_PropagatesToTaskLevel(t *testing.T) {
+ repo := setupTestRepo(t)
+ svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
+ eb := &recordingEventBus{}
+ svc.eventBus = eb
+ svc.messageCreator = &mockMessageCreator{}
+ taskEvents := &recordingTaskEvents{}
+ svc.SetTaskEventPublisher(taskEvents)
+
+ const (
+ taskID = "task1"
+ sessionID = "session-tasklevel"
+ )
+
+ // Yield to background work, then stream foreground output again: two flips.
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: agentEventToolCall,
+ ToolCallID: "subagent-1",
+ ToolStatus: "running",
+ Normalized: streams.NewSubagentTask("explore", "find files", "general-purpose"),
+ },
+ })
+ svc.handleAgentStreamEvent(context.Background(), &lifecycle.AgentStreamEventPayload{
+ TaskID: taskID,
+ SessionID: sessionID,
+ Data: &lifecycle.AgentStreamEventData{
+ Type: "message_streaming",
+ MessageID: "m1",
+ Text: "still working on it",
+ },
+ })
+
+ want := []string{taskID, taskID}
+ if len(taskEvents.activityTaskIDs) != len(want) {
+ t.Fatalf("expected task-level recompute on each flip %v, got %v", want, taskEvents.activityTaskIDs)
+ }
+ for i := range want {
+ if taskEvents.activityTaskIDs[i] != want[i] {
+ t.Fatalf("task-level recompute %d: got %q, want %q", i, taskEvents.activityTaskIDs[i], want[i])
+ }
+ }
+}
diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go
index e7114cbdff..020c9b8c41 100644
--- a/apps/backend/internal/orchestrator/service.go
+++ b/apps/backend/internal/orchestrator/service.go
@@ -111,6 +111,11 @@ type TurnService interface {
type TaskEventPublisher interface {
PublishTaskUpdated(ctx context.Context, task *models.Task, oldWorkflowIDs ...string)
PublishTaskStateChanged(ctx context.Context, task *models.Task, oldState v1.TaskState)
+ // PublishTaskActivityIfChanged recomputes the task-level MOST-ACTIVE-WINS
+ // activity aggregate and emits task.updated only when its three-state value
+ // changed — including a generating↔background flip that leaves the coarse
+ // state unchanged (§spec:task-level-indicator).
+ PublishTaskActivityIfChanged(ctx context.Context, taskID string)
}
// WorkflowStepGetter retrieves workflow step information for prompt building.
@@ -730,6 +735,16 @@ func (s *Service) publishTaskStateChanged(ctx context.Context, task *models.Task
s.taskEvents.PublishTaskStateChanged(ctx, task, oldState)
}
+// publishTaskActivityIfChanged forwards a per-session activity flip to the task
+// service, which recomputes the task-level aggregate and emits task.updated only
+// when the aggregated value actually changes. No-op when the publisher isn't wired.
+func (s *Service) publishTaskActivityIfChanged(ctx context.Context, taskID string) {
+ if s.taskEvents == nil || taskID == "" {
+ return
+ }
+ s.taskEvents.PublishTaskActivityIfChanged(ctx, taskID)
+}
+
func (s *Service) publishTaskMoved(ctx context.Context, task *models.Task, fromWorkflowID, fromStepID, toStepID, sessionID string) {
if s.eventBus == nil || task == nil {
return
diff --git a/apps/backend/internal/orchestrator/turn_activity.go b/apps/backend/internal/orchestrator/turn_activity.go
index 4244dc2730..0f2a27172c 100644
--- a/apps/backend/internal/orchestrator/turn_activity.go
+++ b/apps/backend/internal/orchestrator/turn_activity.go
@@ -334,6 +334,10 @@ func (s *Service) publishForegroundActivityChanged(ctx context.Context, taskID,
zap.String("session_id", sessionID),
zap.Error(err))
}
+ // Propagate the flip to the task-level aggregate so at-a-glance task surfaces
+ // (board card, task list) update live; emits task.updated only when the
+ // task-level three-state value actually changes (§spec:task-level-indicator).
+ s.publishTaskActivityIfChanged(ctx, taskID)
}
// normalizedIsBackgroundTask reports whether a normalized tool payload represents
diff --git a/apps/backend/internal/task/dto/task_activity.go b/apps/backend/internal/task/dto/task_activity.go
index aa789755b8..21fcd704ae 100644
--- a/apps/backend/internal/task/dto/task_activity.go
+++ b/apps/backend/internal/task/dto/task_activity.go
@@ -5,47 +5,6 @@ import (
v1 "github.com/kandev/kandev/pkg/api/v1"
)
-// sessionActivity is the minimal per-session view the task-level aggregate needs:
-// the coarse session state plus the fine-grained foreground activity resolved
-// for a RUNNING session.
-type sessionActivity struct {
- State models.TaskSessionState
- Activity v1.ForegroundActivity
-}
-
-// aggregateForegroundActivity computes the task-level three-state activity from a
-// task's sessions using MOST-ACTIVE-WINS (§spec:task-level-indicator):
-//
-// - "generating" — any session is generating (the foreground agent is producing
-// output);
-// - "background" — none is generating but at least one RUNNING session is
-// holding a turn open for spawned background work;
-// - "" — no session is running, so task-level surfaces fall through to
-// the coarse task state (done / waiting / failed).
-//
-// Only RUNNING sessions carry a meaningful ForegroundActivity; a non-RUNNING
-// session never contributes a busy substate. A finished primary session therefore
-// does not mask a secondary session that is still working — the intended
-// consequence: a task is not "done" while any of its sessions is still working.
-func aggregateForegroundActivity(sessions []sessionActivity) v1.ForegroundActivity {
- sawBackground := false
- for _, s := range sessions {
- if s.State != models.TaskSessionStateRunning {
- continue
- }
- if s.Activity == v1.ForegroundActivityGenerating {
- return v1.ForegroundActivityGenerating
- }
- if s.Activity == v1.ForegroundActivityBackground {
- sawBackground = true
- }
- }
- if sawBackground {
- return v1.ForegroundActivityBackground
- }
- return ""
-}
-
// EnrichTaskForegroundActivity stamps the task-level MOST-ACTIVE-WINS activity
// aggregate onto a TaskDTO from the task's sessions. It is a no-op for a nil DTO
// or nil provider, so the field is emitted (via omitempty) only where a live
@@ -56,16 +15,19 @@ func EnrichTaskForegroundActivity(dto *TaskDTO, sessions []*models.TaskSession,
if dto == nil || provider == nil {
return
}
- acts := make([]sessionActivity, 0, len(sessions))
+ dto.ForegroundActivity = v1.AggregateForegroundActivity(sessionForegroundActivities(sessions, provider))
+}
+
+// sessionForegroundActivities resolves the foreground activity of each RUNNING
+// session via the provider, leaving non-RUNNING sessions empty (they carry no
+// busy substate). The result feeds v1.AggregateForegroundActivity.
+func sessionForegroundActivities(sessions []*models.TaskSession, provider ForegroundActivityProvider) []v1.ForegroundActivity {
+ activities := make([]v1.ForegroundActivity, 0, len(sessions))
for _, session := range sessions {
- if session == nil {
+ if session == nil || session.State != models.TaskSessionStateRunning {
continue
}
- var activity v1.ForegroundActivity
- if session.State == models.TaskSessionStateRunning {
- activity = provider.ForegroundActivity(session.ID)
- }
- acts = append(acts, sessionActivity{State: session.State, Activity: activity})
+ activities = append(activities, provider.ForegroundActivity(session.ID))
}
- dto.ForegroundActivity = aggregateForegroundActivity(acts)
+ return activities
}
diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go
index dbf2309506..c210d8c9ca 100644
--- a/apps/backend/internal/task/service/service.go
+++ b/apps/backend/internal/task/service/service.go
@@ -14,6 +14,7 @@ import (
"github.com/kandev/kandev/internal/task/repository"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/worktree"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
)
// WorktreeCleanup provides worktree cleanup on task deletion.
@@ -224,6 +225,15 @@ type Service struct {
comments CommentRepository
baseBranchPusher AgentBaseBranchPusher
runtimeOverridesMu sync.Mutex
+ // foregroundActivity resolves the live fine-grained busy substate of a RUNNING
+ // session (satisfied by the orchestrator). Used to compute the task-level
+ // MOST-ACTIVE-WINS activity aggregate carried on task.updated events. Optional.
+ foregroundActivity ForegroundActivityProvider
+ // taskActivityMu guards lastTaskActivity, the last task-level activity aggregate
+ // emitted per task. It bounds live-propagation task.updated emissions to an
+ // actual change of the aggregated three-state value (§spec:live-propagation-fallback).
+ taskActivityMu sync.Mutex
+ lastTaskActivity map[string]v1.ForegroundActivity
// cleanupDoneForTest lets unit tests wait for async cleanup; nil in production.
cleanupDoneForTest chan struct{}
cleanupWorkerMu sync.Mutex
@@ -263,6 +273,7 @@ func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discover
logger: log,
discoveryConfig: discoveryConfig,
branchFetcher: newBranchFetcher(log.Zap()),
+ lastTaskActivity: make(map[string]v1.ForegroundActivity),
}
}
diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go
index acf919eef0..c4dd1d590b 100644
--- a/apps/backend/internal/task/service/service_events.go
+++ b/apps/backend/internal/task/service/service_events.go
@@ -221,6 +221,19 @@ func (s *Service) logTaskLifecycleEventPublished(eventType string, task *models.
// primary executor details into the task event payload. Extracted to keep
// publishTaskEvent under the project's function-length limit.
func (s *Service) addTaskSessionEventFields(ctx context.Context, taskID string, data map[string]interface{}) {
+ // Task-level MOST-ACTIVE-WINS activity aggregate (§spec:task-level-indicator).
+ // Always present (nil when no session is running) so a coarse state change never
+ // leaves a stale background-running reading on the client. Recording it keeps the
+ // live-propagation dedup baseline in step with every task event, whichever path
+ // emitted it.
+ activity := s.computeTaskForegroundActivity(ctx, taskID)
+ s.recordTaskActivity(taskID, activity)
+ if activity != "" {
+ data["foreground_activity"] = string(activity)
+ } else {
+ data["foreground_activity"] = nil
+ }
+
if sessionCountMap, err := s.GetSessionCountsForTasks(ctx, []string{taskID}); err == nil {
if count, ok := sessionCountMap[taskID]; ok {
data["session_count"] = count
diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go
index 688b619822..2f592b14bb 100644
--- a/apps/backend/internal/task/service/service_tasks.go
+++ b/apps/backend/internal/task/service/service_tasks.go
@@ -1531,6 +1531,7 @@ func (s *Service) deleteTaskWithReasonAndDBDelete(
extra = map[string]interface{}{"reason": reason}
}
s.publishTaskEventWithExtra(ctx, events.TaskDeleted, task, nil, extra)
+ s.forgetTaskActivity(id)
s.logger.Info("task deleted",
zap.String("task_id", id),
zap.Duration("duration", time.Since(start)))
diff --git a/apps/backend/internal/task/service/task_activity.go b/apps/backend/internal/task/service/task_activity.go
new file mode 100644
index 0000000000..7706160804
--- /dev/null
+++ b/apps/backend/internal/task/service/task_activity.go
@@ -0,0 +1,108 @@
+package service
+
+import (
+ "context"
+
+ "go.uber.org/zap"
+
+ "github.com/kandev/kandev/internal/task/models"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// ForegroundActivityProvider surfaces the live fine-grained busy substate of a
+// RUNNING session (ADR-0038), satisfied by the orchestrator. The task service
+// depends only on this narrow seam so it takes no hard orchestrator dependency
+// and can be faked in tests.
+type ForegroundActivityProvider interface {
+ ForegroundActivity(sessionID string) v1.ForegroundActivity
+}
+
+// SetForegroundActivityProvider wires the live per-session activity tracker used
+// to compute the task-level MOST-ACTIVE-WINS aggregate. Optional; when unset the
+// aggregate is left empty and task-level surfaces fall through to the coarse
+// task state.
+func (s *Service) SetForegroundActivityProvider(provider ForegroundActivityProvider) {
+ s.foregroundActivity = provider
+}
+
+// computeTaskForegroundActivity resolves the task-level MOST-ACTIVE-WINS activity
+// aggregate for a task from its currently-active sessions
+// (§spec:task-level-indicator). Only RUNNING sessions carry a busy substate, so a
+// finished primary session never masks a secondary session that is still working.
+// Returns "" when no provider is wired or no session is running.
+func (s *Service) computeTaskForegroundActivity(ctx context.Context, taskID string) v1.ForegroundActivity {
+ if s.foregroundActivity == nil {
+ return ""
+ }
+ sessions, err := s.sessions.ListActiveTaskSessionsByTaskID(ctx, taskID)
+ if err != nil {
+ s.logger.Warn("failed to list sessions for task activity aggregate",
+ zap.String("task_id", taskID), zap.Error(err))
+ return ""
+ }
+ activities := make([]v1.ForegroundActivity, 0, len(sessions))
+ for _, session := range sessions {
+ if session == nil || session.State != models.TaskSessionStateRunning {
+ continue
+ }
+ activities = append(activities, s.foregroundActivity.ForegroundActivity(session.ID))
+ }
+ return v1.AggregateForegroundActivity(activities)
+}
+
+// PublishTaskActivityIfChanged recomputes the task-level activity aggregate and
+// emits a task.updated ONLY when the aggregated three-state value differs from the
+// value last carried to the client — including a generating↔background flip that
+// leaves the coarse task/session state unchanged. This bounds the added
+// live-propagation traffic to an actual change of the aggregated value
+// (§spec:live-propagation-fallback tradeoff). Safe to call on every per-session
+// activity flip; it no-ops when the task-level reading is unaffected. The emitted
+// task.updated re-records the value (via addTaskSessionEventFields →
+// recordTaskActivity), keeping the dedup map in step with every task event.
+func (s *Service) PublishTaskActivityIfChanged(ctx context.Context, taskID string) {
+ if taskID == "" || s.foregroundActivity == nil {
+ return
+ }
+ current := s.computeTaskForegroundActivity(ctx, taskID)
+
+ s.taskActivityMu.Lock()
+ previous, seen := s.lastTaskActivity[taskID]
+ s.taskActivityMu.Unlock()
+ if seen && previous == current {
+ return
+ }
+
+ task, err := s.tasks.GetTask(ctx, taskID)
+ if err != nil || task == nil {
+ if err != nil {
+ s.logger.Warn("failed to load task for activity update",
+ zap.String("task_id", taskID), zap.Error(err))
+ }
+ return
+ }
+ s.PublishTaskUpdated(ctx, task)
+}
+
+// recordTaskActivity remembers the aggregate carried on a task event so the next
+// per-session flip can tell whether the task-level reading actually changed. Any
+// task.updated / task.state_changed / task.deleted carries the aggregate, so this
+// keeps the dedup baseline fresh regardless of which path emitted the event.
+func (s *Service) recordTaskActivity(taskID string, activity v1.ForegroundActivity) {
+ if taskID == "" {
+ return
+ }
+ s.taskActivityMu.Lock()
+ s.lastTaskActivity[taskID] = activity
+ s.taskActivityMu.Unlock()
+}
+
+// forgetTaskActivity drops the cached last-emitted aggregate for a task so the
+// dedup map does not grow without bound as tasks are deleted.
+func (s *Service) forgetTaskActivity(taskID string) {
+ if taskID == "" {
+ return
+ }
+ s.taskActivityMu.Lock()
+ delete(s.lastTaskActivity, taskID)
+ s.taskActivityMu.Unlock()
+}
diff --git a/apps/backend/internal/task/service/task_activity_test.go b/apps/backend/internal/task/service/task_activity_test.go
new file mode 100644
index 0000000000..ad0fb5e23d
--- /dev/null
+++ b/apps/backend/internal/task/service/task_activity_test.go
@@ -0,0 +1,143 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/kandev/kandev/internal/task/models"
+ v1 "github.com/kandev/kandev/pkg/api/v1"
+)
+
+// fakeActivityProvider resolves a per-session foreground activity so the
+// task-level aggregate can be exercised with distinct values per session.
+type fakeActivityProvider struct {
+ byID map[string]v1.ForegroundActivity
+}
+
+func (f *fakeActivityProvider) ForegroundActivity(sessionID string) v1.ForegroundActivity {
+ return f.byID[sessionID]
+}
+
+func createRunningSession(t *testing.T, ctx context.Context, repo interface {
+ CreateTaskSession(context.Context, *models.TaskSession) error
+}, id, taskID string, state models.TaskSessionState) {
+ t.Helper()
+ now := time.Now().UTC()
+ if err := repo.CreateTaskSession(ctx, &models.TaskSession{
+ ID: id, TaskID: taskID, State: state, StartedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("CreateTaskSession(%s): %v", id, err)
+ }
+}
+
+func foregroundActivityField(t *testing.T, data map[string]interface{}) interface{} {
+ t.Helper()
+ value, ok := data["foreground_activity"]
+ if !ok {
+ t.Fatalf("foreground_activity missing from payload: %#v", data)
+ }
+ return value
+}
+
+// TestTaskUpdated_StampsForegroundActivityAggregate covers the task.updated
+// payload: it carries the MOST-ACTIVE-WINS aggregate, and emits explicit nil when
+// no session is running so a stale background reading is cleared.
+func TestTaskUpdated_StampsForegroundActivityAggregate(t *testing.T) {
+ svc, eventBus, repo := createTestService(t)
+ ctx := context.Background()
+ createTaskWithoutRepositories(t, ctx, repo)
+ createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
+
+ provider := &fakeActivityProvider{byID: map[string]v1.ForegroundActivity{"s1": v1.ForegroundActivityBackground}}
+ svc.SetForegroundActivityProvider(provider)
+ task := &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1"}
+
+ eventBus.ClearEvents()
+ svc.PublishTaskUpdated(ctx, task)
+ if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "background" {
+ t.Fatalf("running background: foreground_activity=%#v, want \"background\"", got)
+ }
+
+ provider.byID["s1"] = v1.ForegroundActivityGenerating
+ eventBus.ClearEvents()
+ svc.PublishTaskUpdated(ctx, task)
+ if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "generating" {
+ t.Fatalf("running generating: foreground_activity=%#v, want \"generating\"", got)
+ }
+}
+
+func TestTaskUpdated_ForegroundActivityNilWhenNoRunningSession(t *testing.T) {
+ svc, eventBus, repo := createTestService(t)
+ ctx := context.Background()
+ createTaskWithoutRepositories(t, ctx, repo)
+ createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateWaitingForInput)
+
+ svc.SetForegroundActivityProvider(&fakeActivityProvider{byID: map[string]v1.ForegroundActivity{"s1": v1.ForegroundActivityBackground}})
+
+ eventBus.ClearEvents()
+ svc.PublishTaskUpdated(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1"})
+ if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != nil {
+ t.Fatalf("no running session: foreground_activity=%#v, want nil", got)
+ }
+}
+
+// TestPublishTaskActivityIfChanged_EmitsOnlyOnAggregateChange is the core
+// live-propagation-fallback behavior: a generating↔background flip emits
+// task.updated only when the MOST-ACTIVE-WINS reading actually changes.
+func TestPublishTaskActivityIfChanged_EmitsOnlyOnAggregateChange(t *testing.T) {
+ svc, eventBus, repo := createTestService(t)
+ ctx := context.Background()
+ createTaskWithoutRepositories(t, ctx, repo)
+ createRunningSession(t, ctx, repo, "s1", "task-1", models.TaskSessionStateRunning)
+ createRunningSession(t, ctx, repo, "s2", "task-1", models.TaskSessionStateRunning)
+
+ provider := &fakeActivityProvider{byID: map[string]v1.ForegroundActivity{
+ "s1": v1.ForegroundActivityGenerating,
+ "s2": v1.ForegroundActivityGenerating,
+ }}
+ svc.SetForegroundActivityProvider(provider)
+
+ // First observation: unseen → generating, emits once.
+ eventBus.ClearEvents()
+ svc.PublishTaskActivityIfChanged(ctx, "task-1")
+ if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "generating" {
+ t.Fatalf("first flip: foreground_activity=%#v, want \"generating\"", got)
+ }
+
+ // One session flips to background but the other still generates: aggregate
+ // stays generating → no emit.
+ provider.byID["s1"] = v1.ForegroundActivityBackground
+ eventBus.ClearEvents()
+ svc.PublishTaskActivityIfChanged(ctx, "task-1")
+ if events := eventBus.GetPublishedEvents(); len(events) != 0 {
+ t.Fatalf("aggregate unchanged (still generating) must not emit, got %d events", len(events))
+ }
+
+ // The last generating session flips to background: aggregate now background → emit.
+ provider.byID["s2"] = v1.ForegroundActivityBackground
+ eventBus.ClearEvents()
+ svc.PublishTaskActivityIfChanged(ctx, "task-1")
+ if got := foregroundActivityField(t, singlePublishedEventData(t, eventBus)); got != "background" {
+ t.Fatalf("flip to background: foreground_activity=%#v, want \"background\"", got)
+ }
+
+ // Idempotent: calling again with no change does not re-emit.
+ eventBus.ClearEvents()
+ svc.PublishTaskActivityIfChanged(ctx, "task-1")
+ if events := eventBus.GetPublishedEvents(); len(events) != 0 {
+ t.Fatalf("no change must not re-emit, got %d events", len(events))
+ }
+}
+
+func TestPublishTaskActivityIfChanged_NoProviderIsNoOp(t *testing.T) {
+ svc, eventBus, repo := createTestService(t)
+ ctx := context.Background()
+ createTaskWithoutRepositories(t, ctx, repo)
+
+ eventBus.ClearEvents()
+ svc.PublishTaskActivityIfChanged(ctx, "task-1")
+ if events := eventBus.GetPublishedEvents(); len(events) != 0 {
+ t.Fatalf("no provider wired must not emit, got %d events", len(events))
+ }
+}
diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go
index 0d80301cf9..8e7e8f2d7c 100644
--- a/apps/backend/pkg/api/v1/task.go
+++ b/apps/backend/pkg/api/v1/task.go
@@ -61,6 +61,36 @@ const (
ForegroundActivityBackground ForegroundActivity = "background"
)
+// AggregateForegroundActivity reduces the per-session foreground activities of a
+// task's RUNNING sessions to a single task-level value using MOST-ACTIVE-WINS
+// (§spec:task-level-indicator):
+//
+// - ForegroundActivityGenerating — any session is generating;
+// - ForegroundActivityBackground — none is generating but at least one is
+// holding a turn open for background work;
+// - "" — neither, so task-level surfaces fall through
+// to the coarse task state (done / waiting / failed).
+//
+// Callers pass only the activities of RUNNING sessions (a non-RUNNING session
+// carries no busy substate); empty values are ignored, so passing "" for a
+// non-RUNNING session is harmless. The background tier is inserted BETWEEN
+// generating and done and does not redefine the other states.
+func AggregateForegroundActivity(activities []ForegroundActivity) ForegroundActivity {
+ sawBackground := false
+ for _, activity := range activities {
+ switch activity {
+ case ForegroundActivityGenerating:
+ return ForegroundActivityGenerating
+ case ForegroundActivityBackground:
+ sawBackground = true
+ }
+ }
+ if sawBackground {
+ return ForegroundActivityBackground
+ }
+ return ""
+}
+
// MessageType represents a normalized session message type.
type MessageType string
diff --git a/apps/backend/pkg/api/v1/task_test.go b/apps/backend/pkg/api/v1/task_test.go
index 481274d131..ec24a0ffbc 100644
--- a/apps/backend/pkg/api/v1/task_test.go
+++ b/apps/backend/pkg/api/v1/task_test.go
@@ -23,3 +23,28 @@ func TestMessageAttachmentHasValidDeliveryMode(t *testing.T) {
})
}
}
+
+func TestAggregateForegroundActivity(t *testing.T) {
+ gen := ForegroundActivityGenerating
+ bg := ForegroundActivityBackground
+ tests := []struct {
+ name string
+ activities []ForegroundActivity
+ want ForegroundActivity
+ }{
+ {name: "empty is empty", activities: nil, want: ""},
+ {name: "any generating wins over background", activities: []ForegroundActivity{bg, gen}, want: gen},
+ {name: "all background is background", activities: []ForegroundActivity{bg, bg}, want: bg},
+ {name: "single background", activities: []ForegroundActivity{bg}, want: bg},
+ {name: "single generating", activities: []ForegroundActivity{gen}, want: gen},
+ {name: "empty values are ignored", activities: []ForegroundActivity{"", bg, ""}, want: bg},
+ {name: "only empty values fall through", activities: []ForegroundActivity{"", ""}, want: ""},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := AggregateForegroundActivity(tc.activities); got != tc.want {
+ t.Fatalf("got %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
From 659f2abe22017e4e417308e463bdf98f87e6369a Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:57:36 +0000
Subject: [PATCH 19/80] feat(web): extend getTaskStateIcon with task-level
background affordance
---
apps/web/lib/kanban/map-task.ts | 9 +++++-
apps/web/lib/ssr/mapper.ts | 1 +
apps/web/lib/state/slices/kanban/types.ts | 12 ++++++-
apps/web/lib/types/backend.ts | 3 ++
apps/web/lib/types/http.ts | 10 ++++++
apps/web/lib/ui/state-icons.test.tsx | 37 ++++++++++++++++++++++
apps/web/lib/ui/state-icons.tsx | 38 +++++++++++++++++++++--
apps/web/lib/ws/handlers/kanban.ts | 5 +++
apps/web/lib/ws/handlers/tasks.ts | 9 ++++++
9 files changed, 120 insertions(+), 4 deletions(-)
diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts
index 119b6162e3..8dc774c293 100644
--- a/apps/web/lib/kanban/map-task.ts
+++ b/apps/web/lib/kanban/map-task.ts
@@ -4,7 +4,12 @@ import {
issueFieldsFromMetadata,
} from "@/lib/metadata-utils";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
-import type { TaskPendingAction, TaskState, TaskSessionState } from "@/lib/types/http";
+import type {
+ ForegroundActivity,
+ TaskPendingAction,
+ TaskState,
+ TaskSessionState,
+} from "@/lib/types/http";
type KanbanTask = KanbanState["tasks"][number];
@@ -36,6 +41,7 @@ export type TaskLike = {
primary_session_id?: string | null;
primary_session_state?: TaskSessionState | string | null;
primary_session_pending_action?: TaskPendingAction | null;
+ foreground_activity?: ForegroundActivity | null;
session_count?: number | null;
review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null;
primary_executor_id?: string | null;
@@ -109,6 +115,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask {
primarySessionId: source.primary_session_id ?? undefined,
primarySessionState: source.primary_session_state ?? undefined,
primarySessionPendingAction: pickPendingAction(source.primary_session_pending_action),
+ foregroundActivity: source.foreground_activity ?? undefined,
sessionCount: source.session_count ?? undefined,
reviewStatus: source.review_status ?? undefined,
primaryExecutorId: source.primary_executor_id ?? undefined,
diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts
index e6f82449bd..3374258302 100644
--- a/apps/web/lib/ssr/mapper.ts
+++ b/apps/web/lib/ssr/mapper.ts
@@ -47,6 +47,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial {
primarySessionId: task.primary_session_id ?? undefined,
primarySessionState: task.primary_session_state ?? undefined,
primarySessionPendingAction: pickPendingAction(task.primary_session_pending_action),
+ foregroundActivity: task.foreground_activity ?? undefined,
sessionCount: task.session_count ?? undefined,
reviewStatus: task.review_status ?? undefined,
parentTaskId: task.parent_id ?? undefined,
diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts
index 1782e1290c..9434de01f8 100644
--- a/apps/web/lib/state/slices/kanban/types.ts
+++ b/apps/web/lib/state/slices/kanban/types.ts
@@ -1,4 +1,8 @@
-import type { TaskPendingAction, TaskState as TaskStatus } from "@/lib/types/http";
+import type {
+ ForegroundActivity,
+ TaskPendingAction,
+ TaskState as TaskStatus,
+} from "@/lib/types/http";
export type KanbanStepEvents = {
on_enter?: Array<{ type: string; config?: Record }>;
@@ -62,6 +66,12 @@ export type KanbanState = {
primarySessionId?: string | null;
primarySessionState?: string | null;
primarySessionPendingAction?: TaskPendingAction | null;
+ /**
+ * Task-level MOST-ACTIVE-WINS activity aggregate (§spec:task-level-indicator);
+ * undefined/null when no session is running. Drives the board card and task
+ * list background-running affordance.
+ */
+ foregroundActivity?: ForegroundActivity | null;
sessionCount?: number | null;
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
primaryExecutorId?: string | null;
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index 18e808012a..8781c644bf 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -90,6 +90,9 @@ export type TaskEventPayload = {
primary_session_id?: string | null;
primary_session_state?: TaskSessionState | null;
primary_session_pending_action?: TaskPendingAction | null;
+ // Task-level MOST-ACTIVE-WINS activity aggregate across the task's sessions
+ // (§spec:task-level-indicator); absent/null when no session is running.
+ foreground_activity?: ForegroundActivity | null;
session_count?: number | null;
review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null;
archived_at?: string | null;
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index 2aa01499d3..656e30f4af 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -317,6 +317,16 @@ export type Task = {
primary_session_id?: SessionId | null;
primary_session_state?: TaskSessionState | null;
primary_session_pending_action?: TaskPendingAction | null;
+ /**
+ * Task-level MOST-ACTIVE-WINS activity aggregate across the task's sessions
+ * (§spec:task-level-indicator): "generating" when any session is generating,
+ * "background" when none is generating but at least one RUNNING session holds a
+ * turn open for background work, and absent/`null` when no session is running
+ * (task-level surfaces then fall through to the coarse task state). Computed on
+ * the backend and carried on the task record so every task-level surface reads
+ * one authoritative value.
+ */
+ foreground_activity?: ForegroundActivity | null;
session_count?: number | null;
review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null;
primary_executor_id?: string | null;
diff --git a/apps/web/lib/ui/state-icons.test.tsx b/apps/web/lib/ui/state-icons.test.tsx
index 30d583ffcb..df5f2ae54b 100644
--- a/apps/web/lib/ui/state-icons.test.tsx
+++ b/apps/web/lib/ui/state-icons.test.tsx
@@ -4,6 +4,7 @@ import {
IconCheck,
IconCircleCheck,
IconCircleFilled,
+ IconLoader,
IconLoader2,
IconMessageQuestion,
} from "@tabler/icons-react";
@@ -33,6 +34,42 @@ describe("getTaskStateIcon", () => {
});
});
+describe("getTaskStateIcon — task-level activity tri-state", () => {
+ // (a) generating → the established running spinner (IconLoader2)
+ // (b) background → a distinct spinner (IconLoader), NEVER the done check
+ // (c) done → the coarse check (IconCheck)
+ it("(a) generating shows the running spinner even when the coarse state is done", () => {
+ // Most-active-wins: a generating session outranks a finished primary that
+ // would otherwise render the done check.
+ expect(iconType(getTaskStateIcon("COMPLETED", undefined, false, "generating"))).toBe(
+ IconLoader2,
+ );
+ });
+
+ it("(b) background shows a working spinner — never the done check — over a done coarse state", () => {
+ const bg = getTaskStateIcon("COMPLETED", undefined, false, "background");
+ expect(iconType(bg)).toBe(IconLoader);
+ expect(iconType(bg)).not.toBe(IconCheck);
+ });
+
+ it("(c) falls through to the coarse task state when no session is active", () => {
+ expect(iconType(getTaskStateIcon("COMPLETED", undefined, false, null))).toBe(IconCheck);
+ expect(iconType(getTaskStateIcon("COMPLETED", undefined, false, undefined))).toBe(IconCheck);
+ });
+
+ it("distinguishes background from BOTH generating and done by icon SHAPE, not hue alone", () => {
+ // Icon TYPE (glyph) differs for all three, so the reading survives a
+ // grayscale/desaturated scan for color-vision-deficient operators
+ // (§req:not-color-alone).
+ const generating = iconType(getTaskStateIcon("IN_PROGRESS", undefined, false, "generating"));
+ const background = iconType(getTaskStateIcon("IN_PROGRESS", undefined, false, "background"));
+ const done = iconType(getTaskStateIcon("COMPLETED", undefined, false, null));
+ expect(background).not.toBe(generating);
+ expect(background).not.toBe(done);
+ expect(generating).not.toBe(done);
+ });
+});
+
describe("getSessionStateIcon — fine-grained busy tri-state", () => {
// ADR-0043. Three distinguishable conditions:
// (a) RUNNING + generating → the established static "running" dot (unchanged)
diff --git a/apps/web/lib/ui/state-icons.tsx b/apps/web/lib/ui/state-icons.tsx
index 3b655b3192..bc3873b4c7 100644
--- a/apps/web/lib/ui/state-icons.tsx
+++ b/apps/web/lib/ui/state-icons.tsx
@@ -6,6 +6,7 @@ import {
IconCircleCheck,
IconCircleFilled,
IconClock,
+ IconLoader,
IconLoader2,
IconMessageQuestion,
IconPlayerPause,
@@ -73,6 +74,27 @@ const SESSION_BACKGROUND_ICON: IconConfig = {
className: "text-emerald-500 animate-spin",
};
+// The task-level generating affordance — the established running spinner
+// (IconLoader2, smooth arc). Rendered when the task-level MOST-ACTIVE-WINS
+// aggregate is "generating"; kept identical to the existing card spinner so the
+// generating look is unchanged (§spec:task-level-indicator).
+const TASK_GENERATING_ICON: IconConfig = {
+ Icon: IconLoader2,
+ className: STYLE_LOADING,
+};
+
+// The task-level background-running affordance (§spec:task-level-indicator):
+// spawned background work is running while the foreground turns are idle. It is a
+// segmented spinner (IconLoader) — distinct from BOTH the generating spinner
+// (IconLoader2, a smooth arc) by SHAPE and from the done check (IconCheck) by
+// shape AND motion — so the three read apart in a grayscale/desaturated scan, not
+// by hue alone (§req:not-color-alone). The motion (a spinner) reads as "still
+// working" while never being mistaken for the done check.
+const TASK_BACKGROUND_ICON: IconConfig = {
+ Icon: IconLoader,
+ className: STYLE_LOADING,
+};
+
const DEFAULT_TASK_ICON: IconConfig = {
Icon: IconAlertCircle,
className: STYLE_MUTED,
@@ -140,7 +162,18 @@ export function shouldUsePermissionTaskIcon(hasPendingPermission = false): boole
return hasPendingPermission;
}
-function getTaskStateIconConfig(state?: TaskState, hasPendingClarification = false): IconConfig {
+function getTaskStateIconConfig(
+ state?: TaskState,
+ hasPendingClarification = false,
+ foregroundActivity?: ForegroundActivity | null,
+): IconConfig {
+ // The task-level MOST-ACTIVE-WINS aggregate sits ABOVE the coarse task state
+ // (§spec:task-level-indicator): a task whose foreground turns are idle while
+ // spawned background work runs reads as background-running, never as done; and a
+ // task with any generating session reads as generating even if its coarse state
+ // (e.g. a finished primary session) would otherwise render done.
+ if (foregroundActivity === "background") return TASK_BACKGROUND_ICON;
+ if (foregroundActivity === "generating") return TASK_GENERATING_ICON;
if (shouldUseQuestionTaskIcon(state, hasPendingClarification)) {
return TASK_STATE_ICONS.WAITING_FOR_INPUT;
}
@@ -152,8 +185,9 @@ export function getTaskStateIcon(
state?: TaskState,
className?: string,
hasPendingClarification = false,
+ foregroundActivity?: ForegroundActivity | null,
) {
- const config = getTaskStateIconConfig(state, hasPendingClarification);
+ const config = getTaskStateIconConfig(state, hasPendingClarification, foregroundActivity);
return ;
}
diff --git a/apps/web/lib/ws/handlers/kanban.ts b/apps/web/lib/ws/handlers/kanban.ts
index 2effddfdcc..14a00cc2c6 100644
--- a/apps/web/lib/ws/handlers/kanban.ts
+++ b/apps/web/lib/ws/handlers/kanban.ts
@@ -61,6 +61,7 @@ export function registerKanbanHandlers(store: StoreApi): WsHandlers {
primarySessionId: existing?.primarySessionId,
primarySessionState: existing?.primarySessionState,
primarySessionPendingAction: existing?.primarySessionPendingAction,
+ foregroundActivity: existing?.foregroundActivity,
};
});
@@ -93,6 +94,10 @@ export function registerKanbanHandlers(store: StoreApi): WsHandlers {
t.primarySessionPendingAction === undefined
? fallback?.primarySessionPendingAction
: t.primarySessionPendingAction,
+ foregroundActivity:
+ t.foregroundActivity === undefined
+ ? fallback?.foregroundActivity
+ : t.foregroundActivity,
};
});
return {
diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts
index 13b02c956e..f95948e7c5 100644
--- a/apps/web/lib/ws/handlers/tasks.ts
+++ b/apps/web/lib/ws/handlers/tasks.ts
@@ -48,6 +48,15 @@ function mergeTaskUpdate(
) {
merged.primarySessionPendingAction = existing.primarySessionPendingAction;
}
+ // Preserve the task-level activity aggregate only when the event omits it
+ // entirely (e.g. a lightweight kanban.update). A task.updated that carries an
+ // explicit null clears a stale background-running reading, so it must win.
+ if (
+ !hasPayloadField(payload, "foreground_activity") &&
+ nextTask.foregroundActivity === undefined
+ ) {
+ merged.foregroundActivity = existing.foregroundActivity;
+ }
return merged;
}
From 952c0216a08f307fa12b8797d76c4b4250655feb Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 09:01:35 +0000
Subject: [PATCH 20/80] feat(web): show task-level background-running
affordance on the kanban card
---
apps/web/components/kanban-card-content.tsx | 23 ++++++++++++++++-----
apps/web/components/kanban-card.tsx | 7 +++++++
2 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx
index 4395c0c914..b49c0aa213 100644
--- a/apps/web/components/kanban-card-content.tsx
+++ b/apps/web/components/kanban-card-content.tsx
@@ -211,6 +211,23 @@ function KanbanCardBadges({ task }: { task: Task }) {
);
}
+// renderTaskStatusIcon resolves the card status icon with the backend task-level
+// MOST-ACTIVE-WINS aggregate taking precedence (§spec:task-level-indicator): a
+// background-running task shows the distinct background affordance rather than the
+// primary-session spinner or a done check, and any generating session keeps the
+// spinner. When the aggregate is absent it falls back to the primary-session-driven
+// spinner (covers STARTING/SCHEDULING before a session reads RUNNING).
+function renderTaskStatusIcon(
+ task: Task,
+ showRunningSpinner: boolean,
+ hasPendingClarification: boolean,
+) {
+ if (showRunningSpinner && task.foregroundActivity !== "background") {
+ return ;
+ }
+ return getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarification, task.foregroundActivity);
+}
+
function KanbanCardActions({
task,
showMaximizeButton,
@@ -238,11 +255,7 @@ function KanbanCardActions({
showRunningSpinner &&
storeWouldShowRunningSpinner === false &&
task.primarySessionState !== storePrimarySessionState;
- const statusIcon = showRunningSpinner ? (
-
- ) : (
- getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest)
- );
+ const statusIcon = renderTaskStatusIcon(task, showRunningSpinner, hasPendingClarificationRequest);
const hasKnownSession =
Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0);
diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx
index 6b07b7f27d..6166618da9 100644
--- a/apps/web/components/kanban-card.tsx
+++ b/apps/web/components/kanban-card.tsx
@@ -27,6 +27,7 @@ import { repositorySlug } from "@/lib/repository-slug";
import { formatUserHomePath } from "@/lib/utils";
import {
repositoryId as toRepositoryId,
+ type ForegroundActivity,
type Repository,
type TaskPendingAction,
type TaskState,
@@ -53,6 +54,12 @@ export interface Task {
*/
primarySessionState?: string | null;
primarySessionPendingAction?: TaskPendingAction | null;
+ /**
+ * Task-level MOST-ACTIVE-WINS activity aggregate (§spec:task-level-indicator);
+ * undefined/null when no session is running. Drives the background-running
+ * affordance on the card status icon.
+ */
+ foregroundActivity?: ForegroundActivity | null;
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
primaryExecutorId?: string | null;
primaryExecutorType?: string | null;
From 0ab757f9427697722fe29c4d3aaf31ebd2c7d7ad Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 09:01:37 +0000
Subject: [PATCH 21/80] feat(web): show task-level background-running
affordance on task list rows
---
apps/web/app/tasks/tasks-list-view.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/app/tasks/tasks-list-view.tsx b/apps/web/app/tasks/tasks-list-view.tsx
index 94da8cda2d..8cca0a1b5a 100644
--- a/apps/web/app/tasks/tasks-list-view.tsx
+++ b/apps/web/app/tasks/tasks-list-view.tsx
@@ -400,7 +400,7 @@ function TaskListRow({
data-testid="tasks-list-row-content"
style={{ paddingLeft: `${level * 28}px` }}
>
- {getTaskStateIcon(task.state, "h-4 w-4 shrink-0")}
+ {getTaskStateIcon(task.state, "h-4 w-4 shrink-0", false, task.foreground_activity)}
{task.title}
From a5f4f7d26f985061c4b25dff9aa565b2d8c1b27d Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 09:16:25 +0000
Subject: [PATCH 22/80] docs(spec): mark task-level-indicator in progress
(backend + 2 surfaces)
---
.../spec.md | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/docs/specs/fine-grained-background-running-status-indicator/spec.md b/docs/specs/fine-grained-background-running-status-indicator/spec.md
index 467f36417d..455ec63db5 100644
--- a/docs/specs/fine-grained-background-running-status-indicator/spec.md
+++ b/docs/specs/fine-grained-background-running-status-indicator/spec.md
@@ -90,7 +90,13 @@ survives a grayscale/desaturated view.
## Task-level indicator reflects live background work §spec:task-level-indicator
-*Status: not started*
+*Status: in progress*
+
+
+
On every at-a-glance task surface — the board / kanban card, the task list rows,
the graph / swimlane nodes, and the open-task header — a task whose foreground
From 9536d0a9dd06d9ff43f6424ba60330b516039454 Mon Sep 17 00:00:00 2001
From: point-source <47544779+point-source@users.noreply.github.com>
Date: Sat, 18 Jul 2026 09:20:32 +0000
Subject: [PATCH 23/80] fix(web): show task-level background affordance when
primary session is done
---
apps/web/components/kanban-card-content.tsx | 26 ++++++---
.../kanban-card-status-icon.test.tsx | 55 +++++++++++++++++++
2 files changed, 72 insertions(+), 9 deletions(-)
create mode 100644 apps/web/components/kanban-card-status-icon.test.tsx
diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx
index b49c0aa213..9fdc483f1f 100644
--- a/apps/web/components/kanban-card-content.tsx
+++ b/apps/web/components/kanban-card-content.tsx
@@ -211,17 +211,26 @@ function KanbanCardBadges({ task }: { task: Task }) {
);
}
-// renderTaskStatusIcon resolves the card status icon with the backend task-level
-// MOST-ACTIVE-WINS aggregate taking precedence (§spec:task-level-indicator): a
-// background-running task shows the distinct background affordance rather than the
-// primary-session spinner or a done check, and any generating session keeps the
-// spinner. When the aggregate is absent it falls back to the primary-session-driven
-// spinner (covers STARTING/SCHEDULING before a session reads RUNNING).
-function renderTaskStatusIcon(
+// renderTaskStatusIcon resolves the card status icon, or null when the actions
+// cluster shows none (a resting done/todo task). The backend task-level
+// MOST-ACTIVE-WINS aggregate takes precedence (§spec:task-level-indicator): a
+// background-running task shows the distinct background affordance — even when its
+// primary session has finished and only a secondary session is still working, so
+// it reads as working, not done — while any generating session keeps the spinner.
+// When the aggregate is absent it falls back to the primary-session-driven spinner
+// (covers STARTING/SCHEDULING before a session reads RUNNING) or the pending-input
+// question icon.
+export function renderTaskStatusIcon(
task: Task,
showRunningSpinner: boolean,
hasPendingClarification: boolean,
) {
+ const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarification);
+ const hasActivity =
+ task.foregroundActivity === "generating" || task.foregroundActivity === "background";
+ if (!showRunningSpinner && !showQuestionIcon && !hasActivity) {
+ return null;
+ }
if (showRunningSpinner && task.foregroundActivity !== "background") {
return ;
}
@@ -245,7 +254,6 @@ function KanbanCardActions({
primarySessionState: task.primarySessionState,
primarySessionPendingAction: task.primarySessionPendingAction,
});
- const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest);
const showRunningSpinner = shouldShowTaskRunningSpinner(task.state, task.primarySessionState);
const storeWouldShowRunningSpinner =
storePrimarySessionState === null
@@ -300,7 +308,7 @@ function KanbanCardActions({
return (