* 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:
@@ -113,7 +113,7 @@ Cross-seam principle (ADR-1411, epic #1411): context resolution — config loadi
|
||||
Diagnostic-output convention for the Resolution Provenance principle (ADR-1411 P3, #1416). Config-interpreting read verbs expose `Resolution<T> { value, configured, reason, warnings }` (`src/resolution.cts`); agent-skills is the first adopter, where `value = { block, skills_count }` and `source`/`degraded` remain config-provenance extras outside the envelope. Other read verbs expose at least `warnings[]` (e.g. capability-state `{ runtimeConfigDir, capabilities, warnings? }`) without `configured`/`reason`, which are meaningful only for config-interpreting verbs. Mutation verbs expose `warnings[]` (advisory) PLUS `errors[]` (operation-not-applied), e.g. capability-writer `{ capabilities, warnings, errors }`. The shared seam across all shapes is `warnings: string[]`; a single generic `Resolution<T>` across read+write verbs was rejected by the deletion test (`configured`/`reason` are meaningless for capability verbs; `errors[]` cannot fold into `warnings[]`) — ADR-1411 P3 amendment. Recurrence prevention is delivered by P4's CI guard (a configured input resolving empty must carry a `reason`), not by a shared envelope. A CI guard (`scripts/lint-resolution-provenance.cjs`, wired into `lint:ci`) enforces that every registered config-interpreting read verb keeps a `configured_empty`/`not_configured` contract test; the registry in that script is the registration point for future verbs (ADR-1411 P4 / #1417).
|
||||
|
||||
### Worktree Safety Policy Module
|
||||
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`, `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb). Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
|
||||
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`, `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb), `planWorktreeCreate(fields) → WorktreeCreatePlan` (write-strict `worktree create` planner — same missing-field-hint and `normalizeCleanupManifestEntry` validation as `planWorktreeRecordAgent`, pure/no-git), `executeWorktreeCreatePlan(plan, repoRoot, deps) → WorktreeCreateResult` (bounded `git rev-parse --verify` base check THEN `git worktree add -b <branch> <path> <base>`; fail-closed `base_unresolved`/`git_timeout`/`worktree_add_failed`; returns `cwd` — the working directory an executor spawn would use), `cmdWorktreeCreate(cwd, args, deps) → WorktreeCreateCmdResult` (CLI verb: plans, creates the worktree, then appends the manifest entry so it is immediately manageable by cleanup-wave/reap-orphans; dedupes by `(worktree_path, branch)`). #2584 ADR-1239 Codex-binding amendment, Phase 2: `worktree create` is the git-worktree-creation primitive for `dispatch.isolation: orchestrator-worktree` hosts — declared and testable but UNCONSUMED (no scheduler calls it yet; Phase 3 wires it). Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
|
||||
|
||||
### Worktree Lifecycle Module
|
||||
Workflow contract seam covering agent worktree lifecycle orchestration rules. The `worktree_branch_check` block lives in one canonical fragment (`gsd-core/references/worktree-branch-check.md`) that `execute-phase.md`, `quick.md`, `diagnose-issues.md`, and `execute-plan.md` embed at dispatch. Key invariants: `worktree_branch_check` is **verify-only and fail-closed** — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via `git symbolic-ref`; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; on base mismatch the sub-agent halts with `exit 42` and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at `execute_waves` entry that resolves the worktree root and refuses drift into an agent worktree (#48); cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`.
|
||||
@@ -128,7 +128,7 @@ Module owning bounded, never-throw git repository introspection — the single s
|
||||
Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`.
|
||||
|
||||
### Host-Integration Interface
|
||||
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; declared and negotiated but not yet consumed by any scheduler — Phase 1 of #2584). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016.
|
||||
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; declared and negotiated but not yet consumed by any scheduler — Phase 1 of #2584). `resolveOrchestratorExec(orchestratorExec, cwd) → { ok:true, command, args, cwd } | { ok:false, reason }` (#2584 Phase 2, pure, no I/O — resolves the `runtime.orchestratorExec` descriptor field, a sibling of `runtime.hostBehaviors` in `capability.json` carrying `{command, args?, cwdFlag?}`, into the concrete argv/cwd a process-spawn primitive would use for a `dispatch.isolation: orchestrator-worktree` host; appends `[cwdFlag, cwd]` to `args` when `cwdFlag` is a non-empty string, e.g. codex `exec --cd <cwd>`, opencode `run --dir <cwd>`, kimi `--work-dir <cwd>`; when `cwdFlag` is `null`/absent — kimi-code's process-cwd case — no flag is appended and `cwd` alone is returned for the caller to bind via the subprocess's own working-directory option; fail-closed `missing_command`/`invalid_cwd`/`invalid_args`/`invalid_cwd_flag`; declared and testable but UNCONSUMED — no scheduler spawns anything with it yet, Phase 3 wires it). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016.
|
||||
|
||||
### Statusline
|
||||
Host-integration hook (`hooks/gsd-statusline.js`) that renders the session status line: model name, context-window meter, workspace directory, and the GSD-state segment (`formatGsdState()` projecting `.planning/` STATE.md). Opt-in segments are gated by `.planning/config.json` keys (`statusline.show_last_command`, `statusline.context_position`, plus the approved `statusline.show_context_tokens` and `statusline.state_format`), each registered across `gsd-core/bin/shared/config-schema.manifest.json` + `src/config.cts` + the `loadConfig` whitelist + `docs/CONFIGURATION.md`. The compact GSD-state format consumes the canonical status vocabulary from `normalizeStateStatus()` (STATE.md Document Module) rather than a parallel keyword list. **Data-source boundary (ADR-2164):** the statusline sources only local, read-only data — it refines the stdin payload Claude Code already sends and may add a new *local* source (e.g. `git`), but does not read credentials or call external/network APIs for data; account/usage/platform-level state is out of scope.
|
||||
|
||||
@@ -75,6 +75,11 @@
|
||||
"runtime": "node",
|
||||
"effortSurface": "argv"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "codex",
|
||||
"args": ["exec"],
|
||||
"cwdFlag": "--cd"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "$gsd-update --reapply",
|
||||
"tomlConfigInstall": true,
|
||||
|
||||
@@ -73,6 +73,11 @@
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "kimi-code",
|
||||
"args": [],
|
||||
"cwdFlag": null
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/skill:gsd-update --reapply",
|
||||
"localInstallDeferred": true,
|
||||
|
||||
@@ -78,6 +78,11 @@
|
||||
"runtime": "python",
|
||||
"effortSurface": "undocumented"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "kimi",
|
||||
"args": [],
|
||||
"cwdFlag": "--work-dir"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/skill:gsd-update --reapply",
|
||||
"localInstallDeferred": true,
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
"runtime": "bun",
|
||||
"effortSurface": "argv"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "opencode",
|
||||
"args": ["run"],
|
||||
"cwdFlag": "--dir"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "opencode",
|
||||
|
||||
@@ -1505,8 +1505,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
||||
require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2));
|
||||
} else if (subcommand === 'set-baseref') {
|
||||
require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
|
||||
} else if (subcommand === 'create') {
|
||||
worktreeSafety.cmdWorktreeCreate(cwd, args.slice(2));
|
||||
} else {
|
||||
error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref, create', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -950,6 +950,13 @@ const capabilities = {
|
||||
"runtime": "node",
|
||||
"effortSurface": "argv"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "codex",
|
||||
"args": [
|
||||
"exec"
|
||||
],
|
||||
"cwdFlag": "--cd"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "$gsd-update --reapply",
|
||||
"tomlConfigInstall": true,
|
||||
@@ -1753,6 +1760,11 @@ const capabilities = {
|
||||
"runtime": "python",
|
||||
"effortSurface": "undocumented"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "kimi",
|
||||
"args": [],
|
||||
"cwdFlag": "--work-dir"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/skill:gsd-update --reapply",
|
||||
"localInstallDeferred": true,
|
||||
@@ -1838,6 +1850,11 @@ const capabilities = {
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "kimi-code",
|
||||
"args": [],
|
||||
"cwdFlag": null
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/skill:gsd-update --reapply",
|
||||
"localInstallDeferred": true,
|
||||
@@ -2162,6 +2179,13 @@ const capabilities = {
|
||||
"runtime": "bun",
|
||||
"effortSurface": "argv"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "opencode",
|
||||
"args": [
|
||||
"run"
|
||||
],
|
||||
"cwdFlag": "--dir"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "opencode",
|
||||
@@ -4674,6 +4698,13 @@ const runtimes = {
|
||||
"runtime": "node",
|
||||
"effortSurface": "argv"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "codex",
|
||||
"args": [
|
||||
"exec"
|
||||
],
|
||||
"cwdFlag": "--cd"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "$gsd-update --reapply",
|
||||
"tomlConfigInstall": true,
|
||||
@@ -5182,6 +5213,11 @@ const runtimes = {
|
||||
"runtime": "python",
|
||||
"effortSurface": "undocumented"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "kimi",
|
||||
"args": [],
|
||||
"cwdFlag": "--work-dir"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/skill:gsd-update --reapply",
|
||||
"localInstallDeferred": true,
|
||||
@@ -5267,6 +5303,11 @@ const runtimes = {
|
||||
"transport": "mcp",
|
||||
"runtime": "node"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "kimi-code",
|
||||
"args": [],
|
||||
"cwdFlag": null
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/skill:gsd-update --reapply",
|
||||
"localInstallDeferred": true,
|
||||
@@ -5367,6 +5408,13 @@ const runtimes = {
|
||||
"runtime": "bun",
|
||||
"effortSurface": "argv"
|
||||
},
|
||||
"orchestratorExec": {
|
||||
"command": "opencode",
|
||||
"args": [
|
||||
"run"
|
||||
],
|
||||
"cwdFlag": "--dir"
|
||||
},
|
||||
"hostBehaviors": {
|
||||
"reapplyCommand": "/gsd-update --reapply",
|
||||
"attributionConfigResolver": "opencode",
|
||||
|
||||
@@ -1316,6 +1316,59 @@ function validateRuntimeBody(cap) {
|
||||
}
|
||||
}
|
||||
|
||||
// orchestratorExec — ADR-1239 Codex-binding amendment (#2584), Phase 2.
|
||||
// OPTIONAL top-level field (sibling of hostBehaviors), like hookEvents:
|
||||
// only hosts whose hostIntegration.dispatch.isolation is
|
||||
// 'orchestrator-worktree' need it, so it is not required on every runtime
|
||||
// descriptor. When present it must be a well-formed exec descriptor for
|
||||
// src/host-integration.cts's resolveOrchestratorExec.
|
||||
if (r.orchestratorExec !== undefined) {
|
||||
if (typeof r.orchestratorExec !== 'object' || r.orchestratorExec === null || Array.isArray(r.orchestratorExec)) {
|
||||
errors.push(
|
||||
'runtime.orchestratorExec must be an object (got: ' +
|
||||
(r.orchestratorExec === null ? 'null' : (Array.isArray(r.orchestratorExec) ? 'array' : typeof r.orchestratorExec)) + ')',
|
||||
);
|
||||
} else {
|
||||
const oe = r.orchestratorExec;
|
||||
|
||||
// S2b: reserved-OWN-KEY guard (CodeQL barrier — inline literal comparisons)
|
||||
if (Object.prototype.hasOwnProperty.call(oe, '__proto__')) {
|
||||
errors.push('runtime.orchestratorExec must not contain reserved key "__proto__"');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(oe, 'constructor')) {
|
||||
errors.push('runtime.orchestratorExec must not contain reserved key "constructor"');
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(oe, 'prototype')) {
|
||||
errors.push('runtime.orchestratorExec must not contain reserved key "prototype"');
|
||||
}
|
||||
|
||||
// command — required non-empty string; reserved-name guard (CodeQL barrier)
|
||||
if (oe.command === '__proto__' || oe.command === 'constructor' || oe.command === 'prototype') {
|
||||
errors.push('runtime.orchestratorExec.command "' + oe.command + '" is a reserved name');
|
||||
} else if (typeof oe.command !== 'string' || oe.command.length === 0) {
|
||||
errors.push(
|
||||
'runtime.orchestratorExec.command must be a non-empty string (got: ' + JSON.stringify(oe.command) + ')',
|
||||
);
|
||||
}
|
||||
|
||||
// args — optional array of strings
|
||||
if (oe.args !== undefined) {
|
||||
if (!Array.isArray(oe.args) || !oe.args.every((a) => typeof a === 'string')) {
|
||||
errors.push(
|
||||
'runtime.orchestratorExec.args must be an array of strings (got: ' + JSON.stringify(oe.args) + ')',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// cwdFlag — optional; string or null
|
||||
if (oe.cwdFlag !== undefined && oe.cwdFlag !== null && typeof oe.cwdFlag !== 'string') {
|
||||
errors.push(
|
||||
'runtime.orchestratorExec.cwdFlag must be a string or null (got: ' + JSON.stringify(oe.cwdFlag) + ')',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GATE A: installSurface ↔ hooksSurface consistency (DEFECT.GENERATIVE-FIX)
|
||||
// Only check if both fields are valid strings (individual field validators above report type errors).
|
||||
if (typeof r.installSurface === 'string' && typeof r.hooksSurface === 'string') {
|
||||
|
||||
@@ -831,6 +831,18 @@ function plannedPhaseCore(content, intent, deps) {
|
||||
}, statusDefaults, lastActivityDefaults);
|
||||
if (body !== beforePos)
|
||||
updated.push('Current Position');
|
||||
// #2400 Bug B: sync progress.total_plans to the frontmatter when a plan count
|
||||
// is given. This writes the explicitly-provided count — it is NOT a re-derivation
|
||||
// from disk (#500 RC1 is about deriving from a half-planned snapshot, not about
|
||||
// refusing to write an explicitly-passed argument).
|
||||
if (intent.planCount !== null && intent.planCount !== undefined && hasFrontmatter) {
|
||||
const fmProgress = existingFm['progress'] || {};
|
||||
if (fmProgress['total_plans'] !== intent.planCount) {
|
||||
fmProgress['total_plans'] = intent.planCount;
|
||||
existingFm['progress'] = fmProgress;
|
||||
updated.push('progress.total_plans');
|
||||
}
|
||||
}
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -739,6 +739,65 @@ function extensionEventSurfaceFor(extensionEvents: unknown): readonly string[] |
|
||||
return EXTENSION_EVENT_SURFACES[extensionEvents] || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveOrchestratorExec — ADR-1239 Codex-binding amendment (#2584), Phase 2
|
||||
//
|
||||
// The `orchestratorExec` descriptor field (sibling of `runtime.hostBehaviors`
|
||||
// in capability.json) tells GSD how to process-spawn a host's own CLI as the
|
||||
// executor inside a worktree GSD itself created (`isolation:
|
||||
// 'orchestrator-worktree'`). Pure, no I/O — this only shapes the argv/cwd a
|
||||
// caller would pass to a process-spawn primitive; it does not spawn anything
|
||||
// itself. UNCONSUMED in Phase 2 — no scheduler calls this yet (Phase 3 wires
|
||||
// it to the actual spawn).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OrchestratorExec {
|
||||
command: string;
|
||||
args?: string[];
|
||||
cwdFlag?: string | null;
|
||||
}
|
||||
|
||||
type OrchestratorExecResolution =
|
||||
| { ok: true; command: string; args: string[]; cwd: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Resolve an `orchestratorExec` descriptor + target cwd into a concrete
|
||||
* argv/cwd shape for a process-spawn primitive.
|
||||
*
|
||||
* Fail-closed: never throws, always returns a discriminated result. When
|
||||
* `cwdFlag` is a non-empty string, `[cwdFlag, cwd]` is appended to `args`
|
||||
* exactly once (e.g. codex: `exec --cd <cwd>`); when `cwdFlag` is `null` or
|
||||
* absent (e.g. kimi-code, which binds via the spawned process's own cwd —
|
||||
* "process-cwd" case), no flag is appended and `cwd` is returned for the
|
||||
* caller to bind via the subprocess's own working-directory option.
|
||||
*/
|
||||
function resolveOrchestratorExec(orchestratorExec: OrchestratorExec | undefined, cwd: string): OrchestratorExecResolution {
|
||||
if (!orchestratorExec || typeof orchestratorExec !== 'object' || Array.isArray(orchestratorExec)) {
|
||||
return { ok: false, reason: 'missing_command' };
|
||||
}
|
||||
const oe = orchestratorExec as unknown as Record<string, unknown>;
|
||||
if (typeof oe.command !== 'string' || oe.command.length === 0) {
|
||||
return { ok: false, reason: 'missing_command' };
|
||||
}
|
||||
if (typeof cwd !== 'string' || cwd.length === 0) {
|
||||
return { ok: false, reason: 'invalid_cwd' };
|
||||
}
|
||||
if (oe.args !== undefined && (!Array.isArray(oe.args) || !oe.args.every((a) => typeof a === 'string'))) {
|
||||
return { ok: false, reason: 'invalid_args' };
|
||||
}
|
||||
if (oe.cwdFlag !== undefined && oe.cwdFlag !== null && typeof oe.cwdFlag !== 'string') {
|
||||
return { ok: false, reason: 'invalid_cwd_flag' };
|
||||
}
|
||||
|
||||
const baseArgs = Array.isArray(oe.args) ? [...oe.args] : [];
|
||||
const args = typeof oe.cwdFlag === 'string' && oe.cwdFlag.length > 0
|
||||
? [...baseArgs, oe.cwdFlag, cwd]
|
||||
: baseArgs;
|
||||
|
||||
return { ok: true, command: oe.command, args, cwd };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module export (CommonJS — matches existing src/*.cts pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -759,4 +818,5 @@ export = {
|
||||
resolveDispatchType,
|
||||
hookEventSurfaceFor,
|
||||
extensionEventSurfaceFor,
|
||||
resolveOrchestratorExec,
|
||||
};
|
||||
|
||||
@@ -1104,6 +1104,369 @@ function cmdWorktreeRecordAgent(cwd: string, args: string[] = [], deps: RecordAg
|
||||
return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
|
||||
}
|
||||
|
||||
// ─── worktree create (#2584 ADR-1239 Codex-binding amendment — Phase 2) ───────
|
||||
//
|
||||
// The `orchestrator-worktree` isolation ladder value (ADR-1239) requires GSD
|
||||
// itself to create + bind the git worktree an executor runs in — this is that
|
||||
// git-worktree-creation primitive. UNCONSUMED in Phase 2: no scheduler calls
|
||||
// this yet (Phase 3 wires it). Mirrors the plan/execute/cmd split used by
|
||||
// cleanup-wave and record-agent above so a created worktree is immediately
|
||||
// manageable by cleanup-wave / reap-orphans without a second code path.
|
||||
|
||||
interface WorktreeCreateFields {
|
||||
agentId: string;
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
base: string;
|
||||
}
|
||||
|
||||
interface WorktreeCreatePlan {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
hint?: string;
|
||||
entry: CleanupManifestEntry | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure planner for `worktree create`. Validates the four required fields
|
||||
* (write-strict, same missing-field-hint style as `planWorktreeRecordAgent`),
|
||||
* then runs the candidate entry through the SAME `normalizeCleanupManifestEntry`
|
||||
* validation the cleanup-wave reader and record-agent use — so a worktree this
|
||||
* verb creates is guaranteed manageable by cleanup-wave/reap-orphans, and an
|
||||
* entry that would fail the reader's branch-namespace guard is rejected here,
|
||||
* fail-closed, before any git command runs.
|
||||
*/
|
||||
function planWorktreeCreate(fields: WorktreeCreateFields): WorktreeCreatePlan {
|
||||
const agentId = (fields.agentId || '').trim();
|
||||
const worktreePath = (fields.worktreePath || '').trim();
|
||||
const branch = (fields.branch || '').trim();
|
||||
const base = (fields.base || '').trim();
|
||||
const missing: string[] = [];
|
||||
if (!agentId) missing.push('--agent-id');
|
||||
if (!worktreePath) missing.push('--path');
|
||||
if (!branch) missing.push('--branch');
|
||||
if (!base) missing.push('--base');
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'missing_field',
|
||||
hint: `worktree create requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
|
||||
entry: null,
|
||||
};
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
agent_id: agentId,
|
||||
worktree_path: worktreePath,
|
||||
branch,
|
||||
expected_base: base,
|
||||
};
|
||||
const entry = normalizeCleanupManifestEntry(candidate);
|
||||
if (!entry) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'invalid_entry',
|
||||
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts both agent-<id> and worktree-agent-<id> namespaces; got branch="${branch}"). Fix the field and re-run.`,
|
||||
entry: null,
|
||||
};
|
||||
}
|
||||
|
||||
// #2584 FIX 4 — git argument-injection guard: a value starting with '-'
|
||||
// could be parsed by git as a FLAG rather than a positional argument (e.g.
|
||||
// base="--upload-pack=x", path="-f"). `git worktree add` / `git rev-parse`
|
||||
// support for a `--` end-of-options separator is inconsistent across git
|
||||
// versions, so rejecting a leading dash outright — not relying on `--` — is
|
||||
// the portable fix.
|
||||
if (branch.startsWith('-') || base.startsWith('-') || worktreePath.startsWith('-')) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'unsafe_leading_dash',
|
||||
hint: `--branch/--base/--path must not start with "-" (a leading dash would be parsed by git as a flag, not a value). Got branch="${branch}" base="${base}" path="${worktreePath}".`,
|
||||
entry: null,
|
||||
};
|
||||
}
|
||||
|
||||
// #2584 FIX 4 — path-traversal guard: reject a ".." path segment in --path.
|
||||
// Absolute paths ARE allowed (the orchestrator legitimately uses them —
|
||||
// Phase-3 root confinement is out of Phase-2 scope); only a literal ".."
|
||||
// component is rejected. Split on BOTH separators so the guard is effective
|
||||
// on a Windows-style path too.
|
||||
if (worktreePath.split(/[/\\]/).includes('..')) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'unsafe_path_traversal',
|
||||
hint: `--path must not contain a ".." path segment (got: "${worktreePath}").`,
|
||||
entry: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, reason: 'ok', entry };
|
||||
}
|
||||
|
||||
interface WorktreeCreateResult {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
worktree_path?: string;
|
||||
branch?: string;
|
||||
base?: string;
|
||||
cwd?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort bounded rollback of a partial/orphaned worktree (#2584 FIX 3,
|
||||
* scope narrowed by FIX 5). Invoked ONLY when a `git worktree add` TIMED OUT
|
||||
* mid-operation — a SIGTERM'd `add` can leave a `.git/worktrees/<name>` admin
|
||||
* entry / directory on disk that got past validation into the file checkout,
|
||||
* so the partial is genuinely THIS call's own creation and is safe to
|
||||
* best-effort remove immediately. It is deliberately NOT invoked on a clean
|
||||
* non-zero `add` exit (see FIX 5) — the most common such failure is a
|
||||
* COLLISION (the path/branch is already a registered worktree), git fails
|
||||
* FAST there having created nothing, and the branch namespace this verb
|
||||
* writes into (`worktree-agent-*`/`agent-*`) is exactly the concurrent-
|
||||
* executor namespace, so a colliding path is very plausibly a LIVE PEER
|
||||
* executor whose uncommitted work `--force` would destroy. Also invoked from
|
||||
* `cmdWorktreeCreate` when a successful `add` is followed by a manifest-write
|
||||
* failure (#2584 FIX 1) — that path proves THIS call created the worktree, so
|
||||
* removing it is safe. This is immediate best-effort hygiene, not the only
|
||||
* safety net: `reapOrphanWorktrees` scans the `.git/worktrees/` admin
|
||||
* directory directly (a genuine directory-scan backstop, not manifest-only),
|
||||
* so any partial this call cannot reach is still eventually discovered and
|
||||
* reaped there. The result is intentionally ignored and a throw is
|
||||
* swallowed: this is best-effort cleanup, never a new source of truth, and
|
||||
* must never mask or block the caller's own degraded-but-honest return.
|
||||
*/
|
||||
function rollbackPartialWorktree(execGit: ExecGitFn, worktreePath: string, repoRoot: string): void {
|
||||
try {
|
||||
execGit(['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot });
|
||||
} catch {
|
||||
// best-effort only — a throwing rollback must never mask the original failure.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a `planWorktreeCreate` plan via bounded git. Fail-closed at every
|
||||
* step — a timeout or a non-zero exit degrades to a structured result rather
|
||||
* than throwing, and the base must resolve BEFORE any worktree is created (no
|
||||
* partial/orphaned worktree on a bad base). Returns `cwd` — the working
|
||||
* directory Phase 3's executor spawn will pass through.
|
||||
*/
|
||||
function executeWorktreeCreatePlan(plan: WorktreeCreatePlan, repoRoot: string, deps: WorktreeDeps = {}): WorktreeCreateResult {
|
||||
const execGit = deps.execGit || execGitDefault;
|
||||
if (!plan || !plan.ok || !plan.entry) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: plan ? plan.reason : 'missing_plan',
|
||||
};
|
||||
}
|
||||
|
||||
const { worktree_path: worktreePath, branch, expected_base: base } = plan.entry;
|
||||
const normalizedPath = posixNormalize(worktreePath);
|
||||
|
||||
// 1. Verify the base resolves BEFORE creating anything (fail-closed). Nothing
|
||||
// has been created on this path yet, so there is nothing to roll back.
|
||||
const baseCheck = execGit(['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: repoRoot });
|
||||
if (baseCheck.timedOut) {
|
||||
return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base };
|
||||
}
|
||||
if (!gitResultOk(baseCheck)) {
|
||||
return { ok: false, reason: 'base_unresolved', worktree_path: normalizedPath, branch, base, stderr: baseCheck.stderr || '' };
|
||||
}
|
||||
|
||||
// 2. Create the worktree + branch together.
|
||||
const addResult = execGit(['worktree', 'add', '-b', branch, worktreePath, base], { cwd: repoRoot });
|
||||
if (addResult.timedOut) {
|
||||
// #2584 FIX 3: a SIGTERM'd `add` can leave a partial worktree on disk.
|
||||
rollbackPartialWorktree(execGit, worktreePath, repoRoot);
|
||||
return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base };
|
||||
}
|
||||
if (addResult.exitCode !== 0) {
|
||||
// #2584 FIX 5: deliberately NO rollback here. A clean non-zero exit is
|
||||
// most commonly a COLLISION (path/branch already a registered worktree),
|
||||
// and git fails FAST on that — it creates nothing. A colliding path in
|
||||
// this branch namespace is very plausibly a LIVE PEER executor;
|
||||
// `git worktree remove --force` on it would destroy real, uncommitted
|
||||
// work. The safe response to a clean failure is to fail loudly and leave
|
||||
// whatever is already on disk untouched.
|
||||
return { ok: false, reason: 'worktree_add_failed', worktree_path: normalizedPath, branch, base, stderr: addResult.stderr || '' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
reason: 'created',
|
||||
worktree_path: normalizedPath,
|
||||
branch,
|
||||
base,
|
||||
cwd: normalizedPath,
|
||||
};
|
||||
}
|
||||
|
||||
interface WorktreeCreateCmdResult {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
hint?: string;
|
||||
entry?: CleanupManifestEntry | null;
|
||||
cwd?: string;
|
||||
manifest_path?: string;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: create a git worktree + branch off `--base`, then append the
|
||||
* validated manifest entry so the worktree is immediately manageable by
|
||||
* `worktree cleanup-wave` / `worktree reap-orphans`.
|
||||
*
|
||||
* Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
||||
*
|
||||
* #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/
|
||||
* plan step runs BEFORE the git side effect (step 5). The ONLY manifest
|
||||
* operation that can run AFTER `git worktree add` has succeeded is the final
|
||||
* guarded write (step 6), and a failure there triggers a best-effort rollback
|
||||
* of the just-created worktree — so a malformed/mis-shaped manifest, or a
|
||||
* `writeFile` IO error, can never leave a REAL worktree on disk with no
|
||||
* manifest entry (cleanup-wave/reap-orphans only discover worktrees via the
|
||||
* manifest, never a directory scan) or an uncaught throw.
|
||||
*/
|
||||
function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCmdDeps & WorktreeDeps = {}): WorktreeCreateCmdResult {
|
||||
const flag = (name: string): string => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
|
||||
};
|
||||
const write = deps.write || ((s: string) => process.stdout.write(s));
|
||||
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
|
||||
|
||||
const manifestPath = flag('--manifest');
|
||||
if (!manifestPath) {
|
||||
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>\n');
|
||||
process.exitCode = 2;
|
||||
return { ok: false, reason: 'usage' };
|
||||
}
|
||||
|
||||
// 1. Read the manifest (no side effect yet).
|
||||
const resolved = path.resolve(cwd, manifestPath);
|
||||
const readFile = deps.readFile || ((p: string) => fs.readFileSync(p, 'utf8'));
|
||||
let manifestRaw: string;
|
||||
try {
|
||||
manifestRaw = readFile(resolved);
|
||||
} catch (err) {
|
||||
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before creating agent worktrees.`;
|
||||
writeErr(`[gsd] worktree.create: manifest_read_failed — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: (err as Error).message }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'manifest_read_failed', hint };
|
||||
}
|
||||
|
||||
// 2. Parse + shape-validate the manifest BEFORE any git command runs.
|
||||
// Mirrors planWorktreeRecordAgent's shell-acceptance rules (canonical
|
||||
// {worktrees:[]} object OR a bare top-level array).
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(manifestRaw);
|
||||
} catch {
|
||||
const hint = 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before creating agent worktrees.';
|
||||
writeErr(`[gsd] worktree.create: invalid_manifest_json — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'invalid_manifest_json', hint }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'invalid_manifest_json', hint };
|
||||
}
|
||||
|
||||
let worktrees: unknown[];
|
||||
let writeBack: unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
worktrees = parsed;
|
||||
writeBack = worktrees;
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
const container = parsed as Record<string, unknown>;
|
||||
if (container.worktrees === undefined) container.worktrees = [];
|
||||
if (!Array.isArray(container.worktrees)) {
|
||||
const hint = 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.';
|
||||
writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'manifest_shape_invalid', hint };
|
||||
}
|
||||
worktrees = container.worktrees;
|
||||
writeBack = container;
|
||||
} else {
|
||||
const hint = 'Manifest must be a JSON object {"worktrees": []} or a top-level array.';
|
||||
writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'manifest_shape_invalid', hint };
|
||||
}
|
||||
|
||||
// 3. Plan (pure, no I/O) — still before any git command.
|
||||
const plan = planWorktreeCreate({
|
||||
agentId: flag('--agent-id'),
|
||||
worktreePath: flag('--path'),
|
||||
branch: flag('--branch'),
|
||||
base: flag('--base'),
|
||||
});
|
||||
|
||||
if (!plan.ok || !plan.entry) {
|
||||
writeErr(`[gsd] worktree.create: ${plan.reason} — ${plan.hint || ''}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: plan.reason, hint: plan.hint };
|
||||
}
|
||||
|
||||
// 4. Compute the deduped final manifest STRING in memory now — the ONLY
|
||||
// manifest work left is the guarded write in step 6, after git succeeds.
|
||||
// #2584 FIX 2: the on-disk entry is the SAME minimal 4-field shape
|
||||
// `cmdWorktreeRecordAgent` writes — never the full normalized entry with
|
||||
// the derived `allowed_bases` — so the two verbs never write divergent
|
||||
// shapes into the same manifest (the reader re-derives `allowed_bases`
|
||||
// from `expected_base` on load, exactly as it does for record-agent).
|
||||
const recorded: CleanupManifestEntry = {
|
||||
agent_id: plan.entry.agent_id,
|
||||
worktree_path: plan.entry.worktree_path,
|
||||
branch: plan.entry.branch,
|
||||
expected_base: plan.entry.expected_base,
|
||||
};
|
||||
const dedupeKey = `${recorded.worktree_path}\0${recorded.branch}`;
|
||||
const alreadyPresent = worktrees.some((existing) => {
|
||||
const normalized = normalizeCleanupManifestEntry(existing);
|
||||
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dedupeKey;
|
||||
});
|
||||
if (!alreadyPresent) {
|
||||
worktrees.push(recorded);
|
||||
}
|
||||
const manifestToWrite = `${JSON.stringify(writeBack, null, 2)}\n`;
|
||||
|
||||
// 5. NOW run the git side effect. Every manifest problem above is caught
|
||||
// before this point, so a malformed/mis-shaped manifest can never leave
|
||||
// an unmanifested worktree on disk. `executeWorktreeCreatePlan` itself
|
||||
// best-effort rolls back a partial worktree on its own add-timeout /
|
||||
// add-failed paths (#2584 FIX 3).
|
||||
const result = executeWorktreeCreatePlan(plan, cwd, deps);
|
||||
if (!result.ok) {
|
||||
writeErr(`[gsd] worktree.create: ${result.reason} — ${result.stderr || ''}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: result.reason, stderr: result.stderr }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: result.reason, stderr: result.stderr };
|
||||
}
|
||||
|
||||
// 6. Write the pre-computed manifest string, guarded. A write failure here
|
||||
// means a REAL worktree now exists with NO manifest entry — roll it back
|
||||
// (best-effort) rather than leaving an orphan cleanup-wave/reap-orphans
|
||||
// can never reach (#2584 FIX 1). Never throws past this function.
|
||||
const writeFile = deps.writeFile || ((p: string, content: string) => fs.writeFileSync(p, content, 'utf8'));
|
||||
try {
|
||||
writeFile(resolved, manifestToWrite);
|
||||
} catch (err) {
|
||||
const execGit = deps.execGit || execGitDefault;
|
||||
rollbackPartialWorktree(execGit, plan.entry.worktree_path, cwd);
|
||||
const hint = `The worktree was created but the manifest write failed (${(err as Error).message}); rolled back the worktree via a best-effort 'git worktree remove --force'.`;
|
||||
writeErr(`[gsd] worktree.create: manifest_write_failed — ${hint}\n`);
|
||||
write(`${JSON.stringify({ ok: false, reason: 'manifest_write_failed', hint, error: (err as Error).message }, null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
return { ok: false, reason: 'manifest_write_failed', hint };
|
||||
}
|
||||
|
||||
write(`${JSON.stringify({ ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved }, null, 2)}\n`);
|
||||
return { ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reap orphaned linked worktrees whose lock owner process is dead, whose
|
||||
* branch tip is fully merged into the default branch, and whose lock file
|
||||
@@ -1405,6 +1768,9 @@ export = {
|
||||
cmdWorktreeCleanupWave,
|
||||
planWorktreeRecordAgent,
|
||||
cmdWorktreeRecordAgent,
|
||||
planWorktreeCreate,
|
||||
executeWorktreeCreatePlan,
|
||||
cmdWorktreeCreate,
|
||||
reapOrphanWorktrees,
|
||||
cmdWorktreeReapOrphans,
|
||||
resolveWorktreeRoot,
|
||||
|
||||
@@ -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