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 +68 -50
- package/package.json +1 -1
- package/src/occupancy.ts +251 -0
- package/src/plugin.ts +24 -5
- package/src/tool.ts +123 -4
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
|
-
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
duplicate load fails.
|
|
35
|
+
### The short version
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
opencode2 installs the plugin itself from npm. Add the package to the
|
|
38
|
+
`plugins` array of your `opencode.json`:
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
+
Or let the CLI write that entry:
|
|
64
47
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
```
|
|
48
|
+
```sh
|
|
49
|
+
opencode2 plugin add opencode2-cow-worktree
|
|
50
|
+
```
|
|
69
51
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
62
|
+
### Options
|
|
76
63
|
|
|
77
|
-
|
|
64
|
+
Options ride in the same entry:
|
|
78
65
|
|
|
79
66
|
```json
|
|
80
67
|
{
|
|
81
68
|
"plugins": [
|
|
82
69
|
{
|
|
83
|
-
"package": "
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
242
|
-
|
|
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
|
|
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.
|
|
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": {
|
package/src/occupancy.ts
ADDED
|
@@ -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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
*
|