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
|
@@ -207,10 +207,25 @@ function clampInt(value, fallback, min) {
|
|
|
207
207
|
return Math.max(min, Math.floor(n));
|
|
208
208
|
}
|
|
209
209
|
let queueCounter = 0;
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
/** Byte format is `q-${stamp14}-${NNN}` (SPEC/scheduling-registry.md "Id
|
|
211
|
+
* formats"), unchanged. `queueCounter` alone is not enough to keep ids
|
|
212
|
+
* unique: it resets to 0 for every fresh `cw` process, so two SEPARATE
|
|
213
|
+
* processes calling `queue add` within the same second both compute
|
|
214
|
+
* `queueCounter === 1` and mint the IDENTICAL id — `sched lease` then
|
|
215
|
+
* keys grants by id, so both queue entries get treated as one, breaking
|
|
216
|
+
* through `maxConcurrent`. `existingIds` is the current queue file's ids
|
|
217
|
+
* (read inside queueAdd's own file lock, so this sees every entry any
|
|
218
|
+
* earlier-completed `queue add` — same process or not — has already
|
|
219
|
+
* saved); bumping past any collision against that real, shared state
|
|
220
|
+
* keeps ids unique across processes without changing the format. */
|
|
221
|
+
function queueId(existingIds) {
|
|
212
222
|
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
213
|
-
|
|
223
|
+
let candidate;
|
|
224
|
+
do {
|
|
225
|
+
queueCounter += 1;
|
|
226
|
+
candidate = `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
|
|
227
|
+
} while (existingIds && existingIds.has(candidate));
|
|
228
|
+
return candidate;
|
|
214
229
|
}
|
|
215
230
|
function isRunLifecycleState(value) {
|
|
216
231
|
return (typeof value === "string" &&
|
|
@@ -397,7 +412,7 @@ class RunRegistry {
|
|
|
397
412
|
const entries = this.loadQueueEntries();
|
|
398
413
|
const entry = {
|
|
399
414
|
schemaVersion: 1,
|
|
400
|
-
id: options.id || queueId(),
|
|
415
|
+
id: options.id || queueId(new Set(entries.map((e) => e.id))),
|
|
401
416
|
runId: options.runId,
|
|
402
417
|
appId: options.appId,
|
|
403
418
|
workflowId: options.workflowId,
|
|
@@ -462,6 +477,13 @@ class RunRegistry {
|
|
|
462
477
|
const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
463
478
|
if (result.report.status === "unsupported")
|
|
464
479
|
return null;
|
|
480
|
+
// Bulk scans (refreshRegistry, below) call deriveRecord directly and
|
|
481
|
+
// tolerate a null the same way as missing/unsupported — one bad run
|
|
482
|
+
// must not stop a listing of everything else. Single-run lookups
|
|
483
|
+
// (deriveRecordForRun, below) re-check this same condition WITHOUT
|
|
484
|
+
// swallowing it, so `cw run show`/`archive`/`rerun` on THIS run
|
|
485
|
+
// specifically get a clear reason instead of a misleading "not found."
|
|
486
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(path.basename(runDir), result);
|
|
465
487
|
run = result.run;
|
|
466
488
|
}
|
|
467
489
|
catch {
|
|
@@ -609,7 +631,7 @@ class RunRegistry {
|
|
|
609
631
|
if (staleRuns.length || missingRuns.length)
|
|
610
632
|
status = "stale";
|
|
611
633
|
}
|
|
612
|
-
const refreshCmd = scope === "home" ? "
|
|
634
|
+
const refreshCmd = scope === "home" ? "cw registry refresh --scope home" : "cw registry refresh";
|
|
613
635
|
return {
|
|
614
636
|
schemaVersion: 1,
|
|
615
637
|
scope,
|
|
@@ -624,7 +646,7 @@ class RunRegistry {
|
|
|
624
646
|
},
|
|
625
647
|
index: current,
|
|
626
648
|
counts: current.counts,
|
|
627
|
-
nextAction: status === "valid" ? "
|
|
649
|
+
nextAction: status === "valid" ? "cw run search" : refreshCmd,
|
|
628
650
|
};
|
|
629
651
|
}
|
|
630
652
|
search(raw = {}) {
|
|
@@ -657,7 +679,7 @@ class RunRegistry {
|
|
|
657
679
|
offset: query.offset,
|
|
658
680
|
limit: query.limit,
|
|
659
681
|
records: page,
|
|
660
|
-
nextAction: report.freshness.status === "valid" ? "
|
|
682
|
+
nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh",
|
|
661
683
|
};
|
|
662
684
|
}
|
|
663
685
|
list(options = {}) {
|
|
@@ -680,7 +702,7 @@ class RunRegistry {
|
|
|
680
702
|
resolvedFrom: located.from,
|
|
681
703
|
repo: located.record.repo,
|
|
682
704
|
record: located.record,
|
|
683
|
-
nextAction: located.record.archived ? "
|
|
705
|
+
nextAction: located.record.archived ? "cw run resume " + runId : "cw run show " + runId,
|
|
684
706
|
};
|
|
685
707
|
}
|
|
686
708
|
const persisted = this.findPersisted(runId, scope);
|
|
@@ -691,10 +713,11 @@ class RunRegistry {
|
|
|
691
713
|
freshness: "missing",
|
|
692
714
|
repo: persisted?.repo,
|
|
693
715
|
persisted,
|
|
694
|
-
nextAction: "
|
|
716
|
+
nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
|
|
695
717
|
};
|
|
696
718
|
}
|
|
697
719
|
locate(runId, scope) {
|
|
720
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
698
721
|
const here = this.deriveRecordForRun(this.repoRoot, runId);
|
|
699
722
|
if (here)
|
|
700
723
|
return { record: here, from: "repo" };
|
|
@@ -711,9 +734,35 @@ class RunRegistry {
|
|
|
711
734
|
}
|
|
712
735
|
deriveRecordForRun(repo, runId) {
|
|
713
736
|
const runDir = path.join(this.repoRunsDir(repo), runId);
|
|
714
|
-
|
|
737
|
+
const statePath = path.join(runDir, "state.json");
|
|
738
|
+
if (!fs.existsSync(statePath))
|
|
715
739
|
return null;
|
|
716
|
-
|
|
740
|
+
const record = this.deriveRecord(repo, runDir);
|
|
741
|
+
if (record)
|
|
742
|
+
return record;
|
|
743
|
+
// deriveRecord returned null even though state.json existed a moment
|
|
744
|
+
// ago: it failed to parse, was flagged unsupported, tripped
|
|
745
|
+
// suspected-data-loss corroboration, or (a narrow race) a concurrent
|
|
746
|
+
// process deleted it in between. Re-derive without swallowing so a
|
|
747
|
+
// genuine corruption reaches THIS single-run lookup (backing `cw run
|
|
748
|
+
// show`/`archive`/`rerun`) instead of the misleading "not found" a bulk
|
|
749
|
+
// scan is content with — but if the file is ACTUALLY gone by the time
|
|
750
|
+
// we check again, that is genuinely missing, not corrupt, and must
|
|
751
|
+
// resolve to null like everywhere else, never a raw "File not found"
|
|
752
|
+
// exception surfacing from this fallback re-read.
|
|
753
|
+
try {
|
|
754
|
+
const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
|
|
755
|
+
if (result.report.status === "unsupported") {
|
|
756
|
+
throw new Error(`Run state for ${runId} is corrupt (fail closed): ${result.report.errors.join("; ") || "unsupported run state"}. Restore ${statePath} from a backup, or remove the run directory to start over.`);
|
|
757
|
+
}
|
|
758
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
|
|
759
|
+
return record;
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
if (!fs.existsSync(statePath))
|
|
763
|
+
return null;
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
717
766
|
}
|
|
718
767
|
findPersisted(runId, scope) {
|
|
719
768
|
for (const s of scope === "home" ? ["home", "repo"] : ["repo"]) {
|
|
@@ -725,6 +774,7 @@ class RunRegistry {
|
|
|
725
774
|
return undefined;
|
|
726
775
|
}
|
|
727
776
|
loadRun(repo, runId) {
|
|
777
|
+
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
728
778
|
const statePath = path.join(this.repoRunsDir(repo), runId, "state.json");
|
|
729
779
|
if (!fs.existsSync(statePath))
|
|
730
780
|
throw new Error(`Run not found: ${runId}`);
|
|
@@ -732,6 +782,7 @@ class RunRegistry {
|
|
|
732
782
|
if (result.report.status === "unsupported") {
|
|
733
783
|
throw new Error(`Unsupported run state for ${runId}: ${result.report.errors.join("; ")}`);
|
|
734
784
|
}
|
|
785
|
+
(0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
|
|
735
786
|
return result.run;
|
|
736
787
|
}
|
|
737
788
|
resume(runId, options = {}) {
|
|
@@ -752,23 +803,23 @@ class RunRegistry {
|
|
|
752
803
|
const nextActions = [];
|
|
753
804
|
if (nextTasks.length) {
|
|
754
805
|
nextActions.push({
|
|
755
|
-
command: `
|
|
806
|
+
command: `cw dispatch ${runId} --cwd ${record.repo}`,
|
|
756
807
|
reason: `Continue ${nextTasks.length} pending/running task(s) from durable state.`,
|
|
757
808
|
});
|
|
758
809
|
nextActions.push({
|
|
759
|
-
command: `
|
|
810
|
+
command: `cw multi-agent step ${runId} --cwd ${record.repo}`,
|
|
760
811
|
reason: "Take one deterministic host step without spawning agents.",
|
|
761
812
|
});
|
|
762
813
|
}
|
|
763
814
|
else if (record.derivedLifecycle === "failed") {
|
|
764
815
|
nextActions.push({
|
|
765
|
-
command: `
|
|
816
|
+
command: `cw run rerun ${runId}`,
|
|
766
817
|
reason: "Run terminated as failed with no runnable tasks; rerun as a new linked run.",
|
|
767
818
|
});
|
|
768
819
|
}
|
|
769
820
|
else {
|
|
770
821
|
nextActions.push({
|
|
771
|
-
command: `
|
|
822
|
+
command: `cw status ${runId} --cwd ${record.repo} --json`,
|
|
772
823
|
reason: "No runnable tasks remain; inspect status.",
|
|
773
824
|
});
|
|
774
825
|
}
|
|
@@ -807,7 +858,14 @@ class RunRegistry {
|
|
|
807
858
|
}
|
|
808
859
|
(0, fs_atomic_1.writeJson)(file, overlay, { durable: true });
|
|
809
860
|
});
|
|
810
|
-
|
|
861
|
+
// deriveRecordForRun (not a bare deriveRecord(...)!) so a run that
|
|
862
|
+
// became corrupt/unsupported/suspected-data-loss in the narrow window
|
|
863
|
+
// between locate() above and this re-fetch gets the same clear,
|
|
864
|
+
// specific error every other single-run lookup gets, instead of a raw
|
|
865
|
+
// TypeError from a non-null assertion on a surprise null.
|
|
866
|
+
const record = this.deriveRecordForRun(repo, runId);
|
|
867
|
+
if (!record)
|
|
868
|
+
throw new Error(`Cannot archive: run ${runId} state became unavailable while archiving (fail closed).`);
|
|
811
869
|
return {
|
|
812
870
|
runId,
|
|
813
871
|
repo,
|
|
@@ -877,8 +935,8 @@ class RunRegistry {
|
|
|
877
935
|
pendingTasks: newRun.tasks.filter((t) => t.status === "pending").length,
|
|
878
936
|
provenance,
|
|
879
937
|
nextActions: [
|
|
880
|
-
{ command: `
|
|
881
|
-
{ command: `
|
|
938
|
+
{ command: `cw run resume ${newRun.id}`, reason: "Continue the new linked run." },
|
|
939
|
+
{ command: `cw run show ${runId}`, reason: "The original failed run is preserved for audit." },
|
|
882
940
|
],
|
|
883
941
|
};
|
|
884
942
|
}
|
|
@@ -918,7 +976,7 @@ class RunRegistry {
|
|
|
918
976
|
limit,
|
|
919
977
|
repos: index.repos,
|
|
920
978
|
entries,
|
|
921
|
-
nextAction: report.freshness.status === "valid" ? "
|
|
979
|
+
nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh --scope home",
|
|
922
980
|
};
|
|
923
981
|
}
|
|
924
982
|
}
|
package/dist/shell/run-store.js
CHANGED
|
@@ -49,6 +49,7 @@ exports.hashArtifactFile = hashArtifactFile;
|
|
|
49
49
|
exports.loadRunStateFile = loadRunStateFile;
|
|
50
50
|
exports.checkRunStateFile = checkRunStateFile;
|
|
51
51
|
exports.migrateRunStateFile = migrateRunStateFile;
|
|
52
|
+
exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
|
|
52
53
|
exports.loadRunFromCwd = loadRunFromCwd;
|
|
53
54
|
exports.withRunStateLock = withRunStateLock;
|
|
54
55
|
exports.saveCheckpoint = saveCheckpoint;
|
|
@@ -98,19 +99,90 @@ function migrateRunStateFile(statePath, options = {}) {
|
|
|
98
99
|
}
|
|
99
100
|
return result;
|
|
100
101
|
}
|
|
102
|
+
/** ONLY tasksDir/commitsDir (plus the audit event log, checked separately
|
|
103
|
+
* below) — deliberately NOT every directory a run's `paths` can carry.
|
|
104
|
+
* Verified empirically (via test/run-fixture-compat-smoke.js's real
|
|
105
|
+
* fixtures) that several other candidate directories are unsafe signals:
|
|
106
|
+
* a plain `cw status`/`cw graph` READ already writes cache/derived files
|
|
107
|
+
* as a side effect — `audit/summary.json` + `audit/index.json`
|
|
108
|
+
* (summarizeTrustAudit, called from the status/report path) are non-empty
|
|
109
|
+
* the moment ANY read touches the run, and `blackboard/`/`candidates/`/
|
|
110
|
+
* `topologies/` subdirectories get created (though left empty) the same
|
|
111
|
+
* way. Using those as "real content" signals would refuse to load a
|
|
112
|
+
* perfectly healthy, merely-already-viewed-once run — report.md is the
|
|
113
|
+
* same story (a derived rendering of state, not independent evidence).
|
|
114
|
+
* tasksDir/commitsDir are the two directories confirmed to hold content
|
|
115
|
+
* ONLY from genuine task-dispatch/commit actions, never from an
|
|
116
|
+
* otherwise-read-only command. */
|
|
117
|
+
function contentDirs(paths) {
|
|
118
|
+
return [paths.tasksDir, paths.commitsDir];
|
|
119
|
+
}
|
|
120
|
+
/** True when the run directory already holds real task/commit files, or a
|
|
121
|
+
* non-empty audit event log — i.e. this was NOT a brand-new run dir. Used
|
|
122
|
+
* only to corroborate `report.suspectedDataLoss`: a bare
|
|
123
|
+
* `{workflow, paths}`-less state.json is unremarkable for a run dir that
|
|
124
|
+
* has nothing else in it either (e.g. a run whose creation crashed before
|
|
125
|
+
* anything else was written), but is a strong corruption signal when real
|
|
126
|
+
* content already sits next to it. A directory entry must not START WITH
|
|
127
|
+
* "." to count — cw never writes dot-prefixed names into these
|
|
128
|
+
* directories, so incidental filesystem debris (a stray `.DS_Store`) never
|
|
129
|
+
* by itself makes a genuinely fresh run look corrupted. */
|
|
130
|
+
function hasPreexistingRunContent(run) {
|
|
131
|
+
for (const dir of contentDirs(run.paths)) {
|
|
132
|
+
try {
|
|
133
|
+
if (fs.readdirSync(dir).some((name) => !name.startsWith(".")))
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* missing dir — not a signal either way */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const eventLogPath = run.audit?.eventLogPath;
|
|
142
|
+
if (eventLogPath && fs.statSync(eventLogPath).size > 0)
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* missing/empty audit log — not a signal */
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
/** Throws when `result.report.suspectedDataLoss` is true AND the run
|
|
151
|
+
* directory already has real content on disk — see
|
|
152
|
+
* `hasPreexistingRunContent`. Shared by every state.json reader
|
|
153
|
+
* (`loadRunFromCwd` here, `RunRegistry.loadRun` in run-registry-io.ts) so
|
|
154
|
+
* a corrupted/wiped state.json is refused the same way regardless of
|
|
155
|
+
* which entry point reached it. */
|
|
156
|
+
function assertNotSuspectedDataLoss(runId, result) {
|
|
157
|
+
if (result.report.suspectedDataLoss && hasPreexistingRunContent(result.run)) {
|
|
158
|
+
throw new Error(`Refusing to load run ${runId}: state.json is missing its core fields (workflow, paths), but the run directory already has task, commit, or other content on disk. This looks like state.json was corrupted, truncated, or replaced by something outside cw, not a new run. Restore state.json from a backup, or remove the run directory to start over.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
101
161
|
/** Refuses an empty id with `Missing run id`; loads
|
|
102
162
|
* `<cwd>/.cw/runs/<runId>/state.json` (dry-run — never writes); throws
|
|
103
|
-
* `
|
|
104
|
-
*
|
|
163
|
+
* `Run not found: <runId>` when no such run directory has a state.json
|
|
164
|
+
* (every caller — report, status, audit, eval snapshot, and more — used
|
|
165
|
+
* to leak the raw `readJson` message, "File not found: <absolute
|
|
166
|
+
* path>/state.json", which named neither the run nor a next step, and
|
|
167
|
+
* didn't contain any of the words `cli/entry.ts`'s `recoveryHint` scans
|
|
168
|
+
* for, so no `Try: cw run list` line ever appeared); throws `Unsupported
|
|
169
|
+
* CW run state: <errors joined by "; ">` on an unsupported verdict;
|
|
170
|
+
* refuses a state.json that lost its core fields (workflow, paths) while
|
|
171
|
+
* the run dir still has real content next to it, rather than silently
|
|
172
|
+
* returning it as a fresh empty run; else returns the migrated
|
|
173
|
+
* WorkflowRun in memory. */
|
|
105
174
|
function loadRunFromCwd(runId, cwd = process.cwd()) {
|
|
106
175
|
if (!runId)
|
|
107
176
|
throw new Error("Missing run id");
|
|
108
177
|
(0, fs_atomic_1.assertSafeRunId)(runId);
|
|
109
178
|
const statePath = path.join(cwd, ".cw", "runs", runId, "state.json");
|
|
179
|
+
if (!fs.existsSync(statePath))
|
|
180
|
+
throw new Error(`Run not found: ${runId}`);
|
|
110
181
|
const result = loadRunStateFile(statePath, { dryRun: true });
|
|
111
182
|
if (result.report.status === "unsupported") {
|
|
112
183
|
throw new Error(`Unsupported CW run state: ${result.report.errors.join("; ")}`);
|
|
113
184
|
}
|
|
185
|
+
assertNotSuspectedDataLoss(runId, result);
|
|
114
186
|
return result.run;
|
|
115
187
|
}
|
|
116
188
|
/** Hold the state.json lock over a WHOLE load -> change -> save cycle.
|
|
@@ -67,6 +67,7 @@ const fs = __importStar(require("node:fs"));
|
|
|
67
67
|
const path = __importStar(require("node:path"));
|
|
68
68
|
const fs_atomic_1 = require("./fs-atomic");
|
|
69
69
|
const run_registry_io_1 = require("./run-registry-io");
|
|
70
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
70
71
|
exports.SCHEDULING_SCHEMA_VERSION = 1;
|
|
71
72
|
/** Conservative fail-closed defaults: serial, bounded retries, exponential backoff. */
|
|
72
73
|
exports.DEFAULT_SCHEDULING_POLICY = {
|
|
@@ -232,14 +233,7 @@ function loadSchedulingPolicy(registry) {
|
|
|
232
233
|
return { schemaVersion: 1, policy: normalizeSchedulingPolicy(parsed), source: "file" };
|
|
233
234
|
}
|
|
234
235
|
function numericFlag(options, key) {
|
|
235
|
-
|
|
236
|
-
return undefined;
|
|
237
|
-
const raw = options[key];
|
|
238
|
-
const n = Number(raw);
|
|
239
|
-
if (!Number.isFinite(n)) {
|
|
240
|
-
throw new Error(`Invalid --${key} "${raw}": expected a number (e.g. --${key} 4)`);
|
|
241
|
-
}
|
|
242
|
-
return n;
|
|
236
|
+
return (0, numeric_flag_1.requiredNumberFlag)(options[key], `--${key}`);
|
|
243
237
|
}
|
|
244
238
|
function schedPlanCli(options = {}) {
|
|
245
239
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
@@ -250,7 +244,7 @@ function schedLeaseCli(options = {}) {
|
|
|
250
244
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
251
245
|
const now = nowIso(options);
|
|
252
246
|
const policy = loadSchedulingPolicy(registry).policy;
|
|
253
|
-
const limit =
|
|
247
|
+
const limit = (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit");
|
|
254
248
|
return (0, fs_atomic_1.withFileLock)(registry.queueFilePath(), () => {
|
|
255
249
|
const { entries, leases } = applyLease(registry.loadQueueEntries(), policy, now, limit);
|
|
256
250
|
registry.saveQueueEntries(entries);
|
|
@@ -305,16 +299,21 @@ function schedPolicyShowCli(options = {}) {
|
|
|
305
299
|
}
|
|
306
300
|
function schedPolicySetCli(options = {}) {
|
|
307
301
|
const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
|
|
308
|
-
const current = loadSchedulingPolicy(registry).policy;
|
|
309
302
|
const patch = {};
|
|
310
303
|
for (const key of ["maxConcurrent", "maxAttempts", "leaseTtlMs", "backoffBaseMs", "backoffFactor", "backoffCapMs"]) {
|
|
311
304
|
const value = numericFlag(options, key);
|
|
312
305
|
if (value !== undefined)
|
|
313
306
|
patch[key] = value;
|
|
314
307
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
308
|
+
// Read-modify-write under the policy file's own lock: two concurrent
|
|
309
|
+
// `sched policy set` calls patching different fields must not drop each
|
|
310
|
+
// other's write.
|
|
311
|
+
return (0, fs_atomic_1.withFileLock)(registry.schedulingPolicyPath(), () => {
|
|
312
|
+
const current = loadSchedulingPolicy(registry).policy;
|
|
313
|
+
const policy = normalizeSchedulingPolicy({ ...current, ...patch });
|
|
314
|
+
(0, fs_atomic_1.writeJson)(registry.schedulingPolicyPath(), policy);
|
|
315
|
+
return { schemaVersion: 1, policy, source: "file" };
|
|
316
|
+
});
|
|
318
317
|
}
|
|
319
318
|
function isTrue(value) {
|
|
320
319
|
return value === true || value === "true" || value === "1";
|
package/dist/shell/state-cli.js
CHANGED
|
@@ -65,6 +65,7 @@ const node_store_1 = require("./node-store");
|
|
|
65
65
|
const contract_migration_1 = require("../core/state/contract-migration");
|
|
66
66
|
const node_snapshot_1 = require("../core/state/node-snapshot");
|
|
67
67
|
const dispatch_1 = require("../core/pipeline/dispatch");
|
|
68
|
+
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
68
69
|
function optionalStringArg(value) {
|
|
69
70
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
70
71
|
}
|
|
@@ -128,12 +129,6 @@ function loadRun(runId, options = {}) {
|
|
|
128
129
|
const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
|
|
129
130
|
return (0, run_store_1.loadRunFromCwd)(runId, cwd);
|
|
130
131
|
}
|
|
131
|
-
function numberOption(value) {
|
|
132
|
-
if (value === undefined || value === null || value === true)
|
|
133
|
-
return undefined;
|
|
134
|
-
const n = Number(value);
|
|
135
|
-
return Number.isFinite(n) ? n : undefined;
|
|
136
|
-
}
|
|
137
132
|
/** `cw next <run-id> [--limit N]` — the read-only "what would dispatch next"
|
|
138
133
|
* preview: the runnable tasks the next `cw dispatch` would pick, in order,
|
|
139
134
|
* without mutating state. A byte-behavior port of the old build's
|
|
@@ -141,7 +136,7 @@ function numberOption(value) {
|
|
|
141
136
|
* the cw_next MCP tool render this same DispatchTask[] value, so their
|
|
142
137
|
* payloads stay identical. */
|
|
143
138
|
function nextCli(runId, options = {}) {
|
|
144
|
-
return (0, dispatch_1.nextDispatchTasks)(loadRun(runId, options),
|
|
139
|
+
return (0, dispatch_1.nextDispatchTasks)(loadRun(runId, options), (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"));
|
|
145
140
|
}
|
|
146
141
|
function listNodes(runId, options = {}) {
|
|
147
142
|
return loadRun(runId, options).nodes || [];
|
|
@@ -134,7 +134,7 @@ function refreshStateExplosionSummaries(run, options = {}) {
|
|
|
134
134
|
generatedAt: now,
|
|
135
135
|
status: "valid",
|
|
136
136
|
deterministic: true,
|
|
137
|
-
nextAction: `
|
|
137
|
+
nextAction: `cw summary show ${run.id}`,
|
|
138
138
|
entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
|
|
139
139
|
views,
|
|
140
140
|
paths: { summariesDir: dir, indexPath: path.join(dir, "index.json"), reportPath },
|
|
@@ -184,15 +184,23 @@ function loadRun(runId, options = {}) {
|
|
|
184
184
|
* only (the run-state checkpoint write is real and load-bearing today;
|
|
185
185
|
* report.md rendering is not). */
|
|
186
186
|
function summaryRefreshCli(runId, options = {}) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
(
|
|
190
|
-
return
|
|
187
|
+
// Hold the state.json lock across the whole load -> refresh -> save so a
|
|
188
|
+
// concurrent run mutation cannot drop this summary refresh (lost-update class).
|
|
189
|
+
const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
|
|
190
|
+
return (0, run_store_1.withRunStateLock)(runId, cwd, (run) => {
|
|
191
|
+
const index = refreshStateExplosionSummaries(run);
|
|
192
|
+
(0, run_store_1.saveCheckpoint)(run);
|
|
193
|
+
return index;
|
|
194
|
+
});
|
|
191
195
|
}
|
|
192
|
-
/** `cw summary show <run-id> [--json]` —
|
|
196
|
+
/** `cw summary show <run-id> [--json]` — a plain read: `showStateExplosionSummary`
|
|
197
|
+
* only loads the persisted summary index and builds a report from it, it
|
|
198
|
+
* never mutates `run`, so there is nothing here for a checkpoint write to
|
|
199
|
+
* durably persist. No `saveCheckpoint` call (dropped one, see ITERATION_LOG
|
|
200
|
+
* — it used to durably rewrite the whole unchanged state.json on every
|
|
201
|
+
* `summary show`, only bumping `run.updatedAt` as a side effect of a
|
|
202
|
+
* command whose whole job is to look, not touch). */
|
|
193
203
|
function summaryShowCli(runId, options = {}) {
|
|
194
204
|
const run = loadRun(runId, options);
|
|
195
|
-
|
|
196
|
-
(0, run_store_1.saveCheckpoint)(run);
|
|
197
|
-
return report;
|
|
205
|
+
return showStateExplosionSummary(run);
|
|
198
206
|
}
|
|
@@ -71,6 +71,23 @@ function topologyRunPath(run, id) {
|
|
|
71
71
|
const safe = id.replace(/[^a-zA-Z0-9_.:-]+/g, "_");
|
|
72
72
|
return path.join(topologyRoot(run), "runs", `${safe}.json`);
|
|
73
73
|
}
|
|
74
|
+
// Dirty-id tracking for persistTopologyState, mirroring coordinator-io.ts's
|
|
75
|
+
// blackboard dirty tracking: without it, every persist call rewrote every
|
|
76
|
+
// topology run's OWN file (topologyRunPath), even ones untouched this call —
|
|
77
|
+
// O(N) writes per call, O(N^2) across N applyTopology calls. Kept off the
|
|
78
|
+
// serialized TopologyState itself (a WeakMap keyed by the state object) so it
|
|
79
|
+
// can never leak into state.json's bytes. applyTopology is the only place
|
|
80
|
+
// state.runs is pushed to or an existing record mutated, so it is the only
|
|
81
|
+
// call site that needs to mark a run id dirty.
|
|
82
|
+
const topologyDirtyIds = new WeakMap();
|
|
83
|
+
function dirtyTopologyIds(state) {
|
|
84
|
+
let ids = topologyDirtyIds.get(state);
|
|
85
|
+
if (!ids) {
|
|
86
|
+
ids = new Set();
|
|
87
|
+
topologyDirtyIds.set(state, ids);
|
|
88
|
+
}
|
|
89
|
+
return ids;
|
|
90
|
+
}
|
|
74
91
|
function ensureTopologyState(run) {
|
|
75
92
|
run.paths.topologiesDir = topologyRoot(run);
|
|
76
93
|
fs.mkdirSync(run.paths.topologiesDir, { recursive: true });
|
|
@@ -90,8 +107,13 @@ function persistTopologyState(run) {
|
|
|
90
107
|
counts: { runs: state.runs.length },
|
|
91
108
|
runs: state.runs.map((record) => ({ id: record.id, topologyId: record.topologyId, status: record.status, updatedAt: record.updatedAt })),
|
|
92
109
|
});
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
const dirty = dirtyTopologyIds(state);
|
|
111
|
+
for (const id of dirty) {
|
|
112
|
+
const record = state.runs.find((entry) => entry.id === id);
|
|
113
|
+
if (record)
|
|
114
|
+
(0, fs_atomic_1.writeJson)(topologyRunPath(run, id), record);
|
|
115
|
+
}
|
|
116
|
+
dirty.clear();
|
|
95
117
|
}
|
|
96
118
|
function now() {
|
|
97
119
|
return new Date().toISOString();
|
|
@@ -238,6 +260,7 @@ function applyTopology(run, topologyId, input = {}) {
|
|
|
238
260
|
metadata: compact({ ...(input.metadata || {}), topology: definition }),
|
|
239
261
|
};
|
|
240
262
|
state.runs.push(record);
|
|
263
|
+
dirtyTopologyIds(state).add(record.id);
|
|
241
264
|
appendTopologyNode(run, record, topo.statusToNodeStatus(record.status));
|
|
242
265
|
(0, trust_audit_1.recordTrustAuditEvent)(run, {
|
|
243
266
|
kind: "topology.verdict",
|
|
@@ -272,7 +295,15 @@ function summarizeTopologies(run) {
|
|
|
272
295
|
const multi = run.multiAgent || {};
|
|
273
296
|
const fanins = multi.fanins || [];
|
|
274
297
|
const active = state.runs.map((record) => {
|
|
275
|
-
|
|
298
|
+
// `fanins` (multi.fanins) grows with total fan-in activity across the
|
|
299
|
+
// whole run, re-scanned here once per topology record -- Sets built
|
|
300
|
+
// from each record's own small groupIds/fanoutIds replace an
|
|
301
|
+
// O(fanins) `.includes()` scan per fanin with an O(1) lookup (the
|
|
302
|
+
// same array-scan-per-item shape 024b007 fixed for phase/task
|
|
303
|
+
// selection).
|
|
304
|
+
const groupIdSet = new Set(record.groupIds);
|
|
305
|
+
const fanoutIdSet = new Set(record.fanoutIds);
|
|
306
|
+
const inferredFanins = fanins.filter((fanin) => groupIdSet.has(fanin.groupId) || fanoutIdSet.has(fanin.fanoutId || ""));
|
|
276
307
|
const allFaninIds = topo.unique([...record.faninIds, ...inferredFanins.map((fanin) => fanin.id)]);
|
|
277
308
|
const blocked = inferredFanins.filter((fanin) => fanin.status === "blocked" || !fanin.verifierReady);
|
|
278
309
|
const ready = inferredFanins.some((fanin) => fanin.verifierReady);
|
|
@@ -291,7 +322,7 @@ function summarizeTopologies(run) {
|
|
|
291
322
|
missingEvidence,
|
|
292
323
|
conflicts: record.conflicts,
|
|
293
324
|
readiness: ready ? "fanin ready" : missingEvidence.length ? "missing evidence" : "awaiting worker output",
|
|
294
|
-
nextActions: ready ? [`
|
|
325
|
+
nextActions: ready ? [`cw candidate register ${run.id} --result-node <reducer-or-panel-result>`] : record.nextActions,
|
|
295
326
|
};
|
|
296
327
|
});
|
|
297
328
|
return {
|
|
@@ -300,7 +331,7 @@ function summarizeTopologies(run) {
|
|
|
300
331
|
runsByStatus: countBy(active, (record) => record.status),
|
|
301
332
|
officialTopologies: topo.listTopologyDefinitions().map((definition) => definition.id),
|
|
302
333
|
active,
|
|
303
|
-
nextAction: active.find((record) => record.nextActions.length)?.nextActions[0] || `
|
|
334
|
+
nextAction: active.find((record) => record.nextActions.length)?.nextActions[0] || `cw topology apply ${run.id} map-reduce --task <task-id>`,
|
|
304
335
|
};
|
|
305
336
|
}
|
|
306
337
|
function buildTopologyGraph(run) {
|