cool-workflow 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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 +11 -5
- package/dist/cli/entry.js +41 -1
- package/dist/cli/io.js +6 -2
- package/dist/cli/parseargv.js +1 -0
- package/dist/core/capability-data.js +1 -0
- package/dist/core/format/completion.js +68 -0
- package/dist/core/format/help.js +25 -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/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 +243 -49
- package/dist/shell/eval-text.js +2 -2
- package/dist/shell/evidence-reasoning.js +4 -4
- package/dist/shell/execution-backend/agent.js +20 -1
- package/dist/shell/execution-backend/container.js +4 -1
- package/dist/shell/execution-backend/local.js +4 -1
- 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 +87 -40
- package/dist/shell/reclamation-io.js +14 -4
- package/dist/shell/registry-cli.js +19 -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 +77 -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/wiring/capability-table/basics.js +33 -8
- package/dist/wiring/capability-table/exec-backend.js +20 -11
- package/dist/wiring/capability-table/multi-agent.js +172 -165
- package/dist/wiring/capability-table/parity.js +1 -0
- package/dist/wiring/capability-table/pipeline.js +54 -42
- package/dist/wiring/capability-table/registry-core.js +29 -10
- package/dist/wiring/capability-table/reporting.js +137 -96
- package/dist/wiring/capability-table/scheduling-registry.js +195 -161
- package/dist/wiring/capability-table/state.js +36 -28
- package/dist/wiring/capability-table/trust-ledger.js +46 -22
- package/dist/wiring/capability-table/workflow-apps.js +83 -68
- package/docs/agent-delegation-drive.7.md +2 -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 +8 -3
- package/docs/contract-migration-tooling.7.md +2 -0
- package/docs/control-plane-scheduling.7.md +2 -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 +2 -0
- package/docs/end-to-end-golden-path.7.md +14 -14
- package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
- package/docs/execution-backends.7.md +2 -0
- package/docs/getting-started.md +37 -37
- package/docs/multi-agent-cli-mcp-surface.7.md +19 -17
- package/docs/multi-agent-eval-replay-harness.7.md +15 -13
- package/docs/multi-agent-operator-ux.7.md +21 -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 +2 -0
- package/docs/observability-cost-accounting.7.md +2 -0
- package/docs/operator-ux.7.md +34 -34
- package/docs/pipeline-runner.7.md +4 -4
- package/docs/project-index.md +27 -4
- package/docs/real-execution-backends.7.md +2 -0
- package/docs/release-and-migration.7.md +3 -1
- package/docs/release-tooling.7.md +2 -0
- package/docs/routines.md +4 -4
- package/docs/run-registry-control-plane.7.md +21 -19
- package/docs/run-retention-reclamation.7.md +2 -0
- package/docs/scheduled-tasks.md +14 -14
- package/docs/security-trust-hardening.7.md +15 -15
- package/docs/state-explosion-management.7.md +12 -10
- package/docs/team-collaboration.7.md +2 -0
- package/docs/trust-audit-anchor.7.md +2 -0
- package/docs/unix-principles.md +3 -1
- package/docs/verifier-gated-commit.7.md +1 -1
- package/docs/web-desktop-workbench.7.md +2 -0
- package/docs/workflow-app-framework.7.md +13 -13
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +2 -2
- package/scripts/agents/claude-p-agent.js +2 -2
- package/scripts/block-unapproved-tag.sh +23 -2
- 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 +3 -0
- package/scripts/release-flow.js +57 -1
- package/scripts/verdict-keygen.js +83 -0
- package/scripts/verify-bump-reproduction.sh +148 -0
- package/scripts/verify-verdict-signature.js +61 -0
- 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);
|
|
@@ -732,8 +743,9 @@ function driveConcurrentRound(ctx, limit) {
|
|
|
732
743
|
return [driveStep(ctx)];
|
|
733
744
|
const phase = (0, dispatch_1.firstRunnablePhase)(run);
|
|
734
745
|
const width = Math.max(1, Math.floor(limit) || 1);
|
|
746
|
+
const phaseTaskIds = new Set(phase.taskIds);
|
|
735
747
|
const batch = run.tasks
|
|
736
|
-
.filter((task) =>
|
|
748
|
+
.filter((task) => phaseTaskIds.has(task.id) && (task.status === "pending" || task.status === "running"))
|
|
737
749
|
.slice(0, width)
|
|
738
750
|
.map((task) => task.id);
|
|
739
751
|
const prepared = prepareConcurrentOutcomes(ctx, batch);
|
|
@@ -762,13 +774,37 @@ function driveConcurrentRound(ctx, limit) {
|
|
|
762
774
|
return steps.length > 0 ? steps : [driveStep(ctx)];
|
|
763
775
|
});
|
|
764
776
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
777
|
+
// A bare Ctrl-C (SIGINT) or an external SIGTERM used to kill the process
|
|
778
|
+
// outright between steps -- no handler was ever installed, so the default
|
|
779
|
+
// kernel action ran: instant termination, no `finally` anywhere gets a
|
|
780
|
+
// chance to run. If that landed mid-`withFileLock` critical section (e.g.
|
|
781
|
+
// the state.json or trust-audit-log lock in run-store.ts/trust-audit.ts),
|
|
782
|
+
// the lock's release `finally` never ran either, leaving a stale `.lock`
|
|
783
|
+
// file that makes the NEXT command retry for ~6s before failing outright
|
|
784
|
+
// (well under the 30s stale-lock steal window).
|
|
785
|
+
//
|
|
786
|
+
// The FIRST fix for this (installing onStopSignal below) assumed the
|
|
787
|
+
// signal would at least be caught BETWEEN rounds, only missing it
|
|
788
|
+
// mid-spawnSync. That assumption was wrong: Node.js does not dispatch a
|
|
789
|
+
// queued POSIX signal to a JS `process.on(signal, ...)` listener until
|
|
790
|
+
// the event loop actually gets a turn, and drive()'s whole multi-round
|
|
791
|
+
// loop -- every round's agent spawn uses spawnSync, and nothing between
|
|
792
|
+
// rounds ever awaits anything -- is ONE continuous synchronous span with
|
|
793
|
+
// no such turn anywhere inside it. A signal landing at any point during
|
|
794
|
+
// that whole span (confirmed live: even a plain CPU-only busy loop with
|
|
795
|
+
// no spawnSync at all drops it the same way) sits queued until the loop
|
|
796
|
+
// finishes on its own, by which point the `finally` below has already
|
|
797
|
+
// removed the listener -- so it is never invoked at all. `drive()` keeps
|
|
798
|
+
// this exact (buggy but byte-identical) shape for full backward
|
|
799
|
+
// compatibility; `driveAsync()` below is the actual fix, for callers
|
|
800
|
+
// that need a live run to really respond to Ctrl-C/SIGTERM.
|
|
801
|
+
const DRIVE_STOP_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
802
|
+
const DRIVE_STOP_EXIT_CODE = { SIGINT: 130, SIGTERM: 143 };
|
|
803
|
+
function buildDriveContext(runId, cwd, options) {
|
|
768
804
|
const now = options.now || new Date().toISOString();
|
|
769
805
|
const config = options.agentConfig || (0, agent_config_1.resolveAgentConfig)(options.args || {});
|
|
770
806
|
const policy = { ...drive_decide_1.DEFAULT_SCHEDULING_POLICY, ...(options.policy || {}) };
|
|
771
|
-
|
|
807
|
+
return {
|
|
772
808
|
runId,
|
|
773
809
|
cwd,
|
|
774
810
|
now,
|
|
@@ -779,22 +815,22 @@ function drive(runId, cwd, options = {}) {
|
|
|
779
815
|
visitedAppIds: options.visitedAppIds || [],
|
|
780
816
|
policy,
|
|
781
817
|
};
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
// renders the line; this closure decides WHEN to emit each boundary.
|
|
818
|
+
}
|
|
819
|
+
// Phase-boundary progress (brew-style): announce each phase when it becomes
|
|
820
|
+
// active and when it finishes — `==> Map ✓ (6/6)` / `==> Assess ⇉ (3/6)`.
|
|
821
|
+
// Describes CW's OWN phases (vendor-neutral); goes to stderr via
|
|
822
|
+
// emitProgress so stdout stays clean data. Byte-exact port of the old
|
|
823
|
+
// build's src/drive.ts emitPhaseProgress. term.phaseProgressLine renders
|
|
824
|
+
// the line; the returned closure decides WHEN to emit each boundary.
|
|
825
|
+
// Shared by drive() and driveAsync() so the two never drift apart.
|
|
826
|
+
function createPhaseProgressEmitter() {
|
|
792
827
|
const announcedPhaseComplete = new Set();
|
|
793
828
|
let activePhaseId;
|
|
794
829
|
const titleCase = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
|
795
|
-
|
|
830
|
+
return (run) => {
|
|
796
831
|
for (const ph of run.phases || []) {
|
|
797
|
-
const
|
|
832
|
+
const phaseTaskIds = new Set(ph.taskIds);
|
|
833
|
+
const phaseTasks = run.tasks.filter((task) => phaseTaskIds.has(task.id));
|
|
798
834
|
const total = phaseTasks.length;
|
|
799
835
|
if (total === 0)
|
|
800
836
|
continue;
|
|
@@ -814,38 +850,113 @@ function drive(runId, cwd, options = {}) {
|
|
|
814
850
|
return; // only the first not-yet-complete phase is "active"
|
|
815
851
|
}
|
|
816
852
|
};
|
|
817
|
-
|
|
818
|
-
|
|
853
|
+
}
|
|
854
|
+
/** Runs exactly one round -- a single driveStep, or a full concurrent
|
|
855
|
+
* round when width>1 -- and records its steps. Returns false when the
|
|
856
|
+
* round loop should stop (a terminal step, or `--once`'s single-round
|
|
857
|
+
* contract), true to go on to another round. Shared by drive()'s plain
|
|
858
|
+
* loop and driveAsync()'s interruptible one so the two never drift
|
|
859
|
+
* apart. */
|
|
860
|
+
function driveOneRound(ctx, options, steps, emitPhaseProgress) {
|
|
861
|
+
// One round-cache scope for the WHOLE round, not just driveConcurrentRound's
|
|
862
|
+
// own batch: the width check and (for a serial round) driveStep/
|
|
863
|
+
// processSelectedTask's several loadRun(ctx) calls otherwise each
|
|
864
|
+
// re-read+re-parse state.json even though nothing on disk changed
|
|
865
|
+
// between them — reads that only reflect the mutations THIS round's
|
|
866
|
+
// own steps make, which the shared in-memory object already carries.
|
|
867
|
+
const roundSteps = withRoundCache(ctx, () => {
|
|
819
868
|
const width = (0, drive_decide_1.roundWidth)(loadRun(ctx), options.concurrency);
|
|
820
869
|
// width>1 (an explicit --concurrency>1, or an auto-width parallel
|
|
821
870
|
// 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
|
-
|
|
871
|
+
// more steps recorded in deterministic batch order, one flush at round
|
|
872
|
+
// end. `--once` still stops after this ONE round even though a round
|
|
873
|
+
// can yield multiple steps.
|
|
874
|
+
return width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
|
|
875
|
+
});
|
|
876
|
+
for (const stepResult of roundSteps)
|
|
877
|
+
steps.push(stepResult);
|
|
878
|
+
// Brew-style phase boundaries: after each round, announce a newly-active
|
|
879
|
+
// phase and any phase that just finished. Cheap — reuses the run we just
|
|
880
|
+
// advanced; goes to stderr so stdout stays clean.
|
|
881
|
+
emitPhaseProgress(loadRun(ctx));
|
|
882
|
+
const last = roundSteps[roundSteps.length - 1];
|
|
883
|
+
if (options.once)
|
|
884
|
+
return false;
|
|
885
|
+
if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked"))
|
|
886
|
+
return false;
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
/** One JS EventEmitter listener per SIGINT/SIGTERM, installed for the span
|
|
890
|
+
* of one drive()/driveAsync() call and removed by that caller's own
|
|
891
|
+
* `finally` (this function recurses for nested sub-workflow runs, and
|
|
892
|
+
* many tests call it repeatedly in one process, so nothing must
|
|
893
|
+
* accumulate on `process`'s listener list across calls). The FIRST
|
|
894
|
+
* signal sets a flag the round loop checks at the top of its next
|
|
895
|
+
* iteration -- never mid-round -- so the in-flight round always
|
|
896
|
+
* finishes its own bookkeeping first. A SECOND signal means the caller
|
|
897
|
+
* wants out right now, not a graceful stop, and force-exits immediately
|
|
898
|
+
* with the conventional 128+signum code. */
|
|
899
|
+
function createStopSignalController() {
|
|
900
|
+
let interruptedBy;
|
|
901
|
+
let stopSignalHits = 0;
|
|
902
|
+
const onStopSignal = (signal) => {
|
|
903
|
+
stopSignalHits += 1;
|
|
904
|
+
if (stopSignalHits === 1) {
|
|
905
|
+
interruptedBy = signal;
|
|
906
|
+
emitProgress(`received ${signal} — stopping after the current step (run again with the same run id to resume)`);
|
|
907
|
+
return;
|
|
840
908
|
}
|
|
841
|
-
|
|
909
|
+
// A second signal means the caller wants out right now, not a graceful stop.
|
|
910
|
+
emitProgress(`received a second ${signal} — exiting immediately`);
|
|
911
|
+
process.exit(DRIVE_STOP_EXIT_CODE[signal] ?? 1);
|
|
912
|
+
};
|
|
913
|
+
return {
|
|
914
|
+
install: () => {
|
|
915
|
+
for (const signal of DRIVE_STOP_SIGNALS)
|
|
916
|
+
process.on(signal, onStopSignal);
|
|
917
|
+
},
|
|
918
|
+
remove: () => {
|
|
919
|
+
for (const signal of DRIVE_STOP_SIGNALS)
|
|
920
|
+
process.off(signal, onStopSignal);
|
|
921
|
+
},
|
|
922
|
+
getInterruptedBy: () => interruptedBy,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
/** Forces one real turn of the Node.js event loop. `setImmediate` (a
|
|
926
|
+
* genuine macrotask, unlike a bare Promise microtask, which never leaves
|
|
927
|
+
* the current turn) is the only thing that actually lets libuv's poll
|
|
928
|
+
* phase run and dispatch a queued SIGINT/SIGTERM to its JS listener --
|
|
929
|
+
* confirmed live: a real external `kill -INT` sent to a busy synchronous
|
|
930
|
+
* process is dropped for as long as the process never yields, and fires
|
|
931
|
+
* within ~1ms of the very next event-loop turn once it does. */
|
|
932
|
+
function yieldToEventLoop() {
|
|
933
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
934
|
+
}
|
|
935
|
+
/** Everything drive()/driveAsync() do once their round loop has stopped:
|
|
936
|
+
* fold the interrupted/exhausted-iterations signal into the right
|
|
937
|
+
* "blocked" step, then assemble the DriveResult. Shared so the two
|
|
938
|
+
* loops can never report status differently for the same outcome. */
|
|
939
|
+
function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterationsAtLoopExit, interruptedBy) {
|
|
940
|
+
let exhaustedMaxIterations = exhaustedMaxIterationsAtLoopExit;
|
|
842
941
|
const run = loadRun(ctx);
|
|
843
942
|
const completedWorkers = (0, drive_decide_1.countCompleted)(run);
|
|
844
943
|
const parkedWorkers = (0, drive_decide_1.countParked)(run);
|
|
845
944
|
const committed = (0, drive_decide_1.hasTerminalCommit)(run);
|
|
846
945
|
const last = steps[steps.length - 1];
|
|
847
|
-
|
|
848
|
-
|
|
946
|
+
// A signal can land on the very last step (the terminal commit itself) --
|
|
947
|
+
// the run is already fully done, same "complete" check the `once` branch
|
|
948
|
+
// of finalDriveStatus below already uses. Reporting "blocked" here anyway
|
|
949
|
+
// would be a real user-visible lie (e.g. pipeline-cli.ts's `--bundle` gate
|
|
950
|
+
// reads status, so a genuinely finished run would wrongly skip sealing).
|
|
951
|
+
const alreadyComplete = completedWorkers === plannedWorkers && committed;
|
|
952
|
+
if (interruptedBy) {
|
|
953
|
+
exhaustedMaxIterations = false;
|
|
954
|
+
if (!alreadyComplete) {
|
|
955
|
+
steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive interrupted by ${interruptedBy} — run again with the same run id to resume` }));
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
else if (exhaustedMaxIterations) {
|
|
959
|
+
steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive reached max iteration limit (${maxIter}) before a terminal state` }));
|
|
849
960
|
}
|
|
850
961
|
const statusInputs = {
|
|
851
962
|
once: Boolean(options.once),
|
|
@@ -862,7 +973,7 @@ function drive(runId, cwd, options = {}) {
|
|
|
862
973
|
const committedCommit = (run.commits || []).find((c) => c.reason && c.reason.startsWith("agent-delegation-drive"));
|
|
863
974
|
return {
|
|
864
975
|
schemaVersion: 1,
|
|
865
|
-
runId,
|
|
976
|
+
runId: ctx.runId,
|
|
866
977
|
workflowId: run.workflow.id,
|
|
867
978
|
status,
|
|
868
979
|
steps,
|
|
@@ -872,9 +983,92 @@ function drive(runId, cwd, options = {}) {
|
|
|
872
983
|
commitId: committedCommit?.id,
|
|
873
984
|
reportPath: run.paths.report,
|
|
874
985
|
statePath: run.paths.state,
|
|
875
|
-
agentConfigured: agentConfigured(config),
|
|
986
|
+
agentConfigured: agentConfigured(ctx.config),
|
|
876
987
|
};
|
|
877
988
|
}
|
|
989
|
+
/** Drive a run: `--once` advances exactly one step; otherwise run to
|
|
990
|
+
* completion, park, or a blocked stop. Fully synchronous, byte-identical
|
|
991
|
+
* to every prior release -- see the file comment above DRIVE_STOP_SIGNALS
|
|
992
|
+
* for why a real SIGINT/SIGTERM sent during a live multi-round call is
|
|
993
|
+
* queued but never actually reaches onStopSignal below; that limitation
|
|
994
|
+
* is deliberately kept here for backward compatibility (recursive
|
|
995
|
+
* sub-workflow runs, and every existing caller/test that depends on this
|
|
996
|
+
* exact synchronous shape) and fixed only in driveAsync(). */
|
|
997
|
+
function drive(runId, cwd, options = {}) {
|
|
998
|
+
const ctx = buildDriveContext(runId, cwd, options);
|
|
999
|
+
const steps = [];
|
|
1000
|
+
const run0 = loadRun(ctx);
|
|
1001
|
+
const plannedWorkers = run0.tasks.length;
|
|
1002
|
+
const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
|
|
1003
|
+
const emitPhaseProgress = createPhaseProgressEmitter();
|
|
1004
|
+
let exhaustedMaxIterations = !options.once;
|
|
1005
|
+
const stopSignal = createStopSignalController();
|
|
1006
|
+
stopSignal.install();
|
|
1007
|
+
try {
|
|
1008
|
+
for (let i = 0; i < maxIter; i++) {
|
|
1009
|
+
if (stopSignal.getInterruptedBy())
|
|
1010
|
+
break;
|
|
1011
|
+
if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
|
|
1012
|
+
exhaustedMaxIterations = false;
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
finally {
|
|
1018
|
+
stopSignal.remove();
|
|
1019
|
+
}
|
|
1020
|
+
return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
|
|
1021
|
+
}
|
|
1022
|
+
/** Same contract and same DriveResult as drive() -- but actually
|
|
1023
|
+
* interruptible. After every round it awaits yieldToEventLoop(), a real
|
|
1024
|
+
* setImmediate-based turn of the event loop, giving Node.js an actual
|
|
1025
|
+
* chance to dispatch a queued SIGINT/SIGTERM to onStopSignal before the
|
|
1026
|
+
* next round's synchronous spawnSync work begins; the loop then sees
|
|
1027
|
+
* the interrupted flag at the top of its next iteration exactly like
|
|
1028
|
+
* drive() already checks it. Shares driveOneRound/createStopSignalController/
|
|
1029
|
+
* finalizeDriveResult with drive(), so the two can only ever differ in
|
|
1030
|
+
* this one respect. Intended for the live, potentially-long-running
|
|
1031
|
+
* entry points (CLI `--drive`, the MCP run.drive.step tool) where a
|
|
1032
|
+
* user's Ctrl-C or a supervisor's SIGTERM must actually be able to stop
|
|
1033
|
+
* the run; internal/recursive/test callers that don't need this keep
|
|
1034
|
+
* calling the plain drive() above, unaffected.
|
|
1035
|
+
*
|
|
1036
|
+
* A signal arriving while a NESTED sub-workflow's own drive() call
|
|
1037
|
+
* (runSubWorkflow, below) is running is still not caught until that
|
|
1038
|
+
* nested call finishes on its own -- an accepted, documented limitation:
|
|
1039
|
+
* the same "cannot interrupt an already in-flight blocking operation,
|
|
1040
|
+
* only checked at explicit boundaries" shape as the pre-existing
|
|
1041
|
+
* cannot-interrupt-mid-spawnSync limit, just one level of recursion
|
|
1042
|
+
* deeper. Fixing that would require threading interruptibility through
|
|
1043
|
+
* the entire recursive call graph (driveStep/processSelectedTask/
|
|
1044
|
+
* runSubWorkflow/handleHop), a materially larger change left for a
|
|
1045
|
+
* future cycle if it proves needed in practice. */
|
|
1046
|
+
async function driveAsync(runId, cwd, options = {}) {
|
|
1047
|
+
const ctx = buildDriveContext(runId, cwd, options);
|
|
1048
|
+
const steps = [];
|
|
1049
|
+
const run0 = loadRun(ctx);
|
|
1050
|
+
const plannedWorkers = run0.tasks.length;
|
|
1051
|
+
const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
|
|
1052
|
+
const emitPhaseProgress = createPhaseProgressEmitter();
|
|
1053
|
+
let exhaustedMaxIterations = !options.once;
|
|
1054
|
+
const stopSignal = createStopSignalController();
|
|
1055
|
+
stopSignal.install();
|
|
1056
|
+
try {
|
|
1057
|
+
for (let i = 0; i < maxIter; i++) {
|
|
1058
|
+
if (stopSignal.getInterruptedBy())
|
|
1059
|
+
break;
|
|
1060
|
+
if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
|
|
1061
|
+
exhaustedMaxIterations = false;
|
|
1062
|
+
break;
|
|
1063
|
+
}
|
|
1064
|
+
await yieldToEventLoop();
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
finally {
|
|
1068
|
+
stopSignal.remove();
|
|
1069
|
+
}
|
|
1070
|
+
return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
|
|
1071
|
+
}
|
|
878
1072
|
function drivePreview(runId, cwd, args = {}) {
|
|
879
1073
|
const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
|
|
880
1074
|
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 } });
|
|
@@ -408,15 +408,34 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
|
|
|
408
408
|
const built = buildAgentChildEnv(policy);
|
|
409
409
|
const childEnv = built.env;
|
|
410
410
|
forwardedEnvVars = built.forwarded;
|
|
411
|
+
const timeoutMs = resolved.timeoutMs || 600000;
|
|
411
412
|
const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
|
|
412
413
|
cwd: request.cwd,
|
|
413
414
|
env: childEnv,
|
|
414
415
|
encoding: "utf8",
|
|
415
|
-
timeout:
|
|
416
|
+
timeout: timeoutMs,
|
|
416
417
|
maxBuffer: 32 * 1024 * 1024,
|
|
417
418
|
shell: false,
|
|
419
|
+
// SIGKILL, not the default SIGTERM: an agent wrapper that ignores
|
|
420
|
+
// SIGTERM would leave this blocking spawnSync waiting forever (no
|
|
421
|
+
// second-stage escalation is possible from inside a sync call), and
|
|
422
|
+
// a timed-out agent's output is discarded by the refusal below
|
|
423
|
+
// anyway — so a hard kill loses nothing and cannot wedge cw.
|
|
424
|
+
killSignal: "SIGKILL",
|
|
418
425
|
stdio: ["ignore", "pipe", streamStderr ? "inherit" : "pipe"],
|
|
419
426
|
});
|
|
427
|
+
// A timeout surfaces as child.error with code ETIMEDOUT (status null,
|
|
428
|
+
// signal set). Classify it FIRST: without this, the generic spawnError
|
|
429
|
+
// branch below reported "agent process failed to spawn: ...ETIMEDOUT"
|
|
430
|
+
// and the null-exit-code "timed out or killed" branch was dead code
|
|
431
|
+
// for real timeouts.
|
|
432
|
+
if (child.error && child.error.code === "ETIMEDOUT") {
|
|
433
|
+
const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported", undefined, undefined, forwardedEnvVars);
|
|
434
|
+
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent process timed out after ${timeoutMs}ms and was killed (SIGKILL)`, {
|
|
435
|
+
...attestation,
|
|
436
|
+
handle: handleOut,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
420
439
|
outcome = {
|
|
421
440
|
...(child.error ? { spawnError: messageOf(child.error) } : {}),
|
|
422
441
|
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
|
}
|
|
@@ -65,7 +65,10 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
|
|
|
65
65
|
cwd: request.cwd,
|
|
66
66
|
env,
|
|
67
67
|
encoding: "utf8",
|
|
68
|
-
timeout
|
|
68
|
+
// An unset timeoutMs must not mean "no timeout" — spawnSync would then
|
|
69
|
+
// block forever on a hung child with no kill path. 600000 matches the
|
|
70
|
+
// agent backend's own default fallback (execution-backend/agent.ts).
|
|
71
|
+
timeout: request.timeoutMs || 600000,
|
|
69
72
|
maxBuffer: 32 * 1024 * 1024,
|
|
70
73
|
};
|
|
71
74
|
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
|
}
|