diff --git a/.specs/features/worker-lineage/spec.md b/.specs/features/worker-lineage/spec.md new file mode 100644 index 0000000..a1779a3 --- /dev/null +++ b/.specs/features/worker-lineage/spec.md @@ -0,0 +1,45 @@ +# Worker lineage + +## Goal + +Show who dispatched whom. Today every session of a run shares one `runId` +(the `open` session's id), and `codedeck run` inside a worker inherits that +same `CODEDECK_RUN_ID`. The run canvas therefore draws every worker as a +direct child of a card labelled "Orchestrator", even when the root is a +`general` session and the reviewer was started by a worker, not by the root. + +## Verified ground + +- `SessionRuntime.spawn` already puts `CODEDECK_SESSION_ID=` in + every worker harness environment (`src/drivers/session-runtime.ts`). +- `open` puts `CODEDECK_RUN_ID=` in the harness environment; the open + session row has `origin: "open"` and `runId === id`. +- `session.list` returns full session rows, so new columns reach + `codedeck ps --json` and the agents pane without extra plumbing. + +## Acceptance criteria + +- AC1: A session row SHALL carry `parentId`, the session that dispatched it, + and `role`, the CodeDeck role it was started with. +- AC2: `codedeck run` SHALL send `parentId = CODEDECK_SESSION_ID`, falling + back to `CODEDECK_RUN_ID`, and `role = parseRole(--role)`. +- AC3: `open` SHALL set `CODEDECK_SESSION_ID` to its own id and send its role + on `session.adopt`. +- AC4: The daemon SHALL record `parentId` only when the parent exists in the + store, and SHALL inherit the parent's `runId` when the request has none. +- AC5: The run canvas SHALL draw a tree: each card hangs from its dispatcher. + A parent outside the snapshot, or a legacy row with none, hangs from the root. + A parent loop is cut at the root. +- AC6: The root card SHALL be labelled with the open session's role, falling + back to "Orchestrator"; worker cards SHALL show their role. +- AC7: A finished worker SHALL stay drawn as a card while any descendant is + live; history lines SHALL name the dispatcher when it is not the root. +- AC8: When the pane is short, a card SHALL be evicted only after all its + children, so the drawn tree stays connected. + +## Out of scope + +- Moving reviewer dispatch from the worker prompt into the harness (CodeDeck + starting the reviewer when a general finishes). The lineage edge written + here is what that later change will fill from the daemon side. +- The web console canvas. diff --git a/plugin/mods/agents/pane.ts b/plugin/mods/agents/pane.ts index b6fdc47..e190ea9 100644 --- a/plugin/mods/agents/pane.ts +++ b/plugin/mods/agents/pane.ts @@ -100,8 +100,12 @@ function text(value: unknown): string { return value; } -function toPaneRow(row: SessionRow): PaneRow { +// `workers` is the set of ids the snapshot keeps: a parent outside it (the +// run root, a row cut by the budget, a legacy row with no parent) leaves +// parentId undefined, which draws the card under the root. +function toPaneRow(row: SessionRow, workers: ReadonlySet): PaneRow { const iso = typeof row.updatedAt === "string" ? row.updatedAt : undefined; + const parent = text(row.parentId); return { id: row.id, status: row.status || EMPTY_CELL, @@ -110,6 +114,8 @@ function toPaneRow(row: SessionRow): PaneRow { effort: text(row.effort) || undefined, name: row.name || EMPTY_CELL, updatedAt: iso, + parentId: parent !== "" && parent !== row.id && workers.has(parent) ? parent : undefined, + role: text(row.role) || undefined, }; } @@ -118,7 +124,8 @@ function toPaneRow(row: SessionRow): PaneRow { * * Filters to `runId`, lifts the single `origin === "open"` row out as the * orchestrator, orders the rest by `updatedAt` descending with a total order, - * and cuts to `budget`. With no explicit budget every matched row is kept and + * and cuts to `budget`. Each kept row carries the id of the kept row that + * dispatched it, so formatPane can draw who started whom. With no explicit budget every matched row is kept and * `hidden` is 0; dropping rows to fit the pane is formatPane's decision. */ export function selectPane(rows: SessionRow[], runId: string, budget?: number): PaneSnapshot { @@ -134,15 +141,17 @@ export function selectPane(rows: SessionRow[], runId: string, budget?: number): agent: text(orchestratorRow.agent) || EMPTY_CELL, model: text(orchestratorRow.model) || undefined, effort: text(orchestratorRow.effort) || undefined, + role: text(orchestratorRow.role) || undefined, } : undefined; const workers = matching.filter((row) => row.origin !== "open"); const ordered = [...workers].sort(compareRows); const kept = ordered.slice(0, limit); + const keptIds = new Set(kept.map((row) => row.id)); return { runId: text(runId), orchestrator, - rows: kept.map(toPaneRow), + rows: kept.map((row) => toPaneRow(row, keptIds)), hidden: ordered.length - kept.length, total: matching.length, }; @@ -235,22 +244,30 @@ function rowAge(iso: unknown): number { return Number.isNaN(then) ? Number.NEGATIVE_INFINITY : then; } -// One drawable piece of the pane: an orchestrator card, a worker card, or a -// compact history section. `count` is how many sessions disappear if the block -// is evicted to make room for the frame and footer. `size` counts only the -// block's own lines; the assembled order adds any connector above it. -type Block = { - kind: "card" | "history"; - size: number; - age: number; +// One card of the lineage tree. `children` holds only the cards drawn under +// it; finished leaves go to the history section instead of the tree. +type Card = { id: string; - count: number; - build: (connects: boolean, lead: boolean) => string[]; + age: number; + body: (inner: number) => string[]; + parent: Card | undefined; + children: Card[]; }; +function roleLabel(role: unknown): string { + const clean = cell(role); + return clean === EMPTY_CELL ? "" : clean.charAt(0).toUpperCase() + clean.slice(1); +} + // One drawing pass, with the widths derived from the column budget. Kept inside // its own function so formatPane can wrap the whole thing in one guard. // `limit` is the usable body height in rows, or undefined for unbounded. +// +// The body is a tree: every card hangs from the session that dispatched it, +// so a reviewer a worker started is drawn under that worker rather than under +// the run root. A child card joins its parent through an elbow on the card's +// first body line; the parent's bottom edge carries a tee where the trunk +// leaves it. function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined): string[] { const source = (snapshot ?? {}) as Partial; const rows = (Array.isArray(source.rows) ? source.rows : []).map( @@ -258,7 +275,7 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined ); const orchestrator = source.orchestrator && typeof source.orchestrator === "object" - ? (source.orchestrator as Partial<{ agent: string; model: string; effort: string }>) + ? (source.orchestrator as Partial<{ agent: string; model: string; effort: string; role: string }>) : undefined; const hidden = count(source.hidden); const total = count(source.total); @@ -267,23 +284,16 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined if (rows.length === 0 && orchestrator === undefined) return []; const workerTotal = rows.length + hidden; + const isLive = (row: Partial) => LIVE.has(row.status ?? "") || WAIT.has(row.status ?? ""); const working = rows.filter((row) => LIVE.has(row.status ?? "")).length; const waiting = rows.filter((row) => WAIT.has(row.status ?? "")).length; // The overflow is reported by count only, its statuses are not in the // snapshot, so it is folded into "finished": the most recently touched // sessions are the live ones and stay inside the budget. const finished = Math.max(0, workerTotal - working - waiting); - const finishedRows = rows.filter( - (row) => !LIVE.has(row.status ?? "") && !WAIT.has(row.status ?? ""), - ); const W = Math.trunc(columns); const IN = W - 2; - // Rule 8: the card box caps at MAX_CARD_COLUMNS no matter how wide the dock - // gets; the outer frame still spans the full width. The two-space inset the - // cards have always sat at inside the frame is kept. - const CARD = Math.min(IN - 4, MAX_CARD_COLUMNS); - const INNER = Math.max(0, CARD - 2); const now = Date.now(); // Everything routed through frame* is exactly W wide. @@ -293,90 +303,154 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined const frameTop = (label: string) => "┌" + fit(("─ " + label + " ").replace(CONTROL, " "), IN, "─") + "┐"; - // A node card, indented inside the frame. The stem that joins it to whatever - // sits above is drawn by the assembly, not by the block: the first block is - // the root and never has one. - const stem = () => frameRow(" " + " ".repeat(Math.floor(INNER / 2) + 1) + "│"); - const cardTop = () => frameRow(" ┌" + "─".repeat(INNER) + "┐"); - const cardRow = (s: string) => frameRow(" │" + fit(s, INNER) + "│"); - const stemAt = Math.floor(INNER / 2); - const cardBottom = (connects: boolean) => - connects - ? frameRow(" └" + "─".repeat(stemAt) + "┬" + "─".repeat(INNER - stemAt - 1) + "┘") - : frameRow(" └" + "─".repeat(INNER) + "┘"); - - const cardBlock = (ageValue: number, id: string, body: string[]): Block => ({ - kind: "card", - size: body.length + 2, - age: ageValue, - id, - count: 1, - build: (connects, lead) => [ - ...(lead ? [stem()] : []), - cardTop(), - ...body.map(cardRow), - cardBottom(connects), - ], - }); - - const blocks: Block[] = []; - - if (orchestrator !== undefined) { - blocks.push( - cardBlock(Number.POSITIVE_INFINITY, "", [ - ` ${glyph(orchestrator.agent)} Orchestrator`, - ` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agents`, - ...(text(orchestrator.model).trim() || text(orchestrator.effort).trim() - ? [detailLine(orchestrator.model, orchestrator.effort, "", "", INNER)] - : []), - ]), - ); - } + // Lineage. A row whose parent is not in the snapshot hangs from the root; + // a parent chain that loops is cut there, so every row is reachable once. + const byId = new Map>(); + for (const row of rows) if (typeof row.id === "string" && !byId.has(row.id)) byId.set(row.id, row); + const declared = (row: Partial): Partial | undefined => { + const id = typeof row.parentId === "string" ? row.parentId : undefined; + return id !== undefined && id !== row.id ? byId.get(id) : undefined; + }; + // A row on a parent loop hangs from the root instead. + const onLoop = (row: Partial): boolean => { + const seen = new Set>(); + for (let up = declared(row); up !== undefined && !seen.has(up); up = declared(up)) { + if (up === row) return true; + seen.add(up); + } + return false; + }; + const parentOf = (row: Partial): Partial | undefined => + onLoop(row) ? undefined : declared(row); + const ancestors = (row: Partial): Partial[] => { + const chain: Partial[] = []; + const seen = new Set>([row]); + for (let up = parentOf(row); up !== undefined && !seen.has(up); up = parentOf(up)) { + seen.add(up); + chain.push(up); + } + return chain; + }; + // A card for every live row and for every ancestor of one, finished or + // not, so a live reviewer never floats free of the worker that started it. + const carded = new Set>(); for (const row of rows) { + if (!isLive(row)) continue; + carded.add(row); + for (const up of ancestors(row)) carded.add(up); + } + const historyRows = rows.filter((row) => !carded.has(row)); + + const workerBody = (row: Partial) => (inner: number): string[] => { + const role = roleLabel(row.role); const status = row.status ?? ""; - // Live rows stay prominent because they need attention. Finished rows are - // collected below into one compact history section. - if (LIVE.has(status) || WAIT.has(status)) { - const when = age(row.updatedAt, now); - const word = statusWord(status); - blocks.push( - cardBlock(rowAge(row.updatedAt), cell(row.id), [ - ` ${glyph(row.agent)} ${cell(row.id)} ${harnessLabel(row.agent)}`, - ` ${cell(row.name)}`, - detailLine(row.model, row.effort, word, when, INNER), - ]), - ); - } + return [ + ` ${glyph(row.agent)} ${cell(row.id)} ${role ? `${role} · ` : ""}${harnessLabel(row.agent)}`, + ` ${cell(row.name)}`, + detailLine(row.model, row.effort, statusWord(status), age(row.updatedAt, now), inner), + ]; + }; + + const root: Card | undefined = + orchestrator === undefined + ? undefined + : { + id: "", + age: Number.POSITIVE_INFINITY, + parent: undefined, + children: [], + body: (inner) => [ + ` ${glyph(orchestrator.agent)} ${roleLabel(orchestrator.role) || "Orchestrator"}`, + ` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agents`, + ...(text(orchestrator.model).trim() || text(orchestrator.effort).trim() + ? [detailLine(orchestrator.model, orchestrator.effort, "", "", inner)] + : []), + ], + }; + + // Rows arrive newest first, so siblings keep that order. + const cards = new Map, Card>(); + for (const row of rows) { + if (!carded.has(row)) continue; + cards.set(row, { id: cell(row.id), age: rowAge(row.updatedAt), body: workerBody(row), parent: undefined, children: [] }); } + const tops: Card[] = root ? [root] : []; + for (const row of rows) { + const card = cards.get(row); + if (!card) continue; + const chain = ancestors(row); + const up = chain.length > 0 ? cards.get(chain[0]) : undefined; + card.parent = up ?? root; + if (card.parent) card.parent.children.push(card); + else tops.push(card); + } + + // Card width at a depth: capped at MAX_CARD_COLUMNS, shrinking as the tree + // indents, never so narrow the frame cannot clip it cleanly. + const cardWidth = (depth: number) => Math.max(10, Math.min(IN - 4 - 4 * depth, MAX_CARD_COLUMNS)); - if (finished > 0) { - const preview = finishedRows.slice(0, HISTORY_PREVIEW_ROWS); - const historyLines = [` ─ HISTORY · ${finished} finished`]; + // A finished worker kept as a card for a live child is not history. + const historyCount = historyRows.length + hidden; + const historyLines = (): string[] => { + if (historyCount === 0) return []; + const preview = historyRows.slice(0, HISTORY_PREVIEW_ROWS); + const lines = [` ─ HISTORY · ${historyCount} finished`]; for (const row of preview) { const when = age(row.updatedAt, now); - historyLines.push( - ` ${glyph(row.agent)} ${cell(row.id)} ${cell(row.name)}${when === "" ? "" : ` · ${when}`}`, + const up = parentOf(row); + lines.push( + ` ${glyph(row.agent)} ${cell(row.id)} ${cell(row.name)}` + + (up ? ` ← ${cell(up.id)}` : "") + + (when === "" ? "" : ` · ${when}`), ); } - const older = finished - preview.length; - if (older > 0) historyLines.push(` +${older} earlier`); - - blocks.push({ - kind: "history", - size: historyLines.length, - age: rowAge(preview[0]?.updatedAt), - id: "history", - count: finished, - build: (_connects, lead) => [...(lead ? [stem()] : []), ...historyLines.map(frameRow)], - }); - } + const older = historyCount - preview.length; + if (older > 0) lines.push(` +${older} earlier`); + return lines; + }; - // Whether the block at index i hangs from the one above it by a stem. The - // first block is the root and never leads with one. A card is always joined - // to what sits above it; history only gets a stem when it follows a card. - const stemAbove = (list: Block[], i: number): boolean => - i > 0 && (list[i].kind === "card" || list[i - 1].kind === "card"); + // Draw the kept part of the tree. `kept` holds the cards still on screen; + // an evicted card's children were evicted before it, so the tree stays + // connected. + const render = (kept: Set, withHistory: boolean, hiddenTotal: number): string[] => { + const out: string[] = []; + // `rails[k]` says whether the ancestor at depth k+1 has a later sibling, + // which keeps its vertical rail running past this card. + const walk = (card: Card, depth: number, rails: boolean[], last: boolean) => { + const width = cardWidth(depth); + const inner = width - 2; + const shown = card.children.filter((child) => kept.has(child)); + const lines = [ + "┌" + "─".repeat(inner) + "┐", + ...card.body(inner).map((line) => "│" + fit(line, inner) + "│"), + shown.length > 0 + ? "└─┬" + "─".repeat(Math.max(0, inner - 2)) + "┘" + : "└" + "─".repeat(inner) + "┘", + ]; + lines.forEach((line, i) => { + let prefix = rails.map((rail) => (rail ? " │ " : " ")).join(""); + let body = line; + if (depth > 0) { + if (i === 0) prefix += " │ "; + else if (i === 1) { + prefix += last ? " └─" : " ├─"; + body = "┤" + line.slice(1); + } else prefix += last ? " " : " │ "; + } + out.push(frameRow(" " + prefix + body)); + }); + shown.forEach((child, i) => + walk(child, depth + 1, depth > 0 ? [...rails, !last] : rails, i === shown.length - 1), + ); + }; + for (const top of tops) if (kept.has(top)) walk(top, 0, [], true); + const history = withHistory ? historyLines() : []; + if (history.length > 0 && out.length > 0) out.push(frameRow("")); + out.push(...history.map(frameRow)); + if (hiddenTotal > 0) out.push(frameRow(` +${hiddenTotal} hidden agents`)); + return out; + }; // These frame lines are always drawn when the pane has content. const head = [ @@ -386,56 +460,45 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined frameSep(), frameRow(""), ]; - const harnessLegend = footerLegend(W); - const tail = [ - frameSep(), - frameRow(harnessLegend), - frameBot(), - ]; + const tail = [frameSep(), frameRow(footerLegend(W)), frameBot()]; const fixed = head.length + tail.length; - // The hidden line keeps its own stem only when it hangs off a full card. - const hiddenSize = (list: Block[]): number => - list.length > 0 && list[list.length - 1].kind === "card" ? 2 : 1; - let kept = blocks; + const all = new Set(); + const collect = (card: Card) => { + all.add(card); + card.children.forEach(collect); + }; + tops.forEach(collect); + + let kept = all; + let withHistory = true; let dropped = 0; - let showHidden = false; + let hiddenTotal = 0; if (limit !== undefined) { // Rule 7: the frame alone does not fit, so nothing is drawn at all. if (limit < fixed) return []; - // History leaves first, then full cards, with the oldest block leaving - // first within each group. The hidden count follows the block's session - // count rather than its number of rendered lines. - const victims = [...blocks].sort( - (a, b) => - Number(a.kind === "card") - Number(b.kind === "card") || - a.age - b.age || - (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), - ); - const sizeOf = (list: Block[]) => - list.reduce((sum, block, i) => sum + block.size + (stemAbove(list, i) ? 1 : 0), 0); - const needed = () => fixed + sizeOf(kept); - for (let vi = 0; needed() > limit && vi < victims.length; vi += 1) { - kept = kept.filter((block) => block !== victims[vi]); - dropped += victims[vi].count; + const needed = () => fixed + render(kept, withHistory, 0).length; + // History leaves first, then cards oldest first, and only a card with no + // child left on screen, so the tree never loses a link. The root carries + // +Infinity and is the last to go. The hidden count follows sessions, + // not rendered lines. + if (needed() > limit && historyLines().length > 0) { + withHistory = false; + dropped += historyCount; + } + kept = new Set(all); + while (needed() > limit && kept.size > 0) { + const leaves = [...kept].filter((card) => !card.children.some((child) => kept.has(child))); + leaves.sort((a, b) => a.age - b.age || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const victim = leaves[0]; + kept.delete(victim); + if (victim !== root) dropped += 1; } // A hidden line is useful only when it does not displace a live card. - showHidden = dropped > 0 && fixed + sizeOf(kept) + hiddenSize(kept) <= limit; + if (dropped > 0 && fixed + render(kept, withHistory, dropped).length <= limit) hiddenTotal = dropped; } - // `hidden` is already represented by the history block: the source does not - // expose the statuses of rows that were cut before formatting. - const hiddenTotal = showHidden ? dropped : 0; - const hiddenRow = frameRow(` +${hiddenTotal} hidden agents`); - const out: string[] = [...head]; - for (let i = 0; i < kept.length; i += 1) { - const connects = i < kept.length - 1 || hiddenTotal > 0; - out.push(...kept[i].build(connects, stemAbove(kept, i))); - } - if (hiddenTotal > 0) { - if (kept.length > 0 && kept[kept.length - 1].kind === "card") out.push(stem(), hiddenRow); - else out.push(hiddenRow); - } + const out: string[] = [...head, ...render(kept, withHistory, hiddenTotal)]; if (limit !== undefined) { const spare = limit - out.length - tail.length; for (let i = 0; i < spare; i += 1) out.push(frameRow("")); diff --git a/plugin/mods/agents/types.ts b/plugin/mods/agents/types.ts index 0423528..fc0a2d7 100644 --- a/plugin/mods/agents/types.ts +++ b/plugin/mods/agents/types.ts @@ -11,6 +11,9 @@ export interface SessionRow { id: string; runId?: string; origin?: string | null; + /** Session that dispatched this one; absent on rows older than lineage. */ + parentId?: string | null; + role?: string | null; name?: string; agent?: string; model?: string; @@ -32,13 +35,20 @@ export interface PaneRow { effort?: string; name: string; updatedAt?: string; + /** + * Id of the card this one hangs from, or undefined when it hangs from the + * run root. A parent outside the run (or a legacy row with none) is folded + * onto the root so every row stays reachable. + */ + parentId?: string; + role?: string; } /** Everything one drawing of the pane needs, already narrowed to one run. */ export interface PaneSnapshot { runId: string; /** The orchestrator's own row, the only one whose origin is "open". */ - orchestrator: { agent: string; model?: string; effort?: string } | undefined; + orchestrator: { agent: string; model?: string; effort?: string; role?: string } | undefined; rows: PaneRow[]; /** Rows that matched the run but fell outside the budget. */ hidden: number; diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index 6e80bcc..e670762 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -650,6 +650,7 @@ export function registerOpenCommand(program: Command): void { ...(openEffort !== undefined ? { effort: openEffort } : {}), cwd, name: role, + role, ...(opts.resume !== undefined ? { resume: opts.resume } : {}), }); const runId = adoptRes.session.id; @@ -750,6 +751,7 @@ export function registerOpenCommand(program: Command): void { cwd: openCwd, envExtra: { CODEDECK_RUN_ID: runId, + CODEDECK_SESSION_ID: runId, OPENCODE_CONFIG_CONTENT: buildInlineConfig( pluginDir, role, @@ -848,7 +850,7 @@ export function registerOpenCommand(program: Command): void { buildCodexOpenArgs(role, { ...opts, model, effort }, pluginDir, invocation.passthrough, openCwd, orchestratorMode), { cwd: openCwd, - envExtra: { CODEDECK_RUN_ID: runId }, + envExtra: { CODEDECK_RUN_ID: runId, CODEDECK_SESSION_ID: runId }, sessionFile, model, notFoundMessage: CODEX_NOT_FOUND, @@ -926,7 +928,7 @@ export function registerOpenCommand(program: Command): void { closeClaude, undefined, undefined, - { CODEDECK_RUN_ID: runId }, + { CODEDECK_RUN_ID: runId, CODEDECK_SESSION_ID: runId }, ptyLaunchForHarness("claude", pluginDir, sessionFile, opts, config, interactive), (child) => { if (child.pid) { diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts index 80a705a..841a21f 100644 --- a/src/cli/commands/run.ts +++ b/src/cli/commands/run.ts @@ -15,6 +15,15 @@ export function runIdFromEnvironment(env: NodeJS.ProcessEnv = process.env): stri return env.CODEDECK_RUN_ID || null; } +/** + * The session dispatching this run. A worker's harness carries its own id in + * CODEDECK_SESSION_ID, so a reviewer a worker starts hangs off that worker. + * An `open` session is the run root and its id is CODEDECK_RUN_ID. + */ +export function parentIdFromEnvironment(env: NodeJS.ProcessEnv = process.env): string | null { + return env.CODEDECK_SESSION_ID || env.CODEDECK_RUN_ID || null; +} + export function registerRunCommand(program: Command): void { program .command("run") @@ -189,6 +198,8 @@ Resume with: ${getCliName()} send "continue" const params: any = { prompt: rolePrompt, runId: runIdFromEnvironment(), + parentId: parentIdFromEnvironment(), + role: parseRole(opts.role), agent, model, effort, diff --git a/src/core/session.ts b/src/core/session.ts index b743030..3baa209 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -37,6 +37,12 @@ export interface Session { id: string; runId?: string | null; origin?: "open" | "run" | string | null; + // Session that dispatched this one (the open session or a worker that ran + // `codedeck run` itself). runId groups a whole tree flat; parentId is the + // edge that says who instantiated whom. + parentId?: string | null; + // CodeDeck role the session was started with (general, reviewer, ...). + role?: string | null; name?: string; agent: AgentId; nativeSessionId?: string; diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index ec95e8c..43e3084 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -23,6 +23,7 @@ import { readSessionProcessMetadata } from "../drivers/session-runtime.js"; import type { AgentEvent } from "../core/events.js"; import { loadConfig, resolveDefaultSandbox } from "../config/config.js"; import { classifyFailure, RunAgentError, type FailureInfo } from "../core/errors.js"; +import { parseRole } from "../core/roles.js"; import { getCachedOrDiscoverModels, type HarnessModels } from "../core/models.js"; import { aggregateRunUsage } from "../core/run-usage.js"; import { UsageLedger } from "../store/usage-ledger.js"; @@ -655,9 +656,16 @@ class Daemon { } const now = new Date(); + // Only record an edge to a session the store knows: a stale or foreign + // CODEDECK_SESSION_ID must not invent a parent the tree cannot draw. + const parent = typeof p.parentId === "string" && p.parentId.length > 0 + ? this.sessions.get(p.parentId) + : null; const session: any = { id: sessionId, - runId: typeof p.runId === "string" ? p.runId : undefined, + runId: typeof p.runId === "string" ? p.runId : parent?.runId ?? undefined, + parentId: parent?.id, + role: typeof p.role === "string" ? parseRole(p.role) : undefined, name: p.name, agent, model: p.model, @@ -771,6 +779,7 @@ class Daemon { id: sessionId, runId: sessionId, origin: "open", + role: typeof p.role === "string" ? parseRole(p.role) : undefined, name: p.name, agent, model: p.model, diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts index a44ee03..9bee358 100644 --- a/src/daemon/protocol.ts +++ b/src/daemon/protocol.ts @@ -33,6 +33,9 @@ export type RequestMethod = export interface RunOptions { prompt: string; runId?: string | null; + // Dispatching session, read by `run` from CODEDECK_SESSION_ID. + parentId?: string | null; + role?: string | null; agent?: AgentId; model?: string; effort?: ReasoningEffort; @@ -59,6 +62,7 @@ export interface AdoptSessionRequest { effort?: ReasoningEffort; cwd: string; name?: string; + role?: string; worktree?: string; branch?: string; baseCommit?: string; diff --git a/src/store/database.ts b/src/store/database.ts index 2f65d86..8419ec8 100644 --- a/src/store/database.ts +++ b/src/store/database.ts @@ -70,7 +70,9 @@ export class Database { fast INTEGER NOT NULL DEFAULT 0, sandbox TEXT, dangerously_bypass_approvals_and_sandbox INTEGER, - origin TEXT + origin TEXT, + parent_id TEXT, + role TEXT ); CREATE TABLE IF NOT EXISTS events ( @@ -168,6 +170,8 @@ export class Database { ["origin", "TEXT"], ["pending_message", "TEXT"], ["pending_at", "TEXT"], + ["parent_id", "TEXT"], + ["role", "TEXT"], ]; for (const [name, type] of additions) { if (!existing.has(name)) this.db.exec(`ALTER TABLE sessions ADD COLUMN ${name} ${type}`); diff --git a/src/store/sessions.ts b/src/store/sessions.ts index 9de419d..c65ebaa 100644 --- a/src/store/sessions.ts +++ b/src/store/sessions.ts @@ -35,6 +35,8 @@ export interface SessionRow { log_offset: number | null; stderr_offset: number | null; origin: string | null; + parent_id: string | null; + role: string | null; pending_message: string | null; pending_at: string | null; } @@ -66,6 +68,8 @@ function rowToSession(row: SessionRow): Session { id: row.id, runId: row.run_id ?? undefined, origin: (row.origin as Session["origin"]) ?? undefined, + parentId: row.parent_id ?? undefined, + role: row.role ?? undefined, name: row.name ?? undefined, agent: row.agent as AgentId, nativeSessionId: row.native_session_id ?? undefined, @@ -133,12 +137,12 @@ export class SessionStore { pid_start_time, created_at, updated_at, completed_at, usage_input_tokens, usage_output_tokens, usage_cached_tokens, usage_cost, last_event, effort, fast, sandbox, dangerously_bypass_approvals_and_sandbox, failure, log_offset, stderr_offset, - run_id, origin, pending_message, pending_at + run_id, origin, pending_message, pending_at, parent_id, role ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ?, ? ) `); stmt.run( @@ -174,6 +178,8 @@ export class SessionStore { session.origin ?? null, session.pendingMessage ?? null, session.pendingAt ?? null, + session.parentId ?? null, + session.role ?? null, ); } diff --git a/tests/daemon-lineage.test.ts b/tests/daemon-lineage.test.ts new file mode 100644 index 0000000..0eb792b --- /dev/null +++ b/tests/daemon-lineage.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Daemon } from "../src/daemon/daemon.js"; +import type { Session } from "../src/core/session.js"; +import { fakeSocket, seam } from "./helpers/daemon-seam.js"; + +let runAgentDir: string; +let configDir: string; +let daemon: Daemon; +let requestNumber: number; +const originalRunAgentDir = process.env.RUN_AGENT_DIR; +const originalConfigDir = process.env.RUN_AGENT_CONFIG_DIR; + +beforeEach(() => { + runAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "daemon-lineage-")); + configDir = fs.mkdtempSync(path.join(os.tmpdir(), "daemon-lineage-config-")); + process.env.RUN_AGENT_DIR = runAgentDir; + process.env.RUN_AGENT_CONFIG_DIR = configDir; + requestNumber = 0; + daemon = new Daemon(); + (daemon as unknown as { startDriverForSession: () => Promise }).startDriverForSession = async () => {}; +}); + +afterEach(() => { + try { seam(daemon).db.close(); } catch {} + if (originalRunAgentDir === undefined) delete process.env.RUN_AGENT_DIR; + else process.env.RUN_AGENT_DIR = originalRunAgentDir; + if (originalConfigDir === undefined) delete process.env.RUN_AGENT_CONFIG_DIR; + else process.env.RUN_AGENT_CONFIG_DIR = originalConfigDir; + fs.rmSync(runAgentDir, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); +}); + +async function call(method: string, params: Record): Promise { + const { writes, socket } = fakeSocket(); + await seam(daemon).handleRequest({ id: `lineage-${++requestNumber}`, method, params }, socket); + const response = JSON.parse(writes[0]) as { result: { session: Session } }; + const session = seam(daemon).sessions.get(response.result.session.id); + if (!session) throw new Error("session was not persisted"); + return session; +} + +const run = (extra: Record) => + call("session.create", { prompt: "task", agent: "claude", cwd: runAgentDir, noWorktree: true, ...extra }); + +describe("session lineage", () => { + it("records the open session's role and each run's dispatcher", async () => { + const root = await call("session.adopt", { agent: "claude", cwd: runAgentDir, name: "general", role: "general" }); + expect(root.role).toBe("general"); + expect(root.parentId).toBeUndefined(); + + const worker = await run({ runId: root.id, parentId: root.id, role: "general" }); + const reviewer = await run({ runId: root.id, parentId: worker.id, role: "rev" }); + + expect(worker.parentId).toBe(root.id); + expect(reviewer.parentId).toBe(worker.id); + expect(reviewer.runId).toBe(root.id); + expect(reviewer.role).toBe("reviewer"); + }); + + it("drops a parent the store does not know", async () => { + const orphan = await run({ parentId: "nope-not-a-session", role: "nonsense" }); + expect(orphan.parentId).toBeUndefined(); + expect(orphan.role).toBeUndefined(); + }); + + it("inherits the parent's run when the request carries none", async () => { + const root = await call("session.adopt", { agent: "claude", cwd: runAgentDir, name: "general" }); + const worker = await run({ parentId: root.id }); + expect(worker.runId).toBe(root.id); + }); +}); diff --git a/tests/mods-agents/pane.test.ts b/tests/mods-agents/pane.test.ts index eca7d78..487d28f 100644 --- a/tests/mods-agents/pane.test.ts +++ b/tests/mods-agents/pane.test.ts @@ -258,12 +258,12 @@ describe("formatPane", () => { expect(at21.join("\n")).not.toContain("cNew"); expect(at21.join("\n")).toContain("+2 hidden agents"); - // At 20 the live cards still win, and the hidden line gives way. - const at20 = formatPane(snap, 40, 20); - expect(at20).toHaveLength(20); - expect(at20.join("\n")).toContain("wNew"); - expect(at20.join("\n")).toContain("wOld"); - expect(at20.join("\n")).not.toContain("hidden agents"); + // At 18 the live cards still win, and the hidden line gives way. + const at18 = formatPane(snap, 40, 18); + expect(at18).toHaveLength(18); + expect(at18.join("\n")).toContain("wNew"); + expect(at18.join("\n")).toContain("wOld"); + expect(at18.join("\n")).not.toContain("hidden agents"); }); it("keeps history counts in the summary and reports dropped blocks when useful (rule 6)", () => { @@ -556,9 +556,12 @@ describe("formatPane", () => { { id: "c3", status: "stopped", agent: "codex", name: "three" }, ]; const lines = formatPane(snapshot({ rows, hidden: 0, total: 4 }), 89); - expect(lines.filter((l) => l === STEM89)).toHaveLength(1); - expect(lines.join("\n")).not.toContain(STEM89 + "\n" + STEM89); - expect(lines.join("\n")).toContain("HISTORY · 3 finished"); + // History sits under the tree after one blank row; no rail runs into it. + expect(lines.filter((l) => l === STEM89)).toHaveLength(0); + expect(lines.filter((l) => l.includes("HISTORY"))).toHaveLength(1); + const at = lines.findIndex((l) => l.includes("HISTORY · 3 finished")); + expect(lines[at - 1]).toBe("│" + " ".repeat(87) + "│"); + expect(lines[at - 2]).toContain("└──"); }); it("draws history as the root block with no stem at all (mandatory)", () => { @@ -584,12 +587,12 @@ describe("formatPane", () => { expect(joined16).toContain("+2 hidden agents"); expect(joined16).not.toContain("a1"); expect(joined16).not.toContain("b2"); - // At 13 rows the root stays, but the hidden line gives way. - const at13 = formatPane(snapshot(), 40, 13); - expect(at13).toHaveLength(13); - const joined13 = at13.join("\n"); - expect(joined13).toContain("Orchestrator"); - expect(joined13).not.toContain("hidden agents"); + // At 12 rows the root stays, but the hidden line gives way. + const at12 = formatPane(snapshot(), 40, 12); + expect(at12).toHaveLength(12); + const joined12 = at12.join("\n"); + expect(joined12).toContain("Orchestrator"); + expect(joined12).not.toContain("hidden agents"); }); it("renders the same 19 workers at 12 rows as header, footer and hidden line (mandatory)", () => { @@ -657,3 +660,126 @@ describe("paneButtonLabel", () => { expect(paneButtonLabel(broken)).toBe("no active agents"); }); }); + +describe("lineage", () => { + const at = (minutes: number) => new Date(Date.parse("2026-09-19T12:00:00.000Z") + minutes * 60000).toISOString(); + + it("keeps a parent edge only to a worker the snapshot kept", () => { + const snap = selectPane( + [ + session({ id: RUN, origin: "open", role: "general" }), + session({ id: "g1", parentId: RUN, role: "general", status: "working", updatedAt: at(1) }), + session({ id: "r1", parentId: "g1", role: "reviewer", status: "working", updatedAt: at(2) }), + session({ id: "x1", parentId: "elsewhere", updatedAt: at(0) }), + session({ id: "o1", updatedAt: at(-1) }), + ], + RUN, + ); + const byId = Object.fromEntries(snap.rows.map((row) => [row.id, row])); + expect(snap.orchestrator?.role).toBe("general"); + expect(byId.g1.parentId).toBeUndefined(); + expect(byId.r1.parentId).toBe("g1"); + expect(byId.r1.role).toBe("reviewer"); + expect(byId.x1.parentId).toBeUndefined(); + expect(byId.o1.parentId).toBeUndefined(); + // A budget that cuts the parent folds the child onto the root. + const cut = selectPane( + [ + session({ id: "g1", status: "working", updatedAt: at(1) }), + session({ id: "r1", parentId: "g1", status: "working", updatedAt: at(2) }), + ], + RUN, + 1, + ); + expect(cut.rows.map((row) => [row.id, row.parentId])).toEqual([["r1", undefined]]); + }); + + it("draws a reviewer under the worker that dispatched it, not under the root", () => { + const lines = formatPane( + snapshot({ + orchestrator: { agent: "claude", role: "general" }, + rows: [ + { id: "r1", status: "working", agent: "claude", name: "review", role: "reviewer", parentId: "g1" }, + { id: "g2", status: "working", agent: "codex", name: "second", role: "general" }, + { id: "g1", status: "working", agent: "claude", name: "first", role: "general" }, + ], + total: 4, + }), + 80, + ); + expectCleanWidth(lines, 80); + const row = (needle: string) => lines.findIndex((l) => l.includes(needle)); + expect(lines[row("General")]).toMatch(/^│ │ . General/); + expect(lines.some((l) => l.includes("Orchestrator"))).toBe(false); + // Root children open at depth 1; the reviewer opens one level deeper, + // right under its own parent. + expect(lines[row("g2")]).toMatch(/^│ {3}├─┤ .* g2 {2}General · Codex/); + expect(lines[row("g1")]).toMatch(/^│ {3}└─┤ .* g1 {2}General · Claude/); + expect(lines[row("r1")]).toMatch(/^│ {7}└─┤ .* r1 {2}Reviewer · Claude/); + expect(row("g1")).toBeLessThan(row("r1")); + // The parent's bottom edge carries the tee the reviewer hangs from. + expect(lines[row("r1") - 2]).toMatch(/^│ {5}└─┬─+┘/); + }); + + it("keeps a finished worker on screen while its reviewer is still live", () => { + const lines = formatPane( + snapshot({ + rows: [ + { id: "r1", status: "working", agent: "claude", name: "review", parentId: "g1" }, + { id: "g1", status: "completed", agent: "claude", name: "impl" }, + ], + total: 3, + }), + 80, + ); + const joined = lines.join("\n"); + expect(joined).toMatch(/g1 .*\n.*impl.*\n.*Completed/); + expect(joined).not.toContain("HISTORY"); + }); + + it("names the dispatching worker next to a finished child in history", () => { + const lines = formatPane( + snapshot({ + rows: [ + { id: "r1", status: "completed", agent: "claude", name: "review", parentId: "g1" }, + { id: "g1", status: "completed", agent: "claude", name: "impl" }, + ], + total: 3, + }), + 80, + ); + expect(lines.some((l) => l.includes("r1 review ← g1"))).toBe(true); + }); + + it("evicts a child before its parent so the tree stays connected", () => { + const snap = snapshot({ + rows: [ + { id: "r1", status: "working", agent: "claude", name: "review", parentId: "g1", updatedAt: at(5) }, + { id: "g1", status: "working", agent: "claude", name: "impl", updatedAt: at(-60) }, + ], + total: 3, + }); + // Frame 8 + root 4 + g1 5: room for one worker card, not two. + const lines = formatPane(snap, 40, 18); + const joined = lines.join("\n"); + expect(joined).toContain("g1"); + expect(joined).not.toContain("r1"); + expect(joined).toContain("+1 hidden agents"); + }); + + it("survives a parent cycle", () => { + const lines = formatPane( + snapshot({ + rows: [ + { id: "a", status: "working", agent: "claude", name: "a", parentId: "b" }, + { id: "b", status: "working", agent: "claude", name: "b", parentId: "a" }, + ], + total: 3, + }), + 60, + ); + expectCleanWidth(lines, 60); + expect(lines.some((l) => l.includes(" a Claude"))).toBe(true); + expect(lines.some((l) => l.includes(" b Claude"))).toBe(true); + }); +}); diff --git a/tests/run-role.test.ts b/tests/run-role.test.ts index 0ba5e74..76877ff 100644 --- a/tests/run-role.test.ts +++ b/tests/run-role.test.ts @@ -13,7 +13,7 @@ vi.mock("../src/daemon/ipc.js", () => ({ }, })); -const { registerRunCommand, runIdFromEnvironment } = await import("../src/cli/commands/run.js"); +const { registerRunCommand, runIdFromEnvironment, parentIdFromEnvironment } = await import("../src/cli/commands/run.js"); class Exited extends Error { constructor(readonly code: number) { @@ -73,6 +73,30 @@ describe("codedeck run --role", () => { expect(params.runId).toBe("run-from-open"); }); + it("prefers the dispatching session over the run root as parent", () => { + expect(parentIdFromEnvironment({ CODEDECK_SESSION_ID: "w1", CODEDECK_RUN_ID: "r1" })).toBe("w1"); + expect(parentIdFromEnvironment({ CODEDECK_RUN_ID: "r1" })).toBe("r1"); + expect(parentIdFromEnvironment({ CODEDECK_SESSION_ID: "", CODEDECK_RUN_ID: "" })).toBeNull(); + }); + + it("sends the dispatcher and the parsed role to session.create", async () => { + const previous = { run: process.env.CODEDECK_RUN_ID, session: process.env.CODEDECK_SESSION_ID }; + process.env.CODEDECK_RUN_ID = "run-from-open"; + process.env.CODEDECK_SESSION_ID = "worker-1"; + try { + await expect(runProgram(["review it", "--agent", "codex", "--role", "rev", "--effort", "high", "--bg"])) + .rejects.toThrow(Exited); + } finally { + for (const [key, value] of [["CODEDECK_RUN_ID", previous.run], ["CODEDECK_SESSION_ID", previous.session]] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + const [, params] = request.mock.calls[0]; + expect(params.parentId).toBe("worker-1"); + expect(params.role).toBe("reviewer"); + }); + it("maps a missing run id to null", () => { expect(runIdFromEnvironment({})).toBeNull(); });