cool-workflow 0.2.3 → 0.2.5
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/.gemini-plugin/mcp.json +10 -0
- package/.gemini-plugin/plugin.json +40 -0
- package/.opencode-plugin/mcp.json +10 -0
- package/.opencode-plugin/plugin.json +40 -0
- package/README.md +202 -48
- 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 +33 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/io.js +6 -20
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +339 -10
- package/dist/core/format/help.js +80 -4
- package/dist/core/format/recovery-hint.js +32 -0
- package/dist/core/multi-agent/collaboration.js +35 -6
- package/dist/core/multi-agent/runtime.js +7 -0
- package/dist/core/multi-agent/trust-policy.js +7 -1
- package/dist/core/pipeline/contract.js +7 -0
- package/dist/core/pipeline/error-feedback.js +2 -2
- package/dist/core/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +55 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +124 -13
- package/dist/mcp-server.js +20 -0
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +129 -64
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +205 -24
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +47 -10
- package/dist/shell/fs-atomic.js +93 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/multi-agent-cli.js +5 -1
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/pipeline-cli.js +20 -2
- package/dist/shell/pipeline.js +2 -1
- package/dist/shell/reclamation-io.js +76 -7
- package/dist/shell/registry-cli.js +4 -0
- package/dist/shell/run-export.js +52 -4
- package/dist/shell/run-registry-io.js +1 -0
- package/dist/shell/run-store.js +156 -0
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +104 -10
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +79 -5
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/shell/workflow-app-loader.js +67 -1
- package/dist/wiring/capability-table/basics.js +7 -2
- package/dist/wiring/capability-table/exec-backend.js +42 -24
- package/dist/wiring/capability-table/multi-agent.js +69 -69
- package/dist/wiring/capability-table/pipeline.js +60 -28
- package/dist/wiring/capability-table/registry-core.js +29 -6
- package/dist/wiring/capability-table/reporting.js +45 -39
- package/dist/wiring/capability-table/scheduling-registry.js +66 -60
- package/dist/wiring/capability-table/state.js +34 -34
- package/dist/wiring/capability-table/trust-ledger.js +66 -24
- package/dist/wiring/capability-table/workflow-apps.js +15 -15
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +43 -13
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +38 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/fix.7.md +4 -4
- package/docs/getting-started.md +40 -32
- package/docs/index.md +17 -0
- package/docs/launch/demo.tape +4 -3
- package/docs/mcp-app-surface.7.md +6 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/project-index.md +43 -9
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +22 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +4 -0
- package/docs/run-retention-reclamation.7.md +28 -3
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +26 -2
- package/docs/workflow-app-framework.7.md +31 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +4 -3
- package/package.json +5 -2
- package/scripts/agents/agent-adapter-core.js +26 -0
- package/scripts/agents/claude-p-agent.js +4 -1
- package/scripts/agents/codex-agent.js +4 -0
- package/scripts/agents/cw-attest-wrap.js +1 -1
- package/scripts/agents/gemini-agent.js +4 -0
- package/scripts/agents/opencode-agent.js +5 -0
- package/scripts/block-unapproved-tag.js +160 -0
- package/scripts/bump-version.js +27 -10
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +72 -4
- package/scripts/children/http-batch-delegate-child.js +132 -0
- package/scripts/children/http-delegate-child.js +8 -0
- package/scripts/dogfood-release.js +2 -2
- package/scripts/fake-date-for-reproduction.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/purity-baseline.json +0 -30
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +264 -41
- package/scripts/release-gate.js +184 -0
- package/scripts/release-oneclick.js +438 -0
- package/scripts/release-tags.js +47 -0
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/scripts/version-sync-check.js +39 -22
- package/ui/workbench/app.css +41 -9
- package/ui/workbench/app.js +160 -27
- package/ui/workbench/index.html +2 -2
- package/docs/agent-framework.md +0 -177
- package/docs/designs/handoff-ledger.md +0 -145
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
- package/docs/launch/launch-kit.md +0 -195
- package/docs/launch/pre-launch-checklist.md +0 -53
- package/docs/readme-v0.1.87-full.md +0 -301
- package/docs/routines.md +0 -101
- package/docs/scheduled-tasks.md +0 -80
- package/scripts/agents/claude-p-agent.sh +0 -9
- package/scripts/block-unapproved-tag.sh +0 -60
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
|
@@ -7,14 +7,49 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Evidence: SPEC/execution-backend.md "container driver (runContainer)".
|
|
9
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.buildContainerEnvArgs = buildContainerEnvArgs;
|
|
10
11
|
exports.containerHandle = containerHandle;
|
|
11
12
|
exports.runContainer = runContainer;
|
|
12
13
|
const node_child_process_1 = require("node:child_process");
|
|
13
14
|
const probes_1 = require("./probes");
|
|
15
|
+
const local_1 = require("./local");
|
|
14
16
|
const envelopes_1 = require("./envelopes");
|
|
15
17
|
function messageOf(error) {
|
|
16
18
|
return error instanceof Error ? error.message : String(error);
|
|
17
19
|
}
|
|
20
|
+
/** buildContainerEnvArgs — build the `-e NAME=value` args passed into the
|
|
21
|
+
* container run command. Pulled out as its own pure function so it can be
|
|
22
|
+
* checked without a real docker/podman on the box (see local.ts's
|
|
23
|
+
* buildChildEnv for the same shape of test-only split).
|
|
24
|
+
*
|
|
25
|
+
* deny must win here too: an operator who sets inherit:true plus a deny
|
|
26
|
+
* list (a valid, normalized combination — see sandbox-profile.ts's
|
|
27
|
+
* normalizeEnv) means "everything EXCEPT these". Before this fix, deny was
|
|
28
|
+
* never read here, so a secret like AWS_SECRET_ACCESS_KEY that the operator
|
|
29
|
+
* named in deny still got copied into the container's `-e` args.
|
|
30
|
+
*
|
|
31
|
+
* This backend builds its `-e` args straight from baseEnv rather than through
|
|
32
|
+
* buildChildEnv, so it must apply CW_NEVER_FORWARD_ENV itself — otherwise
|
|
33
|
+
* inherit:true would still copy CW's own parent-only secrets (the release
|
|
34
|
+
* signing key, the workbench token) into the container. */
|
|
35
|
+
function buildContainerEnvArgs(policy, baseEnv = process.env) {
|
|
36
|
+
const args = [];
|
|
37
|
+
if (policy.env.inherit || (policy.env.expose && policy.env.expose.length)) {
|
|
38
|
+
const deny = new Set(policy.env.deny || []);
|
|
39
|
+
for (const name of policy.env.inherit ? Object.keys(baseEnv) : policy.env.expose || []) {
|
|
40
|
+
if (name === "PATH" || name === "HOME")
|
|
41
|
+
continue;
|
|
42
|
+
if (deny.has(name))
|
|
43
|
+
continue;
|
|
44
|
+
if (local_1.CW_NEVER_FORWARD_ENV.has(name))
|
|
45
|
+
continue; // parent-only secrets never enter the container
|
|
46
|
+
const value = baseEnv[name];
|
|
47
|
+
if (value !== undefined)
|
|
48
|
+
args.push("-e", `${name}=${value}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return args;
|
|
52
|
+
}
|
|
18
53
|
function containerHandle(request, env = process.env) {
|
|
19
54
|
const delegation = request.delegation || {};
|
|
20
55
|
const image = delegation.image || (env.CW_CONTAINER_IMAGE || "").trim() || undefined;
|
|
@@ -46,20 +81,19 @@ function runContainer(descriptor, policy, request, label, handle, attestation) {
|
|
|
46
81
|
if (policy.network.mode !== "any")
|
|
47
82
|
runArgs.push("--network", "none");
|
|
48
83
|
runArgs.push("-v", `${cwd}:${cwd}:ro`, "-w", cwd);
|
|
49
|
-
|
|
50
|
-
for (const name of policy.env.inherit ? Object.keys(process.env) : policy.env.expose || []) {
|
|
51
|
-
if (name === "PATH" || name === "HOME")
|
|
52
|
-
continue;
|
|
53
|
-
const value = process.env[name];
|
|
54
|
-
if (value !== undefined)
|
|
55
|
-
runArgs.push("-e", `${name}=${value}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
84
|
+
runArgs.push(...buildContainerEnvArgs(policy));
|
|
58
85
|
runArgs.push(handle.ref, command, ...args);
|
|
59
86
|
// An unset timeoutMs must not mean "no timeout" — spawnSync would then
|
|
60
87
|
// block forever on a hung container with no kill path. 600000 matches the
|
|
61
88
|
// agent backend's own default fallback (execution-backend/agent.ts).
|
|
62
|
-
|
|
89
|
+
// killSignal SIGKILL, not the default SIGTERM: a container runtime that
|
|
90
|
+
// ignores SIGTERM would leave this blocking spawnSync waiting forever (no
|
|
91
|
+
// second-stage escalation is possible from inside a sync call), so the
|
|
92
|
+
// timeout above would not actually bound anything. SIGKILL is uncatchable,
|
|
93
|
+
// so the runtime always dies and spawnSync returns; a timed-out run is
|
|
94
|
+
// classified the same either way (status null, ETIMEDOUT message does not
|
|
95
|
+
// name the signal, result.signal is never recorded). Mirrors agent.ts.
|
|
96
|
+
const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024, killSignal: "SIGKILL" });
|
|
63
97
|
if (result.error) {
|
|
64
98
|
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `${runtime} run failed: ${messageOf(result.error)}`, attestation);
|
|
65
99
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Evidence: SPEC/execution-backend.md "Local execution (executeLocal)".
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.CW_NEVER_FORWARD_ENV = void 0;
|
|
11
12
|
exports.buildChildEnv = buildChildEnv;
|
|
12
13
|
exports.checkShellGuard = checkShellGuard;
|
|
13
14
|
exports.runtimeNoteFor = runtimeNoteFor;
|
|
@@ -18,21 +19,43 @@ const probes_1 = require("./probes");
|
|
|
18
19
|
function messageOf(error) {
|
|
19
20
|
return error instanceof Error ? error.message : String(error);
|
|
20
21
|
}
|
|
22
|
+
/** CW's OWN secrets that must NEVER reach ANY child process — on any backend
|
|
23
|
+
* (local/container/remote/agent), regardless of policy.env.inherit / expose /
|
|
24
|
+
* deny. Each is read only by parent-side tooling, so no spawned child ever
|
|
25
|
+
* needs it; stripping them here, unconditionally, is a fail-closed backstop
|
|
26
|
+
* that does not depend on an operator remembering to list them in
|
|
27
|
+
* policy.env.deny. Do NOT add the agent-attest private key
|
|
28
|
+
* (CW_AGENT_ATTEST_PRIVKEY) here: the attest wrapper
|
|
29
|
+
* (scripts/agents/cw-attest-wrap.js) is spawned AS the agent and must receive
|
|
30
|
+
* it to sign telemetry. */
|
|
31
|
+
exports.CW_NEVER_FORWARD_ENV = new Set([
|
|
32
|
+
"CW_RELEASE_VERDICT_PRIVKEY", // release verdict signing key — release-flow.js only
|
|
33
|
+
"CW_WORKBENCH_TOKEN", // workbench HTTP bearer token — workbench-host.ts only
|
|
34
|
+
]);
|
|
21
35
|
function buildChildEnv(policy, baseEnv = process.env) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
// deny must win regardless of inherit: a custom profile combining
|
|
37
|
+
// inherit:true with deny:[...] (a valid, normalized combination — see
|
|
38
|
+
// sandbox-profile.ts's normalizeEnv) means "everything EXCEPT these".
|
|
39
|
+
// An early return on inherit before this loop ran meant deny was
|
|
40
|
+
// silently skipped whenever inherit was true.
|
|
41
|
+
const env = policy.env.inherit ? { ...baseEnv } : {};
|
|
42
|
+
if (!policy.env.inherit) {
|
|
43
|
+
if (baseEnv.PATH !== undefined)
|
|
44
|
+
env.PATH = baseEnv.PATH;
|
|
45
|
+
if (baseEnv.HOME !== undefined)
|
|
46
|
+
env.HOME = baseEnv.HOME;
|
|
47
|
+
for (const name of policy.env.expose || []) {
|
|
48
|
+
if (baseEnv[name] !== undefined)
|
|
49
|
+
env[name] = baseEnv[name];
|
|
50
|
+
}
|
|
32
51
|
}
|
|
33
52
|
for (const name of policy.env.deny || []) {
|
|
34
53
|
delete env[name];
|
|
35
54
|
}
|
|
55
|
+
// Fail-closed backstop: parent-only secrets never cross into a child, even
|
|
56
|
+
// when inherit:true forwarded them above or an operator exposed one by name.
|
|
57
|
+
for (const name of exports.CW_NEVER_FORWARD_ENV)
|
|
58
|
+
delete env[name];
|
|
36
59
|
return env;
|
|
37
60
|
}
|
|
38
61
|
/** Shell injection guard (SPEC/execution-backend.md "Local execution"): for
|
|
@@ -70,6 +93,20 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
|
|
|
70
93
|
// agent backend's own default fallback (execution-backend/agent.ts).
|
|
71
94
|
timeout: request.timeoutMs || 600000,
|
|
72
95
|
maxBuffer: 32 * 1024 * 1024,
|
|
96
|
+
// SIGKILL, not the default SIGTERM: a child that ignores SIGTERM would
|
|
97
|
+
// leave this blocking spawnSync waiting forever (no second-stage
|
|
98
|
+
// escalation is possible from inside a sync call), so the `timeout` above
|
|
99
|
+
// would not actually bound anything. SIGKILL is uncatchable, so the child
|
|
100
|
+
// always dies and spawnSync returns. A timed-out run is classified as
|
|
101
|
+
// failed regardless of the signal (status stays null, the ETIMEDOUT
|
|
102
|
+
// message does not name the signal, and this backend never records
|
|
103
|
+
// result.signal). The exact captured stdout/stderr on a timeout is not
|
|
104
|
+
// guaranteed identical to the old SIGTERM path: a child that catches
|
|
105
|
+
// SIGTERM could flush a last partial write or exit with a code before it
|
|
106
|
+
// dies, while under SIGKILL it cannot — but that output belongs to a run
|
|
107
|
+
// we already treat as failed, so the hard kill loses nothing that counts.
|
|
108
|
+
// Mirrors execution-backend/agent.ts's fix.
|
|
109
|
+
killSignal: "SIGKILL",
|
|
73
110
|
};
|
|
74
111
|
if (spawnStyle === "shell") {
|
|
75
112
|
checkShellGuard(command, args);
|
package/dist/shell/fs-atomic.js
CHANGED
|
@@ -48,6 +48,8 @@ exports.assertSafeRunId = assertSafeRunId;
|
|
|
48
48
|
exports.realResolve = realResolve;
|
|
49
49
|
exports.isContainedPath = isContainedPath;
|
|
50
50
|
exports.durableAppendFileSync = durableAppendFileSync;
|
|
51
|
+
exports.logEndsWithNewline = logEndsWithNewline;
|
|
52
|
+
exports.nextBackoffMs = nextBackoffMs;
|
|
51
53
|
exports.withFileLock = withFileLock;
|
|
52
54
|
const fs = __importStar(require("node:fs"));
|
|
53
55
|
const path = __importStar(require("node:path"));
|
|
@@ -196,18 +198,55 @@ function durableAppendFileSync(file, data) {
|
|
|
196
198
|
fs.closeSync(fd);
|
|
197
199
|
}
|
|
198
200
|
}
|
|
201
|
+
/** True when `file`'s final byte is "\n", given its already-known `size`.
|
|
202
|
+
* A COMPLETED `durableAppendFileSync` always leaves the file ending in
|
|
203
|
+
* "\n" (every append is `<line>\n`), so a non-newline last byte means the
|
|
204
|
+
* previous append was torn by a crash — its bytes were never a confirmed
|
|
205
|
+
* record. Reads ONLY the last byte at `size-1`, so callers on an append
|
|
206
|
+
* hot path stay O(1) and never re-read the whole file. A read failure
|
|
207
|
+
* returns false (treat as "not newline-terminated") — the safe side: an
|
|
208
|
+
* extra leading newline is harmless for an NDJSON reader that skips blank
|
|
209
|
+
* lines, while a MISSED torn boundary would merge two records into one
|
|
210
|
+
* unparseable line. Shared by trust-audit's events.jsonl and the
|
|
211
|
+
* blackboard's messages.jsonl append paths — same file shape, same
|
|
212
|
+
* torn-tail risk, one implementation. */
|
|
213
|
+
function logEndsWithNewline(file, size) {
|
|
214
|
+
if (size <= 0)
|
|
215
|
+
return false;
|
|
216
|
+
let fd;
|
|
217
|
+
try {
|
|
218
|
+
fd = fs.openSync(file, "r");
|
|
219
|
+
const buf = Buffer.alloc(1);
|
|
220
|
+
fs.readSync(fd, buf, 0, 1, size - 1);
|
|
221
|
+
return buf[0] === 0x0a; // "\n"
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
if (fd !== undefined)
|
|
228
|
+
fs.closeSync(fd);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
199
231
|
// ---------------------------------------------------------------------------
|
|
200
232
|
// withFileLock — portable advisory cross-process lock.
|
|
201
233
|
//
|
|
202
234
|
// Lock file: `<targetPath>.lock`, created by hard-linking a per-attempt temp
|
|
203
235
|
// file onto the lock path (single-winner `link(2)`), body `"<pid>@<ISO>\n"`.
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
// (Atomics.wait busy-safe sleep
|
|
209
|
-
//
|
|
210
|
-
//
|
|
236
|
+
// On EEXIST, a lock whose mtime is older than FILE_LOCK_STALE_MS (30_000) is
|
|
237
|
+
// stolen — judged and deleted only while holding the single-winner
|
|
238
|
+
// `<lock>.steal` guard (see stealStaleLockUnderGuard) — and retried AT ONCE
|
|
239
|
+
// (so is a lock that vanished between the EEXIST and the stat). Otherwise the
|
|
240
|
+
// thread sleeps a short, growing backoff (Atomics.wait busy-safe sleep:
|
|
241
|
+
// FILE_LOCK_BACKOFF_BASE_MS doubling toward FILE_LOCK_BACKOFF_MAX_MS, with
|
|
242
|
+
// jitter so many processes contending on ONE lock do not retry in lock-step)
|
|
243
|
+
// and tries again. The WHOLE acquire is bounded by wall-clock, not a try
|
|
244
|
+
// count: after FILE_LOCK_ACQUIRE_BUDGET_MS (~6s, kept well under the 30s steal
|
|
245
|
+
// window so a fresh orphan lock fails fast rather than being waited out) with
|
|
246
|
+
// no lock, it throws `could not acquire file lock for <targetPath>`. The tiny
|
|
247
|
+
// early sleeps re-grab a briefly-held lock in a few ms; the wall-clock bound
|
|
248
|
+
// (not the try count) is what keeps the worst case at ~6s. Any non-EEXIST
|
|
249
|
+
// link error is rethrown.
|
|
211
250
|
//
|
|
212
251
|
// Right before fn() the lock mtime is refreshed (utimesSync, best-effort).
|
|
213
252
|
// After fn() returns, the lock body is re-read: if it no longer starts with
|
|
@@ -227,15 +266,37 @@ const FILE_LOCK_FORCE_STALE_MS = FILE_LOCK_STALE_MS * 10;
|
|
|
227
266
|
// guarded window (which is a handful of syscalls, microseconds for any live
|
|
228
267
|
// process) — remove it so stealing cannot wedge forever.
|
|
229
268
|
const FILE_LOCK_STEAL_GUARD_STALE_MS = FILE_LOCK_STALE_MS;
|
|
269
|
+
// Acquire-retry pacing (all internal to withFileLock — no wire/protocol
|
|
270
|
+
// meaning). The wall-clock BUDGET, not a fixed try count, bounds how long a
|
|
271
|
+
// contended acquire blocks the calling thread; kept at ~6s so it stays well
|
|
272
|
+
// under the 30s steal window (a fresh orphan lock is failed fast, not waited
|
|
273
|
+
// out — a later command past 30s steals it). Between misses the thread sleeps
|
|
274
|
+
// a backoff that starts at BASE and doubles to MAX, so a briefly-held lock is
|
|
275
|
+
// re-grabbed in a few ms while a genuinely busy one is not hot-spun. Jitter on
|
|
276
|
+
// each sleep (applied at the call site) de-syncs many contenders on one lock.
|
|
277
|
+
// MAX_ATTEMPTS is only a loop-termination backstop for the (never-observed)
|
|
278
|
+
// case of a clock that fails to advance; the budget is the real bound.
|
|
279
|
+
const FILE_LOCK_ACQUIRE_BUDGET_MS = 6_000;
|
|
280
|
+
const FILE_LOCK_BACKOFF_BASE_MS = 1;
|
|
281
|
+
const FILE_LOCK_BACKOFF_MAX_MS = 50;
|
|
282
|
+
const FILE_LOCK_MAX_ATTEMPTS = 10_000;
|
|
230
283
|
// Lock paths this process holds right now. A nested withFileLock on the
|
|
231
284
|
// SAME target runs its fn directly (re-entrant) instead of waiting on its
|
|
232
|
-
// own lock file until the
|
|
285
|
+
// own lock file until the acquire budget runs out — that lets a whole
|
|
233
286
|
// load -> change -> save cycle hold one lock while the save path inside
|
|
234
287
|
// it keeps its own withFileLock call unchanged.
|
|
235
288
|
const HELD_LOCKS = new Set();
|
|
236
289
|
function sleepSync(ms) {
|
|
237
290
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
238
291
|
}
|
|
292
|
+
/** Next backoff ceiling (ms) given the previous one: double it, capped at
|
|
293
|
+
* FILE_LOCK_BACKOFF_MAX_MS. Pure and deterministic (the per-sleep jitter is
|
|
294
|
+
* applied separately at the call site), so the retry schedule is
|
|
295
|
+
* unit-testable without any timing. Exported for that test only — fs-atomic
|
|
296
|
+
* is not part of the public index.ts surface. */
|
|
297
|
+
function nextBackoffMs(previous) {
|
|
298
|
+
return Math.min(previous * 2, FILE_LOCK_BACKOFF_MAX_MS);
|
|
299
|
+
}
|
|
239
300
|
/** Whether the pid recorded in a lock body ("<pid>@<ISO>\n") is still a
|
|
240
301
|
* live process on this machine. Locks here are local-advisory only (no
|
|
241
302
|
* network-shared run directories in this tool's model), so
|
|
@@ -375,7 +436,9 @@ function withFileLock(targetPath, fn) {
|
|
|
375
436
|
fs.mkdirSync(path.dirname(lock), { recursive: true });
|
|
376
437
|
const pid = String(process.pid);
|
|
377
438
|
let acquired = false;
|
|
378
|
-
|
|
439
|
+
const deadline = Date.now() + FILE_LOCK_ACQUIRE_BUDGET_MS;
|
|
440
|
+
let backoff = FILE_LOCK_BACKOFF_BASE_MS;
|
|
441
|
+
for (let attempt = 0; attempt < FILE_LOCK_MAX_ATTEMPTS && !acquired; attempt++) {
|
|
379
442
|
// Acquire via `linkSync`, not `open(lock, "wx")`: directly testing the
|
|
380
443
|
// retry loop under contention showed `open(O_CREAT|O_EXCL)` is NOT
|
|
381
444
|
// reliably single-winner on every Node version this runs on — under
|
|
@@ -421,16 +484,34 @@ function withFileLock(targetPath, fn) {
|
|
|
421
484
|
// The steal itself runs under a single-winner guard lock — see
|
|
422
485
|
// stealStaleLockUnderGuard for why judging staleness and deleting
|
|
423
486
|
// the lock without one can never be made safe from here.
|
|
424
|
-
if (stealStaleLockUnderGuard(lock, pid, attempt))
|
|
487
|
+
if (stealStaleLockUnderGuard(lock, pid, attempt)) {
|
|
488
|
+
// Verdict rendered and the lock likely just freed — retry AT ONCE
|
|
489
|
+
// and reset the backoff so we grab it before another waiter does.
|
|
490
|
+
backoff = FILE_LOCK_BACKOFF_BASE_MS;
|
|
425
491
|
continue;
|
|
492
|
+
}
|
|
426
493
|
// Guard was busy — another process is mid-steal. Back off like
|
|
427
|
-
// ordinary contention instead of hot-spinning
|
|
494
|
+
// ordinary contention instead of hot-spinning.
|
|
428
495
|
}
|
|
429
496
|
}
|
|
430
497
|
catch {
|
|
498
|
+
// The lock vanished between the EEXIST and the stat — it is likely free
|
|
499
|
+
// now, so retry AT ONCE (no sleep) with a reset backoff.
|
|
500
|
+
backoff = FILE_LOCK_BACKOFF_BASE_MS;
|
|
431
501
|
continue;
|
|
432
502
|
}
|
|
433
|
-
|
|
503
|
+
// Give up on wall-clock, never on a raw try count: this keeps the worst-
|
|
504
|
+
// case block at ~6s even though the per-sleep backoff grew. Bounding by a
|
|
505
|
+
// fixed try count instead (as the old flat-25ms loop's 240 tries did)
|
|
506
|
+
// would have let the growing backoff roughly double that worst case.
|
|
507
|
+
if (Date.now() >= deadline)
|
|
508
|
+
break;
|
|
509
|
+
// Sleep a jittered span in [BASE, backoff] — never 0, so never a busy
|
|
510
|
+
// spin — then grow the backoff toward the cap. The jitter de-syncs many
|
|
511
|
+
// processes contending on the SAME lock so they do not retry in lock-step.
|
|
512
|
+
const span = backoff - FILE_LOCK_BACKOFF_BASE_MS;
|
|
513
|
+
sleepSync(FILE_LOCK_BACKOFF_BASE_MS + Math.floor(Math.random() * (span + 1)));
|
|
514
|
+
backoff = nextBackoffMs(backoff);
|
|
434
515
|
}
|
|
435
516
|
if (!acquired)
|
|
436
517
|
throw new Error(`could not acquire file lock for ${targetPath}`);
|
package/dist/shell/ledger-cli.js
CHANGED
|
@@ -188,10 +188,17 @@ function ledgerApplyCli(options) {
|
|
|
188
188
|
return (0, ledger_1.applyLedgerProposal)(parsed);
|
|
189
189
|
}
|
|
190
190
|
function ledgerListCli(options) {
|
|
191
|
-
|
|
191
|
+
// `--ledger-dir` is the preferred flag: the global CLI front door
|
|
192
|
+
// (cli/entry.ts) treats `--dir` as an alias of `--repo` for EVERY
|
|
193
|
+
// command, so `cw ledger list --dir X` made one flag mean two things.
|
|
194
|
+
// `--dir` keeps working unchanged as the legacy spelling; when both are
|
|
195
|
+
// given, `--ledger-dir` wins. Repeated flags become an array via
|
|
196
|
+
// parseArgv's append behavior, same as `--dir` always has.
|
|
197
|
+
const input = options["ledger-dir"] ?? options.dir;
|
|
198
|
+
const dirs = Array.isArray(input) ? input.map(String).filter(Boolean) : [];
|
|
192
199
|
if (dirs.length > 1)
|
|
193
200
|
return (0, ledger_io_1.unionLedgerEntries)(dirs);
|
|
194
|
-
const dir = required(dirs[0] || stringOption(
|
|
201
|
+
const dir = required(dirs[0] || stringOption(input), "--ledger-dir <ledger-directory>");
|
|
195
202
|
return (0, ledger_io_1.listLedgerEntries)(dir);
|
|
196
203
|
}
|
|
197
204
|
/** MCP-facing verify/apply take the entry OBJECT directly (not a file/
|
|
@@ -113,6 +113,7 @@ exports.evalScoreCli = evalScoreCli;
|
|
|
113
113
|
exports.evalGateCli = evalGateCli;
|
|
114
114
|
exports.evalReportCli = evalReportCli;
|
|
115
115
|
const path = __importStar(require("node:path"));
|
|
116
|
+
const cli_args_1 = require("../core/util/cli-args");
|
|
116
117
|
const numeric_flag_1 = require("../core/util/numeric-flag");
|
|
117
118
|
const run_store_1 = require("./run-store");
|
|
118
119
|
const report_1 = require("./report");
|
|
@@ -188,7 +189,10 @@ function numberArg(value) {
|
|
|
188
189
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
189
190
|
}
|
|
190
191
|
function boolArg(value) {
|
|
191
|
-
|
|
192
|
+
// parseBoolFlag reads "false"/"0"/"no"/"off" as false and throws on an
|
|
193
|
+
// unrecognized string — Boolean("false") is true, which silently
|
|
194
|
+
// ENABLED flags like `--allow-self-approval false` (fail-open).
|
|
195
|
+
return (0, cli_args_1.parseBoolFlag)(value, "flag") ?? false;
|
|
192
196
|
}
|
|
193
197
|
/** `--multi-agent-run <id>` — parseArgv keeps kebab-case option keys
|
|
194
198
|
* verbatim (no camelCase folding), so this must check the literal
|
package/dist/shell/onramp.js
CHANGED
|
@@ -278,9 +278,9 @@ function resolveChangedFiles(options = {}) {
|
|
|
278
278
|
const root = gitRoot(cwd);
|
|
279
279
|
const baseRef = resolveBaseRef(root, options.changedFrom, options.env || process.env);
|
|
280
280
|
const files = new Set();
|
|
281
|
-
for (const file of
|
|
281
|
+
for (const file of gitLinesOrThrow(root, ["diff", "--name-only", baseRef, "--"]))
|
|
282
282
|
files.add(normalizeChangedPath(file));
|
|
283
|
-
for (const file of
|
|
283
|
+
for (const file of gitLinesOrThrow(root, ["ls-files", "--others", "--exclude-standard"]))
|
|
284
284
|
files.add(normalizeChangedPath(file));
|
|
285
285
|
return { baseRef, files: [...files].filter(Boolean).sort() };
|
|
286
286
|
}
|
|
@@ -406,8 +406,26 @@ function resolveBaseRef(root, changedFrom, env) {
|
|
|
406
406
|
return verifyRef(root, changedFrom);
|
|
407
407
|
if (env.CW_ONRAMP_BASE)
|
|
408
408
|
return verifyRef(root, env.CW_ONRAMP_BASE);
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
// A pull_request CI context sets GITHUB_BASE_REF, so a base ref is EXPECTED:
|
|
410
|
+
// the diff must run against the PR's own base branch. If merge-base cannot
|
|
411
|
+
// resolve there (a shallow clone that never fetched the base, a missing
|
|
412
|
+
// origin ref), we must NOT quietly degrade to HEAD -- a HEAD..HEAD diff on a
|
|
413
|
+
// clean, already-committed tree is empty, and an empty change set has no
|
|
414
|
+
// issues, so onramp:check would report ok:true on a real, unseen change (the
|
|
415
|
+
// same vacuous-pass class PR #446 closed one layer below). Fail closed.
|
|
416
|
+
if (env.GITHUB_BASE_REF) {
|
|
417
|
+
const baseBranch = `origin/${env.GITHUB_BASE_REF}`;
|
|
418
|
+
const mergeBase = gitOne(root, ["merge-base", "HEAD", baseBranch]);
|
|
419
|
+
if (mergeBase)
|
|
420
|
+
return mergeBase;
|
|
421
|
+
throw new Error(`onramp: cannot resolve a base ref -- git merge-base HEAD ${baseBranch} found no common commit (fail closed, not treated as zero changes). Fetch the base branch with a full (non-shallow) clone, or pass --changed-from / CW_ONRAMP_BASE.`);
|
|
422
|
+
}
|
|
423
|
+
// No base ref was requested (--changed-from / CW_ONRAMP_BASE) or expected
|
|
424
|
+
// (GITHUB_BASE_REF): this is the local "show my own changes" use. merge-base
|
|
425
|
+
// against origin/main narrows the diff to this branch's commits when the
|
|
426
|
+
// remote is present; with no remote we fall back to HEAD so `git diff HEAD`
|
|
427
|
+
// still surfaces the working-tree (uncommitted) changes.
|
|
428
|
+
const mergeBase = gitOne(root, ["merge-base", "HEAD", "origin/main"]);
|
|
411
429
|
if (mergeBase)
|
|
412
430
|
return mergeBase;
|
|
413
431
|
return verifyRef(root, "HEAD");
|
|
@@ -423,12 +441,37 @@ function verifyRef(root, ref) {
|
|
|
423
441
|
function gitRoot(cwd) {
|
|
424
442
|
return gitOne(node_path_1.default.resolve(cwd), ["rev-parse", "--show-toplevel"]) || node_path_1.default.resolve(cwd);
|
|
425
443
|
}
|
|
444
|
+
// Every onramp git call is a quick metadata read (rev-parse, merge-base, diff
|
|
445
|
+
// --name-only, ls-files). A finite timeout keeps a HUNG git -- a cold fsmonitor
|
|
446
|
+
// daemon, a credential prompt on a misconfigured remote -- from blocking the
|
|
447
|
+
// gate forever; 5s matches the git-metadata timeout already used in
|
|
448
|
+
// shell/commit.ts and shell/doctor.ts. On a timeout spawnSync sets
|
|
449
|
+
// `result.error` (and a null status), so gitLinesOrThrow fails closed while
|
|
450
|
+
// gitLines/gitOne fall back the same way they do for any other git failure.
|
|
451
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
426
452
|
function gitLines(cwd, args) {
|
|
427
|
-
const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
453
|
+
const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
|
|
428
454
|
if (result.status !== 0)
|
|
429
455
|
return [];
|
|
430
456
|
return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
431
457
|
}
|
|
458
|
+
/** Same as gitLines, but a failed git invocation THROWS instead of silently
|
|
459
|
+
* becoming []. resolveChangedFiles must never mistake "git could not run
|
|
460
|
+
* this diff" for "there is nothing to diff" -- that turns a transient git
|
|
461
|
+
* failure into a vacuous onramp-contract pass (a 2026-07-12 security audit
|
|
462
|
+
* finding: a broken base ref or a git error made the changed-file set
|
|
463
|
+
* empty, and an empty set has no issues, so the gate reported ok:true on a
|
|
464
|
+
* real, unseen change). Callers that WANT a soft fallback (merge-base
|
|
465
|
+
* probing, optional discovery) should keep using gitLines/gitOne. */
|
|
466
|
+
function gitLinesOrThrow(cwd, args) {
|
|
467
|
+
const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
|
|
468
|
+
if (result.error)
|
|
469
|
+
throw new Error(`onramp: git ${args.join(" ")} failed to run: ${result.error.message}`);
|
|
470
|
+
if (result.status !== 0) {
|
|
471
|
+
throw new Error(`onramp: git ${args.join(" ")} exited ${result.status} -- cannot resolve changed files (fail closed, not treated as zero changes): ${String(result.stderr || "").trim()}`);
|
|
472
|
+
}
|
|
473
|
+
return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
474
|
+
}
|
|
432
475
|
function gitOne(cwd, args) {
|
|
433
476
|
return gitLines(cwd, args)[0] || "";
|
|
434
477
|
}
|
|
@@ -159,6 +159,24 @@ function planInputsFor(args) {
|
|
|
159
159
|
function invocationCwd(args) {
|
|
160
160
|
return typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd) : process.cwd();
|
|
161
161
|
}
|
|
162
|
+
/** Same as invocationCwd, but ALSO falls back to `--repo` before
|
|
163
|
+
* process.cwd() -- for resolving an EXISTING run's directory specifically
|
|
164
|
+
* (loadRunFromCwd's target), never for a fresh plan()'s repo input (see
|
|
165
|
+
* planRun's own comment on why THAT path deliberately never auto-fills
|
|
166
|
+
* repo from cwd). A caller resuming a run they didn't originally start
|
|
167
|
+
* from this exact directory (the common case: `cw run resume <id>
|
|
168
|
+
* --drive` or `cw quickstart <app> --resume --run <id>` typed from
|
|
169
|
+
* wherever the terminal happens to be, not necessarily the run's own
|
|
170
|
+
* --repo) must not have that already-known fact silently discarded in
|
|
171
|
+
* favor of process.cwd() -- this mirrors the --preview branch's own
|
|
172
|
+
* repoCwd fallback a few lines below, which already gets this right. */
|
|
173
|
+
function existingRunCwd(args) {
|
|
174
|
+
if (typeof args.cwd === "string" && args.cwd.trim())
|
|
175
|
+
return path.resolve(args.cwd);
|
|
176
|
+
if (typeof args.repo === "string" && args.repo.trim())
|
|
177
|
+
return path.resolve(args.repo);
|
|
178
|
+
return process.cwd();
|
|
179
|
+
}
|
|
162
180
|
/** True for a value that counts as "not supplied" — byte-exact to the old
|
|
163
181
|
* build's cli-options.isMissing (undefined / null / empty string). */
|
|
164
182
|
function isMissingInput(value) {
|
|
@@ -239,7 +257,7 @@ async function runDriveStep(args) {
|
|
|
239
257
|
incremental: Boolean(args.incremental),
|
|
240
258
|
};
|
|
241
259
|
if (existingRunId) {
|
|
242
|
-
const cwd =
|
|
260
|
+
const cwd = existingRunCwd(args);
|
|
243
261
|
const run = (0, run_store_1.loadRunFromCwd)(existingRunId, cwd);
|
|
244
262
|
return (0, drive_1.driveAsync)(existingRunId, run.cwd, options);
|
|
245
263
|
}
|
|
@@ -502,7 +520,7 @@ async function quickstartRun(args) {
|
|
|
502
520
|
};
|
|
503
521
|
let run;
|
|
504
522
|
if (existingRunId) {
|
|
505
|
-
run = (0, run_store_1.loadRunFromCwd)(existingRunId,
|
|
523
|
+
run = (0, run_store_1.loadRunFromCwd)(existingRunId, existingRunCwd(args));
|
|
506
524
|
}
|
|
507
525
|
else {
|
|
508
526
|
run = (0, pipeline_1.plan)(resolveWorkflowAppForPlan(appId), planInputsFor(args));
|
package/dist/shell/pipeline.js
CHANGED
|
@@ -48,6 +48,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
48
48
|
exports.pureAppendRunNode = exports.writeJson = void 0;
|
|
49
49
|
exports.plan = plan;
|
|
50
50
|
const crypto = __importStar(require("node:crypto"));
|
|
51
|
+
const fs = __importStar(require("node:fs"));
|
|
51
52
|
const path = __importStar(require("node:path"));
|
|
52
53
|
const run_paths_1 = require("../core/state/run-paths");
|
|
53
54
|
const migrations_1 = require("../core/state/migrations");
|
|
@@ -207,7 +208,7 @@ function plan(app, options) {
|
|
|
207
208
|
loopStage: "interpret",
|
|
208
209
|
artifacts: [{ id: "task", kind: "markdown", path: task.taskPath }],
|
|
209
210
|
metadata: { workflowId: app.workflow.id, appId: app.id, appVersion: app.version, taskId: task.id, phase: task.phase, taskKind: task.kind, requiresEvidence: task.requiresEvidence, sandboxProfileId: task.sandboxProfileId },
|
|
210
|
-
}, { persist: false, persistNode: node_store_1.writeRunNode });
|
|
211
|
+
}, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
|
|
211
212
|
task.stateNodeId = taskResult.outputNodeId;
|
|
212
213
|
}
|
|
213
214
|
(0, report_1.writeReport)(run);
|