Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions src/engine/spawnSubagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
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.
Expand All @@ -377,7 +380,10 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
} 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
Expand Down Expand Up @@ -582,7 +588,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
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).
Expand All @@ -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<string>();

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<SpawnResult> {
/** #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<SpawnResult> {
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);
Expand Down
6 changes: 5 additions & 1 deletion src/panel/rows.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -140,14 +141,17 @@ 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 {
const icon = s.paused ? "⏸" : "▶";
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<LifecycleStatus, string> = {
Expand Down
6 changes: 5 additions & 1 deletion src/panel/runs-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 7 additions & 1 deletion src/panel/widget-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <child-cwd>/.pi/fleet/worktrees/<runId> — 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)
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/run-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
13 changes: 10 additions & 3 deletions test/rows.test.mts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
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, /↗/);
});
20 changes: 19 additions & 1 deletion test/runs-rows.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
});
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, /✎/);
});
6 changes: 5 additions & 1 deletion test/scheduler.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 10 additions & 0 deletions test/widget-rows.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`);
});
Loading