pi-crew 0.6.1 → 0.6.3
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/CHANGELOG.md +160 -0
- package/README.md +70 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +2 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +1 -1
- package/src/extension/registration/team-tool.ts +1 -1
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +60 -11
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { DEFAULT_PATHS, DEFAULT_SUBAGENT } from "../config/defaults.ts";
|
|
4
4
|
import type { PiTeamsToolResult } from "../extension/tool-result.ts";
|
|
5
|
-
import {
|
|
6
|
-
import { projectCrewRoot } from "../utils/paths.ts";
|
|
7
|
-
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
5
|
+
import { loadRunManifestById } from "../state/state-store.ts";
|
|
8
6
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
7
|
+
import { projectCrewRoot } from "../utils/paths.ts";
|
|
9
8
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
10
9
|
|
|
11
|
-
export type SubagentStatus =
|
|
10
|
+
export type SubagentStatus =
|
|
11
|
+
| "queued"
|
|
12
|
+
| "running"
|
|
13
|
+
| "completed"
|
|
14
|
+
| "failed"
|
|
15
|
+
| "cancelled"
|
|
16
|
+
| "error"
|
|
17
|
+
| "blocked"
|
|
18
|
+
| "stopped";
|
|
12
19
|
|
|
13
20
|
export interface SubagentSpawnOptions {
|
|
14
21
|
cwd: string;
|
|
@@ -49,7 +56,10 @@ export interface SubagentRecord {
|
|
|
49
56
|
lifetimeUsage?: { input: number; output: number; cacheWrite: number };
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
type SpawnRunner = (
|
|
59
|
+
type SpawnRunner = (
|
|
60
|
+
options: SubagentSpawnOptions,
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
) => Promise<PiTeamsToolResult>;
|
|
53
63
|
type Notify = (record: SubagentRecord) => void;
|
|
54
64
|
type NotifyEvent = (type: string, data: Record<string, unknown>) => void;
|
|
55
65
|
|
|
@@ -66,7 +76,11 @@ function isValidSubagentId(id: string): boolean {
|
|
|
66
76
|
|
|
67
77
|
function persistedSubagentPath(cwd: string, id: string): string {
|
|
68
78
|
if (!isValidSubagentId(id)) throw new Error(`Invalid subagent id: ${id}`);
|
|
69
|
-
return path.join(
|
|
79
|
+
return path.join(
|
|
80
|
+
projectCrewRoot(cwd),
|
|
81
|
+
DEFAULT_PATHS.state.subagentsSubdir,
|
|
82
|
+
`${id}.json`,
|
|
83
|
+
);
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
function serializableRecord(record: SubagentRecord): SubagentRecord {
|
|
@@ -74,11 +88,18 @@ function serializableRecord(record: SubagentRecord): SubagentRecord {
|
|
|
74
88
|
return rest;
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
export function savePersistedSubagentRecord(
|
|
91
|
+
export function savePersistedSubagentRecord(
|
|
92
|
+
cwd: string,
|
|
93
|
+
record: SubagentRecord,
|
|
94
|
+
): void {
|
|
78
95
|
try {
|
|
79
96
|
const filePath = persistedSubagentPath(cwd, record.id);
|
|
80
97
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
81
|
-
fs.writeFileSync(
|
|
98
|
+
fs.writeFileSync(
|
|
99
|
+
filePath,
|
|
100
|
+
`${JSON.stringify(redactSecrets(serializableRecord(record)), null, 2)}\n`,
|
|
101
|
+
"utf-8",
|
|
102
|
+
);
|
|
82
103
|
// SECURITY: Restrict permissions to owner-only (rw-------).
|
|
83
104
|
// On multi-user systems, other users must not read task prompts,
|
|
84
105
|
// agent descriptions, and run IDs from subagent record files.
|
|
@@ -89,26 +110,67 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
|
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
const ALLOWED_RECORD_FIELDS = new Set([
|
|
92
|
-
"
|
|
93
|
-
"
|
|
113
|
+
"id",
|
|
114
|
+
"agentId",
|
|
115
|
+
"agentName",
|
|
116
|
+
"subagentType",
|
|
117
|
+
"type",
|
|
118
|
+
"description",
|
|
119
|
+
"prompt",
|
|
120
|
+
"status",
|
|
121
|
+
"startedAt",
|
|
122
|
+
"completedAt",
|
|
123
|
+
"spawnedAt",
|
|
124
|
+
"model",
|
|
125
|
+
"runId",
|
|
126
|
+
"cwd",
|
|
127
|
+
"taskId",
|
|
128
|
+
"result",
|
|
129
|
+
"error",
|
|
130
|
+
"resultConsumed",
|
|
131
|
+
"background",
|
|
132
|
+
"ownerSessionGeneration",
|
|
133
|
+
"stuckNotified",
|
|
134
|
+
"blockedAt",
|
|
135
|
+
"turnCount",
|
|
136
|
+
"terminated",
|
|
137
|
+
"durationMs",
|
|
94
138
|
]);
|
|
95
139
|
|
|
96
140
|
function sanitizePersistedRecord(raw: unknown): SubagentRecord | undefined {
|
|
97
141
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
98
142
|
const obj = raw as Record<string, unknown>;
|
|
99
|
-
|
|
100
|
-
|
|
143
|
+
// Accept either `id` (public SubagentRecord field) or `agentId` (legacy).
|
|
144
|
+
// The saved JSON uses `id` (from serializableRecord → JSON.stringify of record).
|
|
145
|
+
const idValue = obj.id ?? obj.agentId;
|
|
146
|
+
if (typeof idValue !== "string" || !idValue) return undefined;
|
|
147
|
+
const clean: Record<string, unknown> = { id: idValue };
|
|
148
|
+
if (obj.agentId && typeof obj.agentId === "string") {
|
|
149
|
+
clean.agentId = obj.agentId;
|
|
150
|
+
}
|
|
101
151
|
for (const key of Object.keys(obj)) {
|
|
102
|
-
if (
|
|
152
|
+
if (
|
|
153
|
+
ALLOWED_RECORD_FIELDS.has(key) &&
|
|
154
|
+
(typeof obj[key] === "string" ||
|
|
155
|
+
typeof obj[key] === "number" ||
|
|
156
|
+
typeof obj[key] === "boolean")
|
|
157
|
+
) {
|
|
103
158
|
clean[key] = obj[key];
|
|
104
159
|
}
|
|
105
160
|
}
|
|
161
|
+
// Re-validate `id` to prevent prototype/constructor smuggling
|
|
162
|
+
if (typeof clean.id !== "string" || clean.id.length === 0) return undefined;
|
|
106
163
|
return clean as unknown as SubagentRecord;
|
|
107
164
|
}
|
|
108
165
|
|
|
109
|
-
export function readPersistedSubagentRecord(
|
|
166
|
+
export function readPersistedSubagentRecord(
|
|
167
|
+
cwd: string,
|
|
168
|
+
id: string,
|
|
169
|
+
): SubagentRecord | undefined {
|
|
110
170
|
try {
|
|
111
|
-
const raw = JSON.parse(
|
|
171
|
+
const raw = JSON.parse(
|
|
172
|
+
fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"),
|
|
173
|
+
);
|
|
112
174
|
return sanitizePersistedRecord(raw);
|
|
113
175
|
} catch {
|
|
114
176
|
return undefined;
|
|
@@ -116,7 +178,12 @@ export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRe
|
|
|
116
178
|
}
|
|
117
179
|
|
|
118
180
|
function resultText(result: PiTeamsToolResult): string {
|
|
119
|
-
return
|
|
181
|
+
return (
|
|
182
|
+
result.content
|
|
183
|
+
?.map((item) => (item.type === "text" ? item.text : ""))
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.join("\n") ?? ""
|
|
186
|
+
);
|
|
120
187
|
}
|
|
121
188
|
|
|
122
189
|
function detailsRunId(result: PiTeamsToolResult): string | undefined {
|
|
@@ -124,9 +191,12 @@ function detailsRunId(result: PiTeamsToolResult): string | undefined {
|
|
|
124
191
|
return typeof details?.runId === "string" ? details.runId : undefined;
|
|
125
192
|
}
|
|
126
193
|
|
|
127
|
-
function totalRunTurns(
|
|
194
|
+
function totalRunTurns(
|
|
195
|
+
cwd: string,
|
|
196
|
+
runId: string | undefined,
|
|
197
|
+
): number | undefined {
|
|
128
198
|
if (!runId) return undefined;
|
|
129
|
-
const loaded = loadRunManifestById(cwd, runId);
|
|
199
|
+
const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
130
200
|
if (!loaded) return undefined;
|
|
131
201
|
let total = 0;
|
|
132
202
|
let hasTurns = false;
|
|
@@ -153,20 +223,33 @@ export class SubagentManager {
|
|
|
153
223
|
private readonly onEvent?: NotifyEvent;
|
|
154
224
|
private readonly pollIntervalMs: number;
|
|
155
225
|
|
|
156
|
-
constructor(
|
|
226
|
+
constructor(
|
|
227
|
+
maxConcurrent = 4,
|
|
228
|
+
onComplete?: Notify,
|
|
229
|
+
pollIntervalMs = 1000,
|
|
230
|
+
onEvent?: NotifyEvent,
|
|
231
|
+
) {
|
|
157
232
|
this.maxConcurrent = maxConcurrent;
|
|
158
233
|
this.onComplete = onComplete;
|
|
159
234
|
this.onEvent = onEvent;
|
|
160
235
|
this.pollIntervalMs = pollIntervalMs;
|
|
161
236
|
}
|
|
162
237
|
|
|
163
|
-
spawn(
|
|
238
|
+
spawn(
|
|
239
|
+
options: SubagentSpawnOptions,
|
|
240
|
+
runner: SpawnRunner,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
): SubagentRecord {
|
|
164
243
|
const record: SubagentRecord = {
|
|
165
244
|
id: `agent_${Date.now().toString(36)}_${(++this.counter).toString(36)}`,
|
|
166
245
|
type: options.type,
|
|
167
246
|
description: options.description,
|
|
168
247
|
prompt: options.prompt,
|
|
169
|
-
status:
|
|
248
|
+
status:
|
|
249
|
+
options.background &&
|
|
250
|
+
this.runningBackground >= this.maxConcurrent
|
|
251
|
+
? "queued"
|
|
252
|
+
: "running",
|
|
170
253
|
startedAt: Date.now(),
|
|
171
254
|
model: options.model,
|
|
172
255
|
skill: options.skill,
|
|
@@ -189,7 +272,9 @@ export class SubagentManager {
|
|
|
189
272
|
}
|
|
190
273
|
|
|
191
274
|
listAgents(): SubagentRecord[] {
|
|
192
|
-
return [...this.records.values()].sort(
|
|
275
|
+
return [...this.records.values()].sort(
|
|
276
|
+
(a, b) => b.startedAt - a.startedAt,
|
|
277
|
+
);
|
|
193
278
|
}
|
|
194
279
|
|
|
195
280
|
abort(id: string, reason?: string): boolean {
|
|
@@ -200,7 +285,8 @@ export class SubagentManager {
|
|
|
200
285
|
this.markStopped(record, reason ?? "Aborted by caller.");
|
|
201
286
|
return true;
|
|
202
287
|
}
|
|
203
|
-
if (record.status !== "running" && record.status !== "blocked")
|
|
288
|
+
if (record.status !== "running" && record.status !== "blocked")
|
|
289
|
+
return false;
|
|
204
290
|
this.controllers.get(id)?.abort();
|
|
205
291
|
this.markStopped(record, reason ?? "Aborted by caller.");
|
|
206
292
|
return true;
|
|
@@ -227,7 +313,16 @@ export class SubagentManager {
|
|
|
227
313
|
async waitForAll(): Promise<void> {
|
|
228
314
|
while (true) {
|
|
229
315
|
this.drainQueue();
|
|
230
|
-
const pending = this.listAgents()
|
|
316
|
+
const pending = this.listAgents()
|
|
317
|
+
.filter(
|
|
318
|
+
(record) =>
|
|
319
|
+
record.status === "running" ||
|
|
320
|
+
record.status === "queued",
|
|
321
|
+
)
|
|
322
|
+
.map((record) => record.promise)
|
|
323
|
+
.filter((promise): promise is Promise<void> =>
|
|
324
|
+
Boolean(promise),
|
|
325
|
+
);
|
|
231
326
|
if (!pending.length) break;
|
|
232
327
|
await Promise.allSettled(pending);
|
|
233
328
|
}
|
|
@@ -237,8 +332,16 @@ export class SubagentManager {
|
|
|
237
332
|
while (true) {
|
|
238
333
|
const record = this.records.get(id);
|
|
239
334
|
if (!record) return undefined;
|
|
240
|
-
if (record.status !== "running" && record.status !== "queued")
|
|
241
|
-
|
|
335
|
+
if (record.status !== "running" && record.status !== "queued")
|
|
336
|
+
return record;
|
|
337
|
+
if (record.promise)
|
|
338
|
+
await record.promise.catch((error) => {
|
|
339
|
+
logInternalError(
|
|
340
|
+
"subagent-manager.waitForRecord",
|
|
341
|
+
error,
|
|
342
|
+
`id=${id}`,
|
|
343
|
+
);
|
|
344
|
+
});
|
|
242
345
|
else await new Promise((resolve) => setTimeout(resolve, 100));
|
|
243
346
|
}
|
|
244
347
|
}
|
|
@@ -248,7 +351,12 @@ export class SubagentManager {
|
|
|
248
351
|
this.drainQueue();
|
|
249
352
|
}
|
|
250
353
|
|
|
251
|
-
private start(
|
|
354
|
+
private start(
|
|
355
|
+
record: SubagentRecord,
|
|
356
|
+
options: SubagentSpawnOptions,
|
|
357
|
+
runner: SpawnRunner,
|
|
358
|
+
signal?: AbortSignal,
|
|
359
|
+
): void {
|
|
252
360
|
if (options.background) this.runningBackground++;
|
|
253
361
|
record.status = "running";
|
|
254
362
|
record.startedAt = Date.now();
|
|
@@ -267,33 +375,72 @@ export class SubagentManager {
|
|
|
267
375
|
record.error = record.result;
|
|
268
376
|
throw new Error(record.error);
|
|
269
377
|
}
|
|
270
|
-
if (record.runId)
|
|
378
|
+
if (record.runId)
|
|
379
|
+
await this.pollRunToTerminal(options.cwd, record);
|
|
271
380
|
else record.status = "completed";
|
|
272
381
|
} catch (error) {
|
|
273
382
|
if (record.status === "stopped" || runSignal.aborted) {
|
|
274
|
-
const abortReason = runSignal.aborted
|
|
383
|
+
const abortReason = runSignal.aborted
|
|
384
|
+
? "Signal aborted — agent cancelled by parent (session switch, user cancel, or tool timeout)."
|
|
385
|
+
: undefined;
|
|
275
386
|
record.status = "stopped";
|
|
276
|
-
if (!record.error)
|
|
387
|
+
if (!record.error)
|
|
388
|
+
record.error =
|
|
389
|
+
abortReason ??
|
|
390
|
+
(error instanceof Error
|
|
391
|
+
? error.message
|
|
392
|
+
: String(error));
|
|
277
393
|
return;
|
|
278
394
|
}
|
|
279
395
|
record.status = "error";
|
|
280
|
-
record.error =
|
|
396
|
+
record.error =
|
|
397
|
+
error instanceof Error ? error.message : String(error);
|
|
281
398
|
throw error; // H4: Propagate rejection so callers awaiting record.promise see the error
|
|
282
399
|
} finally {
|
|
283
400
|
this.cleanupRunSignal(record.id);
|
|
284
|
-
if (options.background)
|
|
285
|
-
|
|
401
|
+
if (options.background)
|
|
402
|
+
this.runningBackground = Math.max(
|
|
403
|
+
0,
|
|
404
|
+
this.runningBackground - 1,
|
|
405
|
+
);
|
|
406
|
+
if (record.status !== "blocked")
|
|
407
|
+
record.completedAt = record.completedAt ?? Date.now();
|
|
286
408
|
savePersistedSubagentRecord(options.cwd, record);
|
|
287
|
-
if (
|
|
409
|
+
if (
|
|
410
|
+
record.status === "completed" ||
|
|
411
|
+
record.status === "failed" ||
|
|
412
|
+
record.status === "cancelled" ||
|
|
413
|
+
record.status === "error" ||
|
|
414
|
+
record.status === "stopped"
|
|
415
|
+
) {
|
|
288
416
|
// Phase 1.6: Populate telemetry fields
|
|
289
|
-
record.turnCount =
|
|
290
|
-
|
|
417
|
+
record.turnCount =
|
|
418
|
+
record.turnCount ??
|
|
419
|
+
totalRunTurns(options.cwd, record.runId);
|
|
420
|
+
record.durationMs = record.completedAt
|
|
421
|
+
? Math.max(0, record.completedAt - record.startedAt)
|
|
422
|
+
: undefined;
|
|
291
423
|
savePersistedSubagentRecord(options.cwd, record);
|
|
292
424
|
this.onComplete?.(record);
|
|
293
425
|
}
|
|
294
426
|
this.drainQueue();
|
|
295
427
|
}
|
|
296
428
|
})();
|
|
429
|
+
// Defense in depth (issue #29): a subagent failure should never crash
|
|
430
|
+
// the host pi process. The IIFE above can reject (e.g. when a run
|
|
431
|
+
// lookup fails) and the re-throw at line 281 propagates to
|
|
432
|
+
// `record.promise`. If no caller awaits the promise, that rejection
|
|
433
|
+
// would become `unhandledRejection` → `uncaughtException` → pi exits.
|
|
434
|
+
// Attaching a no-op catch here keeps the contract for callers that
|
|
435
|
+
// DO await (they still see the rejection) while preventing the
|
|
436
|
+
// harness-killing failure mode for callers that don't.
|
|
437
|
+
record.promise.catch((error) => {
|
|
438
|
+
logInternalError(
|
|
439
|
+
"subagent-manager.start.unhandled",
|
|
440
|
+
error,
|
|
441
|
+
`id=${record.id}`,
|
|
442
|
+
);
|
|
443
|
+
});
|
|
297
444
|
}
|
|
298
445
|
|
|
299
446
|
private markStopped(record: SubagentRecord, reason?: string): void {
|
|
@@ -314,7 +461,9 @@ export class SubagentManager {
|
|
|
314
461
|
if (signal) {
|
|
315
462
|
const abort = (): void => controller.abort();
|
|
316
463
|
signal.addEventListener("abort", abort, { once: true });
|
|
317
|
-
this.controllerCleanup.set(id, () =>
|
|
464
|
+
this.controllerCleanup.set(id, () =>
|
|
465
|
+
signal.removeEventListener("abort", abort),
|
|
466
|
+
);
|
|
318
467
|
}
|
|
319
468
|
return controller.signal;
|
|
320
469
|
}
|
|
@@ -326,32 +475,48 @@ export class SubagentManager {
|
|
|
326
475
|
}
|
|
327
476
|
|
|
328
477
|
private drainQueue(): void {
|
|
329
|
-
while (
|
|
478
|
+
while (
|
|
479
|
+
this.queue.length > 0 &&
|
|
480
|
+
this.runningBackground < this.maxConcurrent
|
|
481
|
+
) {
|
|
330
482
|
const next = this.queue.shift();
|
|
331
483
|
if (!next || next.record.status !== "queued") continue;
|
|
332
484
|
this.start(next.record, next.options, next.runner, next.signal);
|
|
333
485
|
}
|
|
334
486
|
}
|
|
335
487
|
|
|
336
|
-
private async pollRunToTerminal(
|
|
337
|
-
|
|
338
|
-
|
|
488
|
+
private async pollRunToTerminal(
|
|
489
|
+
cwd: string,
|
|
490
|
+
record: SubagentRecord,
|
|
491
|
+
): Promise<void> {
|
|
492
|
+
while (
|
|
493
|
+
record.runId &&
|
|
494
|
+
(record.status === "running" || record.status === "blocked")
|
|
495
|
+
) {
|
|
496
|
+
const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
339
497
|
if (!loaded) {
|
|
340
|
-
await new Promise((resolve) =>
|
|
498
|
+
await new Promise((resolve) =>
|
|
499
|
+
setTimeout(resolve, this.pollIntervalMs),
|
|
500
|
+
);
|
|
341
501
|
continue;
|
|
342
502
|
}
|
|
343
503
|
if (loaded.manifest.status === "completed") {
|
|
344
504
|
record.status = "completed";
|
|
345
505
|
record.error = undefined;
|
|
346
|
-
record.turnCount =
|
|
506
|
+
record.turnCount =
|
|
507
|
+
record.turnCount ?? totalRunTurns(cwd, record.runId);
|
|
347
508
|
record.completedAt = Date.now();
|
|
348
509
|
savePersistedSubagentRecord(cwd, record);
|
|
349
510
|
return;
|
|
350
511
|
}
|
|
351
|
-
if (
|
|
512
|
+
if (
|
|
513
|
+
loaded.manifest.status === "failed" ||
|
|
514
|
+
loaded.manifest.status === "cancelled"
|
|
515
|
+
) {
|
|
352
516
|
record.status = loaded.manifest.status;
|
|
353
517
|
record.error = loaded.manifest.summary;
|
|
354
|
-
record.turnCount =
|
|
518
|
+
record.turnCount =
|
|
519
|
+
record.turnCount ?? totalRunTurns(cwd, record.runId);
|
|
355
520
|
record.completedAt = Date.now();
|
|
356
521
|
savePersistedSubagentRecord(cwd, record);
|
|
357
522
|
return;
|
|
@@ -370,32 +535,52 @@ export class SubagentManager {
|
|
|
370
535
|
savePersistedSubagentRecord(cwd, record);
|
|
371
536
|
return;
|
|
372
537
|
}
|
|
373
|
-
await new Promise((resolve) =>
|
|
538
|
+
await new Promise((resolve) =>
|
|
539
|
+
setTimeout(resolve, this.pollIntervalMs),
|
|
540
|
+
);
|
|
374
541
|
}
|
|
375
542
|
}
|
|
376
543
|
|
|
377
|
-
private scheduleBlockedTerminalPoll(
|
|
544
|
+
private scheduleBlockedTerminalPoll(
|
|
545
|
+
cwd: string,
|
|
546
|
+
record: SubagentRecord,
|
|
547
|
+
): void {
|
|
378
548
|
const poll = (): void => {
|
|
379
549
|
const current = this.records.get(record.id);
|
|
380
|
-
if (!current || current.status !== "blocked" || !current.runId)
|
|
381
|
-
|
|
382
|
-
|
|
550
|
+
if (!current || current.status !== "blocked" || !current.runId)
|
|
551
|
+
return;
|
|
552
|
+
const loaded = loadRunManifestById(cwd, current.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
553
|
+
if (
|
|
554
|
+
!loaded ||
|
|
555
|
+
loaded.manifest.status === "blocked" ||
|
|
556
|
+
loaded.manifest.status === "running" ||
|
|
557
|
+
loaded.manifest.status === "planning" ||
|
|
558
|
+
loaded.manifest.status === "queued"
|
|
559
|
+
) {
|
|
383
560
|
const timer = setTimeout(poll, this.pollIntervalMs);
|
|
384
561
|
timer.unref();
|
|
385
562
|
return;
|
|
386
563
|
}
|
|
387
564
|
const persisted = readPersistedSubagentRecord(cwd, current.id);
|
|
388
|
-
current.resultConsumed =
|
|
565
|
+
current.resultConsumed =
|
|
566
|
+
current.resultConsumed || persisted?.resultConsumed;
|
|
389
567
|
if (loaded.manifest.status === "completed") {
|
|
390
568
|
current.status = "completed";
|
|
391
569
|
current.error = undefined;
|
|
392
|
-
} else if (
|
|
570
|
+
} else if (
|
|
571
|
+
loaded.manifest.status === "failed" ||
|
|
572
|
+
loaded.manifest.status === "cancelled"
|
|
573
|
+
) {
|
|
393
574
|
current.status = loaded.manifest.status;
|
|
394
575
|
current.error = loaded.manifest.summary;
|
|
395
576
|
} else return;
|
|
396
577
|
current.completedAt = Date.now();
|
|
397
|
-
current.turnCount =
|
|
398
|
-
|
|
578
|
+
current.turnCount =
|
|
579
|
+
current.turnCount ?? totalRunTurns(cwd, current.runId);
|
|
580
|
+
current.durationMs = Math.max(
|
|
581
|
+
0,
|
|
582
|
+
current.completedAt - current.startedAt,
|
|
583
|
+
);
|
|
399
584
|
savePersistedSubagentRecord(cwd, current);
|
|
400
585
|
this.onComplete?.(current);
|
|
401
586
|
};
|
|
@@ -403,11 +588,20 @@ export class SubagentManager {
|
|
|
403
588
|
timer.unref();
|
|
404
589
|
}
|
|
405
590
|
|
|
406
|
-
private scheduleStuckBlockedNotify(
|
|
591
|
+
private scheduleStuckBlockedNotify(
|
|
592
|
+
cwd: string,
|
|
593
|
+
record: SubagentRecord,
|
|
594
|
+
): void {
|
|
407
595
|
const threshold = DEFAULT_SUBAGENT.stuckBlockedNotifyMs;
|
|
408
596
|
const fire = (): void => {
|
|
409
597
|
const current = this.records.get(record.id);
|
|
410
|
-
if (
|
|
598
|
+
if (
|
|
599
|
+
!current ||
|
|
600
|
+
current.status !== "blocked" ||
|
|
601
|
+
!current.blockedAt ||
|
|
602
|
+
current.stuckNotified
|
|
603
|
+
)
|
|
604
|
+
return;
|
|
411
605
|
current.stuckNotified = true;
|
|
412
606
|
this.onEvent?.("subagent.stuck-blocked", {
|
|
413
607
|
event: "subagent.stuck-blocked",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type HealthGrade = "A" | "B" | "C" | "D" | "F";
|
|
2
|
+
|
|
3
|
+
export interface HealthPenalty {
|
|
4
|
+
reason: string;
|
|
5
|
+
deduction: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface HealthDelta {
|
|
9
|
+
metric: string;
|
|
10
|
+
delta: number;
|
|
11
|
+
trend: "improving" | "degrading" | "stable";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RunHealth {
|
|
15
|
+
score: number;
|
|
16
|
+
grade: HealthGrade;
|
|
17
|
+
penalties: HealthPenalty[];
|
|
18
|
+
deltas: HealthDelta[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const STALLED_THRESHOLD_MS = 5 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
export function scoreToGrade(score: number): HealthGrade {
|
|
24
|
+
if (score >= 90) return "A";
|
|
25
|
+
if (score >= 70) return "B";
|
|
26
|
+
if (score >= 50) return "C";
|
|
27
|
+
if (score >= 30) return "D";
|
|
28
|
+
return "F";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TaskSummary {
|
|
32
|
+
id: string;
|
|
33
|
+
status: string;
|
|
34
|
+
stalledSince?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManifestSummary {
|
|
38
|
+
runId: string;
|
|
39
|
+
tasks: TaskSummary[];
|
|
40
|
+
createdAt: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function computeRunHealth(manifest: ManifestSummary): RunHealth {
|
|
44
|
+
const penalties: HealthPenalty[] = [];
|
|
45
|
+
const tasks = manifest.tasks;
|
|
46
|
+
const taskCount = tasks.length;
|
|
47
|
+
if (taskCount === 0) return { score: 100, grade: "A", penalties: [], deltas: [] };
|
|
48
|
+
|
|
49
|
+
const failedCount = tasks.filter(t => t.status === "failed").length;
|
|
50
|
+
const stalledCount = tasks.filter(t =>
|
|
51
|
+
t.stalledSince !== undefined && (Date.now() - t.stalledSince) > STALLED_THRESHOLD_MS
|
|
52
|
+
).length;
|
|
53
|
+
|
|
54
|
+
const failureRate = failedCount / taskCount;
|
|
55
|
+
if (failureRate > 0.2) {
|
|
56
|
+
penalties.push({ reason: "high-failure-rate", deduction: Math.round(failureRate * 50) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (stalledCount > 0) {
|
|
60
|
+
penalties.push({ reason: "stalled-tasks", deduction: Math.min(15, stalledCount * 5) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (taskCount > 20) {
|
|
64
|
+
penalties.push({ reason: "large-task-count", deduction: Math.min(10, Math.floor((taskCount - 20) / 10)) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const totalDeduction = penalties.reduce((sum, p) => sum + p.deduction, 0);
|
|
68
|
+
const score = Math.max(0, Math.min(100, 100 - totalDeduction));
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
score,
|
|
72
|
+
grade: scoreToGrade(score),
|
|
73
|
+
penalties,
|
|
74
|
+
deltas: [],
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/runtime/task-id.ts
CHANGED
|
@@ -146,3 +146,23 @@ export type DependencyType =
|
|
|
146
146
|
| "duplicates" // A duplicates B
|
|
147
147
|
| "delegated-from" // A was delegated from B
|
|
148
148
|
| "validates"; // A validates B's output
|
|
149
|
+
|
|
150
|
+
// ── Stable IDs (full hash, for cross-run references) ──────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generate a stable, collision-resistant ID from arbitrary content.
|
|
154
|
+
* Uses full SHA-256 hash (not adaptive length) for maximum stability.
|
|
155
|
+
* Format: {prefix}-{first12charsOfBase36hash}
|
|
156
|
+
*
|
|
157
|
+
* Use for: run-level IDs, artifact keys, cross-run references
|
|
158
|
+
* where determinism and uniqueness matter more than short length.
|
|
159
|
+
*/
|
|
160
|
+
export function stableIdFromContent(content: string, prefix = "id"): string {
|
|
161
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
162
|
+
const hashChars = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
163
|
+
let b36 = "";
|
|
164
|
+
for (let i = 0; i < 16 && i < hash.length; i++) {
|
|
165
|
+
b36 += hashChars[parseInt(hash[i]!, 16)] ?? "0";
|
|
166
|
+
}
|
|
167
|
+
return `${prefix}-${b36.slice(0, 12)}`;
|
|
168
|
+
}
|
|
@@ -65,7 +65,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
|
|
|
65
65
|
const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
|
|
66
66
|
const isCurrent = input.isCurrent ?? (() => {
|
|
67
67
|
if (input.signal?.aborted) return false;
|
|
68
|
-
const loaded = loadRunManifestById(manifest.cwd, manifest.runId);
|
|
68
|
+
const loaded = loadRunManifestById(manifest.cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
69
69
|
const currentTask = loaded?.tasks.find((item) => item.id === task.id);
|
|
70
70
|
if (loaded?.manifest.status && loaded.manifest.status !== "running") return false;
|
|
71
71
|
return currentTask ? currentTask.status === "running" : true;
|
|
@@ -77,7 +77,7 @@ export function applyUsageToProgress(progress: CrewAgentProgress | undefined, us
|
|
|
77
77
|
|
|
78
78
|
export function shouldFlushProgressEvent(event: unknown): boolean {
|
|
79
79
|
const type = asRecord(event)?.type;
|
|
80
|
-
return type === "tool_execution_start" || type === "tool_execution_end" || type === "message_end" || type === "tool_result_end";
|
|
80
|
+
return type === "tool_execution_start" || type === "tool_execution_end" || type === "message_start" || type === "message_end" || type === "tool_result_end";
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function progressEventSummary(task: TeamTaskState, event: unknown): ProgressEventSummary {
|