M2-51: Run graph data and stall detection¶
Status: ready | Depends on: M2-35 | Milestone: M2
Goal¶
After this ticket the run document carries everything the run graph and the control room need (09 5.6, design note), and the stall notice "no movement since ..." is a server fact. The Claude Code runner links every task step (subagent, workflow, background_job) to the step of the tool call that started it (tool_use_id), maps a workflow task to kind workflow once the recording of the pinned version names its task_type, and copies the usage of a task into the step detail member usage; it turns tool_progress into a patch of /metrics/progress_events at most once per runner.progress_patch_interval. The server keeps one summary step per parent when it merges old steps (09 5.3), stores runs.last_progress_at on every start and stored patch, and runs.stall_check sets stall_warned_at for a running run without progress for run.stall_after, with a version step and a run.updated; stall_warned_at is a member of the API document Run and of RunSummary. Why: the owner wants to see what a run and the agents it started do right now (2026-10-24); a tree needs stable parent references from the only party that knows them, a merge of old steps must not flatten it, and "stalled" must not be guessed by a client clock (D-RV-3 to D-RV-6, D-RV-9).
Read first¶
docs/design/run-visualization.md(all),docs/decisions.d/run-visualization.md(D-RV-1toD-RV-12).docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" and the rows M2-09, M2-10, M2-34, M2-35 (what exists) and M2-51 (this ticket); section 10 row M4-39 (who builds on it).- Dependencies: the header names M2-35. Through it this ticket builds on M2-34, M2-29, M2-12, M2-10, M2-09, M2-03 and everything below them (all implied, so not in the header).
docs/spec/09-agents.md: 5.3 (path table, rules ofpatchRun, the summary step and its addendum of 2026-10-24), 5.4, 5.5 (the row "No progress" and the paragraph of 2026-10-24), 5.6 (all), 18.4 (rowssystemtask_*andtool_progress, the step title paragraph), 19 (R-AG-26, R-AG-27), 21 (runner.progress_patch_interval,run.stall_afterthrough 03).docs/spec/03-data-model.md(core contract, read only):runs(last_progress_at,stall_warned_at,metricswithprogress_events),run_steps(parent_step_id,detail, the summary rule), R-DM-53; 9 (run.max_steps_persisted,run.stall_after).docs/spec/04-sync-protocol.md(core contract, read only): 6.2 (opfocus, memberbackground_thread_ids), 11.1 (memberstall_warned_at), 11.3 items 2 to 7, R-SY-27;docs/spec/13-push-notifications.md4.4;docs/spec/03-data-model.mdgateway_presence.docs/spec/02-architecture.md: 12.3 rowruns.stall_check.docs/api/openapi.yaml: schemasRun,RunSummary,RunStep,RunMetrics,RunPatch,JsonPatchOperation; operationsgetRun,listRunSteps,listActiveRuns,patchRun.docs/decisions.d/M2-09.md(D-M2-09-19 to D-M2-09-26:runs.stall_check),docs/decisions.d/M2-10.md(D-M2-10-2 clearingstall_warned_at, D-M2-10-5 normalized ops, D-M2-10-7 the summary step),docs/decisions.d/M2-34.mdandM2-35.mdwhere present.- Briefs:
docs/briefs/M2-09-run-lifecycle.md(note 10,runs.stall_check),M2-10-run-document-live-box.md(patch use case, summary step, normalized ops),M2-34-claude-code-runner-core.md(stream reader, live box mapping),M2-35-claude-code-runner-paths.md(Fake-Claude scenarios); merged code wins on names. - Code on the base branch:
services/server/internal/gateway(focus handling,run.updatedfan-out and presence of M3-23 and M7-17, both merged),services/server/internal/runs(patch, summary,runs.stall_check,listActiveRuns),services/server/migrations(latestrunsmigrations),plugins/claude-code-runner/src/claude_code_runner/(streamreader.py,livebox.py,usage.py) and itstests/fixtures,sdk/python(generated models).
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) already holds this ticket's changes and is not edited by it.
Product requirements implemented here:
- A task step whose
task_*message carries atool_use_idof a tool call step of the same run has that step asparent_step_id; otherwise it has no parent. A tool call keeps its parent fromparent_tool_use_id(09 18.4, D-RV-3). - A task whose
task_typeis the workflow type recorded for the pinned version is a step of kindworkflow; without a recorded value no step isworkflow(09 18.4, D-RV-4). - The usage of a task (
total_tokens,tool_uses,duration_ms), where the stream reports it, becomesdetail.usage(tokens,tool_uses,duration_ms); the runner never setscost_microsfrom an estimate (09 5.6, D-RV-5). tool_progressleads toreplace /metrics/progress_eventsonly when the last stored patch of the run is older thanrunner.progress_patch_interval(09 18.4).- Above
run.max_steps_persisteda removed step is counted into the summary step of its own parent; a new summary step carries that parent; running steps are never removed, and a parent only once its only child is its own summary step, which is then removed with it and counted into the summary of the parent's parent; old summary steps stay as they are (09 5.3, R-DM-53). runs.last_progress_atis set to the server time by every successfulrun.startand every stored patch;runs.stall_checkmarks arunningrun whoselast_progress_atis older thanrun.stall_afterand whosestall_warned_atis empty, with one version step andrun.updatedadd /stall_warned_at; the next stored patch carriesremove /stall_warned_at; a run inwaiting_inputis never marked (09 5.5, 04 11.3 item 7).- The op
focusaccepts the optional memberbackground_thread_ids: unreadable threads are silently ignored as inthread_ids; both lists together hold at mostsync.max_focused_threadsdistinct threads (more answervalidation_failedand keep the previous focus); a background thread gets the whole document once andrun.updatedlike a focused thread, no typing, and is never written togateway_presence.focus_thread_ids, so it never counts as "Active in the thread" (04 6.2, 11.3, 13 4.4, R-SY-27). RunandRunSummarycarrystall_warned_at;RunMetricscarriesprogress_events;listActiveRunsreturnsstall_warned_atof every active run.- No new action, event, op or receive rule; the only protocol addition is the optional
focusmember of requirement 17 (D-RV-8, D-RV-10, R-SY-27).
Scope¶
Server (services/server/):
- Migration
runs_last_progress_at(next free number): columnruns.last_progress_at timestamptz NULL, no backfill (an active run without a value is marked only after its next start or patch),make migrations-lock. internal/runs:last_progress_atin the start and patch writes (the sameUPDATEthat raisesversionand clearsstall_warned_at); normalized opremove /stall_warned_atwhen a patch clears it; summary step per parent in the summary use case; the stall marking in theruns.stall_checkhandler (one unit of work per marked run, compare-and-swap onversion);stall_warned_atin the document and summary encoders;progress_eventsaccepted as a metric.internal/gateway(names of the merged code win): thefocusmemberbackground_thread_idsof requirement 17 in the focus handling, therun.updatedfan-out and the initial document; presence and typing keep usingthread_idsonly.docs/api/openapi.yaml:GatewayFocus.background_thread_ids(array ofUuid, optional, description as 04 6.2),Run.stall_warned_at,RunSummary.stall_warned_at(bothTimestamp, optional),RunMetrics.progress_events(integer, optional), the description ofRunStep.detailnamingusage; regenerate every generated client and server part (each component'sgeneratetarget;make codegen-checkandmake api-clientsprove it).docs/spec/02-architecture.md12.3 rowruns.stall_checkalready names the stall marking; keep it true to the built job.
Runner (plugins/claude-code-runner/):
- Stream mapping in
streamreader.pyandlivebox.py(names of the merged code win): the map from tool use ID to step ID also serves task messages; kindworkflow;detail.usage; the progress patch withrunner.progress_patch_intervalinconfig.pyandconfig.example.toml. - A recording of the pinned Claude Code version (Design note 1) under
tests/fixtures/recording/and Fake-Claude fixtures for every case of the Tests. - README (stream mapping table) and
changelog.d/M2-51.md.
Fragments: docs/decisions.d/M2-51.md with rows D-M2-51-<n>; changelog.d/M2-51.md at the root for the server and plugins/claude-code-runner/changelog.d/M2-51.md; regenerate docs/operations/configuration.md section 4 if the configuration key table changed (make config-reference).
Out of scope¶
- Drawing the graph and the control room (M4-39, M5-14); the derivation function of 09 5.6 lives in
bothy_viz(M4-39). - A Hermes signal for subagents (the pinned Hermes version reports none, 09 17.3).
- Costs per node from an estimate, a graph across runs, a replay slider (design note section 5).
- Any change to permissions, actions, events, gateway ops or the focus limit, beyond the optional
focusmember of requirement 17. - Changes to the core contract (already made by the design change).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-51-run-graph-data; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(runs): ...,feat(claude-code-runner): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the README, fragment and generated files named in this brief.
- Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; database tests use one database per test frompgtest; time comes from the injected clock, never from a fixed sleep. - No real Claude Code session, no login and no model call in tests: the runtime is Fake-Claude. The pinned CLI runs only for the recording of Design note 1, behind the explicit switch of M2-35, without login and without a model call; if a recording needs either, do not record and say so under "What is not proven".
- Never read or print secrets. Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M2 closed, in the manner of D-W-100. Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
- Recording first. Before the mapping code, read the Agent SDK reference cited in 09 18.4 and, behind the switch, record the stream of the pinned version for a turn that starts a subagent, a background command and, if the version offers one without a login, a workflow. Keep in the fixture only the message types, subtypes and field names (no content). The mapping uses
tool_use_id,usageand the workflowtask_typeonly as far as the recording shows them; update the [derived] note of 09 18.4 with what was verified and rule it. - Parent of a task. The runner already keeps the step ID per
tool_useID. Ontask_startedit looks uptool_use_id; a hit givesparent_step_id, a miss gives none. The value is set in the stepaddonly (the server never replaces a parent). A task step added before its tool call step is impossible (the tool call comes first in the stream); if the recording shows otherwise, the task stays at the root and the report names it. - Progress patch. The runner remembers the time of its last stored patch per run (injected clock). On
tool_progressit increments its count and sendsreplace /metrics/progress_eventsonly when that time is older thanrunner.progress_patch_interval; the patch goes through the throttled SDK path like every other patch. - Summary per parent. In the summary use case of M2-10, group the candidates by
parent_step_id(NULL as one group). The removal order stays the lowest ordinal of the run; each removed step goes into the summary step of its group, created on first use with thatparent_step_idand the lowest removed ordinal of the group. A finished step whose only child is its own summary step is a candidate like a leaf: remove both and add one plus the summary'scountandby_kindto the summary of the step's parent. Normalized ops as in M2-10 per summary step. - Stall marking. In the
runs.stall_checkround, selectrunningruns withlast_progress_at < now - run.stall_afterand emptystall_warned_at; per run one unit of work that re-reads and locks the row, setsstall_warned_at, raisesversionand publishes the ephemeralrun.updatedwithbase_version,versionandadd /stall_warned_at. A run whose row changed in between is skipped (compare-and-swap onversion). - Rulings for
docs/decisions.d/M2-51.md(D-M2-51-<n>): what the recording verified; no backfill oflast_progress_at; grouping of summary steps; the stall marking unit of work.
Tests (write them first)¶
Server (services/server/internal/runs, with pgtest and an injected clock):
TestSummary_PerParent: a run with nested finished leaves under two parents and at the root crossesrun.max_steps_persisted; each parent gets its own summary step with thatparent_step_id; running steps and parents stay; normalized ops as M2-10 describes.TestSummary_OldRootSummaryStays: a run with a stored root summary step keeps it and adds per parent summaries for later merges.TestLastProgress_SetOnStartAndPatch:run.startand a stored patch setlast_progress_atto the clock time; a refused patch does not.TestStallCheck_MarksRunningWithoutProgress: afterrun.stall_afterwithout progress onestall_warned_at, one version step, onerun.updatedwithadd /stall_warned_at; a second round changes nothing.TestStallCheck_SkipsWaitingInputandTestStallCheck_PatchBetweenReadAndLockWins.TestPatch_ClearsStallWithRemoveOp: the next stored patch clears the value and its normalized ops carryremove /stall_warned_at.TestSummary_FinishedSubtreesCollapse: more thanrun.max_steps_persisted / 3finished subagents, each anAgenttool call with a subagent step and tool calls under it, never givelimit_reached; the run stays at or below the bound, and a finished step whose only child is its summary step goes with it into its parent's summary (counts add up).- Gateway (
internal/gateway, real gateway in the test harness):TestFocus_BackgroundThreadsGetRunUpdates(initial document andrun.updatedfor a thread only inbackground_thread_ids, notyping.started),TestFocus_BackgroundThreadsNotInPresence(gateway_presence.focus_thread_idsholds onlythread_ids; a person with a card thread in the background list still gets the notification of 13 4.4),TestFocus_BudgetCountsBothLists(validation_failedabovesync.max_focused_threadsdistinct threads, previous focus kept). TestListActiveRuns_StallWarnedAtand a contract test thatgetRunreturnsstall_warned_atandmetrics.progress_events.
Runner (plugins/claude-code-runner/tests, Fake-Claude):
test_task_parent_from_tool_use_id: a subagent started by anAgenttool call, a tool inside it (withparent_tool_use_id) and a background command: the expected chain ofparent_step_id.test_task_without_tool_use_id_at_root.test_workflow_task_kind(with the recorded type; skipped with a reason only if the recording could not name it, and then the report says so).test_task_usage_in_detail:usagebecomesdetail.usage; nocost_micros.test_tool_progress_patch_interval: manytool_progressmessages within the interval give one patch; after the interval one more.
Run the added tests by name with repetitions as the team rules require (Go -race -count=20, pytest repeated), through the machine's test slot.
Commands that must pass, from the repository root:
cd plugins/claude-code-runner && uv run --locked pytestmake fmt-check,make lint,make typecheck,make test,make coveragemake api-lint,make api-checks,make codegen-check,make api-clients,make migrations-lintmake docs,make fragments-checkmake check
Acceptance criteria¶
- Task steps carry the parent of their starting tool call where the stream gives
tool_use_id; tasks without it stay at the root; tools inside a subagent keep their parent fromparent_tool_use_id. -
workflowanddetail.usageare mapped exactly as far as the recording of the pinned version shows; 09 18.4 states what was verified. - Summary steps are kept per parent; nothing running and no parent with a child other than its own summary step is removed; finished subtrees collapse upward and a long run with many finished subagents never hits
limit_reached. -
focusacceptsbackground_thread_ids: run updates for those threads, no typing, not ingateway_presence, both lists withinsync.max_focused_threads. -
last_progress_at, the stall marking with itsrun.updatedand theremoveon clearing work as 09 5.5 says;waiting_inputis never marked;tool_progresskeeps a long tool unmarked. -
GatewayFocus.background_thread_ids,Run.stall_warned_at,RunSummary.stall_warned_atandRunMetrics.progress_eventsare inopenapi.yamland every generated client. - Fragments written;
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why and cite 09 5.3, 5.5, 5.6 or 18.4.
- README of
plugins/claude-code-runner(stream mapping) and ofinternal/runsupdated. - Rulings in
docs/decisions.d/M2-51.md; changelog fragments as in Scope. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (for example a field the recording could not show).
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.