pi-crew 0.9.33 → 0.9.35
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 +211 -0
- package/dist/build-meta.json +257 -261
- package/dist/index.mjs +2127 -1937
- package/dist/index.mjs.map +4 -4
- package/docs/performance-optimization-execution-plan.md +520 -0
- package/package.json +1 -1
- package/src/extension/team-tool/api.ts +8 -8
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +2 -2
- package/src/extension/team-tool/handle-schedule.ts +102 -0
- package/src/extension/team-tool/run.ts +0 -42
- package/src/extension/team-tool.ts +13 -8
- package/src/observability/exporters/otlp-exporter.ts +24 -2
- package/src/runtime/adaptive-plan.ts +3 -3
- package/src/runtime/background-runner.ts +4 -4
- package/src/runtime/child-pi.ts +191 -56
- package/src/runtime/live-session-runtime.ts +39 -18
- package/src/runtime/parallel-utils.ts +5 -0
- package/src/runtime/skill-instructions.ts +61 -2
- package/src/runtime/stale-reconciler.ts +5 -0
- package/src/runtime/task-graph-scheduler.ts +48 -17
- package/src/runtime/task-runner/prompt-builder.ts +55 -1
- package/src/runtime/task-runner/state-helpers.ts +18 -1
- package/src/runtime/task-runner/tail-read.ts +12 -0
- package/src/runtime/task-runner.ts +40 -19
- package/src/runtime/team-runner.ts +204 -84
- package/src/schema/team-tool-schema.ts +4 -0
- package/src/state/artifact-store.ts +5 -0
- package/src/state/atomic-write.ts +69 -9
- package/src/state/state-store.ts +107 -30
- package/src/utils/token-counter.ts +168 -35
- package/workflows/pipeline.workflow.md +13 -12
package/src/runtime/child-pi.ts
CHANGED
|
@@ -435,11 +435,75 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
|
|
|
435
435
|
// We skip mkdirSync here for security — adding it would create parent
|
|
436
436
|
// directories during validation, contradicting the original design where
|
|
437
437
|
// resolveRealContainedPath validates a pre-existing path.
|
|
438
|
-
|
|
438
|
+
// Async optimization: use fire-and-forget async write to avoid blocking the event loop.
|
|
439
|
+
// The caller does not need to await this — transcript writes are best-effort telemetry.
|
|
440
|
+
// OPT-06 follow-up: we still track the write promise in a module-scoped Set so
|
|
441
|
+
// lifecycle boundaries (ChildPiLineObserver.flush, runChildPi settle) can drain
|
|
442
|
+
// them before returning. Without that drain, callers that immediately read the
|
|
443
|
+
// transcript file post-flush (e.g. integration tests at phase3-runtime:50 and
|
|
444
|
+
// phase4-runtime:37/:68/:103) would see ENOENT or empty content because the
|
|
445
|
+
// async file handle had not yet been opened / flushed.
|
|
446
|
+
trackTranscriptWrite(safePath, line);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
|
|
450
|
+
async function appendTranscriptAsync(safePath: string, line: string): Promise<void> {
|
|
451
|
+
const content = `${redactJsonLine(line)}\n`;
|
|
439
452
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
fs.
|
|
453
|
+
// Use async file handle for better performance when many writes occur.
|
|
454
|
+
// O_NOFOLLOW | O_CREAT | O_APPEND ensures security and atomicity.
|
|
455
|
+
const fd = await fs.promises.open(
|
|
456
|
+
safePath,
|
|
457
|
+
fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND,
|
|
458
|
+
0o600,
|
|
459
|
+
);
|
|
460
|
+
try {
|
|
461
|
+
await fd.write(content, undefined, "utf-8");
|
|
462
|
+
} finally {
|
|
463
|
+
await fd.close();
|
|
464
|
+
}
|
|
465
|
+
} catch (error) {
|
|
466
|
+
logInternalError("child-pi.transcript-write-failed", error as Error, `path=${safePath}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Module-scoped set of in-flight transcript-write promises. Each call to
|
|
472
|
+
* {@link trackTranscriptWrite} registers its promise here; the promise
|
|
473
|
+
* removes itself on completion via `.finally`. Lifecycle boundaries
|
|
474
|
+
* (ChildPiLineObserver.flush, runChildPi settle) call
|
|
475
|
+
* {@link flushPendingTranscriptWrites} before returning so callers that
|
|
476
|
+
* immediately read the transcript file after `runChildPi` / `observer.flush()`
|
|
477
|
+
* await see the full content (and a non-ENOENT file).
|
|
478
|
+
*
|
|
479
|
+
* Module-scoped rather than per-input because all transcript writes for a
|
|
480
|
+
* given run share the same Node fs event loop and serialise through a single
|
|
481
|
+
* fs handle per path; one drain per lifecycle boundary is sufficient.
|
|
482
|
+
*/
|
|
483
|
+
const pendingTranscriptWrites: Set<Promise<void>> = new Set();
|
|
484
|
+
|
|
485
|
+
function trackTranscriptWrite(safePath: string, line: string): void {
|
|
486
|
+
const p = appendTranscriptAsync(safePath, line).finally(() => {
|
|
487
|
+
pendingTranscriptWrites.delete(p);
|
|
488
|
+
});
|
|
489
|
+
pendingTranscriptWrites.add(p);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Drain all currently-pending transcript writes. Awaits every promise in the
|
|
494
|
+
* set; if new writes are scheduled during the drain (i.e. the `.finally`
|
|
495
|
+
* callback has not yet removed them), the loop re-checks `size > 0` and
|
|
496
|
+
* awaits them too. Idempotent and safe to call concurrently / reentrantly —
|
|
497
|
+
* each pending promise is tracked exactly once.
|
|
498
|
+
*
|
|
499
|
+
* Exported so external callers (e.g. integration tests that construct a
|
|
500
|
+
* ChildPiLineObserver directly) can drain explicitly if they need to read
|
|
501
|
+
* the transcript file outside of the observer's lifecycle.
|
|
502
|
+
*/
|
|
503
|
+
export async function flushPendingTranscriptWrites(): Promise<void> {
|
|
504
|
+
while (pendingTranscriptWrites.size > 0) {
|
|
505
|
+
const drained = [...pendingTranscriptWrites];
|
|
506
|
+
await Promise.allSettled(drained);
|
|
443
507
|
}
|
|
444
508
|
}
|
|
445
509
|
|
|
@@ -632,11 +696,18 @@ export class ChildPiLineObserver {
|
|
|
632
696
|
for (const line of lines) this.emitLine(line);
|
|
633
697
|
}
|
|
634
698
|
|
|
635
|
-
flush(): void {
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
699
|
+
flush(): Promise<void> {
|
|
700
|
+
if (this.buffer) {
|
|
701
|
+
const line = this.buffer;
|
|
702
|
+
this.buffer = "";
|
|
703
|
+
this.emitLine(line);
|
|
704
|
+
}
|
|
705
|
+
// OPT-06 follow-up: appendTranscript is fire-and-forget async, so the file
|
|
706
|
+
// may not exist on disk by the time this returns. Drain the module-scoped
|
|
707
|
+
// pending-write set before resolving so callers that immediately read the
|
|
708
|
+
// transcript file (e.g. integration tests at phase4-runtime:37/:68/:103
|
|
709
|
+
// after `await observer.flush()`) see the full content.
|
|
710
|
+
return flushPendingTranscriptWrites();
|
|
640
711
|
}
|
|
641
712
|
|
|
642
713
|
/** Last non-empty RAW assistant text (mirrors {@link parsePiJsonOutput}'s
|
|
@@ -715,11 +786,15 @@ export class ChildPiLineObserver {
|
|
|
715
786
|
}
|
|
716
787
|
}
|
|
717
788
|
|
|
718
|
-
/** Mock-only path — real code path reuses a single observer.
|
|
719
|
-
|
|
789
|
+
/** Mock-only path — real code path reuses a single observer.
|
|
790
|
+
* OPT-06 follow-up: returns a Promise so callers can await the transcript
|
|
791
|
+
* drain before resolving runChildPi. Without this, mock-mode callers that
|
|
792
|
+
* read the transcript file post-run see ENOENT (the async file handle had
|
|
793
|
+
* not yet been opened). */
|
|
794
|
+
async function observeStdoutChunk(input: ChildPiRunInput, text: string): Promise<void> {
|
|
720
795
|
const observer = new ChildPiLineObserver(input);
|
|
721
796
|
observer.observe(text);
|
|
722
|
-
observer.flush();
|
|
797
|
+
await observer.flush();
|
|
723
798
|
}
|
|
724
799
|
|
|
725
800
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -779,7 +854,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
779
854
|
logInternalError("child-pi.mock", new Error(`Mock mode active: ${mock}`), "NOT running real agents");
|
|
780
855
|
if (mock === "success") {
|
|
781
856
|
const stdout = `[MOCK] Success for ${input.agent.name}\n`;
|
|
782
|
-
observeStdoutChunk(input, stdout);
|
|
857
|
+
await observeStdoutChunk(input, stdout);
|
|
783
858
|
return { exitCode: 0, stdout, stderr: "" };
|
|
784
859
|
}
|
|
785
860
|
if (mock === "json-success" || mock === "adaptive-plan") {
|
|
@@ -834,7 +909,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
834
909
|
})}\nADAPTIVE_PLAN_JSON_END`
|
|
835
910
|
: `[MOCK] JSON success for ${input.agent.name}`;
|
|
836
911
|
const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
|
|
837
|
-
observeStdoutChunk(input, stdout);
|
|
912
|
+
await observeStdoutChunk(input, stdout);
|
|
838
913
|
return { exitCode: 0, stdout, stderr: "" };
|
|
839
914
|
}
|
|
840
915
|
if (mock === "retryable-failure")
|
|
@@ -882,14 +957,14 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
882
957
|
},
|
|
883
958
|
};
|
|
884
959
|
const stdout = `${JSON.stringify(failureEvent)}\n`;
|
|
885
|
-
observeStdoutChunk(input, stdout);
|
|
960
|
+
await observeStdoutChunk(input, stdout);
|
|
886
961
|
return { exitCode: 0, stdout, stderr: "" };
|
|
887
962
|
}
|
|
888
963
|
// Subsequent invocations: delegate to json-success shape so the
|
|
889
964
|
// fallback chain's second attempt succeeds and the run completes.
|
|
890
965
|
const text = `[MOCK] JSON success for ${input.agent.name}`;
|
|
891
966
|
const stdout = `${JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } })}\n${JSON.stringify({ type: "message_end", usage: { input: 10, output: 5, cost: 0.001, turns: 1 } })}\n`;
|
|
892
|
-
observeStdoutChunk(input, stdout);
|
|
967
|
+
await observeStdoutChunk(input, stdout);
|
|
893
968
|
return { exitCode: 0, stdout, stderr: "" };
|
|
894
969
|
}
|
|
895
970
|
return { exitCode: 1, stdout: "", stderr: `[MOCK] failure: ${mock}` };
|
|
@@ -1107,7 +1182,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1107
1182
|
process.kill(child.pid!, 0);
|
|
1108
1183
|
// Child still alive — force settle with timeout error.
|
|
1109
1184
|
const timeoutErr = `Child Pi produced no new output for ${responseTimeoutMs}ms; killed but did not exit within ${SAFETY_SETTLE_MS}ms (possible zombie).`;
|
|
1110
|
-
settle({
|
|
1185
|
+
void settle({
|
|
1111
1186
|
exitCode: null,
|
|
1112
1187
|
stdout,
|
|
1113
1188
|
stderr,
|
|
@@ -1320,46 +1395,106 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1320
1395
|
clearPostExitGuard();
|
|
1321
1396
|
};
|
|
1322
1397
|
|
|
1323
|
-
const settle = (result: ChildPiRunResult): void => {
|
|
1324
|
-
if (settled) return;
|
|
1398
|
+
const settle = (result: ChildPiRunResult): Promise<void> => {
|
|
1399
|
+
if (settled) return Promise.resolve();
|
|
1325
1400
|
settled = true;
|
|
1326
1401
|
clearChildPiTimeouts();
|
|
1327
|
-
lineObserver.flush()
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
//
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1402
|
+
// OPT-06 follow-up: lineObserver.flush() is now async (returns
|
|
1403
|
+
// Promise<void>) and drains the module-scoped pending-write set
|
|
1404
|
+
// before resolving. We must await it before calling `resolve()`
|
|
1405
|
+
// below so callers that read the transcript file post-`runChildPi`
|
|
1406
|
+
// see all written lines. Caller invocations of `settle` from
|
|
1407
|
+
// sync event handlers (`child.on('close'|'exit'|'error')`,
|
|
1408
|
+
// safety timer) use `void settle(...)` — errors are caught and
|
|
1409
|
+
// logged inside, and `resolve()` only fires after the drain
|
|
1410
|
+
// completes, so runChildPi's outer Promise resolves with a
|
|
1411
|
+
// durable transcript on disk.
|
|
1412
|
+
return lineObserver
|
|
1413
|
+
.flush()
|
|
1414
|
+
.then(() => {
|
|
1415
|
+
input.signal?.removeEventListener("abort", abort);
|
|
1416
|
+
input.signal?.removeEventListener("abort", onParentAbort);
|
|
1417
|
+
try {
|
|
1418
|
+
cleanupTempDir(built.tempDir);
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
cleanupErrors.push(error instanceof Error ? error.message : String(error));
|
|
1421
|
+
}
|
|
1422
|
+
// Catch all errors from settle to prevent unhandled rejection from propagating
|
|
1423
|
+
try {
|
|
1424
|
+
resolve({
|
|
1425
|
+
...result,
|
|
1426
|
+
rawFinalText: lineObserver.getRawFinalText(),
|
|
1427
|
+
intermediateFindings: lineObserver.getIntermediateFindings(),
|
|
1428
|
+
exitStatus: result.exitStatus ?? {
|
|
1429
|
+
exitCode: result.exitCode,
|
|
1430
|
+
cancelled: abortRequested,
|
|
1431
|
+
timedOut: responseTimeoutHit,
|
|
1432
|
+
killed: hardKilled,
|
|
1433
|
+
// Phase-0 diagnostic (HB-003a): surface the final-drain race state.
|
|
1434
|
+
// finalDrainArmed lets Phase 1 decide whether a signal-death (exitCode=null)
|
|
1435
|
+
// should be treated as a forced final drain. READ-ONLY for now.
|
|
1436
|
+
...(finalDrainArmed || forcedFinalDrain
|
|
1437
|
+
? {
|
|
1438
|
+
finalDrainArmed,
|
|
1439
|
+
forcedFinalDrain,
|
|
1440
|
+
finalDrainFiredMonotonicMs,
|
|
1441
|
+
}
|
|
1442
|
+
: {}),
|
|
1443
|
+
cleanupErrors,
|
|
1444
|
+
finalDrainMs,
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
} catch (resolveError) {
|
|
1448
|
+
logInternalError(
|
|
1449
|
+
"child-pi.settle-resolve",
|
|
1450
|
+
resolveError,
|
|
1451
|
+
`result=${JSON.stringify({ exitCode: result.exitCode })}`,
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
})
|
|
1455
|
+
.catch((flushError) => {
|
|
1456
|
+
// Drain failed — log and still resolve so runChildPi doesn't hang.
|
|
1457
|
+
logInternalError(
|
|
1458
|
+
"child-pi.settle-flush-failed",
|
|
1459
|
+
flushError,
|
|
1460
|
+
`result=${JSON.stringify({ exitCode: result.exitCode })}`,
|
|
1461
|
+
);
|
|
1462
|
+
input.signal?.removeEventListener("abort", abort);
|
|
1463
|
+
input.signal?.removeEventListener("abort", onParentAbort);
|
|
1464
|
+
try {
|
|
1465
|
+
cleanupTempDir(built.tempDir);
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
cleanupErrors.push(error instanceof Error ? error.message : String(error));
|
|
1468
|
+
}
|
|
1469
|
+
try {
|
|
1470
|
+
resolve({
|
|
1471
|
+
...result,
|
|
1472
|
+
rawFinalText: lineObserver.getRawFinalText(),
|
|
1473
|
+
intermediateFindings: lineObserver.getIntermediateFindings(),
|
|
1474
|
+
exitStatus: result.exitStatus ?? {
|
|
1475
|
+
exitCode: result.exitCode,
|
|
1476
|
+
cancelled: abortRequested,
|
|
1477
|
+
timedOut: responseTimeoutHit,
|
|
1478
|
+
killed: hardKilled,
|
|
1479
|
+
...(finalDrainArmed || forcedFinalDrain
|
|
1480
|
+
? {
|
|
1481
|
+
finalDrainArmed,
|
|
1482
|
+
forcedFinalDrain,
|
|
1483
|
+
finalDrainFiredMonotonicMs,
|
|
1484
|
+
}
|
|
1485
|
+
: {}),
|
|
1486
|
+
cleanupErrors,
|
|
1487
|
+
finalDrainMs,
|
|
1488
|
+
},
|
|
1489
|
+
});
|
|
1490
|
+
} catch (resolveError) {
|
|
1491
|
+
logInternalError(
|
|
1492
|
+
"child-pi.settle-resolve",
|
|
1493
|
+
resolveError,
|
|
1494
|
+
`result=${JSON.stringify({ exitCode: result.exitCode })}`,
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1359
1497
|
});
|
|
1360
|
-
} catch (resolveError) {
|
|
1361
|
-
logInternalError("child-pi.settle-resolve", resolveError, `result=${JSON.stringify({ exitCode: result.exitCode })}`);
|
|
1362
|
-
}
|
|
1363
1498
|
};
|
|
1364
1499
|
|
|
1365
1500
|
const abort = (): void => {
|
|
@@ -1446,7 +1581,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1446
1581
|
} catch (err) {
|
|
1447
1582
|
logInternalError("child-pi.on-lifecycle-event", err, `event=error, pid=${child.pid}`);
|
|
1448
1583
|
}
|
|
1449
|
-
settle({
|
|
1584
|
+
void settle({
|
|
1450
1585
|
exitCode: null,
|
|
1451
1586
|
stdout,
|
|
1452
1587
|
stderr,
|
|
@@ -1585,7 +1720,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
1585
1720
|
spawnError: undefined,
|
|
1586
1721
|
stderrSnippet: stderr ? redactStderrExcerpt(stderr, 1000) : undefined,
|
|
1587
1722
|
});
|
|
1588
|
-
settle({
|
|
1723
|
+
void settle({
|
|
1589
1724
|
exitCode: finalExitCode,
|
|
1590
1725
|
stdout,
|
|
1591
1726
|
stderr,
|
|
@@ -5,7 +5,7 @@ import { resolveToolPolicy } from "../agents/agent-config.ts";
|
|
|
5
5
|
import type { CrewRuntimeConfig } from "../config/config.ts";
|
|
6
6
|
import { loadConfig } from "../config/config.ts";
|
|
7
7
|
import { DEFAULT_LIVE_SESSION } from "../config/defaults.ts";
|
|
8
|
-
import { appendEvent } from "../state/event-log.ts";
|
|
8
|
+
import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
|
|
9
9
|
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
|
10
10
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
11
11
|
import { redactSecrets } from "../utils/redaction.ts";
|
|
@@ -151,8 +151,20 @@ type LiveSessionLike = {
|
|
|
151
151
|
|
|
152
152
|
function appendTranscript(filePath: string | undefined, event: unknown): void {
|
|
153
153
|
if (!filePath) return;
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
// Fire-and-forget async write to avoid blocking the event loop.
|
|
155
|
+
// Transcript writes are best-effort telemetry — callers do not need to await.
|
|
156
|
+
void appendTranscriptAsync(filePath, event);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
|
|
160
|
+
async function appendTranscriptAsync(filePath: string, event: unknown): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
163
|
+
const content = `${JSON.stringify(redactSecrets(event))}\n`;
|
|
164
|
+
await fs.promises.appendFile(filePath, content, "utf-8");
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logInternalError("live-session.transcript-write-failed", error as Error, `path=${filePath}`);
|
|
167
|
+
}
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -588,13 +600,20 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
588
600
|
jsonEvents: 0,
|
|
589
601
|
error: "createAgentSession export is unavailable.",
|
|
590
602
|
};
|
|
603
|
+
// OPT-03: compute yield-enabled flag early to conditionally allocate
|
|
604
|
+
// collectedJsonEvents — avoids ~1KB array allocation per task when yield
|
|
605
|
+
// detection is disabled. Pattern mirrors task-runner.ts:165. Uses the same
|
|
606
|
+
// `enabled !== false` semantics as the late yield-detection block below so
|
|
607
|
+
// the array is always allocated when (and only when) it will be read.
|
|
608
|
+
const yieldConfig = input.runtimeConfig?.yield ?? { enabled: DEFAULT_YIELD_CONFIG.enabled };
|
|
609
|
+
const yieldEnabled = yieldConfig.enabled !== false;
|
|
591
610
|
let session: LiveSessionLike | undefined;
|
|
592
611
|
let unsubscribe: (() => void) | undefined;
|
|
593
612
|
let unsubscribeControlRealtime: (() => void) | undefined;
|
|
594
613
|
let controlTimer: ReturnType<typeof setInterval> | undefined;
|
|
595
614
|
let stdout = "";
|
|
596
615
|
let jsonEvents = 0;
|
|
597
|
-
const collectedJsonEvents: Record<string, unknown>[] = [];
|
|
616
|
+
const collectedJsonEvents: Record<string, unknown>[] | undefined = yieldEnabled ? [] : undefined;
|
|
598
617
|
const maxCollectedJsonEvents = 1000;
|
|
599
618
|
let yieldResult: YieldResult | undefined;
|
|
600
619
|
|
|
@@ -676,7 +695,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
676
695
|
customTools,
|
|
677
696
|
});
|
|
678
697
|
session = created.session;
|
|
679
|
-
|
|
698
|
+
// P7 (perf): fire-and-forget — return value not needed, blocks the
|
|
699
|
+
// event loop less than the sync appendEvent under file-lock contention.
|
|
700
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
680
701
|
type: "live-session.session_created",
|
|
681
702
|
runId: input.manifest.runId,
|
|
682
703
|
taskId: input.task.id,
|
|
@@ -696,7 +717,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
696
717
|
]);
|
|
697
718
|
} catch (bindError) {
|
|
698
719
|
const msg = bindError instanceof Error ? bindError.message : String(bindError);
|
|
699
|
-
|
|
720
|
+
// P7: fire-and-forget — return value not needed.
|
|
721
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
700
722
|
type: "live-session.bind_extensions_error",
|
|
701
723
|
runId: input.manifest.runId,
|
|
702
724
|
taskId: input.task.id,
|
|
@@ -851,7 +873,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
851
873
|
trackLiveAgentToolEnd(agentId, toolName);
|
|
852
874
|
}
|
|
853
875
|
// Phase 1: collect events for yield detection
|
|
854
|
-
if (event && typeof event === "object" && !Array.isArray(event)) {
|
|
876
|
+
if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event)) {
|
|
855
877
|
collectedJsonEvents.push(event as Record<string, unknown>);
|
|
856
878
|
if (collectedJsonEvents.length > maxCollectedJsonEvents)
|
|
857
879
|
collectedJsonEvents.splice(0, collectedJsonEvents.length - maxCollectedJsonEvents);
|
|
@@ -879,7 +901,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
879
901
|
|
|
880
902
|
// Diagnostic: log prompt size and timing
|
|
881
903
|
const promptStart = Date.now();
|
|
882
|
-
|
|
904
|
+
// P7: fire-and-forget — return value not needed.
|
|
905
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
883
906
|
type: "live-session.prompt_start",
|
|
884
907
|
runId: input.manifest.runId,
|
|
885
908
|
taskId: input.task.id,
|
|
@@ -896,7 +919,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
896
919
|
await promptWithTimeout(session, effectivePrompt, sessionTimeoutMs, "Live-session");
|
|
897
920
|
} catch (promptError) {
|
|
898
921
|
const msg = promptError instanceof Error ? promptError.message : String(promptError);
|
|
899
|
-
|
|
922
|
+
// P7: fire-and-forget — return value not needed.
|
|
923
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
900
924
|
type: "live-session.prompt_error",
|
|
901
925
|
runId: input.manifest.runId,
|
|
902
926
|
taskId: input.task.id,
|
|
@@ -916,7 +940,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
916
940
|
}
|
|
917
941
|
throw promptError;
|
|
918
942
|
}
|
|
919
|
-
|
|
943
|
+
// P7: fire-and-forget — return value not needed.
|
|
944
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
920
945
|
type: "live-session.prompt_done",
|
|
921
946
|
runId: input.manifest.runId,
|
|
922
947
|
taskId: input.task.id,
|
|
@@ -930,16 +955,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
930
955
|
// --- Phase 1: Yield enforcement loop ---
|
|
931
956
|
// After the initial prompt completes, check if the worker called submit_result.
|
|
932
957
|
// Priority: 1) custom tool callback (G1), 2) JSON event detection (legacy).
|
|
933
|
-
const yieldConfig = input.runtimeConfig?.yield ?? {
|
|
934
|
-
enabled: DEFAULT_YIELD_CONFIG.enabled,
|
|
935
|
-
};
|
|
936
|
-
const yieldEnabled = yieldConfig.enabled !== false;
|
|
937
958
|
if (yieldEnabled && session) {
|
|
938
959
|
// Check custom tool callback first (G1)
|
|
939
960
|
if (customToolYieldResolved && customToolYieldResult) {
|
|
940
961
|
yieldResult = customToolYieldResult;
|
|
941
|
-
} else {
|
|
942
|
-
// Legacy: detect from JSON events
|
|
962
|
+
} else if (collectedJsonEvents) {
|
|
963
|
+
// Legacy: detect from JSON events (only when collection is enabled)
|
|
943
964
|
const alreadyYielded = hasYieldInOutput(collectedJsonEvents);
|
|
944
965
|
if (alreadyYielded) {
|
|
945
966
|
const yieldEvent = collectedJsonEvents.find((e) => isYieldEvent(e));
|
|
@@ -970,7 +991,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
970
991
|
// Check again after schema reminder
|
|
971
992
|
if (customToolYieldResolved && customToolYieldResult) {
|
|
972
993
|
yieldResult = customToolYieldResult;
|
|
973
|
-
} else {
|
|
994
|
+
} else if (collectedJsonEvents) {
|
|
974
995
|
const newEvents = collectedJsonEvents.slice(-10);
|
|
975
996
|
if (hasYieldInOutput(newEvents)) {
|
|
976
997
|
const yieldEvent = newEvents.find((e) => isYieldEvent(e));
|
|
@@ -1021,7 +1042,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1021
1042
|
break;
|
|
1022
1043
|
}
|
|
1023
1044
|
// Legacy: check JSON events
|
|
1024
|
-
if (hasYieldInOutput(collectedJsonEvents.slice(-10))) {
|
|
1045
|
+
if (collectedJsonEvents && hasYieldInOutput(collectedJsonEvents.slice(-10))) {
|
|
1025
1046
|
const yieldEvent = collectedJsonEvents.slice(-10).find((e) => isYieldEvent(e));
|
|
1026
1047
|
if (yieldEvent) yieldResult = extractYieldResult(yieldEvent);
|
|
1027
1048
|
break;
|
|
@@ -43,6 +43,11 @@ export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export async function mapConcurrent<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
|
|
46
|
+
// P12 (perf): single-item fast path. Skip the Promise.all + worker-pool
|
|
47
|
+
// setup when there's exactly one item — just call fn directly. Same return
|
|
48
|
+
// shape, no concurrency overhead, no micro-task allocation.
|
|
49
|
+
if (items.length === 1) return [await fn(items[0], 0)];
|
|
50
|
+
if (items.length === 0) return [];
|
|
46
51
|
const safeLimit = Math.max(1, Math.floor(limit) || 1);
|
|
47
52
|
const results: R[] = new Array(items.length);
|
|
48
53
|
let next = 0;
|
|
@@ -20,8 +20,35 @@ const MAX_SKILL_CHARS = 1500;
|
|
|
20
20
|
const MAX_TOTAL_CHARS = 6000;
|
|
21
21
|
const MAX_SKILL_NAME_CHARS = 80;
|
|
22
22
|
const MAX_SELECTED_SKILLS = 32;
|
|
23
|
+
|
|
24
|
+
// ── Cache sizing rationale (OPT-05 analysis, 2026-07-13) ───────────────────
|
|
25
|
+
// The package ships 30 SKILL.md files; 11 are referenced by DEFAULT_ROLE_SKILLS.
|
|
26
|
+
// Cache key = `${cwd}:${skillName}`, so a single-cwd run needs ≤30 entries.
|
|
27
|
+
// Multi-cwd scenarios (worktree isolation) multiply this, but rarely exceed 90.
|
|
28
|
+
// 128 provides 4× headroom over the skill count, ~1.4× over a 3-cwd scenario.
|
|
29
|
+
// Memory per entry ≈ 5KB (raw + compacted body), so 128 entries ≈ 640KB — negligible.
|
|
30
|
+
// Conclusion: 128 is well-tuned. Evictions are near-zero in practice.
|
|
23
31
|
const SKILL_CACHE_MAX_ENTRIES = 128;
|
|
24
32
|
|
|
33
|
+
export interface SkillCacheStats {
|
|
34
|
+
hits: number;
|
|
35
|
+
misses: number;
|
|
36
|
+
evictions: number;
|
|
37
|
+
currentSize: number;
|
|
38
|
+
maxEntries: number;
|
|
39
|
+
/** Computed hit-rate: hits / (hits + misses), 0 when no lookups yet. */
|
|
40
|
+
hitRate: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const skillCacheStats: SkillCacheStats = {
|
|
44
|
+
hits: 0,
|
|
45
|
+
misses: 0,
|
|
46
|
+
evictions: 0,
|
|
47
|
+
currentSize: 0,
|
|
48
|
+
maxEntries: SKILL_CACHE_MAX_ENTRIES,
|
|
49
|
+
hitRate: 0,
|
|
50
|
+
};
|
|
51
|
+
|
|
25
52
|
const DEFAULT_ROLE_SKILLS: Record<string, string[]> = {
|
|
26
53
|
explorer: ["read-only-explorer", "context-artifact-hygiene"],
|
|
27
54
|
analyst: ["read-only-explorer", "requirements-to-task-packet"],
|
|
@@ -146,16 +173,43 @@ const skillReadCache = new Map<string, CachedSkillMarkdown>();
|
|
|
146
173
|
function rememberSkill(key: string, value: CachedSkillMarkdown): CachedSkillMarkdown {
|
|
147
174
|
if (skillReadCache.has(key)) skillReadCache.delete(key);
|
|
148
175
|
skillReadCache.set(key, value);
|
|
149
|
-
while (skillReadCache.size >
|
|
176
|
+
while (skillReadCache.size > skillCacheStats.maxEntries) {
|
|
150
177
|
const oldest = skillReadCache.keys().next().value;
|
|
151
178
|
if (!oldest) break;
|
|
152
179
|
skillReadCache.delete(oldest);
|
|
180
|
+
skillCacheStats.evictions++;
|
|
153
181
|
}
|
|
182
|
+
skillCacheStats.currentSize = skillReadCache.size;
|
|
154
183
|
return value;
|
|
155
184
|
}
|
|
156
185
|
|
|
157
186
|
export function clearSkillInstructionCache(): void {
|
|
158
187
|
skillReadCache.clear();
|
|
188
|
+
skillCacheStats.currentSize = 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getSkillCacheStats(): SkillCacheStats {
|
|
192
|
+
const total = skillCacheStats.hits + skillCacheStats.misses;
|
|
193
|
+
return {
|
|
194
|
+
...skillCacheStats,
|
|
195
|
+
currentSize: skillReadCache.size,
|
|
196
|
+
hitRate: total > 0 ? skillCacheStats.hits / total : 0,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function resetSkillCacheStats(): void {
|
|
201
|
+
skillCacheStats.hits = 0;
|
|
202
|
+
skillCacheStats.misses = 0;
|
|
203
|
+
skillCacheStats.evictions = 0;
|
|
204
|
+
skillCacheStats.currentSize = skillReadCache.size;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Test-only: temporarily override the cache capacity to exercise eviction logic.
|
|
209
|
+
* Always restore to `SKILL_CACHE_MAX_ENTRIES` after the test.
|
|
210
|
+
*/
|
|
211
|
+
export function _setSkillCacheMaxEntriesForTesting(max: number): void {
|
|
212
|
+
skillCacheStats.maxEntries = max;
|
|
159
213
|
}
|
|
160
214
|
|
|
161
215
|
function cachedSkillFresh(value: CachedSkillMarkdown): boolean {
|
|
@@ -181,8 +235,13 @@ function readSkillMarkdown(
|
|
|
181
235
|
if (!isValidSkillName(name)) return undefined;
|
|
182
236
|
const cacheKey = `${path.resolve(cwd)}:${name}`;
|
|
183
237
|
const cached = skillReadCache.get(cacheKey);
|
|
184
|
-
if (cached && cachedSkillFresh(cached))
|
|
238
|
+
if (cached && cachedSkillFresh(cached)) {
|
|
239
|
+
skillCacheStats.hits++;
|
|
240
|
+
return cached;
|
|
241
|
+
}
|
|
185
242
|
if (cached) skillReadCache.delete(cacheKey);
|
|
243
|
+
skillCacheStats.misses++;
|
|
244
|
+
skillCacheStats.currentSize = skillReadCache.size;
|
|
186
245
|
for (const entry of candidateSkillDirs(cwd)) {
|
|
187
246
|
try {
|
|
188
247
|
const relative = path.join(name, "SKILL.md");
|
|
@@ -74,6 +74,9 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
|
|
|
74
74
|
const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
|
|
75
75
|
manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
|
|
76
76
|
// Persist manifest status change immediately to make checkResultFile self-contained.
|
|
77
|
+
// NOTE (OPT-02): Remains sync — reconcileStaleRun → checkResultFile is called from
|
|
78
|
+
// withRunLockSync in crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces.
|
|
79
|
+
// Converting to async would require cascading through all callers including sync UI hooks.
|
|
77
80
|
saveRunManifest(manifest);
|
|
78
81
|
// Sync agent records even when tasks are already terminal
|
|
79
82
|
// (e.g., a previous reconcile fixed tasks but crashed before updating agents)
|
|
@@ -329,6 +332,8 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
|
|
|
329
332
|
// checkResultFile sets manifest.status='completed' and saves it,
|
|
330
333
|
// but we re-save to ensure the completed status is persisted
|
|
331
334
|
// before returning (avoids TOCTOU where caller might re-read stale data)
|
|
335
|
+
// NOTE (OPT-02): Remains sync — reconcileStaleRun is called from withRunLockSync in
|
|
336
|
+
// crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces; cannot await here.
|
|
332
337
|
saveRunManifest(manifest);
|
|
333
338
|
return {
|
|
334
339
|
runId,
|