cool-workflow 0.2.4 → 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 +32 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +337 -8
- package/dist/core/format/help.js +73 -3
- 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 +22 -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 +120 -63
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +195 -23
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +32 -0
- 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.js +2 -1
- package/dist/shell/reclamation-io.js +10 -9
- package/dist/shell/run-export.js +52 -4
- 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/wiring/capability-table/basics.js +5 -0
- package/dist/wiring/capability-table/exec-backend.js +40 -22
- package/dist/wiring/capability-table/pipeline.js +32 -0
- package/dist/wiring/capability-table/registry-core.js +26 -3
- package/dist/wiring/capability-table/reporting.js +7 -1
- package/dist/wiring/capability-table/scheduling-registry.js +8 -2
- package/dist/wiring/capability-table/trust-ledger.js +54 -12
- package/docs/agent-delegation-drive.7.md +15 -0
- package/docs/cli-mcp-parity.7.md +25 -0
- package/docs/contract-migration-tooling.7.md +2 -0
- package/docs/control-plane-scheduling.7.md +36 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +2 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
- package/docs/execution-backends.7.md +2 -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 +2 -0
- package/docs/multi-agent-eval-replay-harness.7.md +2 -0
- package/docs/multi-agent-operator-ux.7.md +2 -0
- package/docs/node-snapshot-diff-replay.7.md +2 -0
- package/docs/observability-cost-accounting.7.md +2 -0
- package/docs/project-index.md +39 -9
- package/docs/real-execution-backends.7.md +2 -0
- package/docs/release-and-migration.7.md +2 -0
- package/docs/release-tooling.7.md +2 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +2 -0
- package/docs/run-retention-reclamation.7.md +10 -4
- package/docs/state-explosion-management.7.md +2 -0
- package/docs/team-collaboration.7.md +2 -0
- package/docs/trust-audit-anchor.7.md +2 -0
- package/docs/web-desktop-workbench.7.md +24 -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 +4 -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/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-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +128 -30
- package/scripts/release-gate.js +184 -0
- package/scripts/release-oneclick.js +38 -7
- 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/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 -75
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// MILESTONE 5 (docs/rebuild/PLAN.md build order, step 5). Byte-exact port of
|
|
6
6
|
// plugins/cool-workflow/src/execution-backend/agent.ts. This module holds
|
|
7
7
|
// the PURE data-transform helpers (invocation resolution, arg substitution,
|
|
8
|
-
// secret redaction, report parsing) plus the batch delegate-child spawn
|
|
8
|
+
// secret redaction, report parsing) plus the batch delegate-child spawn — both
|
|
9
|
+
// the CLI-binary batch (runAgentBatchOutcomes) and, added later, its
|
|
10
|
+
// HTTP-endpoint sibling (runEndpointBatchOutcomes) so `--concurrency N` gives
|
|
11
|
+
// endpoint-mode agents real concurrency instead of a serial per-task spawn.
|
|
9
12
|
//
|
|
10
13
|
// THE RED LINE: CW spawns the agent and records its attested output. It
|
|
11
14
|
// NEVER imports a model SDK, holds an API key, or constructs a model API
|
|
@@ -49,6 +52,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
49
52
|
};
|
|
50
53
|
})();
|
|
51
54
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.reapRecordedVendor = reapRecordedVendor;
|
|
52
56
|
exports.buildAgentChildEnv = buildAgentChildEnv;
|
|
53
57
|
exports.resolveAgentInvocation = resolveAgentInvocation;
|
|
54
58
|
exports.stripSecretArgs = stripSecretArgs;
|
|
@@ -59,12 +63,15 @@ exports.recordedAgentHandle = recordedAgentHandle;
|
|
|
59
63
|
exports.extractEndpointResult = extractEndpointResult;
|
|
60
64
|
exports.agentHandle = agentHandle;
|
|
61
65
|
exports.prepareAgentSpawn = prepareAgentSpawn;
|
|
66
|
+
exports.prepareEndpointJob = prepareEndpointJob;
|
|
62
67
|
exports.reconcileBatchOutcomes = reconcileBatchOutcomes;
|
|
63
68
|
exports.runAgentBatchOutcomes = runAgentBatchOutcomes;
|
|
69
|
+
exports.runEndpointBatchOutcomes = runEndpointBatchOutcomes;
|
|
64
70
|
exports.shouldStreamAgentStderr = shouldStreamAgentStderr;
|
|
65
71
|
exports.runAgentProcess = runAgentProcess;
|
|
66
72
|
const fs = __importStar(require("node:fs"));
|
|
67
73
|
const path = __importStar(require("node:path"));
|
|
74
|
+
const os = __importStar(require("node:os"));
|
|
68
75
|
const node_child_process_1 = require("node:child_process");
|
|
69
76
|
const local_1 = require("./local");
|
|
70
77
|
const envelopes_1 = require("./envelopes");
|
|
@@ -85,6 +92,46 @@ const AGENT_PROVIDER_KEY_ENV_RE = /^(CW_|ANTHROPIC_|OPENAI_|GEMINI_|DEEPSEEK_|CO
|
|
|
85
92
|
* call it, so the allowlist cannot drift into a second silent copy again.
|
|
86
93
|
* Returns the forwarded var NAMES too (never values) for the
|
|
87
94
|
* worker.agent-env trust-audit event. */
|
|
95
|
+
/** A unique sidecar path a shipped wrapper writes its vendor child's PID to
|
|
96
|
+
* (via the env CW_AGENT_VENDOR_PIDFILE we set below), so cw can reap the
|
|
97
|
+
* vendor if it has to SIGKILL the wrapper on a timeout. */
|
|
98
|
+
function vendorPidFilePath() {
|
|
99
|
+
return path.join(os.tmpdir(), `cw-agent-vendor-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.pid`);
|
|
100
|
+
}
|
|
101
|
+
/** Reap the vendor process a shipped wrapper recorded, then remove the sidecar.
|
|
102
|
+
* A wrapper that exited cleanly already removed the file, so this is a no-op
|
|
103
|
+
* then; only a wrapper cw SIGKILLed on a timeout leaves a live vendor PID
|
|
104
|
+
* here. Best-effort and race-tolerant: a missing file, an unparseable/too-low
|
|
105
|
+
* PID, or an already-gone process are all silently fine. Scope: kills the
|
|
106
|
+
* vendor process itself (the token spender), not a deeper grandchild tree,
|
|
107
|
+
* and only for the shipped vendor wrappers (claude, codex, gemini, and
|
|
108
|
+
* opencode/deepseek) -- an arbitrary CW_AGENT_COMMAND records no PID and is
|
|
109
|
+
* not covered. Returns true only if it
|
|
110
|
+
* actually signalled a process. Exported for tests; agent.ts is not part of
|
|
111
|
+
* the package public surface (index.ts). */
|
|
112
|
+
function reapRecordedVendor(pidFile) {
|
|
113
|
+
let raw;
|
|
114
|
+
try {
|
|
115
|
+
raw = fs.readFileSync(pidFile, "utf8");
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false; // no sidecar => the wrapper cleaned up (or never recorded one)
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(pidFile);
|
|
122
|
+
}
|
|
123
|
+
catch { /* already gone */ }
|
|
124
|
+
const pid = Number(raw.trim());
|
|
125
|
+
if (!Number.isInteger(pid) || pid <= 1)
|
|
126
|
+
return false;
|
|
127
|
+
try {
|
|
128
|
+
process.kill(pid, "SIGKILL");
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return false; // already exited, or a pid we may not signal
|
|
133
|
+
}
|
|
134
|
+
}
|
|
88
135
|
function buildAgentChildEnv(policy, baseEnv = process.env) {
|
|
89
136
|
const env = (0, local_1.buildChildEnv)(policy, baseEnv);
|
|
90
137
|
// deny must win here too: buildChildEnv already applied it above, but the
|
|
@@ -98,6 +145,14 @@ function buildAgentChildEnv(policy, baseEnv = process.env) {
|
|
|
98
145
|
for (const key of Object.keys(baseEnv)) {
|
|
99
146
|
if (denied.has(key))
|
|
100
147
|
continue;
|
|
148
|
+
// Parent-only CW secrets are never re-added, even though they match the
|
|
149
|
+
// CW_ arm of AGENT_PROVIDER_KEY_ENV_RE and even if the operator forgot to
|
|
150
|
+
// deny them. buildChildEnv already stripped them above; this keeps the
|
|
151
|
+
// re-add loop from putting them back (and out of the trust-audit
|
|
152
|
+
// `forwarded` list). CW_AGENT_ATTEST_PRIVKEY is intentionally NOT in that
|
|
153
|
+
// set, so the attest wrapper still gets its signing key.
|
|
154
|
+
if (local_1.CW_NEVER_FORWARD_ENV.has(key))
|
|
155
|
+
continue;
|
|
101
156
|
if (AGENT_PROVIDER_KEY_ENV_RE.test(key)) {
|
|
102
157
|
env[key] = baseEnv[key];
|
|
103
158
|
forwarded.push(key);
|
|
@@ -297,9 +352,9 @@ function agentHandle(request, env = process.env) {
|
|
|
297
352
|
},
|
|
298
353
|
};
|
|
299
354
|
}
|
|
300
|
-
/** Resolve a request to a spawn-style batch job, or undefined when
|
|
301
|
-
* is endpoint-configured
|
|
302
|
-
* path). */
|
|
355
|
+
/** Resolve a request to a spawn-style (CLI-binary) batch job, or undefined when
|
|
356
|
+
* the agent is endpoint-configured (see prepareEndpointJob) or unconfigured
|
|
357
|
+
* (those settle through the serial path). */
|
|
303
358
|
function prepareAgentSpawn(request) {
|
|
304
359
|
const resolved = resolveAgentInvocation(request);
|
|
305
360
|
if (!resolved.binary)
|
|
@@ -312,6 +367,27 @@ function prepareAgentSpawn(request) {
|
|
|
312
367
|
timeoutMs: resolved.timeoutMs || 600000,
|
|
313
368
|
};
|
|
314
369
|
}
|
|
370
|
+
/** Resolve a request to an endpoint (HTTP-delegate) batch job, or undefined when
|
|
371
|
+
* the agent has a CLI binary (see prepareAgentSpawn) or is unconfigured. The
|
|
372
|
+
* POST body mirrors runAgentEndpoint's exactly: {manifest, prompt, model,
|
|
373
|
+
* resultPath, sandboxProfileId} — sandboxProfileId comes from
|
|
374
|
+
* request.sandboxPolicy.id, which runBackend sets to the same policy the serial
|
|
375
|
+
* path passes (registry.ts: `const policy = request.sandboxPolicy`), so the two
|
|
376
|
+
* paths post identical bytes. */
|
|
377
|
+
function prepareEndpointJob(request) {
|
|
378
|
+
const resolved = resolveAgentInvocation(request);
|
|
379
|
+
if (resolved.binary || !resolved.endpoint)
|
|
380
|
+
return undefined;
|
|
381
|
+
const manifest = request.manifest;
|
|
382
|
+
const job = JSON.stringify({
|
|
383
|
+
manifest,
|
|
384
|
+
prompt: manifest?.prompt,
|
|
385
|
+
model: resolved.model,
|
|
386
|
+
resultPath: manifest?.resultPath,
|
|
387
|
+
sandboxProfileId: request.sandboxPolicy.id,
|
|
388
|
+
});
|
|
389
|
+
return { endpoint: resolved.endpoint, job, timeoutMs: resolved.timeoutMs || 600000 };
|
|
390
|
+
}
|
|
315
391
|
// __dirname is dist/shell/execution-backend at runtime — THREE levels up
|
|
316
392
|
// (execution-backend -> shell -> dist -> package root) reaches scripts/,
|
|
317
393
|
// a sibling of dist/, not two (that was a bug: it resolved to a
|
|
@@ -376,6 +452,36 @@ function runAgentBatchOutcomes(jobs) {
|
|
|
376
452
|
}
|
|
377
453
|
return reconcileBatchOutcomes(jobs, child);
|
|
378
454
|
}
|
|
455
|
+
// Same package-root depth fix as BATCH_DELEGATE_CHILD_SCRIPT above.
|
|
456
|
+
const HTTP_BATCH_DELEGATE_CHILD_SCRIPT = path.resolve(__dirname, "..", "..", "..", "scripts", "children", "http-batch-delegate-child.js");
|
|
457
|
+
/** Run a batch of ENDPOINT (HTTP-delegate) jobs concurrently; outcomes
|
|
458
|
+
* index-align with jobs. The endpoint sibling of runAgentBatchOutcomes: one
|
|
459
|
+
* spawnSync of http-batch-delegate-child.js POSTs all N at once from a single
|
|
460
|
+
* process, so `--concurrency N` with an endpoint agent runs N delegations in
|
|
461
|
+
* parallel instead of serially. The child streams the SAME NDJSON line shape as
|
|
462
|
+
* batch-delegate-child.js — `{i, spawnError?, exitCode, stdout}` — so the same
|
|
463
|
+
* reconcileBatchOutcomes reads it (byte-boundary split on a Buffer, index-aligned,
|
|
464
|
+
* every job that produced no full line failed closed). Env is inherited (the
|
|
465
|
+
* serial runAgentEndpoint likewise runs the child with the full process env for
|
|
466
|
+
* any endpoint auth); no sandbox child-env is built here, matching the serial
|
|
467
|
+
* endpoint path. */
|
|
468
|
+
function runEndpointBatchOutcomes(jobs) {
|
|
469
|
+
if (!jobs.length)
|
|
470
|
+
return [];
|
|
471
|
+
const maxTimeout = Math.max(...jobs.map((job) => job.timeoutMs));
|
|
472
|
+
let child;
|
|
473
|
+
try {
|
|
474
|
+
child = (0, node_child_process_1.spawnSync)(process.execPath, [HTTP_BATCH_DELEGATE_CHILD_SCRIPT], {
|
|
475
|
+
input: JSON.stringify(jobs),
|
|
476
|
+
maxBuffer: 34 * 1024 * 1024 * jobs.length,
|
|
477
|
+
timeout: maxTimeout + 30000,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
child = { error: error instanceof Error ? error : new Error(String(error)), status: null, stdout: null };
|
|
482
|
+
}
|
|
483
|
+
return reconcileBatchOutcomes(jobs, child);
|
|
484
|
+
}
|
|
379
485
|
// ---------------------------------------------------------------------------
|
|
380
486
|
// The stateful agent runners (spawn + settle). Byte-exact port of
|
|
381
487
|
// src/execution-backend.ts's runAgentProcess (:915-995), runAgentEndpoint
|
|
@@ -415,9 +521,15 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
|
|
|
415
521
|
else {
|
|
416
522
|
const streamStderr = shouldStreamAgentStderr(process.env, Boolean(process.stderr.isTTY));
|
|
417
523
|
const built = buildAgentChildEnv(policy);
|
|
418
|
-
const childEnv = built.env;
|
|
419
524
|
forwardedEnvVars = built.forwarded;
|
|
420
525
|
const timeoutMs = resolved.timeoutMs || 600000;
|
|
526
|
+
// A shipped wrapper (claude/codex/gemini) records its vendor child's PID
|
|
527
|
+
// here so we can reap the vendor if the timeout below SIGKILLs the
|
|
528
|
+
// wrapper -- a SIGKILL is uncatchable, so the wrapper cannot forward the
|
|
529
|
+
// stop itself. Not a forwarded secret; it is cw plumbing, so it stays out
|
|
530
|
+
// of `built.forwarded` / the trust-audit forwarded list.
|
|
531
|
+
const vendorPidFile = vendorPidFilePath();
|
|
532
|
+
const childEnv = { ...built.env, CW_AGENT_VENDOR_PIDFILE: vendorPidFile };
|
|
421
533
|
const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
|
|
422
534
|
cwd: request.cwd,
|
|
423
535
|
env: childEnv,
|
|
@@ -439,12 +551,30 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
|
|
|
439
551
|
// and the null-exit-code "timed out or killed" branch was dead code
|
|
440
552
|
// for real timeouts.
|
|
441
553
|
if (child.error && child.error.code === "ETIMEDOUT") {
|
|
554
|
+
// spawnSync SIGKILLed the wrapper (uncatchable, so the wrapper could
|
|
555
|
+
// not forward the stop to its vendor child). Reap the vendor a shipped
|
|
556
|
+
// wrapper recorded, so a timed-out agent's vendor process does not live
|
|
557
|
+
// on as an orphan and keep spending. Scoped: reaps the vendor process
|
|
558
|
+
// itself for the shipped wrappers (claude, codex, gemini, opencode/
|
|
559
|
+
// deepseek), not a deeper grandchild tree nor an arbitrary
|
|
560
|
+
// CW_AGENT_COMMAND (which records no PID). We do
|
|
561
|
+
// NOT use `detached:true` -- that would make the wrapper its own group
|
|
562
|
+
// leader, so an interactive Ctrl-C / a group SIGINT to cw would no
|
|
563
|
+
// longer reach it (strictly worse); reaping from the recorded PID keeps
|
|
564
|
+
// the wrapper in cw's group.
|
|
565
|
+
reapRecordedVendor(vendorPidFile);
|
|
442
566
|
const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported", undefined, undefined, forwardedEnvVars);
|
|
443
567
|
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent process timed out after ${timeoutMs}ms and was killed (SIGKILL)`, {
|
|
444
568
|
...attestation,
|
|
445
569
|
handle: handleOut,
|
|
446
570
|
});
|
|
447
571
|
}
|
|
572
|
+
// Non-timeout paths: a wrapper that ran to any normal end removed its own
|
|
573
|
+
// sidecar on exit; clear any stray file best-effort so tmp cannot grow.
|
|
574
|
+
try {
|
|
575
|
+
fs.unlinkSync(vendorPidFile);
|
|
576
|
+
}
|
|
577
|
+
catch { /* usually already gone */ }
|
|
448
578
|
outcome = {
|
|
449
579
|
...(child.error ? { spawnError: messageOf(child.error) } : {}),
|
|
450
580
|
exitCode: typeof child.status === "number" ? child.status : null,
|
|
@@ -473,12 +603,70 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
|
|
|
473
603
|
}
|
|
474
604
|
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-target-missing", `Backend ${descriptor.id} has no command-template or endpoint configured`, attestation);
|
|
475
605
|
}
|
|
606
|
+
/** Settle a completed endpoint delegation (a numeric exitCode + stdout) into
|
|
607
|
+
* its envelope: parse the agent report, write result.md as transport when the
|
|
608
|
+
* runner returned a body and none exists yet, record the handle. Shared by the
|
|
609
|
+
* serial spawn path and the concurrent prepared-outcome path so the two never
|
|
610
|
+
* drift. The result-write stays HERE (the cw shell process), never in the batch
|
|
611
|
+
* child, so the child does no filesystem writes into the run dir. */
|
|
612
|
+
function settleEndpointResult(descriptor, label, endpoint, resolvedModel, manifest, attestation, exitCode, stdout) {
|
|
613
|
+
const report = parseAgentReport(stdout);
|
|
614
|
+
if (manifest?.resultPath && report.usage === undefined) {
|
|
615
|
+
const body = extractEndpointResult(stdout);
|
|
616
|
+
if (body && !fs.existsSync(manifest.resultPath)) {
|
|
617
|
+
try {
|
|
618
|
+
fs.writeFileSync(manifest.resultPath, body, "utf8");
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
/* the accept layer will fail closed on a missing result.md */
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
|
|
626
|
+
const handleOut = recordedAgentHandle(undefined, endpoint, [], resolvedModel, reportedModel, report.usage, report.usageSignature);
|
|
627
|
+
return (0, envelopes_1.delegatedEnvelope)(descriptor, label, handleOut, { ...attestation, handle: handleOut }, "agent-endpoint", [endpoint], exitCode, stdout);
|
|
628
|
+
}
|
|
476
629
|
/** Agent HTTP endpoint variant — POSTs the worker manifest/prompt to a
|
|
477
630
|
* configured agent endpoint via the shared Node delegate child. Byte-exact
|
|
478
|
-
* port of src/execution-backend.ts:1002-1062
|
|
631
|
+
* port of src/execution-backend.ts:1002-1062, plus a prepared-outcome branch:
|
|
632
|
+
* in a concurrent round the POST already ran in the batch child
|
|
633
|
+
* (runEndpointBatchOutcomes), so settle that pre-collected outcome instead of
|
|
634
|
+
* spawning again. The serial (no prepared outcome) path is unchanged. */
|
|
479
635
|
function runAgentEndpoint(descriptor, policy, request, label, resolved, attestation) {
|
|
480
636
|
const endpoint = resolved.endpoint;
|
|
481
637
|
const manifest = request.manifest;
|
|
638
|
+
const baseHandle = recordedAgentHandle(undefined, endpoint, [], resolved.model, "unreported");
|
|
639
|
+
// Concurrent round: the batch child already POSTed and settled this job. Any
|
|
640
|
+
// failure (network, non-2xx, poll, unparseable, timeout, no exitCode) is a
|
|
641
|
+
// spawnError; a success carries a numeric exitCode + stdout.
|
|
642
|
+
if (request.preparedAgentOutcome) {
|
|
643
|
+
const prep = request.preparedAgentOutcome;
|
|
644
|
+
if (prep.spawnError) {
|
|
645
|
+
// Match the SERIAL path's refusal wording so a concurrent round records the
|
|
646
|
+
// same reason bytes as a serial one. A per-job HTTP failure (non-2xx, poll,
|
|
647
|
+
// timeout, no exitCode) is the serial `{error}` branch — `agent endpoint
|
|
648
|
+
// error:`. A whole-batch-child process failure surfaces as
|
|
649
|
+
// reconcileBatchOutcomes' `batch delegate failed:` fallback — the serial
|
|
650
|
+
// analog is `child.error` — `agent endpoint delegation failed:`.
|
|
651
|
+
const summary = prep.spawnError.startsWith("batch delegate failed:")
|
|
652
|
+
? `agent endpoint delegation failed: ${prep.spawnError}`
|
|
653
|
+
: `agent endpoint error: ${prep.spawnError}`;
|
|
654
|
+
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", summary, {
|
|
655
|
+
...attestation,
|
|
656
|
+
handle: baseHandle,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// Defensive: the batch child always pairs a null exitCode with a spawnError,
|
|
660
|
+
// so this is unreachable in practice — kept as a fail-closed backstop with
|
|
661
|
+
// the same wording the serial no-exitCode refusal uses.
|
|
662
|
+
if (typeof prep.exitCode !== "number") {
|
|
663
|
+
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent endpoint error: no exitCode reported`, {
|
|
664
|
+
...attestation,
|
|
665
|
+
handle: baseHandle,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
return settleEndpointResult(descriptor, label, endpoint, resolved.model, manifest, attestation, prep.exitCode, prep.stdout);
|
|
669
|
+
}
|
|
482
670
|
const job = JSON.stringify({
|
|
483
671
|
manifest,
|
|
484
672
|
prompt: manifest?.prompt,
|
|
@@ -493,7 +681,6 @@ function runAgentEndpoint(descriptor, policy, request, label, resolved, attestat
|
|
|
493
681
|
timeout: resolved.timeoutMs || 600000,
|
|
494
682
|
maxBuffer: 32 * 1024 * 1024,
|
|
495
683
|
});
|
|
496
|
-
const baseHandle = recordedAgentHandle(undefined, endpoint, [], resolved.model, "unreported");
|
|
497
684
|
if (child.error) {
|
|
498
685
|
return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent endpoint delegation failed: ${messageOf(child.error)}`, {
|
|
499
686
|
...attestation,
|
|
@@ -516,20 +703,5 @@ function runAgentEndpoint(descriptor, policy, request, label, resolved, attestat
|
|
|
516
703
|
handle: baseHandle,
|
|
517
704
|
});
|
|
518
705
|
}
|
|
519
|
-
|
|
520
|
-
const report = parseAgentReport(stdout);
|
|
521
|
-
if (manifest?.resultPath && report.usage === undefined) {
|
|
522
|
-
const body = extractEndpointResult(stdout);
|
|
523
|
-
if (body && !fs.existsSync(manifest.resultPath)) {
|
|
524
|
-
try {
|
|
525
|
-
fs.writeFileSync(manifest.resultPath, body, "utf8");
|
|
526
|
-
}
|
|
527
|
-
catch {
|
|
528
|
-
/* the accept layer will fail closed on a missing result.md */
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
const reportedModel = report.model && report.model.trim() ? report.model.trim() : "unreported";
|
|
533
|
-
const handleOut = recordedAgentHandle(undefined, endpoint, [], resolved.model, reportedModel, report.usage, report.usageSignature);
|
|
534
|
-
return (0, envelopes_1.delegatedEnvelope)(descriptor, label, handleOut, { ...attestation, handle: handleOut }, "agent-endpoint", [endpoint], parsed.exitCode, stdout);
|
|
706
|
+
return settleEndpointResult(descriptor, label, endpoint, resolved.model, manifest, attestation, parsed.exitCode, String(parsed.stdout || ""));
|
|
535
707
|
}
|
|
@@ -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,6 +19,19 @@ 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
36
|
// deny must win regardless of inherit: a custom profile combining
|
|
23
37
|
// inherit:true with deny:[...] (a valid, normalized combination — see
|
|
@@ -38,6 +52,10 @@ function buildChildEnv(policy, baseEnv = process.env) {
|
|
|
38
52
|
for (const name of policy.env.deny || []) {
|
|
39
53
|
delete env[name];
|
|
40
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];
|
|
41
59
|
return env;
|
|
42
60
|
}
|
|
43
61
|
/** Shell injection guard (SPEC/execution-backend.md "Local execution"): for
|
|
@@ -75,6 +93,20 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
|
|
|
75
93
|
// agent backend's own default fallback (execution-backend/agent.ts).
|
|
76
94
|
timeout: request.timeoutMs || 600000,
|
|
77
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",
|
|
78
110
|
};
|
|
79
111
|
if (spawnStyle === "shell") {
|
|
80
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/
|