opencode2-cow-worktree 0.1.1 → 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
@@ -206,7 +206,9 @@ If the requested name already belongs to a cow worktree, the call attaches: a
206
206
  new session binds to the existing directory and `attached: true` comes back —
207
207
  nothing is cloned. Attach only happens for worktrees this strategy
208
208
  materialized; anything else already at that path (a `git` worktree, an unknown
209
- 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.
210
212
 
211
213
  A create whose target path already exists is refused before the first write:
212
214
  `cow` never merges into, or deletes, a directory it did not create. Resolve
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode2-cow-worktree",
3
- "version": "0.1.1",
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
  *