From 7af092adbd924b0ce54e8a3cd0db0babec481860 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:25:57 -0700 Subject: [PATCH] docs(attr-child-clock): settle the mechanism and write the implementation plan --- ATTRIBUTION-child-clock.md | 451 +++++++++++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) diff --git a/ATTRIBUTION-child-clock.md b/ATTRIBUTION-child-clock.md index f36709a1b54..e8f7d866641 100644 --- a/ATTRIBUTION-child-clock.md +++ b/ATTRIBUTION-child-clock.md @@ -162,3 +162,454 @@ The producer already has the attribution and discards it: So attributing at the producer means **persisting a value we already compute and throw away three lines later** — not plumbing a new one end to end. + +--- + +# Review, mechanism decision, and implementation plan (2026-09-17) + +Every claim below is marked **VERIFIED** (read at source in this worktree at the stated +file:line) or **UNVERIFIED**. Line numbers are against `origin/main` = `25dd70e6118b`. + +## 1. Re-baseline and drift + +**VERIFIED.** `git fetch origin main` puts `origin/main` at `25dd70e6118b`, exactly **one** +commit ahead of this document's base `779667c1e7`: `25dd70e611 Test: target question card title +by testid instead of text (#21153)`. It touches `NativeChatQuestionCard.tsx`, +`browser-history-match-budget.ts` and two tests. **No drift** on anything this defect cites — +every line the document quotes still reads as quoted, including +`worktree-subagent-child-rows.ts:41-47`. + +## 2. The claims the document left unverified + +### 2.1 Is `startedAt` deliberately preserved? Yes — and the source says so, in two places + +**VERIFIED.** `src/main/claude/claude-settled-background-tasks.ts:28-30` documents the field on +the tracked row itself: + +> `/** First-observed epoch ms; preserved across updates and roster replacement` +> ` * so clients can render elapsed and keep a stable first-seen sort. */` + +That single comment names **both** load-bearing uses and settles the question the document asked +first. The preservation sites the document guessed at are real: + +- **VERIFIED** `src/main/claude/claude-background-task-tracker.ts:245` — + `startedAt: existing?.startedAt ?? this.now(),` inside `replaceAggregateRoster`. +- **VERIFIED** `src/main/claude/claude-background-task-tracker.ts:303` — + `startedAt: existing.startedAt,` inside `upsert`. + +The same discipline holds independently on the **hook** path, which the document did not look at: + +- **VERIFIED** `src/shared/claude-subagent-roster.ts:73-84` — `upsertWorkingClaudeSubagent`'s + `existing` branch writes `state`, `agentType`, `description` and two authority flags, and + **never touches `startedAt`**. The only write is at creation, `:93`. +- **VERIFIED** `src/shared/codex-subagent-roster.ts:41-47` — identical shape; the only write is + `:56`. + +Four further consumers make mutating it actively wrong, all **VERIFIED**: + +1. `src/shared/claude-subagent-roster.ts:351-353` sorts snapshots by `startedAt`, with the stated + reason *"hook arrival order is not stable across reconciles; sort so equal rosters serialize + identically and downstream equality checks can dedupe."* Mutating it reshuffles the array on + every ping and defeats that dedupe. `src/shared/codex-subagent-roster.ts:122` does the same. +2. `src/renderer/src/components/sidebar/worktree-agent-row-order.ts:17-23` — + `compareWorktreeAgentRows` uses `row.startedAt` as its **primary** sort key, and + `worktree-agent-rows.ts:273` applies it to every row including children. A mutating stamp + reorders sibling child rows under the user's cursor. +3. `src/shared/claude-subagent-roster.ts:99-113` — `evictOldestIdleClaudeSubagent` picks the + victim by smallest `startedAt`. Mutating it changes which teammate is dropped at the cap. +4. `src/shared/agent-status-types.ts:334` — `agentSubagentsEqual` compares `startedAt`, and + `src/shared/agent-hook-relay.ts:130-136` folds it into the shed-roster digest. + +**Verdict: the stamp is load-bearing for stable first-seen ordering and for roster identity. +Mutating it is wrong. A second field is required.** This settles the question the document +told us to settle first. + +### 2.2 Does the wire type have an activity field? No + +**VERIFIED.** `src/shared/agent-session-background-task-wire.ts:18-35` — `AgentSessionBackgroundTask` +carries exactly `id`, `kind`, `description?`, `name?`, `state?`, `startedAt?`, `totalTokens?`, +`stoppable?`. No activity field. `:26` reads verbatim +*"Host epoch ms when the task was first observed, so clients render elapsed."* +`backgroundTaskFieldsEqual` at `:53-67` compares those eight and nothing else. + +**VERIFIED.** `src/shared/agent-status-types.ts:72-82` — `AgentSubagentSnapshot` carries +`id`, `agentType?`, `model?`, `description?`, `state`, `startedAt`. `:80` reads +*"Timestamp (ms) when this subagent was first observed."* No activity field. The document's +`:80-81` citation is exact. + +### 2.3 `lastEnteredDoneAt` for a child row + +**VERIFIED, and stronger than reported.** `src/renderer/src/components/dashboard/agent-finished-timestamp.ts:14-16` +returns `null` when `rowSource === 'subagent' && state !== 'done'`. But +`worktree-subagent-child-rows.ts:39` maps `'done'` to `'idle'` before the row is built, so a +subagent row's `state` is one of `working | blocked | waiting | idle | unverifiable` and is +**never `'done'`**. The guard therefore fires on **every** child row, always. A child row can +never display a finish time; it always falls through to `startedAt`. + +### 2.4 One claim the document did not make, which changes the design + +**VERIFIED.** *The producer already computes the child's activity timestamp, already passes it +into the roster upsert, and the upsert discards it.* On all three paths: + +- `src/shared/agent-hook-listener/providers/claude-events.ts:161-167` — every child-attributed + `PreToolUse` / `PostToolUse` / `PostToolUseFailure` calls + `upsertWorkingClaudeSubagent(roster, eventAgentId, {...}, Date.now())`. That `Date.now()` **is** + the child's own last-activity moment. `upsertWorkingClaudeSubagent`'s `existing` branch + (`claude-subagent-roster.ts:73-84`) never reads the `now` parameter. +- `src/shared/agent-hook-listener/providers/codex-events.ts:133-143` — identical, for every + child-attributed Codex hook event. `upsertCodexSubagent`'s `existing` branch + (`codex-subagent-roster.ts:41-47`) never reads `now`. +- `src/main/claude/claude-background-task-tracker.ts:134-142` — the provider sends a + per-child `task_progress` frame whose own comment says *"Progress `description` is the current + activity (\"Running \")"*. Orca takes `totalTokens` off it and **drops the frame entirely + when it carries no usage** (`:139-140`, `return false`). The arrival time — proof the child is + alive right now — is never recorded. + +So this is not end-to-end plumbing. On every path the value is already in hand at the producer +boundary and thrown away within a few lines, exactly as the document's "Feasibility fact" says +of the journal — but it holds for the sidebar's actual producers, which the journal is not. + +## 3. Framing: is this a missing field or a display bug? — **Both, and not where the document says** + +### The time column is not a recency column. It is documented as bimodal, and it is already elapsed-for-active + +**VERIFIED**, in two independent places: + +- `src/renderer/src/components/dashboard/agent-finished-timestamp.ts:4-9`: + *"Shared by the left worktree sidebar and the pop-out dashboard so both time from the SAME + event: a finished agent reads 'N since it finished', an active one falls through to its start."* +- `src/shared/dashboard-snapshot.ts:125-130`: `startedAt` is *"'Started … ago' display"*; + `finishedAt` *"Drives the card's time column: finished cards read time-since-finish (parity with + the left worktree sidebar), active cards fall back to startedAt."* + +And that is what the code does: `worktree-card-compact-agent-row.tsx:59-66` takes +`lastEnteredDoneAt(agent)` when non-null, else `agent.startedAt`, and renders it through +`formatShortTimeAgo` (`src/renderer/src/lib/short-time-ago.ts:1-16`, which emits a bare +`now / 14m / 3h / 2d` with no words). + +**So "14m" on a child that has been working 14 minutes is correct, and it is an elapsed +duration — the same reading the parent row's own active-state number has.** The premise in +"Observed" — that the row *"sits in a column read as recency"* — is **refuted at source**. The +column reads as recency only for rows that have finished. + +### Then why did it look wrong? Because of defect A, not C + +The report is a *comparison*: the child read `14m` while its idle parent read `now`. Defect A's +document establishes (and its chain is independently consistent with what I read here) that the +parent's `now` was **false** — the child's own frames were restamping the parent's +`stateStartedAt`. Put an honest number on the parent and the child's honest `14m` stops looking +like the anomaly. **C should not be fixed by making the child's number match a parent number that +is itself wrong.** + +### The honest rendering for a working child is a DURATION, not a recency. Reasons, not precedent + +1. **A recency on a healthy child conveys nothing.** A working child emits a tool event every few + seconds (`claude-events.ts:161-167` fires on each one), so "last active 3s ago" is a constant. + The number a user acts on is the elapsed: *this has been running forty minutes, something is + wrong.* The duration carries the information; the recency carries a pulse. +2. **Orca would otherwise disagree with itself about the same child.** The pop-out dashboard's + subagent row is `{ id, name, dotState }` — **no timestamp at all** + (`src/shared/dashboard-snapshot.ts:55-59`, VERIFIED). The sidebar's column is contractually + "start for active, finish for done". Introducing a third meaning for children only, in one of + the three surfaces, is the reader-side precedence rule + `docs/reference/agent-status-store.md:55-62` forbids. +3. **Where recency *is* the useful reading, Orca already has a dedicated surface for it, in + words.** `worktree-card-compact-agent-row.tsx:36-38` renders `agentNoUpdateLabel(entry, now)` + on the **secondary** line for an `unverifiable` row, producing `No update in 14m` + (`src/renderer/src/lib/agent-row-decay-state.ts:46-56`). Its own comment says it *"deliberately + says what Orca last heard rather than what the agent is doing."* That is the recency channel, + and it is reserved for rows whose liveness is in doubt — a derived judgement, not a second + stored clock. Adding a recency to the time column would be a second representation of liveness + sitting beside a derivation that already exists. + +**Decision: `getCompactAgentTime` is not changed. No new field is needed for the time column.** + +### The real, independent defect in C: the child's recency clock is its parent's + +**VERIFIED.** The recency surface above reads `agentStatusEvidenceObservedAt(entry)` = +`mirroredEvidenceReceivedAt ?? evidenceObservedAt ?? updatedAt` +(`src/shared/agent-status-freshness.ts:13-29`). The synthetic child entry +(`worktree-subagent-child-rows.ts:43-60`) sets **no** `evidenceObservedAt` and sets +`updatedAt: args.parentEntry.updatedAt` (`:46`). So when a child row decays to `unverifiable`, +`No update in N` is **the parent's silence, reported as the child's**. + +With exactly one child this reads correctly by coincidence — the child's activity is what advanced +the parent's `updatedAt` — which is why it survived. With **siblings** it is wrong: child X quiet +for ten minutes while child Y pings shows `No update in 0m` on X's row. A parent that speaks for +itself does the same. That is the attribution bug class this family is about, in its mirror form: +convergence finding #1 says a child's activity must never stamp the parent's row; its dual is that +the parent's must never stamp the child's. + +**So the missing field is real — but it is a missing *recency* clock feeding an existing recency +label, not a missing number for the time column.** + +## 4. Does defect B solve this for free? **No.** Three independent reasons + +B's own document (read at `brennanb2025/attr-parent-label`, doc-only, no implementation) proposes +attributing **journal items** to their producing agent and scoping projections by it, and +speculates that this *"likely also resolves defects A and C."* For A that is plausible — A's chain +runs through `journal-reducer.ts:69`'s journal-wide `lastActivityAt`. For **C it does not hold**: + +1. **The hook path has no journal at all.** Child rows for CLI panes come from + `claudeRosterToSnapshots` / `codexRosterToSnapshots` (`src/shared/claude-subagent-roster.ts:335`, + `src/shared/codex-subagent-roster.ts:108`), fed by hook lifecycle and tool events in + `src/shared/agent-hook-listener/providers/`. No journal item exists on that path to attribute. + **VERIFIED.** +2. **A backgrounded child emits no parent-attributed frames even on the structured path.** + `src/main/claude/claude-subagent-roster.ts:3-7` states it directly: *"a BACKGROUNDED subagent + emits no child frames at all, so a roster fed by `parent_tool_use_id` alone would leave every + one of them an unlabelled row forever."* Backgrounded children are precisely the long-running + ones this defect is about. A per-producer journal clock would be permanently silent for them. + **VERIFIED.** +3. **Even on the structured path the sidebar child row is not built from journal items.** + `StructuredAgentSessionStatusBridge.tsx:103-127` builds the child snapshots from + `summary.backgroundTasks` — the `ClaudeBackgroundTaskTracker` roster, fed from + `background_tasks` / `task_*` system frames (`claude-structured-session-adapter.ts:209`), not + from the journal. A journal-derived per-producer clock would still need new plumbing into that + roster. **VERIFIED.** + +**Conclusion: B and C are independent. Do not sequence C behind B, and do not wait for B's +mechanism.** They are complementary — B attributes *content*, C attributes *liveness* — and they +touch disjoint files. Either may land first. + +## 5. Mechanism + +**Give the child its own `evidenceObservedAt`, stamped at the producer boundary from the +observation Orca already receives, and let the existing recency reader prefer it.** + +Why this and not a bespoke field: + +- **It reuses the field that already means exactly this.** `AgentStatusEntry.evidenceObservedAt` + is *"Timestamp (ms) the reported evidence was first observed. Separate from `updatedAt`, which + is the delivery/ordering clock"* (`agent-status-types.ts:95-98`). Use that name on the child + types too, and the reader edit becomes a single spread: `agentStatusEvidenceObservedAt` already + prefers it and already falls back to `updatedAt`. A grep for a second last-activity concept + stays empty. +- **It removes the bug class rather than guarding it.** The defect is a reader reading a clock + that belongs to a different producer. After this, the child row reads a clock stamped by the + child's own evidence, at the same boundary that already classifies the event as the child's + (`claude-events.ts:153-161` computes `subagentOriginId` for exactly this purpose). Attribution + happens at ingestion, which is convergence finding #2. +- **It does not add a second writer.** The producers named below are the only writers of a + subagent row today; each gains one assignment. No new writer, no new publication filter. +- **The time column is untouched**, so it cannot start disagreeing with the pop-out dashboard. + +Honest limits of the claim, stated rather than left implicit: + +- This does **not** make an idle-parked teammate's row time its idle-since moment; there is no + settle stamp on `AgentSubagentSnapshot`. Out of scope, listed in §10. +- On the structured path, `foldClaudeBackgroundTasksIntoRoster` stamps from the **lead's** Stop + inventory. That is still an observation *of the child* (the provider asserts it is running now), + not a borrow of the parent's clock — but it is a coarser observation and a reviewer should + confirm the distinction is acceptable. Marked for review in §9 Step 2. + +## 6. Wire compatibility — **Rule 1 on two surfaces, with an explicit absence semantic** + +Read `docs/reference/remote-wire-compatibility.md`. Two additive optional numeric fields: + +| Surface | Type | Crosses | Category | +| --- | --- | --- | --- | +| `AgentSubagentSnapshot.evidenceObservedAt?: number` (`src/shared/agent-status-types.ts:72-82`) | hook status payload | agent-hook relay, WSL/SSH relay receivers (`src/shared/agent-hook-relay.ts`) | **Rule 1** | +| `AgentSessionBackgroundTask.evidenceObservedAt?: number` (`src/shared/agent-session-background-task-wire.ts:18-35`) | `agentSession.*` | paired desktop client ↔ remote Orca host | **Rule 1** | + +- **Not Rule 2.** No new opcode, no new frame, no new journal item kind. Both are new keys on + frames that already exist, decoded by parsers that ignore unknown keys. +- **Not Rule 3.** Nothing the host already publishes changes meaning, units, nullability, or + population. `startedAt` keeps its exact value and its exact comment on both types. No content + stops being synthesized. One caveat below. +- **What an old client does:** ignores the key entirely. Its child rows keep borrowing the + parent's `updatedAt` for `No update in N`, which is today's behaviour — so the skew degrades to + the status quo, never to something worse. +- **What a new client against an old host does — this is the part to get right.** The field is + **absent**, and absence must read as *the host never reported it*, never as *this child has + never been active*. This is the `agentWait` worked example + (`remote-wire-compatibility.md:198-217`): collapsing absent into a value makes an old host + indistinguishable from a silent child. Concretely: `evidenceObservedAt` must be left + `undefined` — **never normalized to `0`**, which `startedAt` does at + `agent-status-types.ts:295-296` and which would render `No update in 56 years`. The reader + then falls through to `updatedAt` through the existing helper, with no extra branch. +- **No client may ever require it.** Nothing gates on presence; the only reader prefers it when + present. +- **Rule-3 caveat to state in the PR body:** `claude-background-task-tracker.ts:139-140` must stop + dropping a usage-free `task_progress` frame. That changes **publish frequency** on an existing + path, not frame content. A `task_progress` frame that today produces no publish will produce + one. Old clients decode the identical frame shape and see a row whose fields are unchanged + except the key they ignore, so this is inside Rule 1 — but it is a behaviour change on a shared + path and belongs in the PR body, not buried. +- **Enforcement:** `tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts` + covers the `agentSession.*` surface and stays green for an added optional field by design + (`remote-wire-compatibility.md:128-134`). Add no `not.toHaveProperty` assertion anywhere — + `remote-wire-compatibility.md:136-144` explains why that rots on the next release cut. +- **Mobile is not affected. VERIFIED:** `rg` over `mobile/src/` finds **zero** non-test references + to `subagent` or `backgroundTask` in production code. + +## 7. The pinned tests + +Neither test needs to change, and both should be **kept and extended**. They pin the right thing. + +- **`worktree-subagent-child-rows.test.ts:19-54`** asserts `row.startedAt === 20` across a + 14-case freshness matrix, and also asserts `parentEntry.subagents` is unmutated. That is exactly + the invariant §2.1 proved load-bearing. **Keep verbatim.** Extend the same file with two new + cases: (a) a subagent carrying `evidenceObservedAt` produces a row whose + `entry.evidenceObservedAt` is the child's value and whose `startedAt` is still the spawn stamp; + (b) a subagent **without** it produces a row with `entry.evidenceObservedAt === undefined`, so + `agentStatusEvidenceObservedAt` falls back to the parent's `updatedAt` — the old-host skew, + pinned. +- **`useWorktreeAgentRows.test.ts:728-768`** asserts `startedAt: 1500` for a child spawned at + 1500, plus lineage and label. **Keep verbatim.** Add one sibling case: two children, one pinged + recently and one long quiet, and assert their `entry.evidenceObservedAt` values **differ**. That + is the multi-child case that makes the parent borrow observably wrong, and it is the regression + test this defect most needs — today it is untestable because the field does not exist. + +**VERIFIED** baseline: `pnpm test src/renderer/src/components/sidebar/worktree-subagent-child-rows.test.ts` +→ 14 passed, 147ms, at `origin/main` + this branch's docs. + +## 8. Should the fix also stop the child borrowing the parent's `updatedAt`? — **Partly. Demote it, do not delete it.** + +`worktree-subagent-child-rows.ts:46` sets `updatedAt: args.parentEntry.updatedAt`. After this +change it stays, but it is no longer the child's recency clock — it becomes the documented +**fallback for a host that predates the field**, which is precisely what Rule 1 requires of an +absent optional. `agentStatusEvidenceObservedAt` already encodes that fallback, so demoting the +borrow costs one added spread line and no new branch. + +Deleting it is wrong. The alternatives are both worse: +- setting `updatedAt` to the spawn stamp makes every child on an old host read + `No update in `; +- setting it to `now` fabricates freshness, which is the exact defect on the parent side (A). + +The residual must be stated plainly: **while defect A is unfixed, the fallback clock is polluted**, +so a child row on an old host still inherits a wrong number. That pollution is A's to fix; C's +field is what lets a current host stop depending on it at all. Recording this here rather than +silently relying on A is the point. + +## 9. Implementation plan + +Independent of A and B; no shared files. Land in any order. + +### Step 1 — wire types (additive only) + +1. `src/shared/agent-status-types.ts` + - Add to `AgentSubagentSnapshot` (`:72-82`), below `startedAt`: + `evidenceObservedAt?: number` with a one-line comment: last moment the host observed this + child's own activity; absent means the host never reported it. + - `normalizeSubagentSnapshot` (`:271-300`): accept it as an optional finite number. + **Do not mirror the `startedAt` `?? 0` at `:295-296`** — absent must stay `undefined` (§6). + - `agentSubagentsEqual` (`:322-345`): add the comparison. +2. `src/shared/agent-hook-relay.ts:130-136` — add it to `subagentRosterDigest`'s stable tuple, so + a roster differing only in this field cannot collide with a shed digest. +3. `src/shared/agent-session-background-task-wire.ts` — add `evidenceObservedAt?: number` to + `AgentSessionBackgroundTask` (`:18-35`) and to `backgroundTaskFieldsEqual` (`:53-67`). + +### Step 2 — producers stamp what they already compute + +4. `src/shared/claude-subagent-roster.ts` + - `TrackedClaudeSubagent` (`:21-53`): add `evidenceObservedAt?: number`. + - `upsertWorkingClaudeSubagent` `existing` branch (`:73-84`): `existing.evidenceObservedAt = now`. + Create branch (`:91-96`): `evidenceObservedAt: now`. **Leave `startedAt` untouched at both.** + - `foldClaudeBackgroundTasksIntoRoster` (`:154-248`): stamp on the id-exact running match + (`:183-189`). **Flag for review** — this observation arrives on the lead's Stop payload + (§5 limits). + - `claudeRosterToSnapshots` (`:335-355`): emit it when defined. **Do not change the sort at + `:353`.** +5. `src/shared/agent-hook-listener/providers/claude-roster-state.ts:155-180` — + `seedClaudeSubagentRosterFromSnapshots` carries `snapshot.evidenceObservedAt` through, so a + restored row does not read as freshly observed. Absent stays absent. +6. `src/shared/codex-subagent-roster.ts` — same three edits: `TrackedCodexSubagent` (`:14-20`), + `upsertCodexSubagent` existing (`:41-47`) and create (`:51-57`), `codexRosterToSnapshots` + (`:108-123`), and `seedCodexSubagentRoster` (`:86-105`). + **Two call sites must NOT stamp:** `setCodexSubagentModel` (`:70-84`), which its own comment + says never touches lifecycle; and `codex-subagent-transcript.ts:287-292`, which passes + `tracked.startedAt` — a **spawn** stamp — as `now`. Stamping recency there would backdate it to + the spawn. Give that call site an explicit observation argument or leave the field alone. +7. `src/main/claude/claude-settled-background-tasks.ts` + - `TrackedClaudeBackgroundTask` (`:18-32`): add `evidenceObservedAt?: number`. + - `claudeBackgroundTaskDetail` (`:34-49`): project it onto the wire row when defined. +8. `src/main/claude/claude-background-task-tracker.ts` — stamp `this.now()` on every frame that is + evidence the task is alive: `task_started` upsert (`:164-171`), `task_updated` upsert + (`:198-205`), `replaceAggregateRoster` (`:236-248`), and `upsert`'s existing branch + (`:295-307`). **Preserve `:245` and `:303` exactly as written.** + **The load-bearing edit in this slice:** `task_progress` (`:134-142`) currently returns `false` + and writes nothing when the frame carries no `totalTokens` (`:139-140`). A progress frame with + no usage is still proof the child is alive. Stamp before that early return, and return `true` + so the state refreshes. Everything else about that branch stays — in particular the comment's + rule that progress `description` must not overwrite the task's name. + +### Step 3 — bridge + +9. `src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx:115-121` — + carry `task.evidenceObservedAt` onto the snapshot when defined. The write gate at `:168` uses + `agentSubagentsEqual`, which Step 1 already widened. + +### Step 4 — reader (presentation policy only) + +10. `src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts:43-60` — add exactly one + line to the synthetic entry: + `...(subagent.evidenceObservedAt !== undefined ? { evidenceObservedAt: subagent.evidenceObservedAt } : {})` + Keep `updatedAt: args.parentEntry.updatedAt` (`:46`) as the documented old-host fallback (§8). + Keep `stateStartedAt: startedAt` (`:47`) and `row.startedAt` (`:69`) unchanged. +11. **No change** to `worktree-card-compact-agent-row.tsx`, `agent-finished-timestamp.ts`, + `short-time-ago.ts`, `agent-row-decay-state.ts`, or `agent-status-child-work-freshness.ts`. + The recency label already prefers the new clock through + `agentStatusEvidenceObservedAt` (`agent-status-freshness.ts:25-29`). A reuse check on the + consumer side should come back empty — that is the success signal. + +### Step 5 — tests + +12. Extend the two pinned tests as in §7 (keep every existing assertion). +13. `src/shared/claude-subagent-roster.test.ts` / `codex-subagent-roster.test.ts`: a second upsert + for an existing id advances `evidenceObservedAt` and **leaves `startedAt` unchanged**, and the + snapshot sort order is unchanged across the advance. This is the ablation that proves the two + stamps are genuinely separate. +14. `src/main/claude/claude-background-task-tracker.test.ts`: a `task_progress` frame **with no + `usage`** advances `evidenceObservedAt`, preserves `startedAt`, preserves `name`, and reports + changed. Pin this explicitly — it is the branch that today returns early. +15. A relay test that a roster differing only in `evidenceObservedAt` produces a different shed + digest. +16. Gates: `pnpm tc`, `pnpm test` on the touched paths, `oxlint`, + `pnpm run check:code-quality:changed`, plus + `pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts`. + Restore `pnpm-lock.yaml` with `git checkout -- pnpm-lock.yaml` after any test run — that file + only, never a bare checkout. + +## 10. Risks, and what is deliberately not fixed + +1. **Publish/fanout churn.** `evidenceObservedAt` changes on every child tool event, so + `agentSubagentsEqual` stops returning true for consecutive pings. + - `agent-status-live-entry-builder.ts:260-262` uses it only for **array reference reuse**, not + as a fanout gate, so the cost is a new `subagents` array per child event and the React + re-renders that follow. **UNVERIFIED: not measured.** Measure before merging; if it bites, the + mitigation is to quantize the stamp to a coarse bucket at the producer, and that tradeoff + should be recorded rather than applied silently. + - `StructuredAgentSessionStatusBridge.tsx:160-180` **is** a write gate, but its chain already + includes `current.updatedAt === summary.updatedAt`, which moves on every publish anyway, so + the added term changes nothing there. **VERIFIED** by reading the gate. +2. **Frequency change on `task_progress`.** Step 2/8 makes usage-free progress frames produce a + publish where today they produce none. Rule-1 safe, but it is the one behavioural change on a + shared path (§6). +3. **Silent no-op if a provider sends no child-attributed events.** For a provider whose children + emit nothing, the field stays absent and the row keeps today's behaviour. That is the correct + degrade, but it means the fix is unobservable on such a provider — QA must pick a provider and + a child shape that actually emits, and say which. +4. **Not fixed: an idle-parked teammate's row still times from spawn.** There is no settle stamp + on `AgentSubagentSnapshot`. A parked teammate reads `14m` meaning "spawned 14m ago", not + "parked 14m ago". Separate defect; needs a settled-at field, not this one. +5. **Not fixed: the two-writer structure.** The material deviation this family must disclose — + Orca keeps two real writers of one status row apart with publication filters, where every + reference examined removes the possibility of a second writer — is untouched here. C adds no + writer and removes none. Say so in the PR body rather than implying the slice narrows it. +6. **Not fixed: defect A's pollution of the fallback clock** (§8). + +## 11. Claim ledger + +**VERIFIED at source in this worktree:** §1 drift; §2.1 all five preservation/consumer sites; +§2.2 both wire types; §2.3 the always-null guard; §2.4 all three discard sites; §3 both +column-contract docstrings, `formatShortTimeAgo`, the pop-out subagent shape, the +`agentNoUpdateLabel` surface, the `updatedAt` borrow, the `agentStatusEvidenceObservedAt` +fallback chain; §4 all three reasons; §6 the mobile-consumer absence and the `startedAt` `?? 0` +normalization hazard; §7 the baseline test run; §10.1 second bullet. + +**UNVERIFIED:** §10.1 first bullet (fanout churn is reasoned from the code path, not measured); +the assertion that defect A's chain is the cause of the reported `now` on the parent (read from +A's document and consistent with what I read here, but A's journal-reducer line was not +re-read in this worktree); any statement about what B *will* do, since B is doc-only — its branch +contains one markdown file and no implementation.