pi-crew 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/schema.json +24 -0
- package/src/agents/agent-config.ts +2 -0
- package/src/agents/discover-agents.ts +29 -5
- package/src/config/config.ts +148 -9
- package/src/extension/register.ts +10 -2
- package/src/extension/team-tool.ts +113 -10
- package/src/prompt/prompt-runtime.ts +12 -2
- package/src/runtime/agent-control.ts +64 -0
- package/src/runtime/agent-observability.ts +88 -0
- package/src/runtime/async-runner.ts +30 -1
- package/src/runtime/background-runner.ts +4 -2
- package/src/runtime/child-pi.ts +137 -7
- package/src/runtime/crew-agent-records.ts +137 -0
- package/src/runtime/crew-agent-runtime.ts +54 -0
- package/src/runtime/foreground-control.ts +82 -0
- package/src/runtime/group-join.ts +88 -0
- package/src/runtime/live-session-runtime.ts +33 -0
- package/src/runtime/pi-args.ts +29 -0
- package/src/runtime/policy-engine.ts +23 -0
- package/src/runtime/recovery-recipes.ts +74 -0
- package/src/runtime/role-permission.ts +28 -0
- package/src/runtime/runtime-resolver.ts +75 -0
- package/src/runtime/session-usage.ts +79 -0
- package/src/runtime/task-graph-scheduler.ts +107 -0
- package/src/runtime/task-output-context.ts +106 -0
- package/src/runtime/task-runner.ts +220 -4
- package/src/runtime/team-runner.ts +86 -14
- package/src/runtime/worker-startup.ts +57 -0
- package/src/state/contracts.ts +7 -0
- package/src/state/event-log.ts +103 -2
- package/src/state/state-store.ts +23 -2
- package/src/state/types.ts +3 -0
- package/src/ui/run-dashboard.ts +82 -10
- package/src/worktree/branch-freshness.ts +45 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PiTeamsConfig } from "../config/config.ts";
|
|
2
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
|
+
import { appendEvent } from "../state/event-log.ts";
|
|
4
|
+
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
5
|
+
import { upsertCrewAgent } from "./crew-agent-records.ts";
|
|
6
|
+
|
|
7
|
+
export interface CrewControlConfig {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
needsAttentionAfterMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_NEEDS_ATTENTION_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
function positiveInt(value: unknown): number | undefined {
|
|
15
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveCrewControlConfig(config: PiTeamsConfig | undefined): CrewControlConfig {
|
|
19
|
+
const raw = config as PiTeamsConfig & { control?: { enabled?: unknown; needsAttentionAfterMs?: unknown } } | undefined;
|
|
20
|
+
return {
|
|
21
|
+
enabled: raw?.control?.enabled === false ? false : true,
|
|
22
|
+
needsAttentionAfterMs: positiveInt(raw?.control?.needsAttentionAfterMs) ?? DEFAULT_NEEDS_ATTENTION_MS,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function activityAgeMs(agent: CrewAgentRecord, now = Date.now()): number | undefined {
|
|
27
|
+
const timestamp = agent.progress?.lastActivityAt ?? agent.startedAt;
|
|
28
|
+
if (!timestamp) return undefined;
|
|
29
|
+
const ms = now - new Date(timestamp).getTime();
|
|
30
|
+
return Number.isFinite(ms) ? Math.max(0, ms) : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatActivityAge(agent: CrewAgentRecord, now = Date.now()): string | undefined {
|
|
34
|
+
const age = activityAgeMs(agent, now);
|
|
35
|
+
if (age === undefined) return undefined;
|
|
36
|
+
if (age < 1000) return "active now";
|
|
37
|
+
const seconds = Math.floor(age / 1000);
|
|
38
|
+
if (seconds < 60) return agent.progress?.activityState === "needs_attention" ? `no activity for ${seconds}s` : `active ${seconds}s ago`;
|
|
39
|
+
const minutes = Math.floor(seconds / 60);
|
|
40
|
+
return agent.progress?.activityState === "needs_attention" ? `no activity for ${minutes}m` : `active ${minutes}m ago`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function applyAttentionState(manifest: TeamRunManifest, agent: CrewAgentRecord, config: CrewControlConfig, now = Date.now()): CrewAgentRecord {
|
|
44
|
+
if (!config.enabled || agent.status !== "running") return agent;
|
|
45
|
+
const age = activityAgeMs(agent, now);
|
|
46
|
+
if (age === undefined || age <= config.needsAttentionAfterMs) return agent;
|
|
47
|
+
if (agent.progress?.activityState === "needs_attention") return agent;
|
|
48
|
+
const updated: CrewAgentRecord = {
|
|
49
|
+
...agent,
|
|
50
|
+
progress: {
|
|
51
|
+
...(agent.progress ?? { recentTools: [], recentOutput: [], toolCount: agent.toolUses ?? 0 }),
|
|
52
|
+
activityState: "needs_attention",
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
upsertCrewAgent(manifest, updated);
|
|
56
|
+
appendEvent(manifest.eventsPath, {
|
|
57
|
+
type: "agent.needs_attention",
|
|
58
|
+
runId: manifest.runId,
|
|
59
|
+
taskId: agent.taskId,
|
|
60
|
+
message: `${agent.agent} needs attention (no observed activity for ${Math.floor(age / 1000)}s).`,
|
|
61
|
+
data: { agentId: agent.id, ageMs: age, needsAttentionAfterMs: config.needsAttentionAfterMs },
|
|
62
|
+
});
|
|
63
|
+
return updated;
|
|
64
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
3
|
+
import { agentOutputPath, readCrewAgents } from "./crew-agent-records.ts";
|
|
4
|
+
import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
|
|
5
|
+
|
|
6
|
+
export interface TextTailResult {
|
|
7
|
+
path: string;
|
|
8
|
+
text: string;
|
|
9
|
+
bytes: number;
|
|
10
|
+
truncated: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function readTextTail(filePath: string, maxBytes = 64_000): TextTailResult {
|
|
14
|
+
if (!fs.existsSync(filePath)) return { path: filePath, text: "", bytes: 0, truncated: false };
|
|
15
|
+
const stat = fs.statSync(filePath);
|
|
16
|
+
const bytesToRead = Math.min(stat.size, Math.max(0, maxBytes));
|
|
17
|
+
const fd = fs.openSync(filePath, "r");
|
|
18
|
+
try {
|
|
19
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
20
|
+
fs.readSync(fd, buffer, 0, bytesToRead, stat.size - bytesToRead);
|
|
21
|
+
return { path: filePath, text: buffer.toString("utf-8"), bytes: stat.size, truncated: stat.size > bytesToRead };
|
|
22
|
+
} finally {
|
|
23
|
+
fs.closeSync(fd);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function activityText(agent: CrewAgentRecord): string {
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
if (agent.progress?.activityState) parts.push(agent.progress.activityState);
|
|
30
|
+
if (agent.progress?.currentTool) parts.push(`tool=${agent.progress.currentTool}`);
|
|
31
|
+
if (agent.toolUses !== undefined) parts.push(`tools=${agent.toolUses}`);
|
|
32
|
+
if (agent.progress?.tokens !== undefined) parts.push(`tokens=${agent.progress.tokens}`);
|
|
33
|
+
if (agent.progress?.turns !== undefined) parts.push(`turns=${agent.progress.turns}`);
|
|
34
|
+
if (agent.progress?.durationMs !== undefined) parts.push(`durationMs=${agent.progress.durationMs}`);
|
|
35
|
+
if (agent.progress?.failedTool) parts.push(`failedTool=${agent.progress.failedTool}`);
|
|
36
|
+
if (agent.progress?.recentOutput?.length) parts.push(`last=${agent.progress.recentOutput.at(-1)}`);
|
|
37
|
+
return parts.join(" ") || "idle";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function statusGlyph(status: CrewAgentRecord["status"]): string {
|
|
41
|
+
if (status === "completed") return "✓";
|
|
42
|
+
if (status === "failed") return "✗";
|
|
43
|
+
if (status === "running") return "▶";
|
|
44
|
+
if (status === "cancelled" || status === "stopped") return "■";
|
|
45
|
+
return "·";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function outputWarning(agent: CrewAgentRecord): string {
|
|
49
|
+
if (agent.status !== "completed") return "";
|
|
50
|
+
if (!agent.outputPath || !fs.existsSync(agent.outputPath)) return " no-output";
|
|
51
|
+
try {
|
|
52
|
+
return fs.statSync(agent.outputPath).size === 0 ? " no-output" : "";
|
|
53
|
+
} catch {
|
|
54
|
+
return " no-output";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function agentLine(agent: CrewAgentRecord): string {
|
|
59
|
+
return `- ${statusGlyph(agent.status)} ${agent.taskId} [${agent.status}] ${agent.role}->${agent.agent} runtime=${agent.runtime} ${activityText(agent)}${outputWarning(agent)}${agent.error ? ` error=${agent.error}` : ""}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buildAgentDashboard(manifest: TeamRunManifest): { text: string; groups: Record<string, CrewAgentRecord[]> } {
|
|
63
|
+
const agents = readCrewAgents(manifest);
|
|
64
|
+
const groups: Record<string, CrewAgentRecord[]> = {
|
|
65
|
+
running: agents.filter((agent) => agent.status === "running"),
|
|
66
|
+
queued: agents.filter((agent) => agent.status === "queued"),
|
|
67
|
+
recent: agents.filter((agent) => agent.status !== "running" && agent.status !== "queued"),
|
|
68
|
+
};
|
|
69
|
+
const lines = [
|
|
70
|
+
`Crew agents for ${manifest.runId}`,
|
|
71
|
+
`Run status: ${manifest.status}`,
|
|
72
|
+
`Counts: running=${groups.running.length}, queued=${groups.queued.length}, recent=${groups.recent.length}`,
|
|
73
|
+
"",
|
|
74
|
+
"## Running",
|
|
75
|
+
...(groups.running.length ? groups.running.map(agentLine) : ["- (none)"]),
|
|
76
|
+
"",
|
|
77
|
+
"## Queued",
|
|
78
|
+
...(groups.queued.length ? groups.queued.map(agentLine) : ["- (none)"]),
|
|
79
|
+
"",
|
|
80
|
+
"## Recent",
|
|
81
|
+
...(groups.recent.length ? groups.recent.slice(-10).map(agentLine) : ["- (none)"]),
|
|
82
|
+
];
|
|
83
|
+
return { text: lines.join("\n"), groups };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readAgentOutput(manifest: TeamRunManifest, taskId: string, maxBytes?: number): TextTailResult {
|
|
87
|
+
return readTextTail(agentOutputPath(manifest, taskId), maxBytes);
|
|
88
|
+
}
|
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
6
7
|
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
|
|
10
|
+
function resolveJitiCliPath(): string | undefined {
|
|
11
|
+
const candidates: Array<() => string> = [
|
|
12
|
+
() => path.join(path.dirname(require.resolve("jiti/package.json")), "lib/jiti-cli.mjs"),
|
|
13
|
+
() => path.join(path.dirname(require.resolve("@mariozechner/jiti/package.json")), "lib/jiti-cli.mjs"),
|
|
14
|
+
];
|
|
15
|
+
for (const candidate of candidates) {
|
|
16
|
+
try {
|
|
17
|
+
const filePath = candidate();
|
|
18
|
+
if (fs.existsSync(filePath)) return filePath;
|
|
19
|
+
} catch {
|
|
20
|
+
// Try the next possible runtime dependency location.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getBackgroundRunnerCommand(runnerPath: string, cwd: string, runId: string): { args: string[]; loader: "jiti" | "strip-types" } {
|
|
27
|
+
const jitiCliPath = resolveJitiCliPath();
|
|
28
|
+
const runnerArgs = [runnerPath, "--cwd", cwd, "--run-id", runId];
|
|
29
|
+
return jitiCliPath
|
|
30
|
+
? { args: [jitiCliPath, ...runnerArgs], loader: "jiti" }
|
|
31
|
+
: { args: ["--experimental-strip-types", ...runnerArgs], loader: "strip-types" };
|
|
32
|
+
}
|
|
33
|
+
|
|
7
34
|
export interface SpawnBackgroundTeamRunResult {
|
|
8
35
|
pid?: number;
|
|
9
36
|
logPath: string;
|
|
@@ -14,7 +41,9 @@ export function spawnBackgroundTeamRun(manifest: TeamRunManifest): SpawnBackgrou
|
|
|
14
41
|
const logPath = path.join(manifest.stateRoot, "background.log");
|
|
15
42
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
16
43
|
const logFd = fs.openSync(logPath, "a");
|
|
17
|
-
const
|
|
44
|
+
const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId);
|
|
45
|
+
fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
|
|
46
|
+
const child = spawn(process.execPath, command.args, {
|
|
18
47
|
cwd: manifest.cwd,
|
|
19
48
|
detached: true,
|
|
20
49
|
stdio: ["ignore", logFd, logFd],
|
|
@@ -5,6 +5,7 @@ import { loadRunManifestById, updateRunStatus } from "../state/state-store.ts";
|
|
|
5
5
|
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
6
6
|
import { loadConfig } from "../config/config.ts";
|
|
7
7
|
import { executeTeamRun } from "./team-runner.ts";
|
|
8
|
+
import { resolveCrewRuntime } from "./runtime-resolver.ts";
|
|
8
9
|
|
|
9
10
|
function argValue(name: string): string | undefined {
|
|
10
11
|
const index = process.argv.indexOf(name);
|
|
@@ -29,8 +30,9 @@ async function main(): Promise<void> {
|
|
|
29
30
|
if (!workflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
|
|
30
31
|
const agents = allAgents(discoverAgents(cwd));
|
|
31
32
|
const loadedConfig = loadConfig(cwd);
|
|
32
|
-
const
|
|
33
|
-
const
|
|
33
|
+
const runtime = await resolveCrewRuntime(loadedConfig.config);
|
|
34
|
+
const executeWorkers = runtime.kind === "child-process";
|
|
35
|
+
const result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: loadedConfig.config.limits, runtime, runtimeConfig: loadedConfig.config.runtime });
|
|
34
36
|
manifest = result.manifest;
|
|
35
37
|
tasks = result.tasks;
|
|
36
38
|
appendEvent(manifest.eventsPath, { type: "async.completed", runId: manifest.runId, data: { status: manifest.status, tasks: tasks.length } });
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
2
4
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
3
|
-
import { buildPiWorkerArgs, cleanupTempDir } from "./pi-args.ts";
|
|
5
|
+
import { buildPiWorkerArgs, checkCrewDepth, cleanupTempDir } from "./pi-args.ts";
|
|
4
6
|
import { getPiSpawnCommand } from "./pi-spawn.ts";
|
|
5
7
|
|
|
8
|
+
const POST_EXIT_STDIO_GUARD_MS = 3000;
|
|
9
|
+
const FINAL_DRAIN_MS = 5000;
|
|
10
|
+
const HARD_KILL_MS = 3000;
|
|
11
|
+
|
|
6
12
|
export interface ChildPiRunInput {
|
|
7
13
|
cwd: string;
|
|
8
14
|
task: string;
|
|
9
15
|
agent: AgentConfig;
|
|
10
16
|
model?: string;
|
|
11
17
|
signal?: AbortSignal;
|
|
18
|
+
transcriptPath?: string;
|
|
19
|
+
onStdoutLine?: (line: string) => void;
|
|
20
|
+
onJsonEvent?: (event: unknown) => void;
|
|
21
|
+
maxDepth?: number;
|
|
22
|
+
finalDrainMs?: number;
|
|
23
|
+
hardKillMs?: number;
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
export interface ChildPiRunResult {
|
|
@@ -18,15 +30,87 @@ export interface ChildPiRunResult {
|
|
|
18
30
|
error?: string;
|
|
19
31
|
}
|
|
20
32
|
|
|
33
|
+
function appendTranscript(input: ChildPiRunInput, line: string): void {
|
|
34
|
+
if (!input.transcriptPath) return;
|
|
35
|
+
fs.mkdirSync(path.dirname(input.transcriptPath), { recursive: true });
|
|
36
|
+
fs.appendFileSync(input.transcriptPath, `${line}\n`, "utf-8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ChildPiLineObserver {
|
|
40
|
+
private buffer = "";
|
|
41
|
+
private readonly input: ChildPiRunInput;
|
|
42
|
+
|
|
43
|
+
constructor(input: ChildPiRunInput) {
|
|
44
|
+
this.input = input;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
observe(text: string): void {
|
|
48
|
+
this.buffer += text;
|
|
49
|
+
const lines = this.buffer.split(/\r?\n/);
|
|
50
|
+
this.buffer = lines.pop() ?? "";
|
|
51
|
+
for (const line of lines) this.emitLine(line);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
flush(): void {
|
|
55
|
+
if (!this.buffer) return;
|
|
56
|
+
const line = this.buffer;
|
|
57
|
+
this.buffer = "";
|
|
58
|
+
this.emitLine(line);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private emitLine(line: string): void {
|
|
62
|
+
if (!line.trim()) return;
|
|
63
|
+
appendTranscript(this.input, line);
|
|
64
|
+
this.input.onStdoutLine?.(line);
|
|
65
|
+
try {
|
|
66
|
+
this.input.onJsonEvent?.(JSON.parse(line));
|
|
67
|
+
} catch {
|
|
68
|
+
// Raw stdout is allowed.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function observeStdoutChunk(input: ChildPiRunInput, text: string): void {
|
|
74
|
+
const observer = new ChildPiLineObserver(input);
|
|
75
|
+
observer.observe(text);
|
|
76
|
+
observer.flush();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
80
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isFinalAssistantEvent(event: unknown): boolean {
|
|
84
|
+
const obj = asRecord(event);
|
|
85
|
+
if (!obj || obj.type !== "message_end") return false;
|
|
86
|
+
const message = asRecord(obj.message);
|
|
87
|
+
const role = message?.role;
|
|
88
|
+
if (role !== undefined && role !== "assistant") return false;
|
|
89
|
+
const stopReason = typeof message?.stopReason === "string" ? message.stopReason : typeof obj.stopReason === "string" ? obj.stopReason : undefined;
|
|
90
|
+
if (stopReason !== undefined && stopReason !== "stop") return false;
|
|
91
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
92
|
+
return !content.some((part) => asRecord(part)?.type === "toolCall");
|
|
93
|
+
}
|
|
94
|
+
|
|
21
95
|
export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResult> {
|
|
96
|
+
const depth = checkCrewDepth(input.maxDepth);
|
|
97
|
+
if (depth.blocked) return { exitCode: 1, stdout: "", stderr: `pi-crew depth guard blocked child worker: depth ${depth.depth} >= max ${depth.maxDepth}` };
|
|
22
98
|
const mock = process.env.PI_TEAMS_MOCK_CHILD_PI;
|
|
23
99
|
if (mock) {
|
|
24
|
-
if (mock === "success")
|
|
25
|
-
|
|
100
|
+
if (mock === "success") {
|
|
101
|
+
const stdout = `Mock child Pi success for ${input.agent.name}\n`;
|
|
102
|
+
observeStdoutChunk(input, stdout);
|
|
103
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
104
|
+
}
|
|
105
|
+
if (mock === "json-success") {
|
|
106
|
+
const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: `Mock JSON success for ${input.agent.name}` }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
|
|
107
|
+
observeStdoutChunk(input, stdout);
|
|
108
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
109
|
+
}
|
|
26
110
|
if (mock === "retryable-failure") return { exitCode: 1, stdout: "", stderr: "rate limit: mock failure" };
|
|
27
111
|
return { exitCode: 1, stdout: "", stderr: `mock failure: ${mock}` };
|
|
28
112
|
}
|
|
29
|
-
const built = buildPiWorkerArgs({ task: input.task, agent: input.agent, model: input.model, sessionEnabled: false });
|
|
113
|
+
const built = buildPiWorkerArgs({ task: input.task, agent: input.agent, model: input.model, sessionEnabled: false, maxDepth: input.maxDepth });
|
|
30
114
|
const spawnSpec = getPiSpawnCommand(built.args);
|
|
31
115
|
try {
|
|
32
116
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
@@ -38,10 +122,46 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
38
122
|
let stdout = "";
|
|
39
123
|
let stderr = "";
|
|
40
124
|
let settled = false;
|
|
125
|
+
let childExited = false;
|
|
126
|
+
let postExitGuard: NodeJS.Timeout | undefined;
|
|
127
|
+
let finalDrainTimer: NodeJS.Timeout | undefined;
|
|
128
|
+
let hardKillTimer: NodeJS.Timeout | undefined;
|
|
129
|
+
const finalDrainMs = input.finalDrainMs ?? FINAL_DRAIN_MS;
|
|
130
|
+
const hardKillMs = input.hardKillMs ?? HARD_KILL_MS;
|
|
131
|
+
let forcedFinalDrain = false;
|
|
132
|
+
const lineObserver = new ChildPiLineObserver({
|
|
133
|
+
...input,
|
|
134
|
+
onJsonEvent: (event) => {
|
|
135
|
+
input.onJsonEvent?.(event);
|
|
136
|
+
if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
|
|
137
|
+
finalDrainTimer = setTimeout(() => {
|
|
138
|
+
if (settled || childExited) return;
|
|
139
|
+
forcedFinalDrain = true;
|
|
140
|
+
try { child.kill(process.platform === "win32" ? undefined : "SIGTERM"); } catch {}
|
|
141
|
+
hardKillTimer = setTimeout(() => {
|
|
142
|
+
if (settled || childExited) return;
|
|
143
|
+
try { child.kill(process.platform === "win32" ? undefined : "SIGKILL"); } catch {}
|
|
144
|
+
}, hardKillMs);
|
|
145
|
+
hardKillTimer.unref?.();
|
|
146
|
+
}, finalDrainMs);
|
|
147
|
+
finalDrainTimer.unref?.();
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const clearFinalDrainTimers = (): void => {
|
|
152
|
+
if (finalDrainTimer) clearTimeout(finalDrainTimer);
|
|
153
|
+
if (hardKillTimer) clearTimeout(hardKillTimer);
|
|
154
|
+
finalDrainTimer = undefined;
|
|
155
|
+
hardKillTimer = undefined;
|
|
156
|
+
};
|
|
41
157
|
|
|
42
158
|
const settle = (result: ChildPiRunResult): void => {
|
|
43
159
|
if (settled) return;
|
|
44
160
|
settled = true;
|
|
161
|
+
if (postExitGuard) clearTimeout(postExitGuard);
|
|
162
|
+
clearFinalDrainTimers();
|
|
163
|
+
lineObserver.flush();
|
|
164
|
+
input.signal?.removeEventListener("abort", abort);
|
|
45
165
|
cleanupTempDir(built.tempDir);
|
|
46
166
|
resolve(result);
|
|
47
167
|
};
|
|
@@ -56,7 +176,9 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
56
176
|
|
|
57
177
|
input.signal?.addEventListener("abort", abort, { once: true });
|
|
58
178
|
child.stdout?.on("data", (chunk: Buffer) => {
|
|
59
|
-
|
|
179
|
+
const text = chunk.toString("utf-8");
|
|
180
|
+
stdout += text;
|
|
181
|
+
lineObserver.observe(text);
|
|
60
182
|
});
|
|
61
183
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
62
184
|
stderr += chunk.toString("utf-8");
|
|
@@ -64,9 +186,17 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
64
186
|
child.on("error", (error) => {
|
|
65
187
|
settle({ exitCode: null, stdout, stderr, error: error.message });
|
|
66
188
|
});
|
|
189
|
+
child.on("exit", () => {
|
|
190
|
+
childExited = true;
|
|
191
|
+
clearFinalDrainTimers();
|
|
192
|
+
postExitGuard = setTimeout(() => {
|
|
193
|
+
child.stdout?.destroy();
|
|
194
|
+
child.stderr?.destroy();
|
|
195
|
+
}, POST_EXIT_STDIO_GUARD_MS);
|
|
196
|
+
postExitGuard.unref?.();
|
|
197
|
+
});
|
|
67
198
|
child.on("close", (exitCode) => {
|
|
68
|
-
|
|
69
|
-
settle({ exitCode, stdout, stderr });
|
|
199
|
+
settle({ exitCode, stdout, stderr, ...(forcedFinalDrain && !stderr.trim() ? { error: `Child Pi did not exit within ${finalDrainMs}ms after final assistant message; termination was requested.` } : {}) });
|
|
70
200
|
});
|
|
71
201
|
});
|
|
72
202
|
} finally {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
4
|
+
import { atomicWriteJson, readJsonFile } from "../state/atomic-write.ts";
|
|
5
|
+
import type { CrewAgentProgress, CrewAgentRecord, CrewRuntimeKind } from "./crew-agent-runtime.ts";
|
|
6
|
+
import { taskStatusToAgentStatus } from "./crew-agent-runtime.ts";
|
|
7
|
+
|
|
8
|
+
export function agentsPath(manifest: TeamRunManifest): string {
|
|
9
|
+
return path.join(manifest.stateRoot, "agents.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function agentStateDir(manifest: TeamRunManifest, taskId: string): string {
|
|
13
|
+
return path.join(manifest.stateRoot, "agents", taskId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function agentStatusPath(manifest: TeamRunManifest, taskId: string): string {
|
|
17
|
+
return path.join(agentStateDir(manifest, taskId), "status.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function agentEventsPath(manifest: TeamRunManifest, taskId: string): string {
|
|
21
|
+
return path.join(agentStateDir(manifest, taskId), "events.jsonl");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function agentOutputPath(manifest: TeamRunManifest, taskId: string): string {
|
|
25
|
+
return path.join(agentStateDir(manifest, taskId), "output.log");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function readCrewAgents(manifest: TeamRunManifest): CrewAgentRecord[] {
|
|
29
|
+
return readJsonFile<CrewAgentRecord[]>(agentsPath(manifest)) ?? [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
33
|
+
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
34
|
+
atomicWriteJson(agentsPath(manifest), records);
|
|
35
|
+
for (const record of records) writeCrewAgentStatus(manifest, record);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
39
|
+
const records = readCrewAgents(manifest).filter((item) => item.id !== record.id);
|
|
40
|
+
records.push(record);
|
|
41
|
+
saveCrewAgents(manifest, records);
|
|
42
|
+
writeCrewAgentStatus(manifest, record);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
46
|
+
fs.mkdirSync(agentStateDir(manifest, record.taskId), { recursive: true });
|
|
47
|
+
atomicWriteJson(agentStatusPath(manifest, record.taskId), record);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: string): CrewAgentRecord | undefined {
|
|
51
|
+
const taskId = taskOrAgentId.includes(":") ? taskOrAgentId.split(":").pop()! : taskOrAgentId;
|
|
52
|
+
return readJsonFile<CrewAgentRecord>(agentStatusPath(manifest, taskId));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nextAgentEventSeq(filePath: string): number {
|
|
56
|
+
if (!fs.existsSync(filePath)) return 1;
|
|
57
|
+
let max = 0;
|
|
58
|
+
for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(line) as { seq?: unknown };
|
|
62
|
+
if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
|
|
63
|
+
else max += 1;
|
|
64
|
+
} catch {
|
|
65
|
+
max += 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return max + 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string, event: unknown): void {
|
|
72
|
+
fs.mkdirSync(agentStateDir(manifest, taskId), { recursive: true });
|
|
73
|
+
const filePath = agentEventsPath(manifest, taskId);
|
|
74
|
+
fs.appendFileSync(filePath, `${JSON.stringify({ seq: nextAgentEventSeq(filePath), time: new Date().toISOString(), event })}\n`, "utf-8");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CrewAgentEventCursorOptions {
|
|
78
|
+
sinceSeq?: number;
|
|
79
|
+
limit?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readCrewAgentEvents(manifest: TeamRunManifest, taskId: string): unknown[] {
|
|
83
|
+
return readCrewAgentEventsCursor(manifest, taskId).events;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readCrewAgentEventsCursor(manifest: TeamRunManifest, taskId: string, options: CrewAgentEventCursorOptions = {}): { path: string; events: unknown[]; nextSeq: number; total: number } {
|
|
87
|
+
const filePath = agentEventsPath(manifest, taskId);
|
|
88
|
+
if (!fs.existsSync(filePath)) return { path: filePath, events: [], nextSeq: options.sinceSeq ?? 0, total: 0 };
|
|
89
|
+
const sinceSeq = typeof options.sinceSeq === "number" && Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : 0;
|
|
90
|
+
const limit = typeof options.limit === "number" && Number.isInteger(options.limit) && options.limit >= 0 ? options.limit : undefined;
|
|
91
|
+
const parsed = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter(Boolean).map((line, index) => {
|
|
92
|
+
try {
|
|
93
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
94
|
+
if (typeof event.seq !== "number") event.seq = index + 1;
|
|
95
|
+
return event;
|
|
96
|
+
} catch {
|
|
97
|
+
return { seq: index + 1, raw: line };
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const filtered = parsed.filter((event) => typeof event.seq === "number" && event.seq > sinceSeq);
|
|
101
|
+
const events = limit !== undefined ? filtered.slice(0, limit) : filtered;
|
|
102
|
+
const returnedMaxSeq = events.reduce((max, event) => typeof event.seq === "number" ? Math.max(max, event.seq) : max, sinceSeq);
|
|
103
|
+
return { path: filePath, events, nextSeq: returnedMaxSeq, total: filtered.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function appendCrewAgentOutput(manifest: TeamRunManifest, taskId: string, text: string): void {
|
|
107
|
+
if (!text.trim()) return;
|
|
108
|
+
fs.mkdirSync(agentStateDir(manifest, taskId), { recursive: true });
|
|
109
|
+
fs.appendFileSync(agentOutputPath(manifest, taskId), `${text}\n`, "utf-8");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function emptyCrewAgentProgress(): CrewAgentProgress {
|
|
113
|
+
return { recentTools: [], recentOutput: [], toolCount: 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function recordFromTask(manifest: TeamRunManifest, task: TeamTaskState, runtime: CrewRuntimeKind): CrewAgentRecord {
|
|
117
|
+
return {
|
|
118
|
+
id: `${manifest.runId}:${task.id}`,
|
|
119
|
+
runId: manifest.runId,
|
|
120
|
+
taskId: task.id,
|
|
121
|
+
agent: task.agent,
|
|
122
|
+
role: task.role,
|
|
123
|
+
runtime,
|
|
124
|
+
status: taskStatusToAgentStatus(task.status),
|
|
125
|
+
startedAt: task.startedAt ?? new Date().toISOString(),
|
|
126
|
+
completedAt: task.finishedAt,
|
|
127
|
+
resultArtifactPath: task.resultArtifact?.path,
|
|
128
|
+
transcriptPath: task.transcriptArtifact?.path ?? task.logArtifact?.path,
|
|
129
|
+
statusPath: agentStatusPath(manifest, task.id),
|
|
130
|
+
eventsPath: agentEventsPath(manifest, task.id),
|
|
131
|
+
outputPath: agentOutputPath(manifest, task.id),
|
|
132
|
+
toolUses: task.agentProgress?.toolCount,
|
|
133
|
+
jsonEvents: task.jsonEvents,
|
|
134
|
+
progress: task.agentProgress,
|
|
135
|
+
error: task.error,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TeamTaskStatus } from "../state/contracts.ts";
|
|
2
|
+
|
|
3
|
+
export type CrewRuntimeKind = "scaffold" | "child-process" | "live-session";
|
|
4
|
+
export type CrewAgentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped";
|
|
5
|
+
|
|
6
|
+
export interface CrewAgentRecentTool {
|
|
7
|
+
tool: string;
|
|
8
|
+
args?: string;
|
|
9
|
+
endedAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CrewAgentProgress {
|
|
13
|
+
currentTool?: string;
|
|
14
|
+
currentToolArgs?: string;
|
|
15
|
+
currentToolStartedAt?: string;
|
|
16
|
+
recentTools: CrewAgentRecentTool[];
|
|
17
|
+
recentOutput: string[];
|
|
18
|
+
toolCount: number;
|
|
19
|
+
tokens?: number;
|
|
20
|
+
turns?: number;
|
|
21
|
+
durationMs?: number;
|
|
22
|
+
lastActivityAt?: string;
|
|
23
|
+
activityState?: "active" | "needs_attention" | "stale";
|
|
24
|
+
failedTool?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CrewAgentRecord {
|
|
28
|
+
id: string;
|
|
29
|
+
runId: string;
|
|
30
|
+
taskId: string;
|
|
31
|
+
agent: string;
|
|
32
|
+
role: string;
|
|
33
|
+
runtime: CrewRuntimeKind;
|
|
34
|
+
status: CrewAgentStatus;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
completedAt?: string;
|
|
37
|
+
resultArtifactPath?: string;
|
|
38
|
+
transcriptPath?: string;
|
|
39
|
+
statusPath?: string;
|
|
40
|
+
eventsPath?: string;
|
|
41
|
+
outputPath?: string;
|
|
42
|
+
toolUses?: number;
|
|
43
|
+
jsonEvents?: number;
|
|
44
|
+
progress?: CrewAgentProgress;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function taskStatusToAgentStatus(status: TeamTaskStatus): CrewAgentStatus {
|
|
49
|
+
if (status === "completed") return "completed";
|
|
50
|
+
if (status === "failed") return "failed";
|
|
51
|
+
if (status === "cancelled") return "cancelled";
|
|
52
|
+
if (status === "running") return "running";
|
|
53
|
+
return "queued";
|
|
54
|
+
}
|