@junghanacs/entwurf 0.45.0 → 0.50.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.
Files changed (105) hide show
  1. package/AGENTS.md +3 -2
  2. package/BASELINE.md +110 -1
  3. package/CHANGELOG.md +169 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/ENTWURF-CLI.json +1229 -0
  6. package/ENTWURF.json +63 -15
  7. package/FAQ.md +4 -4
  8. package/README.md +124 -188
  9. package/VERIFY.md +2 -2
  10. package/docs/external-mcp-host.md +3 -3
  11. package/docs/setup-clean-host.md +37 -12
  12. package/mcp/entwurf-bridge/dist/ENTWURF.json +63 -15
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +19 -8
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +483 -18
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -10
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +613 -33
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-resume-call.js +497 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +3 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +50 -5
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +7 -1
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-location.js +347 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-owner-fence.js +1132 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +1 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +18 -11
  25. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +30 -15
  26. package/mcp/entwurf-bridge/dist/scripts/herdr-rails.mjs +40 -7
  27. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-fresh-cut.js +1 -27
  28. package/mcp/entwurf-bridge/dist/scripts/meta-bridge-prune.js +1 -1
  29. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  30. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +1 -1
  31. package/mcp/entwurf-bridge/src/index.ts +28 -8
  32. package/mcp/entwurf-bridge/tsconfig.build.json +7 -1
  33. package/native/win32-x64/manifest.json +1 -1
  34. package/package.json +6 -3
  35. package/pi/pi-durable/bootstrap.mjs +179 -6
  36. package/pi/pi-durable/carrier/runtime.js +108 -9
  37. package/pi/pi-durable/carrier/session-name.js +11 -0
  38. package/pi/pi-durable/carrier/sessions.js +96 -20
  39. package/pi/pi-durable/overlay/upstream-pin.json +11 -5
  40. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +710 -38
  41. package/pi-extensions/lib/fresh-call-dispatch.ts +64 -12
  42. package/pi-extensions/lib/herdr-fresh-call.ts +919 -40
  43. package/pi-extensions/lib/herdr-resume-call.ts +810 -0
  44. package/pi-extensions/lib/meta-session.ts +13 -8
  45. package/pi-extensions/lib/mux-resume-call.ts +77 -4
  46. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +9 -2
  47. package/pi-extensions/lib/pi-durable-location.ts +446 -0
  48. package/pi-extensions/lib/pi-durable-owner-fence.ts +1461 -0
  49. package/pi-extensions/lib/pi-durable-runtime.ts +4 -0
  50. package/pi-extensions/lib/resume-launch-identity.ts +31 -12
  51. package/pi-extensions/meta-bridge-pi-durable.ts +29 -15
  52. package/run.sh +411 -657
  53. package/scripts/check-claude-launch.ts +341 -0
  54. package/scripts/check-entwurf-v2-surface.ts +2 -2
  55. package/scripts/check-fresh-call-dispatch.ts +70 -9
  56. package/scripts/check-fresh-cut-gate.sh +23 -9
  57. package/scripts/check-gate-qualification.ts +18 -13
  58. package/scripts/check-herdr-activation.ts +301 -23
  59. package/scripts/check-herdr-fresh-call.ts +161 -25
  60. package/scripts/check-herdr-plugin-build.ts +173 -1
  61. package/scripts/check-herdr-plugin.ts +28 -2
  62. package/scripts/check-herdr-runtime-bootstrap.ts +136 -0
  63. package/scripts/check-install-surface.ts +1236 -6
  64. package/scripts/check-meta-doctor-oracle.sh +1 -1
  65. package/scripts/check-meta-facts.ts +20 -1
  66. package/scripts/check-mux-resume-call.ts +28 -0
  67. package/scripts/check-peer-facts.ts +39 -2
  68. package/scripts/cli-help.mjs +222 -0
  69. package/scripts/dev-bin.sh +39 -2
  70. package/scripts/herdr-activation.mjs +28 -6
  71. package/scripts/herdr-operator-link.mjs +389 -0
  72. package/scripts/herdr-plugin-activate.mjs +5 -0
  73. package/scripts/herdr-plugin-deactivate.mjs +18 -5
  74. package/scripts/herdr-rails.mjs +43 -7
  75. package/scripts/herdr-runtime.mjs +27 -0
  76. package/scripts/meta-bridge-fresh-cut.ts +1 -27
  77. package/scripts/meta-bridge-prune.ts +1 -1
  78. package/scripts/meta-facts.ts +1 -1
  79. package/scripts/mutants/agent-guidance.json +4 -4
  80. package/scripts/mutants/claude-launch.json +142 -0
  81. package/scripts/mutants/cli-help-source.json +420 -0
  82. package/scripts/mutants/dev-gate-dispatch.json +109 -0
  83. package/scripts/mutants/fresh-call-dispatch.json +38 -2
  84. package/scripts/mutants/fresh-cut.json +15 -0
  85. package/scripts/mutants/herdr-activation.json +128 -0
  86. package/scripts/mutants/herdr-fresh-call.json +278 -5
  87. package/scripts/mutants/herdr-operator-link.json +318 -0
  88. package/scripts/mutants/herdr-plugin-build.json +55 -0
  89. package/scripts/mutants/herdr-plugin.json +17 -0
  90. package/scripts/mutants/herdr-resume-call.json +288 -0
  91. package/scripts/mutants/herdr-runtime-bootstrap.json +48 -0
  92. package/scripts/mutants/meta-facts.json +15 -0
  93. package/scripts/mutants/mux-resume-call.json +63 -2
  94. package/scripts/mutants/peer-facts.json +15 -0
  95. package/scripts/mutants/pi-durable.json +979 -1
  96. package/scripts/mutants/source-install.json +35 -0
  97. package/scripts/mutants/v2-surface.json +2 -2
  98. package/scripts/mutants/v2-visible-resume.json +255 -6
  99. package/scripts/peer-facts.ts +1 -1
  100. package/scripts/smoke-agy-install-state.sh +66 -0
  101. package/scripts/smoke-meta-prune.sh +23 -0
  102. package/scripts/smoke-omp-fresh-live.ts +12 -3
  103. package/scripts/smoke-omp-receive-live.ts +7 -1
  104. package/scripts/smoke-pi-durable-fresh-live.ts +12 -2
  105. package/team.json.example +39 -0
@@ -0,0 +1,1461 @@
1
+ /**
2
+ * pi-durable OWNER FENCE (#157 B3, pre-release fence) — the claim an owned pi-durable open takes inside its
3
+ * native session directory D BEFORE the native lease (proper-lockfile's `${D}.lock`), and carries through
4
+ * storage open and close.
5
+ *
6
+ * WHAT THIS IS. Three fixed names inside D, never derived from the session id and never under the SDK's
7
+ * `session.sqlite` prefix:
8
+ * - `entwurf-owner.json` — the claim: one regular file, replaced by tmp+rename, read no-follow;
9
+ * - `entwurf-owner.tx` — the transaction mutex S_D, created `wx`; its body is evidence only;
10
+ * - `entwurf-owner.json.tmp-<pid>-<hex>` — the atomic temp a write renames into place.
11
+ * Every claim write — the first creation of an absent claim included — happens inside ONE S_D hold, on a
12
+ * decision read inside that hold; an owner's own transition also requires the generation it last wrote.
13
+ * The holder unlinks only the tx it created, in `finally`. A tx it did not create (another holder, or crash
14
+ * residue) is never removed, taken over or aged out: there is no TTL, no mtime rule and no cleanup verb.
15
+ *
16
+ * WHAT IT GUARANTEES. Mutual exclusion among PARTICIPATING owned openers only. It does not prove that no
17
+ * foreign lease exists, and it does not make a legacy or non-participating opener safe (an opener with
18
+ * neither a fence nor a lease is invisible to it). A `closed` claim is a participating owner's end receipt in
19
+ * one of two rows; it is not "untouched since", not dormancy and not public-resume evidence, and neither is
20
+ * `firstParticipation`, which records the first participation attempt for D, not a completed enrollment.
21
+ *
22
+ * SCHEMA. A new admission writes schema 2, whose `selected` also carries `profile`: the agent directory exactly as
23
+ * the owned open's `getAgentDir()` returned it, the value its `directory` was joined from. A schema 1 claim (the
24
+ * pre-profile build: `selected` is `{n, directory}`) is still read, and every admission row judges it as before; its
25
+ * profile is never inferred, and it is replaced only by the next admission's own claim, never rewritten to migrate.
26
+ * Schema 2 alone knows `reserved` with its `reservation`, and `adoptedFrom`; an older reader refuses schema 2, so the
27
+ * rollout is complete only once every participating reader is this one.
28
+ *
29
+ * RESERVATION (#157 B3 I-2). A parent reserves one citizen's D for one child (`reservePiDurableSession`), the child
30
+ * adopts exactly that reservation (`createPiDurableOwnerFence({adoption})`), and only the parent withdraws its own
31
+ * (`withdrawPiDurableReservation`). The reserve and the adoption take record-arm(gid), then S_D(R), on the record and
32
+ * C4 pair read inside the arm hold; the withdrawal takes S_D only. A parent's per-target lock is its caller's outer
33
+ * scope and is never taken here. The claim and S_D live in R, the physical D; the SDK's lease is `${S}.lock`, S being
34
+ * the donor's own selection, and every lease snapshot is taken there. `observePiDurableReservation` is a read, never a
35
+ * watch.
36
+ *
37
+ * DEATH POLICY stays in meta-session.ts (Hard Rule 14): `classifyMarkerOwner` over `processStartKey` and
38
+ * `probePidExistence`, plus `isPlausibleOwnerPid`. A live or uncertain owner — this very process included — is
39
+ * refused; only a proven-dead owner on this host is reclaimed, and an unattributed lease that is present is
40
+ * never presumed to be the dead owner's.
41
+ *
42
+ * The owned app (pi/pi-durable/src/experimental/durable/sessions.ts) declares the `SessionFence` and
43
+ * `FenceTicket` shapes this module implements, type-only and without importing Entwurf; the packaged
44
+ * bootstrap builds one fence from the compiled twin of this file and hands it to every owned open.
45
+ */
46
+
47
+ import { createHash, randomBytes } from "node:crypto";
48
+ import * as fs from "node:fs";
49
+ import * as os from "node:os";
50
+ import * as path from "node:path";
51
+ import { setTimeout as sleep } from "node:timers/promises";
52
+ import { acquireLock, type LockClaim, releaseLock } from "./entwurf-v2-lock.ts";
53
+ import {
54
+ classifyMarkerOwner,
55
+ defaultMetaRecordArmLocksDir,
56
+ isPlausibleOwnerPid,
57
+ type MetaIdentity,
58
+ probePidExistence,
59
+ processStartKey,
60
+ readStoreRecordFile,
61
+ requireAttachIncarnation,
62
+ requireGardenId,
63
+ } from "./meta-session.ts";
64
+ import { type PiDurableLocation, verifyPiDurableLocation } from "./pi-durable-location.ts";
65
+
66
+ export const PI_DURABLE_OWNER_CLAIM = "entwurf-owner.json";
67
+ export const PI_DURABLE_OWNER_TX = "entwurf-owner.tx";
68
+ export const PI_DURABLE_OWNER_TMP_PREFIX = `${PI_DURABLE_OWNER_CLAIM}.tmp-`;
69
+ /** The schema a new admission writes: `selected` is `{n, directory, profile}`. */
70
+ export const PI_DURABLE_OWNER_SCHEMA_VERSION = 2;
71
+ /** The pre-profile schema, still read and never written: `selected` is `{n, directory}`. */
72
+ const LEGACY_SCHEMA_VERSION = 1;
73
+
74
+ /** The bounded wait for S_D: 20 attempts 100 ms apart, then `tx-held`. */
75
+ const TX_ATTEMPTS = 20;
76
+ const TX_DELAY_MS = 100;
77
+ const CAUSE_MAX = 512;
78
+ const HEX32 = /^[0-9a-f]{32}$/;
79
+
80
+ /** The states an owned open's own lifetime writes. */
81
+ export type PiDurableNativeState = "fenced" | "leased" | "storage-open" | "closed" | "close-failed";
82
+ /** Schema 2 adds `reserved`: a parent's hold of D for one child, never an owned open's own state. */
83
+ export type PiDurableOwnerState = PiDurableNativeState | "reserved";
84
+ /** `${S}.lock` as a reserve saw it, S being the donor's own selection. */
85
+ export type PiDurableNativeLease = "absent" | "present-attributed";
86
+ export type PiDurableLeaseOutcome = "never-held" | "held" | "released" | "failed" | "unknown";
87
+ export type PiDurableStorageEnd = "closed" | "never-opened" | "failed" | "unknown";
88
+ export type PiDurableParticipation = "fresh" | "unarmed-existing";
89
+
90
+ export interface PiDurableOwner {
91
+ readonly pid: number;
92
+ readonly startKey: string;
93
+ readonly hostname: string;
94
+ }
95
+
96
+ /** An owner's end as the opener observed it; `at` is added when it is written. */
97
+ export interface PiDurableOwnerEnd {
98
+ readonly storage: PiDurableStorageEnd;
99
+ readonly lease: PiDurableLeaseOutcome;
100
+ readonly envCleanup?: "failed";
101
+ readonly cause?: string;
102
+ }
103
+
104
+ export interface PiDurableOwnerClaim {
105
+ readonly schemaVersion: typeof PI_DURABLE_OWNER_SCHEMA_VERSION | 1;
106
+ readonly resource: string;
107
+ /** Schema 2 also carries `profile`, the agent directory `directory` was joined from, exactly as it was read. */
108
+ readonly selected: { readonly n: string; readonly directory: string; readonly profile?: string };
109
+ readonly state: PiDurableOwnerState;
110
+ readonly owner: PiDurableOwner;
111
+ readonly generation: string;
112
+ readonly firstParticipation: {
113
+ readonly at: string;
114
+ readonly generation: string;
115
+ readonly from: PiDurableParticipation;
116
+ };
117
+ readonly reclaimedFrom?: {
118
+ readonly generation: string;
119
+ readonly state: PiDurableOwnerState;
120
+ readonly owner: PiDurableOwner;
121
+ readonly leaseOutcome: PiDurableLeaseOutcome;
122
+ };
123
+ readonly lease: PiDurableLeaseOutcome;
124
+ readonly end?: PiDurableOwnerEnd & { readonly at: string };
125
+ /** Schema 2, `reserved` only: the reservation this parent holds D under. */
126
+ readonly reservation?: PiDurableReservationBody;
127
+ /** Schema 2: the reservation this owner adopted D from; kept through its own transitions only. */
128
+ readonly adoptedFrom?: { readonly generation: string; readonly owner: PiDurableOwner };
129
+ readonly writtenAt: string;
130
+ }
131
+
132
+ /** The last native owner a reservation was taken from: one flat row, never a reservation, adoption or reclaim. */
133
+ export interface PiDurableDonor {
134
+ readonly generation: string;
135
+ readonly state: PiDurableNativeState;
136
+ readonly owner: PiDurableOwner;
137
+ readonly lease: PiDurableLeaseOutcome;
138
+ readonly end?: PiDurableOwnerEnd & { readonly at: string };
139
+ readonly selected: PiDurableOwnerClaim["selected"];
140
+ }
141
+
142
+ /** A parent's reservation of D for one child, written only on a `reserved` claim. */
143
+ export interface PiDurableReservationBody {
144
+ readonly gardenId: string;
145
+ readonly n: string;
146
+ readonly cwd: string;
147
+ readonly resource: string;
148
+ /** The donor's raw profile: the child's SDK is started with exactly this value. */
149
+ readonly profile: string;
150
+ /** The record's binding when the reservation was taken. */
151
+ readonly attachIncarnation: string;
152
+ readonly nativeLease: PiDurableNativeLease;
153
+ readonly from: PiDurableDonor;
154
+ }
155
+
156
+ /** What an owned open hands the fence after it chose D and before it takes the native lease. */
157
+ export interface PiDurableOwnerAdmission {
158
+ /** D exactly as this opener selected it (pre-canonical; its lease is `${directory}.lock`). */
159
+ readonly directory: string;
160
+ /** The session id N this opener selected D by. */
161
+ readonly selectedN: string;
162
+ /** The opener's own `mkdir(D)` created D in this call. */
163
+ readonly created: boolean;
164
+ /** The agent directory exactly as the opener's `getAgentDir()` returned it, the value `directory` was joined from. */
165
+ readonly profile: string;
166
+ }
167
+
168
+ /** One admitted owner, kept in memory between its checkpoints (the app's `FenceTicket`). */
169
+ export interface PiDurableOwnerTicket {
170
+ /** Set by the opener immediately before it calls the storage open; never written to the claim. */
171
+ storageOpenAttempted: boolean;
172
+ /** fenced → leased: the native lease was acquired. */
173
+ leased(): Promise<void>;
174
+ /** leased → storage-open: the storage open and the Harness open both resolved. */
175
+ storageOpened(): Promise<void>;
176
+ /** → closed, in exactly one of the two allowed end rows. */
177
+ closed(end: PiDurableOwnerEnd): Promise<void>;
178
+ /** → close-failed: anything that is not a verified end. */
179
+ closeFailed(end: PiDurableOwnerEnd): Promise<void>;
180
+ /** fenced → closed {never-opened, never-held}: the lease was not acquired. */
181
+ endUnleased(): Promise<void>;
182
+ }
183
+
184
+ /** The fence an owned open takes before its native lease (the app's `SessionFence`). */
185
+ export interface PiDurableOwnerFence {
186
+ admit(request: PiDurableOwnerAdmission): Promise<PiDurableOwnerTicket>;
187
+ }
188
+
189
+ export type PiDurableOwnerFenceReason =
190
+ | "admission-invalid"
191
+ | "directory-invalid"
192
+ | "owner-pid-implausible"
193
+ | "owner-start-key-unreadable"
194
+ | "tx-held"
195
+ | "tx-release-failed"
196
+ | "legacy-lease-present"
197
+ | "owner-live"
198
+ | "owner-uncertain"
199
+ | "owner-other-host"
200
+ | "lease-unattributed"
201
+ | "claim-unreadable"
202
+ | "claim-resource-mismatch"
203
+ | "claim-missing"
204
+ | "generation-mismatch"
205
+ | "transition-invalid"
206
+ | "reservation-invalid"
207
+ | "reserve-meta-root-split"
208
+ | "record-arm-busy"
209
+ | "record-arm-release-failed"
210
+ | "binding-mismatch"
211
+ | "profile-record-unbound"
212
+ | "profile-other-host"
213
+ | "profile-unattributed"
214
+ | "profile-n-mismatch"
215
+ | "profile-layout-mismatch"
216
+ | "profile-alias-moved"
217
+ | "reserve-unarmed"
218
+ | "reserve-not-clean"
219
+ | "lease-appeared"
220
+ | "adopt-not-reserved"
221
+ | "adopt-generation-mismatch"
222
+ | "adopt-binding-changed"
223
+ | "adopt-reservation-mismatch"
224
+ | "adopt-profile-mismatch"
225
+ | "adopt-lease-appeared"
226
+ | "withdraw-not-own";
227
+
228
+ /**
229
+ * The decision write a call had COMMITTED before one of its lock releases failed (#157 S3 joint review D1/D3): the
230
+ * claim it wrote, as it wrote it, captured right after that write returned. Only `tx-release-failed` and
231
+ * `record-arm-release-failed` carry one, and only from a reserve or a withdrawal. It is what this call wrote, not what
232
+ * the claim holds now. Its absence records no write; it is not evidence that none was made.
233
+ */
234
+ export interface PiDurableWrittenClaim {
235
+ readonly resource: string;
236
+ readonly state: PiDurableOwnerState;
237
+ readonly generation: string;
238
+ readonly ownerPid: number;
239
+ }
240
+
241
+ /** Structured facts a refusal carries beside its reason, so no caller reads them out of the message. */
242
+ export interface PiDurableOwnerFenceErrorContext {
243
+ /** A reserve's holder refusal (owner-live / owner-uncertain / owner-other-host): the refused claim's state. */
244
+ readonly holderState?: PiDurableOwnerState;
245
+ readonly written?: PiDurableWrittenClaim;
246
+ }
247
+
248
+ export class PiDurableOwnerFenceError extends Error {
249
+ readonly reason: PiDurableOwnerFenceReason;
250
+ readonly holderState?: PiDurableOwnerState;
251
+ readonly written?: PiDurableWrittenClaim;
252
+ constructor(reason: PiDurableOwnerFenceReason, message: string, context: PiDurableOwnerFenceErrorContext = {}) {
253
+ super(`${reason}: ${message}`);
254
+ this.name = "PiDurableOwnerFenceError";
255
+ this.reason = reason;
256
+ if (context.holderState !== undefined) this.holderState = context.holderState;
257
+ if (context.written !== undefined) this.written = context.written;
258
+ }
259
+ }
260
+
261
+ function refuse(
262
+ reason: PiDurableOwnerFenceReason,
263
+ message: string,
264
+ context?: PiDurableOwnerFenceErrorContext,
265
+ ): PiDurableOwnerFenceError {
266
+ return new PiDurableOwnerFenceError(reason, message, context);
267
+ }
268
+
269
+ /**
270
+ * A release failure that came AFTER this call's own decision write committed keeps its reason, message and cause; it
271
+ * only gains what was written. Any other error, or one that already names a write, is returned as it is.
272
+ */
273
+ function afterWrite(error: unknown, written: PiDurableWrittenClaim | undefined): unknown {
274
+ if (
275
+ written !== undefined &&
276
+ error instanceof PiDurableOwnerFenceError &&
277
+ error.written === undefined &&
278
+ (error.reason === "tx-release-failed" || error.reason === "record-arm-release-failed")
279
+ ) {
280
+ Object.assign(error, { written });
281
+ }
282
+ return error;
283
+ }
284
+
285
+ function writtenOf(claim: PiDurableOwnerClaim): PiDurableWrittenClaim {
286
+ return { resource: claim.resource, state: claim.state, generation: claim.generation, ownerPid: claim.owner.pid };
287
+ }
288
+
289
+ function messageOf(error: unknown): string {
290
+ return error instanceof Error ? error.message : String(error);
291
+ }
292
+
293
+ /** What a launched child carries to adopt ONE reservation (#157 B3), decoded by its caller: its closed set only. */
294
+ export interface PiDurableAdoption {
295
+ readonly gardenId: string;
296
+ /** ONE absolute meta root; the four roots are its standard children. */
297
+ readonly metaRoot: string;
298
+ /** The reservation's generation g_r: matching data, never removal authority. */
299
+ readonly sourceGeneration: string;
300
+ }
301
+
302
+ export interface PiDurableOwnerFenceOptions {
303
+ readonly adoption?: PiDurableAdoption;
304
+ }
305
+
306
+ /**
307
+ * The fence an owned open takes. Building it reads and writes nothing; every check happens at `admit`. With an
308
+ * adoption, `admit` adopts exactly that reservation or refuses; it never falls back to an ordinary admission.
309
+ */
310
+ export function createPiDurableOwnerFence(options: PiDurableOwnerFenceOptions = {}): PiDurableOwnerFence {
311
+ if (options.adoption === undefined) return { admit: (request) => admitOwner(request) };
312
+ const adoption = requireAdoption(options.adoption);
313
+ return { admit: (request) => adoptOwner(request, adoption) };
314
+ }
315
+
316
+ // ── The claim's schema and its state × lease × end table ─────────────────────────────────────────
317
+
318
+ const NATIVE_STATES: readonly PiDurableNativeState[] = ["fenced", "leased", "storage-open", "closed", "close-failed"];
319
+ /** Schema 2's states; schema 1 knows only the native ones. */
320
+ const STATES: readonly PiDurableOwnerState[] = [...NATIVE_STATES, "reserved"];
321
+ const NATIVE_LEASES: readonly PiDurableNativeLease[] = ["absent", "present-attributed"];
322
+ const LEASES: readonly PiDurableLeaseOutcome[] = ["never-held", "held", "released", "failed", "unknown"];
323
+ const STORAGE_ENDS: readonly PiDurableStorageEnd[] = ["closed", "never-opened", "failed", "unknown"];
324
+ const PARTICIPATIONS: readonly PiDurableParticipation[] = ["fresh", "unarmed-existing"];
325
+ const CLAIM_KEYS = [
326
+ "schemaVersion",
327
+ "resource",
328
+ "selected",
329
+ "state",
330
+ "owner",
331
+ "generation",
332
+ "firstParticipation",
333
+ "lease",
334
+ "writtenAt",
335
+ ] as const;
336
+ const CLAIM_OPTIONAL_KEYS = ["reclaimedFrom", "end"] as const;
337
+ /** Only schema 2 carries a reservation or an adoption. */
338
+ const CLAIM_OPTIONAL_KEYS_V2 = [...CLAIM_OPTIONAL_KEYS, "reservation", "adoptedFrom"] as const;
339
+ const RESERVATION_KEYS = ["gardenId", "n", "cwd", "resource", "profile", "attachIncarnation", "nativeLease", "from"];
340
+
341
+ type Json = Record<string, unknown>;
342
+
343
+ function isObject(value: unknown): value is Json {
344
+ return typeof value === "object" && value !== null && !Array.isArray(value);
345
+ }
346
+
347
+ function hasExactKeys(value: Json, required: readonly string[], optional: readonly string[] = []): boolean {
348
+ const keys = Object.keys(value);
349
+ return (
350
+ required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key))
351
+ );
352
+ }
353
+
354
+ function nonEmpty(value: unknown): value is string {
355
+ return typeof value === "string" && value.length > 0;
356
+ }
357
+
358
+ function isTime(value: unknown): value is string {
359
+ return nonEmpty(value) && Number.isFinite(Date.parse(value));
360
+ }
361
+
362
+ function isOwner(value: unknown): value is PiDurableOwner {
363
+ return (
364
+ isObject(value) &&
365
+ hasExactKeys(value, ["pid", "startKey", "hostname"]) &&
366
+ isPlausibleOwnerPid(value.pid) &&
367
+ nonEmpty(value.startKey) &&
368
+ nonEmpty(value.hostname)
369
+ );
370
+ }
371
+
372
+ function isEnd(value: unknown): value is PiDurableOwnerEnd & { at: string } {
373
+ return (
374
+ isObject(value) &&
375
+ hasExactKeys(value, ["at", "storage", "lease"], ["envCleanup", "cause"]) &&
376
+ isTime(value.at) &&
377
+ STORAGE_ENDS.includes(value.storage as PiDurableStorageEnd) &&
378
+ LEASES.includes(value.lease as PiDurableLeaseOutcome) &&
379
+ (value.envCleanup === undefined || value.envCleanup === "failed") &&
380
+ (value.cause === undefined || (typeof value.cause === "string" && value.cause.length <= CAUSE_MAX))
381
+ );
382
+ }
383
+
384
+ /**
385
+ * The complete table (anything else is a malformed claim):
386
+ * reserved never-held — (schema 2 only)
387
+ * fenced never-held —
388
+ * leased held —
389
+ * storage-open held —
390
+ * closed released closed | never-opened
391
+ * closed never-held never-opened
392
+ * close-failed released | failed | unknown failed | unknown; closed or never-opened only when the release
393
+ * failed or is unknown
394
+ * `end.lease` repeats the claim's `lease`; an env-cleanup failure never rides a storage that was never opened.
395
+ */
396
+ function combinationValid(
397
+ state: PiDurableOwnerState,
398
+ lease: PiDurableLeaseOutcome,
399
+ end: (PiDurableOwnerEnd & { at: string }) | undefined,
400
+ ): boolean {
401
+ if (state === "reserved") return lease === "never-held" && end === undefined;
402
+ if (state === "fenced") return lease === "never-held" && end === undefined;
403
+ if (state === "leased" || state === "storage-open") return lease === "held" && end === undefined;
404
+ if (end === undefined || end.lease !== lease) return false;
405
+ if (end.envCleanup !== undefined && end.storage === "never-opened") return false;
406
+ if (state === "closed") {
407
+ if (lease === "released") return end.storage === "closed" || end.storage === "never-opened";
408
+ return lease === "never-held" && end.storage === "never-opened";
409
+ }
410
+ if (lease !== "released" && lease !== "failed" && lease !== "unknown") return false;
411
+ if (end.storage === "failed" || end.storage === "unknown") return true;
412
+ return lease !== "released";
413
+ }
414
+
415
+ /**
416
+ * A claim body exactly as the schema and the table allow, or a named refusal; never a default. A body read from D
417
+ * is `claim-unreadable`; a body about to be written is `transition-invalid`.
418
+ */
419
+ function requireClaim(
420
+ value: unknown,
421
+ where: string,
422
+ reason: "claim-unreadable" | "transition-invalid" = "claim-unreadable",
423
+ ): PiDurableOwnerClaim {
424
+ const bad = (what: string): PiDurableOwnerFenceError => refuse(reason, `${where}: ${what}`);
425
+ if (!isObject(value)) throw bad("not a JSON object");
426
+ const v2 = value.schemaVersion === PI_DURABLE_OWNER_SCHEMA_VERSION;
427
+ if (!v2 && value.schemaVersion !== LEGACY_SCHEMA_VERSION) throw bad("unknown schemaVersion");
428
+ const optional = v2 ? CLAIM_OPTIONAL_KEYS_V2 : CLAIM_OPTIONAL_KEYS;
429
+ if (!hasExactKeys(value, CLAIM_KEYS, optional)) throw bad("its keys are not exactly the claim's keys");
430
+ if (!nonEmpty(value.resource) || !path.isAbsolute(value.resource)) throw bad("resource is not an absolute path");
431
+ const selected = value.selected;
432
+ if (
433
+ !isObject(selected) ||
434
+ !hasExactKeys(selected, v2 ? ["n", "directory", "profile"] : ["n", "directory"]) ||
435
+ !nonEmpty(selected.n) ||
436
+ !nonEmpty(selected.directory) ||
437
+ !path.isAbsolute(selected.directory)
438
+ ) {
439
+ throw bad(v2 ? "selected is not {n, directory, profile}" : "selected is not {n, directory}");
440
+ }
441
+ // Schema 2's profile is the value its directory was joined from, as it was read; schema 1 has none to infer.
442
+ if (v2) {
443
+ const profile = selected.profile;
444
+ if (!nonEmpty(profile) || !path.isAbsolute(profile)) throw bad("selected.profile is not an absolute path");
445
+ const n = selected.n as string;
446
+ const directory = selected.directory as string;
447
+ const joined = path.join(profile, "experimental", "durable-sessions", path.basename(path.dirname(directory)), n);
448
+ if (joined !== directory) throw bad("selected.directory is not joined from selected.profile and selected.n");
449
+ }
450
+ const states: readonly PiDurableOwnerState[] = v2 ? STATES : NATIVE_STATES;
451
+ if (!states.includes(value.state as PiDurableOwnerState)) throw bad("unknown state");
452
+ if (!isOwner(value.owner)) throw bad("owner is not {pid, startKey, hostname} of a plausible pid");
453
+ if (typeof value.generation !== "string" || !HEX32.test(value.generation)) throw bad("generation is not 32 hex");
454
+ const first = value.firstParticipation;
455
+ if (
456
+ !isObject(first) ||
457
+ !hasExactKeys(first, ["at", "generation", "from"]) ||
458
+ !isTime(first.at) ||
459
+ typeof first.generation !== "string" ||
460
+ !HEX32.test(first.generation) ||
461
+ !PARTICIPATIONS.includes(first.from as PiDurableParticipation)
462
+ ) {
463
+ throw bad("firstParticipation is not {at, generation, from}");
464
+ }
465
+ const reclaimed = value.reclaimedFrom;
466
+ if (
467
+ reclaimed !== undefined &&
468
+ !(
469
+ isObject(reclaimed) &&
470
+ hasExactKeys(reclaimed, ["generation", "state", "owner", "leaseOutcome"]) &&
471
+ typeof reclaimed.generation === "string" &&
472
+ HEX32.test(reclaimed.generation) &&
473
+ states.includes(reclaimed.state as PiDurableOwnerState) &&
474
+ isOwner(reclaimed.owner) &&
475
+ LEASES.includes(reclaimed.leaseOutcome as PiDurableLeaseOutcome)
476
+ )
477
+ ) {
478
+ throw bad("reclaimedFrom is not {generation, state, owner, leaseOutcome}");
479
+ }
480
+ if (!LEASES.includes(value.lease as PiDurableLeaseOutcome)) throw bad("unknown lease outcome");
481
+ if (value.end !== undefined && !isEnd(value.end)) throw bad("end is not {at, storage, lease, envCleanup?, cause?}");
482
+ if (!isTime(value.writtenAt)) throw bad("writtenAt is not a time");
483
+ const claim = value as unknown as PiDurableOwnerClaim;
484
+ if (!combinationValid(claim.state, claim.lease, claim.end)) {
485
+ throw bad(`state ${claim.state} with lease ${claim.lease} and end ${JSON.stringify(claim.end ?? null)} is invalid`);
486
+ }
487
+ // Exactly a `reserved` claim carries a reservation (#157 B3); an adoption rides only an owned state.
488
+ if ((claim.state === "reserved") !== (value.reservation !== undefined)) {
489
+ throw bad(`a ${claim.state} claim ${claim.state === "reserved" ? "lacks" : "carries"} a reservation`);
490
+ }
491
+ if (value.reservation !== undefined) requireReservation(value.reservation, claim, bad);
492
+ if (value.adoptedFrom !== undefined && (claim.state === "reserved" || !isAdoptedFrom(value.adoptedFrom))) {
493
+ throw bad("adoptedFrom is not {generation, owner} on an owned state");
494
+ }
495
+ return claim;
496
+ }
497
+
498
+ function isAdoptedFrom(value: unknown): boolean {
499
+ return (
500
+ isObject(value) &&
501
+ hasExactKeys(value, ["generation", "owner"]) &&
502
+ typeof value.generation === "string" &&
503
+ HEX32.test(value.generation) &&
504
+ isOwner(value.owner)
505
+ );
506
+ }
507
+
508
+ /** Whether a native row's own lease outcome makes a present lease that owner's: held, or held and never seen released. */
509
+ function leaseAttributed(lease: PiDurableLeaseOutcome): boolean {
510
+ return lease === "held" || lease === "unknown";
511
+ }
512
+
513
+ /**
514
+ * A reservation exactly as the reserve writes it: it names the claim's resource, session and profile; its donor is ONE
515
+ * flat native row whose selection is the claim's; a present lease is attributed only to a donor that held one.
516
+ */
517
+ function requireReservation(
518
+ value: unknown,
519
+ claim: PiDurableOwnerClaim,
520
+ bad: (what: string) => PiDurableOwnerFenceError,
521
+ ): void {
522
+ if (!isObject(value) || !hasExactKeys(value, RESERVATION_KEYS))
523
+ throw bad("reservation is not the reservation's keys");
524
+ try {
525
+ requireGardenId(value.gardenId);
526
+ if (requireAttachIncarnation(value.attachIncarnation) === null) throw new Error("attachIncarnation is null");
527
+ } catch (error) {
528
+ throw bad(`reservation: ${messageOf(error)}`);
529
+ }
530
+ if (!nonEmpty(value.cwd) || !path.isAbsolute(value.cwd)) throw bad("reservation.cwd is not an absolute path");
531
+ if (value.resource !== claim.resource || value.n !== claim.selected.n || value.profile !== claim.selected.profile) {
532
+ throw bad("reservation does not name the claim's resource, session and profile");
533
+ }
534
+ if (!NATIVE_LEASES.includes(value.nativeLease as PiDurableNativeLease))
535
+ throw bad("reservation.nativeLease is unknown");
536
+ const from = value.from;
537
+ if (
538
+ !isObject(from) ||
539
+ !hasExactKeys(from, ["generation", "state", "owner", "lease", "selected"], ["end"]) ||
540
+ typeof from.generation !== "string" ||
541
+ !HEX32.test(from.generation) ||
542
+ !NATIVE_STATES.includes(from.state as PiDurableNativeState) ||
543
+ !isOwner(from.owner) ||
544
+ !LEASES.includes(from.lease as PiDurableLeaseOutcome) ||
545
+ (from.end !== undefined && !isEnd(from.end))
546
+ ) {
547
+ throw bad("reservation.from is not one flat native owner row");
548
+ }
549
+ // The reserve selects nothing itself: the donor's selection IS the claim's.
550
+ const selected = from.selected;
551
+ if (
552
+ !isObject(selected) ||
553
+ !hasExactKeys(selected, ["n", "directory", "profile"]) ||
554
+ selected.n !== claim.selected.n ||
555
+ selected.directory !== claim.selected.directory ||
556
+ selected.profile !== claim.selected.profile
557
+ ) {
558
+ throw bad("reservation.from.selected is not the claim's selected");
559
+ }
560
+ const donor = from as unknown as PiDurableDonor;
561
+ if (!combinationValid(donor.state, donor.lease, donor.end))
562
+ throw bad("reservation.from is not a row the table allows");
563
+ const attributable = leaseAttributed(donor.lease);
564
+ if (value.nativeLease === "present-attributed" && !attributable) {
565
+ throw bad("reservation.nativeLease is present-attributed for a donor that held no lease");
566
+ }
567
+ }
568
+
569
+ // ── Reads, writes and the S_D hold ───────────────────────────────────────────────────────────────
570
+
571
+ /** The claim of D, or undefined when there is none; a symlink, a non-regular entry or a bad body is refused. */
572
+ function readClaim(resource: string): PiDurableOwnerClaim | undefined {
573
+ const file = path.join(resource, PI_DURABLE_OWNER_CLAIM);
574
+ let text: string;
575
+ try {
576
+ text = readStoreRecordFile(file);
577
+ } catch (error) {
578
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
579
+ throw refuse("claim-unreadable", `${file}: ${messageOf(error)}`);
580
+ }
581
+ let parsed: unknown;
582
+ try {
583
+ parsed = JSON.parse(text);
584
+ } catch (error) {
585
+ throw refuse("claim-unreadable", `${file}: ${messageOf(error)}`);
586
+ }
587
+ return requireClaim(parsed, file);
588
+ }
589
+
590
+ /** tmp+rename, mode 0600, fsync'd before the rename. Only a body the schema and the table accept is written. */
591
+ function writeClaim(resource: string, body: PiDurableOwnerClaim): void {
592
+ requireClaim(JSON.parse(JSON.stringify(body)), "the claim about to be written", "transition-invalid");
593
+ const tmp = path.join(resource, `${PI_DURABLE_OWNER_TMP_PREFIX}${process.pid}-${randomBytes(6).toString("hex")}`);
594
+ // Ownership of the temp is its creation by THIS call: a `wx` that failed (EEXIST included) made nothing here.
595
+ let created = false;
596
+ try {
597
+ const fd = fs.openSync(tmp, "wx", 0o600);
598
+ created = true;
599
+ try {
600
+ fs.writeSync(fd, `${JSON.stringify(body, null, "\t")}\n`);
601
+ fs.fsyncSync(fd);
602
+ } finally {
603
+ fs.closeSync(fd);
604
+ }
605
+ fs.renameSync(tmp, path.join(resource, PI_DURABLE_OWNER_CLAIM));
606
+ } catch (error) {
607
+ // Only the temp this call created is its to remove; a file that was already there is left as it is.
608
+ if (created) fs.rmSync(tmp, { force: true });
609
+ throw error;
610
+ }
611
+ }
612
+
613
+ /** Whether `${D}.lock` (the lease this opener would take) exists in any form; an unreadable answer throws. */
614
+ function leasePresent(directory: string): boolean {
615
+ try {
616
+ fs.lstatSync(`${directory}.lock`);
617
+ return true;
618
+ } catch (error) {
619
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
620
+ throw error;
621
+ }
622
+ }
623
+
624
+ interface TxHold {
625
+ readonly dev: number;
626
+ readonly ino: number;
627
+ }
628
+
629
+ /** What a busy tx says about its holder, as evidence only; nothing is decided from it. */
630
+ function txEvidence(tx: string): string {
631
+ try {
632
+ return readStoreRecordFile(tx).trim().slice(0, CAUSE_MAX) || "(empty)";
633
+ } catch (error) {
634
+ return `(unreadable: ${(error as NodeJS.ErrnoException).code ?? messageOf(error)})`;
635
+ }
636
+ }
637
+
638
+ async function enterTx(tx: string): Promise<TxHold> {
639
+ for (let attempt = 1; ; attempt++) {
640
+ let fd: number | undefined;
641
+ try {
642
+ fd = fs.openSync(tx, "wx", 0o600);
643
+ } catch (error) {
644
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
645
+ }
646
+ if (fd !== undefined) {
647
+ try {
648
+ const evidence = {
649
+ pid: process.pid,
650
+ startKey: processStartKey(process.pid),
651
+ hostname: os.hostname(),
652
+ enteredAt: new Date().toISOString(),
653
+ };
654
+ fs.writeSync(fd, `${JSON.stringify(evidence)}\n`);
655
+ const { dev, ino } = fs.fstatSync(fd);
656
+ return { dev, ino };
657
+ } catch (error) {
658
+ // This tx was created by this call a moment ago, so it is this actor's own to remove.
659
+ fs.rmSync(tx, { force: true });
660
+ throw error;
661
+ } finally {
662
+ fs.closeSync(fd);
663
+ }
664
+ }
665
+ if (attempt >= TX_ATTEMPTS) {
666
+ throw refuse(
667
+ "tx-held",
668
+ `${tx} is held (${txEvidence(tx)}); a tx this fence did not create is never removed, taken over or aged out`,
669
+ );
670
+ }
671
+ await sleep(TX_DELAY_MS);
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Unlink the tx this actor created, and only that one. A failure is reported; it does not prove the file remains (it
677
+ * may be gone, or another actor's), so the report says what a remaining file does rather than that one remains.
678
+ */
679
+ function leaveTx(tx: string, held: TxHold, during: unknown): void {
680
+ try {
681
+ const now = fs.lstatSync(tx);
682
+ if (now.dev !== held.dev || now.ino !== held.ino) {
683
+ throw new Error("the transaction file is no longer the one this owner created");
684
+ }
685
+ fs.unlinkSync(tx);
686
+ } catch (error) {
687
+ throw refuse(
688
+ "tx-release-failed",
689
+ `${tx}: ${messageOf(error)}${during === undefined ? "" : `; the step had already failed: ${messageOf(during)}`}; if that transaction file remains, it fails closed until an operator removes it`,
690
+ );
691
+ }
692
+ }
693
+
694
+ /** One S_D hold around a synchronous decision read and write. */
695
+ async function withTx<T>(resource: string, step: () => T): Promise<T> {
696
+ const tx = path.join(resource, PI_DURABLE_OWNER_TX);
697
+ const held = await enterTx(tx);
698
+ let stepError: unknown;
699
+ try {
700
+ return step();
701
+ } catch (error) {
702
+ stepError = error;
703
+ throw error;
704
+ } finally {
705
+ leaveTx(tx, held, stepError);
706
+ }
707
+ }
708
+
709
+ function newGeneration(): string {
710
+ return randomBytes(16).toString("hex");
711
+ }
712
+
713
+ function boundedCause(cause: string | undefined): { cause?: string } {
714
+ return cause === undefined ? {} : { cause: cause.slice(0, CAUSE_MAX) };
715
+ }
716
+
717
+ // ── Admission ────────────────────────────────────────────────────────────────────────────────────
718
+
719
+ function requireAdmission(request: unknown): PiDurableOwnerAdmission {
720
+ if (
721
+ !isObject(request) ||
722
+ !nonEmpty(request.directory) ||
723
+ !path.isAbsolute(request.directory) ||
724
+ !nonEmpty(request.selectedN) ||
725
+ typeof request.created !== "boolean" ||
726
+ !nonEmpty(request.profile) ||
727
+ !path.isAbsolute(request.profile)
728
+ ) {
729
+ throw refuse(
730
+ "admission-invalid",
731
+ "an admission names an absolute directory, its session id, whether it was created and the absolute profile it was joined from",
732
+ );
733
+ }
734
+ return request as unknown as PiDurableOwnerAdmission;
735
+ }
736
+
737
+ function thisOwner(): PiDurableOwner {
738
+ const pid = process.pid;
739
+ if (!isPlausibleOwnerPid(pid)) throw refuse("owner-pid-implausible", `pid ${pid} cannot own a session`);
740
+ const startKey = processStartKey(pid);
741
+ if (startKey === "") throw refuse("owner-start-key-unreadable", `the start key of pid ${pid} cannot be read`);
742
+ return { pid, startKey, hostname: os.hostname() };
743
+ }
744
+
745
+ /**
746
+ * The admission table, inside the S_D hold, after D is chosen and before `lockfile.lock`:
747
+ * absent + created by this call → fenced, from "fresh"
748
+ * absent + D existed, or `closed` → `${D}.lock` present (any age): legacy-lease-present, nothing
749
+ * written; absent: fenced (a `closed` claim's firstParticipation kept)
750
+ * resource ≠ realpath(D) → claim-resource-mismatch, never read as absent
751
+ * an owner on another host → owner-other-host
752
+ * an owner live or uncertain (this pid too)→ owner-live / owner-uncertain
753
+ * a dead owner, unattributed lease → `${D}.lock` present: lease-unattributed; absent: reclaim
754
+ * a dead owner, attributed lease → reclaim (the library's stale takeover is admissible only here)
755
+ * a dead reservation's parent → as a dead owner, its lease attributed only by its donor's snapshot
756
+ * and only for that snapshot's own selection
757
+ * A differing `selected` (another N or another spelling of D) with an equal resource is an alias, judged by the
758
+ * rows above.
759
+ */
760
+ async function admitOwner(request: PiDurableOwnerAdmission): Promise<PiDurableOwnerTicket> {
761
+ const { directory, selectedN, created, profile } = requireAdmission(request);
762
+ let resource: string;
763
+ try {
764
+ resource = fs.realpathSync(directory);
765
+ if (!fs.statSync(resource).isDirectory()) throw new Error("not a directory");
766
+ } catch (error) {
767
+ throw refuse("directory-invalid", `${directory}: ${messageOf(error)}`);
768
+ }
769
+ const owner = thisOwner();
770
+ const written = await withTx(resource, () => {
771
+ const read = readClaim(resource);
772
+ if (read !== undefined && read.resource !== resource) {
773
+ throw refuse("claim-resource-mismatch", `the claim in ${directory} names ${read.resource}, not ${resource}`);
774
+ }
775
+ // Only a claim for THIS resource is judged below; a mismatched one was refused above, never read as absent.
776
+ const claim = read;
777
+ const at = new Date().toISOString();
778
+ const generation = newGeneration();
779
+ const fenced = (
780
+ firstParticipation: PiDurableOwnerClaim["firstParticipation"],
781
+ reclaimedFrom?: PiDurableOwnerClaim["reclaimedFrom"],
782
+ ): PiDurableOwnerClaim => ({
783
+ schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
784
+ resource,
785
+ selected: { n: selectedN, directory, profile },
786
+ state: "fenced",
787
+ owner,
788
+ generation,
789
+ firstParticipation,
790
+ ...(reclaimedFrom === undefined ? {} : { reclaimedFrom }),
791
+ lease: "never-held",
792
+ writtenAt: at,
793
+ });
794
+ // `selected` is never compared: an alias of D (another N, another spelling) is judged by the same rows.
795
+ if (claim === undefined || claim.state === "closed") {
796
+ // An unarmed or ended D: a lease seen here is nobody this fence can name, so its stale takeover is never invoked.
797
+ if ((claim !== undefined || !created) && leasePresent(directory)) {
798
+ throw refuse("legacy-lease-present", `${directory}.lock exists while no participating owner holds it`);
799
+ }
800
+ const from = created ? "fresh" : "unarmed-existing";
801
+ const body = fenced(claim?.firstParticipation ?? { at, generation, from });
802
+ writeClaim(resource, body);
803
+ return body;
804
+ }
805
+ if (claim.owner.hostname !== owner.hostname) {
806
+ throw refuse("owner-other-host", `${directory} is claimed by pid ${claim.owner.pid} on ${claim.owner.hostname}`);
807
+ }
808
+ const verdict = classifyMarkerOwner(claim.owner.startKey, {
809
+ currentStartKey: processStartKey(claim.owner.pid),
810
+ pidExists: probePidExistence(claim.owner.pid),
811
+ });
812
+ const proven = verdict === "dead";
813
+ if (!proven) {
814
+ throw refuse(
815
+ verdict === "live" ? "owner-live" : "owner-uncertain",
816
+ `${directory} is ${claim.state} under pid ${claim.owner.pid}, which is ${verdict}`,
817
+ );
818
+ }
819
+ // A dead owner whose recorded lease is attributable to it: held, or held and its release never observed.
820
+ const attributed = claim.lease === "held" || claim.lease === "unknown";
821
+ // A dead reservation's parent never held a lease: only its donor's snapshot attributes one, and only to the very
822
+ // selection that snapshot was taken on; another spelling's sidecar is not that evidence (#157 B3).
823
+ const snapshot = claim.reservation;
824
+ const reservedAttributed =
825
+ snapshot?.nativeLease === "present-attributed" && snapshot.from.selected.directory === directory;
826
+ if (!(attributed || reservedAttributed) && leasePresent(directory)) {
827
+ throw refuse(
828
+ "lease-unattributed",
829
+ `${directory}.lock exists, and the dead owner's claim (${claim.state}, lease ${claim.lease}) does not make it that owner's`,
830
+ );
831
+ }
832
+ const body = fenced(claim.firstParticipation, {
833
+ generation: claim.generation,
834
+ state: claim.state,
835
+ owner: claim.owner,
836
+ leaseOutcome: claim.lease,
837
+ });
838
+ writeClaim(resource, body);
839
+ return body;
840
+ });
841
+ return new OwnerTicket(resource, written);
842
+ }
843
+
844
+ // ── An admitted owner's transitions ──────────────────────────────────────────────────────────────
845
+
846
+ const UNLEASED_END: PiDurableOwnerEnd = { storage: "never-opened", lease: "never-held" };
847
+
848
+ function requireEnd(end: unknown): PiDurableOwnerEnd {
849
+ if (
850
+ !isObject(end) ||
851
+ !hasExactKeys(end, ["storage", "lease"], ["envCleanup", "cause"]) ||
852
+ !STORAGE_ENDS.includes(end.storage as PiDurableStorageEnd) ||
853
+ !LEASES.includes(end.lease as PiDurableLeaseOutcome) ||
854
+ (end.envCleanup !== undefined && end.envCleanup !== "failed") ||
855
+ (end.cause !== undefined && typeof end.cause !== "string")
856
+ ) {
857
+ throw refuse("transition-invalid", "an end is {storage, lease, envCleanup?, cause?}");
858
+ }
859
+ return end as unknown as PiDurableOwnerEnd;
860
+ }
861
+
862
+ class OwnerTicket implements PiDurableOwnerTicket {
863
+ storageOpenAttempted = false;
864
+ readonly #resource: string;
865
+ /** The body this owner last wrote; its generation is what the next transition requires. */
866
+ #claim: PiDurableOwnerClaim;
867
+ #ended = false;
868
+
869
+ constructor(resource: string, claim: PiDurableOwnerClaim) {
870
+ this.#resource = resource;
871
+ this.#claim = claim;
872
+ }
873
+
874
+ async leased(): Promise<void> {
875
+ await this.#write(["fenced"], "leased", "held", undefined);
876
+ }
877
+
878
+ async storageOpened(): Promise<void> {
879
+ if (!this.storageOpenAttempted) {
880
+ throw refuse("transition-invalid", "storage-open without an attempted storage open");
881
+ }
882
+ await this.#write(["leased"], "storage-open", "held", undefined);
883
+ }
884
+
885
+ async endUnleased(): Promise<void> {
886
+ await this.#end(["fenced"], "closed", UNLEASED_END);
887
+ }
888
+
889
+ async closed(end: PiDurableOwnerEnd): Promise<void> {
890
+ const checked = this.#checkEnd(end);
891
+ if (checked.storage === "closed" && this.#claim.state !== "storage-open") {
892
+ throw refuse("transition-invalid", "a storage close is recorded only for an opened storage");
893
+ }
894
+ await this.#end(["leased", "storage-open"], "closed", checked);
895
+ }
896
+
897
+ async closeFailed(end: PiDurableOwnerEnd): Promise<void> {
898
+ await this.#end(["leased", "storage-open"], "close-failed", this.#checkEnd(end));
899
+ }
900
+
901
+ #checkEnd(end: PiDurableOwnerEnd): PiDurableOwnerEnd {
902
+ const checked = requireEnd(end);
903
+ if (checked.storage === "never-opened" && this.storageOpenAttempted) {
904
+ throw refuse("transition-invalid", "the storage open was attempted, so this end is not never-opened");
905
+ }
906
+ return checked;
907
+ }
908
+
909
+ async #end(
910
+ from: readonly PiDurableOwnerState[],
911
+ to: "closed" | "close-failed",
912
+ end: PiDurableOwnerEnd,
913
+ ): Promise<void> {
914
+ await this.#write(from, to, end.lease, end);
915
+ this.#ended = true;
916
+ }
917
+
918
+ async #write(
919
+ from: readonly PiDurableOwnerState[],
920
+ to: PiDurableOwnerState,
921
+ lease: PiDurableLeaseOutcome,
922
+ end: PiDurableOwnerEnd | undefined,
923
+ ): Promise<void> {
924
+ if (this.#ended) throw refuse("transition-invalid", "this owner's claim has already ended");
925
+ if (!from.includes(this.#claim.state)) {
926
+ throw refuse("transition-invalid", `${this.#claim.state} → ${to} is not a transition`);
927
+ }
928
+ const last = this.#claim;
929
+ this.#claim = await withTx(this.#resource, () => {
930
+ const current = readClaim(this.#resource);
931
+ if (current === undefined) {
932
+ throw refuse("claim-missing", `the claim this owner wrote in ${this.#resource} is gone`);
933
+ }
934
+ if (current.generation !== last.generation) {
935
+ throw refuse(
936
+ "generation-mismatch",
937
+ `the claim in ${this.#resource} is generation ${current.generation}, not ${last.generation} this owner wrote`,
938
+ );
939
+ }
940
+ const at = new Date().toISOString();
941
+ const body: PiDurableOwnerClaim = {
942
+ schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
943
+ resource: last.resource,
944
+ selected: last.selected,
945
+ state: to,
946
+ owner: last.owner,
947
+ generation: newGeneration(),
948
+ firstParticipation: last.firstParticipation,
949
+ ...(last.adoptedFrom === undefined ? {} : { adoptedFrom: last.adoptedFrom }),
950
+ lease,
951
+ ...(end === undefined
952
+ ? {}
953
+ : {
954
+ end: {
955
+ at,
956
+ storage: end.storage,
957
+ lease: end.lease,
958
+ ...(end.envCleanup === undefined ? {} : { envCleanup: end.envCleanup }),
959
+ ...boundedCause(end.cause),
960
+ },
961
+ }),
962
+ writtenAt: at,
963
+ };
964
+ writeClaim(this.#resource, body);
965
+ return body;
966
+ });
967
+ }
968
+ }
969
+
970
+ // ── Reservation, adoption and withdrawal (#157 B3 I-2) ─────────────────────────────────────────────
971
+
972
+ /** The four meta roots one reservation addresses: the standard children of ONE meta root, never a split shape. */
973
+ export interface PiDurableMetaRoots {
974
+ readonly sessionsDir: string;
975
+ readonly mailboxDir: string;
976
+ readonly sendersDir: string;
977
+ readonly receiversDir: string;
978
+ }
979
+
980
+ const META_ROOT_CHILDREN: Readonly<Record<keyof PiDurableMetaRoots, string>> = {
981
+ sessionsDir: "meta-sessions",
982
+ mailboxDir: "meta-mailbox",
983
+ sendersDir: "meta-senders",
984
+ receiversDir: "meta-receivers",
985
+ };
986
+
987
+ function standardRoots(metaRoot: string): PiDurableMetaRoots {
988
+ return {
989
+ sessionsDir: path.join(metaRoot, META_ROOT_CHILDREN.sessionsDir),
990
+ mailboxDir: path.join(metaRoot, META_ROOT_CHILDREN.mailboxDir),
991
+ sendersDir: path.join(metaRoot, META_ROOT_CHILDREN.sendersDir),
992
+ receiversDir: path.join(metaRoot, META_ROOT_CHILDREN.receiversDir),
993
+ };
994
+ }
995
+
996
+ /** ONE meta root whose standard children are exactly the four roots given; any other shape is refused, never merged. */
997
+ function requireMetaRoot(roots: unknown): string {
998
+ const fields = Object.keys(META_ROOT_CHILDREN) as (keyof PiDurableMetaRoots)[];
999
+ const absolute = (field: keyof PiDurableMetaRoots): boolean => {
1000
+ const root = isObject(roots) ? roots[field] : undefined;
1001
+ return nonEmpty(root) && path.isAbsolute(root);
1002
+ };
1003
+ if (!fields.every(absolute)) throw refuse("reservation-invalid", "a reservation names four absolute meta roots");
1004
+ const given = roots as PiDurableMetaRoots;
1005
+ const metaRoot = path.dirname(given.sessionsDir);
1006
+ const standard = standardRoots(metaRoot);
1007
+ const split = fields.filter((field) => given[field] !== standard[field]);
1008
+ if (split.length > 0) {
1009
+ throw refuse("reserve-meta-root-split", `${split.join(", ")}: not the standard children of ${metaRoot}`);
1010
+ }
1011
+ return metaRoot;
1012
+ }
1013
+
1014
+ function requireReservationGardenId(value: unknown): string {
1015
+ try {
1016
+ return requireGardenId(value);
1017
+ } catch (error) {
1018
+ throw refuse("reservation-invalid", messageOf(error));
1019
+ }
1020
+ }
1021
+
1022
+ interface CheckedAdoption extends PiDurableAdoption {
1023
+ readonly roots: PiDurableMetaRoots;
1024
+ }
1025
+
1026
+ function requireAdoption(value: unknown): CheckedAdoption {
1027
+ if (
1028
+ !isObject(value) ||
1029
+ !hasExactKeys(value, ["gardenId", "metaRoot", "sourceGeneration"]) ||
1030
+ !nonEmpty(value.metaRoot) ||
1031
+ !path.isAbsolute(value.metaRoot) ||
1032
+ typeof value.sourceGeneration !== "string" ||
1033
+ !HEX32.test(value.sourceGeneration)
1034
+ ) {
1035
+ throw refuse("reservation-invalid", "an adoption is exactly {gardenId, metaRoot, sourceGeneration}");
1036
+ }
1037
+ const metaRoot = value.metaRoot as string;
1038
+ return {
1039
+ gardenId: requireReservationGardenId(value.gardenId),
1040
+ metaRoot,
1041
+ sourceGeneration: value.sourceGeneration as string,
1042
+ roots: standardRoots(metaRoot),
1043
+ };
1044
+ }
1045
+
1046
+ /** Takes the record-arm lock of `gardenId` at once; a busy lock refuses with nothing read or written. */
1047
+ function takeRecordArm(gardenId: string, roots: PiDurableMetaRoots): LockClaim {
1048
+ const acquired = acquireLock(gardenId, { dir: defaultMetaRecordArmLocksDir(roots.receiversDir) });
1049
+ if (!acquired.ok) throw refuse("record-arm-busy", `${acquired.conflict.detail}; nothing was read or written`);
1050
+ return acquired.claim;
1051
+ }
1052
+
1053
+ /**
1054
+ * Releases the record-arm lock, after S_D. Returns what the caller throws: the step's own error when the release
1055
+ * answered `released`, else a named release failure carrying it, and the write that step had committed if it names
1056
+ * one. A clean step whose release answered returns undefined.
1057
+ */
1058
+ function leaveRecordArm(arm: LockClaim, during: unknown): unknown {
1059
+ let released: string;
1060
+ try {
1061
+ released = releaseLock(arm);
1062
+ } catch (error) {
1063
+ released = `threw: ${messageOf(error)}`;
1064
+ }
1065
+ if (released === "released") return during;
1066
+ const failed = during === undefined ? "" : `; the step had already failed: ${messageOf(during)}`;
1067
+ const written = during instanceof PiDurableOwnerFenceError ? during.written : undefined;
1068
+ return refuse(
1069
+ "record-arm-release-failed",
1070
+ `the record-arm lock of ${arm.gardenId} answered ${released}${failed}`,
1071
+ written === undefined ? undefined : { written },
1072
+ );
1073
+ }
1074
+
1075
+ type Pair = { readonly record: MetaIdentity; readonly location: PiDurableLocation };
1076
+
1077
+ /** The record and C4 pair read NOW, inside the caller's arm hold (P1), or a named refusal. */
1078
+ function freshPair(gardenId: string, roots: PiDurableMetaRoots): Pair {
1079
+ const { sessionsDir, receiversDir } = roots;
1080
+ const verdict = verifyPiDurableLocation({ gardenId, sessionsDir, receiversDir });
1081
+ if (!verdict.ok) {
1082
+ const reason = verdict.reason === "binding-mismatch" ? "binding-mismatch" : "profile-record-unbound";
1083
+ throw refuse(reason, `${gardenId}: ${verdict.reason}: ${verdict.detail}`);
1084
+ }
1085
+ return { record: verdict.record, location: verdict.location };
1086
+ }
1087
+
1088
+ /** The current holder's liveness by the shared death policy; only `dead` lets a reserve take D over. */
1089
+ function holderVerdict(holder: PiDurableOwner): ReturnType<typeof classifyMarkerOwner> {
1090
+ return classifyMarkerOwner(holder.startKey, {
1091
+ currentStartKey: processStartKey(holder.pid),
1092
+ pidExists: probePidExistence(holder.pid),
1093
+ });
1094
+ }
1095
+
1096
+ /** Reserve rows 3–4: the current holder of a non-closed claim, a reservation's parent included; its host first. */
1097
+ function requireHolderGone(claim: PiDurableOwnerClaim, resource: string, hostname: string): void {
1098
+ const where = `${resource} is ${claim.state} under pid ${claim.owner.pid} on ${claim.owner.hostname}`;
1099
+ const otherHost = claim.owner.hostname !== hostname;
1100
+ const verdict = otherHost ? "other-host" : holderVerdict(claim.owner);
1101
+ // The holder's state rides beside the reason: a live reservation parent is not a running citizen (S3 joint review D4).
1102
+ const context = { holderState: claim.state };
1103
+ if (verdict === "other-host") throw refuse("owner-other-host", where, context);
1104
+ if (verdict !== "dead") {
1105
+ throw refuse(verdict === "live" ? "owner-live" : "owner-uncertain", `${where}: ${verdict}`, context);
1106
+ }
1107
+ }
1108
+
1109
+ /** The reservation a `reserved` claim carries; the reader guarantees one. */
1110
+ function reservationOf(claim: PiDurableOwnerClaim): PiDurableReservationBody {
1111
+ if (claim.reservation === undefined) {
1112
+ throw refuse("claim-unreadable", `the ${claim.state} claim in ${claim.resource} carries no reservation`);
1113
+ }
1114
+ return claim.reservation;
1115
+ }
1116
+
1117
+ /** The native donor: a `reserved` claim's flat `from`, or a native claim's own row. Never a reservation's parent. */
1118
+ function donorOf(claim: PiDurableOwnerClaim): PiDurableDonor {
1119
+ if (claim.state === "reserved") return reservationOf(claim).from;
1120
+ return {
1121
+ generation: claim.generation,
1122
+ state: claim.state,
1123
+ owner: claim.owner,
1124
+ lease: claim.lease,
1125
+ ...(claim.end === undefined ? {} : { end: claim.end }),
1126
+ selected: claim.selected,
1127
+ };
1128
+ }
1129
+
1130
+ function reclaimOf(claim: PiDurableOwnerClaim): NonNullable<PiDurableOwnerClaim["reclaimedFrom"]> {
1131
+ return { generation: claim.generation, state: claim.state, owner: claim.owner, leaseOutcome: claim.lease };
1132
+ }
1133
+
1134
+ /**
1135
+ * P2–P6 on the native donor, on the pair read in this hold (P1); P2 is the claim's resource row before this. The donor
1136
+ * is the incarnation C4's binding names, on this host; its selection is the record's session, joined from its own raw
1137
+ * profile, and still reaches R. Returns that profile. A schema 1 donor recorded none, and none is ever inferred.
1138
+ */
1139
+ function requireProfile(donor: PiDurableDonor, pair: Pair, resource: string): string {
1140
+ const { record, location } = pair;
1141
+ const { owner, selected } = donor;
1142
+ if (owner.hostname !== os.hostname()) throw refuse("profile-other-host", `the donor was owned on ${owner.hostname}`);
1143
+ if (owner.pid !== location.ownerPid || owner.startKey !== location.ownerStartKey) {
1144
+ throw refuse("profile-unattributed", `pid ${owner.pid} is not the incarnation ${record.gardenId}'s binding names`);
1145
+ }
1146
+ if (selected.n !== record.nativeSessionId) {
1147
+ throw refuse("profile-n-mismatch", `the donor selected ${selected.n}, the record names ${record.nativeSessionId}`);
1148
+ }
1149
+ const recorded = selected.profile;
1150
+ if (!nonEmpty(recorded)) throw refuse("profile-unattributed", "the donor's schema 1 claim records no profile");
1151
+ const cwdHash = createHash("sha256").update(record.cwd).digest("hex").slice(0, 24);
1152
+ if (path.join(recorded, "experimental", "durable-sessions", cwdHash, selected.n) !== selected.directory) {
1153
+ throw refuse("profile-layout-mismatch", `${selected.directory} is not joined from ${recorded} and ${record.cwd}`);
1154
+ }
1155
+ let real: string;
1156
+ try {
1157
+ real = fs.realpathSync(selected.directory);
1158
+ } catch (error) {
1159
+ throw refuse("profile-alias-moved", `${selected.directory}: ${messageOf(error)}`);
1160
+ }
1161
+ if (real !== resource)
1162
+ throw refuse("profile-alias-moved", `${selected.directory} now reaches ${real}, not ${resource}`);
1163
+ return recorded;
1164
+ }
1165
+
1166
+ /** What a parent asks to reserve: the garden id it resolved (the record is the address) and its own four roots. */
1167
+ export interface PiDurableReserveRequest {
1168
+ readonly gardenId: string;
1169
+ readonly roots: PiDurableMetaRoots;
1170
+ }
1171
+
1172
+ /** A taken reservation's coordinates, for its caller's launch, observation and withdrawal. */
1173
+ export interface PiDurableReservation {
1174
+ readonly gardenId: string;
1175
+ readonly metaRoot: string;
1176
+ /** R, the physical D (C4's `nativeDirectory`): the claim and S_D live here. */
1177
+ readonly resource: string;
1178
+ /** g_r: matching data for the child, and the withdrawal's own-generation test. */
1179
+ readonly generation: string;
1180
+ /** The donor's raw profile: the child's SDK is started with exactly this value. */
1181
+ readonly profile: string;
1182
+ /** The record's cwd: the launch's cwd. */
1183
+ readonly cwd: string;
1184
+ readonly owner: PiDurableOwner;
1185
+ }
1186
+
1187
+ /**
1188
+ * A parent's reservation of one pi-durable citizen's D for one child (#157 B3): record-arm(gid), then S_D(R), on the
1189
+ * record and C4 pair read inside the arm hold. Both are released before this returns, so before any launch; the
1190
+ * caller's own per-target lock, if it holds one, stays its outer scope and is never taken here. An eligibility
1191
+ * refusal writes nothing. A release that fails AFTER the reservation was written is a different failure: the
1192
+ * reservation exists, and the error names it in `written` (its reason and message stay the release's own). Either
1193
+ * way its caller must not launch, and nothing here withdraws, retries or repairs.
1194
+ */
1195
+ export async function reservePiDurableSession(request: PiDurableReserveRequest): Promise<PiDurableReservation> {
1196
+ if (!isObject(request)) throw refuse("reservation-invalid", "a reserve names a garden id and four meta roots");
1197
+ const gardenId = requireReservationGardenId(request.gardenId);
1198
+ const metaRoot = requireMetaRoot(request.roots);
1199
+ const roots = standardRoots(metaRoot);
1200
+ const owner = thisOwner();
1201
+ const arm = takeRecordArm(gardenId, roots);
1202
+ let reserved: PiDurableReservation;
1203
+ try {
1204
+ reserved = await reserveInPair(gardenId, roots, metaRoot, owner);
1205
+ } catch (error) {
1206
+ throw leaveRecordArm(arm, error);
1207
+ }
1208
+ const failure = leaveRecordArm(arm, undefined);
1209
+ if (failure !== undefined) {
1210
+ const { resource, generation } = reserved;
1211
+ throw afterWrite(failure, { resource, state: "reserved", generation, ownerPid: owner.pid });
1212
+ }
1213
+ return reserved;
1214
+ }
1215
+
1216
+ /** The reserve's rows and write, inside S_D(R), on the pair read in this arm hold. Rows decide before P-rules. */
1217
+ async function reserveInPair(
1218
+ gardenId: string,
1219
+ roots: PiDurableMetaRoots,
1220
+ metaRoot: string,
1221
+ owner: PiDurableOwner,
1222
+ ): Promise<PiDurableReservation> {
1223
+ const pair = freshPair(gardenId, roots);
1224
+ const { record, location } = pair;
1225
+ const resource = location.nativeDirectory;
1226
+ // Set once the write returned: a release failing after it must name the reservation it left (joint review D3).
1227
+ let committed: PiDurableWrittenClaim | undefined;
1228
+ const written = await withTx(resource, () => {
1229
+ const claim = readClaim(resource);
1230
+ if (claim === undefined) throw refuse("reserve-unarmed", `${resource} holds no claim; a reserve never enrolls`);
1231
+ if (claim.resource !== resource) {
1232
+ throw refuse("claim-resource-mismatch", `the claim in ${resource} names ${claim.resource}`);
1233
+ }
1234
+ if (claim.state !== "closed") requireHolderGone(claim, resource, owner.hostname);
1235
+ if (claim.state === "closed" && claim.end?.storage !== "closed") {
1236
+ throw refuse("reserve-not-clean", `${resource} closed without its storage ever opening`);
1237
+ }
1238
+ // P1–P6 judge the native donor: a dead reservation's flat `from`, never its parent.
1239
+ const donor = donorOf(claim);
1240
+ const profile = requireProfile(donor, pair, resource);
1241
+ // The snapshot is taken on S, the donor's own selection: the SDK's lease is `${S}.lock`, never `${R}.lock`.
1242
+ const sidecar = leasePresent(donor.selected.directory);
1243
+ let nativeLease: PiDurableNativeLease = "absent";
1244
+ if (claim.state === "closed") {
1245
+ if (sidecar) throw refuse("legacy-lease-present", `${donor.selected.directory}.lock exists after a clean close`);
1246
+ } else if (claim.state === "reserved") {
1247
+ const before = reservationOf(claim).nativeLease;
1248
+ if (before === "absent" && sidecar) {
1249
+ throw refuse("lease-appeared", `${donor.selected.directory}.lock appeared after its reservation saw none`);
1250
+ }
1251
+ if (before === "present-attributed" && sidecar) nativeLease = "present-attributed";
1252
+ } else if (leaseAttributed(claim.lease)) {
1253
+ if (sidecar) nativeLease = "present-attributed";
1254
+ } else if (sidecar) {
1255
+ throw refuse("lease-unattributed", `${donor.selected.directory}.lock exists, and the dead owner never held it`);
1256
+ }
1257
+ const body: PiDurableOwnerClaim = {
1258
+ schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
1259
+ resource,
1260
+ selected: donor.selected,
1261
+ state: "reserved",
1262
+ owner,
1263
+ generation: newGeneration(),
1264
+ firstParticipation: claim.firstParticipation,
1265
+ ...(claim.state === "closed" ? {} : { reclaimedFrom: reclaimOf(claim) }),
1266
+ reservation: {
1267
+ gardenId: record.gardenId,
1268
+ n: record.nativeSessionId,
1269
+ cwd: record.cwd,
1270
+ resource,
1271
+ profile,
1272
+ attachIncarnation: location.attachIncarnation,
1273
+ nativeLease,
1274
+ from: donor,
1275
+ },
1276
+ lease: "never-held",
1277
+ writtenAt: new Date().toISOString(),
1278
+ };
1279
+ writeClaim(resource, body);
1280
+ committed = writtenOf(body);
1281
+ return body;
1282
+ }).catch((error: unknown) => {
1283
+ throw afterWrite(error, committed);
1284
+ });
1285
+ return {
1286
+ gardenId: record.gardenId,
1287
+ metaRoot,
1288
+ resource,
1289
+ generation: written.generation,
1290
+ profile: reservationOf(written).profile,
1291
+ cwd: record.cwd,
1292
+ owner,
1293
+ };
1294
+ }
1295
+
1296
+ /**
1297
+ * A launched child's adoption of exactly one reservation (#157 B3), inside record-arm(gid) then S_D(R), never the
1298
+ * parent's per-target lock, on the pair read in this hold. Every row must hold; a refusal writes nothing and takes no
1299
+ * lease. The admission's directory is resolved inside S_D, never before it. The ticket returns only after both locks
1300
+ * are released, so the native lease and storage come after them.
1301
+ */
1302
+ async function adoptOwner(request: PiDurableOwnerAdmission, adoption: CheckedAdoption): Promise<PiDurableOwnerTicket> {
1303
+ const admission = requireAdmission(request);
1304
+ const owner = thisOwner();
1305
+ const arm = takeRecordArm(adoption.gardenId, adoption.roots);
1306
+ let adopted: PiDurableOwnerClaim;
1307
+ try {
1308
+ adopted = await adoptInPair(admission, adoption, owner);
1309
+ } catch (error) {
1310
+ throw leaveRecordArm(arm, error);
1311
+ }
1312
+ const failure = leaveRecordArm(arm, undefined);
1313
+ if (failure !== undefined) throw failure;
1314
+ return new OwnerTicket(adopted.resource, adopted);
1315
+ }
1316
+
1317
+ /** The adoption's rows and write, inside S_D(R), on the pair read in this arm hold. */
1318
+ async function adoptInPair(
1319
+ admission: PiDurableOwnerAdmission,
1320
+ adoption: CheckedAdoption,
1321
+ owner: PiDurableOwner,
1322
+ ): Promise<PiDurableOwnerClaim> {
1323
+ const { record, location } = freshPair(adoption.gardenId, adoption.roots);
1324
+ const resource = location.nativeDirectory;
1325
+ const { directory, selectedN, profile } = admission;
1326
+ return withTx(resource, () => {
1327
+ const claim = readClaim(resource);
1328
+ if (claim !== undefined && claim.resource !== resource) {
1329
+ throw refuse("claim-resource-mismatch", `the claim in ${resource} names ${claim.resource}`);
1330
+ }
1331
+ if (claim === undefined || claim.state !== "reserved") {
1332
+ throw refuse("adopt-not-reserved", `${resource} holds no reservation to adopt`);
1333
+ }
1334
+ const reservation = reservationOf(claim);
1335
+ if (claim.generation !== adoption.sourceGeneration) {
1336
+ throw refuse(
1337
+ "adopt-generation-mismatch",
1338
+ `${resource} is at ${claim.generation}, not ${adoption.sourceGeneration}`,
1339
+ );
1340
+ }
1341
+ if (record.attachIncarnation !== reservation.attachIncarnation) {
1342
+ throw refuse("adopt-binding-changed", `${record.gardenId} was bound again after its reservation`);
1343
+ }
1344
+ // D as the admission names it NOW, in this hold: a path resolved before S_D is not this decision's evidence.
1345
+ let real: string;
1346
+ try {
1347
+ real = fs.realpathSync(directory);
1348
+ } catch (error) {
1349
+ throw refuse("directory-invalid", `${directory}: ${messageOf(error)}`);
1350
+ }
1351
+ const expected = [adoption.gardenId, record.nativeSessionId, record.cwd, real];
1352
+ const named = [reservation.gardenId, reservation.n, reservation.cwd, reservation.resource];
1353
+ if (expected.some((value, index) => value !== named[index])) {
1354
+ throw refuse(
1355
+ "adopt-reservation-mismatch",
1356
+ `the reservation names ${named.join(", ")}, not ${expected.join(", ")}`,
1357
+ );
1358
+ }
1359
+ const cwdHash = createHash("sha256").update(record.cwd).digest("hex").slice(0, 24);
1360
+ const joined = path.join(profile, "experimental", "durable-sessions", cwdHash, record.nativeSessionId);
1361
+ if (profile !== reservation.profile || directory !== claim.selected.directory || joined !== directory) {
1362
+ throw refuse("adopt-profile-mismatch", `${directory} with profile ${profile} is not the reserved selection`);
1363
+ }
1364
+ if (claim.owner.hostname !== owner.hostname) {
1365
+ throw refuse("owner-other-host", `the reservation in ${resource} was taken on ${claim.owner.hostname}`);
1366
+ }
1367
+ if (reservation.nativeLease === "absent" && leasePresent(directory)) {
1368
+ throw refuse("adopt-lease-appeared", `${directory}.lock appeared after the reservation saw none`);
1369
+ }
1370
+ const body: PiDurableOwnerClaim = {
1371
+ schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
1372
+ resource,
1373
+ selected: { n: selectedN, directory, profile },
1374
+ state: "fenced",
1375
+ owner,
1376
+ generation: newGeneration(),
1377
+ firstParticipation: claim.firstParticipation,
1378
+ adoptedFrom: { generation: claim.generation, owner: claim.owner },
1379
+ lease: "never-held",
1380
+ writtenAt: new Date().toISOString(),
1381
+ };
1382
+ writeClaim(resource, body);
1383
+ return body;
1384
+ });
1385
+ }
1386
+
1387
+ function sameOwner(a: PiDurableOwner, b: PiDurableOwner): boolean {
1388
+ return a.pid === b.pid && a.startKey === b.startKey && a.hostname === b.hostname;
1389
+ }
1390
+
1391
+ /**
1392
+ * The parent's withdrawal of its OWN reservation, once its launch is definitely none-created (its caller's judgement
1393
+ * from the rail's own outcome). One S_D hold and no record or C4 read: only `reserved` + this owner + exactly g_r is
1394
+ * withdrawn, back to the donor's own row under a new generation. Nothing of the reservation, the reserve's reclaim or
1395
+ * any earlier history is carried; anything else writes nothing and refuses `withdraw-not-own`. A release that fails
1396
+ * after the donor's row was written names that row in `written` (joint review D1): the reservation is gone then, and
1397
+ * only the release's residue is left.
1398
+ */
1399
+ export async function withdrawPiDurableReservation(reservation: PiDurableReservation): Promise<void> {
1400
+ const owner = thisOwner();
1401
+ const resource = reservation.resource;
1402
+ let committed: PiDurableWrittenClaim | undefined;
1403
+ await withTx(resource, () => {
1404
+ const claim = readClaim(resource);
1405
+ if (claim?.state !== "reserved") throw refuse("withdraw-not-own", `${resource} is not reserved`);
1406
+ if (claim.generation !== reservation.generation) {
1407
+ throw refuse("withdraw-not-own", `${resource} is at ${claim.generation}, not ${reservation.generation}`);
1408
+ }
1409
+ if (!sameOwner(claim.owner, owner)) {
1410
+ throw refuse("withdraw-not-own", `${resource} is reserved by pid ${claim.owner.pid}, not this process`);
1411
+ }
1412
+ const donor = reservationOf(claim).from;
1413
+ const body: PiDurableOwnerClaim = {
1414
+ schemaVersion: PI_DURABLE_OWNER_SCHEMA_VERSION,
1415
+ resource: claim.resource,
1416
+ selected: donor.selected,
1417
+ state: donor.state,
1418
+ owner: donor.owner,
1419
+ generation: newGeneration(),
1420
+ firstParticipation: claim.firstParticipation,
1421
+ lease: donor.lease,
1422
+ ...(donor.end === undefined ? {} : { end: donor.end }),
1423
+ writtenAt: new Date().toISOString(),
1424
+ };
1425
+ writeClaim(resource, body);
1426
+ committed = writtenOf(body);
1427
+ }).catch((error: unknown) => {
1428
+ throw afterWrite(error, committed);
1429
+ });
1430
+ }
1431
+
1432
+ /** What one reservation's D says now: a read, never a watch. */
1433
+ export type PiDurableReservationObservation =
1434
+ | { readonly class: "adopted" | "not-yet-adopted" | "lost" }
1435
+ | { readonly class: "unobserved"; readonly reason: PiDurableOwnerFenceReason; readonly detail: string };
1436
+
1437
+ /**
1438
+ * `adopted`: any state carrying `adoptedFrom.generation === g_r` (an adoption write, not readiness or success);
1439
+ * `not-yet-adopted`: still `reserved` at g_r; `lost`: any other claim the strict reader accepted. A claim that could
1440
+ * not be read, is gone, or names another resource is `unobserved` by name, never `lost`.
1441
+ */
1442
+ export function observePiDurableReservation(reservation: PiDurableReservation): PiDurableReservationObservation {
1443
+ let claim: PiDurableOwnerClaim | undefined;
1444
+ try {
1445
+ claim = readClaim(reservation.resource);
1446
+ } catch (error) {
1447
+ if (error instanceof PiDurableOwnerFenceError) {
1448
+ return { class: "unobserved", reason: error.reason, detail: error.message };
1449
+ }
1450
+ throw error;
1451
+ }
1452
+ if (claim === undefined) {
1453
+ return { class: "unobserved", reason: "claim-missing", detail: `${reservation.resource} holds no claim` };
1454
+ }
1455
+ if (claim.resource !== reservation.resource) {
1456
+ return { class: "unobserved", reason: "claim-resource-mismatch", detail: `the claim names ${claim.resource}` };
1457
+ }
1458
+ if (claim.adoptedFrom?.generation === reservation.generation) return { class: "adopted" };
1459
+ if (claim.state === "reserved" && claim.generation === reservation.generation) return { class: "not-yet-adopted" };
1460
+ return { class: "lost" };
1461
+ }