cool-workflow 0.1.96 → 0.1.98
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/candidate-scoring.js +3 -3
- package/dist/capability-core.js +9 -1
- package/dist/capability-registry.js +7 -1
- package/dist/cli/command-surface.js +4 -0
- package/dist/cli/handlers/ledger.js +169 -0
- package/dist/cli/handlers/scheduling.js +7 -1
- package/dist/drive.js +108 -61
- package/dist/execution-backend/agent.js +84 -24
- package/dist/execution-backend.js +25 -5
- package/dist/ledger.js +313 -0
- package/dist/mcp/tool-call.js +36 -0
- package/dist/mcp/tool-definitions.js +26 -0
- package/dist/mcp-server.js +4 -0
- package/dist/onramp.js +2 -0
- package/dist/orchestrator/app-operations.js +6 -0
- package/dist/orchestrator/cli-options.js +8 -2
- package/dist/orchestrator/lifecycle-operations.js +40 -13
- package/dist/orchestrator/migration-operations.js +1 -1
- package/dist/orchestrator.js +11 -3
- package/dist/remote-source.js +10 -3
- package/dist/run-export.js +45 -5
- package/dist/sandbox-profile.js +6 -1
- package/dist/triggers.js +7 -1
- package/dist/version.js +1 -1
- package/dist/workbench-host.js +18 -2
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/cli-mcp-parity.7.md +16 -2
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/cross-agent-ledger.7.md +217 -0
- package/docs/demo.7.md +80 -0
- package/docs/designs/handoff-ledger.md +145 -0
- package/docs/doctor.7.md +97 -0
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/fix.7.md +44 -0
- package/docs/handoff-setup.md +120 -0
- package/docs/init.7.md +62 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/pipeline-verbs.7.md +93 -0
- package/docs/project-index.md +28 -5
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +4 -0
- package/docs/routine.7.md +73 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +4 -0
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +4 -0
- package/manifest/README.md +16 -10
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +1 -1
- package/scripts/agents/agent-adapter-core.js +4 -1
- package/scripts/agents/codex-agent.js +34 -4
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +40 -13
- package/scripts/children/http-delegate-child.js +2 -1
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/release-flow.js +31 -17
package/dist/drive.js
CHANGED
|
@@ -43,6 +43,8 @@ const observability_1 = require("./observability");
|
|
|
43
43
|
const loop_expansion_1 = require("./loop-expansion");
|
|
44
44
|
const telemetry_attestation_1 = require("./telemetry-attestation");
|
|
45
45
|
const state_1 = require("./state");
|
|
46
|
+
const commit_1 = require("./commit");
|
|
47
|
+
const report_1 = require("./orchestrator/report");
|
|
46
48
|
const trust_audit_1 = require("./trust-audit");
|
|
47
49
|
const compare_1 = require("./compare");
|
|
48
50
|
exports.DRIVE_SCHEMA_VERSION = 1;
|
|
@@ -183,7 +185,7 @@ function buildAgentRequest(ctx, run, task, manifest, preparedOutcome) {
|
|
|
183
185
|
* fulfill step then settles it through runBackend instead of spawning again,
|
|
184
186
|
* so the concurrent round and the serial step share EVERY envelope/accept
|
|
185
187
|
* branch by construction. */
|
|
186
|
-
function processSelectedTask(ctx, selected, preparedOutcome) {
|
|
188
|
+
function processSelectedTask(ctx, selected, preparedOutcome, deferPersist = false) {
|
|
187
189
|
const { runner, runId } = ctx;
|
|
188
190
|
let run = runner.loadRun(runId);
|
|
189
191
|
// 1. DISPATCH (only a fresh pending task; a running task is a retry on its scope).
|
|
@@ -191,7 +193,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
|
|
|
191
193
|
let workerId = selected.workerId;
|
|
192
194
|
let dispatched = false;
|
|
193
195
|
if (selected.status === "pending") {
|
|
194
|
-
const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent" });
|
|
196
|
+
const manifest = runner.dispatch(runId, { limit: 1, backend: selected.agentType || "agent", ...(deferPersist ? { persistState: false } : {}) });
|
|
195
197
|
const task = manifest.tasks.find((entry) => entry.id === selected.id) || manifest.tasks[0];
|
|
196
198
|
if (!task || !task.workerId) {
|
|
197
199
|
return step("dispatch", "failed", { runId, taskId: selected.id, phase: selected.phase, reason: "dispatch produced no worker scope" });
|
|
@@ -216,10 +218,10 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
|
|
|
216
218
|
emitProgress(`↺ ${selected.label || selected.id} (${selected.phase}) — accepting cached result`);
|
|
217
219
|
try {
|
|
218
220
|
node_fs_1.default.writeFileSync(manifest.resultPath, node_fs_1.default.readFileSync(cachePath, "utf8"), "utf8");
|
|
219
|
-
runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {});
|
|
221
|
+
runner.recordWorkerOutput(runId, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
|
|
220
222
|
}
|
|
221
223
|
catch (error) {
|
|
222
|
-
return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}
|
|
224
|
+
return handleHop(ctx, selected, workerId, `result cache rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
|
|
223
225
|
}
|
|
224
226
|
return step("accept", "ok", {
|
|
225
227
|
runId,
|
|
@@ -233,7 +235,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
|
|
|
233
235
|
// CHILD run and bind its report back as this task's result. Leaf work is still
|
|
234
236
|
// external-agent delegation at every level; CW imports no model SDK here.
|
|
235
237
|
if (selected.subWorkflow) {
|
|
236
|
-
return runSubWorkflow(ctx, run, selected, workerId, manifest);
|
|
238
|
+
return runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist);
|
|
237
239
|
}
|
|
238
240
|
emitProgress(`→ ${selected.label || selected.id} (${selected.phase}) — ${dispatched ? "dispatched, " : ""}spawning agent, may take minutes…`);
|
|
239
241
|
const envelope = (0, execution_backend_1.runBackend)(buildAgentRequest(ctx, run, selected, manifest, preparedOutcome));
|
|
@@ -242,16 +244,17 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
|
|
|
242
244
|
const reportedUsage = handle?.metadata?.reportedUsage;
|
|
243
245
|
const usageSignature = handle?.metadata?.usageSignature;
|
|
244
246
|
if (envelope.status !== "completed") {
|
|
245
|
-
return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}
|
|
247
|
+
return handleHop(ctx, selected, workerId, `agent hop ${envelope.status}: ${envelope.result.summary}`, deferPersist);
|
|
246
248
|
}
|
|
247
249
|
// 3. ACCEPT — the SEPARATE recordWorkerOutput layer validates + records result.md.
|
|
248
250
|
// A missing result.md is a failed hop (pre-checked so no terminal side effect);
|
|
249
251
|
// an invalid result.md throws at validation BEFORE any state mutation.
|
|
250
252
|
if (!manifest.resultPath || !node_fs_1.default.existsSync(manifest.resultPath)) {
|
|
251
|
-
return handleHop(ctx, selected, workerId, "agent produced no result.md");
|
|
253
|
+
return handleHop(ctx, selected, workerId, "agent produced no result.md", deferPersist);
|
|
252
254
|
}
|
|
253
255
|
try {
|
|
254
256
|
runner.recordWorkerOutput(runId, workerId, manifest.resultPath, {
|
|
257
|
+
...(deferPersist ? { persistState: false } : {}),
|
|
255
258
|
agentDelegation: {
|
|
256
259
|
handle: handle,
|
|
257
260
|
model: reportedModel,
|
|
@@ -270,7 +273,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
|
|
|
270
273
|
});
|
|
271
274
|
}
|
|
272
275
|
catch (error) {
|
|
273
|
-
return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}
|
|
276
|
+
return handleHop(ctx, selected, workerId, `result.md rejected: ${error instanceof Error ? error.message : String(error)}`, deferPersist);
|
|
274
277
|
}
|
|
275
278
|
if (cachePath && manifest.resultPath && node_fs_1.default.existsSync(manifest.resultPath)) {
|
|
276
279
|
writeResultCache(cachePath, node_fs_1.default.readFileSync(manifest.resultPath, "utf8"));
|
|
@@ -404,41 +407,61 @@ function writeResultCache(file, content) {
|
|
|
404
407
|
* collect-all: a failed/hung/dirty hop never aborts its siblings, every hop
|
|
405
408
|
* settles and is recorded (failures park via the same retryOrPark). */
|
|
406
409
|
function driveConcurrentRound(ctx, limit) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
410
|
+
// The whole round runs inside ONE cached in-memory run object (loadWithCache —
|
|
411
|
+
// reentrant, so a sub-workflow task's recursive drive() call cannot clobber this
|
|
412
|
+
// scope's cache). Every dispatch/accept in the round defers its own state.json
|
|
413
|
+
// write (persistState:false / deferPersist) and mutates that SAME shared object;
|
|
414
|
+
// the round flushes to disk exactly ONCE at the end instead of once per task —
|
|
415
|
+
// was O(N) full-state durable rewrites per round (measured: dominates wall time
|
|
416
|
+
// at scale), now O(1). A crash mid-round loses that round's dispatch/accept
|
|
417
|
+
// bookkeeping (bounded by the round width, i.e. limits.maxConcurrentAgents) and
|
|
418
|
+
// forces a safe-but-wasteful re-dispatch/re-spawn on the next drive — never
|
|
419
|
+
// disk corruption or double-counting, since the atomic-rename+fsync write
|
|
420
|
+
// itself is untouched; only the write FREQUENCY changed.
|
|
421
|
+
return ctx.runner.loadWithCache(() => {
|
|
422
|
+
const run = ctx.runner.loadRun(ctx.runId);
|
|
423
|
+
const selected = selectDriveTask(run);
|
|
424
|
+
const gate = terminalOrConfigStep(ctx, run, selected);
|
|
425
|
+
if (gate)
|
|
426
|
+
return [gate];
|
|
427
|
+
const phase = (0, dispatch_1.firstRunnablePhase)(run);
|
|
428
|
+
const width = Math.max(1, Math.floor(limit) || 1);
|
|
429
|
+
const batch = run.tasks
|
|
430
|
+
.filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
|
|
431
|
+
.slice(0, width)
|
|
432
|
+
.map((task) => task.id);
|
|
433
|
+
// Phase A+B: dispatch every batch task (sequential — dispatch mutates state),
|
|
434
|
+
// then collect ALL spawn-style child outcomes in one concurrent window. The
|
|
435
|
+
// token-budget gate ran at round entry; it is NOT re-checked between accepts —
|
|
436
|
+
// the spawns already happened, and refusing to RECORD finished work would
|
|
437
|
+
// discard real results (collect-all + never-claw-back). Overshoot is bounded
|
|
438
|
+
// by the round width; the next round blocks.
|
|
439
|
+
const prepared = prepareConcurrentOutcomes(ctx, batch);
|
|
440
|
+
// Phase C: settle + accept in deterministic batch order, regardless of the
|
|
441
|
+
// wall-clock order the children finished in.
|
|
442
|
+
const steps = [];
|
|
443
|
+
for (const taskId of batch) {
|
|
444
|
+
const failStep = prepared.failSteps.get(taskId);
|
|
445
|
+
if (failStep) {
|
|
446
|
+
steps.push(failStep);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
// Re-read per task: a prior accept in this round mutated state (the SAME
|
|
450
|
+
// cached object — no disk round-trip until the round-end flush below).
|
|
451
|
+
const freshRun = ctx.runner.loadRun(ctx.runId);
|
|
452
|
+
const fresh = freshRun.tasks.find((task) => task.id === taskId);
|
|
453
|
+
if (!fresh || (fresh.status !== "pending" && fresh.status !== "running"))
|
|
454
|
+
continue;
|
|
455
|
+
steps.push(processSelectedTask(ctx, fresh, prepared.outcomes.get(taskId), true));
|
|
433
456
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
457
|
+
if (steps.length > 0) {
|
|
458
|
+
const settledRun = ctx.runner.loadRun(ctx.runId);
|
|
459
|
+
(0, commit_1.commitState)(settledRun, `concurrent-round:${batch.length}-tasks`);
|
|
460
|
+
(0, report_1.writeReport)(settledRun);
|
|
461
|
+
(0, state_1.saveCheckpoint)(settledRun);
|
|
462
|
+
}
|
|
463
|
+
return steps.length > 0 ? steps : [driveStep(ctx)];
|
|
464
|
+
});
|
|
442
465
|
}
|
|
443
466
|
/** Dispatch each batch task and run every spawn-style agent child concurrently
|
|
444
467
|
* (one batch delegate child, per-job timeout kill). Returns outcomes keyed by
|
|
@@ -457,7 +480,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
|
|
|
457
480
|
continue;
|
|
458
481
|
let workerId = task.workerId;
|
|
459
482
|
if (task.status === "pending") {
|
|
460
|
-
const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent" });
|
|
483
|
+
const manifest = runner.dispatch(runId, { limit: 1, backend: task.agentType || "agent", persistState: false });
|
|
461
484
|
const dispatchedTask = manifest.tasks.find((entry) => entry.id === task.id) || manifest.tasks[0];
|
|
462
485
|
if (!dispatchedTask || !dispatchedTask.workerId) {
|
|
463
486
|
failSteps.set(taskId, step("dispatch", "failed", { runId, taskId, phase: task.phase, reason: "dispatch produced no worker scope" }));
|
|
@@ -475,6 +498,16 @@ function prepareConcurrentOutcomes(ctx, batch) {
|
|
|
475
498
|
continue;
|
|
476
499
|
const job = (0, execution_backend_1.prepareAgentSpawn)(buildAgentRequest(ctx, run, task, manifest));
|
|
477
500
|
if (job) {
|
|
501
|
+
const sandboxPolicy = manifest.sandboxPolicy;
|
|
502
|
+
if (sandboxPolicy) {
|
|
503
|
+
const filteredEnv = (0, execution_backend_1.buildChildEnv)(sandboxPolicy);
|
|
504
|
+
for (const key of Object.keys(process.env)) {
|
|
505
|
+
if (/^(CW_|ANTHROPIC_|OPENAI_|GEMINI_|DEEPSEEK_|CODEX_|GOOGLE_|COHERE_|MISTRAL_|OLLAMA_|AZURE_|AWS_)/i.test(key)) {
|
|
506
|
+
filteredEnv[key] = process.env[key];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
job.env = filteredEnv;
|
|
510
|
+
}
|
|
478
511
|
jobs.push(job);
|
|
479
512
|
jobTaskIds.push(taskId);
|
|
480
513
|
}
|
|
@@ -489,7 +522,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
|
|
|
489
522
|
}
|
|
490
523
|
/** A failed agent hop: charge one attempt and (reuse v0.1.37 retryOrPark) either
|
|
491
524
|
* retry on the SAME worker scope next step, or PARK past the retry budget. */
|
|
492
|
-
function handleHop(ctx, task, workerId, reason) {
|
|
525
|
+
function handleHop(ctx, task, workerId, reason, deferPersist = false) {
|
|
493
526
|
const persisted = ctx.runner.showWorker(ctx.runId, workerId).retryCount || 0;
|
|
494
527
|
const prior = Math.max(ctx.attempts.get(task.id) || 0, persisted);
|
|
495
528
|
const entry = {
|
|
@@ -510,7 +543,8 @@ function handleHop(ctx, task, workerId, reason) {
|
|
|
510
543
|
ctx.runner.recordWorkerFailure(ctx.runId, workerId, decided.parkedReason || reason, {
|
|
511
544
|
code: "agent-delegation-parked",
|
|
512
545
|
retryable: false,
|
|
513
|
-
retryCount: attempts
|
|
546
|
+
retryCount: attempts,
|
|
547
|
+
...(deferPersist ? { persistState: false } : {})
|
|
514
548
|
});
|
|
515
549
|
return step("park", "parked", {
|
|
516
550
|
runId: ctx.runId,
|
|
@@ -522,7 +556,7 @@ function handleHop(ctx, task, workerId, reason) {
|
|
|
522
556
|
});
|
|
523
557
|
}
|
|
524
558
|
// Retryable: leave the task running (scope reused) for the next step.
|
|
525
|
-
(0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason);
|
|
559
|
+
(0, worker_isolation_1.recordWorkerRetryAttempt)(ctx.runner.loadRun(ctx.runId), workerId, decided.attempts || prior + 1, reason, deferPersist ? { persist: false } : {});
|
|
526
560
|
return step("fulfill", "failed", {
|
|
527
561
|
runId: ctx.runId,
|
|
528
562
|
taskId: task.id,
|
|
@@ -555,17 +589,17 @@ function renderSubInputs(spec, parentInputs) {
|
|
|
555
589
|
* own telemetry/audit live in the child run; the parent records ONE honest
|
|
556
590
|
* `worker.sub-workflow` cross-link (child run id + report digest + child audit
|
|
557
591
|
* verdict) — nothing is summed or fabricated. */
|
|
558
|
-
function runSubWorkflow(ctx, run, selected, workerId, manifest) {
|
|
592
|
+
function runSubWorkflow(ctx, run, selected, workerId, manifest, deferPersist = false) {
|
|
559
593
|
const spec = selected.subWorkflow;
|
|
560
594
|
const parentApp = run.workflow.id;
|
|
561
595
|
// Fail-closed BEFORE planning a child (no child state minted when refused).
|
|
562
596
|
if (ctx.depth + 1 > exports.MAX_SUB_WORKFLOW_DEPTH) {
|
|
563
|
-
return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})
|
|
597
|
+
return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`, deferPersist);
|
|
564
598
|
}
|
|
565
599
|
// Include the CURRENT app on the path, so a direct self-cycle (A→A) is caught at
|
|
566
600
|
// depth 0 — before any child run dir is minted.
|
|
567
601
|
if ([...ctx.visitedAppIds, parentApp].includes(spec.appId)) {
|
|
568
|
-
return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}
|
|
602
|
+
return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`, deferPersist);
|
|
569
603
|
}
|
|
570
604
|
// Deterministic child run id derived from the parent run + task (no clock/random).
|
|
571
605
|
const childRunId = `sub-${run.id}-${(0, state_1.safeFileName)(selected.id)}`;
|
|
@@ -582,7 +616,7 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
|
|
|
582
616
|
childRun = ctx.runner.plan(spec.appId, childInputs);
|
|
583
617
|
}
|
|
584
618
|
catch (error) {
|
|
585
|
-
return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}
|
|
619
|
+
return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`, deferPersist);
|
|
586
620
|
}
|
|
587
621
|
const childResult = drive(ctx.runner, childRun.id, {
|
|
588
622
|
now: ctx.now,
|
|
@@ -593,7 +627,7 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
|
|
|
593
627
|
visitedAppIds: [...ctx.visitedAppIds, parentApp]
|
|
594
628
|
});
|
|
595
629
|
if (childResult.status !== "complete") {
|
|
596
|
-
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})
|
|
630
|
+
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`, deferPersist);
|
|
597
631
|
}
|
|
598
632
|
// Bind the child's bytes: the rendered report (default) or the verdict result.
|
|
599
633
|
const finalChild = ctx.runner.loadRun(childRun.id);
|
|
@@ -606,15 +640,15 @@ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
|
|
|
606
640
|
childBytes = node_fs_1.default.existsSync(finalChild.paths.report) ? node_fs_1.default.readFileSync(finalChild.paths.report, "utf8") : undefined;
|
|
607
641
|
}
|
|
608
642
|
if (childBytes === undefined) {
|
|
609
|
-
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}
|
|
643
|
+
return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`, deferPersist);
|
|
610
644
|
}
|
|
611
645
|
// Accept through the SAME path as any other result (verifier/schema/evidence gate).
|
|
612
646
|
try {
|
|
613
647
|
node_fs_1.default.writeFileSync(manifest.resultPath, childBytes, "utf8");
|
|
614
|
-
ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, {});
|
|
648
|
+
ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, deferPersist ? { persistState: false } : {});
|
|
615
649
|
}
|
|
616
650
|
catch (error) {
|
|
617
|
-
return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}
|
|
651
|
+
return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`, deferPersist);
|
|
618
652
|
}
|
|
619
653
|
// Honest cross-link (provenance only — never fails the accepted hop): one
|
|
620
654
|
// worker.sub-workflow audit event on the parent pins the child run + report digest
|
|
@@ -720,6 +754,7 @@ function drive(runner, runId, options = {}) {
|
|
|
720
754
|
return; // only the first not-yet-complete phase is "active"
|
|
721
755
|
}
|
|
722
756
|
};
|
|
757
|
+
let exhaustedMaxIterations = !options.once;
|
|
723
758
|
for (let i = 0; i < maxIterations; i++) {
|
|
724
759
|
const width = concurrency > 1 ? concurrency : autoWidth(runner.loadRun(runId));
|
|
725
760
|
const roundSteps = width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
|
|
@@ -741,27 +776,39 @@ function drive(runner, runId, options = {}) {
|
|
|
741
776
|
// reuses the run we just advanced; goes to stderr via emitProgress so stdout is clean.
|
|
742
777
|
emitPhaseProgress(runner.loadRun(runId));
|
|
743
778
|
const last = roundSteps[roundSteps.length - 1];
|
|
744
|
-
if (options.once)
|
|
779
|
+
if (options.once) {
|
|
780
|
+
exhaustedMaxIterations = false;
|
|
745
781
|
break;
|
|
746
|
-
|
|
782
|
+
}
|
|
783
|
+
if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked")) {
|
|
784
|
+
exhaustedMaxIterations = false;
|
|
747
785
|
break;
|
|
786
|
+
}
|
|
748
787
|
}
|
|
749
788
|
const run = runner.loadRun(runId);
|
|
750
789
|
const completedWorkers = countCompleted(run);
|
|
751
790
|
const parkedWorkers = countParked(run);
|
|
752
791
|
const committed = (run.commits || []).find((commit) => commit.reason && commit.reason.startsWith("agent-delegation-drive"));
|
|
753
792
|
const last = steps[steps.length - 1];
|
|
793
|
+
if (exhaustedMaxIterations) {
|
|
794
|
+
steps.push(step("blocked", "blocked", {
|
|
795
|
+
runId,
|
|
796
|
+
reason: `drive reached max iteration limit (${maxIterations}) before a terminal state`
|
|
797
|
+
}));
|
|
798
|
+
}
|
|
754
799
|
const status = options.once
|
|
755
800
|
? completedWorkers === plannedWorkers && committed
|
|
756
801
|
? "complete"
|
|
757
802
|
: last && (last.status === "parked" || last.status === "blocked")
|
|
758
803
|
? last.status
|
|
759
804
|
: "in-progress"
|
|
760
|
-
:
|
|
761
|
-
? "
|
|
762
|
-
: last && last.status === "
|
|
763
|
-
? "
|
|
764
|
-
: "
|
|
805
|
+
: exhaustedMaxIterations
|
|
806
|
+
? "blocked"
|
|
807
|
+
: parkedWorkers > 0 || (last && last.status === "parked")
|
|
808
|
+
? "parked"
|
|
809
|
+
: last && last.status === "blocked"
|
|
810
|
+
? "blocked"
|
|
811
|
+
: "complete";
|
|
765
812
|
return {
|
|
766
813
|
schemaVersion: 1,
|
|
767
814
|
runId,
|
|
@@ -12,6 +12,7 @@ exports.recordedAgentHandle = recordedAgentHandle;
|
|
|
12
12
|
exports.extractEndpointResult = extractEndpointResult;
|
|
13
13
|
exports.agentHandle = agentHandle;
|
|
14
14
|
exports.prepareAgentSpawn = prepareAgentSpawn;
|
|
15
|
+
exports.reconcileBatchOutcomes = reconcileBatchOutcomes;
|
|
15
16
|
exports.runAgentBatchOutcomes = runAgentBatchOutcomes;
|
|
16
17
|
// Agent-delegation pure helpers + concurrent batch fulfillment for the
|
|
17
18
|
// execution-backend driver layer. Carved out of execution-backend.ts
|
|
@@ -259,36 +260,95 @@ function prepareAgentSpawn(request) {
|
|
|
259
260
|
// `node -e` string — F11). It reads jobs JSON on stdin, spawns ALL concurrently
|
|
260
261
|
// (shell:false, inherited env — the agent's own credentials resolve; CW never
|
|
261
262
|
// reads them), per-job SIGTERM at timeoutMs + SIGKILL at +5s, caps each captured
|
|
262
|
-
// stdout at 32MB, and
|
|
263
|
-
// is drained (a full pipe must never wedge a child). A kill yields
|
|
264
|
-
// the no-exit-code refusal. We spawn it BY PATH (shell:false);
|
|
265
|
-
// resolved from this compiled module (dist/execution-backend/agent.js)
|
|
266
|
-
// package's `scripts/children/` dir, which package.json ships in "files".
|
|
263
|
+
// stdout at 32MB, and streams ONE NDJSON line per job the instant it settles.
|
|
264
|
+
// stderr is drained (a full pipe must never wedge a child). A kill yields
|
|
265
|
+
// exitCode null — the no-exit-code refusal. We spawn it BY PATH (shell:false);
|
|
266
|
+
// the path is resolved from this compiled module (dist/execution-backend/agent.js)
|
|
267
|
+
// up to the package's `scripts/children/` dir, which package.json ships in "files".
|
|
267
268
|
const BATCH_DELEGATE_CHILD_SCRIPT = node_path_1.default.resolve(__dirname, "..", "..", "scripts", "children", "batch-delegate-child.js");
|
|
269
|
+
/** Parse the delegate child's NDJSON stdout and reconcile it against `jobs` by
|
|
270
|
+
* index. Runs even when `child.error` is set (ENOBUFS from the combined
|
|
271
|
+
* output exceeding maxBuffer, ETIMEDOUT from the parent backstop, or a
|
|
272
|
+
* nonzero/null exit) — a batch-level failure must fail-close ONLY the jobs
|
|
273
|
+
* whose line never fully arrived, never every job in the batch: a job whose
|
|
274
|
+
* line already streamed through keeps its REAL outcome.
|
|
275
|
+
*
|
|
276
|
+
* `stdout` is split on the raw newline BYTE, on a Buffer, before any UTF-8
|
|
277
|
+
* decoding — never on a decoded string. 0x0A never appears inside a UTF-8
|
|
278
|
+
* continuation byte, so this is a safe boundary; decoding is deferred to
|
|
279
|
+
* ONE LINE at a time (bounded by the delegate's own 32MB-per-job cap), so
|
|
280
|
+
* no single decode ever approaches V8's hard per-string character ceiling
|
|
281
|
+
* regardless of how large the COMBINED batch output is. Decoding the whole
|
|
282
|
+
* combined buffer as one string up front (the prior approach) could itself
|
|
283
|
+
* throw past that ceiling for a large-enough batch — an uncaught crash, not
|
|
284
|
+
* a graceful `child.error` — which this line-at-a-time approach avoids by
|
|
285
|
+
* construction. The trailing split segment is always dropped before
|
|
286
|
+
* parsing (empty from a clean trailing newline, or a line truncated
|
|
287
|
+
* mid-write by a hard kill — either way, never a complete line), and one
|
|
288
|
+
* corrupt line can never crash the reconciliation of its siblings. */
|
|
289
|
+
function reconcileBatchOutcomes(jobs, child) {
|
|
290
|
+
const buf = Buffer.isBuffer(child.stdout) ? child.stdout : Buffer.from(String(child.stdout || ""), "utf8");
|
|
291
|
+
const byIndex = new Map();
|
|
292
|
+
let lineStart = 0;
|
|
293
|
+
for (let i = 0; i < buf.length; i++) {
|
|
294
|
+
if (buf[i] !== 0x0a)
|
|
295
|
+
continue;
|
|
296
|
+
const lineBuf = buf.subarray(lineStart, i);
|
|
297
|
+
lineStart = i + 1;
|
|
298
|
+
if (lineBuf.length === 0)
|
|
299
|
+
continue;
|
|
300
|
+
let parsed;
|
|
301
|
+
try {
|
|
302
|
+
parsed = JSON.parse(lineBuf.toString("utf8"));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (typeof parsed.i !== "number" || parsed.i < 0 || parsed.i >= jobs.length)
|
|
308
|
+
continue;
|
|
309
|
+
byIndex.set(parsed.i, {
|
|
310
|
+
...(parsed.spawnError ? { spawnError: parsed.spawnError } : {}),
|
|
311
|
+
exitCode: typeof parsed.exitCode === "number" ? parsed.exitCode : null,
|
|
312
|
+
stdout: String(parsed.stdout || "")
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
const reason = child.error
|
|
316
|
+
? (0, util_1.messageOf)(child.error)
|
|
317
|
+
: typeof child.status === "number" && child.status !== 0
|
|
318
|
+
? `batch delegate exited with ${child.status}`
|
|
319
|
+
: "batch delegate produced no outcome for this job";
|
|
320
|
+
return jobs.map((_, index) => byIndex.get(index) || { spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" });
|
|
321
|
+
}
|
|
268
322
|
/** Run a batch of agent spawns concurrently; outcomes index-align with jobs. The
|
|
269
323
|
* parent backstop timeout (max job timeout + 30s) means even a wedged delegate
|
|
270
|
-
* child cannot deadlock the drive
|
|
271
|
-
*
|
|
324
|
+
* child cannot deadlock the drive. `maxBuffer` scales with batch size (the
|
|
325
|
+
* delegate's own per-job 32MB cap is the real safety bound — no separate outer
|
|
326
|
+
* ceiling here, since a flat ceiling that stops scaling with job count is
|
|
327
|
+
* exactly what let one verbose batch strand its siblings before this fix).
|
|
328
|
+
* Collect-all is a real guarantee even under buffer/timeout pressure: a job
|
|
329
|
+
* whose NDJSON line fully streamed through keeps its real outcome regardless
|
|
330
|
+
* of what happens to the rest of the batch. */
|
|
272
331
|
function runAgentBatchOutcomes(jobs) {
|
|
273
332
|
if (!jobs.length)
|
|
274
333
|
return [];
|
|
275
334
|
const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
335
|
+
// No `encoding` option: keep stdout as a raw Buffer so reconcileBatchOutcomes
|
|
336
|
+
// can split on the newline byte and decode one line at a time — decoding the
|
|
337
|
+
// WHOLE combined buffer as one string up front could itself throw past V8's
|
|
338
|
+
// per-string character ceiling for a large-enough batch (an uncaught crash,
|
|
339
|
+
// not a graceful child.error). The try/catch below is a second backstop for
|
|
340
|
+
// any other unexpected native failure at this boundary — a wedged or
|
|
341
|
+
// over-limit delegate must fail every job closed, never crash the drive.
|
|
342
|
+
let child;
|
|
343
|
+
try {
|
|
344
|
+
child = (0, node_child_process_1.spawnSync)(process.execPath, [BATCH_DELEGATE_CHILD_SCRIPT], {
|
|
345
|
+
input: JSON.stringify(jobs),
|
|
346
|
+
maxBuffer: 34 * 1024 * 1024 * jobs.length,
|
|
347
|
+
timeout: maxTimeout + 30000
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
child = { error: error instanceof Error ? error : new Error(String(error)), status: null, stdout: null };
|
|
291
352
|
}
|
|
292
|
-
|
|
293
|
-
return jobs.map(() => ({ spawnError: `batch delegate failed: ${reason}`, exitCode: null, stdout: "" }));
|
|
353
|
+
return reconcileBatchOutcomes(jobs, child);
|
|
294
354
|
}
|
|
@@ -39,6 +39,7 @@ exports.attestSandbox = attestSandbox;
|
|
|
39
39
|
exports.probeBackend = probeBackend;
|
|
40
40
|
exports.runBackend = runBackend;
|
|
41
41
|
exports.delegateChildScript = delegateChildScript;
|
|
42
|
+
exports.shouldStreamAgentStderr = shouldStreamAgentStderr;
|
|
42
43
|
exports.createExecutionBackend = createExecutionBackend;
|
|
43
44
|
exports.backendListPayload = backendListPayload;
|
|
44
45
|
exports.backendShowPayload = backendShowPayload;
|
|
@@ -410,7 +411,7 @@ function executeLocal(descriptor, policy, request, label, attestation) {
|
|
|
410
411
|
// Shell injection guard: reject args that contain shell control characters
|
|
411
412
|
// (beyond template placeholders). The command itself is operator-configured.
|
|
412
413
|
const shellArg = [command, ...args].join(" ").replace(/\{\{[a-zA-Z0-9_.-]+\}\}/g, "");
|
|
413
|
-
if (/[;&|`$(){}<>!\n\r]/.test(shellArg)) {
|
|
414
|
+
if (/[;&|`$(){}<>!\n\r#*?~]/.test(shellArg)) {
|
|
414
415
|
throw new Error(`Shell backend refused: args contain shell control characters. ` +
|
|
415
416
|
`Use the node, bun, or agent backend instead for untrusted inputs.`);
|
|
416
417
|
}
|
|
@@ -675,6 +676,25 @@ function runHttpDelegation(descriptor, policy, request, label, handle, attestati
|
|
|
675
676
|
// agentSubstitutions, substituteAgentArg, recordedAgentHandle, extractEndpointResult,
|
|
676
677
|
// agentHandle) and the concurrent batch live in ./execution-backend/agent.ts; the
|
|
677
678
|
// stateful runners below build the refusal/delegated envelopes and stay here.
|
|
679
|
+
/** Decide whether cw FORWARDS the agent wrapper's live stderr view (stdio
|
|
680
|
+
* "inherit") or captures it ("pipe"). Default follows isTTY — interactive shows
|
|
681
|
+
* the live view, a pipe/CI stays silent (Rule of Silence). The two env knobs are
|
|
682
|
+
* explicit opt-out/opt-in, honored regardless of isTTY so a piped/CI run can still
|
|
683
|
+
* opt into the wrapper's plain append-only trace, exactly as the man page promises
|
|
684
|
+
* (agent-delegation-drive.7.md "Live output"):
|
|
685
|
+
* CW_NO_STREAM=1 — master off (wins over everything)
|
|
686
|
+
* CW_AGENT_STREAM=0 — off
|
|
687
|
+
* CW_AGENT_STREAM=1 — on, even without a TTY
|
|
688
|
+
* With no env set this returns isTTY — byte-identical to the prior inline gate (POLA). */
|
|
689
|
+
function shouldStreamAgentStderr(env, isTTY) {
|
|
690
|
+
if (env.CW_NO_STREAM === "1")
|
|
691
|
+
return false;
|
|
692
|
+
if (env.CW_AGENT_STREAM === "0")
|
|
693
|
+
return false;
|
|
694
|
+
if (env.CW_AGENT_STREAM === "1")
|
|
695
|
+
return true;
|
|
696
|
+
return isTTY;
|
|
697
|
+
}
|
|
678
698
|
function runAgentProcess(descriptor, policy, request, label, handle, attestation) {
|
|
679
699
|
const resolved = (0, agent_1.resolveAgentInvocation)(request);
|
|
680
700
|
const subst = (0, agent_1.agentSubstitutions)(request, resolved.model);
|
|
@@ -692,10 +712,10 @@ function runAgentProcess(descriptor, policy, request, label, handle, attestation
|
|
|
692
712
|
outcome = request.preparedAgentOutcome;
|
|
693
713
|
}
|
|
694
714
|
else {
|
|
695
|
-
// Live output on by default when stderr is a TTY. stdout is always
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
const streamStderr = process.env
|
|
715
|
+
// Live output on by default when stderr is a TTY. stdout is always captured
|
|
716
|
+
// as data. CI/pipes stay silent unless CW_AGENT_STREAM=1 opts them into the
|
|
717
|
+
// wrapper's plain append-only trace; CW_AGENT_STREAM=0 / CW_NO_STREAM=1 force off.
|
|
718
|
+
const streamStderr = shouldStreamAgentStderr(process.env, Boolean(process.stderr.isTTY));
|
|
699
719
|
// Build child env from sandbox policy as baseline (respects env.inherit/expose/deny),
|
|
700
720
|
// then re-allow CW_* + well-known API key env vars the agent needs.
|
|
701
721
|
const childEnv = buildChildEnv(policy);
|