diff --git a/docs/engineering-invariants.md b/docs/engineering-invariants.md index 11ea01946..9195f95bf 100644 --- a/docs/engineering-invariants.md +++ b/docs/engineering-invariants.md @@ -684,6 +684,17 @@ trust root outside the workspace. **Session-reset worktree resilience (FR-004):** `.swarm-worktrees/` directories created by parallel lanes must be reconciled on session resume/reset. `provisionWorktree` in `src/worktree/core.ts` implements idempotent provisioning: if a branch exists but is not checked out in any active worktree, it is adopted; if it is active elsewhere, an error is returned. `reset-session.ts` reclaims OWNED worktree lanes in the project-internal `.swarm-worktrees/` base (issue #2527: foreign lanes are never deleted; lanes with uncommitted or live work require `--confirm=`) and orphan branches. The swarm-resume skill (slug `resume` before the #2379 rename) explicitly calls out reconciliation as the first step. This prevents stale worktrees from causing provisioning failures or silent git state corruption when a session resumes after reset. +**Bootstrap project-root ownership (issue #2679):** the same boundary policy the tool layer enforces at write time is applied once, synchronously, at plugin bootstrap and at the `mcp serve --dir` boundary, BEFORE any init-path or first-write consumer touches a directory. `resolveProjectRootDecision` (`src/utils/project-boundary.ts`) is the decision form of `assertProjectRoot`'s ancestor walk and shares its implementation, so the two cannot drift. Rules for the directory the host supplies (`ctx.directory`) or the operator supplies (`--dir`): + +- **Ordinary child** — a directory with no direct `.git` file/directory and no `.opencode/` directory, whose ancestor (within 20 levels) contains BOTH a `.swarm/` directory and a project indicator (e.g. `.git`, `package.json`): the boot resolves to the owning ancestor root. All project-surface consumers (`.swarm` state, snapshot rehydration, project config under `.opencode/`, telemetry, observability lineage, bundled-skill sync, agent overrides, teardown) use the resolved root; one unconditional bounded `console.warn` hint names the owning root (plus a `/swarm diagnose` advisory and a durable `.swarm/advisories/bootstrap-root-redirect.json` record under the owning root). The plugin manifest stays fail-open (invariant 1): agents and tools still register. +- **Directly declared nested root** — a directory with its own `.git` file (repository, linked worktree, submodule) or `.opencode/` directory stays an independent root; bootstrap writes there exactly as before. Marker symlinks/junctions do not count as declarations. +- **Standalone root** — a directory with no claiming ancestor (no ancestor owns `.swarm/` state) is its own root; behavior is unchanged. An indicator-only ancestor WITHOUT `.swarm/` does not capture the boot. +- **Fail-closed** — if ownership cannot be determined (inaccessible ancestor probes, ancestor-depth exhaustion, uncanonicalizable directory), NO runtime state is written anywhere for that boot; the manifest is still delivered and one bounded warning names the reason. + +Workspace-surface operations (git diffs, file authority on opened files, language-backend probes of the opened tree, delegation-lane pathing) keep using the opened directory. This is bootstrap root ownership only: it is distinct from the separate process-global hydration eviction and generation fencing owned by issue #2667 (Workstream D PR 14 of 17). The decision is computed before any writer is scheduled, so concurrent boots of the same ordinary child cannot interleave a child write. + +**Verification:** `tests/unit/utils/project-boundary-resolver-2679.test.ts` (decision semantics incl. marker/standalone/redirect/fail-closed), `tests/unit/index-bootstrap-root-ownership-2679.test.ts` (real-host boots: redirect + hint, marker independence, standalone, indicator-only edge, rehydration, config inheritance), `tests/unit/index-bootstrap-late-writer-2679.test.ts` (concurrent-boot race + registered late writer), `tests/unit/index-bootstrap-root-sources-2679.test.ts` (static source-scan: every enumerated project-surface symbol in `src/index.ts` binds `bootstrapRoot`, never bare `ctx.directory`), and the MCP `resolveMcpRoot` redirect/fail-closed tests. + **Anti-pattern:** ```ts diff --git a/docs/releases/pending/2679-project-root-ownership-bootstrap.md b/docs/releases/pending/2679-project-root-ownership-bootstrap.md new file mode 100644 index 000000000..ccc29a9b8 --- /dev/null +++ b/docs/releases/pending/2679-project-root-ownership-bootstrap.md @@ -0,0 +1,24 @@ +# Apply project-root ownership before initialization creates runtime state + +Issue: #2679 + +## What + +- Plugin bootstrap and `mcp serve --dir` now apply the project-boundary policy (the same policy tools enforce at write time) once, synchronously, before any init-path or first-write consumer touches a directory. +- An **ordinary child directory** (no direct `.git`/`.opencode` marker) of a project root that already owns `.swarm/` state no longer receives a second runtime-state tree: the boot resolves to the owning project root, all `.swarm` state, project config, telemetry, and bundled-skill materialization land there, and one bounded always-visible hint names the owning root (plus a `/swarm diagnose` advisory and a durable `.swarm/advisories/bootstrap-root-redirect.json` record). +- Directly declared nested roots (`.git` file/dir, linked worktrees, submodules, `.opencode/` directories) and standalone roots keep owning their state exactly as before. +- If project-root ownership cannot be verified (inaccessible ancestor probes, ancestor-depth exhaustion), the boot writes NO runtime state anywhere, stays fail-open for the plugin manifest (agents/tools still register), and warns once with the reason. +- The SQLite DB, bundled-skill sync, snapshot writer (per-tool-call), telemetry, observability lineage, knowledge/curation hooks, and every teardown path honor the single resolved decision; concurrent boots of the same ordinary child cannot interleave a child write. + +## Why + +Before this change, opening an ordinary subdirectory while its parent project already owned `.swarm/` state silently created a complete second runtime-state tree under the child (advisories, automation status, bundled skills, telemetry, DB surfaces), splitting state from the owning project. See `docs/engineering-invariants.md` ("Bootstrap project-root ownership", invariant 4) for the full rule set and the distinction from #2667's process-global hydration eviction. + +## Operator action required + +- **Pre-existing child `.swarm/` trees** created by the old behavior are NOT migrated or deleted by this change. If a workspace previously booted from an ordinary subdirectory, move or delete that stray `.swarm/` directory manually; new boots write to the owning project root. +- **Redirected boots inherit the parent project's project-level config flags** (`quiet`, `version_check`, `guardrails.enabled`, `full_auto.*`, `agents.*`, `auto_review`, `memory`, `retention`, `hooks.background_submodules`, `repo_graph`, `observability.export`). In particular, the `guardrails.enabled === false` security warning now reflects the applied (parent) configuration while you opened the child directory — the redirect hint names the owning root so the attribution is traceable. A child-local `.opencode/opencode-swarm.json` is no longer read for an ordinary child; open the project root, or give the child its own `.git`/`.opencode` marker, to use a child-local config. + +## Verification + +Real-host boots (registered plugin `server()`), frozen acceptance checks C1–C8 under the issue trace: ordinary-child redirect (child tree absent, parent populated, hint names the parent, manifest delivered), nested git-dir/git-file/.opencode independence, standalone root, indicator-only-parent edge, concurrent double-boot race, registered late writer, and the documentation contract — all RED on base 9ba5b411f, GREEN on the fix. Measured `repro-704` init latency unchanged (marker-first short-circuit; no subprocess). diff --git a/scripts/retention-registry.data.ts b/scripts/retention-registry.data.ts index 0935226a0..539ca5460 100644 --- a/scripts/retention-registry.data.ts +++ b/scripts/retention-registry.data.ts @@ -4555,6 +4555,7 @@ export const EXEMPT_WRITER_MODULES: Readonly> = Object.fr 'src/memory/jsonl-migration.ts': 'legacy JSONL→SQLite migration executor — memory-sqlite row owns the destination', 'src/retention/jsonl-cap.ts': 'shared retention plumbing (appendCappedJsonl/readTailJsonl, issue #2483 §1) — callers own the streams; their rows carry the cap citations', 'src/evaluation/retrieval-quality.ts': 'temporary bounded evaluation artifacts under os.tmpdir — always removed in finally and never durable project state', + 'src/index.ts': 'bootstrap-root redirect advisory record (#2679): one bounded best-effort .swarm/advisories/bootstrap-root-redirect.json per redirected boot, mirrored to console + /swarm diagnose — no durable stream, no reader, never enumerated', }); /** Sequence window for fix-in-issue dispositions (issue #2036 amendment clause). */ diff --git a/src/cli/mcp.ts b/src/cli/mcp.ts index 078b15cae..6a77ce4f9 100644 --- a/src/cli/mcp.ts +++ b/src/cli/mcp.ts @@ -16,6 +16,7 @@ import { existsSync, statSync } from 'node:fs'; import path from 'node:path'; import type { RunMcpServerOptions } from '../mcp/server.js'; import { validateProjectDirectory } from '../utils/path-security.js'; +import { resolveProjectRootDecision } from '../utils/project-boundary.js'; export interface McpServeArgs { root: string; @@ -94,7 +95,7 @@ export function parseMcpServeArgs( /** Resolve + fail-closed validate the configured project root. */ export function resolveMcpRoot( input: string, -): { root: string } | { error: string } { +): { root: string; redirectedFrom?: string } | { error: string } { const resolved = path.isAbsolute(input) ? path.normalize(input) : path.resolve(process.cwd(), input); @@ -108,6 +109,25 @@ export function resolveMcpRoot( if (!existsSync(resolved) || !statSync(resolved).isDirectory()) { return { error: `invalid --dir: not an existing directory: ${resolved}` }; } + // Project-root ownership (#2679): apply the same boundary policy as plugin + // bootstrap. An ordinary child of a project root that owns `.swarm/` state + // resolves to the parent (with a startup line naming the served root); + // indeterminable ownership fails closed instead of writing state somewhere + // unverifiable. + const decision = resolveProjectRootDecision(resolved); + if (decision.kind === 'fail-closed') { + return { + error: `invalid --dir: cannot verify project root for "${resolved}" — ${decision.reason}`, + }; + } + if (decision.kind === 'redirect') { + // stderr, NOT stdout: StdioServerTransport owns stdout for JSON-RPC — + // a non-protocol line there corrupts strict stdio clients (review F2). + console.error( + `[opencode-swarm] mcp serve: --dir "${resolved}" is an ordinary subdirectory — serving the owning project root "${decision.owningRoot}" (state and config live there).`, + ); + return { root: decision.owningRoot, redirectedFrom: resolved }; + } return { root: resolved }; } diff --git a/src/index.ts b/src/index.ts index fff05e3fd..c8f3f02a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; @@ -330,6 +331,7 @@ import { ENSURE_SWARM_GIT_EXCLUDED_OUTER_TIMEOUT_MS, ensureSwarmGitExcluded, } from './utils/gitignore-warning'; +import { resolveProjectRootDecision } from './utils/project-boundary'; import { withTimeout, withTimeoutSignal } from './utils/timeout'; import { truncateToolOutput } from './utils/tool-output'; @@ -1122,6 +1124,34 @@ function isPlainRecord(value: unknown): value is Record { // Return type intentionally inferred so the literal `{ name: ..., agent: ... }` // does not trip excess-property checks against `Hooks`. The wrapper above is // typed as `Plugin`, which validates the structural shape at the call site. +/** + * Durable, bounded record of a bootstrap root redirect (#2679), written under + * the OWNING project root's `.swarm/advisories/` (init-orphan-recovery + * pattern) so the operator has a persistent, operator-understandable result + * beyond the console/diagnose hint. Best-effort: the console hint already + * fired, so any write failure is swallowed. + */ +function writeBootstrapRootRedirectRecord( + bootstrapRoot: string, + openedDirectory: string, +): void { + try { + const advisoriesDir = path.join(bootstrapRoot, '.swarm', 'advisories'); + mkdirSync(advisoriesDir, { recursive: true }); + writeFileSync( + path.join(advisoriesDir, 'bootstrap-root-redirect.json'), + JSON.stringify({ + kind: 'bootstrap-root-redirect', + opened_directory: openedDirectory, + project_root: bootstrapRoot, + note: 'Runtime state, project config, and telemetry for the opened workspace are owned by the project root.', + }), + ); + } catch { + // bounded best-effort record; the console/diagnose hint already fired + } +} + async function initializeOpenCodeSwarm( ctx: Parameters[0], postResolutionTasks: PostResolutionTask[], @@ -1160,6 +1190,24 @@ async function initializeOpenCodeSwarm( resetConfigAdvisoryDedup(); resetArchitectPromptBudgetAdvisories(); + // Project-root ownership (issue #2679): resolve ONCE, synchronously, before + // any init-path consumer touches a directory. This applies the same boundary + // policy as the write-time sinks (`assertProjectRoot`) to the bootstrap + // boundary, so an ordinary child directory of a project root that already + // owns `.swarm/` state can no longer receive a second runtime-state tree. + // Bounded (invariant 1): one realpath + at most MAX_PROJECT_ROOT_DEPTH (20) + // ancestor `.swarm` probes, with the indicator list consulted only for an + // ancestor that has `.swarm`; a directory with a direct `.git`/`.opencode` + // marker short-circuits after 1-2 lstat probes. No subprocess — comparable + // to the `hasManifestAncestor` walk already on this path. + const rootDecision = resolveProjectRootDecision(ctx.directory); + // Project surface (all `.swarm` state + `.opencode` project config/agent + // overrides) anchors at the bootstrap root; the opened workspace surface + // (git diffs, file authority, language-backend probes) keeps ctx.directory. + const bootstrapRoot = + rootDecision.kind === 'redirect' ? rootDecision.owningRoot : ctx.directory; + const bootstrapStateWritesEnabled = rootDecision.kind !== 'fail-closed'; + // PARALLEL INIT I/O (issue #1782 / repro-704 T1 Windows failures). // // Three independent bounded reads used to be awaited SEQUENTIALLY here: @@ -1190,7 +1238,7 @@ async function initializeOpenCodeSwarm( // created). const __initIoStart = performance.now(); const configLoadP = withTimeout( - loadPluginConfigWithMetaAsyncForInit(ctx.directory), + loadPluginConfigWithMetaAsyncForInit(bootstrapRoot), LOAD_PLUGIN_CONFIG_TIMEOUT_MS, new Error( `loadPluginConfigWithMetaAsync exceeded ${LOAD_PLUGIN_CONFIG_TIMEOUT_MS}ms budget; continuing with safe-default config`, @@ -1207,36 +1255,38 @@ async function initializeOpenCodeSwarm( ); return getSafeDefaultConfigLoadResult(); }); - const snapshotP = hasSwarmState(ctx.directory) - ? withTimeout( - loadSnapshotForInit(ctx.directory), - 5_000, - new Error( - 'loadSnapshot exceeded 5s budget; continuing without snapshot rehydration', - ), - ).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - log('loadSnapshot timed out or failed (non-fatal)', { error: msg }); - }) - : Promise.resolve(); - const gitExcludeP = hasGitMarkerAncestor(ctx.directory) - ? withTimeout( - // `quiet` defaults to false; the option is currently void-discarded in - // `ensureSwarmGitExcluded` (src/utils/gitignore-warning.ts:223-224), so - // dropping `{ quiet: config.quiet }` is behavior-identical AND lets us - // parallelize without waiting on the config read. - ensureSwarmGitExcludedForInit(ctx.directory), - ENSURE_SWARM_GIT_EXCLUDED_OUTER_TIMEOUT_MS, - new Error( - `ensureSwarmGitExcluded exceeded ${ENSURE_SWARM_GIT_EXCLUDED_OUTER_TIMEOUT_MS}ms budget; continuing without git-hygiene check`, - ), - ).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - log('ensureSwarmGitExcluded timed out or failed (non-fatal)', { - error: msg, - }); - }) - : Promise.resolve(); + const snapshotP = + bootstrapStateWritesEnabled && hasSwarmState(bootstrapRoot) + ? withTimeout( + loadSnapshotForInit(bootstrapRoot), + 5_000, + new Error( + 'loadSnapshot exceeded 5s budget; continuing without snapshot rehydration', + ), + ).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + log('loadSnapshot timed out or failed (non-fatal)', { error: msg }); + }) + : Promise.resolve(); + const gitExcludeP = + bootstrapStateWritesEnabled && hasGitMarkerAncestor(bootstrapRoot) + ? withTimeout( + // `quiet` defaults to false; the option is currently void-discarded in + // `ensureSwarmGitExcluded` (src/utils/gitignore-warning.ts:223-224), so + // dropping `{ quiet: config.quiet }` is behavior-identical AND lets us + // parallelize without waiting on the config read. + ensureSwarmGitExcludedForInit(bootstrapRoot), + ENSURE_SWARM_GIT_EXCLUDED_OUTER_TIMEOUT_MS, + new Error( + `ensureSwarmGitExcluded exceeded ${ENSURE_SWARM_GIT_EXCLUDED_OUTER_TIMEOUT_MS}ms budget; continuing without git-hygiene check`, + ), + ).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + log('ensureSwarmGitExcluded timed out or failed (non-fatal)', { + error: msg, + }); + }) + : Promise.resolve(); // Phase 4b: resolve language-agnostic project context in parallel with the // other independent init reads. Starting the lazy backend import here keeps // its cold-module cost off the tail of the critical path while preserving the @@ -1267,6 +1317,28 @@ async function initializeOpenCodeSwarm( : Promise.resolve(null); await Promise.all([configLoadP, snapshotP, gitExcludeP, projectContextP]); const { config, loadedFromFile } = await configLoadP; + if (rootDecision.kind === 'redirect') { + // Actionable, bounded parent-root hint (AC1). `advisoryWarn` alone is + // buffered-only (warning-buffer), so the operator-visible leg is one + // console.warn line (quiet-gated, deferred fallback), matching the + // full-auto model-matching warning pattern. + const redirectHint = `[opencode-swarm] project-root ownership: "${ctx.directory}" is an ordinary subdirectory — runtime state, project config, and telemetry for this workspace are owned by the project root "${bootstrapRoot}". Open the project root (or add a .git/.opencode marker in "${ctx.directory}") to make this directory independent.`; + // UNCONDITIONAL (not quiet-gated): `quiet` defaults to true for routine + // startup noise, but a boot whose state silently lands under a different + // root is a containment signal the operator must see once per boot — + // same class as the unconditional startup version line. + // biome-ignore lint/suspicious/noConsole: containment redirect — operator must see which root owns .swarm state for this boot (issue #2679 AC1) + console.warn(redirectHint); + addDeferredWarning(redirectHint); + advisoryWarn(redirectHint); + writeBootstrapRootRedirectRecord(bootstrapRoot, ctx.directory); + } else if (rootDecision.kind === 'fail-closed') { + const failClosedHint = `[opencode-swarm] project-root ownership: cannot verify "${ctx.directory}" (${rootDecision.reason}) — runtime state is disabled for this session. Reopen the project from its verified root.`; + // biome-ignore lint/suspicious/noConsole: fail-closed ownership — operator must know state writes are disabled for this boot (issue #2679) + console.warn(failClosedHint); + addDeferredWarning(failClosedHint); + advisoryWarn(failClosedHint); + } log( `init-path I/O completed in ${(performance.now() - __initIoStart).toFixed(1)}ms (parallel: config+snapshot+git-exclude)`, ); @@ -1378,7 +1450,7 @@ async function initializeOpenCodeSwarm( // init-path subprocess to obtain one is exactly what invariant 1 forbids. // Populating it is #2047's call, off the init path. initObservability({ - directory: ctx.directory, + directory: bootstrapRoot, provenance: { pluginVersion: packageJson.version, // Detected via `process.versions`, never a `Bun` global reference — @@ -1404,8 +1476,10 @@ async function initializeOpenCodeSwarm( // #2482: register the SQLite observability sink FIRST so the very first // emitted event is captured. Registration is O(1) (one listener push), // never opens the DB, and never throws — safe on the init path. - registerObservabilityEventSink(ctx.directory); - initTelemetry(ctx.directory); + if (bootstrapStateWritesEnabled) { + registerObservabilityEventSink(bootstrapRoot); + initTelemetry(bootstrapRoot); + } startHeartbeatTracking(); // #2485: opt-in remote OTLP/OpenInference export. Registration is O(1) @@ -1415,8 +1489,8 @@ async function initializeOpenCodeSwarm( // set, or an invalid endpoint, NOTHING is registered and no // `.swarm/otlp-export/` directory is created. const otlpExportConfig = config.observability?.export; - if (otlpExportConfig !== undefined) { - registerOtlpExporter(ctx.directory, otlpExportConfig); + if (otlpExportConfig !== undefined && bootstrapStateWritesEnabled) { + registerOtlpExporter(bootstrapRoot, otlpExportConfig); if (isOtlpExporterActive()) { postResolutionTasks.push(() => { // Post-resolution first flush (spool replay after restart). @@ -1425,7 +1499,7 @@ async function initializeOpenCodeSwarm( // work. `flushOtlpExporterForTesting` is the drain-now entry // point (single-flight, bounded iterations), used here and by // checks/tests alike. - void flushOtlpExporterForTesting(ctx.directory).catch(() => { + void flushOtlpExporterForTesting(bootstrapRoot).catch(() => { /* fail-open: the interval retries */ }); }); @@ -1434,16 +1508,17 @@ async function initializeOpenCodeSwarm( const repoGraphConfig = RepoGraphConfigSchema.parse(config.repo_graph ?? {}); const repoGraphHookFactory = createRepoGraphBuilderHookForInit; - const repoGraphHook = repoGraphConfig.enabled - ? repoGraphHookFactory(ctx.directory, undefined, { - enabled: true, - initRefresh: repoGraphConfig.init_refresh, - refreshCap: repoGraphConfig.refresh_cap, - walkBudgetMs: repoGraphConfig.walk_budget_ms, - maxFiles: repoGraphConfig.max_files, - excludeDirs: repoGraphConfig.exclude_dirs, - }) - : null; + const repoGraphHook = + repoGraphConfig.enabled && bootstrapStateWritesEnabled + ? repoGraphHookFactory(bootstrapRoot, undefined, { + enabled: true, + initRefresh: repoGraphConfig.init_refresh, + refreshCap: repoGraphConfig.refresh_cap, + walkBudgetMs: repoGraphConfig.walk_budget_ms, + maxFiles: repoGraphConfig.max_files, + excludeDirs: repoGraphConfig.exclude_dirs, + }) + : null; let repoGraphInitPromise: Promise | undefined; if (repoGraphHook) { postResolutionTasks.push(() => { @@ -1469,7 +1544,8 @@ async function initializeOpenCodeSwarm( // snapshot-coordination-init retains the underlying promise; this detached // scheduler is only the trigger and is never treated as the owner. postResolutionTasks.push(function snapshotCoordinationPostResolutionTask() { - return startSnapshotCoordinationInitialization(ctx.directory); + if (!bootstrapStateWritesEnabled) return Promise.resolve(); + return startSnapshotCoordinationInitialization(bootstrapRoot); }); // Issue #2271 bug 4 / issue #2680: model-resolution preflight runs OFF the @@ -1539,10 +1615,11 @@ async function initializeOpenCodeSwarm( // createInitOrphanRecoveryAdvisoryHook surfaces results to the architect // on their first turn after plugin init. postResolutionTasks.push(() => { - void runInitOrphanRecovery(ctx.directory).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); - log('initOrphanRecovery failed (non-fatal)', { error: msg }); - }); + if (bootstrapStateWritesEnabled) + void runInitOrphanRecovery(bootstrapRoot).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + log('initOrphanRecovery failed (non-fatal)', { error: msg }); + }); }); // Issue #2041 — one bounded, fail-open PRM trajectory/replay cleanup pass. @@ -1557,7 +1634,9 @@ async function initializeOpenCodeSwarm( // per-session trigger and every subsequent plugin load remain as backstops. postResolutionTasks.push(function trajectoryCleanupPostInitTask() { return withTimeout( - cleanupOldTrajectoryFiles(ctx.directory), + bootstrapStateWritesEnabled + ? cleanupOldTrajectoryFiles(bootstrapRoot) + : Promise.resolve(), TRAJECTORY_CLEANUP_INIT_TIMEOUT_MS, new Error( `trajectory cleanup exceeded ${TRAJECTORY_CLEANUP_INIT_TIMEOUT_MS}ms post-init budget; continuing without it (lazy per-session cleanup remains a backstop)`, @@ -1580,6 +1659,7 @@ async function initializeOpenCodeSwarm( // server()-resolution path — Invariant 1) and fails open; the timeout // bounds the scheduler's wait, not the sweep's per-family deletion caps. postResolutionTasks.push(function retentionSweepPostInitTask() { + if (!bootstrapStateWritesEnabled) return Promise.resolve(); const retentionConfig = (config as Record | undefined) ?.retention as { enabled?: unknown; dry_run?: unknown } | undefined; const retentionSummaries = (config as Record | undefined) @@ -1590,7 +1670,7 @@ async function initializeOpenCodeSwarm( // families instead of finishing them after the awaiter moved on. let sweepCancelled = false; return withTimeout( - runRetentionSweep(ctx.directory, { + runRetentionSweep(bootstrapRoot, { enabled: retentionConfig?.enabled !== false, dryRun: retentionConfig?.dry_run === true, summariesRetentionDays: @@ -1627,13 +1707,14 @@ async function initializeOpenCodeSwarm( ?.background_subagents === true ) { postResolutionTasks.push(function backgroundMaintenancePostInitTask() { + if (!bootstrapStateWritesEnabled) return Promise.resolve(); // Returned (not `void`ed) so the task is awaitable like // regenerateMemoryReflectionTask — tests and any future awaiter of // the post-resolution queue can observe completion. The scheduler // already treats tasks as void | Promise. return withTimeout( import('./background/pending-delegations.js').then((m) => - m.maintainBackgroundDelegations(ctx.directory, { + m.maintainBackgroundDelegations(bootstrapRoot, { lockTimeoutMs: 5_000, reason: 'post-init', onLegacyCoderSettlementReconciled: @@ -1660,9 +1741,11 @@ async function initializeOpenCodeSwarm( }); } - // Side tasks are small and scoped to `/.swarm/` - // or `/.opencode/`, so none risks a home-tree scan. - writeSwarmConfigExampleIfNew(ctx.directory); + // Side tasks are small and scoped to `/.swarm/` + // or `/.opencode/`, so none risks a home-tree scan. + if (bootstrapStateWritesEnabled) { + writeSwarmConfigExampleIfNew(bootstrapRoot); + } // Materialize the bundled architect MODE skills into the project so the // architect's first auto-entered mode (e.g. SPECIFY on a fresh project) can // load its `.swarm/bundled-skills//SKILL.md` without first running a /swarm @@ -1684,9 +1767,10 @@ async function initializeOpenCodeSwarm( // atomic-overwrite-with-rollback, symlink-guarded, byte/file-bounded. On timeout/error we // fail open — the command-path sync remains as a backstop. postResolutionTasks.push(() => { + if (!bootstrapStateWritesEnabled) return; void withTimeout( syncBundledProjectSkillsIfMissingAsync( - ctx.directory, + bootstrapRoot, PACKAGE_ROOT, config.quiet, ), @@ -1747,7 +1831,7 @@ async function initializeOpenCodeSwarm( }; const agents = getAgentConfigs( configWithResolvedAutoReview, - ctx.directory, + bootstrapRoot, undefined, projectContext ?? undefined, ); @@ -1810,11 +1894,11 @@ async function initializeOpenCodeSwarm( // guard (adversarial review C1 fix). swarmState.generatedAgentNames = [...instanceGeneratedAgentNames]; - const pipelineHook = createPipelineTrackerHook(config, ctx.directory); - const systemEnhancerHook = createSystemEnhancerHook(config, ctx.directory); + const pipelineHook = createPipelineTrackerHook(config, bootstrapRoot); + const systemEnhancerHook = createSystemEnhancerHook(config, bootstrapRoot); const architectMessagesEnhancerHook = createSystemEnhancerHook( config, - ctx.directory, + bootstrapRoot, { surface: 'messages', deferRealtimeLearningNudgeState: true, @@ -1824,9 +1908,9 @@ async function initializeOpenCodeSwarm( ); const contextCapsuleInjectHook = createContextCapsuleInjectHook( config, - ctx.directory, + bootstrapRoot, ); - const compactionHook = createCompactionCustomizerHook(config, ctx.directory); + const compactionHook = createCompactionCustomizerHook(config, bootstrapRoot); const resolveIncomingAgentModel = (agentName: string): string | undefined => resolveRuntimeAgentModel(config, agents, agentName); const resolveTaskRouteModelChain = ( @@ -1923,7 +2007,7 @@ async function initializeOpenCodeSwarm( try { const result = await sessionApi.get({ path: { id: childSessionID }, - query: { directory: ctx.directory }, + query: { directory: bootstrapRoot }, }); return typeof result?.data?.parentID === 'string' && result.data.parentID.trim() !== '' @@ -1961,7 +2045,7 @@ async function initializeOpenCodeSwarm( ).catch(() => undefined); if (!parentSessionId) return; const recovered = await recoverPendingCostCorrection( - ctx.directory, + bootstrapRoot, parentSessionId, rememberedUsage.sessionId, config.pricing, @@ -2021,14 +2105,14 @@ async function initializeOpenCodeSwarm( resolveIncomingAgentModel, // #2044: scopes + persists the headroom health observation under the // owning project (the chat-transform hook input carries no directory). - ctx.directory, + bootstrapRoot, ); // #2107 §3: the ONE final accounting step (registered after // consolidation in the messages.transform chain). const finalContextAccountingStep = createFinalContextAccountingStep({ config, // #2044: scopes the model-limit health observation to this project. - directory: ctx.directory, + directory: bootstrapRoot, // Same seam createContextBudgetHandler consumes: keeps the final // accounting step's model-identity ladder identical to physical // pruning's (agent handoffs included). @@ -2055,7 +2139,7 @@ async function initializeOpenCodeSwarm( ); const systemRenderBoundaryHook = createSystemRenderBoundaryHook(); const commandHandler = createSwarmCommandHandler( - ctx.directory, + bootstrapRoot, agentDefinitionMap, { getActiveAgentName: getActiveReviewAgentName, @@ -2077,7 +2161,7 @@ async function initializeOpenCodeSwarm( agents, { surface: 'messages' }, ); - const activityHooks = createAgentActivityHooks(config, ctx.directory); + const activityHooks = createAgentActivityHooks(config, bootstrapRoot); // #1821 Workstream B: real-time admission + PRM pattern persistence budgets. // Parsed once at init (pure Zod, no I/O) so the hot hook path reads plain // numbers rather than re-parsing per tool call. @@ -2090,7 +2174,7 @@ async function initializeOpenCodeSwarm( const prmConfig = config.prm ?? PrmConfigSchema.parse({}); const prmHook = createPrmHook( prmConfig, - ctx.directory, + bootstrapRoot, // #1821 F3: this mapping used to be an inline literal that ANDed // `realtime_admission.enabled` into the producer's `enabled` flag, which // also disabled the hook's durable `appendInsightCandidates` backstop — so @@ -2107,11 +2191,11 @@ async function initializeOpenCodeSwarm( enabled: true, max_lines: prmConfig.max_trajectory_lines, }, - ctx.directory, + bootstrapRoot, ); const delegationGateHooks = createDelegationGateHook( configWithResolvedAutoReview, - ctx.directory, + bootstrapRoot, agents, ); const advisoryInjector = (sessionId: string, message: string) => { @@ -2128,7 +2212,7 @@ async function initializeOpenCodeSwarm( (config.hooks as Record | undefined) ?.background_subagents === true, }, - directory: ctx.directory, + directory: bootstrapRoot, reviewerReceiptOptions: { dispatcher: reviewModelDispatcher, config: autoReviewConfig, @@ -2169,7 +2253,7 @@ async function initializeOpenCodeSwarm( pendingDelegationsModulePromise = modulePromise; } const module = await modulePromise; - await module.maintainBackgroundDelegations(ctx.directory, { + await module.maintainBackgroundDelegations(bootstrapRoot, { lockTimeoutMs: 2_000, reason: 'session-close', onLegacyCoderSettlementReconciled: @@ -2178,13 +2262,13 @@ async function initializeOpenCodeSwarm( backgroundCompletionObserver.notifyLegacyCoderSettlementAdvisoryReplaced, }); }; - const delegationSanitizerHook = createDelegationSanitizerHook(ctx.directory); + const delegationSanitizerHook = createDelegationSanitizerHook(bootstrapRoot); // #2486 (D7): the consent-gated training-content capture observer. // Construction performs NO I/O (invariant 1) — consent is read lazily on // the first observation, so an unconsented project pays nothing. - const trainingCaptureObserver = createTrainingCaptureObserver(ctx.directory); + const trainingCaptureObserver = createTrainingCaptureObserver(bootstrapRoot); const memoryLifecycleHooks = createMemoryLifecycleHooks({ - directory: ctx.directory, + directory: bootstrapRoot, config: config.memory, getActiveAgentName: (sessionID) => sessionID ? swarmState.activeAgent.get(sessionID) : undefined, @@ -2215,7 +2299,12 @@ async function initializeOpenCodeSwarm( .catch(() => undefined) .then(() => withTimeout( - regenerateMemoryReflectionForInit(ctx.directory, reflectionConfig), + bootstrapStateWritesEnabled + ? regenerateMemoryReflectionForInit( + bootstrapRoot, + reflectionConfig, + ) + : Promise.resolve(), 15_000, new Error('memory reflection startup regeneration exceeded 15s'), ), @@ -2274,7 +2363,7 @@ async function initializeOpenCodeSwarm( const delegationHandler = createDelegationTrackerHook( config, guardrailsConfig.enabled, - ctx.directory, + bootstrapRoot, ); const authorityConfig = AuthorityConfigSchema.parse(config.authority ?? {}); const worktreeDirOverride = @@ -2283,7 +2372,7 @@ async function initializeOpenCodeSwarm( ? [worktreeDirOverride] : []; const guardrailsHooks = createGuardrailsHooks( - ctx.directory, + bootstrapRoot, undefined, guardrailsConfig, authorityConfig, @@ -2419,7 +2508,7 @@ async function initializeOpenCodeSwarm( // Full-auto intercept: autonomous oversight when full-auto mode is active const fullAutoInterceptHook = createFullAutoInterceptHook( config, - ctx.directory, + bootstrapRoot, ); // Full-Auto v2 hooks: permission, input-probe, delegation. Always armed @@ -2437,22 +2526,22 @@ async function initializeOpenCodeSwarm( // - full-auto-delegation return check runs alongside. const fullAutoPermissionHook = createFullAutoPermissionHook({ config, - directory: ctx.directory, + directory: bootstrapRoot, }); const fullAutoInputProbeHook = createFullAutoInputProbeHook({ config, - directory: ctx.directory, + directory: bootstrapRoot, }); const fullAutoDelegationHook = createFullAutoDelegationHook({ config, - directory: ctx.directory, + directory: bootstrapRoot, }); // CC command intercept: handle Claude Code command interception const ccCommandInterceptHook = createCcCommandInterceptHook({}); // Issue trace: mode-transition workflow for traced GitHub issues - const issueTraceHook = createIssueTraceHook(config, ctx.directory); + const issueTraceHook = createIssueTraceHook(config, bootstrapRoot); // Watchdog: scope-guard + delegation-ledger const watchdogConfig = WatchdogConfigSchema.parse(config.watchdog ?? {}); @@ -2461,29 +2550,28 @@ async function initializeOpenCodeSwarm( { enabled: watchdogConfig.scope_guard, }, - ctx.directory, + bootstrapRoot, advisoryInjector, ); const prWorkflowResponseGate = createPrWorkflowResponseGate({ - directory: ctx.directory, + directory: bootstrapRoot, client: ctx.client, }); const prWorkflowSessionResolver = createPrWorkflowSessionResolver({ - directory: ctx.directory, + directory: bootstrapRoot, client: ctx.client, }); const delegationLedgerHook = createDelegationLedgerHook( { enabled: watchdogConfig.delegation_ledger }, - ctx.directory, + bootstrapRoot, advisoryInjector, ); // Init orphan recovery advisory: surfaces plugin-init orphan reclamation results // to the architect on their next turn via pendingAdvisoryMessages. - const initOrphanRecoveryAdvisoryHook = createInitOrphanRecoveryAdvisoryHook( - ctx.directory, - ); + const initOrphanRecoveryAdvisoryHook = + createInitOrphanRecoveryAdvisoryHook(bootstrapRoot); // Self-review advisory hook const selfReviewConfig = SelfReviewConfigSchema.parse( @@ -2502,7 +2590,7 @@ async function initializeOpenCodeSwarm( // boundaries. Advisory + fire-and-forget — never blocks a tool call. const autoReviewHook = createAutoReviewHook({ config: autoReviewConfig, - directory: ctx.directory, + directory: bootstrapRoot, dispatcher: reviewModelDispatcher, generatedAgentNames: instanceGeneratedAgentNames, agentModelRegistry: reviewAgentModelRegistry, @@ -2513,7 +2601,7 @@ async function initializeOpenCodeSwarm( const summaryConfig = SummaryConfigSchema.parse(config.summaries ?? {}); const toolSummarizerHook = createToolSummarizerHook( summaryConfig, - ctx.directory, + bootstrapRoot, ); // v6.17 Knowledge system hooks — fire-and-forget, wrapped in safeHook @@ -2534,7 +2622,7 @@ async function initializeOpenCodeSwarm( .then(({ runSkillConsolidationFireAndForget }) => { runSkillConsolidationFireAndForget( { - directory: ctx.directory, + directory: bootstrapRoot, config: skillImproverConfig, source: 'startup', enrichmentQuota: { @@ -2563,9 +2651,9 @@ async function initializeOpenCodeSwarm( // skill_improver keeps its own proposal quota; curator/micro-reflector // enrichment uses knowledge.enrichment below. const knowledgeCuratorHook = knowledgeConfig.enabled - ? createKnowledgeCuratorHook(ctx.directory, knowledgeConfig, { + ? createKnowledgeCuratorHook(bootstrapRoot, knowledgeConfig, { llmDelegateFactory: (sessionID) => - createCuratorLLMDelegate(ctx.directory, 'phase', sessionID), + createCuratorLLMDelegate(bootstrapRoot, 'phase', sessionID), enrichmentQuota: { maxCalls: knowledgeConfig.enrichment.max_calls_per_day, window: knowledgeConfig.enrichment.quota_window, @@ -2574,11 +2662,11 @@ async function initializeOpenCodeSwarm( : undefined; const hivePromoterHook = knowledgeConfig.enabled && knowledgeConfig.hive_enabled - ? createHivePromoterHook(ctx.directory, knowledgeConfig) + ? createHivePromoterHook(bootstrapRoot, knowledgeConfig) : undefined; const knowledgeInjectorHook = knowledgeConfig.enabled ? createKnowledgeInjectorHook( - ctx.directory, + bootstrapRoot, knowledgeConfig, config.context_budget?.model_limits ?? {}, config.context_budget?.unified_injection_tokens, @@ -2586,11 +2674,11 @@ async function initializeOpenCodeSwarm( : undefined; // v6.18 Steering acknowledgment hook — auto-acknowledges unconsumed steering directives - const steeringConsumedHook = createSteeringConsumedHook(ctx.directory); + const steeringConsumedHook = createSteeringConsumedHook(bootstrapRoot); // v6.18 Agent intelligence hooks — co-change suggestions and dark-matter gap detection - const coChangeSuggesterHook = createCoChangeSuggesterHook(ctx.directory); - const darkMatterDetectorHook = createDarkMatterDetectorHook(ctx.directory); + const coChangeSuggesterHook = createCoChangeSuggesterHook(bootstrapRoot); + const darkMatterDetectorHook = createDarkMatterDetectorHook(bootstrapRoot); const slopDetectorHook = config.slop_detector?.enabled !== false ? createSlopDetectorHook( @@ -2601,7 +2689,7 @@ async function initializeOpenCodeSwarm( diffLineThreshold: 200, importHygieneThreshold: 2, }, - ctx.directory, + bootstrapRoot, (sessionId, message) => { const s = swarmState.agentSessions.get(sessionId); if (s) { @@ -2619,7 +2707,7 @@ async function initializeOpenCodeSwarm( timeoutMs: 30000, triggerAgents: ['coder'], }, - ctx.directory, + bootstrapRoot, (sessionId, message) => { const s = swarmState.agentSessions.get(sessionId); if (s) { @@ -2638,7 +2726,7 @@ async function initializeOpenCodeSwarm( emergencyThreshold: 80, preserveLastNTurns: 5, }, - ctx.directory, + bootstrapRoot, (sessionId, message) => { const s = swarmState.agentSessions.get(sessionId); if (s) { @@ -2648,7 +2736,9 @@ async function initializeOpenCodeSwarm( ) : null; // v6.18 Session persistence — write state snapshot after each tool call - const snapshotWriterHook = createSnapshotWriterHook(ctx.directory); + const snapshotWriterHook = bootstrapStateWritesEnabled + ? createSnapshotWriterHook(bootstrapRoot) + : async () => {}; // fail-closed (#2679): per-tool-call snapshot writes disabled // Parse automation config (v6.7 feature flags) // Read flags without activating - scaffold only for now @@ -2665,7 +2755,7 @@ async function initializeOpenCodeSwarm( if (automationConfig.mode !== 'manual') { automationManager = createAutomationManager(automationConfig); - automationManager.start(); + if (bootstrapStateWritesEnabled) automationManager.start(); // v6.7 Task 5.5: Initialize trigger manager (plumbing only, no preflight logic yet) const { PreflightTriggerManager: PTM } = await import( @@ -2684,7 +2774,7 @@ async function initializeOpenCodeSwarm( const { getSharedAutomationStatusArtifact } = await import( './background/status-artifact' ); - const swarmDir = path.resolve(ctx.directory, '.swarm'); + const swarmDir = path.resolve(bootstrapRoot, '.swarm'); const automationStatusArtifactPostInitTask = async () => { try { // Shared per-swarmDir instance: the preflight integration @@ -2705,37 +2795,44 @@ async function initializeOpenCodeSwarm( }); } }; - postResolutionTasks.push(automationStatusArtifactPostInitTask); + if (bootstrapStateWritesEnabled) + postResolutionTasks.push(automationStatusArtifactPostInitTask); // v6.8 Task 1.1: Wire evidence summary integration - if (automationConfig.capabilities?.evidence_auto_summaries === true) { + if ( + automationConfig.capabilities?.evidence_auto_summaries === true && + bootstrapStateWritesEnabled + ) { const { createEvidenceSummaryIntegration } = await import( './background/evidence-summary-integration' ); createEvidenceSummaryIntegration({ automationConfig, - directory: ctx.directory, - projectDir: ctx.directory, + directory: bootstrapRoot, + projectDir: bootstrapRoot, summaryFilename: 'evidence-summary.json', }); log('Evidence summary integration initialized', { - directory: ctx.directory, + directory: bootstrapRoot, }); } // v6.8 Task 2.2: Wire preflight integration - if (automationConfig.capabilities?.phase_preflight === true) { + if ( + automationConfig.capabilities?.phase_preflight === true && + bootstrapStateWritesEnabled + ) { const { createPreflightIntegration } = await import( './services/preflight-integration' ); try { const { manager } = createPreflightIntegration({ automationConfig, - directory: ctx.directory, + directory: bootstrapRoot, swarmDir, }); preflightTriggerManager = manager; - log('Preflight integration initialized', { directory: ctx.directory }); + log('Preflight integration initialized', { directory: bootstrapRoot }); } catch (err) { log('Preflight integration failed to initialize (non-fatal)', { error: err instanceof Error ? err.message : String(err), @@ -2744,14 +2841,17 @@ async function initializeOpenCodeSwarm( } // v6.8 Task 3.2: Wire PlanSyncWorker for plan.json -> plan.md sync - if (automationConfig.capabilities?.plan_sync === true) { + if ( + automationConfig.capabilities?.plan_sync === true && + bootstrapStateWritesEnabled + ) { try { planSyncWorker = new PlanSyncWorker({ - directory: ctx.directory, + directory: bootstrapRoot, // Using defaults: debounceMs=300, pollIntervalMs=2000 }); planSyncWorker.start(); - log('PlanSyncWorker initialized', { directory: ctx.directory }); + log('PlanSyncWorker initialized', { directory: bootstrapRoot }); } catch (err) { log('PlanSyncWorker failed to initialize (non-fatal)', { error: err instanceof Error ? err.message : String(err), @@ -2805,7 +2905,7 @@ async function initializeOpenCodeSwarm( // leaves A's registry entry untouched; each instance's cleanupAutomation // removes only its own entry. ensurePrSubscriptionDispatcherInstalled(); - registerPrMonitorWorkerHandler(ctx.directory, ensurePrMonitorWorkerRunning); + registerPrMonitorWorkerHandler(bootstrapRoot, ensurePrMonitorWorkerRunning); // Register PR event subscribers for event delivery to active sessions let prEventCleanup: (() => void) | null = null; @@ -2823,7 +2923,7 @@ async function initializeOpenCodeSwarm( './background/pr-event-subscribers' ); prEventCleanup = registerPrEventSubscribers({ - directory: ctx.directory, + directory: bootstrapRoot, config: prMonitorConfig, }); } catch (err) { @@ -2836,7 +2936,7 @@ async function initializeOpenCodeSwarm( const deliveryModule = await import('./background/pr-event-delivery'); deliveryModule.registerPrEventDelivery({ client: ctx.client, - directory: ctx.directory, + directory: bootstrapRoot, config: prMonitorConfig, }); prEventDelivery = { @@ -2855,7 +2955,7 @@ async function initializeOpenCodeSwarm( // Cheap to construct; all gating (enabled + auto_subscribe_on_pr_create) // happens inside the hook. const prAutoSubscribeHook = createPrAutoSubscribeHook( - ctx.directory, + bootstrapRoot, prMonitorConfig, ); @@ -2863,10 +2963,10 @@ async function initializeOpenCodeSwarm( // Deferred via the wrapper-owned post-resolution queue (fail-open). if (prMonitorConfig.enabled) { postResolutionTasks.push(() => { - void listActiveSubscriptions(ctx.directory) + void listActiveSubscriptions(bootstrapRoot) .then((active) => { if (active.length > 0) { - ensurePrMonitorWorkerRunning(ctx.directory); + ensurePrMonitorWorkerRunning(bootstrapRoot); } }) .catch((err) => { @@ -2896,7 +2996,7 @@ async function initializeOpenCodeSwarm( dashboardDisposed = true; try { closeDashboardServerForRootIfOwner( - ctx.directory, + bootstrapRoot, dashboardHandleRef.current, ); } catch { @@ -2909,20 +3009,20 @@ async function initializeOpenCodeSwarm( // The expected-handler guard makes a stale dispose arriving after a // same-root re-init a no-op instead of stripping the newer // instance's registration (final-critic follow-up, this round). - removePrMonitorWorkerHandler(ctx.directory, ensurePrMonitorWorkerRunning); + removePrMonitorWorkerHandler(bootstrapRoot, ensurePrMonitorWorkerRunning); prEventCleanup?.(); prEventDelivery?.unregisterPrEventDelivery(); - markSnapshotCoordinationClosing(ctx.directory); + markSnapshotCoordinationClosing(bootstrapRoot); // #2480: durable-state close: flush queued group-commit writes, then // closeProjectDb (its own best-effort TRUNCATE→PASSIVE checkpoint is // contention-reporting and stays fast, so it is safe on the exit path). try { - closeGroupCommitWriter(ctx.directory); + closeGroupCommitWriter(bootstrapRoot); // Exit handlers cannot await the retained initialization promise. If it // is still running, leave the handle to OS process teardown rather than // closing it underneath the import transaction. - if (getSnapshotCoordinationStatus(ctx.directory).settled) { - closeProjectDb(ctx.directory); + if (getSnapshotCoordinationStatus(bootstrapRoot).settled) { + closeProjectDb(bootstrapRoot); } } catch { // best-effort by contract @@ -2931,7 +3031,7 @@ async function initializeOpenCodeSwarm( // Register THIS instance's cleanup in the shared once-guarded process // dispatcher's registry (issue #2472 W9) — never process.on directly, which // accumulated one 'exit' listener per init and never removed any. - const instanceExitCleanupToken = `${ctx.directory}#${++instanceExitCleanupCounter}`; + const instanceExitCleanupToken = `${bootstrapRoot}#${++instanceExitCleanupCounter}`; registerProcessExitCleanupDispatcher(); instanceExitCleanups.set(instanceExitCleanupToken, cleanupAutomation); @@ -2950,7 +3050,7 @@ async function initializeOpenCodeSwarm( ({ runConfigDoctorWithFixes }) => { // Default to scan-only mode (autoFix=false) for security // Autofix only runs when explicitly enabled via capability - return runConfigDoctorWithFixes(ctx.directory, config, enableAutofix) + return runConfigDoctorWithFixes(bootstrapRoot, config, enableAutofix) .then((doctorResult) => { if (doctorResult.result.findings.length > 0) { log('Config Doctor ran on startup', { @@ -3053,7 +3153,7 @@ async function initializeOpenCodeSwarm( const handle = await startDashboardServer({ port: dashboardPort, host: '127.0.0.1', - directory: ctx.directory, + directory: bootstrapRoot, }); if (!handle.listening) { // Disable-with-notice (AC3/AC7): the handle + the @@ -3398,7 +3498,7 @@ async function initializeOpenCodeSwarm( // (#1849) sessionID from output.messages[].info, not input. const mctx = resolveMessageTransformContext(output as MessageArrayLike); return knowledgeApplicationTransformScan( - ctx.directory, + bootstrapRoot, output as { messages?: import('./hooks/knowledge-types.js').MessageWithParts[]; }, @@ -3424,7 +3524,7 @@ async function initializeOpenCodeSwarm( // (#1849) sessionID from output.messages[].info, not input. const mctx = resolveMessageTransformContext(output as MessageArrayLike); return skillPropagationTransformScan( - ctx.directory, + bootstrapRoot, output as { messages?: import('./hooks/knowledge-types.js').MessageWithParts[]; }, @@ -3550,7 +3650,7 @@ async function initializeOpenCodeSwarm( // only and must never block teardown; a future re-init for the // same directory (new system-enhancer instance) un-serves it. try { - cancelDeferredMaintenanceScans(ctx.directory); + cancelDeferredMaintenanceScans(bootstrapRoot); } catch (err) { log('dispose deferred-scan cancellation failed (non-fatal)', { error: err instanceof Error ? err.message : String(err), @@ -3577,11 +3677,11 @@ async function initializeOpenCodeSwarm( // the global pool-clearing variant — the module-level pool is shared // process-wide and other projects' handles must survive this // instance's teardown. - evictAndClose(ctx.directory); + evictAndClose(bootstrapRoot); try { - await closeSnapshotCoordinationInitialization(ctx.directory); - closeGroupCommitWriter(ctx.directory); - closeProjectDb(ctx.directory); + await closeSnapshotCoordinationInitialization(bootstrapRoot); + closeGroupCommitWriter(bootstrapRoot); + closeProjectDb(bootstrapRoot); } catch (err) { log('dispose durable-state close failed (non-fatal)', { error: err instanceof Error ? err.message : String(err), @@ -3686,7 +3786,7 @@ async function initializeOpenCodeSwarm( childSessionID: eventChildSessionID, }); const fullAutoRunState = loadFullAutoRunState( - ctx.directory, + bootstrapRoot, eventParentSessionID, ); if (fullAutoRunState?.runGeneration !== undefined) { @@ -3778,7 +3878,7 @@ async function initializeOpenCodeSwarm( // durable owner-state cleanup. try { await terminalizePrWorkflowGateForSession( - ctx.directory, + bootstrapRoot, sessionID, ); } catch { @@ -3788,7 +3888,7 @@ async function initializeOpenCodeSwarm( } try { const reconciliation = reconcilePrWorkflowCheckoutReceipts( - ctx.directory, + bootstrapRoot, sessionID, ); const summary = await withTimeout( @@ -3813,7 +3913,9 @@ async function initializeOpenCodeSwarm( 'PR workflow checkout receipt reconciliation on session deletion failed or exceeded its event budget (non-fatal)', ); } - deleteSnapshotSessionRows(ctx.directory, sessionID); + if (bootstrapStateWritesEnabled) { + deleteSnapshotSessionRows(bootstrapRoot, sessionID); + } clearPendingTaskModelRoutesForSession(sessionID); clearSessionActionCircuits(sessionID); clearFullAutoSevereSession(sessionID); @@ -3825,7 +3927,7 @@ async function initializeOpenCodeSwarm( // maintenance service's own tight lock bound and // fail-open — failures are recorded in the durable // facts ring, never fatal to the event hook. - if (backgroundSubagentsEnabled) { + if (backgroundSubagentsEnabled && bootstrapStateWritesEnabled) { try { await maintainBackgroundDelegationsOnSessionEvent(); } catch { @@ -3842,7 +3944,7 @@ async function initializeOpenCodeSwarm( const { recoverTerminalLaneReceipts } = await import( './background/delegation-lifecycle.js' ); - await recoverTerminalLaneReceipts(ctx.directory); + await recoverTerminalLaneReceipts(bootstrapRoot); } catch { // fail-open — recovery must never break the event hook } @@ -4622,19 +4724,19 @@ async function initializeOpenCodeSwarm( automationConfig.capabilities?.phase_preflight === true && preflightTriggerManager ? createPhaseMonitorHook( - ctx.directory, + bootstrapRoot, preflightTriggerManager, undefined, (sessionId) => - createCuratorLLMDelegate(ctx.directory, 'init', sessionId), + createCuratorLLMDelegate(bootstrapRoot, 'init', sessionId), ) : knowledgeConfig.enabled ? createPhaseMonitorHook( - ctx.directory, + bootstrapRoot, undefined, undefined, (sessionId) => - createCuratorLLMDelegate(ctx.directory, 'init', sessionId), + createCuratorLLMDelegate(bootstrapRoot, 'init', sessionId), ) : undefined, swarmCommandSystemRuleHook, @@ -4738,7 +4840,7 @@ async function initializeOpenCodeSwarm( ensureAgentSession( input.sessionID, ORCHESTRATOR_NAME, - ctx.directory, + bootstrapRoot, ); } } @@ -4798,7 +4900,7 @@ async function initializeOpenCodeSwarm( halfOpenAfterMs: dispatchProtectionConfig.half_open_after_ms, }); await acquireDispatchToken({ - directory: ctx.directory, + directory: bootstrapRoot, ratePerSecond: dispatchProtectionConfig.rate_per_second, burstCapacity: dispatchProtectionConfig.burst_capacity, }); @@ -4825,7 +4927,7 @@ async function initializeOpenCodeSwarm( output as { args?: unknown }, ); await enforcePrWorkflowToolBefore( - ctx.directory, + bootstrapRoot, prWorkflowControllerSessionID, normalizeToolName(input.tool) ?? input.tool, prWorkflowToolContext.args ?? undefined, @@ -4857,7 +4959,7 @@ async function initializeOpenCodeSwarm( // a critical directive was shown but no ack was recorded. // In `warn` mode it appends to events.jsonl and returns. await knowledgeApplicationGateBefore( - ctx.directory, + bootstrapRoot, { // (#1849) tool.execute.before input has no agent/sessionID-derived // agent; use the host-boundary adapter (reads swarmState.activeAgent). @@ -4881,7 +4983,7 @@ async function initializeOpenCodeSwarm( // before its optional propagation-enabled early return. Calling it once // avoids reopening every referenced skill twice. const skillResult = await skillPropagationGateBefore( - ctx.directory, + bootstrapRoot, { // (#1849) agent + args via the host-boundary adapter. tool: input.tool, @@ -4907,7 +5009,7 @@ async function initializeOpenCodeSwarm( const skillSession = ensureAgentSession( input.sessionID, swarmState.activeAgent.get(input.sessionID) ?? ORCHESTRATOR_NAME, - ctx.directory, + bootstrapRoot, ); pushAdvisory(skillSession, skillResult.reason); } @@ -4931,10 +5033,10 @@ async function initializeOpenCodeSwarm( ); const toolBeforeArgs = toolBeforeCtx.args ?? {}; const skillAttributionPlanTaskOptions = toTaskIdPlanContextOptions( - await loadPlanTaskIdContext(ctx.directory), + await loadPlanTaskIdContext(bootstrapRoot), ); injectSkillsIntoDelegation( - ctx.directory, + bootstrapRoot, toolBeforeArgs, skillResult.recommendedSkills, stripKnownSwarmPrefix( @@ -4964,7 +5066,7 @@ async function initializeOpenCodeSwarm( // directives + ack contract. Internally fail-open; never blocks. if (knowledgeConfig.enabled) { await injectDelegateDirectivesBefore( - ctx.directory, + bootstrapRoot, { tool: input.tool, agent: toolBeforeCtx.agent, @@ -4999,7 +5101,7 @@ async function initializeOpenCodeSwarm( const pressureSession = ensureAgentSession( input.sessionID, swarmState.activeAgent.get(input.sessionID) ?? ORCHESTRATOR_NAME, - ctx.directory, + bootstrapRoot, ); if (!pressureSession.contextPressureWarningSent) { pressureSession.contextPressureWarningSent = true; @@ -5019,7 +5121,7 @@ async function initializeOpenCodeSwarm( // would strand identity-bound state when a later policy gate throws. if (autoReviewConfig.enabled) { await beginApprovedReviewerScopeLifecycle({ - directory: ctx.directory, + directory: bootstrapRoot, tool: input.tool, args: toolBeforeArgs, parentSessionID: input.sessionID, @@ -5035,7 +5137,7 @@ async function initializeOpenCodeSwarm( // completions correlate by parent session + call ID; background calls // promote that binding in tool.execute.after. await reserveApprovedPhaseParticipation({ - directory: ctx.directory, + directory: bootstrapRoot, tool: input.tool, parentSessionId: input.sessionID, callId: input.callID, @@ -5141,7 +5243,7 @@ async function initializeOpenCodeSwarm( args: deniedArgs, }, deniedMessage, - ctx.directory, + bootstrapRoot, // Same knob as the successful-call path (issue // #2041 Required 5): prm.max_trajectory_lines. { maxLines: prmConfig.max_trajectory_lines }, @@ -5296,7 +5398,7 @@ async function initializeOpenCodeSwarm( input.sessionID, ); await recordPrFeedbackPushAttemptResult( - ctx.directory, + bootstrapRoot, { sessionID: pushAttemptSessionID, callID: input.callID, @@ -5311,7 +5413,7 @@ async function initializeOpenCodeSwarm( } if (autoReviewConfig.enabled && isTaskTool) { await completeReviewerScopeLifecycle({ - directory: ctx.directory, + directory: bootstrapRoot, tool: input.tool, args: afterCtx.args, output, @@ -5341,7 +5443,7 @@ async function initializeOpenCodeSwarm( if (knowledgeConfig.enabled) { await safeHook(() => collectDelegateAcksAfter( - ctx.directory, + bootstrapRoot, { tool: input.tool, sessionID: input.sessionID, @@ -5354,7 +5456,7 @@ async function initializeOpenCodeSwarm( // parse a returning reviewer's per-ID verdicts into knowledge events. await safeHook(() => collectReviewerVerdictsAfter( - ctx.directory, + bootstrapRoot, { tool: input.tool, sessionID: input.sessionID, @@ -5368,10 +5470,10 @@ async function initializeOpenCodeSwarm( // transcript. Quota-gated; classification-only without an LLM client. await safeHook(() => microReflectorAfter( - ctx.directory, + bootstrapRoot, input, output, - createCuratorLLMDelegate(ctx.directory, 'phase', input.sessionID), + createCuratorLLMDelegate(bootstrapRoot, 'phase', input.sessionID), { maxCalls: knowledgeConfig.enrichment.max_calls_per_day, window: knowledgeConfig.enrichment.quota_window, @@ -5393,18 +5495,18 @@ async function initializeOpenCodeSwarm( // queue-depth probe, so the non-Task path does no I/O and takes no lock. await safeHook(async () => { const summary = await realtimeAdmissionAfter( - ctx.directory, + bootstrapRoot, { tool: input.tool, sessionID: input.sessionID }, learningConfig.realtime_admission, async () => { - const plan = await loadPlan(ctx.directory).catch(() => null); + const plan = await loadPlan(bootstrapRoot).catch(() => null); return { knowledgeConfig, projectName: plan?.title ?? 'unknown', phaseNumber: plan?.current_phase ?? 1, sessionID: input.sessionID, llmDelegate: createCuratorLLMDelegate( - ctx.directory, + bootstrapRoot, 'phase', input.sessionID, ), @@ -5433,7 +5535,7 @@ async function initializeOpenCodeSwarm( // recovered args so the collector can parse subagent_type/prompt. await safeHook(async () => { await collectReviewerReceiptAfter( - ctx.directory, + bootstrapRoot, { tool: input.tool, sessionID: input.sessionID, @@ -5474,7 +5576,7 @@ async function initializeOpenCodeSwarm( await safeHook(delegationGateHooks.toolAfter)(input, output); await safeHook(async () => { await observePhaseParticipationToolResult({ - directory: ctx.directory, + directory: bootstrapRoot, tool: input.tool, parentSessionId: input.sessionID, callId: input.callID, @@ -5543,7 +5645,7 @@ async function initializeOpenCodeSwarm( // Debugging spiral detection try { const spiralMatch = await detectDebuggingSpiral( - ctx.directory, + bootstrapRoot, input.sessionID, ); if (spiralMatch) { @@ -5553,7 +5655,7 @@ async function initializeOpenCodeSwarm( const spiralResult = await handleDebuggingSpiral( spiralMatch, taskId, - ctx.directory, + bootstrapRoot, ); const session = swarmState.agentSessions.get(input.sessionID); if (session) { @@ -5622,7 +5724,7 @@ async function initializeOpenCodeSwarm( implementation_summary: agentOutput.slice(0, 500), task_goal: '', final_status: 'completed', - directory: ctx.directory, + directory: bootstrapRoot, }); } } catch { @@ -5757,7 +5859,7 @@ async function initializeOpenCodeSwarm( .slice(0, 32); } swarmState.activeAgent.set(sessionId, ORCHESTRATOR_NAME); - ensureAgentSession(sessionId, ORCHESTRATOR_NAME, ctx.directory); + ensureAgentSession(sessionId, ORCHESTRATOR_NAME, bootstrapRoot); const taskSession = swarmState.agentSessions.get(sessionId); if (taskSession) { taskSession.delegationActive = false; @@ -6005,7 +6107,7 @@ async function initializeOpenCodeSwarm( // unset and callers fall back to readLinkPointer / re-resolve-once. if (input?.sessionID) { try { - await cacheCohortIdAtMessage(ctx.directory, input.sessionID); + await cacheCohortIdAtMessage(bootstrapRoot, input.sessionID); } catch { /* non-blocking — cache stays unset */ } @@ -6025,7 +6127,7 @@ async function initializeOpenCodeSwarm( const stripped = stripKnownSwarmPrefix(String(input.agent)); if (stripped === 'architect') { tickAndMaybeDispatchCadence( - ctx.directory, + bootstrapRoot, input.sessionID, 'architectTurns', config, diff --git a/src/observability/catalog.ts b/src/observability/catalog.ts index 7461573f6..875a501fe 100644 --- a/src/observability/catalog.ts +++ b/src/observability/catalog.ts @@ -406,7 +406,7 @@ const CATALOG_SOURCE: readonly (readonly [string, CatalogEntryInput])[] = [ category: 'delegation', severity: 'info', privacyClass: 'pseudonymous', - producer: 'src/index.ts:935', + producer: 'src/index.ts:937', consumers: CONSUMER_COST_CORRECTION, retentionOwnerIssue: ISSUE_COST_RETENTION, requiredWorkflowIds: REQUIRE_SESSION_AND_TASK, @@ -419,7 +419,7 @@ const CATALOG_SOURCE: readonly (readonly [string, CatalogEntryInput])[] = [ category: 'delegation', severity: 'info', privacyClass: 'pseudonymous', - producer: 'src/index.ts:1973', + producer: 'src/index.ts:2057', consumers: NO_CONSUMERS, futureOwnerIssue: ISSUE_SINK, retentionOwnerIssue: ISSUE_COST_RETENTION, @@ -433,7 +433,7 @@ const CATALOG_SOURCE: readonly (readonly [string, CatalogEntryInput])[] = [ category: 'delegation', severity: 'notice', privacyClass: 'pseudonymous', - producer: 'src/index.ts:1993', + producer: 'src/index.ts:2077', consumers: CONSUMER_COST_JOIN, retentionOwnerIssue: ISSUE_COST_RETENTION, requiredWorkflowIds: REQUIRE_SESSION, diff --git a/src/utils/project-boundary.ts b/src/utils/project-boundary.ts index 92f51fb8c..7311f1d67 100644 --- a/src/utils/project-boundary.ts +++ b/src/utils/project-boundary.ts @@ -87,6 +87,77 @@ type ProjectRootProbeDependencies = Pick< 'realpathSync' | 'statSync' >; +export type { ProjectRootProbeDependencies }; + +/** + * Outcome of the shared boundary walk used by both `assertProjectRoot` and + * `resolveProjectRootDecision`, so the two consumers cannot drift. + */ +type BoundaryWalkResult = + | { outcome: 'marker-root' } + | { outcome: 'claimed'; ancestor: string } + | { outcome: 'unclaimed' } + | { + outcome: 'fail-closed'; + reason: 'depth' | 'ancestor-swarm' | 'ancestor-indicators'; + path?: string; + }; + +function walkProjectBoundary( + resolved: string, + dependencies: ProjectRootProbeDependencies, +): BoundaryWalkResult { + if (hasExplicitProjectBoundary(resolved)) return { outcome: 'marker-root' }; + + let current = resolved; + let depth = 0; + while (true) { + if (depth >= MAX_PROJECT_ROOT_DEPTH) { + return { outcome: 'fail-closed', reason: 'depth' }; + } + depth++; + const parent = path.dirname(current); + if (parent === current) return { outcome: 'unclaimed' }; + if (path.dirname(parent) === parent) { + current = parent; + continue; + } + + const parentSwarm = path.join(parent, '.swarm'); + let parentSwarmStat: fs.Stats; + try { + parentSwarmStat = dependencies.statSync(parentSwarm); + } catch (error) { + if (isMissingPathError(error)) { + current = parent; + continue; + } + return { + outcome: 'fail-closed', + reason: 'ancestor-swarm', + path: parentSwarm, + }; + } + + if (parentSwarmStat.isDirectory()) { + const indicatorState = projectIndicatorState(parent, dependencies, { + allowConfigOnly: !isWeakConfigContainerRoot(parent, dependencies), + }); + if (indicatorState === 'inaccessible') { + return { + outcome: 'fail-closed', + reason: 'ancestor-indicators', + path: parent, + }; + } + if (indicatorState === 'present') { + return { outcome: 'claimed', ancestor: parent }; + } + } + current = parent; + } +} + function isMissingPathError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException | undefined)?.code; return code === 'ENOENT' || code === 'ENOTDIR'; @@ -157,67 +228,95 @@ export function assertProjectRoot( `Cannot verify project root for "${directory}" — directory may not exist or is inaccessible`, ); } - if (hasExplicitProjectBoundary(resolved)) return; - - let current = resolved; - let depth = 0; - while (true) { - if (depth >= MAX_PROJECT_ROOT_DEPTH) { - warn( - `[project-boundary] Ancestor search exceeded ${MAX_PROJECT_ROOT_DEPTH} levels for "${resolved}" — failing closed`, - ); - throw new Error( - `Cannot verify project root for "${resolved}" — ancestor search exceeded ${MAX_PROJECT_ROOT_DEPTH} levels`, - ); - } - depth++; - const parent = path.dirname(current); - if (parent === current) break; - if (path.dirname(parent) === parent) { - current = parent; - continue; - } + const walk = walkProjectBoundary(resolved, dependencies); + if (walk.outcome === 'marker-root' || walk.outcome === 'unclaimed') return; + if (walk.outcome === 'claimed') { + warn( + `[project-boundary] Rejecting write to subdirectory "${resolved}" — parent "${walk.ancestor}" already contains .swarm/`, + ); + throw new Error( + `Cannot write ${artifactLabel} in "${resolved}" — parent directory "${walk.ancestor}" already contains a .swarm/ folder. Runtime state must be written to the project root.`, + ); + } + if (walk.reason === 'depth') { + warn( + `[project-boundary] Ancestor search exceeded ${MAX_PROJECT_ROOT_DEPTH} levels for "${resolved}" — failing closed`, + ); + throw new Error( + `Cannot verify project root for "${resolved}" — ancestor search exceeded ${MAX_PROJECT_ROOT_DEPTH} levels`, + ); + } + if (walk.reason === 'ancestor-swarm') { + warn( + `[project-boundary] Cannot inspect ancestor state "${walk.path}" — failing closed`, + ); + throw new Error( + `Cannot verify project root for "${resolved}" — ancestor state "${walk.path}" is inaccessible`, + ); + } + warn( + `[project-boundary] Cannot inspect project indicators in ancestor "${walk.path}" — failing closed`, + ); + throw new Error( + `Cannot verify project root for "${resolved}" — project indicators in ancestor "${walk.path}" are inaccessible`, + ); +} - const parentSwarm = path.join(parent, '.swarm'); - let parentSwarmStat: fs.Stats; - try { - parentSwarmStat = dependencies.statSync(parentSwarm); - } catch (error) { - if (isMissingPathError(error)) { - current = parent; - continue; - } - warn( - `[project-boundary] Cannot inspect ancestor state "${parentSwarm}" — failing closed`, - ); - throw new Error( - `Cannot verify project root for "${resolved}" — ancestor state "${parentSwarm}" is inaccessible`, - ); - } +/** + * Decision form of the same boundary policy (#2679): non-throwing, for the + * bootstrap path where the outcome must (a) name the owning project root for an + * ordinary child so every init/first-write consumer can target it, and + * (b) fail closed (no runtime-state writes) when ownership cannot be + * determined, while the plugin manifest stays fail-open. + * + * - `root`: the directory is a standalone root or declares its own boundary + * (`.git` file/dir, `.opencode` dir) — use it verbatim (canonicalized). + * - `redirect`: an ancestor owns `.swarm/` plus a project indicator — use + * `owningRoot` for all project-surface reads/writes and surface an + * actionable hint naming it. + * - `fail-closed`: ownership is indeterminable (inaccessible ancestor probes + * or depth exhaustion) — write no runtime state anywhere. + * + * Silent by design: messaging is the caller's job (`assertProjectRoot` keeps + * its warn/throw contract; bootstrap emits one bounded operational hint). + */ +export type ProjectRootDecision = + | { kind: 'root'; directory: string } + | { kind: 'redirect'; directory: string; owningRoot: string } + | { kind: 'fail-closed'; directory: string; reason: string }; - if (parentSwarmStat.isDirectory()) { - const indicatorState = projectIndicatorState(parent, dependencies, { - allowConfigOnly: !isWeakConfigContainerRoot(parent, dependencies), - }); - if (indicatorState === 'inaccessible') { - warn( - `[project-boundary] Cannot inspect project indicators in ancestor "${parent}" — failing closed`, - ); - throw new Error( - `Cannot verify project root for "${resolved}" — project indicators in ancestor "${parent}" are inaccessible`, - ); - } - if (indicatorState === 'present') { - warn( - `[project-boundary] Rejecting write to subdirectory "${resolved}" — parent "${parent}" already contains .swarm/`, - ); - throw new Error( - `Cannot write ${artifactLabel} in "${resolved}" — parent directory "${parent}" already contains a .swarm/ folder. Runtime state must be written to the project root.`, - ); - } - } - current = parent; +export function resolveProjectRootDecision( + directory: string, + dependencies: ProjectRootProbeDependencies = fs, +): ProjectRootDecision { + let resolved: string; + try { + resolved = dependencies.realpathSync(directory); + } catch { + return { + kind: 'fail-closed', + directory, + reason: `cannot canonicalize directory "${directory}" — it may not exist or is inaccessible`, + }; + } + const walk = walkProjectBoundary(resolved, dependencies); + if (walk.outcome === 'marker-root' || walk.outcome === 'unclaimed') { + return { kind: 'root', directory: resolved }; + } + if (walk.outcome === 'claimed') { + return { + kind: 'redirect', + directory: resolved, + owningRoot: walk.ancestor, + }; } + const reason = + walk.reason === 'depth' + ? `ancestor search exceeded ${MAX_PROJECT_ROOT_DEPTH} levels` + : walk.reason === 'ancestor-swarm' + ? `ancestor state "${walk.path}" is inaccessible` + : `project indicators in ancestor "${walk.path}" are inaccessible`; + return { kind: 'fail-closed', directory: resolved, reason }; } /** Narrow filesystem seam for deterministic marker error tests. */ diff --git a/tests/unit/hooks/issue-trace-registration.test.ts b/tests/unit/hooks/issue-trace-registration.test.ts index a9b50d2c8..ef27007ba 100644 --- a/tests/unit/hooks/issue-trace-registration.test.ts +++ b/tests/unit/hooks/issue-trace-registration.test.ts @@ -21,7 +21,7 @@ describe('Issue Trace Hook Registration in src/index.ts', () => { describe('Hook instance creation', () => { it('src/index.ts creates issueTraceHook via createIssueTraceHook', () => { expect(indexSource).toContain( - 'createIssueTraceHook(config, ctx.directory)', + 'createIssueTraceHook(config, bootstrapRoot)', ); }); }); diff --git a/tests/unit/hooks/repo-graph-telemetry-order.test.ts b/tests/unit/hooks/repo-graph-telemetry-order.test.ts index 4b42013f9..79462399c 100644 --- a/tests/unit/hooks/repo-graph-telemetry-order.test.ts +++ b/tests/unit/hooks/repo-graph-telemetry-order.test.ts @@ -8,7 +8,7 @@ describe('repo graph startup ordering', () => { const sourceCode = readFileSync(indexPath, 'utf-8'); const initTelemetryLine = sourceCode.indexOf( - 'initTelemetry(ctx.directory);', + 'initTelemetry(bootstrapRoot);', ); const registrationLine = sourceCode.indexOf( 'postResolutionTasks.push(() => {', diff --git a/tests/unit/index-bootstrap-late-writer-2679.test.ts b/tests/unit/index-bootstrap-late-writer-2679.test.ts new file mode 100644 index 000000000..451ad6090 --- /dev/null +++ b/tests/unit/index-bootstrap-late-writer-2679.test.ts @@ -0,0 +1,249 @@ +/** + * Issue #2679 — bootstrap project-root ownership under racy writers, REAL + * plugin boots. Bun:test adaptation of the frozen acceptance checks + * .agents/issue-traces/2679-project-root-ownership-bootstrap/repro/ + * c5-concurrent-boot-race.ts and c6-late-writer.ts: + * + * - C5 (AC3 first-write race): TWO CONCURRENT server() boots on the SAME + * ordinary child under a claiming parent (.git + .swarm) must never leave + * a child .swarm tree; the state converges on the parent. The project-root + * decision is resolved once per boot before any writer, so a racy + * check-then-create child writer cannot leak through. + * - C6 (AC3 late writer): after a boot has settled, a LATE optional writer + * invoked with the CHILD directory — the registered 'tool.execute.after' + * hook (its snapshot writer materializes project state) — must not create + * child .swarm either; state lands in the resolved project root. + * + * Env isolation is XDG-only (+ APPDATA/LOCALAPPDATA), matching the frozen + * scripts: HOME/USERPROFILE stay real so the boundary walk's weak-container + * rule keeps recognizing the real home/tmpdir. No mock.module, no clock. + */ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from 'bun:test'; +import * as fs from 'node:fs'; +import path from 'node:path'; + +import { closeAllProjectDbs, closeProjectDb } from '../../src/db/project-db'; +import OpenCodeSwarm from '../../src/index'; +import { resetSwarmState } from '../../src/state'; +import { resetTelemetryForTesting } from '../../src/telemetry'; +import { safeRmRecursive } from '../helpers/safe-test-dir'; +import { canonicalMkdtemp } from '../helpers/tmpdir'; + +const ISOLATED_ENV_KEYS = [ + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'APPDATA', + 'LOCALAPPDATA', +] as const; + +const fixtureRoots: string[] = []; +const savedEnv = new Map(); +let isolatedEnvRoot = ''; +let restoreEnv: (() => void) | null = null; + +beforeAll(() => { + isolatedEnvRoot = canonicalMkdtemp('swarm2679-late-env-'); + for (const key of ISOLATED_ENV_KEYS) { + savedEnv.set(key, process.env[key]); + process.env[key] = isolatedEnvRoot; + } + restoreEnv = () => { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +}); + +afterEach(async () => { + // Release plugin-owned handles BEFORE removing fixtures (Windows EBUSY, #2480). + resetTelemetryForTesting(); + await closeAllProjectDbs(); + resetSwarmState(); + for (const dir of fixtureRoots.splice(0)) { + try { + safeRmRecursive(dir); + } catch { + // best-effort: a lingering detached writer handle must not fail the suite + } + } +}); + +afterAll(() => { + restoreEnv?.(); + restoreEnv = null; + if (isolatedEnvRoot) { + try { + safeRmRecursive(isolatedEnvRoot); + } catch { + // best-effort + } + isolatedEnvRoot = ''; + } +}); + +/** tests/helpers/plugin-host.ts ctxFor shape — the hand-built host context. */ +function ctxFor(directory: string) { + return { + client: {}, + project: {} as unknown, + directory, + worktree: directory, + serverUrl: new URL('http://localhost:3000'), + $: {} as unknown, + }; +} + +/** Claimed-parent fixture: parent(.git + .swarm) + ordinary child, no markers on the child. */ +function makeClaimedFixture(prefix: string): { parent: string; child: string } { + const root = canonicalMkdtemp(prefix); + fixtureRoots.push(root); + const parent = path.join(root, 'outer'); + const child = path.join(parent, 'child'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.mkdirSync(path.join(parent, '.swarm'), { recursive: true }); + fs.mkdirSync(child, { recursive: true }); + // Keep the boot offline and quiet; the config is read from the PARENT + // (the bootstrap root after the redirect), never from the ordinary child. + fs.mkdirSync(path.join(parent, '.opencode'), { recursive: true }); + fs.writeFileSync( + path.join(parent, '.opencode', 'opencode-swarm.json'), + JSON.stringify({ version_check: false, quiet: true }, null, 2), + ); + return { parent, child }; +} + +function swarmExists(dir: string): boolean { + return fs.existsSync(path.join(dir, '.swarm')); +} + +function dirEntryCount(dir: string): number { + try { + return fs.readdirSync(dir).length; + } catch { + return -1; + } +} + +/** Bounded condition poll — no clock read, attempt-counter bounded. */ +async function waitFor( + condition: () => boolean, + attempts = 60, + stepMs = 100, +): Promise { + for (let i = 0; i < attempts; i += 1) { + if (condition()) return true; + await Bun.sleep(stepMs); + } + return condition(); +} + +describe('bootstrap project-root ownership — concurrent boots on one ordinary child (#2679, C5)', () => { + test('two concurrent server() boots never leave a child .swarm; state converges on the parent', async () => { + const { parent, child } = makeClaimedFixture('swarm2679-c5-'); + + // Start BOTH boots before awaiting either so the two initialization + // paths interleave at their await points (the first-write race window). + const settled = await Promise.allSettled([ + OpenCodeSwarm.server(ctxFor(child) as never), + OpenCodeSwarm.server(ctxFor(child) as never), + ]); + // Boot rejections are recorded, non-fatal: the frozen contract is the + // filesystem outcome, not boot success. + const resolvedBoots = settled.filter( + (o) => o.status === 'fulfilled', + ).length; + expect(resolvedBoots).toBeGreaterThanOrEqual(1); + + // Fixed macrotask drain for the wrapper-owned post-resolution queue. + await Bun.sleep(4000); + + expect(swarmExists(child)).toBe(false); + const parentEntries = dirEntryCount(path.join(parent, '.swarm')); + expect(parentEntries).toBeGreaterThanOrEqual(1); + }); +}); + +describe('bootstrap project-root ownership — late writer after settle (#2679, C6)', () => { + test('the registered tool.execute.after snapshot writer respects the resolved project root', async () => { + const { parent, child } = makeClaimedFixture('swarm2679-c6-'); + + const manifest = (await OpenCodeSwarm.server( + ctxFor(child) as never, + )) as unknown as Record; + expect( + Object.keys((manifest.tool ?? {}) as Record).length, + ).toBeGreaterThanOrEqual(100); + await Bun.sleep(4000); + + // Isolate the late-writer probe (c6 idiom): close plugin-owned handles on + // the child, then best-effort remove any boot-created child tree so a + // post-probe tree is attributable to the late writer alone. On the fixed + // tree the boot never creates the child tree, so this is a no-op guard. + if (swarmExists(child)) { + try { + closeProjectDb(child); + } catch { + // non-fatal: no cached handle for this directory + } + resetTelemetryForTesting(); + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + fs.rmSync(path.join(child, '.swarm'), { + recursive: true, + force: true, + }); + break; + } catch { + await Bun.sleep(200); + } + } + } + + const afterHook = manifest['tool.execute.after'] as + | ((input: unknown, output: unknown) => Promise) + | undefined; + expect(typeof afterHook).toBe('function'); + try { + await afterHook( + { + tool: 'read', + sessionID: 'ses-swarm2679-late-writer', + callID: 'call-swarm2679-late-writer-1', + }, + { + title: '', + output: 'late-writer probe payload (issue 2679)', + metadata: null, + }, + ); + } catch { + // Failing closed with a bounded error is an acceptable late-writer + // outcome; the filesystem verdict decides (c6 contract). + } + + await Bun.sleep(2000); + + // The only forbidden outcome is a child .swarm tree. + expect(swarmExists(child)).toBe(false); + // The writer landed in the owning parent: session state is present there + // (project DB and/or the snapshot projection under parent/.swarm). + const parentHasSessionState = await waitFor( + () => + fs.existsSync(path.join(parent, '.swarm', 'swarm.db')) || + fs.existsSync(path.join(parent, '.swarm', 'session')), + ); + expect(parentHasSessionState).toBe(true); + expect(dirEntryCount(path.join(parent, '.swarm'))).toBeGreaterThanOrEqual( + 1, + ); + }); +}); diff --git a/tests/unit/index-bootstrap-root-ownership-2679.test.ts b/tests/unit/index-bootstrap-root-ownership-2679.test.ts new file mode 100644 index 000000000..79b918675 --- /dev/null +++ b/tests/unit/index-bootstrap-root-ownership-2679.test.ts @@ -0,0 +1,425 @@ +/** + * Issue #2679 — bootstrap project-root ownership, REAL plugin boots. + * + * Adapts the frozen acceptance-check idioms from + * .agents/issue-traces/2679-project-root-ownership-bootstrap/repro/ + * (c1 ordinary-child redirect, c3 nested-root independence, c4 standalone, + * c8 indicator-only parent) into bun:test: boot the REAL plugin + * (src/index.ts default export's server()) with a hand-built host ctx + * (tests/helpers/plugin-host.ts ctxFor shape) inside an isolated XDG env + * and assert the FILESYSTEM outcome — an ordinary child of a project root + * that owns `.swarm/` never receives its own runtime-state tree; the owning + * parent does. + * + * Env isolation is deliberately XDG-only (plus APPDATA/LOCALAPPDATA), NOT + * createIsolatedTestEnv(): the boundary walk's weak-container rule keys on + * the REAL user home and raw OS temp root. Redirecting HOME/USERPROFILE (which + * bun test honors, unlike plain `bun`) makes the resolver treat the real + * home as a claiming ancestor for C4/C8 fixtures whose walk escapes the + * temp tree, flipping them from root to redirect-to-home. The frozen repro + * scripts isolate the same way (XDG_CONFIG_HOME only). + * + * Console capture is by reassignment with finally-restore (no spyOn / + * mock.module). No clock usage: waits are fixed drains or bounded condition + * polls with attempt counters. + */ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from 'bun:test'; +import * as fs from 'node:fs'; +import path from 'node:path'; +import { closeAllProjectDbs } from '../../src/db/project-db'; +import OpenCodeSwarm from '../../src/index'; +import { resetSwarmState } from '../../src/state'; +import { resetTelemetryForTesting } from '../../src/telemetry'; +import { safeRmRecursive } from '../helpers/safe-test-dir'; +import { canonicalMkdtemp } from '../helpers/tmpdir'; + +/** Fixed drain (ms) after server() resolves, for the wrapper-owned post-resolution writer queue. */ +const SETTLE_MS = 3500; + +/** Env roots redirected for boot isolation. HOME/USERPROFILE stay real (see header). */ +const ISOLATED_ENV_KEYS = [ + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'APPDATA', + 'LOCALAPPDATA', +] as const; + +const fixtureRoots: string[] = []; +const savedEnv = new Map(); +let isolatedEnvRoot = ''; +let restoreEnv: (() => void) | null = null; + +beforeAll(() => { + isolatedEnvRoot = canonicalMkdtemp('swarm2679-own-env-'); + for (const key of ISOLATED_ENV_KEYS) { + savedEnv.set(key, process.env[key]); + process.env[key] = isolatedEnvRoot; + } + restoreEnv = () => { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +}); + +afterEach(async () => { + // Release plugin-owned handles BEFORE removing fixtures: an open telemetry + // stream or sqlite WAL lock makes Windows rmSync fail EBUSY (#2480). + resetTelemetryForTesting(); + await closeAllProjectDbs(); + resetSwarmState(); + for (const dir of fixtureRoots.splice(0)) { + try { + safeRmRecursive(dir); + } catch { + // best-effort: a lingering detached writer handle must not fail the suite + } + } +}); + +afterAll(() => { + restoreEnv?.(); + restoreEnv = null; + if (isolatedEnvRoot) { + try { + safeRmRecursive(isolatedEnvRoot); + } catch { + // best-effort + } + isolatedEnvRoot = ''; + } +}); + +/** tests/helpers/plugin-host.ts ctxFor shape — the hand-built host context. */ +function ctxFor(directory: string) { + return { + client: {}, + project: {} as unknown, + directory, + worktree: directory, + serverUrl: new URL('http://localhost:3000'), + $: {} as unknown, + }; +} + +function fixtureRoot(prefix: string): string { + const dir = canonicalMkdtemp(prefix); + fixtureRoots.push(dir); + return dir; +} + +interface ConsoleCapture { + warn: string[]; + log: string[]; +} + +function captureConsole(): { captured: ConsoleCapture; restore: () => void } { + const captured: ConsoleCapture = { warn: [], log: [] }; + const originalWarn = console.warn; + const originalLog = console.log; + console.warn = (...args: unknown[]) => { + captured.warn.push( + args + .map((a) => (typeof a === 'string' ? a : JSON.stringify(a))) + .join(' '), + ); + }; + console.log = (...args: unknown[]) => { + captured.log.push( + args + .map((a) => (typeof a === 'string' ? a : JSON.stringify(a))) + .join(' '), + ); + }; + return { + captured, + restore: () => { + console.warn = originalWarn; + console.log = originalLog; + }, + }; +} + +function norm(p: string): string { + return p.toLowerCase().replace(/\\/g, '/'); +} + +/** + * The redirect-hint contract: some console line mentions BOTH the + * 'project-root ownership' marker AND the owning parent root — after masking + * occurrences of the CHILD path, so a child-path mention (which lexically + * contains the parent) cannot fake a parent-root hint (c1 hygiene). + */ +function hasParentRootHint( + captured: ConsoleCapture, + parent: string, + child: string, +): boolean { + const lines = [...captured.warn, ...captured.log].map((line) => norm(line)); + return lines.some( + (line) => + line.includes('project-root ownership') && + line.split(norm(child)).join('').includes(norm(parent)), + ); +} + +function swarmExists(dir: string): boolean { + return fs.existsSync(path.join(dir, '.swarm')); +} + +function dirEntryCount(dir: string): number { + try { + return fs.readdirSync(dir).length; + } catch { + return -1; + } +} + +/** Bounded condition poll — no clock read, attempt-counter bounded. */ +async function waitFor( + condition: () => boolean, + attempts = 50, + stepMs = 100, +): Promise { + for (let i = 0; i < attempts; i += 1) { + if (condition()) return true; + await Bun.sleep(stepMs); + } + return condition(); +} + +async function bootAndSettle( + directory: string, + settleMs = SETTLE_MS, +): Promise<{ + manifest: Record; + captured: ConsoleCapture; +}> { + const { captured, restore } = captureConsole(); + try { + const manifest = (await OpenCodeSwarm.server( + ctxFor(directory) as never, + )) as unknown as Record; + await Bun.sleep(settleMs); + return { manifest, captured }; + } finally { + restore(); + } +} + +/** Claimed-parent fixture: parent(.git + .swarm [+ .opencode config]) + ordinary child. */ +function makeClaimedFixture( + name: string, + parentConfig: Record | null = { + version_check: false, + quiet: true, + }, +) { + const root = fixtureRoot(`swarm2679-${name}-`); + const parent = path.join(root, 'outer'); + const child = path.join(parent, 'child'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.mkdirSync(path.join(parent, '.swarm'), { recursive: true }); + fs.mkdirSync(child, { recursive: true }); + if (parentConfig) { + fs.mkdirSync(path.join(parent, '.opencode'), { recursive: true }); + fs.writeFileSync( + path.join(parent, '.opencode', 'opencode-swarm.json'), + JSON.stringify(parentConfig, null, 2), + ); + } + return { parent, child }; +} + +describe('bootstrap project-root ownership — ordinary child redirect (#2679, AC1 / C1)', () => { + test('boot in an ordinary child lands state in the owning parent, never in the child', async () => { + const { parent, child } = makeClaimedFixture('c1'); + + const { manifest, captured } = await bootAndSettle(child); + + // Fail-open manifest: the boot still delivered the full tool surface. + const toolCount = Object.keys( + (manifest.tool ?? {}) as Record, + ).length; + expect(toolCount).toBeGreaterThanOrEqual(100); + + // Filesystem outcome: child tree absent, owning parent populated. + expect(swarmExists(child)).toBe(false); + const parentEntries = dirEntryCount(path.join(parent, '.swarm')); + expect(parentEntries).toBeGreaterThanOrEqual(1); + + // Operator hint: one console line names the parent root. + expect(hasParentRootHint(captured, parent, child)).toBe(true); + + // Durable redirect record under the owning parent. + const advisoryPath = path.join( + parent, + '.swarm', + 'advisories', + 'bootstrap-root-redirect.json', + ); + expect(fs.existsSync(advisoryPath)).toBe(true); + const record = JSON.parse(fs.readFileSync(advisoryPath, 'utf-8')) as { + project_root?: string; + }; + expect(record.project_root).toBe(parent); + }); +}); + +describe('bootstrap project-root ownership — nested roots stay independent (#2679, AC2 / C3)', () => { + const variants = [ + { marker: 'git-directory' as const, label: 'git-dir' }, + { marker: 'git-file' as const, label: 'git-file-worktree' }, + { marker: 'opencode' as const, label: 'opencode-dir' }, + ]; + + for (const variant of variants) { + test(`nested root declaring ${variant.label} keeps its own .swarm; outer stays empty`, async () => { + const root = fixtureRoot(`swarm2679-c3-${variant.label}-`); + const outer = path.join(root, 'outer'); + const nested = path.join(outer, 'nested'); + fs.mkdirSync(path.join(outer, '.git'), { recursive: true }); + fs.mkdirSync(path.join(outer, '.swarm'), { recursive: true }); + fs.mkdirSync(nested, { recursive: true }); + if (variant.marker === 'git-directory') { + fs.mkdirSync(path.join(nested, '.git')); + } else if (variant.marker === 'git-file') { + fs.writeFileSync(path.join(nested, '.git'), 'gitdir: ../git-data\n'); + } else { + fs.mkdirSync(path.join(nested, '.opencode')); + } + // A declared nested root may carry its own project config; also keeps the boot offline. + fs.mkdirSync(path.join(nested, '.opencode'), { recursive: true }); + fs.writeFileSync( + path.join(nested, '.opencode', 'opencode-swarm.json'), + JSON.stringify({ version_check: false }, null, 2), + ); + + await bootAndSettle(nested); + + expect(swarmExists(nested)).toBe(true); + expect(dirEntryCount(path.join(outer, '.swarm'))).toBe(0); + }); + } +}); + +describe('bootstrap project-root ownership — standalone and indicator-only roots (#2679, C4/C8)', () => { + test('standalone root (package.json, no markers, no ancestor .swarm) keeps its own .swarm', async () => { + const standalone = fixtureRoot('swarm2679-c4-root-'); + fs.writeFileSync( + path.join(standalone, 'package.json'), + JSON.stringify( + { name: 'swarm2679-standalone', version: '0.0.0', private: true }, + null, + 2, + ), + ); + fs.mkdirSync(path.join(standalone, '.opencode'), { recursive: true }); + fs.writeFileSync( + path.join(standalone, '.opencode', 'opencode-swarm.json'), + JSON.stringify({ version_check: false }, null, 2), + ); + + await bootAndSettle(standalone); + + expect(swarmExists(standalone)).toBe(true); + }); + + test('indicator-only parent (.git + package.json, NO .swarm) does not capture an ordinary child', async () => { + const root = fixtureRoot('swarm2679-c8-'); + const parent = path.join(root, 'outer'); + const child = path.join(parent, 'child'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(parent, 'package.json'), + JSON.stringify({ name: 'swarm2679-outer', version: '0.0.0' }, null, 2), + ); + fs.mkdirSync(child, { recursive: true }); + + await bootAndSettle(child); + + expect(swarmExists(child)).toBe(true); + expect(swarmExists(parent)).toBe(false); + }); +}); + +describe('bootstrap project-root ownership — rehydration and attribution (#2679)', () => { + test('a parent that already owns swarm session state receives the child boot state (rehydration regression)', async () => { + const { parent, child } = makeClaimedFixture('rehydr', null); + // Minimal pre-existing snapshot under the OWNING parent so + // hasSwarmState(parent) is true and the snapshot-load path targets the + // parent. Fixture shape mirrors tests/unit/session/hydration-plugin-instance.test.ts. + fs.mkdirSync(path.join(parent, '.swarm', 'session'), { recursive: true }); + fs.writeFileSync( + path.join(parent, '.swarm', 'session', 'state.json'), + JSON.stringify({ + version: 3, + writtenAt: 1, + toolAggregates: {}, + activeAgent: { 'ses-2679-parent-owned': 'coder' }, + delegationChains: {}, + agentSessions: {}, + }), + ); + + await bootAndSettle(child); + + // OBSERVABLE assertions only: the boot's state went to the parent + // (redirect record + telemetry latch). Deep rehydration semantics + // (in-memory swarmState after loadSnapshot) are covered by the + // dedicated hydration suite; asserting them here would couple this + // ownership test to unrelated snapshot-coordination timing. + expect(swarmExists(child)).toBe(false); + const advisoryPath = path.join( + parent, + '.swarm', + 'advisories', + 'bootstrap-root-redirect.json', + ); + expect(fs.existsSync(advisoryPath)).toBe(true); + expect(fs.existsSync(path.join(parent, '.swarm', 'telemetry.jsonl'))).toBe( + true, + ); + }); + + test('guardrails config read from the parent stays attributable via the redirect hint', async () => { + // The parent's .opencode config disables guardrails; the boot in the + // child still surfaces the parent-root hint, so any parent-config + // warning is traceable to the root that owns the config. The + // guardrails warning text itself is NOT asserted (fragile); the hint + // naming the parent IS the attribution contract. + const { parent, child } = makeClaimedFixture('guardrails', { + version_check: false, + guardrails: { enabled: false }, + }); + + const { captured } = await bootAndSettle(child); + + expect(hasParentRootHint(captured, parent, child)).toBe(true); + }); + + test('telemetry latches to the owning parent, never to the ordinary child', async () => { + const { parent, child } = makeClaimedFixture('telemetry'); + + await bootAndSettle(child); + + expect(fs.existsSync(path.join(child, '.swarm', 'telemetry.jsonl'))).toBe( + false, + ); + await waitFor(() => + fs.existsSync(path.join(parent, '.swarm', 'telemetry.jsonl')), + ); + expect(fs.existsSync(path.join(parent, '.swarm', 'telemetry.jsonl'))).toBe( + true, + ); + }); +}); diff --git a/tests/unit/index-bootstrap-root-sources-2679.test.ts b/tests/unit/index-bootstrap-root-sources-2679.test.ts new file mode 100644 index 000000000..de4a2934a --- /dev/null +++ b/tests/unit/index-bootstrap-root-sources-2679.test.ts @@ -0,0 +1,167 @@ +/** + * Issue #2679 — static source guardrail: the bootstrap root binding. + * + * The fix resolves the project root ONCE per boot + * (`resolveProjectRootDecision(ctx.directory)` → `bootstrapRoot`) and threads + * it through EVERY project-surface consumer in `src/index.ts`: `.swarm` + * state, project config, telemetry, observability, agent configs, plan/ + * session state. Before the fix these call sites consumed raw + * `ctx.directory`, so an ordinary child boot created a SECOND runtime-state + * tree under the child. This test FAILS on the pre-fix tree — that is the + * guardrail property. Its runtime counterparts are the frozen acceptance + * checks C1 (ordinary-child redirect) and C6 (late writer) in + * .agents/issue-traces/2679-project-root-ownership-bootstrap/repro/ and the + * boot tests in tests/unit/index-bootstrap-root-ownership-2679.test.ts. + * + * Scan approach (robust to formatting): for each symbol, find EVERY + * `\bsymbol\(` occurrence and require that within the next 200 characters + * (enough for a multi-line argument list) the binding appears: + * - PROJECT-surface symbols: `bootstrapRoot`, never `ctx.directory`; + * - WORKSPACE-surface symbols (git diffs, file authority, lane + * permissions): `ctx.directory`, never `bootstrapRoot`. + * + * Known deviation from the fix brief: the host session-API lookup + * `query: { directory: ... }` is threaded at `bootstrapRoot` in the actual + * implementation (src/index.ts, lookupParentSessionIDForTaskRoute) so the + * host resolves child sessions against the root that owns the state — this + * test pins the implemented shape, not the brief's `ctx.directory` spelling. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const SOURCE_PATH = path.join(import.meta.dir, '..', '..', 'src', 'index.ts'); +const SOURCE = readFileSync(SOURCE_PATH, 'utf-8'); + +/** Argument-window length after `symbol(` — long enough for multi-line calls, short enough to stay in the call. */ +const ARG_WINDOW_CHARS = 200; + +interface CallSite { + line: number; + window: string; +} + +function callSites(symbol: string): CallSite[] { + const pattern = new RegExp(`\\b${symbol}\\(`, 'g'); + const sites: CallSite[] = []; + let match: RegExpExecArray | null; + while ((match = pattern.exec(SOURCE)) !== null) { + const start = match.index + match[0].length; + sites.push({ + line: SOURCE.slice(0, match.index).split('\n').length, + window: SOURCE.slice(start, start + ARG_WINDOW_CHARS), + }); + } + return sites; +} + +/** + * PROJECT-surface symbols (#2679): every call site must bind `bootstrapRoot` + * and none may fall back to the opened workspace's `ctx.directory`. + */ +const PROJECT_SURFACE_SYMBOLS = [ + 'loadPluginConfigWithMetaAsyncForInit', + 'loadSnapshotForInit', + 'hasSwarmState', + 'ensureSwarmGitExcludedForInit', + 'hasGitMarkerAncestor', + 'initObservability', + 'registerObservabilityEventSink', + 'initTelemetry', + 'repoGraphHookFactory', + 'startSnapshotCoordinationInitialization', + 'runInitOrphanRecovery', + 'cleanupOldTrajectoryFiles', + 'runRetentionSweep', + 'maintainBackgroundDelegations', + 'writeSwarmConfigExampleIfNew', + 'syncBundledProjectSkillsIfMissingAsync', + 'getAgentConfigs', + 'regenerateMemoryReflectionForInit', + 'createSnapshotWriterHook', + 'loadPlan', + 'ensureAgentSession', + 'cacheCohortIdAtMessage', + 'beginApprovedReviewerScopeLifecycle', + 'completeReviewerScopeLifecycle', +] as const; + +/** + * WORKSPACE-surface symbols: these intentionally keep `ctx.directory` (git + * diffs, file authority, and lane permission scoping operate on the OPENED + * workspace, which for a lane instance IS ctx.directory). + */ +const WORKSPACE_SURFACE_SYMBOLS = [ + 'hasManifestAncestor', + 'buildProjectContext', + 'applyLanePermissions', +] as const; + +describe('src/index.ts bootstrap-root sources (#2679)', () => { + test('every project-surface call site binds bootstrapRoot and never ctx.directory', () => { + const violations: string[] = []; + for (const symbol of PROJECT_SURFACE_SYMBOLS) { + const sites = callSites(symbol); + if (sites.length === 0) { + violations.push(`${symbol}: no call site found (renamed or removed?)`); + continue; + } + for (const site of sites) { + if (!site.window.includes('bootstrapRoot')) { + violations.push( + `${symbol} (line ${site.line}): no bootstrapRoot within ${ARG_WINDOW_CHARS} chars of the call`, + ); + } + if (site.window.includes('ctx.directory')) { + violations.push( + `${symbol} (line ${site.line}): still consumes ctx.directory — pre-fix shape`, + ); + } + } + } + expect(violations).toEqual([]); + }); + + test('workspace-surface call sites keep ctx.directory and never bootstrapRoot', () => { + const violations: string[] = []; + for (const symbol of WORKSPACE_SURFACE_SYMBOLS) { + const sites = callSites(symbol); + if (sites.length === 0) { + violations.push(`${symbol}: no call site found (renamed or removed?)`); + continue; + } + for (const site of sites) { + if (!site.window.includes('ctx.directory')) { + violations.push( + `${symbol} (line ${site.line}): expected ctx.directory within ${ARG_WINDOW_CHARS} chars of the call`, + ); + } + if (site.window.includes('bootstrapRoot')) { + violations.push( + `${symbol} (line ${site.line}): unexpectedly rebound to bootstrapRoot (workspace surface)`, + ); + } + } + } + expect(violations).toEqual([]); + }); + + test('the resolver is invoked with ctx.directory and derives the bootstrap root', () => { + // The decision input is the OPENED workspace; ownership is derived from it. + expect(/resolveProjectRootDecision\(ctx\.directory\)/.test(SOURCE)).toBe( + true, + ); + // bootstrapRoot is the redirect-or-workspace derivation, not a bare alias. + expect(/const bootstrapRoot =/.test(SOURCE)).toBe(true); + expect(SOURCE).toContain( + "rootDecision.kind === 'redirect' ? rootDecision.owningRoot : ctx.directory", + ); + }); + + test('the host session-API lookup is threaded at the owning bootstrap root', () => { + // Deviation note (header): the implementation resolves child-session + // parentage against the root that owns the state, so the query binds + // bootstrapRoot rather than ctx.directory. + expect(/query:\s*\{\s*directory:\s*bootstrapRoot/.test(SOURCE)).toBe(true); + }); +}); diff --git a/tests/unit/mcp/offline-wiring-2499.test.ts b/tests/unit/mcp/offline-wiring-2499.test.ts index 3645bbea7..ca9257d75 100644 --- a/tests/unit/mcp/offline-wiring-2499.test.ts +++ b/tests/unit/mcp/offline-wiring-2499.test.ts @@ -92,3 +92,73 @@ describe('Persistence-free compute cores (#2499 R1)', () => { expect(fs.existsSync(path.join(root, '.swarm'))).toBe(false); }); }); + +describe('MCP project-root ownership (#2679)', () => { + test('an ordinary child of a parent owning .git + .swarm redirects to the parent root', () => { + const base = canonicalMkdtemp('mcp-root-2679-redirect-'); + const parent = path.join(base, 'outer'); + const child = path.join(parent, 'child'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.mkdirSync(path.join(parent, '.swarm'), { recursive: true }); + fs.mkdirSync(child, { recursive: true }); + + const logged: string[] = []; + const originalLog = console.error; + console.error = (...args: unknown[]) => { + logged.push( + args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '), + ); + }; + let resolved: { root: string; redirectedFrom?: string } | { error: string }; + try { + resolved = resolveMcpRoot(child); + } finally { + console.error = originalLog; + } + + expect('error' in resolved).toBe(false); + if ('error' in resolved) return; + // The resolver canonicalizes the served root; compare via realpath on both sides. + expect(fs.realpathSync(resolved.root)).toBe(fs.realpathSync(parent)); + expect(resolved.redirectedFrom).toBe(child); + // Startup line naming the served root (operator sees the redirect). + expect( + logged.some( + (line) => + line.includes('serving the owning project root') && + line.includes(parent), + ), + ).toBe(true); + }); + + test('fails closed when the claiming ancestor is deeper than the walk budget', () => { + const base = canonicalMkdtemp('mcp-root-2679-depth-'); + const parent = path.join(base, 'outer'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.mkdirSync(path.join(parent, '.swarm'), { recursive: true }); + // The walk counts upward until it finds the nearest claiming ancestor, + // so a chain deeper than MAX_PROJECT_ROOT_DEPTH (20) levels under the + // claiming parent fails closed with the 'exceeded' reason. + let deepest = parent; + for (let i = 0; i < 25; i += 1) { + deepest = path.join(deepest, `level-${i}`); + } + fs.mkdirSync(deepest, { recursive: true }); + + const resolved = resolveMcpRoot(deepest); + expect('error' in resolved).toBe(true); + if (!('error' in resolved)) return; + expect(resolved.error).toContain('exceeded'); + }); + + test('a plain root with .git passes through unchanged', () => { + const root = canonicalMkdtemp('mcp-root-2679-git-'); + fs.mkdirSync(path.join(root, '.git'), { recursive: true }); + + const resolved = resolveMcpRoot(root); + expect('root' in resolved).toBe(true); + if (!('root' in resolved)) return; + expect(resolved.root).toBe(root); + expect('redirectedFrom' in resolved).toBe(false); + }); +}); diff --git a/tests/unit/utils/project-boundary-resolver-2679.test.ts b/tests/unit/utils/project-boundary-resolver-2679.test.ts new file mode 100644 index 000000000..ff777247b --- /dev/null +++ b/tests/unit/utils/project-boundary-resolver-2679.test.ts @@ -0,0 +1,174 @@ +/** + * Issue #2679 — `resolveProjectRootDecision` resolver semantics. + * + * The decision resolver is the non-throwing twin of `assertProjectRoot`: the + * bootstrap path (src/index.ts) consumes it once per boot to pick the root + * that owns ALL project-surface state. These unit tests pin the fixture-level + * matrix the frozen acceptance checks exercise end-to-end (C1/C3/C4/C8 in + * .agents/issue-traces/2679-project-root-ownership-bootstrap/repro/): + * + * - `.git` file/dir and `.opencode` dir are local boundary markers → root. + * - an ordinary child of an ancestor owning `.swarm/` + a project indicator + * → redirect to that ancestor (owningRoot). + * - an indicator WITHOUT `.swarm/` must NOT capture an ordinary child. + * - unresolvable ownership (missing dir, >MAX_PROJECT_ROOT_DEPTH claiming + * ancestor) → fail-closed with a bounded reason. + * + * Real filesystem fixtures only — no mock.module, no clock usage. + */ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + assertProjectRoot, + MAX_PROJECT_ROOT_DEPTH, + resolveProjectRootDecision, +} from '../../../src/utils/project-boundary'; +import { safeRmRecursive } from '../../helpers/safe-test-dir'; +import { canonicalMkdtemp } from '../../helpers/tmpdir'; + +let root: string; + +beforeEach(() => { + root = canonicalMkdtemp('project-boundary-resolver-2679-'); +}); + +afterEach(() => { + safeRmRecursive(root); +}); + +describe('resolveProjectRootDecision — marker roots (#2679)', () => { + it('returns root for a directory with a .git directory', () => { + const project = path.join(root, 'git-dir-root'); + fs.mkdirSync(path.join(project, '.git'), { recursive: true }); + + const decision = resolveProjectRootDecision(project); + expect(decision.kind).toBe('root'); + if (decision.kind !== 'root') return; + expect(decision.directory).toBe(fs.realpathSync(project)); + }); + + it('returns root for a directory with a .git FILE (gitdir: worktree pointer)', () => { + const project = path.join(root, 'git-file-root'); + fs.mkdirSync(project, { recursive: true }); + fs.writeFileSync(path.join(project, '.git'), 'gitdir: ../git-data\n'); + + const decision = resolveProjectRootDecision(project); + expect(decision.kind).toBe('root'); + if (decision.kind !== 'root') return; + expect(decision.directory).toBe(fs.realpathSync(project)); + }); + + it('returns root for a directory with an .opencode directory', () => { + const project = path.join(root, 'opencode-root'); + fs.mkdirSync(path.join(project, '.opencode'), { recursive: true }); + + const decision = resolveProjectRootDecision(project); + expect(decision.kind).toBe('root'); + if (decision.kind !== 'root') return; + expect(decision.directory).toBe(fs.realpathSync(project)); + }); +}); + +describe('resolveProjectRootDecision — redirect and independence (#2679)', () => { + it('redirects an ordinary child of a parent owning .git + .swarm to the parent', () => { + const parent = path.join(root, 'outer'); + const child = path.join(parent, 'child'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.mkdirSync(path.join(parent, '.swarm'), { recursive: true }); + fs.mkdirSync(child, { recursive: true }); + + const decision = resolveProjectRootDecision(child); + expect(decision.kind).toBe('redirect'); + if (decision.kind !== 'redirect') return; + // The decision names BOTH the canonicalized input and the owning root. + expect(decision.directory).toBe(fs.realpathSync(child)); + expect(decision.owningRoot).toBe(fs.realpathSync(parent)); + }); + + it('returns root (standalone) for a plain directory with package.json and no ancestor .swarm', () => { + const standalone = path.join(root, 'standalone'); + fs.mkdirSync(standalone, { recursive: true }); + fs.writeFileSync( + path.join(standalone, 'package.json'), + JSON.stringify({ name: 'standalone-2679', version: '0.0.0' }), + ); + + const decision = resolveProjectRootDecision(standalone); + expect(decision.kind).toBe('root'); + if (decision.kind !== 'root') return; + expect(decision.directory).toBe(fs.realpathSync(standalone)); + }); + + it('does NOT capture an ordinary child of an indicator-only parent (no .swarm)', () => { + // C8 semantics: .git + package.json WITHOUT .swarm must not redirect — + // an indicator alone never claims ownership of swarm state. + const parent = path.join(root, 'indicator-only'); + const child = path.join(parent, 'child'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(parent, 'package.json'), + JSON.stringify({ name: 'indicator-only-2679', version: '0.0.0' }), + ); + fs.mkdirSync(child, { recursive: true }); + + const decision = resolveProjectRootDecision(child); + expect(decision.kind).toBe('root'); + if (decision.kind !== 'root') return; + expect(decision.directory).toBe(fs.realpathSync(child)); + }); +}); + +describe('resolveProjectRootDecision — fail-closed outcomes (#2679)', () => { + it('fails closed for a nonexistent directory input', () => { + const decision = resolveProjectRootDecision( + path.join(root, 'does-not-exist'), + ); + expect(decision.kind).toBe('fail-closed'); + if (decision.kind !== 'fail-closed') return; + expect(decision.reason).toContain('cannot canonicalize'); + }); + + it('fails closed when the claiming ancestor is deeper than MAX_PROJECT_ROOT_DEPTH', () => { + // Depth counts upward from the resolved directory, so exceeding 20 + // levels requires the CLAIMING ancestor (.git + .swarm) to sit more + // than MAX_PROJECT_ROOT_DEPTH levels above the probed directory. + const parent = path.join(root, 'deep-outer'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.mkdirSync(path.join(parent, '.swarm'), { recursive: true }); + let deepest = parent; + for (let i = 0; i < MAX_PROJECT_ROOT_DEPTH + 5; i += 1) { + deepest = path.join(deepest, `level-${i}`); + } + fs.mkdirSync(deepest, { recursive: true }); + + const decision = resolveProjectRootDecision(deepest); + expect(decision.kind).toBe('fail-closed'); + if (decision.kind !== 'fail-closed') return; + expect(decision.reason).toContain('exceeded'); + expect(decision.reason).toContain(String(MAX_PROJECT_ROOT_DEPTH)); + }); + + // Weak-container behavior (a .swarm directly under the OS temp/hometree + // strips .opencode-only indicators) is intentionally NOT covered here: + // probing it requires placing a .swarm + .opencode in the REAL + // real OS temp/hometree root, which this suite must never pollute. The + // tmpdir-weak-container branch stays covered by implementation review, + // not by a real-root fixture. +}); + +describe('assertProjectRoot — legacy throw contract preserved (#2679)', () => { + it('still throws the exact redirect message for an ordinary child of a claiming parent', () => { + const parent = path.join(root, 'legacy-outer'); + const child = path.join(parent, 'child'); + fs.mkdirSync(path.join(parent, '.git'), { recursive: true }); + fs.mkdirSync(path.join(parent, '.swarm'), { recursive: true }); + fs.mkdirSync(child, { recursive: true }); + + const resolved = fs.realpathSync(child); + const canonicalParent = fs.realpathSync(parent); + expect(() => assertProjectRoot(child)).toThrow( + `Cannot write runtime state in "${resolved}" — parent directory "${canonicalParent}" already contains a .swarm/ folder. Runtime state must be written to the project root.`, + ); + }); +});