cool-workflow 0.2.2 → 0.2.4
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/cli/dispatch.js +13 -6
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +12 -22
- package/dist/cli/parseargv.js +1 -0
- package/dist/core/capability-data.js +3 -2
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +31 -6
- package/dist/core/format/safe-json.js +73 -0
- package/dist/core/format/state-explosion-text.js +1 -1
- package/dist/core/multi-agent/candidate-scoring.js +5 -1
- package/dist/core/multi-agent/collaboration.js +3 -3
- package/dist/core/multi-agent/coordinator.js +5 -5
- package/dist/core/multi-agent/runtime.js +4 -4
- package/dist/core/multi-agent/topology.js +3 -3
- package/dist/core/pipeline/dispatch.js +18 -4
- package/dist/core/pipeline/drive-decide.js +2 -1
- package/dist/core/state/migrations.js +16 -1
- package/dist/core/state/state-explosion/digest.js +13 -13
- package/dist/core/state/state-explosion/graph.js +17 -11
- package/dist/core/state/state-explosion/report.js +6 -6
- package/dist/core/util/cli-args.js +33 -0
- package/dist/core/util/numeric-flag.js +40 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/server.js +99 -11
- package/dist/shell/audit-cli.js +57 -25
- package/dist/shell/coordinator-io.js +73 -13
- package/dist/shell/dispatch.js +1 -1
- package/dist/shell/doctor.js +80 -1
- package/dist/shell/drive.js +251 -49
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +4 -4
- package/dist/shell/execution-backend/agent.js +30 -2
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +19 -11
- package/dist/shell/feedback-cli.js +6 -6
- package/dist/shell/fs-atomic.js +218 -29
- package/dist/shell/man-cli.js +6 -0
- package/dist/shell/metrics-cli.js +2 -1
- package/dist/shell/multi-agent-cli.js +367 -323
- package/dist/shell/multi-agent-host.js +9 -9
- package/dist/shell/multi-agent-operator-ux.js +80 -38
- package/dist/shell/node-store.js +10 -4
- package/dist/shell/observability.js +1 -1
- package/dist/shell/operator-ux-text.js +22 -22
- package/dist/shell/operator-ux.js +15 -14
- package/dist/shell/orchestrator.js +49 -38
- package/dist/shell/pipeline-cli.js +107 -42
- package/dist/shell/reclamation-io.js +88 -10
- package/dist/shell/registry-cli.js +23 -17
- package/dist/shell/remote-source.js +13 -8
- package/dist/shell/report-cli.js +45 -0
- package/dist/shell/report.js +2 -1
- package/dist/shell/run-registry-io.js +78 -19
- package/dist/shell/run-store.js +74 -2
- package/dist/shell/scheduling-io.js +12 -13
- package/dist/shell/state-cli.js +2 -7
- package/dist/shell/state-explosion-cli.js +17 -9
- package/dist/shell/topology-io.js +36 -5
- package/dist/shell/trust-audit.js +224 -22
- package/dist/shell/trust-policy-io.js +1 -1
- package/dist/shell/worker-cli.js +35 -31
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +33 -8
- package/dist/wiring/capability-table/exec-backend.js +28 -19
- package/dist/wiring/capability-table/multi-agent.js +187 -180
- package/dist/wiring/capability-table/parity.js +1 -0
- package/dist/wiring/capability-table/pipeline.js +64 -52
- package/dist/wiring/capability-table/registry-core.js +30 -11
- package/dist/wiring/capability-table/reporting.js +147 -106
- package/dist/wiring/capability-table/scheduling-registry.js +201 -167
- package/dist/wiring/capability-table/state.js +59 -51
- package/dist/wiring/capability-table/trust-ledger.js +52 -28
- package/dist/wiring/capability-table/workflow-apps.js +85 -70
- package/docs/agent-delegation-drive.7.md +4 -0
- package/docs/candidate-scoring.7.md +1 -1
- package/docs/canonical-workflow-apps.7.md +7 -7
- package/docs/cli-mcp-parity.7.md +26 -16
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +4 -0
- package/docs/coordinator-blackboard.7.md +17 -17
- package/docs/dogfood-one-real-repo.7.md +11 -11
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/end-to-end-golden-path.7.md +14 -14
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/getting-started.md +37 -37
- package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
- package/docs/multi-agent-eval-replay-harness.7.md +17 -13
- package/docs/multi-agent-operator-ux.7.md +23 -19
- package/docs/multi-agent-runtime-core.7.md +22 -22
- package/docs/multi-agent-topologies.7.md +6 -6
- package/docs/multi-agent-trust-policy-audit.7.md +11 -11
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/operator-ux.7.md +34 -34
- package/docs/pipeline-runner.7.md +4 -4
- package/docs/project-index.md +31 -4
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +5 -1
- package/docs/release-tooling.7.md +22 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +23 -19
- package/docs/run-retention-reclamation.7.md +24 -3
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +15 -15
- package/docs/state-explosion-management.7.md +14 -10
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/unix-principles.md +3 -1
- package/docs/verifier-gated-commit.7.md +1 -1
- package/docs/web-desktop-workbench.7.md +4 -0
- package/docs/workflow-app-framework.7.md +13 -13
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +3 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +42 -6
- package/scripts/bump-version.js +27 -10
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +52 -2
- package/scripts/dogfood-release.js +1 -1
- package/scripts/fake-date-for-reproduction.js +44 -0
- package/scripts/golden-path.js +4 -4
- package/scripts/purity-baseline.json +0 -27
- package/scripts/release-flow.js +194 -13
- package/scripts/release-oneclick.js +407 -0
- package/scripts/verdict-keygen.js +83 -0
- package/scripts/verify-bump-reproduction.sh +148 -0
- package/scripts/verify-verdict-signature.js +61 -0
- package/scripts/version-sync-check.js +39 -22
- package/skills/cool-workflow/SKILL.md +9 -9
- package/skills/cool-workflow/references/commands.md +89 -88
- package/ui/workbench/app.css +37 -1
- package/ui/workbench/app.js +124 -6
package/dist/shell/drive.js
CHANGED
|
@@ -51,6 +51,7 @@ exports.MAX_SUB_WORKFLOW_DEPTH = exports.DRIVE_SCHEMA_VERSION = void 0;
|
|
|
51
51
|
exports.maybeExpandLoop = maybeExpandLoop;
|
|
52
52
|
exports.driveStep = driveStep;
|
|
53
53
|
exports.drive = drive;
|
|
54
|
+
exports.driveAsync = driveAsync;
|
|
54
55
|
exports.drivePreview = drivePreview;
|
|
55
56
|
const fs = __importStar(require("node:fs"));
|
|
56
57
|
const path = __importStar(require("node:path"));
|
|
@@ -153,15 +154,22 @@ function loadRun(ctx) {
|
|
|
153
154
|
/** Runs `fn` with `runId`'s loadRun calls served from one shared cached
|
|
154
155
|
* object (seeded fresh from disk), and always clears the cache entry
|
|
155
156
|
* afterward — even on throw — so a round never leaks its cache into a
|
|
156
|
-
* later, unrelated drive call.
|
|
157
|
+
* later, unrelated drive call. Re-entrant: a nested call (e.g. the
|
|
158
|
+
* serial drive loop's own round-cache wrapping a driveStep that itself
|
|
159
|
+
* runs inside a caller's round cache) reuses the outer seed instead of
|
|
160
|
+
* re-reading disk, and only the OUTERMOST call clears the entry — so
|
|
161
|
+
* nesting never re-reads a run mid-round nor drops the cache early out
|
|
162
|
+
* from under an enclosing scope. */
|
|
157
163
|
function withRoundCache(ctx, fn) {
|
|
158
|
-
const
|
|
159
|
-
|
|
164
|
+
const alreadyActive = roundCache.has(ctx.runId);
|
|
165
|
+
if (!alreadyActive)
|
|
166
|
+
roundCache.set(ctx.runId, (0, run_store_1.loadRunFromCwd)(ctx.runId, ctx.cwd));
|
|
160
167
|
try {
|
|
161
168
|
return fn();
|
|
162
169
|
}
|
|
163
170
|
finally {
|
|
164
|
-
|
|
171
|
+
if (!alreadyActive)
|
|
172
|
+
roundCache.delete(ctx.runId);
|
|
165
173
|
}
|
|
166
174
|
}
|
|
167
175
|
function resultCachePath(run, task, promptDigest, incremental, delegationDigest) {
|
|
@@ -551,7 +559,8 @@ function maybeExpandLoop(run) {
|
|
|
551
559
|
const latest = loopPhases.reduce((a, b) => ((b.loopRound || 1) >= (a.loopRound || 1) ? b : a));
|
|
552
560
|
if (phase.id !== latest.id)
|
|
553
561
|
continue;
|
|
554
|
-
const
|
|
562
|
+
const latestTaskIds = new Set(latest.taskIds);
|
|
563
|
+
const roundTasks = run.tasks.filter((t) => latestTaskIds.has(t.id));
|
|
555
564
|
if (roundTasks.length === 0 || !roundTasks.every((t) => t.status === "completed"))
|
|
556
565
|
continue;
|
|
557
566
|
const round = latest.loopRound || 1;
|
|
@@ -560,7 +569,8 @@ function maybeExpandLoop(run) {
|
|
|
560
569
|
.sort((a, b) => compareBytes(a.id, b.id))
|
|
561
570
|
.map((t) => t.result);
|
|
562
571
|
const roundResults = ordered(roundTasks);
|
|
563
|
-
const
|
|
572
|
+
const loopTaskIds = new Set(loopPhases.flatMap((p) => p.taskIds));
|
|
573
|
+
const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopTaskIds.has(t.id));
|
|
564
574
|
const allResults = ordered(allLoopTasks);
|
|
565
575
|
const ctx = {
|
|
566
576
|
round,
|
|
@@ -597,7 +607,8 @@ function maybeExpandLoop(run) {
|
|
|
597
607
|
// Expand: clone the ROUND-1 template tasks into a fresh phase appended
|
|
598
608
|
// right after the latest round.
|
|
599
609
|
const nextRound = round + 1;
|
|
600
|
-
const
|
|
610
|
+
const originTaskIds = new Set(origin.taskIds);
|
|
611
|
+
const templateTasks = run.tasks.filter((t) => originTaskIds.has(t.id));
|
|
601
612
|
const { phase: nextPhase, tasks: newTasks } = (0, loop_expansion_1.cloneLoopRoundTasks)(origin, templateTasks, nextRound);
|
|
602
613
|
const insertAt = run.phases.findIndex((p) => p.id === latest.id);
|
|
603
614
|
run.phases.splice(insertAt + 1, 0, nextPhase);
|
|
@@ -706,6 +717,14 @@ function prepareConcurrentOutcomes(ctx, batch) {
|
|
|
706
717
|
}
|
|
707
718
|
if (jobs.length) {
|
|
708
719
|
emitProgress(`⇉ concurrent round: ${jobs.length} agent${jobs.length > 1 ? "s" : ""} spawning in parallel, may take minutes…`);
|
|
720
|
+
// Every task above that reached "pending" got dispatched (workerId
|
|
721
|
+
// assigned) in the round-cached run object, but nothing durable was
|
|
722
|
+
// written for it — unlike the serial path's own dispatch branch, which
|
|
723
|
+
// always checkpoints immediately. Flush now, BEFORE the batch's long
|
|
724
|
+
// spawn window opens: a crash mid-spawn (which can run for minutes)
|
|
725
|
+
// then leaves state.json correctly showing these tasks as dispatched,
|
|
726
|
+
// instead of losing the dispatch entirely.
|
|
727
|
+
(0, run_store_1.saveCheckpoint)(loadRun(ctx));
|
|
709
728
|
}
|
|
710
729
|
const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
|
|
711
730
|
const outcomes = new Map();
|
|
@@ -732,8 +751,9 @@ function driveConcurrentRound(ctx, limit) {
|
|
|
732
751
|
return [driveStep(ctx)];
|
|
733
752
|
const phase = (0, dispatch_1.firstRunnablePhase)(run);
|
|
734
753
|
const width = Math.max(1, Math.floor(limit) || 1);
|
|
754
|
+
const phaseTaskIds = new Set(phase.taskIds);
|
|
735
755
|
const batch = run.tasks
|
|
736
|
-
.filter((task) =>
|
|
756
|
+
.filter((task) => phaseTaskIds.has(task.id) && (task.status === "pending" || task.status === "running"))
|
|
737
757
|
.slice(0, width)
|
|
738
758
|
.map((task) => task.id);
|
|
739
759
|
const prepared = prepareConcurrentOutcomes(ctx, batch);
|
|
@@ -762,13 +782,37 @@ function driveConcurrentRound(ctx, limit) {
|
|
|
762
782
|
return steps.length > 0 ? steps : [driveStep(ctx)];
|
|
763
783
|
});
|
|
764
784
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
785
|
+
// A bare Ctrl-C (SIGINT) or an external SIGTERM used to kill the process
|
|
786
|
+
// outright between steps -- no handler was ever installed, so the default
|
|
787
|
+
// kernel action ran: instant termination, no `finally` anywhere gets a
|
|
788
|
+
// chance to run. If that landed mid-`withFileLock` critical section (e.g.
|
|
789
|
+
// the state.json or trust-audit-log lock in run-store.ts/trust-audit.ts),
|
|
790
|
+
// the lock's release `finally` never ran either, leaving a stale `.lock`
|
|
791
|
+
// file that makes the NEXT command retry for ~6s before failing outright
|
|
792
|
+
// (well under the 30s stale-lock steal window).
|
|
793
|
+
//
|
|
794
|
+
// The FIRST fix for this (installing onStopSignal below) assumed the
|
|
795
|
+
// signal would at least be caught BETWEEN rounds, only missing it
|
|
796
|
+
// mid-spawnSync. That assumption was wrong: Node.js does not dispatch a
|
|
797
|
+
// queued POSIX signal to a JS `process.on(signal, ...)` listener until
|
|
798
|
+
// the event loop actually gets a turn, and drive()'s whole multi-round
|
|
799
|
+
// loop -- every round's agent spawn uses spawnSync, and nothing between
|
|
800
|
+
// rounds ever awaits anything -- is ONE continuous synchronous span with
|
|
801
|
+
// no such turn anywhere inside it. A signal landing at any point during
|
|
802
|
+
// that whole span (confirmed live: even a plain CPU-only busy loop with
|
|
803
|
+
// no spawnSync at all drops it the same way) sits queued until the loop
|
|
804
|
+
// finishes on its own, by which point the `finally` below has already
|
|
805
|
+
// removed the listener -- so it is never invoked at all. `drive()` keeps
|
|
806
|
+
// this exact (buggy but byte-identical) shape for full backward
|
|
807
|
+
// compatibility; `driveAsync()` below is the actual fix, for callers
|
|
808
|
+
// that need a live run to really respond to Ctrl-C/SIGTERM.
|
|
809
|
+
const DRIVE_STOP_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
810
|
+
const DRIVE_STOP_EXIT_CODE = { SIGINT: 130, SIGTERM: 143 };
|
|
811
|
+
function buildDriveContext(runId, cwd, options) {
|
|
768
812
|
const now = options.now || new Date().toISOString();
|
|
769
813
|
const config = options.agentConfig || (0, agent_config_1.resolveAgentConfig)(options.args || {});
|
|
770
814
|
const policy = { ...drive_decide_1.DEFAULT_SCHEDULING_POLICY, ...(options.policy || {}) };
|
|
771
|
-
|
|
815
|
+
return {
|
|
772
816
|
runId,
|
|
773
817
|
cwd,
|
|
774
818
|
now,
|
|
@@ -779,22 +823,22 @@ function drive(runId, cwd, options = {}) {
|
|
|
779
823
|
visitedAppIds: options.visitedAppIds || [],
|
|
780
824
|
policy,
|
|
781
825
|
};
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
// renders the line; this closure decides WHEN to emit each boundary.
|
|
826
|
+
}
|
|
827
|
+
// Phase-boundary progress (brew-style): announce each phase when it becomes
|
|
828
|
+
// active and when it finishes — `==> Map ✓ (6/6)` / `==> Assess ⇉ (3/6)`.
|
|
829
|
+
// Describes CW's OWN phases (vendor-neutral); goes to stderr via
|
|
830
|
+
// emitProgress so stdout stays clean data. Byte-exact port of the old
|
|
831
|
+
// build's src/drive.ts emitPhaseProgress. term.phaseProgressLine renders
|
|
832
|
+
// the line; the returned closure decides WHEN to emit each boundary.
|
|
833
|
+
// Shared by drive() and driveAsync() so the two never drift apart.
|
|
834
|
+
function createPhaseProgressEmitter() {
|
|
792
835
|
const announcedPhaseComplete = new Set();
|
|
793
836
|
let activePhaseId;
|
|
794
837
|
const titleCase = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
|
795
|
-
|
|
838
|
+
return (run) => {
|
|
796
839
|
for (const ph of run.phases || []) {
|
|
797
|
-
const
|
|
840
|
+
const phaseTaskIds = new Set(ph.taskIds);
|
|
841
|
+
const phaseTasks = run.tasks.filter((task) => phaseTaskIds.has(task.id));
|
|
798
842
|
const total = phaseTasks.length;
|
|
799
843
|
if (total === 0)
|
|
800
844
|
continue;
|
|
@@ -814,38 +858,113 @@ function drive(runId, cwd, options = {}) {
|
|
|
814
858
|
return; // only the first not-yet-complete phase is "active"
|
|
815
859
|
}
|
|
816
860
|
};
|
|
817
|
-
|
|
818
|
-
|
|
861
|
+
}
|
|
862
|
+
/** Runs exactly one round -- a single driveStep, or a full concurrent
|
|
863
|
+
* round when width>1 -- and records its steps. Returns false when the
|
|
864
|
+
* round loop should stop (a terminal step, or `--once`'s single-round
|
|
865
|
+
* contract), true to go on to another round. Shared by drive()'s plain
|
|
866
|
+
* loop and driveAsync()'s interruptible one so the two never drift
|
|
867
|
+
* apart. */
|
|
868
|
+
function driveOneRound(ctx, options, steps, emitPhaseProgress) {
|
|
869
|
+
// One round-cache scope for the WHOLE round, not just driveConcurrentRound's
|
|
870
|
+
// own batch: the width check and (for a serial round) driveStep/
|
|
871
|
+
// processSelectedTask's several loadRun(ctx) calls otherwise each
|
|
872
|
+
// re-read+re-parse state.json even though nothing on disk changed
|
|
873
|
+
// between them — reads that only reflect the mutations THIS round's
|
|
874
|
+
// own steps make, which the shared in-memory object already carries.
|
|
875
|
+
const roundSteps = withRoundCache(ctx, () => {
|
|
819
876
|
const width = (0, drive_decide_1.roundWidth)(loadRun(ctx), options.concurrency);
|
|
820
877
|
// width>1 (an explicit --concurrency>1, or an auto-width parallel
|
|
821
878
|
// phase) runs the whole round through driveConcurrentRound — one or
|
|
822
|
-
// more steps recorded in deterministic batch order, one flush at
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
879
|
+
// more steps recorded in deterministic batch order, one flush at round
|
|
880
|
+
// end. `--once` still stops after this ONE round even though a round
|
|
881
|
+
// can yield multiple steps.
|
|
882
|
+
return width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
|
|
883
|
+
});
|
|
884
|
+
for (const stepResult of roundSteps)
|
|
885
|
+
steps.push(stepResult);
|
|
886
|
+
// Brew-style phase boundaries: after each round, announce a newly-active
|
|
887
|
+
// phase and any phase that just finished. Cheap — reuses the run we just
|
|
888
|
+
// advanced; goes to stderr so stdout stays clean.
|
|
889
|
+
emitPhaseProgress(loadRun(ctx));
|
|
890
|
+
const last = roundSteps[roundSteps.length - 1];
|
|
891
|
+
if (options.once)
|
|
892
|
+
return false;
|
|
893
|
+
if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked"))
|
|
894
|
+
return false;
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
/** One JS EventEmitter listener per SIGINT/SIGTERM, installed for the span
|
|
898
|
+
* of one drive()/driveAsync() call and removed by that caller's own
|
|
899
|
+
* `finally` (this function recurses for nested sub-workflow runs, and
|
|
900
|
+
* many tests call it repeatedly in one process, so nothing must
|
|
901
|
+
* accumulate on `process`'s listener list across calls). The FIRST
|
|
902
|
+
* signal sets a flag the round loop checks at the top of its next
|
|
903
|
+
* iteration -- never mid-round -- so the in-flight round always
|
|
904
|
+
* finishes its own bookkeeping first. A SECOND signal means the caller
|
|
905
|
+
* wants out right now, not a graceful stop, and force-exits immediately
|
|
906
|
+
* with the conventional 128+signum code. */
|
|
907
|
+
function createStopSignalController(runId) {
|
|
908
|
+
let interruptedBy;
|
|
909
|
+
let stopSignalHits = 0;
|
|
910
|
+
const onStopSignal = (signal) => {
|
|
911
|
+
stopSignalHits += 1;
|
|
912
|
+
if (stopSignalHits === 1) {
|
|
913
|
+
interruptedBy = signal;
|
|
914
|
+
emitProgress(`received ${signal} — stopping after the current step (resume: cw run resume ${runId} --drive)`);
|
|
915
|
+
return;
|
|
840
916
|
}
|
|
841
|
-
|
|
917
|
+
// A second signal means the caller wants out right now, not a graceful stop.
|
|
918
|
+
emitProgress(`received a second ${signal} — exiting immediately`);
|
|
919
|
+
process.exit(DRIVE_STOP_EXIT_CODE[signal] ?? 1);
|
|
920
|
+
};
|
|
921
|
+
return {
|
|
922
|
+
install: () => {
|
|
923
|
+
for (const signal of DRIVE_STOP_SIGNALS)
|
|
924
|
+
process.on(signal, onStopSignal);
|
|
925
|
+
},
|
|
926
|
+
remove: () => {
|
|
927
|
+
for (const signal of DRIVE_STOP_SIGNALS)
|
|
928
|
+
process.off(signal, onStopSignal);
|
|
929
|
+
},
|
|
930
|
+
getInterruptedBy: () => interruptedBy,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
/** Forces one real turn of the Node.js event loop. `setImmediate` (a
|
|
934
|
+
* genuine macrotask, unlike a bare Promise microtask, which never leaves
|
|
935
|
+
* the current turn) is the only thing that actually lets libuv's poll
|
|
936
|
+
* phase run and dispatch a queued SIGINT/SIGTERM to its JS listener --
|
|
937
|
+
* confirmed live: a real external `kill -INT` sent to a busy synchronous
|
|
938
|
+
* process is dropped for as long as the process never yields, and fires
|
|
939
|
+
* within ~1ms of the very next event-loop turn once it does. */
|
|
940
|
+
function yieldToEventLoop() {
|
|
941
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
942
|
+
}
|
|
943
|
+
/** Everything drive()/driveAsync() do once their round loop has stopped:
|
|
944
|
+
* fold the interrupted/exhausted-iterations signal into the right
|
|
945
|
+
* "blocked" step, then assemble the DriveResult. Shared so the two
|
|
946
|
+
* loops can never report status differently for the same outcome. */
|
|
947
|
+
function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterationsAtLoopExit, interruptedBy) {
|
|
948
|
+
let exhaustedMaxIterations = exhaustedMaxIterationsAtLoopExit;
|
|
842
949
|
const run = loadRun(ctx);
|
|
843
950
|
const completedWorkers = (0, drive_decide_1.countCompleted)(run);
|
|
844
951
|
const parkedWorkers = (0, drive_decide_1.countParked)(run);
|
|
845
952
|
const committed = (0, drive_decide_1.hasTerminalCommit)(run);
|
|
846
953
|
const last = steps[steps.length - 1];
|
|
847
|
-
|
|
848
|
-
|
|
954
|
+
// A signal can land on the very last step (the terminal commit itself) --
|
|
955
|
+
// the run is already fully done, same "complete" check the `once` branch
|
|
956
|
+
// of finalDriveStatus below already uses. Reporting "blocked" here anyway
|
|
957
|
+
// would be a real user-visible lie (e.g. pipeline-cli.ts's `--bundle` gate
|
|
958
|
+
// reads status, so a genuinely finished run would wrongly skip sealing).
|
|
959
|
+
const alreadyComplete = completedWorkers === plannedWorkers && committed;
|
|
960
|
+
if (interruptedBy) {
|
|
961
|
+
exhaustedMaxIterations = false;
|
|
962
|
+
if (!alreadyComplete) {
|
|
963
|
+
steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive interrupted by ${interruptedBy} — resume: cw run resume ${ctx.runId} --drive` }));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else if (exhaustedMaxIterations) {
|
|
967
|
+
steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive reached max iteration limit (${maxIter}) before a terminal state — resume: cw run resume ${ctx.runId} --drive` }));
|
|
849
968
|
}
|
|
850
969
|
const statusInputs = {
|
|
851
970
|
once: Boolean(options.once),
|
|
@@ -862,7 +981,7 @@ function drive(runId, cwd, options = {}) {
|
|
|
862
981
|
const committedCommit = (run.commits || []).find((c) => c.reason && c.reason.startsWith("agent-delegation-drive"));
|
|
863
982
|
return {
|
|
864
983
|
schemaVersion: 1,
|
|
865
|
-
runId,
|
|
984
|
+
runId: ctx.runId,
|
|
866
985
|
workflowId: run.workflow.id,
|
|
867
986
|
status,
|
|
868
987
|
steps,
|
|
@@ -872,9 +991,92 @@ function drive(runId, cwd, options = {}) {
|
|
|
872
991
|
commitId: committedCommit?.id,
|
|
873
992
|
reportPath: run.paths.report,
|
|
874
993
|
statePath: run.paths.state,
|
|
875
|
-
agentConfigured: agentConfigured(config),
|
|
994
|
+
agentConfigured: agentConfigured(ctx.config),
|
|
876
995
|
};
|
|
877
996
|
}
|
|
997
|
+
/** Drive a run: `--once` advances exactly one step; otherwise run to
|
|
998
|
+
* completion, park, or a blocked stop. Fully synchronous, byte-identical
|
|
999
|
+
* to every prior release -- see the file comment above DRIVE_STOP_SIGNALS
|
|
1000
|
+
* for why a real SIGINT/SIGTERM sent during a live multi-round call is
|
|
1001
|
+
* queued but never actually reaches onStopSignal below; that limitation
|
|
1002
|
+
* is deliberately kept here for backward compatibility (recursive
|
|
1003
|
+
* sub-workflow runs, and every existing caller/test that depends on this
|
|
1004
|
+
* exact synchronous shape) and fixed only in driveAsync(). */
|
|
1005
|
+
function drive(runId, cwd, options = {}) {
|
|
1006
|
+
const ctx = buildDriveContext(runId, cwd, options);
|
|
1007
|
+
const steps = [];
|
|
1008
|
+
const run0 = loadRun(ctx);
|
|
1009
|
+
const plannedWorkers = run0.tasks.length;
|
|
1010
|
+
const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
|
|
1011
|
+
const emitPhaseProgress = createPhaseProgressEmitter();
|
|
1012
|
+
let exhaustedMaxIterations = !options.once;
|
|
1013
|
+
const stopSignal = createStopSignalController(runId);
|
|
1014
|
+
stopSignal.install();
|
|
1015
|
+
try {
|
|
1016
|
+
for (let i = 0; i < maxIter; i++) {
|
|
1017
|
+
if (stopSignal.getInterruptedBy())
|
|
1018
|
+
break;
|
|
1019
|
+
if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
|
|
1020
|
+
exhaustedMaxIterations = false;
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
finally {
|
|
1026
|
+
stopSignal.remove();
|
|
1027
|
+
}
|
|
1028
|
+
return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
|
|
1029
|
+
}
|
|
1030
|
+
/** Same contract and same DriveResult as drive() -- but actually
|
|
1031
|
+
* interruptible. After every round it awaits yieldToEventLoop(), a real
|
|
1032
|
+
* setImmediate-based turn of the event loop, giving Node.js an actual
|
|
1033
|
+
* chance to dispatch a queued SIGINT/SIGTERM to onStopSignal before the
|
|
1034
|
+
* next round's synchronous spawnSync work begins; the loop then sees
|
|
1035
|
+
* the interrupted flag at the top of its next iteration exactly like
|
|
1036
|
+
* drive() already checks it. Shares driveOneRound/createStopSignalController/
|
|
1037
|
+
* finalizeDriveResult with drive(), so the two can only ever differ in
|
|
1038
|
+
* this one respect. Intended for the live, potentially-long-running
|
|
1039
|
+
* entry points (CLI `--drive`, the MCP run.drive.step tool) where a
|
|
1040
|
+
* user's Ctrl-C or a supervisor's SIGTERM must actually be able to stop
|
|
1041
|
+
* the run; internal/recursive/test callers that don't need this keep
|
|
1042
|
+
* calling the plain drive() above, unaffected.
|
|
1043
|
+
*
|
|
1044
|
+
* A signal arriving while a NESTED sub-workflow's own drive() call
|
|
1045
|
+
* (runSubWorkflow, below) is running is still not caught until that
|
|
1046
|
+
* nested call finishes on its own -- an accepted, documented limitation:
|
|
1047
|
+
* the same "cannot interrupt an already in-flight blocking operation,
|
|
1048
|
+
* only checked at explicit boundaries" shape as the pre-existing
|
|
1049
|
+
* cannot-interrupt-mid-spawnSync limit, just one level of recursion
|
|
1050
|
+
* deeper. Fixing that would require threading interruptibility through
|
|
1051
|
+
* the entire recursive call graph (driveStep/processSelectedTask/
|
|
1052
|
+
* runSubWorkflow/handleHop), a materially larger change left for a
|
|
1053
|
+
* future cycle if it proves needed in practice. */
|
|
1054
|
+
async function driveAsync(runId, cwd, options = {}) {
|
|
1055
|
+
const ctx = buildDriveContext(runId, cwd, options);
|
|
1056
|
+
const steps = [];
|
|
1057
|
+
const run0 = loadRun(ctx);
|
|
1058
|
+
const plannedWorkers = run0.tasks.length;
|
|
1059
|
+
const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
|
|
1060
|
+
const emitPhaseProgress = createPhaseProgressEmitter();
|
|
1061
|
+
let exhaustedMaxIterations = !options.once;
|
|
1062
|
+
const stopSignal = createStopSignalController(runId);
|
|
1063
|
+
stopSignal.install();
|
|
1064
|
+
try {
|
|
1065
|
+
for (let i = 0; i < maxIter; i++) {
|
|
1066
|
+
if (stopSignal.getInterruptedBy())
|
|
1067
|
+
break;
|
|
1068
|
+
if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
|
|
1069
|
+
exhaustedMaxIterations = false;
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
await yieldToEventLoop();
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
finally {
|
|
1076
|
+
stopSignal.remove();
|
|
1077
|
+
}
|
|
1078
|
+
return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
|
|
1079
|
+
}
|
|
878
1080
|
function drivePreview(runId, cwd, args = {}) {
|
|
879
1081
|
const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
|
|
880
1082
|
const config = (0, agent_config_1.resolveAgentConfig)(args);
|
package/dist/shell/eval-text.js
CHANGED
|
@@ -167,7 +167,7 @@ function formatMultiAgentEval(value) {
|
|
|
167
167
|
` replay=${value.paths.replayRunPath}`,
|
|
168
168
|
"",
|
|
169
169
|
"Next Action",
|
|
170
|
-
`
|
|
170
|
+
` cw eval compare ${value.paths.snapshotPath} ${value.paths.replayRunPath}`,
|
|
171
171
|
].join("\n");
|
|
172
172
|
}
|
|
173
173
|
if (isSnapshot(value)) {
|
|
@@ -201,7 +201,7 @@ function formatMultiAgentEval(value) {
|
|
|
201
201
|
" snapshot-ready",
|
|
202
202
|
"",
|
|
203
203
|
"Next Action",
|
|
204
|
-
`
|
|
204
|
+
` cw eval replay ${value.paths.snapshotPath}`,
|
|
205
205
|
].join("\n");
|
|
206
206
|
}
|
|
207
207
|
if (isReport(value)) {
|
|
@@ -65,10 +65,10 @@ function buildEvidenceReasoningReport(run, options = {}) {
|
|
|
65
65
|
if (persisted && persisted.sourceFingerprint !== currentFingerprint)
|
|
66
66
|
status = "stale";
|
|
67
67
|
const nextAction = status === "stale" || status === "absent"
|
|
68
|
-
? `
|
|
68
|
+
? `cw multi-agent reasoning ${run.id} --refresh`
|
|
69
69
|
: totals.unexplained > 0
|
|
70
|
-
? `
|
|
71
|
-
: `
|
|
70
|
+
? `cw multi-agent reasoning ${run.id} --json`
|
|
71
|
+
: `cw multi-agent evidence ${run.id} --json`;
|
|
72
72
|
return {
|
|
73
73
|
schemaVersion: exports.EVIDENCE_REASONING_SCHEMA_VERSION,
|
|
74
74
|
runId: run.id,
|
|
@@ -343,7 +343,7 @@ function refreshEvidenceReasoning(run) {
|
|
|
343
343
|
totals: report.totals,
|
|
344
344
|
entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
|
|
345
345
|
paths: { reasoningDir: dir, indexPath, reportPath },
|
|
346
|
-
nextAction: `
|
|
346
|
+
nextAction: `cw multi-agent reasoning ${run.id}`,
|
|
347
347
|
};
|
|
348
348
|
(0, fs_atomic_1.writeJson)(indexPath, index);
|
|
349
349
|
(0, fs_atomic_1.writeJson)(reportPath, { ...report, freshness: { ...report.freshness, status: "valid", persistedFingerprint: report.sourceFingerprint } });
|
|
@@ -87,14 +87,23 @@ const AGENT_PROVIDER_KEY_ENV_RE = /^(CW_|ANTHROPIC_|OPENAI_|GEMINI_|DEEPSEEK_|CO
|
|
|
87
87
|
* worker.agent-env trust-audit event. */
|
|
88
88
|
function buildAgentChildEnv(policy, baseEnv = process.env) {
|
|
89
89
|
const env = (0, local_1.buildChildEnv)(policy, baseEnv);
|
|
90
|
+
// deny must win here too: buildChildEnv already applied it above, but the
|
|
91
|
+
// provider-key/USER re-add below exists specifically to put vars BACK
|
|
92
|
+
// that buildChildEnv's readonly/locked-down defaults strip — without this
|
|
93
|
+
// check that re-add silently overrode an operator's explicit deny (e.g.
|
|
94
|
+
// deny:["AWS_SECRET_ACCESS_KEY"] still forwarded it, since AWS_ matches
|
|
95
|
+
// AGENT_PROVIDER_KEY_ENV_RE).
|
|
96
|
+
const denied = new Set(policy.env.deny || []);
|
|
90
97
|
const forwarded = [];
|
|
91
98
|
for (const key of Object.keys(baseEnv)) {
|
|
99
|
+
if (denied.has(key))
|
|
100
|
+
continue;
|
|
92
101
|
if (AGENT_PROVIDER_KEY_ENV_RE.test(key)) {
|
|
93
102
|
env[key] = baseEnv[key];
|
|
94
103
|
forwarded.push(key);
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
|
-
if (baseEnv.USER !== undefined && env.USER === undefined) {
|
|
106
|
+
if (!denied.has("USER") && baseEnv.USER !== undefined && env.USER === undefined) {
|
|
98
107
|
env.USER = baseEnv.USER;
|
|
99
108
|
forwarded.push("USER");
|
|
100
109
|
}
|
|
@@ -408,15 +417,34 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
|
|
|
408
417
|
const built = buildAgentChildEnv(policy);
|
|
409
418
|
const childEnv = built.env;
|
|
410
419
|
forwardedEnvVars = built.forwarded;
|
|
420
|
+
const timeoutMs = resolved.timeoutMs || 600000;
|
|
411
421
|
const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
|
|
412
422
|
cwd: request.cwd,
|
|
413
423
|
env: childEnv,
|
|
414
424
|
encoding: "utf8",
|
|
415
|
-
timeout:
|
|
425
|
+
timeout: timeoutMs,
|
|
416
426
|
maxBuffer: 32 * 1024 * 1024,
|
|
417
427
|
shell: false,
|
|
428
|
+
// SIGKILL, not the default SIGTERM: an agent wrapper that ignores
|
|
429
|
+
// SIGTERM would leave this blocking spawnSync waiting forever (no
|
|
430
|
+
// second-stage escalation is possible from inside a sync call), and
|
|
431
|
+
// a timed-out agent's output is discarded by the refusal below
|
|
432
|
+
// anyway — so a hard kill loses nothing and cannot wedge cw.
|
|
433
|
+
killSignal: "SIGKILL",
|
|
418
434
|
stdio: ["ignore", "pipe", streamStderr ? "inherit" : "pipe"],
|
|
419
435
|
});
|
|
436
|
+
// A timeout surfaces as child.error with code ETIMEDOUT (status null,
|
|
437
|
+
// signal set). Classify it FIRST: without this, the generic spawnError
|
|
438
|
+
// branch below reported "agent process failed to spawn: ...ETIMEDOUT"
|
|
439
|
+
// and the null-exit-code "timed out or killed" branch was dead code
|
|
440
|
+
// for real timeouts.
|
|
441
|
+
if (child.error && child.error.code === "ETIMEDOUT") {
|
|
442
|
+
const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported", undefined, undefined, forwardedEnvVars);
|
|
443
|
+
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent process timed out after ${timeoutMs}ms and was killed (SIGKILL)`, {
|
|
444
|
+
...attestation,
|
|
445
|
+
handle: handleOut,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
420
448
|
outcome = {
|
|
421
449
|
...(child.error ? { spawnError: messageOf(child.error) } : {}),
|
|
422
450
|
exitCode: typeof child.status === "number" ? child.status : null,
|
|
@@ -56,7 +56,10 @@ function runContainer(descriptor, policy, request, label, handle, attestation) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
runArgs.push(handle.ref, command, ...args);
|
|
59
|
-
|
|
59
|
+
// An unset timeoutMs must not mean "no timeout" — spawnSync would then
|
|
60
|
+
// block forever on a hung container with no kill path. 600000 matches the
|
|
61
|
+
// agent backend's own default fallback (execution-backend/agent.ts).
|
|
62
|
+
const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024 });
|
|
60
63
|
if (result.error) {
|
|
61
64
|
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `${runtime} run failed: ${messageOf(result.error)}`, attestation);
|
|
62
65
|
}
|
|
@@ -19,16 +19,21 @@ function messageOf(error) {
|
|
|
19
19
|
return error instanceof Error ? error.message : String(error);
|
|
20
20
|
}
|
|
21
21
|
function buildChildEnv(policy, baseEnv = process.env) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
// deny must win regardless of inherit: a custom profile combining
|
|
23
|
+
// inherit:true with deny:[...] (a valid, normalized combination — see
|
|
24
|
+
// sandbox-profile.ts's normalizeEnv) means "everything EXCEPT these".
|
|
25
|
+
// An early return on inherit before this loop ran meant deny was
|
|
26
|
+
// silently skipped whenever inherit was true.
|
|
27
|
+
const env = policy.env.inherit ? { ...baseEnv } : {};
|
|
28
|
+
if (!policy.env.inherit) {
|
|
29
|
+
if (baseEnv.PATH !== undefined)
|
|
30
|
+
env.PATH = baseEnv.PATH;
|
|
31
|
+
if (baseEnv.HOME !== undefined)
|
|
32
|
+
env.HOME = baseEnv.HOME;
|
|
33
|
+
for (const name of policy.env.expose || []) {
|
|
34
|
+
if (baseEnv[name] !== undefined)
|
|
35
|
+
env[name] = baseEnv[name];
|
|
36
|
+
}
|
|
32
37
|
}
|
|
33
38
|
for (const name of policy.env.deny || []) {
|
|
34
39
|
delete env[name];
|
|
@@ -65,7 +70,10 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
|
|
|
65
70
|
cwd: request.cwd,
|
|
66
71
|
env,
|
|
67
72
|
encoding: "utf8",
|
|
68
|
-
timeout
|
|
73
|
+
// An unset timeoutMs must not mean "no timeout" — spawnSync would then
|
|
74
|
+
// block forever on a hung child with no kill path. 600000 matches the
|
|
75
|
+
// agent backend's own default fallback (execution-backend/agent.ts).
|
|
76
|
+
timeout: request.timeoutMs || 600000,
|
|
69
77
|
maxBuffer: 32 * 1024 * 1024,
|
|
70
78
|
};
|
|
71
79
|
if (spawnStyle === "shell") {
|
|
@@ -67,15 +67,15 @@ function feedbackSummaryCli(args) {
|
|
|
67
67
|
const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
|
|
68
68
|
return (0, error_feedback_1.summarizeFeedback)((0, feedback_operations_1.listFeedback)(run));
|
|
69
69
|
}
|
|
70
|
+
// collect/task/resolve mutate the run and saveCheckpoint (transitively,
|
|
71
|
+
// in feedback-operations -> error-feedback-io), so they hold the state.json
|
|
72
|
+
// lock across the whole load -> change -> save cycle (lost-update class).
|
|
70
73
|
function feedbackCollectCli(args) {
|
|
71
|
-
|
|
72
|
-
return (0, feedback_operations_1.collectFeedback)(run);
|
|
74
|
+
return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.collectFeedback)(run));
|
|
73
75
|
}
|
|
74
76
|
function feedbackTaskCli(args) {
|
|
75
|
-
|
|
76
|
-
return (0, feedback_operations_1.createFeedbackTask)(run, req(args.feedbackId, "feedback id"), args);
|
|
77
|
+
return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.createFeedbackTask)(run, req(args.feedbackId, "feedback id"), args));
|
|
77
78
|
}
|
|
78
79
|
function feedbackResolveCli(args) {
|
|
79
|
-
|
|
80
|
-
return (0, feedback_operations_1.resolveFeedback)(run, req(args.feedbackId, "feedback id"), args);
|
|
80
|
+
return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.resolveFeedback)(run, req(args.feedbackId, "feedback id"), args));
|
|
81
81
|
}
|