opencode2-cow-worktree 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,8 +7,9 @@ milliseconds at almost no disk cost. Where a `git worktree` carries tracked
7
7
  files only, a **Deep clone** here carries everything, so the agent can run the
8
8
  test suite immediately.
9
9
 
10
- In daily use, and published to npm. Install from a local checkout (form A
11
- below) or from the npm registry.
10
+ Current release: [v0.1.0](https://github.com/rbelem/opencode2-cow-worktree/releases/tag/v0.1.0),
11
+ also on [npm](https://www.npmjs.com/package/opencode2-cow-worktree). Install
12
+ from a local checkout (form A below) or from the npm registry.
12
13
 
13
14
  ## Requirements
14
15
 
@@ -28,59 +29,45 @@ capability gate on that default: on a filesystem that cannot reflink (ext4,
28
29
  tmpfs), worktree creation fails loudly until you remove the plugin. Install
29
30
  it only on machines whose projects meet the Requirements above. opencode2
30
31
  Desktop cannot select plugin strategies today, so it ignores the plugin
31
- entirely (`docs/research/desktop-strategy-hardcode.md`).
32
+ entirely
33
+ ([`docs/research/desktop-strategy-hardcode.md`](https://github.com/rbelem/opencode2-cow-worktree/blob/main/docs/research/desktop-strategy-hardcode.md)).
32
34
 
33
- Two forms. Pick one. Having both makes opencode2 load the tree twice, and the
34
- duplicate load fails.
35
+ ### The short version
35
36
 
36
- ### Form A: directory discovery
37
+ opencode2 installs the plugin itself from npm. Add the package to the
38
+ `plugins` array of your `opencode.json`:
37
39
 
38
- 1. Clone this repository somewhere permanent, e.g. `/path/to/opencode2-cow-worktree`.
39
- 2. Create the plugin directory and its `node_modules`:
40
-
41
- ```sh
42
- mkdir -p ~/.config/opencode/plugins/opencode2-cow-worktree/node_modules
43
- ```
44
-
45
- 3. Create two one-line seam files in the plugin directory. The first loads
46
- the server plugin; the second loads its TUI half, which shows a small
47
- `cow` marker in the sidebar footer while a session runs in a cow
48
- worktree:
49
-
50
- ```ts
51
- // ~/.config/opencode/plugins/opencode2-cow-worktree/index.ts
52
- export { default } from "opencode2-cow-worktree";
53
- ```
54
-
55
- ```tsx
56
- // ~/.config/opencode/plugins/opencode2-cow-worktree/tui.tsx
57
- export { default } from "opencode2-cow-worktree/tui";
58
- ```
59
-
60
- The server works without the second file; skip it if you do not want the
61
- marker.
40
+ ```json
41
+ {
42
+ "plugins": [{ "package": "opencode2-cow-worktree@latest" }]
43
+ }
44
+ ```
62
45
 
63
- 4. Symlink the checkout into `node_modules` so the bare specifiers resolve:
46
+ Or let the CLI write that entry:
64
47
 
65
- ```sh
66
- ln -sfn /path/to/opencode2-cow-worktree \
67
- ~/.config/opencode/plugins/opencode2-cow-worktree/node_modules/opencode2-cow-worktree
68
- ```
48
+ ```sh
49
+ opencode2 plugin add opencode2-cow-worktree
50
+ ```
69
51
 
70
- Because opencode2's runtime is Bun and the symlink points at the working tree,
71
- tracked edits are live with no build step.
52
+ On the next startup opencode2 downloads the package into its own cache
53
+ (`~/.cache/opencode/node_modules/`) and loads it. That is the whole install:
54
+ the `cow` marker in the TUI sidebar ships in the same package and loads with
55
+ it — no seam files, no checkout, no symlink. The first startup waits for the
56
+ download, so give it a few extra seconds.
72
57
 
73
- This form runs with default options. To set options, use form B.
58
+ Declare the plugin exactly once. A duplicate declaration (registry entry plus
59
+ local path, or a discovered plugin directory plus the array) makes one of the
60
+ two loads fail with `Plugin failed to load`.
74
61
 
75
- ### Form B: the `plugins` array (required for options)
62
+ ### Options
76
63
 
77
- Point a `plugins` entry at the checkout itself — no symlink, no seam files:
64
+ Options ride in the same entry:
78
65
 
79
66
  ```json
80
67
  {
81
68
  "plugins": [
82
69
  {
83
- "package": "/path/to/opencode2-cow-worktree",
70
+ "package": "opencode2-cow-worktree@latest",
84
71
  "options": {
85
72
  "hooks": { "postCreate": ["corepack use pnpm@latest"] }
86
73
  }
@@ -92,16 +79,40 @@ Point a `plugins` entry at the checkout itself — no symlink, no seam files:
92
79
  `hooks`, `fallback`, and `targetRoot` are all optional; anything omitted takes
93
80
  its default. Each is described below.
94
81
 
82
+ ### Development install
83
+
84
+ To work on the plugin itself, point the entry at a checkout instead of the
85
+ registry. Because opencode2's runtime is Bun and the path points at the
86
+ working tree, tracked edits are live with no build step:
87
+
88
+ ```json
89
+ {
90
+ "plugins": [{ "package": "/path/to/opencode2-cow-worktree" }]
91
+ }
92
+ ```
93
+
95
94
  ### Verify
96
95
 
96
+ Confirm the plugin loaded:
97
+
98
+ ```sh
99
+ opencode2 plugin list
100
+ ```
101
+
102
+ `opencode2-cow-worktree` should appear with state `active`.
103
+
104
+ For a deeper check — that a worktree create with no `strategy` field
105
+ materializes a Deep clone, proving `cow` became the default — the repository
106
+ ships a script:
107
+
97
108
  ```sh
98
109
  bun scripts/dogfood-install-check.ts
99
110
  ```
100
111
 
101
- This boots a throwaway server against the installed plugin and asserts that it
102
- activates (`GET /api/plugin` reports `state.status: "active"`) and that a
103
- worktree create with no `strategy` field produces a Deep clone, which proves
104
- `cow` became the default strategy.
112
+ Run it from a repository checkout; the script ships with the repo, not the
113
+ npm package. It boots a throwaway server against the installed plugin and
114
+ asserts that the plugin activates and that the default create is a Deep
115
+ clone.
105
116
 
106
117
  Two gotchas when checking by hand: `GET /api/plugin` does not await
107
118
  activation, so a list taken right after boot can look empty — resolve
@@ -195,7 +206,9 @@ If the requested name already belongs to a cow worktree, the call attaches: a
195
206
  new session binds to the existing directory and `attached: true` comes back —
196
207
  nothing is cloned. Attach only happens for worktrees this strategy
197
208
  materialized; anything else already at that path (a `git` worktree, an unknown
198
- directory) is refused before anything changes.
209
+ directory) is refused before anything changes. If the worktree's recorded
210
+ session still shows activity, the attach is refused and the error names the
211
+ occupying session and the ways to recover.
199
212
 
200
213
  A create whose target path already exists is refused before the first write:
201
214
  `cow` never merges into, or deletes, a directory it did not create. Resolve
@@ -226,6 +239,9 @@ regardless of `fallback`. Only `spawn_workspace` consults the policy.
226
239
  - **`cow` fails on an ext4 or tmpfs project** — expected: that filesystem
227
240
  cannot clone. Use the `git` fallback for tool calls, or let the project use
228
241
  the built-in strategy.
242
+ - **The plugin is stuck on an old version** — opencode2 caches the package
243
+ under `~/.cache/opencode/node_modules/`. Remove the plugin's cache
244
+ directory and restart: `rm -rf ~/.cache/opencode/node_modules/opencode2-cow-worktree`.
229
245
  - **Plugin looks absent right after boot** — resolve
230
246
  `POST /api/plugin/await-activation` before reading `GET /api/plugin`.
231
247
  - **`Plugin failed to load`** — the plugin is declared twice (discovered
@@ -238,13 +254,15 @@ regardless of `fallback`. Only `spawn_workspace` consults the policy.
238
254
 
239
255
  The unit suite (`bun test`), typecheck (`bun run typecheck`), coverage gate
240
256
  (`bun run test:coverage`), and the e2e harness (`bun scripts/e2e/harness.ts`)
241
- are described in [`docs/development.md`](docs/development.md), along with the
242
- recorded live runs and the parallel-lane tooling this repository is developed
243
- with.
257
+ are described in
258
+ [`docs/development.md`](https://github.com/rbelem/opencode2-cow-worktree/blob/main/docs/development.md),
259
+ along with the recorded live runs and the parallel-lane tooling this
260
+ repository is developed with.
244
261
 
245
262
  Terms the output uses: a **Workspace** is a logical handle, a **Location** is
246
263
  where a session runs, and a **Worktree** is a directory materialized by a
247
- **Strategy** — more in [CONTEXT.md](CONTEXT.md).
264
+ **Strategy** — more in
265
+ [CONTEXT.md](https://github.com/rbelem/opencode2-cow-worktree/blob/main/CONTEXT.md).
248
266
 
249
267
  ## License
250
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode2-cow-worktree",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Copy-on-write worktree strategy for opencode2: a Deep clone of the whole working directory, ignored files included, so parallel agents start ready to run",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,251 @@
1
+ /**
2
+ * The occupancy guard for `spawn_workspace` (issue #15): a cow worktree carries
3
+ * a marker naming the session that holds it, and an attach refuses while that
4
+ * session is still alive.
5
+ *
6
+ * The decision half is pure and table-driven, in the style of `dirty.ts`, so
7
+ * the refusal table is testable without a filesystem or opencode2. The probe
8
+ * half classifies one session lookup; the file half owns the marker file and
9
+ * the git exclude entry that keeps it out of `git status`.
10
+ *
11
+ * opencode2 sessions persist after completion and carry no status field — the
12
+ * record's `time.updated` (epoch milliseconds) is the only liveness signal a
13
+ * probe gets (verified against 0.0.0-next-20260912.3). Dormancy is therefore a
14
+ * time judgement, not a state read, and the guard fails closed wherever that
15
+ * judgement cannot be made: a probe that errors, and a record whose
16
+ * `time.updated` is missing or unusable, both refuse.
17
+ */
18
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
19
+ import { dirname, join } from "node:path";
20
+
21
+ /** The worktree-root file naming the session that holds the worktree. */
22
+ export const MARKER_NAME = ".cow-session.json";
23
+
24
+ /**
25
+ * How long a session may stay silent before it no longer counts as holding a
26
+ * worktree. `time.updated` advances at message writes only (probe 2026-09-14:
27
+ * exactly 2 distinct values across a completed sim turn), so a turn silent
28
+ * longer than this window false-clears; false clearance is the worse
29
+ * direction, so the window is set to the generous end.
30
+ */
31
+ export const OCCUPIED_AFTER_MS = 60 * 60 * 1000;
32
+
33
+ /**
34
+ * The marker payload. `startedAt` is informational, written for a human
35
+ * reading the file: decisions never compare it against the server's
36
+ * `time.updated` — the two come from different clocks.
37
+ */
38
+ export interface OccupancyMarker {
39
+ readonly sessionID: string;
40
+ readonly startedAt?: string;
41
+ }
42
+
43
+ /** A marker file read's three answers: a marker, no marker, or an unusable one. */
44
+ export type ParsedMarker = OccupancyMarker | undefined | "malformed";
45
+
46
+ /** What one probe of the marker's session reported. */
47
+ export type ProbeResult =
48
+ | { readonly kind: "absent" }
49
+ | { readonly kind: "live"; readonly updated: number }
50
+ | { readonly kind: "error" };
51
+
52
+ /** Whether the attach may proceed, and why not when it may not. */
53
+ export type OccupancyDecision =
54
+ | { readonly action: "refuse"; readonly reason: string }
55
+ | { readonly action: "proceed"; readonly rewrite: boolean };
56
+
57
+ /**
58
+ * The three ways out of an occupancy refusal, spelled out in full because the
59
+ * refusal is the only place the guard explains itself. Exactly three, in the
60
+ * order a blocked caller reaches for them.
61
+ */
62
+ export const RECOVERIES =
63
+ "Pick another worktree name; delete the occupying session out of band " +
64
+ "(DELETE /api/session/<sessionID> against this server); or remove the " +
65
+ `occupancy marker (${MARKER_NAME}) from the worktree root if you know it is stale.`;
66
+
67
+ /**
68
+ * Parses a marker file's raw content. An absent file is `undefined`; anything
69
+ * that does not parse as JSON, or that names no session, is `"malformed"` —
70
+ * the guard cannot tell who holds the worktree, so it will not guess.
71
+ */
72
+ export function parseMarker(raw: string | undefined): ParsedMarker {
73
+ if (raw === undefined) return undefined;
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(raw);
77
+ } catch {
78
+ return "malformed";
79
+ }
80
+ if (typeof parsed !== "object" || parsed === null) return "malformed";
81
+ const sessionID = (parsed as { sessionID?: unknown }).sessionID;
82
+ if (typeof sessionID !== "string" || sessionID.length === 0) return "malformed";
83
+ const startedAt = (parsed as { startedAt?: unknown }).startedAt;
84
+ return typeof startedAt === "string" ? { sessionID, startedAt } : { sessionID };
85
+ }
86
+
87
+ /**
88
+ * Classifies one `sessionGet` probe of the session a marker names.
89
+ *
90
+ * The verified server contract: an absent id makes the lookup throw with
91
+ * `_tag: "SessionNotFoundError"` (HTTP 404), which is a *positive* absence —
92
+ * the one answer that proves the worktree free. A resolved `null` or
93
+ * `undefined` counts as absent too. Anything else is an unanswerable probe: a
94
+ * throw with any other tag, a non-object body, or a body without a string id
95
+ * all refuse rather than guess, and so does a body whose `time.updated` is not
96
+ * a usable epoch-milliseconds number — a malformed 200 must never become
97
+ * dormancy, because naive `now - updated` arithmetic over a missing or
98
+ * non-numeric value would read as endlessly idle.
99
+ */
100
+ export async function probeOccupyingSession(
101
+ sessionGet: (id: string) => Promise<unknown>,
102
+ sessionID: string,
103
+ ): Promise<ProbeResult> {
104
+ let answer: unknown;
105
+ try {
106
+ answer = await sessionGet(sessionID);
107
+ } catch (cause) {
108
+ return isSessionNotFound(cause) ? { kind: "absent" } : { kind: "error" };
109
+ }
110
+ if (answer === null || answer === undefined) return { kind: "absent" };
111
+ if (typeof answer !== "object") return { kind: "error" };
112
+ const record = answer as { id?: unknown; time?: { updated?: unknown } };
113
+ if (typeof record.id !== "string") return { kind: "error" };
114
+ const updated = record.time?.updated;
115
+ return typeof updated === "number" && Number.isFinite(updated) && updated >= 0
116
+ ? { kind: "live", updated }
117
+ : { kind: "error" };
118
+ }
119
+
120
+ function isSessionNotFound(cause: unknown): boolean {
121
+ // The only positive-absence signals from a throw, both verified: the HTTP
122
+ // payload tags the 404 `SessionNotFoundError`, while the error the
123
+ // plugin-side lookup throws is `Session.NotFoundError` with an empty
124
+ // message. No message matching: a substring is not a tag, and an unrelated
125
+ // gateway error that happens to phrase itself like a 404 must refuse, not
126
+ // clear the worktree.
127
+ return (
128
+ typeof cause === "object" &&
129
+ cause !== null &&
130
+ ((cause as { _tag?: unknown })._tag === "SessionNotFoundError" ||
131
+ (cause as { _tag?: unknown })._tag === "Session.NotFoundError")
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The occupancy decision table.
137
+ *
138
+ * Refusals, in the order checked: a malformed marker (the holder is unknown),
139
+ * a probe error (a failed lookup is not a positive absence), a live record
140
+ * whose `time.updated` is unusable (fail closed — the naive arithmetic would
141
+ * call it dormant), and a live record still inside the occupancy window.
142
+ * Otherwise the attach proceeds; `rewrite` says whether an old marker is being
143
+ * replaced — a stale one the probe proved dead, or a dormant one past the
144
+ * window — as opposed to a first marker for a worktree that never had one.
145
+ */
146
+ export function occupancyDecision(input: {
147
+ readonly marker: ParsedMarker;
148
+ readonly probe: ProbeResult;
149
+ readonly now: number;
150
+ }): OccupancyDecision {
151
+ if (input.marker === "malformed") {
152
+ return {
153
+ action: "refuse",
154
+ reason: `the occupancy marker is unparsable or names no session, so the holder is unknown. ${RECOVERIES}`,
155
+ };
156
+ }
157
+ if (input.probe.kind === "error") {
158
+ return {
159
+ action: "refuse",
160
+ reason: `the session holding this worktree could not be checked, and a failed lookup is not proof it is gone. ${RECOVERIES}`,
161
+ };
162
+ }
163
+ if (input.probe.kind === "live") {
164
+ const updated = input.probe.updated;
165
+ // Deliberate defense-in-depth, not dead code: the classifier already maps
166
+ // an unusable `updated` to a probe error, so this row only fires if that
167
+ // classification ever drifts.
168
+ if (!Number.isFinite(updated) || updated < 0) {
169
+ return {
170
+ action: "refuse",
171
+ reason: `the session holding this worktree reports no usable last-activity time. ${RECOVERIES}`,
172
+ };
173
+ }
174
+ const idle = input.now - updated;
175
+ if (!Number.isFinite(idle) || idle <= OCCUPIED_AFTER_MS) {
176
+ const holder = input.marker === undefined ? "A session" : `Session ${input.marker.sessionID}`;
177
+ return { action: "refuse", reason: occupiedMessage(holder, idle) };
178
+ }
179
+ }
180
+ return { action: "proceed", rewrite: input.marker !== undefined };
181
+ }
182
+
183
+ function occupiedMessage(holder: string, idleMs: number): string {
184
+ return `${holder} appears to still be using it — its last activity was ${humanAge(idleMs)} ago. ${RECOVERIES}`;
185
+ }
186
+
187
+ /** A last-active age in human form; an unanswerable age never reads as fresh. */
188
+ function humanAge(ms: number): string {
189
+ if (!Number.isFinite(ms) || ms < 0) return "an unknown amount of time";
190
+ if (ms < 60_000) return "less than a minute";
191
+ const minutes = Math.floor(ms / 60_000);
192
+ if (minutes < 60) return `${minutes} minute(s)`;
193
+ const hours = Math.floor(minutes / 60);
194
+ if (hours < 24) return `${hours} hour(s)`;
195
+ return `${Math.floor(hours / 24)} day(s)`;
196
+ }
197
+
198
+ // The file half: the marker file and its git exclude entry. Read failures
199
+ // other than "not there" propagate — an unreadable marker is not evidence of a
200
+ // free worktree — and write failures propagate to the caller's warning path.
201
+
202
+ const EXCLUDE_APPEND = `\n# opencode2-cow-worktree occupancy marker\n${MARKER_NAME}\n`;
203
+
204
+ /**
205
+ * The raw marker file's content, or `undefined` when there is none. Any other
206
+ * read failure (permissions, an obstacle in the path) propagates.
207
+ */
208
+ export async function readMarkerFile(directory: string): Promise<string | undefined> {
209
+ try {
210
+ return await readFile(join(directory, MARKER_NAME), "utf8");
211
+ } catch (cause) {
212
+ if (isNotFound(cause)) return undefined;
213
+ throw cause;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Writes the marker naming `sessionID`, first ensuring the worktree's
219
+ * `.git/info/exclude` ignores the marker file — the exclude line goes in
220
+ * before the marker exists, so `git status` never sees it, not even in the
221
+ * moment between the two writes. Both steps are idempotent, and both
222
+ * propagate their failures: the caller demotes them to a warning, never a
223
+ * failed spawn.
224
+ */
225
+ export async function writeMarkerFile(directory: string, sessionID: string): Promise<void> {
226
+ await ensureGitExclude(directory);
227
+ const marker: OccupancyMarker = { sessionID, startedAt: new Date().toISOString() };
228
+ await writeFile(join(directory, MARKER_NAME), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
229
+ }
230
+
231
+ async function ensureGitExclude(directory: string): Promise<void> {
232
+ const excludePath = join(directory, ".git", "info", "exclude");
233
+ const current = await readFile(excludePath, "utf8").catch((cause) => {
234
+ if (isNotFound(cause)) return undefined;
235
+ throw cause;
236
+ });
237
+ if (current !== undefined && hasExcludeEntry(current)) return;
238
+ await mkdir(dirname(excludePath), { recursive: true });
239
+ await writeFile(excludePath, (current ?? "") + EXCLUDE_APPEND, "utf8");
240
+ }
241
+
242
+ /** Whether the exclude already carries the marker entry as its own line. */
243
+ function hasExcludeEntry(content: string): boolean {
244
+ return content.split("\n").some((line) => line.trim() === MARKER_NAME);
245
+ }
246
+
247
+ function isNotFound(cause: unknown): boolean {
248
+ return (
249
+ typeof cause === "object" && cause !== null && (cause as { code?: unknown }).code === "ENOENT"
250
+ );
251
+ }
package/src/plugin.ts CHANGED
@@ -3,6 +3,7 @@ import { stat } from "node:fs/promises";
3
3
  import { probeCowCapability } from "./capability";
4
4
  import { fallbackPolicy, postCreateHooks, targetRoot } from "./config";
5
5
  import { deviceOf, isDirectory } from "./device";
6
+ import { readMarkerFile, writeMarkerFile } from "./occupancy";
6
7
  import { listCowWorktrees, spawnWorkspace } from "./tool";
7
8
  import type {
8
9
  FallbackPolicy,
@@ -60,6 +61,11 @@ const spawnWorkspaceOutput = {
60
61
  description:
61
62
  "True when the session was attached to an existing worktree instead of a new clone.",
62
63
  },
64
+ markerWarning: {
65
+ type: "string",
66
+ description:
67
+ "Set when the occupancy marker could not be written after a successful session start.",
68
+ },
63
69
  },
64
70
  required: ["sessionID", "directory", "mechanism"],
65
71
  additionalProperties: false,
@@ -155,6 +161,14 @@ function liveDeps(
155
161
  },
156
162
  removeWorktree: (directory) =>
157
163
  ctx.worktree.remove({ directory, force: true }),
164
+ // `ctx.session.get` exists on the v2 runtime but is untyped in the
165
+ // installed beta; types/opencode2-worktree.d.ts declares the verified
166
+ // `{ sessionID }` shape and the structural record slice the occupancy
167
+ // guard reads.
168
+ sessionGet: (id) => ctx.session.get({ sessionID: id }),
169
+ readMarker: readMarkerFile,
170
+ writeMarker: writeMarkerFile,
171
+ now: Date.now,
158
172
  fallback,
159
173
  targetRoot: worktreeRoot,
160
174
  };
@@ -192,7 +206,9 @@ export default {
192
206
  description:
193
207
  "Create a worktree and start a session in it, reporting the mechanism that produced the directory. " +
194
208
  "When a worktree with the requested name already exists and was produced by the cow strategy, the " +
195
- "session is attached to it instead of creating a new directory; anything else at that name is refused.",
209
+ "session is attached to it instead of creating a new directory; anything else at that name is refused. " +
210
+ "If the worktree's recorded session still shows activity, the attach is refused and the error names " +
211
+ "the occupying session and the ways to recover.",
196
212
  input: spawnWorkspaceInput,
197
213
  output: spawnWorkspaceOutput,
198
214
  // A tool defaults into CodeMode, which advertises it to the model only
@@ -203,10 +219,13 @@ export default {
203
219
  const result = await spawnWorkspace(input, liveDeps(ctx, fallback, worktreeRoot));
204
220
  // The text is what tells attach from create: on attach `mechanism`
205
221
  // reports the found directory's mechanism, and the caller must never
206
- // read that as a fresh clone having happened.
207
- const content = result.attached
208
- ? `Attached to existing cow worktree at ${result.directory} (session ${result.sessionID}); no new worktree was created.`
209
- : `Created ${result.mechanism} worktree at ${result.directory} (session ${result.sessionID}).`;
222
+ // read that as a fresh clone having happened. A marker-write failure
223
+ // never fails the call; it rides along as a warning line.
224
+ const content =
225
+ (result.attached
226
+ ? `Attached to existing cow worktree at ${result.directory} (session ${result.sessionID}); no new worktree was created.`
227
+ : `Created ${result.mechanism} worktree at ${result.directory} (session ${result.sessionID}).`) +
228
+ (result.markerWarning === undefined ? "" : `\nwarning: ${result.markerWarning}`);
210
229
  return {
211
230
  output: result,
212
231
  content,
package/src/tool.ts CHANGED
@@ -3,7 +3,8 @@ import { basename, join, resolve } from "node:path";
3
3
  import { assertSameDevice } from "./device";
4
4
  import type { CowCapability } from "./capability";
5
5
  import type { Mechanism } from "./mechanism";
6
- import type { WorktreeInventoryEntry } from "../types/opencode2-worktree";
6
+ import { MARKER_NAME, RECOVERIES, occupancyDecision, parseMarker, probeOccupyingSession } from "./occupancy";
7
+ import type { SessionGetResult, WorktreeInventoryEntry } from "../types/opencode2-worktree";
7
8
 
8
9
  /** What a successful `spawnWorkspace` produced. */
9
10
  export interface SpawnWorkspaceResult {
@@ -17,6 +18,13 @@ export interface SpawnWorkspaceResult {
17
18
  * directory that was found, not of a clone this call performed.
18
19
  */
19
20
  readonly attached?: boolean;
21
+ /**
22
+ * Set when the occupancy marker could not be written after a successful
23
+ * session start. The session is live either way — the marker only arms the
24
+ * occupancy guard for the next spawn — so the failure rides along as a
25
+ * warning instead of failing the call.
26
+ */
27
+ readonly markerWarning?: string;
20
28
  }
21
29
 
22
30
  /** Whether a caller who asked for a CoW clone may be given a Shallow worktree. */
@@ -69,6 +77,23 @@ export interface SpawnWorkspaceDeps {
69
77
  * without a filesystem.
70
78
  */
71
79
  readonly isDirectory: (path: string) => Promise<boolean>;
80
+ /**
81
+ * Reads one session by id — the occupancy guard probes the session a marker
82
+ * names. Declared total because the classifier treats every throw and every
83
+ * odd shape as an answer (see `probeOccupyingSession`); the live binding's
84
+ * 404 arrives as a thrown `SessionNotFoundError`.
85
+ */
86
+ readonly sessionGet: (id: string) => Promise<SessionGetResult>;
87
+ /** The raw occupancy marker file's content, or `undefined` when there is none. */
88
+ readonly readMarker: (directory: string) => Promise<string | undefined>;
89
+ /**
90
+ * Writes the occupancy marker for a fresh session, git-excluding it first.
91
+ * A failure here is demoted to the result's `markerWarning`, never a failed
92
+ * spawn: an unmarked worktree is the pre-guard status quo, not an error.
93
+ */
94
+ readonly writeMarker: (directory: string, sessionID: string) => Promise<void>;
95
+ /** Wall-clock milliseconds. Injected so dormancy is testable. */
96
+ readonly now: () => number;
72
97
  /** Defaults to `"none"`: a request for `cow` is a statement about what you get. */
73
98
  readonly fallback?: FallbackPolicy;
74
99
  /** Worktree parent. Defaults to a same-filesystem sibling of the source. */
@@ -155,7 +180,17 @@ export async function spawnWorkspace(
155
180
 
156
181
  try {
157
182
  const sessionID = await deps.createSession(worktree.directory, input.name);
158
- return { sessionID, directory: worktree.directory, mechanism };
183
+ // Cow only: a git worktree's `.git` is a file, so the exclude the marker
184
+ // needs has no place to live — no marker, no exclude; the guard is a
185
+ // cow-worktree contract.
186
+ const markerWarning =
187
+ mechanism === "cow" ? await markerWarningOf(worktree.directory, sessionID, deps) : undefined;
188
+ return {
189
+ sessionID,
190
+ directory: worktree.directory,
191
+ mechanism,
192
+ ...(markerWarning === undefined ? {} : { markerWarning }),
193
+ };
159
194
  } catch (cause) {
160
195
  // The cleanup is best-effort: it must never replace the failure that
161
196
  // triggered it. `removeWorktree` goes through opencode2's DELETE route,
@@ -277,7 +312,9 @@ async function tryAttach(
277
312
  * The attach decision for a path that already exists. The inventory is the
278
313
  * source of truth for "ours" (ADR 0003): an entry recorded with `cow` plus the
279
314
  * Deep-clone signature (`.git` is a directory) means the `cow` strategy
280
- * materialized this directory, so a new session may bind to it. Every other
315
+ * materialized this directory, so a new session may bind to it — and the
316
+ * occupancy marker then decides whether it may, while the session the marker
317
+ * names is still alive (`assertOccupancyFree`). Every other
281
318
  * answer is refused loudly — before any session is created and with no
282
319
  * filesystem change — because a Foreign worktree, another strategy's Worktree,
283
320
  * or a `cow` row that lost its Deep-clone shape is not this tool's to attach
@@ -296,9 +333,19 @@ async function attachToExisting(
296
333
  if (!(await deps.isDirectory(join(target, ".git")))) {
297
334
  throw notDeepCloneError(target);
298
335
  }
336
+ await assertOccupancyFree(target, deps);
299
337
  try {
300
338
  const sessionID = await deps.createSession(target, name);
301
- return { sessionID, directory: target, mechanism: "cow", attached: true };
339
+ // Written even when no marker existed (a legacy worktree): from the next
340
+ // spawn onward, this guard is what protects the directory.
341
+ const markerWarning = await markerWarningOf(target, sessionID, deps);
342
+ return {
343
+ sessionID,
344
+ directory: target,
345
+ mechanism: "cow",
346
+ attached: true,
347
+ ...(markerWarning === undefined ? {} : { markerWarning }),
348
+ };
302
349
  } catch (cause) {
303
350
  // No removeWorktree here, unlike the create flow: the directory existed
304
351
  // before this call, so a failed session start must leave it untouched.
@@ -306,6 +353,78 @@ async function attachToExisting(
306
353
  }
307
354
  }
308
355
 
356
+ /**
357
+ * The occupancy half of attach (issue #15), run after the Deep-clone signature
358
+ * check: the marker decides whether the worktree is free to attach. No marker
359
+ * is a legacy worktree — it attaches, and the marker is written on the way
360
+ * out. A marker naming a live session refuses, naming the holder and the way
361
+ * out; a marker whose session is gone (a 404) or past the occupancy window is
362
+ * stale, and the attach proceeds to rewrite it.
363
+ */
364
+ async function assertOccupancyFree(target: string, deps: SpawnWorkspaceDeps): Promise<void> {
365
+ let raw: string | undefined;
366
+ try {
367
+ raw = await deps.readMarker(target);
368
+ } catch (cause) {
369
+ // readMarker's live binding answers `undefined` for a marker that is not
370
+ // there, so a throw here is EACCES-class: an unreadable marker is not
371
+ // evidence of a free worktree.
372
+ const reason = cause instanceof Error ? cause.message : String(cause);
373
+ throw markerReadError(target, reason);
374
+ }
375
+ const marker = parseMarker(raw);
376
+ if (marker === "malformed") throw malformedMarkerError(target);
377
+ const probe =
378
+ marker === undefined
379
+ ? { kind: "absent" as const }
380
+ : await probeOccupyingSession(deps.sessionGet, marker.sessionID);
381
+ const decision = occupancyDecision({ marker, probe, now: deps.now() });
382
+ if (decision.action === "refuse") {
383
+ throw new Error(`refusing to attach to ${target}: ${decision.reason}`);
384
+ }
385
+ }
386
+
387
+ /**
388
+ * The refusal for a marker that cannot be read: the guard cannot tell who
389
+ * holds the worktree, so it will not hand it out.
390
+ */
391
+ function markerReadError(target: string, reason: string): Error {
392
+ return new Error(
393
+ `refusing to attach to ${target}: the occupancy marker at ` +
394
+ `${join(target, MARKER_NAME)} could not be read (${reason}). ${RECOVERIES}`,
395
+ );
396
+ }
397
+
398
+ /**
399
+ * The refusal for a marker that cannot be parsed: the guard cannot tell who
400
+ * holds the worktree, so it will not hand it out.
401
+ */
402
+ function malformedMarkerError(target: string): Error {
403
+ return new Error(
404
+ `refusing to attach to ${target}: ${join(target, MARKER_NAME)} is malformed — ` +
405
+ `it does not parse as JSON or names no session, so the holder is unknown. ${RECOVERIES}`,
406
+ );
407
+ }
408
+
409
+ /**
410
+ * Writes the occupancy marker through the injected seam, demoting a failure to
411
+ * the warning string the result carries. The session is live either way; the
412
+ * marker only arms the guard for the next spawn.
413
+ */
414
+ async function markerWarningOf(
415
+ directory: string,
416
+ sessionID: string,
417
+ deps: Pick<SpawnWorkspaceDeps, "writeMarker">,
418
+ ): Promise<string | undefined> {
419
+ try {
420
+ await deps.writeMarker(directory, sessionID);
421
+ return undefined;
422
+ } catch (cause) {
423
+ const reason = cause instanceof Error ? cause.message : String(cause);
424
+ return `the occupancy marker could not be written to ${directory}: ${reason}`;
425
+ }
426
+ }
427
+
309
428
  /**
310
429
  * The inventory entry recorded for a directory, when the inventory knows it.
311
430
  *