* feat(#2584): Phase 2 — worktree create verb + orchestrator-exec resolver
Phase 2 of the negotiated executor-isolation feature (ADR-1239 Codex-binding amendment). Two building blocks for `dispatch.isolation: orchestrator-worktree` hosts, both unconsumed — no scheduler wires them yet (that is Phase 3), so no runtime behavior changes.
worktree create verb (planWorktreeCreate / executeWorktreeCreatePlan / cmdWorktreeCreate in worktree-safety.cts, routed via routeWorktree in gsd-tools.cjs): validates the wave base, creates a bounded branch+worktree, records it in the run manifest reusing record-agent 4-field entry shape, returns the executor working directory. Bounded git (10s timeout, degrade-not-throw); all manifest read/parse/validate/dedupe precedes the single git side effect (no unmanifested-orphan on a bad manifest); timeout-only best-effort partial rollback (a clean collision-exit never removes a live peer worktree); fail-closed on bad base, unsafe leading-dash / .. inputs, and malformed/mis-shaped manifest.
resolveOrchestratorExec (host-integration.cts): pure descriptor->argv resolver reading the new runtime.orchestratorExec descriptor field (codex/opencode/kimi/kimi-code), fail-closed on missing/invalid shape. Validator (capability-validator.cjs) + a parity guard asserting every orchestrator-worktree host declares a resolvable orchestratorExec.
Adding the create route edits the installed gsd-core/bin/gsd-tools.cjs, so the golden-install-parity fixtures for all 19 runtimes are regenerated (npm run gen:golden) — the only changed hash is gsd-tools.cjs. CONTEXT.md glossary updated; capability-registry regenerated. Behavioral tests (worktree-safety + host-integration) incl. a fast-check property test and the parity sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: rebuild tracked state-transition.cjs to match #2400 source
The tracked compiled artifact drifted from src/state-transition.cts: #2400 (commit 2bcfaa2e2) added the progress.total_plans frontmatter sync to source but the tracked bin/lib/state-transition.cjs was never rebuilt, so the fix was not shipping to consumers of the compiled artifact. The mandatory build:lib step for Phase 2 surfaced the drift; recompiling makes the already-merged, already-changelogged #2400 fix effective. Artifact-only resync (no source/test change); drift class tracked by #2591.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "81a14ee661fc6d9e",
|
||||
"gsd-core/bin/gsd-tools.cjs": "6578e0a1bc8adb0a",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf",
|
||||
"gsd-core/bin/gsd-tools.cjs": "4b16121487e88694",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b9c55c2bbeec85fe",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "81a14ee661fc6d9e",
|
||||
"gsd-core/bin/gsd-tools.cjs": "6578e0a1bc8adb0a",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828",
|
||||
"gsd-core/bin/gsd-tools.cjs": "8edb842f617d5774",
|
||||
"gsd-core/bin/gsd-tools.cjs": "3ac4c0a9f3a509f9",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f",
|
||||
"gsd-core/bin/gsd-tools.cjs": "936cbc6c8670a2bd",
|
||||
"gsd-core/bin/gsd-tools.cjs": "551974377e74c2bd",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
2
tests/fixtures/golden-install-parity/pi.json
vendored
2
tests/fixtures/golden-install-parity/pi.json
vendored
@@ -6,7 +6,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2",
|
||||
"gsd-core/bin/gsd-tools.cjs": "3bdfaa6906a522db",
|
||||
"gsd-core/bin/gsd-tools.cjs": "509319fab53d5022",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527",
|
||||
"gsd-core/bin/gsd-tools.cjs": "9859f8d6249a0c5a",
|
||||
"gsd-core/bin/gsd-tools.cjs": "70126458e4c1216f",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e1c37ecdafc97d8f",
|
||||
"gsd-core/bin/gsd-tools.cjs": "5e8b0e86049ca8ef",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "df8f5d69659a4a34",
|
||||
"gsd-core/bin/gsd-tools.cjs": "407f4e44045c6e24",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "2f60b9eaa6cf6bc1",
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
HOOK_EVENT_SURFACES,
|
||||
extensionEventSurfaceFor,
|
||||
EXTENSION_EVENT_SURFACES,
|
||||
resolveOrchestratorExec,
|
||||
} = hi;
|
||||
|
||||
const {
|
||||
@@ -1317,3 +1318,300 @@ describe('#2584 dispatch.isolation — validator', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #2584 — ADR-1239 Codex-binding amendment, Phase 2: resolveOrchestratorExec
|
||||
// + the `runtime.orchestratorExec` descriptor field (sibling of hostBehaviors).
|
||||
// UNCONSUMED in Phase 2 — no scheduler calls this yet (Phase 3 wires it).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveOrchestratorExec — the 4 shipped orchestrator-worktree descriptors', () => {
|
||||
const CWD = '/repo/.claude/worktrees/agent-a1';
|
||||
|
||||
test('codex: exec --cd <cwd>', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'codex', args: ['exec'], cwdFlag: '--cd' }, CWD);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.command, 'codex');
|
||||
assert.deepEqual(result.args, ['exec', '--cd', CWD]);
|
||||
assert.equal(result.cwd, CWD);
|
||||
});
|
||||
|
||||
test('opencode: run --dir <cwd>', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'opencode', args: ['run'], cwdFlag: '--dir' }, CWD);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.command, 'opencode');
|
||||
assert.deepEqual(result.args, ['run', '--dir', CWD]);
|
||||
assert.equal(result.cwd, CWD);
|
||||
});
|
||||
|
||||
test('kimi: --work-dir <cwd> (no leading verb)', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'kimi', args: [], cwdFlag: '--work-dir' }, CWD);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.command, 'kimi');
|
||||
assert.deepEqual(result.args, ['--work-dir', CWD]);
|
||||
assert.equal(result.cwd, CWD);
|
||||
});
|
||||
|
||||
test('kimi-code: cwdFlag:null — NO flag appended, cwd still returned (process-cwd case)', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'kimi-code', args: [], cwdFlag: null }, CWD);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.command, 'kimi-code');
|
||||
assert.deepEqual(result.args, []);
|
||||
assert.equal(result.cwd, CWD);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrchestratorExec — fail-closed', () => {
|
||||
test('undefined descriptor → missing_command', () => {
|
||||
const result = resolveOrchestratorExec(undefined, '/repo/wt');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'missing_command');
|
||||
});
|
||||
|
||||
test('{} (no command) → missing_command', () => {
|
||||
const result = resolveOrchestratorExec({}, '/repo/wt');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'missing_command');
|
||||
});
|
||||
|
||||
test('command: "" (empty string) → missing_command', () => {
|
||||
const result = resolveOrchestratorExec({ command: '' }, '/repo/wt');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'missing_command');
|
||||
});
|
||||
|
||||
test('command: non-string → missing_command', () => {
|
||||
for (const bogus of [42, null, {}, []]) {
|
||||
const result = resolveOrchestratorExec({ command: bogus }, '/repo/wt');
|
||||
assert.equal(result.ok, false, `command=${JSON.stringify(bogus)} must fail`);
|
||||
assert.equal(result.reason, 'missing_command');
|
||||
}
|
||||
});
|
||||
|
||||
test('empty cwd → invalid_cwd', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'codex' }, '');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_cwd');
|
||||
});
|
||||
|
||||
test('non-string cwd → invalid_cwd', () => {
|
||||
for (const bogus of [undefined, null, 42, {}]) {
|
||||
const result = resolveOrchestratorExec({ command: 'codex' }, bogus);
|
||||
assert.equal(result.ok, false, `cwd=${JSON.stringify(bogus)} must fail`);
|
||||
assert.equal(result.reason, 'invalid_cwd');
|
||||
}
|
||||
});
|
||||
|
||||
test('args not an array → invalid_args', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'codex', args: 'exec' }, '/repo/wt');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_args');
|
||||
});
|
||||
|
||||
test('args array with a non-string element → invalid_args', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'codex', args: ['exec', 42] }, '/repo/wt');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_args');
|
||||
});
|
||||
|
||||
test('cwdFlag a number → invalid_cwd_flag', () => {
|
||||
const result = resolveOrchestratorExec({ command: 'codex', cwdFlag: 42 }, '/repo/wt');
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_cwd_flag');
|
||||
});
|
||||
|
||||
test('cwdFlag an object/array → invalid_cwd_flag', () => {
|
||||
for (const bogus of [{}, []]) {
|
||||
const result = resolveOrchestratorExec({ command: 'codex', cwdFlag: bogus }, '/repo/wt');
|
||||
assert.equal(result.ok, false, `cwdFlag=${JSON.stringify(bogus)} must fail`);
|
||||
assert.equal(result.reason, 'invalid_cwd_flag');
|
||||
}
|
||||
});
|
||||
|
||||
test('a reserved-name command string ("__proto__") still resolves at the resolver layer', () => {
|
||||
// The resolver only checks "is it a non-empty string" — it never does a
|
||||
// property lookup keyed by `command`, so there is no prototype-pollution
|
||||
// surface here. The VALIDATOR (capability-validator.cjs) is what rejects
|
||||
// "__proto__" at descriptor-load time — see the validator describe block below.
|
||||
const result = resolveOrchestratorExec({ command: '__proto__' }, '/repo/wt');
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.command, '__proto__');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOrchestratorExec — fast-check property test', () => {
|
||||
const commandArb = fc.string({ minLength: 1 }).filter((s) => s.length > 0);
|
||||
const argsArb = fc.array(fc.string());
|
||||
const cwdFlagArb = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.constant(undefined),
|
||||
fc.string({ minLength: 1 }).filter((s) => s.length > 0),
|
||||
);
|
||||
const cwdArb = fc.string({ minLength: 1 }).filter((s) => s.length > 0);
|
||||
|
||||
test('property: ok:true, command preserved, cwdFlag appended exactly once (or never for null/absent)', () => {
|
||||
fc.assert(
|
||||
fc.property(commandArb, argsArb, cwdFlagArb, cwdArb, (command, args, cwdFlag, cwd) => {
|
||||
// Guard against the astronomically-rare but non-zero case where the
|
||||
// arbitrary `args` already happens to contain the exact `cwd` string —
|
||||
// that would make "cwd appears exactly once, at the tail" a false
|
||||
// assertion about pre-existing data rather than the resolver's own
|
||||
// behavior. Deterministic given the seed; not a flakiness workaround.
|
||||
fc.pre(!args.includes(cwd));
|
||||
const descriptor = cwdFlag === undefined ? { command, args } : { command, args, cwdFlag };
|
||||
const result = resolveOrchestratorExec(descriptor, cwd);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.command, command);
|
||||
assert.equal(result.cwd, cwd);
|
||||
if (typeof cwdFlag === 'string' && cwdFlag.length > 0) {
|
||||
assert.deepEqual(result.args.slice(-2), [cwdFlag, cwd]);
|
||||
// exactly once: cwdFlag/cwd do not appear anywhere earlier in args
|
||||
const earlier = result.args.slice(0, -2);
|
||||
assert.ok(!earlier.includes(cwd), 'cwd must not appear before the trailing pair');
|
||||
} else {
|
||||
assert.ok(!result.args.includes(cwd), 'cwd must not appear in args when cwdFlag is null/absent');
|
||||
}
|
||||
}),
|
||||
{ numRuns: 200, seed: 2584 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2584 orchestratorExec — parity / divergence guard', () => {
|
||||
const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities');
|
||||
|
||||
function loadCapability(id) {
|
||||
return JSON.parse(fs.readFileSync(path.join(CAPABILITIES_DIR, id, 'capability.json'), 'utf8'));
|
||||
}
|
||||
|
||||
test('every capability whose dispatch.isolation is "orchestrator-worktree" declares a resolvable orchestratorExec', () => {
|
||||
const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => {
|
||||
const capPath = path.join(CAPABILITIES_DIR, entry, 'capability.json');
|
||||
return fs.existsSync(capPath);
|
||||
});
|
||||
const orchestratorWorktreeHosts = [];
|
||||
for (const id of capIds) {
|
||||
const cap = loadCapability(id);
|
||||
const iso = cap && cap.runtime && cap.runtime.hostIntegration && cap.runtime.hostIntegration.dispatch
|
||||
&& cap.runtime.hostIntegration.dispatch.isolation;
|
||||
if (iso !== 'orchestrator-worktree') continue;
|
||||
orchestratorWorktreeHosts.push(id);
|
||||
|
||||
const orchestratorExec = cap.runtime.orchestratorExec;
|
||||
assert.ok(
|
||||
orchestratorExec,
|
||||
`${id}: dispatch.isolation:"orchestrator-worktree" but no runtime.orchestratorExec declared — ` +
|
||||
`a future orchestrator-worktree host MUST declare orchestratorExec (Phase 3 has nothing to spawn otherwise)`,
|
||||
);
|
||||
const result = resolveOrchestratorExec(orchestratorExec, '/tmp/wt');
|
||||
assert.equal(result.ok, true,
|
||||
`${id}: runtime.orchestratorExec must resolve cleanly; got reason="${result.ok ? '' : result.reason}"`);
|
||||
}
|
||||
// Sanity: the sweep actually found the 4 known hosts (guards a broken sweep
|
||||
// silently matching zero capabilities and passing vacuously).
|
||||
assert.deepEqual(orchestratorWorktreeHosts.sort(), ['codex', 'kimi', 'kimi-code', 'opencode']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2584 orchestratorExec — validator', () => {
|
||||
function shippedCodexCapabilityWithoutOrchestratorExec() {
|
||||
const cap = JSON.parse(
|
||||
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', 'codex', 'capability.json'), 'utf8'),
|
||||
);
|
||||
delete cap.runtime.orchestratorExec;
|
||||
return cap;
|
||||
}
|
||||
|
||||
test('a descriptor that omits orchestratorExec entirely still validates clean (optional field)', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.deepEqual(errors, [], `omitted orchestratorExec must validate clean, got: ${JSON.stringify(errors)}`);
|
||||
});
|
||||
|
||||
test('a well-formed orchestratorExec passes with zero orchestratorExec errors', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { command: 'codex', args: ['exec'], cwdFlag: '--cd' };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
const oeErrors = errors.filter((e) => e.includes('orchestratorExec'));
|
||||
assert.deepEqual(oeErrors, []);
|
||||
});
|
||||
|
||||
test('orchestratorExec: not an object (array/null/string) → rejected', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
for (const bogus of [[], null, 'codex', 42]) {
|
||||
cap.runtime.orchestratorExec = bogus;
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('runtime.orchestratorExec must be an object')),
|
||||
`orchestratorExec=${JSON.stringify(bogus)} must be rejected; got: ${JSON.stringify(errors)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('command missing → rejected', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { args: [], cwdFlag: null };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.command')));
|
||||
});
|
||||
|
||||
test('command: "" (empty string) → rejected', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { command: '' };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.command')));
|
||||
});
|
||||
|
||||
test('command: "__proto__" (reserved name) → rejected', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { command: '__proto__' };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('runtime.orchestratorExec.command') && e.includes('reserved name')),
|
||||
`"__proto__" must produce a reserved-name validator error; got: ${JSON.stringify(errors)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('args: non-array → rejected', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { command: 'codex', args: 'exec' };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.args')));
|
||||
});
|
||||
|
||||
test('args: array with a non-string element → rejected', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { command: 'codex', args: ['exec', 42] };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.args')));
|
||||
});
|
||||
|
||||
test('cwdFlag: a number → rejected', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { command: 'codex', cwdFlag: 42 };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
assert.ok(errors.some((e) => e.includes('runtime.orchestratorExec.cwdFlag')));
|
||||
});
|
||||
|
||||
test('cwdFlag: null is valid (no error)', () => {
|
||||
const cap = shippedCodexCapabilityWithoutOrchestratorExec();
|
||||
cap.runtime.orchestratorExec = { command: 'kimi-code', args: [], cwdFlag: null };
|
||||
const errors = validateCapability(cap, 'codex');
|
||||
const oeErrors = errors.filter((e) => e.includes('orchestratorExec'));
|
||||
assert.deepEqual(oeErrors, []);
|
||||
});
|
||||
|
||||
test('full descriptor sweep: every shipped capability.json still validates clean after the orchestratorExec addition', () => {
|
||||
const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities');
|
||||
const capIds = fs.readdirSync(CAPABILITIES_DIR).filter((entry) => {
|
||||
const capPath = path.join(CAPABILITIES_DIR, entry, 'capability.json');
|
||||
return fs.existsSync(capPath);
|
||||
});
|
||||
for (const id of capIds) {
|
||||
const cap = JSON.parse(fs.readFileSync(path.join(CAPABILITIES_DIR, id, 'capability.json'), 'utf8'));
|
||||
if (cap.role !== 'runtime') continue;
|
||||
const errors = validateCapability(cap, id);
|
||||
assert.deepEqual(errors, [], `${id}: capability.json must still validate clean; got: ${JSON.stringify(errors)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,9 @@ const {
|
||||
executeWorktreeWaveCleanupPlan,
|
||||
planWorktreeRecordAgent,
|
||||
cmdWorktreeRecordAgent,
|
||||
planWorktreeCreate,
|
||||
executeWorktreeCreatePlan,
|
||||
cmdWorktreeCreate,
|
||||
} = require(WORKTREE_SAFETY_PATH);
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
@@ -1046,6 +1049,539 @@ describe('worktree record-agent — real CLI dispatch (#1298)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── worktree create (#2584 ADR-1239 Codex-binding amendment — Phase 2) ───────
|
||||
// UNCONSUMED in Phase 2: no scheduler calls this yet (Phase 3 wires it). These
|
||||
// tests pin the git-worktree-creation primitive itself.
|
||||
|
||||
describe('planWorktreeCreate', () => {
|
||||
const okFields = {
|
||||
agentId: 'a1',
|
||||
worktreePath: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
base: 'abc123',
|
||||
};
|
||||
|
||||
test('happy path returns ok:true with the normalized entry', () => {
|
||||
const plan = planWorktreeCreate(okFields);
|
||||
assert.equal(plan.ok, true);
|
||||
assert.equal(plan.reason, 'ok');
|
||||
assert.equal(plan.entry.agent_id, 'a1');
|
||||
assert.equal(plan.entry.worktree_path, okFields.worktreePath);
|
||||
assert.equal(plan.entry.branch, 'worktree-agent-a1');
|
||||
assert.equal(plan.entry.expected_base, 'abc123');
|
||||
});
|
||||
|
||||
test('missing --agent-id reports the missing flag', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, agentId: '' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'missing_field');
|
||||
assert.match(plan.hint, /--agent-id/);
|
||||
assert.equal(plan.entry, null);
|
||||
});
|
||||
|
||||
test('whitespace-only field is treated as missing', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, base: ' ' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'missing_field');
|
||||
assert.match(plan.hint, /--base/);
|
||||
});
|
||||
|
||||
test('branch-regex fail-closed: a branch outside the worktree-agent-* namespace is rejected', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, branch: 'not-worktree-agent-x' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'invalid_entry');
|
||||
assert.equal(plan.entry, null);
|
||||
});
|
||||
|
||||
test('accepts the current agent-<id> namespace (#1995)', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, branch: 'agent-a1' });
|
||||
assert.equal(plan.ok, true);
|
||||
assert.equal(plan.entry.branch, 'agent-a1');
|
||||
});
|
||||
|
||||
// ─── #2584 FIX 4: git argument-injection + path-traversal guards ──────────
|
||||
|
||||
test('FIX4: a leading-dash branch is rejected (fail-closed via the pre-existing branch-namespace regex)', () => {
|
||||
// The leading-dash guard runs AFTER normalizeCleanupManifestEntry (per the
|
||||
// fix ordering), and WORKTREE_AGENT_BRANCH_RE anchors on `^(worktree-)?agent-`
|
||||
// — no string starting with '-' can ever match it. So a dash-prefixed branch
|
||||
// is already fully fail-closed by the EARLIER regex gate (reason
|
||||
// 'invalid_entry') and never reaches the leading-dash check at all; the
|
||||
// net security property (a branch value can never reach git as a bare
|
||||
// flag) holds either way.
|
||||
const plan = planWorktreeCreate({ ...okFields, branch: '-x' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'invalid_entry');
|
||||
assert.equal(plan.entry, null);
|
||||
});
|
||||
|
||||
test('FIX4: a leading-dash base is rejected', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, base: '-f' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'unsafe_leading_dash');
|
||||
});
|
||||
|
||||
test('FIX4: a leading-dash path is rejected', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, worktreePath: '-f' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'unsafe_leading_dash');
|
||||
});
|
||||
|
||||
test('FIX4: a ".." path-traversal segment is rejected (POSIX separator)', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, worktreePath: 'a/../../etc/x' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'unsafe_path_traversal');
|
||||
});
|
||||
|
||||
test('FIX4: a ".." path-traversal segment is rejected (Windows separator)', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, worktreePath: 'a\\..\\..\\etc\\x' });
|
||||
assert.equal(plan.ok, false);
|
||||
assert.equal(plan.reason, 'unsafe_path_traversal');
|
||||
});
|
||||
|
||||
test('FIX4: a normal path with a hyphen in a segment name still passes', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, worktreePath: '/tmp/wt-1' });
|
||||
assert.equal(plan.ok, true);
|
||||
});
|
||||
|
||||
test('FIX4: an absolute path is NOT rejected (the orchestrator legitimately uses absolute paths)', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, worktreePath: '/repo/.claude/worktrees/agent-a1' });
|
||||
assert.equal(plan.ok, true);
|
||||
assert.equal(plan.entry.worktree_path, '/repo/.claude/worktrees/agent-a1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeWorktreeCreatePlan', () => {
|
||||
const okFields = {
|
||||
agentId: 'a1',
|
||||
worktreePath: '/repo/.claude/worktrees/agent-a1',
|
||||
branch: 'worktree-agent-a1',
|
||||
base: 'abc123',
|
||||
};
|
||||
|
||||
test('base_unresolved: a non-zero rev-parse --verify blocks BEFORE any worktree add is attempted', () => {
|
||||
const plan = planWorktreeCreate(okFields);
|
||||
const calls = [];
|
||||
const execGit = (args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === 'rev-parse') return { exitCode: 1, stdout: '', stderr: 'fatal: bad revision', timedOut: false };
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
};
|
||||
const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'base_unresolved');
|
||||
assert.ok(!calls.some((c) => c[0] === 'worktree'), 'must NOT attempt `git worktree add` when the base is unresolved');
|
||||
});
|
||||
|
||||
test('successful create: ok:true, cwd is the posix-normalized worktree path', () => {
|
||||
const plan = planWorktreeCreate(okFields);
|
||||
const calls = [];
|
||||
const execGit = (args) => {
|
||||
calls.push(args);
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
};
|
||||
const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.reason, 'created');
|
||||
assert.equal(result.cwd, okFields.worktreePath);
|
||||
assert.equal(result.worktree_path, okFields.worktreePath);
|
||||
assert.equal(result.branch, 'worktree-agent-a1');
|
||||
assert.equal(result.base, 'abc123');
|
||||
assert.ok(calls.some((c) => c[0] === 'worktree' && c[1] === 'add'), 'must call `git worktree add`');
|
||||
});
|
||||
|
||||
test('timeout on the base check degrades to git_timeout — does not throw, and no rollback is attempted (nothing was created)', () => {
|
||||
const plan = planWorktreeCreate(okFields);
|
||||
const calls = [];
|
||||
const timeoutStub = makeTimeoutStub();
|
||||
const execGit = (args, opts) => { calls.push(args); return timeoutStub(args, opts); };
|
||||
const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'git_timeout');
|
||||
assert.ok(!calls.some((c) => c[0] === 'worktree'), 'must NOT attempt any `git worktree` call (nothing was ever created)');
|
||||
});
|
||||
|
||||
test('FIX3: timeout on `git worktree add` degrades to git_timeout AND best-effort rolls back the partial worktree', () => {
|
||||
const plan = planWorktreeCreate(okFields);
|
||||
const timeoutStub = makeTimeoutStub();
|
||||
const calls = [];
|
||||
const execGit = (args, opts) => {
|
||||
calls.push(args);
|
||||
if (args[0] === 'rev-parse') return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
return timeoutStub(args, opts);
|
||||
};
|
||||
const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'git_timeout');
|
||||
const rollbackCall = calls.find((c) => c[0] === 'worktree' && c[1] === 'remove');
|
||||
assert.ok(rollbackCall, 'a best-effort `git worktree remove --force` rollback must be attempted');
|
||||
assert.ok(rollbackCall.includes('--force'));
|
||||
assert.equal(rollbackCall[rollbackCall.length - 1], okFields.worktreePath);
|
||||
});
|
||||
|
||||
test('FIX5 [data-loss guard]: worktree_add_failed (clean collision exit) does NOT roll back — leaves a pre-existing peer worktree untouched', () => {
|
||||
// The most common non-zero `add` exit is a COLLISION: the target
|
||||
// path/branch is already a registered worktree. git fails FAST there and
|
||||
// creates nothing. The branch namespace this verb writes into
|
||||
// (worktree-agent-*/agent-*) IS the concurrent-executor namespace, so a
|
||||
// colliding path is very plausibly a LIVE PEER executor — rolling it back
|
||||
// would destroy real, uncommitted work. This must NEVER call
|
||||
// `git worktree remove`.
|
||||
const plan = planWorktreeCreate(okFields);
|
||||
const calls = [];
|
||||
const execGit = (args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === 'rev-parse') return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
return { exitCode: 128, stdout: '', stderr: `fatal: '${okFields.worktreePath}' already exists`, timedOut: false };
|
||||
};
|
||||
const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'worktree_add_failed');
|
||||
assert.match(result.stderr, /already exists/);
|
||||
assert.ok(
|
||||
!calls.some((c) => c[0] === 'worktree' && c[1] === 'remove'),
|
||||
'a clean non-zero `add` exit must NEVER trigger a rollback — the colliding path may be a live peer executor',
|
||||
);
|
||||
});
|
||||
|
||||
test('the timeout-path rollback never masks the original failure even if the rollback execGit itself throws', () => {
|
||||
// The throwing-rollback contract is only exercised on the SURVIVING
|
||||
// rollback call site (the timeout path) after FIX 5 removed the
|
||||
// clean-exit rollback.
|
||||
const plan = planWorktreeCreate(okFields);
|
||||
const execGit = (args) => {
|
||||
if (args[0] === 'rev-parse') return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
if (args[0] === 'worktree' && args[1] === 'add') {
|
||||
return { exitCode: null, stdout: '', stderr: '', timedOut: true, signal: 'SIGTERM' };
|
||||
}
|
||||
throw new Error('rollback execGit exploded');
|
||||
};
|
||||
const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'git_timeout');
|
||||
});
|
||||
|
||||
test('a skip plan (invalid_entry) is echoed back without any git call', () => {
|
||||
const plan = planWorktreeCreate({ ...okFields, branch: 'not-worktree-agent-x' });
|
||||
const calls = [];
|
||||
const result = executeWorktreeCreatePlan(plan, '/repo/main', { execGit: (args) => { calls.push(args); return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; } });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_entry');
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('Windows path: a worktreePath with backslashes is posix-normalized in the result (worktree_path and cwd)',
|
||||
() => {
|
||||
const winFields = { ...okFields, worktreePath: 'C:\\repo\\.claude\\worktrees\\agent-a1' };
|
||||
const plan = planWorktreeCreate(winFields);
|
||||
const execGit = () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false });
|
||||
const result = executeWorktreeCreatePlan(plan, 'C:\\repo\\main', { execGit });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.cwd, 'C:/repo/.claude/worktrees/agent-a1');
|
||||
assert.equal(result.worktree_path, 'C:/repo/.claude/worktrees/agent-a1');
|
||||
assert.ok(!result.cwd.includes('\\'), 'cwd must contain no backslashes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cmdWorktreeCreate', () => {
|
||||
function withExitCode(fn) {
|
||||
const saved = process.exitCode;
|
||||
try { return fn(); } finally { process.exitCode = saved; }
|
||||
}
|
||||
|
||||
const okArgs = [
|
||||
'--manifest', 'manifest.json',
|
||||
'--agent-id', 'a1',
|
||||
'--path', '/repo/.claude/worktrees/agent-a1',
|
||||
'--branch', 'worktree-agent-a1',
|
||||
'--base', 'abc123',
|
||||
];
|
||||
|
||||
function okExecGit() {
|
||||
return () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false });
|
||||
}
|
||||
|
||||
test('creates the worktree and appends the entry to an empty manifest', () => {
|
||||
let writtenPath = null;
|
||||
let writtenContent = null;
|
||||
const out = [];
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
|
||||
writeFile: (p, c) => { writtenPath = p; writtenContent = c; },
|
||||
write: (s) => out.push(s),
|
||||
writeErr: () => {},
|
||||
execGit: okExecGit(),
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.reason, 'created');
|
||||
assert.equal(result.cwd, '/repo/.claude/worktrees/agent-a1');
|
||||
assert.equal(writtenPath, path.resolve('/repo/main', 'manifest.json'));
|
||||
const written = JSON.parse(writtenContent);
|
||||
assert.equal(written.worktrees.length, 1);
|
||||
assert.equal(written.worktrees[0].agent_id, 'a1');
|
||||
assert.equal(written.worktrees[0].worktree_path, '/repo/.claude/worktrees/agent-a1');
|
||||
assert.equal(written.worktrees[0].branch, 'worktree-agent-a1');
|
||||
assert.equal(written.worktrees[0].expected_base, 'abc123');
|
||||
assert.deepEqual(
|
||||
Object.keys(written.worktrees[0]).sort(),
|
||||
['agent_id', 'branch', 'expected_base', 'worktree_path'],
|
||||
'FIX2: the on-disk entry must be the minimal 4-field shape — no derived allowed_bases',
|
||||
);
|
||||
assert.match(out.join(''), /"ok": true/);
|
||||
});
|
||||
|
||||
test('boundary: appending to a manifest with 1 existing entry yields 2', () => {
|
||||
let writtenContent = null;
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => JSON.stringify({
|
||||
orchestrator_root: '/repo/main',
|
||||
worktrees: [{ agent_id: 'other', worktree_path: '/repo/.claude/worktrees/agent-other', branch: 'worktree-agent-other', expected_base: 'def456' }],
|
||||
}),
|
||||
writeFile: (_p, c) => { writtenContent = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: okExecGit(),
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
const written = JSON.parse(writtenContent);
|
||||
assert.equal(written.worktrees.length, 2);
|
||||
});
|
||||
|
||||
test('boundary: appending to a manifest with 2 existing entries yields 3', () => {
|
||||
let writtenContent = null;
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => JSON.stringify({
|
||||
orchestrator_root: '/repo/main',
|
||||
worktrees: [
|
||||
{ agent_id: 'x1', worktree_path: '/repo/.claude/worktrees/agent-x1', branch: 'worktree-agent-x1', expected_base: 'def456' },
|
||||
{ agent_id: 'x2', worktree_path: '/repo/.claude/worktrees/agent-x2', branch: 'worktree-agent-x2', expected_base: 'def456' },
|
||||
],
|
||||
}),
|
||||
writeFile: (_p, c) => { writtenContent = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: okExecGit(),
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
const written = JSON.parse(writtenContent);
|
||||
assert.equal(written.worktrees.length, 3);
|
||||
});
|
||||
|
||||
test('dedupe: re-recording an identical (worktree_path, branch) entry does NOT grow the manifest', () => {
|
||||
let writtenContent = null;
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => JSON.stringify({
|
||||
orchestrator_root: '/repo/main',
|
||||
worktrees: [
|
||||
{ agent_id: 'a1', worktree_path: '/repo/.claude/worktrees/agent-a1', branch: 'worktree-agent-a1', expected_base: 'abc123' },
|
||||
],
|
||||
}),
|
||||
writeFile: (_p, c) => { writtenContent = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: okExecGit(),
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
const written = JSON.parse(writtenContent);
|
||||
assert.equal(written.worktrees.length, 1, 'dedupe must not append a second identical entry');
|
||||
});
|
||||
|
||||
test('exits 2 with usage when --manifest is missing', () => {
|
||||
withExitCode(() => {
|
||||
const errs = [];
|
||||
const result = cmdWorktreeCreate('/repo/main', ['--agent-id', 'a1'], {
|
||||
writeErr: (s) => errs.push(s),
|
||||
write: () => {},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'usage');
|
||||
assert.equal(process.exitCode, 2);
|
||||
assert.match(errs.join(''), /Usage: worktree create/);
|
||||
});
|
||||
});
|
||||
|
||||
test('exits 1 loudly when the manifest cannot be read', () => {
|
||||
withExitCode(() => {
|
||||
const errs = [];
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => { throw new Error('ENOENT'); },
|
||||
writeErr: (s) => errs.push(s),
|
||||
write: () => {},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'manifest_read_failed');
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.match(errs.join(''), /manifest_read_failed/);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not write the manifest and does not call git when the entry is invalid', () => {
|
||||
withExitCode(() => {
|
||||
let wrote = false;
|
||||
let gitCalled = false;
|
||||
const result = cmdWorktreeCreate('/repo/main',
|
||||
['--manifest', 'm.json', '--agent-id', 'a1', '--path', '/p', '--branch', 'feature/x', '--base', 'abc123'], {
|
||||
readFile: () => '{"worktrees":[]}',
|
||||
writeFile: () => { wrote = true; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: () => { gitCalled = true; return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; },
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_entry');
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(gitCalled, false, 'must not call git before the entry validates');
|
||||
assert.equal(process.exitCode, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not write the manifest when the base is unresolved', () => {
|
||||
withExitCode(() => {
|
||||
let wrote = false;
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => '{"worktrees":[]}',
|
||||
writeFile: () => { wrote = true; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: (args) => (args[0] === 'rev-parse'
|
||||
? { exitCode: 1, stdout: '', stderr: 'fatal: bad revision', timedOut: false }
|
||||
: { exitCode: 0, stdout: '', stderr: '', timedOut: false }),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'base_unresolved');
|
||||
assert.equal(wrote, false, 'must not write the manifest when the worktree was never created');
|
||||
assert.equal(process.exitCode, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2584 FIX 1: manifest work must ALL run before the git side effect ───
|
||||
|
||||
test('FIX1: a malformed (truncated JSON) manifest fails with the parse reason BEFORE any git command runs', () => {
|
||||
withExitCode(() => {
|
||||
let addCalled = false;
|
||||
let wrote = false;
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => '{"worktrees": [', // truncated JSON — a git stub here WOULD succeed if ever called
|
||||
writeFile: () => { wrote = true; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: (args) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'add') addCalled = true;
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'invalid_manifest_json');
|
||||
assert.equal(addCalled, false, 'git worktree add must never be invoked when the manifest fails to parse (no orphan possible)');
|
||||
assert.equal(wrote, false);
|
||||
assert.equal(process.exitCode, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('FIX1: a manifest whose "worktrees" is not an array fails with manifest_shape_invalid BEFORE any git command runs', () => {
|
||||
withExitCode(() => {
|
||||
let addCalled = false;
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => JSON.stringify({ orchestrator_root: '/repo/main', worktrees: 'not-an-array' }),
|
||||
writeFile: () => {},
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: (args) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'add') addCalled = true;
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'manifest_shape_invalid');
|
||||
assert.equal(addCalled, false, 'git worktree add must never be invoked when the manifest shape is invalid');
|
||||
assert.equal(process.exitCode, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('FIX1: a writeFile failure AFTER a successful git create rolls back the worktree and reports manifest_write_failed', () => {
|
||||
withExitCode(() => {
|
||||
const gitCalls = [];
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
|
||||
writeFile: () => { throw new Error('EACCES: permission denied'); },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: (args) => { gitCalls.push(args); return { exitCode: 0, stdout: '', stderr: '', timedOut: false }; },
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'manifest_write_failed');
|
||||
assert.equal(process.exitCode, 1);
|
||||
assert.ok(gitCalls.some((c) => c[0] === 'worktree' && c[1] === 'add'), 'the worktree must actually have been created before the write failed');
|
||||
const rollbackCall = gitCalls.find((c) => c[0] === 'worktree' && c[1] === 'remove');
|
||||
assert.ok(rollbackCall, 'a git worktree remove --force rollback call must be made after a manifest write failure');
|
||||
assert.ok(rollbackCall.includes('--force'));
|
||||
assert.equal(rollbackCall[rollbackCall.length - 1], '/repo/.claude/worktrees/agent-a1');
|
||||
});
|
||||
});
|
||||
|
||||
test('FIX1: a writeFile failure does not throw past cmdWorktreeCreate even when the rollback execGit itself throws', () => {
|
||||
withExitCode(() => {
|
||||
const result = cmdWorktreeCreate('/repo/main', okArgs, {
|
||||
readFile: () => '{"orchestrator_root":"/repo/main","worktrees":[]}',
|
||||
writeFile: () => { throw new Error('ENOSPC: no space left on device'); },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: (args) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'remove') throw new Error('rollback execGit exploded');
|
||||
return { exitCode: 0, stdout: '', stderr: '', timedOut: false };
|
||||
},
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.reason, 'manifest_write_failed');
|
||||
assert.equal(process.exitCode, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2584 FIX 2: on-disk entry-shape parity between create and record-agent ──
|
||||
// Generative-fix-divergence guard: both verbs write into the SAME manifest, so
|
||||
// they must persist the identical field-set for equivalent inputs.
|
||||
|
||||
describe('cmdWorktreeCreate / cmdWorktreeRecordAgent — on-disk entry parity (#2584 FIX 2)', () => {
|
||||
test('both verbs persist the identical 4-field entry for equivalent inputs', () => {
|
||||
const argsFor = (manifestFlag) => [
|
||||
manifestFlag, 'manifest.json',
|
||||
'--agent-id', 'a1',
|
||||
'--path', '/repo/.claude/worktrees/agent-a1',
|
||||
'--branch', 'worktree-agent-a1',
|
||||
'--base', 'abc123',
|
||||
];
|
||||
|
||||
let createdContent = null;
|
||||
cmdWorktreeCreate('/repo/main', argsFor('--manifest'), {
|
||||
readFile: () => '{"worktrees":[]}',
|
||||
writeFile: (_p, c) => { createdContent = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
execGit: () => ({ exitCode: 0, stdout: '', stderr: '', timedOut: false }),
|
||||
});
|
||||
|
||||
let recordedContent = null;
|
||||
cmdWorktreeRecordAgent('/repo/main', argsFor('--manifest'), {
|
||||
readFile: () => '{"worktrees":[]}',
|
||||
writeFile: (_p, c) => { recordedContent = c; },
|
||||
write: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
assert.ok(createdContent, 'cmdWorktreeCreate must have written a manifest');
|
||||
assert.ok(recordedContent, 'cmdWorktreeRecordAgent must have written a manifest');
|
||||
const createdEntry = JSON.parse(createdContent).worktrees[0];
|
||||
const recordedEntry = JSON.parse(recordedContent).worktrees[0];
|
||||
assert.deepEqual(
|
||||
Object.keys(createdEntry).sort(),
|
||||
Object.keys(recordedEntry).sort(),
|
||||
'both verbs must persist the SAME field-set (no derived allowed_bases from create)',
|
||||
);
|
||||
assert.deepEqual(createdEntry, recordedEntry, 'identical inputs must produce byte-identical on-disk entries');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── executeWorktreeWaveCleanupPlan ───────────────────────────────────────────
|
||||
|
||||
describe('executeWorktreeWaveCleanupPlan', () => {
|
||||
|
||||
Reference in New Issue
Block a user