@sema-agent/core
Version:
Stateless, task-oriented AI agent core
96 lines (95 loc) • 7.25 kB
TypeScript
/**
* 审计深挖 G4 — the `EnterWorktree` / `ExitWorktree` tool pair: SESSION-level git-worktree
* isolation (CC 2.1.198 parity). EnterWorktree creates a managed worktree under `.sema-worktrees/`
* (the same {@link WORKTREE_PARENT} the subagent `isolation:"worktree"` lane uses) and REALLY switches
* the task's working directory into it; ExitWorktree returns to the original directory and cleans up.
*
* CC-parity semantics (198 bundle, verbatim-checked):
* ① worktrees live in a managed parent dir inside the repo (CC: `.claude/worktrees/`; sema:
* `.sema-worktrees/`) — never scattered;
* ② baseRef is two-state: `"head"` = the current local HEAD (unpushed commits present), `"fresh"` =
* the default branch tip (CC: `origin/<default-branch>` for a clean tree);
* ③ a second CREATING EnterWorktree (`name`) while one is active is REFUSED ("Already in a worktree
* session" — CC errorCode 2); switching into an EXISTING worktree via `path` is allowed (深对比残差
* M22 RESOLVED, live-CC anchor 2026-07-09: the previous worktree is left on disk untouched, only the
* new one is tracked for exit-time cleanup, and a path-entered tree is NEVER removed by ExitWorktree);
* ④ ExitWorktree outside a session is an honest NO-OP (CC errorCode 1), and a worktree with
* uncommitted files or commits is NOT deleted by default (CC refuses `remove` without
* `discard_changes`; sema keeps it and reports the path — inspect/merge in userland);
* ⑤ outside a git repository CC delegates to WorktreeCreate/WorktreeRemove hooks — sema has no such
* shell extension point, so the tool says so honestly and refuses (no silent degrade).
*
* cwd architecture (the load-bearing conclusion): the task's working directory IS mutable state — the
* hands band shares one {@link CwdRef} (`handsCwdRef` in prepare-task): Bash runs every command in
* `cwdRef.current` (and `cd` moves it), Read/Edit/Write resolve relative paths against it, Monitor
* spawns there. EnterWorktree therefore performs a REAL switch by assigning `cwdRef.current`; the
* Runner's per-tool cwd read reports it as `workspace_changed`, same as an observed `cd`. Path
* containment is untouched: the worktree lives UNDER the task root, so resolveKey's canonical-within
* check still holds for every subsequent fs op.
*
* ⚠️ Containment ruling (deliberate): entering a worktree is a **cwd switch, NOT a
* sandbox**. resolveKey's containment anchor stays the ORIGINAL task root — it is deliberately NOT
* re-anchored/tightened to the worktree dir, so `../../` from inside the worktree can still reach the
* rest of the repository (which is WITHIN the task root and was reachable before Enter too), while
* anything outside the task root stays fenced exactly as before. This matches CC 2.1.198 semantics
* (CC worktrees live beside the repo with no containment tightening either); tightening here would buy
* no security (Bash/`cd` already roam the root) and would break legitimate repo-relative access. The
* tool description + Enter reply state this honestly so the model never assumes isolation.
*
* Security posture: both tools only ever run `git` against the task root / managed worktree (no
* arbitrary command surface), but they create/delete on-disk state ⇒ effect:"write" (plan mode gates
* them like every other write tool; read-only hands never mount them — see prepare-task).
*
* Durability: the active session lives in a SHARED, ALL-SERIALIZABLE
* {@link WorktreeSessionRef} owned by prepare-task (never a closure-only var, and no captured destroy
* closure — Exit re-derives removal as a plain `git worktree remove`). A durable suspend stamps it into
* `CheckpointState.activeWorktree` (+ the cwd into `handsCwd`); resume re-seeds both, so
* Enter→suspend→resume→Exit behaves exactly like an unsuspended session.
*/
import type { ActiveWorktreeSession, AgentTool, ExecutionEnv } from "../internal/harness-types.js";
import type { CwdRef } from "./fs/index.js";
/** the ACTIVE worktree session, ALL-SERIALIZABLE (plain strings, no closures):
* prepare-task owns the ref, stamps `current` into `CheckpointState.activeWorktree` at a durable
* suspend, and re-seeds it on resume, so ExitWorktree works across the suspend/resume boundary (the
* tree survives on disk; removal is re-derived as a plain `git worktree remove`).
*
* REF-D3/D4: canonical definition moved to `engine/harness/types.ts` (the harness layer must never
* import FROM `tools/`, so it cannot point back at a definition declared here); this re-export keeps
* the existing `ActiveWorktreeSession` import path (including the public one via `src/index.ts`)
* unchanged for every consumer. */
export type { ActiveWorktreeSession };
/** The shared mutable holder for {@link ActiveWorktreeSession} — the worktree twin of {@link CwdRef}. */
export interface WorktreeSessionRef {
current?: ActiveWorktreeSession;
/** HRD-PTL-8 — an ExitWorktree call is in flight on `current`. RUNTIME-ONLY (deliberately not part of
* the durable `CheckpointState.activeWorktree` projection: a suspend cannot happen inside the exit's
* own await chain, and a resumed process has no in-flight exit by definition). A concurrent or
* re-entrant Exit reads this and is refused honestly instead of being told no session is active. */
exiting?: boolean;
/** HRD-PTL-13 — an EnterWorktree call is in flight on this ref (either form). RUNTIME-ONLY, same
* rationale as {@link exiting}. The single-session rule cannot be enforced by reading `current` alone:
* the create path assigns it only after several awaits, so two overlapping calls would both pass that
* check and both create a worktree, leaving the earlier one untracked (see the guard in EnterWorktree
* for the CAS-reject-vs-serialize trade-off). */
entering?: boolean;
}
export interface WorktreeToolsOptions {
/** The task root (the repo root the managed worktrees nest under; `git` runs `-C` here). */
repoRoot: string;
/** The task's shared mutable working directory (the hands band's `handsCwdRef`) — EnterWorktree
* assigns it to the worktree path (a REAL cwd switch: Bash/Read/Edit/Write/Monitor all follow),
* ExitWorktree restores it. */
cwdRef: CwdRef;
/** the shared session-state ref (checkpointable — prepare-task serializes it into
* the durable checkpoint and re-seeds it on resume). Omitted ⇒ a private per-mount ref (same live
* behavior, minus durable-suspend survival — prepare-task always passes one). */
session?: WorktreeSessionRef;
}
/**
* Build the EnterWorktree/ExitWorktree pair. One session-state closure per mount (= per task): the
* active worktree handle lives here, never in a model argument. Mounted by prepare-task only when the
* task has real write hands over a tracked cwd (`handsCwdRef` exists — same premise family as the
* Monitor mount); git-ness is probed honestly at Enter time (same posture as the subagent
* worktree-isolation helper — no per-task prepare-time git exec).
*/
export declare function createWorktreeTools(env: ExecutionEnv, opts: WorktreeToolsOptions): AgentTool[];