opencode2-cow-worktree 0.1.1 → 0.3.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 +12 -4
- package/package.json +1 -1
- package/src/occupancy.ts +251 -0
- package/src/plugin.ts +41 -14
- package/src/tool.ts +123 -4
package/README.md
CHANGED
|
@@ -148,7 +148,11 @@ clone (default `"none"`):
|
|
|
148
148
|
- `"none"`: a request for `cow` produces a Deep clone or fails. Never a
|
|
149
149
|
shallow worktree.
|
|
150
150
|
- `"git"`: on a non-CoW filesystem the tool may build a regular `git`
|
|
151
|
-
worktree instead and report `mechanism: "git"`.
|
|
151
|
+
worktree instead and report `mechanism: "git"`. This works on opencode2
|
|
152
|
+
builds whose worktree create still accepts a `strategy` request
|
|
153
|
+
(2.0.2-era). From the projectID-era API onward (20260915 nightlies,
|
|
154
|
+
v2.0.3+) the create always runs the selected strategy and ignores the
|
|
155
|
+
field, so the non-CoW refusal surfaces and the fallback cannot engage.
|
|
152
156
|
|
|
153
157
|
**`targetRoot`** — where `spawn_workspace` places the worktree. Unset (the
|
|
154
158
|
default) means a sibling of the source, on the source's filesystem by
|
|
@@ -206,7 +210,9 @@ If the requested name already belongs to a cow worktree, the call attaches: a
|
|
|
206
210
|
new session binds to the existing directory and `attached: true` comes back —
|
|
207
211
|
nothing is cloned. Attach only happens for worktrees this strategy
|
|
208
212
|
materialized; anything else already at that path (a `git` worktree, an unknown
|
|
209
|
-
directory) is refused before anything changes.
|
|
213
|
+
directory) is refused before anything changes. If the worktree's recorded
|
|
214
|
+
session still shows activity, the attach is refused and the error names the
|
|
215
|
+
occupying session and the ways to recover.
|
|
210
216
|
|
|
211
217
|
A create whose target path already exists is refused before the first write:
|
|
212
218
|
`cow` never merges into, or deletes, a directory it did not create. Resolve
|
|
@@ -235,8 +241,10 @@ regardless of `fallback`. Only `spawn_workspace` consults the policy.
|
|
|
235
241
|
- **"the target is on a different filesystem"** — set `worktree.directory` as
|
|
236
242
|
shown above, or point `targetRoot` at the source's filesystem.
|
|
237
243
|
- **`cow` fails on an ext4 or tmpfs project** — expected: that filesystem
|
|
238
|
-
cannot clone.
|
|
239
|
-
the
|
|
244
|
+
cannot clone. On opencode2 builds from the projectID era (20260915
|
|
245
|
+
nightlies, v2.0.3+) the `git` fallback cannot be requested through the
|
|
246
|
+
create API, so the refusal is final; on 2.0.2-era builds the `fallback:
|
|
247
|
+
"git"` option produces a regular git worktree for tool calls.
|
|
240
248
|
- **The plugin is stuck on an old version** — opencode2 caches the package
|
|
241
249
|
under `~/.cache/opencode/node_modules/`. Remove the plugin's cache
|
|
242
250
|
directory and restart: `rm -rf ~/.cache/opencode/node_modules/opencode2-cow-worktree`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode2-cow-worktree",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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,
|
|
@@ -140,21 +146,35 @@ function liveDeps(
|
|
|
140
146
|
return {
|
|
141
147
|
probe: probeCowCapability,
|
|
142
148
|
probeDevice: deviceOf,
|
|
143
|
-
|
|
149
|
+
// The projectID-era API (20260915 nightlies onward) requires the project
|
|
150
|
+
// id on every worktree call and no longer accepts a `location` query, so
|
|
151
|
+
// all three seams derive it from the plugin's own location context.
|
|
152
|
+
listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
|
|
144
153
|
isDirectory,
|
|
145
154
|
createWorktree: (input) =>
|
|
146
155
|
ctx.worktree.create({
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
location: { directory: input.sourceDirectory },
|
|
156
|
+
projectID: ctx.location.project.id,
|
|
157
|
+
from: input.sourceDirectory,
|
|
150
158
|
directory: input.parentDirectory,
|
|
159
|
+
name: input.name,
|
|
160
|
+
// Ignored by projectID-era binaries (the selected strategy wins);
|
|
161
|
+
// honored by the 2.0.2-era API, where the git fallback needs it.
|
|
162
|
+
strategy: input.strategy,
|
|
151
163
|
}),
|
|
152
164
|
createSession: async (directory, name) => {
|
|
153
165
|
const session = await ctx.session.create({ title: name, location: { directory } });
|
|
154
166
|
return session.id;
|
|
155
167
|
},
|
|
156
168
|
removeWorktree: (directory) =>
|
|
157
|
-
ctx.worktree.remove({ directory, force: true }),
|
|
169
|
+
ctx.worktree.remove({ projectID: ctx.location.project.id, directory, force: true }),
|
|
170
|
+
// `ctx.session.get` exists on the v2 runtime but is untyped in the
|
|
171
|
+
// installed beta; types/opencode2-worktree.d.ts declares the verified
|
|
172
|
+
// `{ sessionID }` shape and the structural record slice the occupancy
|
|
173
|
+
// guard reads.
|
|
174
|
+
sessionGet: (id) => ctx.session.get({ sessionID: id }),
|
|
175
|
+
readMarker: readMarkerFile,
|
|
176
|
+
writeMarker: writeMarkerFile,
|
|
177
|
+
now: Date.now,
|
|
158
178
|
fallback,
|
|
159
179
|
targetRoot: worktreeRoot,
|
|
160
180
|
};
|
|
@@ -165,9 +185,11 @@ function liveDeps(
|
|
|
165
185
|
*
|
|
166
186
|
* `setup` registers the `cow` Strategy through the worktree seam, and the
|
|
167
187
|
* `spawn_workspace` and `list_worktrees` tools through the tool seam.
|
|
168
|
-
* Registering the Strategy also selects it as the
|
|
169
|
-
*
|
|
170
|
-
*
|
|
188
|
+
* Registering the Strategy also selects it as the default; the projectID-era
|
|
189
|
+
* create API has no per-request strategy field, so the selected strategy is
|
|
190
|
+
* what every create uses. The create call still carries `strategy` for
|
|
191
|
+
* 2.0.2-era binaries, where it is what lets the tool's opt-in `git` fallback
|
|
192
|
+
* name the built-in git strategy.
|
|
171
193
|
*/
|
|
172
194
|
export default {
|
|
173
195
|
id: "opencode2-cow-worktree",
|
|
@@ -192,7 +214,9 @@ export default {
|
|
|
192
214
|
description:
|
|
193
215
|
"Create a worktree and start a session in it, reporting the mechanism that produced the directory. " +
|
|
194
216
|
"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."
|
|
217
|
+
"session is attached to it instead of creating a new directory; anything else at that name is refused. " +
|
|
218
|
+
"If the worktree's recorded session still shows activity, the attach is refused and the error names " +
|
|
219
|
+
"the occupying session and the ways to recover.",
|
|
196
220
|
input: spawnWorkspaceInput,
|
|
197
221
|
output: spawnWorkspaceOutput,
|
|
198
222
|
// A tool defaults into CodeMode, which advertises it to the model only
|
|
@@ -203,10 +227,13 @@ export default {
|
|
|
203
227
|
const result = await spawnWorkspace(input, liveDeps(ctx, fallback, worktreeRoot));
|
|
204
228
|
// The text is what tells attach from create: on attach `mechanism`
|
|
205
229
|
// reports the found directory's mechanism, and the caller must never
|
|
206
|
-
// read that as a fresh clone having happened.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
230
|
+
// read that as a fresh clone having happened. A marker-write failure
|
|
231
|
+
// never fails the call; it rides along as a warning line.
|
|
232
|
+
const content =
|
|
233
|
+
(result.attached
|
|
234
|
+
? `Attached to existing cow worktree at ${result.directory} (session ${result.sessionID}); no new worktree was created.`
|
|
235
|
+
: `Created ${result.mechanism} worktree at ${result.directory} (session ${result.sessionID}).`) +
|
|
236
|
+
(result.markerWarning === undefined ? "" : `\nwarning: ${result.markerWarning}`);
|
|
210
237
|
return {
|
|
211
238
|
output: result,
|
|
212
239
|
content,
|
|
@@ -233,7 +260,7 @@ export default {
|
|
|
233
260
|
// the inventory and one stat per row, none of spawn_workspace's
|
|
234
261
|
// other seams. (Option validation happens once in setup, so there
|
|
235
262
|
// is no validation side effect to dodge either way.)
|
|
236
|
-
listWorktrees: () => ctx.worktree.list(),
|
|
263
|
+
listWorktrees: () => ctx.worktree.list({ projectID: ctx.location.project.id }),
|
|
237
264
|
statEntry: stat,
|
|
238
265
|
});
|
|
239
266
|
const summary =
|
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
|
*
|