diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index 94bd271..f53705b 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -351,7 +351,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { priorStatus = link.priorStatus; opts.runRegistry.update(runId, { todoId }); } catch (e) { - return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, (e as Error).message, agentDef.name, model); + return await finishRun({ + opts, runId, startedAt, status: "failed", finalText: "", todoId, priorStatus, + error: (e as Error).message, agentName: agentDef.name, model, + }); } // SPEC-6-1: fallback retry loop — try candidates[0], on rejection retry candidates[1], etc. @@ -377,7 +380,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { } catch (e) { lastErr = e as Error; } } if (!session) { - return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, `backend create failed: ${lastErr?.message ?? "unknown"}`, agentDef.name, model, 0, 0, 0); + return await finishRun({ + opts, runId, startedAt, status: "failed", finalText: "", todoId, priorStatus, + error: `backend create failed: ${lastErr?.message ?? "unknown"}`, agentName: agentDef.name, model, + }); } // SPEC-5b-4: retain a narrow live-session handle on the run record so the panel can @@ -582,7 +588,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { status = "completed"; } - return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary, toolCallCount); + return await finishRun({ + opts, runId, startedAt, status, finalText, todoId, priorStatus, error, + agentName: agentDef.name, model, tokenTotal, costTotal, contextTokens, + retryable: modelError ? true : undefined, filesTouched: filesTouchedList, + reachedSummary, toolCallCount, + }); } finally { // #31: a readOnly dispatch never acquired the lock — don't release what it didn't take // (releasing a lock held by another concurrent write dispatch would corrupt serialization). @@ -600,13 +611,34 @@ function fail(runId: string, startedAt: number, message: string, agent: string): /** SPEC-6-2: guard against double-finishRun (abort-then-complete). */ const finalizedRunIds = new Set(); -async function finishRun( - opts: SpawnOptions, runId: string, startedAt: number, - status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined, - error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0, - retryable?: boolean, - filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0, -): Promise { +/** #NIT: options object — was 17 positional params (3 call sites, error-prone at the tail). */ +interface FinishRunArgs { + opts: SpawnOptions; + runId: string; + startedAt: number; + status: FleetRunStatus; + finalText: string; + todoId: string | null; + priorStatus?: string; + error?: string; + agentName: string; + model: string; + tokenTotal?: number; + costTotal?: number; + contextTokens?: number; + retryable?: boolean; + filesTouched?: string[]; + reachedSummary?: boolean; + toolCallCount?: number; +} + +async function finishRun(a: FinishRunArgs): Promise { + const { opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentName, model } = a; + const tokenTotal = a.tokenTotal ?? 0; + const costTotal = a.costTotal ?? 0; + const contextTokens = a.contextTokens ?? 0; + const toolCallCount = a.toolCallCount ?? 0; + const { retryable, filesTouched, reachedSummary } = a; if (finalizedRunIds.has(runId)) { // Already finalized — return the existing registry record's result without re-appending. const existing = opts.runRegistry.get(runId); diff --git a/src/panel/rows.ts b/src/panel/rows.ts index 5ea07cc..47b870f 100644 --- a/src/panel/rows.ts +++ b/src/panel/rows.ts @@ -1,4 +1,5 @@ // src/panel/rows.ts +import { basename } from "node:path"; import type { AgentDef } from "../registry/frontmatter.ts"; import type { FleetRunStatus } from "../todo-sync/port.ts"; import type { RunRecord } from "../engine/run-registry.ts"; @@ -140,6 +141,8 @@ export interface ScheduleRow { task: string; nextFire: Date | null; paused: boolean; + /** #62: pinned dispatch cwd — rendered as a ↗ basename so cross-cwd schedules are visible. */ + cwd?: string; } export function scheduleRow(s: ScheduleRow): string { @@ -147,7 +150,8 @@ export function scheduleRow(s: ScheduleRow): string { const next = s.nextFire ? `next: ${s.nextFire.toLocaleString()}` : "paused"; const task = s.task.length > 24 ? s.task.slice(0, 23) + "…" : s.task; const lc = s.lifecycle ?? "default"; - return `${icon} ${s.expression} ${lc} "${task}" ${next} ${s.id}`; + const cwd = s.cwd ? ` ↗${basename(s.cwd)}` : ""; + return `${icon} ${s.expression} ${lc} "${task}"${cwd} ${next} ${s.id}`; } const LC_GLYPH: Record = { diff --git a/src/panel/runs-rows.ts b/src/panel/runs-rows.ts index 6a0c638..ead199e 100644 --- a/src/panel/runs-rows.ts +++ b/src/panel/runs-rows.ts @@ -16,9 +16,13 @@ export function runsRow(r: RunMeta, getModelContextWindow?: (model: string) => n const maxCtx = getModelContextWindow?.(r.model); const ctx = (r.contextTokens != null && maxCtx != null && maxCtx > 0) ? ` ${Math.round(r.contextTokens / maxCtx * 100)}%` : ""; const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : ""; + // #59/#60/#61 NIT: the journal fields v0.14.0 added to run:ended, now visible in the list. + const err = r.error ? ` ✗"${r.error.length > 60 ? r.error.slice(0, 59) + "…" : r.error}"` : ""; + const tools = r.toolCallCount != null ? ` ·${r.toolCallCount}t` : ""; + const files = r.filesTouched?.length ? ` ✎${r.filesTouched.length}` : ""; const summary = r.resultSummary ? ` "${r.resultSummary}"` : ""; const prov = r.resumedFrom ? ` ← resumed:${r.resumedFrom}` : r.forkedFrom ? ` ← forked:${r.forkedFrom}` : ""; - return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${ctx}${cost}${summary}${prov}`; + return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${ctx}${cost}${tools}${files}${err}${summary}${prov}`; } export function runTimelineRow(e: MessageEvent | ToolEvent): string { diff --git a/src/panel/widget-rows.ts b/src/panel/widget-rows.ts index 7916b68..4b157db 100644 --- a/src/panel/widget-rows.ts +++ b/src/panel/widget-rows.ts @@ -119,7 +119,13 @@ function widgetLine(r: WidgetRun, now: number): string { const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId; // SPEC-6-5: cross-cwd glyph — when the run's cwd differs from the session cwd, mark it so the // operator sees "this run is scoped to a different project" at a glance. Same-cwd → no glyph. - const crossCwd = (r.cwd && r.sessionCwd && r.cwd !== r.sessionCwd) ? ` ↗${basename(r.cwd)}` : ""; + // #62 NIT: isolated runs pin cwd to /.pi/fleet/worktrees/ — the basename + // was the cryptic run-id. Strip the worktrees suffix so the glyph names the TARGET repo dir. + const displayCwd = (cwd: string): string => { + const wt = cwd.indexOf("/.pi/fleet/worktrees/"); + return wt > 0 ? basename(cwd.slice(0, wt)) : basename(cwd); + }; + const crossCwd = (r.cwd && r.sessionCwd && r.cwd !== r.sessionCwd) ? ` ↗${displayCwd(r.cwd)}` : ""; const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : ""; // #23: liveness — only after LIVENESS_THRESHOLD_MS, to keep short runs concise (per acceptance). // turn N/max + last-event class (no prompt content, no args/results — only the tool name) diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index 8b625ec..d357931 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -61,6 +61,12 @@ export interface RunMeta { cwd?: string; /** SPEC-6-5: the session cwd the dispatch originated from (= parentCwd). */ sessionCwd?: string; + /** #59 NIT: the failure reason on failed runs (from run:ended). */ + error?: string; + /** #61 NIT: executed-tool count (the zero-work signal). */ + toolCallCount?: number; + /** #60 NIT: file paths the run mutated. */ + filesTouched?: string[]; } const ARGS_LIMIT = 200; @@ -145,6 +151,8 @@ export class RunLog { meta.resultSummary = ended.resultSummary; meta.tokenTotal = ended.tokenTotal; meta.resumedFrom = ended.resumedFrom; meta.forkedFrom = ended.forkedFrom; meta.costTotal = ended.costTotal; meta.contextTokens = ended.contextTokens; + // #59/#60/#61 NIT: surface the newer journal fields to the Runs tab too. + meta.error = ended.error; meta.toolCallCount = ended.toolCallCount; meta.filesTouched = ended.filesTouched; } out.push(meta); } diff --git a/test/rows.test.mts b/test/rows.test.mts index 73afe7c..2032fc7 100644 --- a/test/rows.test.mts +++ b/test/rows.test.mts @@ -1,7 +1,7 @@ // test/rows.test.mts import { test } from "node:test"; -import { strictEqual, ok } from "node:assert"; -import { fleetRow, agentsRow, fmtDuration } from "../src/panel/rows.ts"; +import { strictEqual, ok, match, doesNotMatch } from "node:assert"; +import { fleetRow, agentsRow, fmtDuration, scheduleRow } from "../src/panel/rows.ts"; import type { RunRecord } from "../src/engine/run-registry.ts"; import type { AgentDef } from "../src/registry/frontmatter.ts"; @@ -51,4 +51,11 @@ test("agentsRow default model + tools/skills omitted", () => { ok(r.includes("armory:[t✗ m✗ v✗]"), r); ok(!r.includes("tools:"), r); ok(!r.includes("skills:"), r); -}); \ No newline at end of file +}); +test("#62 NIT: scheduleRow renders the pinned cwd as a ↗ basename; absent cwd renders nothing", () => { + const base = { id: "sch-1", expression: "*/5 * * * *", lifecycle: "default", task: "sweep", nextFire: new Date("2026-09-01T00:00:00Z"), paused: false }; + const withCwd = scheduleRow({ ...base, cwd: "/Users/rector/local-dev/getpipher/armory-fleet" }); + match(withCwd, /↗armory-fleet/); + const without = scheduleRow(base); + doesNotMatch(without, /↗/); +}); diff --git a/test/runs-rows.test.mts b/test/runs-rows.test.mts index 3007c25..943d6a9 100644 --- a/test/runs-rows.test.mts +++ b/test/runs-rows.test.mts @@ -67,4 +67,22 @@ test("runsRow: ctx% hidden when maxContext unresolved; $ hidden when costTotal 0 const line = runsRow(meta({ contextTokens: 100, costTotal: 0 }), () => undefined); assert.doesNotMatch(line, /%/, `no ctx% without maxContext: ${line}`); assert.doesNotMatch(line, /\$/, `no $ when costTotal 0: ${line}`); -}); \ No newline at end of file +}); +test("#59/#60/#61 NIT: runsRow renders the journal fields (error, toolCallCount, filesTouched)", () => { + const failed = runsRow(meta({ + status: "failed", + error: "model call ended with stopReason 'error' (provider/auth failure or rate limit)", + toolCallCount: 7, filesTouched: ["/a.ts", "/b.ts", "/c.ts"], + })); + assert.match(failed, /✗"model call ended with stopReason 'error' \(provider\/auth fai…"/, "long error truncated at 60"); + assert.match(failed, /·7t/); + assert.match(failed, /✎3/); + + const clean = runsRow(meta({ toolCallCount: 0, filesTouched: [] })); + assert.match(clean, /·0t/, "zero tools is the #61 zero-work signal — it MUST render"); + assert.doesNotMatch(clean, /✎/, "empty filesTouched renders nothing"); + + const absent = runsRow(meta()); + assert.doesNotMatch(absent, /·\d+t/, "undefined toolCallCount renders nothing"); + assert.doesNotMatch(absent, /✎/); +}); diff --git a/test/scheduler.test.mts b/test/scheduler.test.mts index 9f73873..1734591 100644 --- a/test/scheduler.test.mts +++ b/test/scheduler.test.mts @@ -139,7 +139,11 @@ test("#62: cwd threads through register + list, survives persist+load, and reach sch2.start(); const id3 = sch2.register({ task: "t3", expression: "1s", lifecycle: "default", auto: true, cwd: "/fired/cwd" }); void id3; - await new Promise((r) => setTimeout(r, 1500)); + // NIT: poll for the fire instead of a blind sleep — deterministic-ish and faster (fires ≈1.0s). + const fireDeadline = Date.now() + 5000; + while (!fired.some((f) => f.task === "t3") && Date.now() < fireDeadline) { + await new Promise((r) => setTimeout(r, 25)); + } sch2.stop(); const firedCwd = fired.find((f) => f.task === "t3")?.cwd; assert.equal(firedCwd, "/fired/cwd", "onFire spec carries cwd"); diff --git a/test/widget-rows.test.mts b/test/widget-rows.test.mts index a0de86e..8a8a5b4 100644 --- a/test/widget-rows.test.mts +++ b/test/widget-rows.test.mts @@ -298,3 +298,13 @@ test("SPEC-6-5: same-cwd fg run has no ↗ glyph", () => { const lines = renderWidgetLines([w], 2000); ok(!lines[0]!.includes("↗"), `same-cwd → no glyph: ${lines[0]}`); }); + +test("#62 NIT: isolated-run cwd (worktree path) shows the TARGET repo basename, not the run-id", () => { + const w = toWidgetRun(fg({ + runId: "fl-iso", startedAt: 1000, task: "do", + cwd: "/Users/r/target-repo/.pi/fleet/worktrees/fl-iso", sessionCwd: "/Users/r/session", + })); + const lines = renderWidgetLines([w], 2000); + ok(lines[0]!.includes("\u2197target-repo"), `isolated glyph names the target repo: ${lines[0]}`); + ok(!lines[0]!.includes("\u2197fl-iso"), `no cryptic run-id basename: ${lines[0]}`); +});