pi-crew 0.6.0 → 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 +225 -0
- package/README.md +70 -31
- 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/agents/agent-config.ts +2 -1
- package/src/benchmark/feedback-loop.ts +4 -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/cross-extension-rpc.ts +48 -0
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +194 -34
- package/src/extension/registration/commands.ts +4 -3
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +3 -1
- package/src/extension/registration/team-tool.ts +3 -1
- package/src/extension/registration/viewers.ts +3 -2
- package/src/extension/run-export.ts +16 -1
- package/src/extension/run-import.ts +16 -0
- package/src/extension/team-tool/anchor.ts +5 -1
- package/src/extension/team-tool/api.ts +12 -7
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/config-patch.ts +15 -1
- 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 +175 -47
- package/src/hooks/registry.ts +84 -12
- package/src/hooks/types.ts +12 -3
- package/src/i18n.ts +15 -2
- package/src/observability/exporters/otlp-exporter.ts +73 -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/adaptive-plan.ts +24 -0
- package/src/runtime/agent-control.ts +6 -3
- package/src/runtime/async-runner.ts +86 -3
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +67 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +165 -38
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +6 -3
- package/src/runtime/cross-extension-rpc.ts +34 -8
- package/src/runtime/diagnostic-export.ts +4 -5
- package/src/runtime/dynamic-script-runner.ts +9 -6
- package/src/runtime/foreground-watchdog.ts +3 -3
- 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/live-agent-manager.ts +6 -3
- package/src/runtime/live-irc.ts +4 -2
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parallel-utils.ts +2 -1
- 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 +11 -4
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +42 -21
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/semaphore.ts +2 -1
- package/src/runtime/settings-store.ts +14 -2
- package/src/runtime/skill-effectiveness.ts +109 -64
- package/src/runtime/skill-instructions.ts +114 -33
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +265 -53
- package/src/runtime/subprocess-tool-registry.ts +2 -2
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-packet.ts +13 -1
- 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 +98 -67
- package/src/runtime/team-runner.ts +186 -20
- package/src/runtime/usage-tracker.ts +4 -2
- package/src/runtime/verification-gates.ts +36 -9
- 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/contracts.ts +2 -1
- 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 +270 -75
- package/src/state/health-store.ts +71 -0
- package/src/state/hook-instinct-bridge.ts +2 -1
- package/src/state/locks.ts +109 -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 +333 -44
- package/src/state/task-claims.ts +9 -2
- package/src/tools/safe-bash.ts +69 -20
- package/src/types/new-api-types.ts +10 -5
- package/src/ui/keybinding-map.ts +2 -1
- 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 +4 -3
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +2 -1
- package/src/ui/syntax-highlight.ts +2 -1
- package/src/ui/tool-render.ts +13 -3
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/fs-watch.ts +4 -2
- package/src/utils/gh-protocol.ts +2 -1
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +287 -10
- package/src/worktree/cleanup.ts +117 -26
- 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.
|
|
@@ -88,17 +109,81 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
|
|
|
88
109
|
}
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
|
|
112
|
+
const ALLOWED_RECORD_FIELDS = new Set([
|
|
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",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
function sanitizePersistedRecord(raw: unknown): SubagentRecord | undefined {
|
|
141
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
142
|
+
const obj = raw as Record<string, unknown>;
|
|
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
|
+
}
|
|
151
|
+
for (const key of Object.keys(obj)) {
|
|
152
|
+
if (
|
|
153
|
+
ALLOWED_RECORD_FIELDS.has(key) &&
|
|
154
|
+
(typeof obj[key] === "string" ||
|
|
155
|
+
typeof obj[key] === "number" ||
|
|
156
|
+
typeof obj[key] === "boolean")
|
|
157
|
+
) {
|
|
158
|
+
clean[key] = obj[key];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Re-validate `id` to prevent prototype/constructor smuggling
|
|
162
|
+
if (typeof clean.id !== "string" || clean.id.length === 0) return undefined;
|
|
163
|
+
return clean as unknown as SubagentRecord;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function readPersistedSubagentRecord(
|
|
167
|
+
cwd: string,
|
|
168
|
+
id: string,
|
|
169
|
+
): SubagentRecord | undefined {
|
|
92
170
|
try {
|
|
93
|
-
const
|
|
94
|
-
|
|
171
|
+
const raw = JSON.parse(
|
|
172
|
+
fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"),
|
|
173
|
+
);
|
|
174
|
+
return sanitizePersistedRecord(raw);
|
|
95
175
|
} catch {
|
|
96
176
|
return undefined;
|
|
97
177
|
}
|
|
98
178
|
}
|
|
99
179
|
|
|
100
180
|
function resultText(result: PiTeamsToolResult): string {
|
|
101
|
-
return
|
|
181
|
+
return (
|
|
182
|
+
result.content
|
|
183
|
+
?.map((item) => (item.type === "text" ? item.text : ""))
|
|
184
|
+
.filter(Boolean)
|
|
185
|
+
.join("\n") ?? ""
|
|
186
|
+
);
|
|
102
187
|
}
|
|
103
188
|
|
|
104
189
|
function detailsRunId(result: PiTeamsToolResult): string | undefined {
|
|
@@ -106,9 +191,12 @@ function detailsRunId(result: PiTeamsToolResult): string | undefined {
|
|
|
106
191
|
return typeof details?.runId === "string" ? details.runId : undefined;
|
|
107
192
|
}
|
|
108
193
|
|
|
109
|
-
function totalRunTurns(
|
|
194
|
+
function totalRunTurns(
|
|
195
|
+
cwd: string,
|
|
196
|
+
runId: string | undefined,
|
|
197
|
+
): number | undefined {
|
|
110
198
|
if (!runId) return undefined;
|
|
111
|
-
const loaded = loadRunManifestById(cwd, runId);
|
|
199
|
+
const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
112
200
|
if (!loaded) return undefined;
|
|
113
201
|
let total = 0;
|
|
114
202
|
let hasTurns = false;
|
|
@@ -135,20 +223,33 @@ export class SubagentManager {
|
|
|
135
223
|
private readonly onEvent?: NotifyEvent;
|
|
136
224
|
private readonly pollIntervalMs: number;
|
|
137
225
|
|
|
138
|
-
constructor(
|
|
226
|
+
constructor(
|
|
227
|
+
maxConcurrent = 4,
|
|
228
|
+
onComplete?: Notify,
|
|
229
|
+
pollIntervalMs = 1000,
|
|
230
|
+
onEvent?: NotifyEvent,
|
|
231
|
+
) {
|
|
139
232
|
this.maxConcurrent = maxConcurrent;
|
|
140
233
|
this.onComplete = onComplete;
|
|
141
234
|
this.onEvent = onEvent;
|
|
142
235
|
this.pollIntervalMs = pollIntervalMs;
|
|
143
236
|
}
|
|
144
237
|
|
|
145
|
-
spawn(
|
|
238
|
+
spawn(
|
|
239
|
+
options: SubagentSpawnOptions,
|
|
240
|
+
runner: SpawnRunner,
|
|
241
|
+
signal?: AbortSignal,
|
|
242
|
+
): SubagentRecord {
|
|
146
243
|
const record: SubagentRecord = {
|
|
147
244
|
id: `agent_${Date.now().toString(36)}_${(++this.counter).toString(36)}`,
|
|
148
245
|
type: options.type,
|
|
149
246
|
description: options.description,
|
|
150
247
|
prompt: options.prompt,
|
|
151
|
-
status:
|
|
248
|
+
status:
|
|
249
|
+
options.background &&
|
|
250
|
+
this.runningBackground >= this.maxConcurrent
|
|
251
|
+
? "queued"
|
|
252
|
+
: "running",
|
|
152
253
|
startedAt: Date.now(),
|
|
153
254
|
model: options.model,
|
|
154
255
|
skill: options.skill,
|
|
@@ -171,7 +272,9 @@ export class SubagentManager {
|
|
|
171
272
|
}
|
|
172
273
|
|
|
173
274
|
listAgents(): SubagentRecord[] {
|
|
174
|
-
return [...this.records.values()].sort(
|
|
275
|
+
return [...this.records.values()].sort(
|
|
276
|
+
(a, b) => b.startedAt - a.startedAt,
|
|
277
|
+
);
|
|
175
278
|
}
|
|
176
279
|
|
|
177
280
|
abort(id: string, reason?: string): boolean {
|
|
@@ -182,7 +285,8 @@ export class SubagentManager {
|
|
|
182
285
|
this.markStopped(record, reason ?? "Aborted by caller.");
|
|
183
286
|
return true;
|
|
184
287
|
}
|
|
185
|
-
if (record.status !== "running" && record.status !== "blocked")
|
|
288
|
+
if (record.status !== "running" && record.status !== "blocked")
|
|
289
|
+
return false;
|
|
186
290
|
this.controllers.get(id)?.abort();
|
|
187
291
|
this.markStopped(record, reason ?? "Aborted by caller.");
|
|
188
292
|
return true;
|
|
@@ -209,7 +313,16 @@ export class SubagentManager {
|
|
|
209
313
|
async waitForAll(): Promise<void> {
|
|
210
314
|
while (true) {
|
|
211
315
|
this.drainQueue();
|
|
212
|
-
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
|
+
);
|
|
213
326
|
if (!pending.length) break;
|
|
214
327
|
await Promise.allSettled(pending);
|
|
215
328
|
}
|
|
@@ -219,8 +332,16 @@ export class SubagentManager {
|
|
|
219
332
|
while (true) {
|
|
220
333
|
const record = this.records.get(id);
|
|
221
334
|
if (!record) return undefined;
|
|
222
|
-
if (record.status !== "running" && record.status !== "queued")
|
|
223
|
-
|
|
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
|
+
});
|
|
224
345
|
else await new Promise((resolve) => setTimeout(resolve, 100));
|
|
225
346
|
}
|
|
226
347
|
}
|
|
@@ -230,7 +351,12 @@ export class SubagentManager {
|
|
|
230
351
|
this.drainQueue();
|
|
231
352
|
}
|
|
232
353
|
|
|
233
|
-
private start(
|
|
354
|
+
private start(
|
|
355
|
+
record: SubagentRecord,
|
|
356
|
+
options: SubagentSpawnOptions,
|
|
357
|
+
runner: SpawnRunner,
|
|
358
|
+
signal?: AbortSignal,
|
|
359
|
+
): void {
|
|
234
360
|
if (options.background) this.runningBackground++;
|
|
235
361
|
record.status = "running";
|
|
236
362
|
record.startedAt = Date.now();
|
|
@@ -249,33 +375,72 @@ export class SubagentManager {
|
|
|
249
375
|
record.error = record.result;
|
|
250
376
|
throw new Error(record.error);
|
|
251
377
|
}
|
|
252
|
-
if (record.runId)
|
|
378
|
+
if (record.runId)
|
|
379
|
+
await this.pollRunToTerminal(options.cwd, record);
|
|
253
380
|
else record.status = "completed";
|
|
254
381
|
} catch (error) {
|
|
255
382
|
if (record.status === "stopped" || runSignal.aborted) {
|
|
256
|
-
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;
|
|
257
386
|
record.status = "stopped";
|
|
258
|
-
if (!record.error)
|
|
387
|
+
if (!record.error)
|
|
388
|
+
record.error =
|
|
389
|
+
abortReason ??
|
|
390
|
+
(error instanceof Error
|
|
391
|
+
? error.message
|
|
392
|
+
: String(error));
|
|
259
393
|
return;
|
|
260
394
|
}
|
|
261
395
|
record.status = "error";
|
|
262
|
-
record.error =
|
|
396
|
+
record.error =
|
|
397
|
+
error instanceof Error ? error.message : String(error);
|
|
263
398
|
throw error; // H4: Propagate rejection so callers awaiting record.promise see the error
|
|
264
399
|
} finally {
|
|
265
400
|
this.cleanupRunSignal(record.id);
|
|
266
|
-
if (options.background)
|
|
267
|
-
|
|
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();
|
|
268
408
|
savePersistedSubagentRecord(options.cwd, record);
|
|
269
|
-
if (
|
|
409
|
+
if (
|
|
410
|
+
record.status === "completed" ||
|
|
411
|
+
record.status === "failed" ||
|
|
412
|
+
record.status === "cancelled" ||
|
|
413
|
+
record.status === "error" ||
|
|
414
|
+
record.status === "stopped"
|
|
415
|
+
) {
|
|
270
416
|
// Phase 1.6: Populate telemetry fields
|
|
271
|
-
record.turnCount =
|
|
272
|
-
|
|
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;
|
|
273
423
|
savePersistedSubagentRecord(options.cwd, record);
|
|
274
424
|
this.onComplete?.(record);
|
|
275
425
|
}
|
|
276
426
|
this.drainQueue();
|
|
277
427
|
}
|
|
278
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
|
+
});
|
|
279
444
|
}
|
|
280
445
|
|
|
281
446
|
private markStopped(record: SubagentRecord, reason?: string): void {
|
|
@@ -296,7 +461,9 @@ export class SubagentManager {
|
|
|
296
461
|
if (signal) {
|
|
297
462
|
const abort = (): void => controller.abort();
|
|
298
463
|
signal.addEventListener("abort", abort, { once: true });
|
|
299
|
-
this.controllerCleanup.set(id, () =>
|
|
464
|
+
this.controllerCleanup.set(id, () =>
|
|
465
|
+
signal.removeEventListener("abort", abort),
|
|
466
|
+
);
|
|
300
467
|
}
|
|
301
468
|
return controller.signal;
|
|
302
469
|
}
|
|
@@ -308,32 +475,48 @@ export class SubagentManager {
|
|
|
308
475
|
}
|
|
309
476
|
|
|
310
477
|
private drainQueue(): void {
|
|
311
|
-
while (
|
|
478
|
+
while (
|
|
479
|
+
this.queue.length > 0 &&
|
|
480
|
+
this.runningBackground < this.maxConcurrent
|
|
481
|
+
) {
|
|
312
482
|
const next = this.queue.shift();
|
|
313
483
|
if (!next || next.record.status !== "queued") continue;
|
|
314
484
|
this.start(next.record, next.options, next.runner, next.signal);
|
|
315
485
|
}
|
|
316
486
|
}
|
|
317
487
|
|
|
318
|
-
private async pollRunToTerminal(
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
321
497
|
if (!loaded) {
|
|
322
|
-
await new Promise((resolve) =>
|
|
498
|
+
await new Promise((resolve) =>
|
|
499
|
+
setTimeout(resolve, this.pollIntervalMs),
|
|
500
|
+
);
|
|
323
501
|
continue;
|
|
324
502
|
}
|
|
325
503
|
if (loaded.manifest.status === "completed") {
|
|
326
504
|
record.status = "completed";
|
|
327
505
|
record.error = undefined;
|
|
328
|
-
record.turnCount =
|
|
506
|
+
record.turnCount =
|
|
507
|
+
record.turnCount ?? totalRunTurns(cwd, record.runId);
|
|
329
508
|
record.completedAt = Date.now();
|
|
330
509
|
savePersistedSubagentRecord(cwd, record);
|
|
331
510
|
return;
|
|
332
511
|
}
|
|
333
|
-
if (
|
|
512
|
+
if (
|
|
513
|
+
loaded.manifest.status === "failed" ||
|
|
514
|
+
loaded.manifest.status === "cancelled"
|
|
515
|
+
) {
|
|
334
516
|
record.status = loaded.manifest.status;
|
|
335
517
|
record.error = loaded.manifest.summary;
|
|
336
|
-
record.turnCount =
|
|
518
|
+
record.turnCount =
|
|
519
|
+
record.turnCount ?? totalRunTurns(cwd, record.runId);
|
|
337
520
|
record.completedAt = Date.now();
|
|
338
521
|
savePersistedSubagentRecord(cwd, record);
|
|
339
522
|
return;
|
|
@@ -352,32 +535,52 @@ export class SubagentManager {
|
|
|
352
535
|
savePersistedSubagentRecord(cwd, record);
|
|
353
536
|
return;
|
|
354
537
|
}
|
|
355
|
-
await new Promise((resolve) =>
|
|
538
|
+
await new Promise((resolve) =>
|
|
539
|
+
setTimeout(resolve, this.pollIntervalMs),
|
|
540
|
+
);
|
|
356
541
|
}
|
|
357
542
|
}
|
|
358
543
|
|
|
359
|
-
private scheduleBlockedTerminalPoll(
|
|
544
|
+
private scheduleBlockedTerminalPoll(
|
|
545
|
+
cwd: string,
|
|
546
|
+
record: SubagentRecord,
|
|
547
|
+
): void {
|
|
360
548
|
const poll = (): void => {
|
|
361
549
|
const current = this.records.get(record.id);
|
|
362
|
-
if (!current || current.status !== "blocked" || !current.runId)
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
) {
|
|
365
560
|
const timer = setTimeout(poll, this.pollIntervalMs);
|
|
366
561
|
timer.unref();
|
|
367
562
|
return;
|
|
368
563
|
}
|
|
369
564
|
const persisted = readPersistedSubagentRecord(cwd, current.id);
|
|
370
|
-
current.resultConsumed =
|
|
565
|
+
current.resultConsumed =
|
|
566
|
+
current.resultConsumed || persisted?.resultConsumed;
|
|
371
567
|
if (loaded.manifest.status === "completed") {
|
|
372
568
|
current.status = "completed";
|
|
373
569
|
current.error = undefined;
|
|
374
|
-
} else if (
|
|
570
|
+
} else if (
|
|
571
|
+
loaded.manifest.status === "failed" ||
|
|
572
|
+
loaded.manifest.status === "cancelled"
|
|
573
|
+
) {
|
|
375
574
|
current.status = loaded.manifest.status;
|
|
376
575
|
current.error = loaded.manifest.summary;
|
|
377
576
|
} else return;
|
|
378
577
|
current.completedAt = Date.now();
|
|
379
|
-
current.turnCount =
|
|
380
|
-
|
|
578
|
+
current.turnCount =
|
|
579
|
+
current.turnCount ?? totalRunTurns(cwd, current.runId);
|
|
580
|
+
current.durationMs = Math.max(
|
|
581
|
+
0,
|
|
582
|
+
current.completedAt - current.startedAt,
|
|
583
|
+
);
|
|
381
584
|
savePersistedSubagentRecord(cwd, current);
|
|
382
585
|
this.onComplete?.(current);
|
|
383
586
|
};
|
|
@@ -385,11 +588,20 @@ export class SubagentManager {
|
|
|
385
588
|
timer.unref();
|
|
386
589
|
}
|
|
387
590
|
|
|
388
|
-
private scheduleStuckBlockedNotify(
|
|
591
|
+
private scheduleStuckBlockedNotify(
|
|
592
|
+
cwd: string,
|
|
593
|
+
record: SubagentRecord,
|
|
594
|
+
): void {
|
|
389
595
|
const threshold = DEFAULT_SUBAGENT.stuckBlockedNotifyMs;
|
|
390
596
|
const fire = (): void => {
|
|
391
597
|
const current = this.records.get(record.id);
|
|
392
|
-
if (
|
|
598
|
+
if (
|
|
599
|
+
!current ||
|
|
600
|
+
current.status !== "blocked" ||
|
|
601
|
+
!current.blockedAt ||
|
|
602
|
+
current.stuckNotified
|
|
603
|
+
)
|
|
604
|
+
return;
|
|
393
605
|
current.stuckNotified = true;
|
|
394
606
|
this.onEvent?.("subagent.stuck-blocked", {
|
|
395
607
|
event: "subagent.stuck-blocked",
|
|
@@ -61,7 +61,7 @@ class SubprocessToolRegistryImpl implements SubprocessToolRegistry {
|
|
|
61
61
|
|
|
62
62
|
export const subprocessToolRegistry: SubprocessToolRegistry = new SubprocessToolRegistryImpl();
|
|
63
63
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/** @internal Reset the global singleton registry (for test isolation). */
|
|
65
|
+
function resetSubprocessToolRegistry(): void {
|
|
66
66
|
subprocessToolRegistry.clear();
|
|
67
67
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import type { TeamRunManifest, TaskPacket, TaskScope, VerificationContract } from "../state/types.ts";
|
|
3
3
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
4
|
+
import { generateTaskHashId } from "./task-id.ts";
|
|
4
5
|
|
|
5
6
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
6
7
|
// SEC-007 Fix: Workflow Step Task Sanitization
|
|
@@ -81,8 +82,19 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
|
|
|
81
82
|
const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
|
|
82
83
|
// SEC-007: Sanitize task text before inserting into task packet
|
|
83
84
|
const sanitizedTask = sanitizeTaskText(input.step.task);
|
|
85
|
+
const sanitizedGoal = sanitizeTaskText(input.manifest.goal);
|
|
86
|
+
|
|
87
|
+
// Generate a deterministic hash-based task ID for traceability and logging.
|
|
88
|
+
// Uses goal + step ID + run ID as content parts.
|
|
89
|
+
// TODO: Once TaskPacket type gains a hashId field, include this in the packet.
|
|
90
|
+
const _taskHashId = generateTaskHashId([
|
|
91
|
+
input.manifest.goal,
|
|
92
|
+
input.step.id,
|
|
93
|
+
input.manifest.runId,
|
|
94
|
+
]);
|
|
95
|
+
|
|
84
96
|
return {
|
|
85
|
-
objective: sanitizedTask.replaceAll("{goal}",
|
|
97
|
+
objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
|
|
86
98
|
scope,
|
|
87
99
|
scopePath,
|
|
88
100
|
repo: path.basename(input.manifest.cwd) || input.manifest.cwd,
|
|
@@ -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 {
|