pi-crew 0.9.26 → 0.9.28
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 +102 -0
- package/NOTICE.md +14 -0
- package/assets/crew-vibes.ttf +0 -0
- package/assets/runner-spritesheet.png +0 -0
- package/dist/build-meta.json +245 -48
- package/dist/index.mjs +1611 -243
- package/dist/index.mjs.map +4 -4
- package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
- package/package.json +9 -2
- package/scripts/bench-check.mjs +96 -0
- package/scripts/bench-cold-start.mjs +216 -0
- package/scripts/build-bundle.mjs +84 -0
- package/scripts/build-crew-vibes-font.py +328 -0
- package/scripts/check-all-skills.ts +294 -0
- package/scripts/check-bundle-staleness.mjs +97 -0
- package/scripts/check-conflict-markers.mjs +91 -0
- package/scripts/check-lazy-imports.mjs +28 -0
- package/scripts/install-crew-vibes-font.mjs +91 -0
- package/scripts/postinstall.mjs +47 -0
- package/scripts/profile-startup.mjs +125 -0
- package/scripts/release-smoke.mjs +74 -0
- package/scripts/run-bench.mjs +47 -0
- package/scripts/run-real-chain.ts +41 -0
- package/scripts/test-issue-29-crash.ts +111 -0
- package/scripts/test-issue-29-e2e.ts +183 -0
- package/scripts/test-issue-29-real-runtime.ts +330 -0
- package/scripts/test-issue-29-real-tasks.ts +387 -0
- package/scripts/test-issue-29-team-tool.ts +105 -0
- package/scripts/test-runner.mjs +74 -0
- package/scripts/verify-flicker-fix.ts +109 -0
- package/scripts/verify-skill.ts +550 -0
- package/scripts/watch-bundle.mjs +259 -0
- package/scripts/watchdog-harness.ts +119 -0
- package/src/agents/agent-config.ts +2 -0
- package/src/agents/discover-agents.ts +10 -1
- package/src/config/defaults.ts +6 -0
- package/src/extension/crew-vibes/cat-frames.ts +18 -0
- package/src/extension/crew-vibes/config.ts +195 -0
- package/src/extension/crew-vibes/figures.ts +94 -0
- package/src/extension/crew-vibes/font-detect.ts +58 -0
- package/src/extension/crew-vibes/index.ts +385 -0
- package/src/extension/crew-vibes/provider-usage.ts +396 -0
- package/src/extension/crew-vibes/render.ts +206 -0
- package/src/extension/crew-vibes/speed.ts +286 -0
- package/src/extension/knowledge-injection.ts +12 -3
- package/src/extension/management.ts +23 -3
- package/src/extension/register.ts +7 -0
- package/src/extension/team-tool.ts +11 -0
- package/src/prompt/prompt-runtime.ts +65 -0
- package/src/runtime/child-pi.ts +123 -10
- package/src/runtime/crew-agent-records.ts +10 -3
- package/src/runtime/pi-args.ts +2 -0
- package/src/runtime/retry-executor.ts +4 -1
- package/src/runtime/task-runner/state-helpers.ts +9 -30
- package/src/runtime/task-runner.ts +1 -0
- package/src/runtime/team-runner.ts +5 -3
- package/src/state/atomic-write.ts +153 -49
- package/src/state/event-log.ts +16 -10
- package/src/state/locks.ts +7 -8
- package/src/state/mailbox.ts +15 -4
- package/src/state/state-store.ts +39 -10
- package/src/teams/discover-teams.ts +56 -1
- package/src/utils/safe-paths.ts +2 -1
- package/src/workflows/discover-workflows.ts +72 -1
package/src/runtime/child-pi.ts
CHANGED
|
@@ -31,6 +31,18 @@ const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
|
|
|
31
31
|
const activeChildProcesses = new Map<number, ChildProcess>();
|
|
32
32
|
const childHardKillTimers = new Map<number, NodeJS.Timeout>();
|
|
33
33
|
|
|
34
|
+
// Periodic cleanup of dead child process entries to prevent memory leaks.
|
|
35
|
+
// If a child process never emits exit/close (zombie), the entry would leak.
|
|
36
|
+
setInterval(() => {
|
|
37
|
+
for (const [pid, child] of activeChildProcesses) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 0); // Throws ESRCH if dead
|
|
40
|
+
} catch {
|
|
41
|
+
activeChildProcesses.delete(pid);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}, 60_000).unref();
|
|
45
|
+
|
|
34
46
|
/**
|
|
35
47
|
* SEC-1: Extract a redacted stderr/stdout excerpt for embedding in lifecycle
|
|
36
48
|
* events and error messages. The in-memory stdout/stderr accumulators receive
|
|
@@ -176,6 +188,10 @@ export interface ChildPiLifecycleEvent {
|
|
|
176
188
|
stderrExcerpt?: string;
|
|
177
189
|
/** Timestamp (ISO). */
|
|
178
190
|
ts: string;
|
|
191
|
+
/** F12: optional cause for `final_drain` events. `"stdout-quiet"` indicates
|
|
192
|
+
* the drain was triggered by the quiet-window early-exit rather than the
|
|
193
|
+
* default 5 s ceiling. Other drain reasons (default) leave this undefined. */
|
|
194
|
+
reason?: "stdout-quiet";
|
|
179
195
|
/** Phase-0 diagnostic (HB-003a): the signal that killed the child (when
|
|
180
196
|
* available). Was previously discarded after building the error string. */
|
|
181
197
|
signal?: string;
|
|
@@ -205,6 +221,9 @@ export interface ChildPiRunInput {
|
|
|
205
221
|
onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
|
|
206
222
|
maxDepth?: number;
|
|
207
223
|
finalDrainMs?: number;
|
|
224
|
+
/** F12: early-exit the drain when stdout has been silent for this many ms
|
|
225
|
+
* after the final assistant event. Set to ≥ finalDrainMs to disable. */
|
|
226
|
+
finalDrainQuietMs?: number;
|
|
208
227
|
hardKillMs?: number;
|
|
209
228
|
responseTimeoutMs?: number;
|
|
210
229
|
/** Soft limit on assistant turns — inject steer at this count. */
|
|
@@ -219,6 +238,8 @@ export interface ChildPiRunInput {
|
|
|
219
238
|
excludeContextBash?: boolean;
|
|
220
239
|
/** pi session ID for session naming (aligns with pi-crew run ID) */
|
|
221
240
|
sessionId?: string;
|
|
241
|
+
/** Path to steering JSONL file for real-time steer injection. */
|
|
242
|
+
steeringFile?: string;
|
|
222
243
|
/** Run ID for cleanup tracking */
|
|
223
244
|
runId?: string;
|
|
224
245
|
/** Agent ID for cleanup tracking */
|
|
@@ -298,6 +319,8 @@ const BASE_ALLOWLIST: string[] = [
|
|
|
298
319
|
"PI_TEAMS_PI_BIN",
|
|
299
320
|
"PI_TEAMS_MOCK_CHILD_PI",
|
|
300
321
|
"PI_CREW_ALLOW_MOCK",
|
|
322
|
+
"PI_CREW_MAX_OUTPUT",
|
|
323
|
+
"PI_CREW_STEERING_FILE",
|
|
301
324
|
];
|
|
302
325
|
|
|
303
326
|
export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
|
|
@@ -569,17 +592,22 @@ function compactChildPiLine(line: string): {
|
|
|
569
592
|
export class ChildPiLineObserver {
|
|
570
593
|
private buffer = "";
|
|
571
594
|
private readonly input: ChildPiRunInput;
|
|
572
|
-
/** RAW
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
595
|
+
/** F9: bounded ring buffer for RAW assistant-text fragments. Consumers
|
|
596
|
+
* (getRawFinalText) only read the last element, but the legacy implementation
|
|
597
|
+
* accumulated every fragment unconditionally, which let a verbose/long-running
|
|
598
|
+
* worker grow this array linearly with output. We retain the last 2 entries:
|
|
599
|
+
* the consumer needs the last; we keep the second-to-last only as a defensive
|
|
600
|
+
* fence against a race where a final event arrives just after the consumer
|
|
601
|
+
* read (the previous "last" is still the most-recent pre-final text in that
|
|
602
|
+
* window). 2 is well below any plausible consumer's "tail-only" need while
|
|
603
|
+
* bounding memory. */
|
|
604
|
+
private static readonly MAX_RAW_TEXT_EVENTS = 2;
|
|
577
605
|
private readonly rawTextEvents: string[] = [];
|
|
578
|
-
/**
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
|
|
606
|
+
/** F9: bounded ring buffer for intermediate findings. The downstream digest
|
|
607
|
+
* (getIntermediateFindings) slices the last 20, but the array previously grew
|
|
608
|
+
* to 1000s of entries. We keep MAX_INTERMEDIATE_DIGEST_LINES + headroom so
|
|
609
|
+
* the public API behaviour is preserved (still returns "last 20 lines"). */
|
|
610
|
+
private static readonly MAX_INTERMEDIATE_FINDINGS = 32;
|
|
583
611
|
private readonly intermediateFindings: string[] = [];
|
|
584
612
|
|
|
585
613
|
constructor(input: ChildPiRunInput) {
|
|
@@ -633,12 +661,19 @@ export class ChildPiLineObserver {
|
|
|
633
661
|
const rawParsed = JSON.parse(line);
|
|
634
662
|
const rawTexts = extractText(rawParsed);
|
|
635
663
|
if (rawTexts.length > 0) {
|
|
664
|
+
// F9: trim from the front if the push would exceed the cap. Slice's
|
|
665
|
+
// second arg excludes the index, so this drops the oldest entries
|
|
666
|
+
// while keeping the freshly pushed tail.
|
|
636
667
|
this.rawTextEvents.push(...rawTexts);
|
|
668
|
+
const rawOverflow = this.rawTextEvents.length - ChildPiLineObserver.MAX_RAW_TEXT_EVENTS;
|
|
669
|
+
if (rawOverflow > 0) this.rawTextEvents.splice(0, rawOverflow);
|
|
637
670
|
// Also capture raw assistant text as intermediate findings — the last raw
|
|
638
671
|
// text may be a partial answer before the worker ran out of budget.
|
|
639
672
|
const last = rawTexts[rawTexts.length - 1];
|
|
640
673
|
if (last.trim().length > 0) {
|
|
641
674
|
this.intermediateFindings.push(last.trim());
|
|
675
|
+
const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
|
|
676
|
+
if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
|
|
642
677
|
}
|
|
643
678
|
}
|
|
644
679
|
} catch {
|
|
@@ -663,6 +698,8 @@ export class ChildPiLineObserver {
|
|
|
663
698
|
// findings. This ensures we capture tool output even when no assistant text
|
|
664
699
|
// is emitted (budget exhausted on tool calls).
|
|
665
700
|
this.intermediateFindings.push(compact.displayLine!.trim());
|
|
701
|
+
const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
|
|
702
|
+
if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
|
|
666
703
|
}
|
|
667
704
|
}
|
|
668
705
|
}
|
|
@@ -855,6 +892,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
855
892
|
skillPaths: input.skillPaths,
|
|
856
893
|
role: input.role,
|
|
857
894
|
});
|
|
895
|
+
// Pass steering file path to child for real-time steer injection
|
|
896
|
+
if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
|
|
858
897
|
const spawnSpec = getPiSpawnCommand(built.args);
|
|
859
898
|
try {
|
|
860
899
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
@@ -921,6 +960,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
921
960
|
// handler know a drain timer existed even after clearFinalDrainTimers() ran;
|
|
922
961
|
// spawnMonotonicMs gives us relative timing to distinguish a race from a crash.
|
|
923
962
|
let finalDrainArmed = false;
|
|
963
|
+
// F12: monotonic timestamp of the last stdout JSON event (any event —
|
|
964
|
+
// we want to know when stdout *stopped*, not when the final assistant
|
|
965
|
+
// event arrived). Updated on every onJsonEvent dispatch.
|
|
966
|
+
let lastStdoutActivityMonotonicMs = performance.now();
|
|
924
967
|
let finalDrainFiredMonotonicMs: number | undefined;
|
|
925
968
|
const spawnMonotonicMs = performance.now();
|
|
926
969
|
let finalAssistantEventMonotonicMs: number | undefined;
|
|
@@ -1141,10 +1184,80 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1141
1184
|
completeOperation(eventOpId);
|
|
1142
1185
|
throw err;
|
|
1143
1186
|
}
|
|
1187
|
+
// F12: capture monotonic timestamp BEFORE dispatching — any stdout
|
|
1188
|
+
// JSON event counts as activity. This lets the quiet-window
|
|
1189
|
+
// detection measure "time since last byte of stdout" accurately
|
|
1190
|
+
// regardless of what onJsonEvent does.
|
|
1191
|
+
lastStdoutActivityMonotonicMs = performance.now();
|
|
1144
1192
|
input.onJsonEvent?.(event);
|
|
1145
1193
|
if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
|
|
1146
1194
|
finalAssistantEventMonotonicMs = performance.now();
|
|
1147
1195
|
finalDrainArmed = true; // Phase-0 diagnostic: track that a drain timer was created.
|
|
1196
|
+
// F12: alongside the 5 s ceiling timer, start a polling watcher
|
|
1197
|
+
// that fires the drain early if stdout goes quiet for `quietMs`
|
|
1198
|
+
// after the final assistant event. Heavy children that emit a
|
|
1199
|
+
// stopReason=stop message_end and then sit idle will exit in
|
|
1200
|
+
// ~quietMs (default 800 ms) instead of up to up to 5 s. unref() so
|
|
1201
|
+
// the poller never holds the event loop on shutdown.
|
|
1202
|
+
// NOTE: The polling watcher is NOT explicitly cleared on process exit.
|
|
1203
|
+
// This is safe because: (1) it's unref()'d, so it won't prevent exit;
|
|
1204
|
+
// (2) the `settled || childExited` guard at the top prevents firing
|
|
1205
|
+
// after the child has exited; (3) sending SIGTERM to an already-
|
|
1206
|
+
// exiting process is harmless. The `finalDrainQuietMs` config allows
|
|
1207
|
+
// disabling this behavior (set >= finalDrainMs, e.g., 10000).
|
|
1208
|
+
const quietMs = input.finalDrainQuietMs ?? DEFAULT_CHILD_PI.finalDrainQuietMs;
|
|
1209
|
+
if (quietMs < (input.finalDrainMs ?? DEFAULT_CHILD_PI.finalDrainMs)) {
|
|
1210
|
+
const pollHandle = setInterval(() => {
|
|
1211
|
+
if (settled || childExited) {
|
|
1212
|
+
clearInterval(pollHandle);
|
|
1213
|
+
pollHandle.unref();
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
const sinceLast = performance.now() - lastStdoutActivityMonotonicMs;
|
|
1217
|
+
if (sinceLast >= quietMs) {
|
|
1218
|
+
clearInterval(pollHandle);
|
|
1219
|
+
pollHandle.unref();
|
|
1220
|
+
// Trigger the same drain path as the 5 s timer:
|
|
1221
|
+
// mark forced, fire final_drain lifecycle, SIGTERM.
|
|
1222
|
+
forcedFinalDrain = true;
|
|
1223
|
+
finalDrainFiredMonotonicMs = performance.now();
|
|
1224
|
+
input.onLifecycleEvent?.({
|
|
1225
|
+
type: "final_drain",
|
|
1226
|
+
pid: child.pid,
|
|
1227
|
+
ts: new Date().toISOString(),
|
|
1228
|
+
reason: "stdout-quiet",
|
|
1229
|
+
});
|
|
1230
|
+
try {
|
|
1231
|
+
child.kill(process.platform === "win32" ? undefined : "SIGTERM");
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
logInternalError("child-pi.quiet-drain-term", error, `pid=${child.pid}`);
|
|
1234
|
+
}
|
|
1235
|
+
// Mark for hard kill fallback so the existing timer is
|
|
1236
|
+
// still reaped if it ever fires later.
|
|
1237
|
+
hardKillTimer = setTimeout(() => {
|
|
1238
|
+
if (settled || childExited) return;
|
|
1239
|
+
try {
|
|
1240
|
+
hardKilled = true;
|
|
1241
|
+
input.onLifecycleEvent?.({
|
|
1242
|
+
type: "hard_kill",
|
|
1243
|
+
pid: child.pid,
|
|
1244
|
+
ts: new Date().toISOString(),
|
|
1245
|
+
});
|
|
1246
|
+
child.kill(process.platform === "win32" ? undefined : "SIGKILL");
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
logInternalError("child-pi.quiet-drain-hard-kill", error, `pid=${child.pid}`);
|
|
1249
|
+
}
|
|
1250
|
+
}, hardKillMs);
|
|
1251
|
+
hardKillTimer.unref();
|
|
1252
|
+
// Cancel the 5 s ceiling so we don't double-fire.
|
|
1253
|
+
if (finalDrainTimer) {
|
|
1254
|
+
clearTimeout(finalDrainTimer);
|
|
1255
|
+
finalDrainTimer = undefined;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}, 200);
|
|
1259
|
+
pollHandle.unref();
|
|
1260
|
+
}
|
|
1148
1261
|
finalDrainTimer = setTimeout(() => {
|
|
1149
1262
|
if (settled || childExited) return;
|
|
1150
1263
|
forcedFinalDrain = true;
|
|
@@ -338,7 +338,9 @@ export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentReco
|
|
|
338
338
|
|
|
339
339
|
export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
340
340
|
ensureAgentStateDir(manifest, record.taskId);
|
|
341
|
-
|
|
341
|
+
// F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
|
|
342
|
+
// durability so the notifier/dashboard health sees the final state immediately.
|
|
343
|
+
atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
|
|
342
344
|
}
|
|
343
345
|
|
|
344
346
|
// 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
|
|
@@ -350,14 +352,19 @@ const AGENT_COALESCE_MS = 250;
|
|
|
350
352
|
export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
|
|
351
353
|
const filePath = agentsPath(manifest);
|
|
352
354
|
fs.mkdirSync(manifest.stateRoot, { recursive: true });
|
|
353
|
-
|
|
355
|
+
// F4: progress write — best-effort is safe because the terminal
|
|
356
|
+
// writeCrewAgentStatus above remains durable and the notifier watches the
|
|
357
|
+
// events.jsonl independently of these JSON files.
|
|
358
|
+
atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
|
|
354
359
|
asyncAgentReaderCache.delete(filePath);
|
|
355
360
|
for (const record of records) writeCrewAgentStatusCoalesced(manifest, record);
|
|
356
361
|
}
|
|
357
362
|
|
|
358
363
|
export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
359
364
|
ensureAgentStateDir(manifest, record.taskId);
|
|
360
|
-
atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS
|
|
365
|
+
atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS, {
|
|
366
|
+
durability: "best-effort",
|
|
367
|
+
});
|
|
361
368
|
}
|
|
362
369
|
|
|
363
370
|
/** @internal Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
|
package/src/runtime/pi-args.ts
CHANGED
|
@@ -360,6 +360,8 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
|
|
|
360
360
|
PI_TEAMS_DEPTH: String(parentDepth + 1),
|
|
361
361
|
PI_TEAMS_MAX_DEPTH: String(maxDepth),
|
|
362
362
|
PI_TEAMS_ROLE: input.agent.name,
|
|
363
|
+
// maxTokens cap for background workers — prompt-runtime reads this to cap API output
|
|
364
|
+
...(input.agent.maxTokens ? { PI_CREW_MAX_OUTPUT: String(input.agent.maxTokens) } : {}),
|
|
363
365
|
},
|
|
364
366
|
tempDir,
|
|
365
367
|
};
|
|
@@ -33,7 +33,10 @@ function asError(error: unknown): Error {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function globToRegex(pattern: string): RegExp {
|
|
36
|
-
const escaped = pattern
|
|
36
|
+
const escaped = pattern
|
|
37
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
38
|
+
.replace(/\*/g, ".*")
|
|
39
|
+
.replace(/\?/g, ".");
|
|
37
40
|
return new RegExp(`^${escaped}$`, "i");
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -68,12 +68,19 @@ export function persistSingleTaskUpdate(
|
|
|
68
68
|
const latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
|
|
69
69
|
merged = updateTask(latest, taskWithCheckpoint);
|
|
70
70
|
|
|
71
|
-
//
|
|
71
|
+
// F2: collapsed from 3 redundant statSync calls into 1. The previous
|
|
72
|
+
// implementation re-checked mtime twice more after load and before
|
|
73
|
+
// write, but since the code is synchronous and `loadRunManifestById`
|
|
74
|
+
// holds no I/O-yield between the load and this stat, those re-checks
|
|
75
|
+
// always returned the same mtime and added nothing. The one CAS below
|
|
76
|
+
// remains necessary for best-effort writers (async-notifier,
|
|
77
|
+
// crash-recovery) that don't acquire the run lock.
|
|
72
78
|
let currentMtime: number;
|
|
73
79
|
try {
|
|
74
80
|
currentMtime = fs.statSync(manifest.tasksPath).mtimeMs;
|
|
75
81
|
} catch {
|
|
76
|
-
|
|
82
|
+
// Run state deleted (prune/forget) — nothing to persist.
|
|
83
|
+
return fallbackTasks;
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
if (currentMtime !== baseMtime) {
|
|
@@ -82,34 +89,6 @@ export function persistSingleTaskUpdate(
|
|
|
82
89
|
continue;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
// No concurrent writer — check that our merged result is based on the
|
|
86
|
-
// same base we observed (no intermediate writer between our load and check)
|
|
87
|
-
let recheckMtime: number;
|
|
88
|
-
try {
|
|
89
|
-
recheckMtime = fs.statSync(manifest.tasksPath).mtimeMs;
|
|
90
|
-
} catch {
|
|
91
|
-
// Run state deleted (prune/forget) — nothing to persist.
|
|
92
|
-
return fallbackTasks;
|
|
93
|
-
}
|
|
94
|
-
if (recheckMtime !== baseMtime) {
|
|
95
|
-
baseMtime = recheckMtime;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Final pre-write mtime check to catch any concurrent writer that completed
|
|
100
|
-
// between the recheck and saveRunTasks
|
|
101
|
-
let preWriteMtime: number;
|
|
102
|
-
try {
|
|
103
|
-
preWriteMtime = fs.statSync(manifest.tasksPath).mtimeMs;
|
|
104
|
-
} catch {
|
|
105
|
-
preWriteMtime = 0;
|
|
106
|
-
}
|
|
107
|
-
if (preWriteMtime !== baseMtime) {
|
|
108
|
-
// Another writer committed — retry
|
|
109
|
-
baseMtime = preWriteMtime;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
92
|
break;
|
|
114
93
|
}
|
|
115
94
|
|
|
@@ -449,6 +449,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
449
449
|
runId: manifest.runId,
|
|
450
450
|
agentId: task.id,
|
|
451
451
|
artifactsRoot: manifest.artifactsRoot,
|
|
452
|
+
steeringFile: `${manifest.artifactsRoot}/steering/${task.id}.jsonl`,
|
|
452
453
|
onSpawn: (pid) => {
|
|
453
454
|
try {
|
|
454
455
|
({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
|
|
@@ -171,14 +171,16 @@ export function checkPerTaskBudget(
|
|
|
171
171
|
const totalUsed = (usage?.input ?? 0) + (usage?.output ?? 0) + (usage?.cacheWrite ?? 0);
|
|
172
172
|
const abort = totalUsed >= budgetAbort * budgetTotal;
|
|
173
173
|
const warning = !abort && totalUsed >= budgetWarning * budgetTotal;
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
// Fair share threshold based on TOTAL budget, not remaining budget.
|
|
175
|
+
// This ensures a task that consumed 60% of total budget is flagged even
|
|
176
|
+
// if only 40% remains (40% * 50% = 20% threshold would miss the 60% usage).
|
|
177
|
+
const fairShareThreshold = budgetTotal * fairShareFraction;
|
|
176
178
|
const fairShareViolators: string[] = [];
|
|
177
179
|
for (const task of tasks) {
|
|
178
180
|
if (!task.usage) continue;
|
|
179
181
|
const taskTotal = (task.usage.input ?? 0) + (task.usage.output ?? 0) + (task.usage.cacheWrite ?? 0);
|
|
180
182
|
// Only flag tasks that individually consumed a significant portion of the
|
|
181
|
-
// budget (>10% of total) AND exceeded the fair share
|
|
183
|
+
// budget (>10% of total) AND exceeded the fair share threshold.
|
|
182
184
|
if (fairShareThreshold > 0 && taskTotal > fairShareThreshold && taskTotal > budgetTotal * 0.1) {
|
|
183
185
|
fairShareViolators.push(task.id);
|
|
184
186
|
}
|