Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/engineering-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,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=<token>`) 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
Expand Down
24 changes: 24 additions & 0 deletions docs/releases/pending/2679-project-root-ownership-bootstrap.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions scripts/retention-registry.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4555,6 +4555,7 @@ export const EXEMPT_WRITER_MODULES: Readonly<Record<string, string>> = 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). */
Expand Down
21 changes: 20 additions & 1 deletion src/cli/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -108,6 +109,24 @@ 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') {
// biome-ignore lint/suspicious/noConsole: operator must see which project root the MCP server actually serves after the #2679 redirect
console.log(
`[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 };
}

Expand Down
Loading
Loading