feat(background): add background registry and status tool with tests

This commit is contained in:
pi
2026-04-12 13:49:55 +01:00
parent afab606237
commit 21ae108bae
6 changed files with 306 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createBackgroundRegistry } from "./background-registry.ts";
test("recordLaunch and counts update correctly", () => {
const reg = createBackgroundRegistry();
reg.recordLaunch({ runId: "r1", preset: "p1", task: "t1" });
reg.recordLaunch({ runId: "r2", preset: "p2", task: "t2" });
let counts = reg.getCounts();
assert.equal(counts.running, 2);
assert.equal(counts.total, 2);
reg.recordUpdate("r2", { status: "completed", exitCode: 0, finalText: "done" });
counts = reg.getCounts();
assert.equal(counts.running, 1);
assert.equal(counts.completed, 1);
assert.equal(counts.total, 2);
const r1 = reg.getRun("r1");
const r2 = reg.getRun("r2");
assert.ok(r1);
assert.ok(r2);
assert.equal(r2?.finalText, "done");
});
test("replay seeds registry and counts", () => {
const reg = createBackgroundRegistry();
const now = Date.now();
reg.replay([
{ runId: "a", preset: "p", task: "x", status: "running", startTime: now },
{ runId: "b", preset: "p2", task: "y", status: "failed", startTime: now - 1000, endTime: now - 500 },
]);
const counts = reg.getCounts();
assert.equal(counts.running, 1);
assert.equal(counts.failed, 1);
assert.equal(counts.total, 2);
const snap = reg.getSnapshot({ includeCompleted: true });
assert.equal(snap.length, 2);
});

136
src/background-registry.ts Normal file
View File

@@ -0,0 +1,136 @@
export type BackgroundStatus = "running" | "completed" | "failed" | "aborted";
export interface BackgroundRun {
runId: string;
preset: string;
task: string;
requestedModel?: string;
resolvedModel?: string;
status: BackgroundStatus;
startTime: number; // epoch ms
endTime?: number; // epoch ms when finished
finalText?: string;
exitCode?: number;
stopReason?: string;
paths?: Record<string, string>;
meta?: Record<string, unknown>;
}
export interface BackgroundRegistry {
recordLaunch(entry: {
runId: string;
preset: string;
task: string;
requestedModel?: string;
resolvedModel?: string;
paths?: Record<string, string>;
meta?: Record<string, unknown>;
}): void;
recordUpdate(runId: string, update: Partial<Omit<BackgroundRun, "runId" | "startTime">>): void;
replay(entries: BackgroundRun[]): void;
getRun(runId: string): BackgroundRun | undefined;
getSnapshot(opts?: { runId?: string; includeCompleted?: boolean }): BackgroundRun[];
getCounts(): { running: number; completed: number; failed: number; aborted: number; total: number };
}
export function createBackgroundRegistry(): BackgroundRegistry {
const map = new Map<string, BackgroundRun>();
function recordLaunch(entry: {
runId: string;
preset: string;
task: string;
requestedModel?: string;
resolvedModel?: string;
paths?: Record<string, string>;
meta?: Record<string, unknown>;
}) {
const now = Date.now();
const run: BackgroundRun = {
runId: entry.runId,
preset: entry.preset,
task: entry.task,
requestedModel: entry.requestedModel,
resolvedModel: entry.resolvedModel,
status: "running",
startTime: now,
paths: entry.paths,
meta: entry.meta,
};
map.set(entry.runId, run);
}
function recordUpdate(runId: string, update: Partial<Omit<BackgroundRun, "runId" | "startTime">>) {
const existing = map.get(runId);
if (!existing) return;
const merged: BackgroundRun = { ...existing, ...update } as BackgroundRun;
if ((update.status && update.status !== "running") || (update.exitCode !== undefined && merged.status === "running")) {
// if status moved to finished states or exitCode provided while status still running, set endTime
if (!merged.endTime) merged.endTime = Date.now();
}
// if status explicitly set to completed/failed/aborted and no endTime, set it
if ((merged.status === "completed" || merged.status === "failed" || merged.status === "aborted") && !merged.endTime) {
merged.endTime = Date.now();
}
map.set(runId, merged);
}
function replay(entries: BackgroundRun[]) {
map.clear();
for (const e of entries) {
// keep provided timestamps and status as-is
map.set(e.runId, { ...e });
}
}
function getRun(runId: string) {
const r = map.get(runId);
return r ? { ...r } : undefined;
}
function getSnapshot(opts?: { runId?: string; includeCompleted?: boolean }) {
const includeCompleted = Boolean(opts?.includeCompleted);
if (opts?.runId) {
const r = map.get(opts.runId);
if (!r) return [];
if (!includeCompleted && (r.status === "completed" || r.status === "failed" || r.status === "aborted")) return [];
return [{ ...r }];
}
const arr = Array.from(map.values()).filter((r) => includeCompleted || r.status === "running");
// sort by startTime desc (most recent first)
arr.sort((a, b) => (b.startTime || 0) - (a.startTime || 0));
return arr.map((r) => ({ ...r }));
}
function getCounts() {
let running = 0,
completed = 0,
failed = 0,
aborted = 0;
for (const r of map.values()) {
switch (r.status) {
case "running":
running++;
break;
case "completed":
completed++;
break;
case "failed":
failed++;
break;
case "aborted":
aborted++;
break;
}
}
const total = running + completed + failed + aborted;
return { running, completed, failed, aborted, total };
}
return { recordLaunch, recordUpdate, replay, getRun, getSnapshot, getCounts };
}

35
src/background-schema.ts Normal file
View File

@@ -0,0 +1,35 @@
import { StringEnum } from "@mariozechner/pi-ai";
import { Type, type Static } from "@sinclair/typebox";
export function createBackgroundAgentSchema(availableModels: readonly string[]) {
return Type.Object(
{
preset: Type.String({ description: "Subagent preset name to use for this task" }),
task: Type.String({ description: "Task to delegate to the child agent" }),
model: Type.Optional(
StringEnum(availableModels, {
description: "Optional child model override. Must be one of the currently available models.",
}),
),
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
},
{ description: "Parameters for launching a background_agent run" },
);
}
export const BackgroundAgentSchema = createBackgroundAgentSchema([]);
export function createBackgroundAgentStatusSchema() {
return Type.Object(
{
runId: Type.Optional(Type.String({ description: "Optional runId to query a single run" })),
includeCompleted: Type.Optional(Type.Boolean({ description: "Include completed/finished runs in listing" })),
},
{ description: "Parameters for background_agent_status tool" },
);
}
export const BackgroundAgentStatusSchema = createBackgroundAgentStatusSchema();
export type BackgroundAgentParams = Static<typeof BackgroundAgentSchema>;
export type BackgroundAgentStatusParams = Static<typeof BackgroundAgentStatusSchema>;

View File

@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createBackgroundRegistry } from "./background-registry.ts";
import { createBackgroundStatusTool } from "./background-status-tool.ts";
test("status tool shows active runs by default and can query single run", async () => {
const reg = createBackgroundRegistry();
reg.recordLaunch({ runId: "r1", preset: "p1", task: "t1" });
reg.recordLaunch({ runId: "r2", preset: "p2", task: "t2" });
reg.recordUpdate("r2", { status: "completed", exitCode: 0, finalText: "done" });
const tool = createBackgroundStatusTool({ registry: reg });
const resDefault: any = await tool.execute("id", {}, undefined, undefined, undefined);
// default should show only active (running) runs
assert.equal(resDefault.details.runs.length, 1);
assert.equal(resDefault.details.runs[0].runId, "r1");
assert.match(resDefault.content[0].text, /Active runs: 1/);
const resSingle: any = await tool.execute("id", { runId: "r2" }, undefined, undefined, undefined);
assert.equal(resSingle.details.runs.length, 1);
assert.equal(resSingle.details.runs[0].runId, "r2");
assert.match(resSingle.content[0].text, /status=completed/);
const resNotFound: any = await tool.execute("id", { runId: "nope" }, undefined, undefined, undefined);
assert.equal(resNotFound.details.runs.length, 0);
assert.match(resNotFound.content[0].text, /No run found/);
});

View File

@@ -0,0 +1,54 @@
import { BackgroundAgentStatusSchema } from "./background-schema.ts";
import type { BackgroundRegistry } from "./background-registry.ts";
export function createBackgroundStatusTool(deps: { registry: BackgroundRegistry } | { registry?: BackgroundRegistry } = {}) {
const registry = (deps as any).registry;
return {
name: "background_agent_status",
label: "Background Agent Status",
description: "Query background agent runs and their status.",
parameters: BackgroundAgentStatusSchema,
async execute(_toolCallId: string, params: any = {}, _signal: AbortSignal | undefined, _onUpdate: any, _ctx: any) {
if (!registry) {
return {
content: [{ type: "text" as const, text: "Background registry not available" }],
details: { runs: [] },
isError: true,
};
}
const runId = typeof params.runId === "string" ? params.runId : undefined;
const includeCompleted = Boolean(params.includeCompleted);
// when querying a single run by id, allow completed runs regardless of includeCompleted flag
const runs = runId ? registry.getSnapshot({ runId, includeCompleted: true }) : registry.getSnapshot({ includeCompleted });
if (runId) {
if (runs.length === 0) {
return {
content: [{ type: "text" as const, text: `No run found for runId: ${runId}` }],
details: { runs: [] },
isError: false,
};
}
const r = runs[0];
const text = `Run ${r.runId}: status=${r.status}, task=${r.task}, final="${r.finalText ?? "(no output)"}"`;
return {
content: [{ type: "text" as const, text }],
details: { runs },
isError: false,
};
}
// default: active runs only (unless includeCompleted)
const active = runs;
const text = `Active runs: ${active.length}`;
return {
content: [{ type: "text" as const, text }],
details: { runs: active },
isError: false,
};
},
};
}

12
tmp-debug.mjs Normal file
View File

@@ -0,0 +1,12 @@
import { createBackgroundRegistry } from './src/background-registry.js';
import { createBackgroundStatusTool } from './src/background-status-tool.js';
(async()=>{
const reg = createBackgroundRegistry();
reg.recordLaunch({ runId: 'r1', preset: 'p1', task: 't1' });
reg.recordLaunch({ runId: 'r2', preset: 'p2', task: 't2' });
reg.recordUpdate('r2', { status: 'completed', exitCode: 0, finalText: 'done' });
const tool = createBackgroundStatusTool({ registry: reg });
const resDefault = await tool.execute('id', {}, undefined, undefined, undefined);
console.log('default details', resDefault.details);
console.log('default content', resDefault.content);
})();