@arnilo/prism-coding-agent 0.2.5 → 0.2.7

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.
@@ -9,6 +9,11 @@ import { OutputAccumulator } from "../output-accumulator.js";
9
9
  import { resolveToCwd } from "../path-utils.js";
10
10
  import { killProcessTree } from "../shell.js";
11
11
  import { ProcessSessionError, resolveProcessSessionLimits, } from "./types.js";
12
+ import { acquireRecordLease, attachWithTimeout, buildProcessRecoveryRecord, deleteProcessRecoveryRecord, loadProcessRecoveryRecord, loadProcessRecoveryRecords, PROCESS_RECOVERY_LEASE_NAMESPACE, releaseRecordLease, resolveProcessRecoveryLimits, saveProcessRecoveryRecord, validateBackendRef, } from "./recovery.js";
13
+ import { ProcessRecoveryError } from "./recovery.js";
14
+ import { DEFAULT_MAX_TERMINAL_COLUMNS, DEFAULT_MAX_TERMINAL_ROWS } from "../limits.js";
15
+ /** Default TERM for PTY sessions (validated <= maxTerminalTermBytes). */
16
+ const DEFAULT_TERM = "xterm-256color";
12
17
  function ownershipKey(ownership, identity) {
13
18
  if (ownership) {
14
19
  return `${ownership.tenantId ?? ""}:${ownership.accountId ?? ""}:${ownership.userId ?? ""}`;
@@ -34,6 +39,22 @@ function isInsideRoot(root, target) {
34
39
  function nowIso() {
35
40
  return new Date().toISOString();
36
41
  }
42
+ /**
43
+ * Start-budget for host PTY attachment (frozen cap). Timeout fails closed with
44
+ * ERR_PRISM_PROCESS_PTY_LIMIT; the underlying promise result is discarded.
45
+ */
46
+ function withPtyAttachTimeout(promise, timeoutMs) {
47
+ return new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => reject(new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `PTY attach timed out (${timeoutMs}ms)`)), timeoutMs);
49
+ promise.then((value) => {
50
+ clearTimeout(timer);
51
+ resolve(value);
52
+ }, (error) => {
53
+ clearTimeout(timer);
54
+ reject(error);
55
+ });
56
+ });
57
+ }
37
58
  export function createProcessSessions(options) {
38
59
  const workspace = resolve(options.cwd);
39
60
  const limits = resolveProcessSessionLimits(options.limits);
@@ -41,9 +62,23 @@ export function createProcessSessions(options) {
41
62
  const policy = options.policy;
42
63
  const onEvent = options.onEvent;
43
64
  const sandbox = options.sandbox;
65
+ const ptyBackend = options.ptyBackend;
66
+ const ptyResizeCapable = ptyBackend?.capabilities?.resize === true;
44
67
  const sessions = new Map();
45
68
  let disposed = false;
46
69
  let sandboxLost = false;
70
+ // Durable process recovery (plan 026 Task 5): checkpoints + leases + ownerId
71
+ // activate the seam together; a partial recovery configuration fails closed
72
+ // at construction (no implicit activation, no half-durable state).
73
+ const checkpoints = options.checkpoints;
74
+ const leases = options.leases;
75
+ const ownerId = options.ownerId;
76
+ const recoveryBackend = options.recoveryBackend;
77
+ const recoveryLimits = resolveProcessRecoveryLimits(options.recoveryLimits);
78
+ const durable = checkpoints !== undefined || leases !== undefined || ownerId !== undefined || recoveryBackend !== undefined;
79
+ if (durable && !(checkpoints && leases && ownerId)) {
80
+ throw new ProcessRecoveryError("ERR_PRISM_RECOVERY_UNSUPPORTED", "durable process recovery requires checkpoints, leases, and ownerId together");
81
+ }
47
82
  const emit = (event) => {
48
83
  onEvent?.(event);
49
84
  };
@@ -76,6 +111,21 @@ export function createProcessSessions(options) {
76
111
  reconcileAllUnknown();
77
112
  }
78
113
  };
114
+ const resolveTerminal = (terminal) => {
115
+ const columns = terminal?.columns ?? Math.min(DEFAULT_MAX_TERMINAL_COLUMNS, limits.maxTerminalColumns);
116
+ const rows = terminal?.rows ?? Math.min(DEFAULT_MAX_TERMINAL_ROWS, limits.maxTerminalRows);
117
+ const term = terminal?.term ?? DEFAULT_TERM;
118
+ if (!Number.isSafeInteger(columns) || columns < 1 || columns > limits.maxTerminalColumns) {
119
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `columns must be 1..${limits.maxTerminalColumns}`);
120
+ }
121
+ if (!Number.isSafeInteger(rows) || rows < 1 || rows > limits.maxTerminalRows) {
122
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `rows must be 1..${limits.maxTerminalRows}`);
123
+ }
124
+ if (Buffer.byteLength(term, "utf8") > limits.maxTerminalTermBytes) {
125
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `term exceeds maxTerminalTermBytes (${limits.maxTerminalTermBytes})`);
126
+ }
127
+ return { columns, rows, term };
128
+ };
79
129
  const sweepExpired = () => {
80
130
  const now = Date.now();
81
131
  for (const record of sessions.values()) {
@@ -93,6 +143,187 @@ export function createProcessSessions(options) {
93
143
  for (const resolveWait of waiters)
94
144
  resolveWait(result);
95
145
  };
146
+ // Durable transition write: fire-and-forget CAS (fence/version conflicts mean
147
+ // another replica moved the record first — the newer state wins). The crash
148
+ // window between a terminal transition and its durable write converges on
149
+ // recovery to attach/terminal/unknown, never a duplicate spawn.
150
+ const persistTransition = (record) => {
151
+ if (!durable || record.recoveryVersion === 0)
152
+ return;
153
+ const next = buildProcessRecoveryRecord({
154
+ id: record.id,
155
+ owner: record.owner,
156
+ workspace: record.workspace,
157
+ command: record.command,
158
+ args: record.args,
159
+ commandFingerprint: record.commandFingerprint,
160
+ policyDecision: record.policyDecision,
161
+ startedAt: record.startedAt,
162
+ state: record.state,
163
+ exitCode: record.exitCode,
164
+ releaseOnCancel: record.releaseOnCancel,
165
+ expiresAt: record.expiresAt,
166
+ ...(record.backendRef !== undefined ? { backendRef: record.backendRef } : {}),
167
+ ...(record.ptyTerminal !== undefined ? { pty: record.ptyTerminal } : {}),
168
+ fencingToken: record.recoveryFencingToken,
169
+ });
170
+ const expectedVersion = record.recoveryVersion;
171
+ record.recoveryVersion += 1;
172
+ const write = async () => {
173
+ try {
174
+ await saveProcessRecoveryRecord({
175
+ checkpoints: checkpoints,
176
+ record: next,
177
+ expectedVersion,
178
+ version: record.recoveryVersion,
179
+ ownership: options.ownership,
180
+ });
181
+ }
182
+ catch {
183
+ // stale fence or store failure: the durable record keeps its last state
184
+ return;
185
+ }
186
+ void evictRecoveryOverflow();
187
+ // Terminal transition: release the record lease (clean shutdown makes
188
+ // recovery immediate). Live running/starting transitions renew it so a
189
+ // crashed replica's lease lapses within TTL while a live one stays
190
+ // fenced. Best effort on both.
191
+ if (isTerminalState(record.state)) {
192
+ if (record.recoveryLeaseToken) {
193
+ void releaseRecordLease({
194
+ leases: leases,
195
+ id: record.id,
196
+ ownerId: ownerId,
197
+ token: record.recoveryLeaseToken,
198
+ ownership: options.ownership,
199
+ });
200
+ record.recoveryLeaseToken = undefined;
201
+ }
202
+ }
203
+ else if (record.recoveryLeaseToken) {
204
+ void leases
205
+ .renewLease({
206
+ namespace: PROCESS_RECOVERY_LEASE_NAMESPACE,
207
+ key: `recover:${record.id}`,
208
+ ownerId: ownerId,
209
+ token: record.recoveryLeaseToken,
210
+ ttlMs: recoveryLimits.leaseTtlMs,
211
+ ...options.ownership,
212
+ })
213
+ .catch(() => {
214
+ // lease lapsed or fenced elsewhere; recovery is attestation-gated
215
+ });
216
+ }
217
+ };
218
+ // Per-record chain: transition CAS writes must never invert order on slow
219
+ // stores (a later terminal write landing before the running write would
220
+ // fail its CAS and leave the record running forever).
221
+ record.recoveryWriteChain = (record.recoveryWriteChain ?? Promise.resolve()).then(write, write);
222
+ };
223
+ // Bound durable record growth: after a terminal transition, drop the oldest
224
+ // terminal records until at most maxRecords remain (running/starting records
225
+ // are never evicted). Best effort, bounded work.
226
+ const evictRecoveryOverflow = async () => {
227
+ if (!durable)
228
+ return;
229
+ try {
230
+ const page = await loadProcessRecoveryRecords({
231
+ checkpoints: checkpoints,
232
+ limits: { ...recoveryLimits, maxRecords: recoveryLimits.maxRecords + 1 },
233
+ ownership: options.ownership,
234
+ });
235
+ if (page.records.length <= recoveryLimits.maxRecords)
236
+ return;
237
+ const overflow = page.records.length - recoveryLimits.maxRecords;
238
+ let evicted = 0;
239
+ for (const { record } of [...page.records].reverse()) {
240
+ if (evicted >= overflow)
241
+ break;
242
+ if (record.state === "running" || record.state === "starting")
243
+ continue;
244
+ await deleteProcessRecoveryRecord({ checkpoints: checkpoints, id: record.id, ownership: options.ownership });
245
+ evicted += 1;
246
+ }
247
+ }
248
+ catch {
249
+ // best effort: caps are enforced again on the next transition
250
+ }
251
+ };
252
+ const isTerminalState = (state) => state === "exited" || state === "killed" || state === "released" || state === "expired" || state === "unknown";
253
+ // Atomic starting|running -> unknown (never an exit code). Returns false when
254
+ // a fence/version conflict means another replica moved the record first.
255
+ const persistRecoveryUnknown = async (current, version, signal) => {
256
+ const next = buildProcessRecoveryRecord({ ...current, state: "unknown", exitCode: null, updatedAt: nowIso() });
257
+ try {
258
+ await saveProcessRecoveryRecord({
259
+ checkpoints: checkpoints,
260
+ record: next,
261
+ expectedVersion: version,
262
+ version: version + 1,
263
+ ownership: options.ownership,
264
+ signal,
265
+ });
266
+ return true;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ };
272
+ // Reattach an attested handle into the live registry. Recovered sessions
273
+ // expose control (input/signal/kill/release/resize/wait) through the handle;
274
+ // output streaming is not re-established after a restart — the host backend
275
+ // owns any buffered output behind its opaque ref.
276
+ const attachRecoveredHandle = (current, handle, version) => {
277
+ const accumulator = new OutputAccumulator({
278
+ maxBytes: limits.maxOutputChunkBytes,
279
+ maxLines: 100_000,
280
+ maxTotalOutputBytes: limits.maxTotalOutputBytes,
281
+ tempFilePrefix: "prism-proc",
282
+ });
283
+ const record = {
284
+ id: current.id,
285
+ owner: current.owner,
286
+ workspace: current.workspace,
287
+ command: current.command,
288
+ args: current.args,
289
+ commandFingerprint: current.commandFingerprint,
290
+ policyDecision: current.policyDecision,
291
+ startedAt: current.startedAt,
292
+ releaseOnCancel: current.releaseOnCancel,
293
+ expiresAt: current.expiresAt,
294
+ state: "running",
295
+ exitCode: null,
296
+ ptyTerminal: current.pty,
297
+ ptyResizeAt: [],
298
+ accumulator,
299
+ waiters: [],
300
+ stdinClosed: false,
301
+ handle: null,
302
+ backendRef: current.backendRef,
303
+ recoveryFencingToken: current.fencingToken,
304
+ recoveryVersion: version,
305
+ };
306
+ if (current.pty !== undefined) {
307
+ record.pty = handle;
308
+ }
309
+ else {
310
+ record.backend = handle;
311
+ }
312
+ record.handle = makeHandle(record);
313
+ sessions.set(record.id, record);
314
+ void handle
315
+ .wait()
316
+ .then((result) => {
317
+ if (record.state !== "running" && record.state !== "starting")
318
+ return;
319
+ terminateRecord(record, "exited", result.exitCode);
320
+ })
321
+ .catch(() => {
322
+ if (record.state !== "running" && record.state !== "starting")
323
+ return;
324
+ terminateRecord(record, "unknown", null);
325
+ });
326
+ };
96
327
  const terminateRecord = (record, state, exitCode) => {
97
328
  if (record.state === "exited" ||
98
329
  record.state === "killed" ||
@@ -103,8 +334,10 @@ export function createProcessSessions(options) {
103
334
  }
104
335
  const child = record.child;
105
336
  const backend = record.backend;
337
+ const pty = record.pty;
106
338
  record.child = undefined;
107
339
  record.backend = undefined;
340
+ record.pty = undefined;
108
341
  if (state === "released") {
109
342
  try {
110
343
  child?.stdout.removeAllListeners();
@@ -118,6 +351,7 @@ export function createProcessSessions(options) {
118
351
  // best effort
119
352
  }
120
353
  void backend?.release().catch(() => undefined);
354
+ void pty?.release().catch(() => undefined);
121
355
  }
122
356
  else if (state === "killed" || state === "expired" || state === "unknown") {
123
357
  if (child?.pid) {
@@ -131,6 +365,9 @@ export function createProcessSessions(options) {
131
365
  if (backend) {
132
366
  void backend.kill().catch(() => undefined);
133
367
  }
368
+ if (pty) {
369
+ void pty.kill().catch(() => undefined);
370
+ }
134
371
  }
135
372
  else if (state === "exited" && child) {
136
373
  try {
@@ -152,6 +389,7 @@ export function createProcessSessions(options) {
152
389
  catch {
153
390
  // ignore
154
391
  }
392
+ persistTransition(record);
155
393
  settleWaiters(record);
156
394
  const type = state === "exited"
157
395
  ? "process_exited"
@@ -233,6 +471,9 @@ export function createProcessSessions(options) {
233
471
  exitedAt: record.exitedAt,
234
472
  state: record.state,
235
473
  releaseOnCancel: record.releaseOnCancel,
474
+ pty: record.ptyTerminal !== undefined,
475
+ terminal: record.ptyTerminal,
476
+ ptyBackendMetadata: record.pty?.metadata,
236
477
  };
237
478
  },
238
479
  async output(request) {
@@ -260,6 +501,13 @@ export function createProcessSessions(options) {
260
501
  if (buf.byteLength > limits.maxInputBytes) {
261
502
  throw new ProcessSessionError("ERR_PRISM_PROCESS_LIMIT", `input exceeds maxInputBytes (${limits.maxInputBytes})`);
262
503
  }
504
+ if (record.pty) {
505
+ if (buf.includes(0)) {
506
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_POLICY", "NUL bytes are not permitted in PTY input");
507
+ }
508
+ await record.pty.write(buf);
509
+ return;
510
+ }
263
511
  if (record.backend) {
264
512
  await record.backend.write(buf);
265
513
  return;
@@ -319,6 +567,10 @@ export function createProcessSessions(options) {
319
567
  throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot signal in state ${record.state}`);
320
568
  }
321
569
  await assertPolicy("process_signal", record.command, record.args, record.workspace, record.owner);
570
+ if (record.pty) {
571
+ await record.pty.signal(name);
572
+ return;
573
+ }
322
574
  if (record.backend) {
323
575
  await record.backend.signal(name);
324
576
  return;
@@ -345,13 +597,31 @@ export function createProcessSessions(options) {
345
597
  throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot kill in state ${record.state}`);
346
598
  }
347
599
  await assertPolicy("process_kill", record.command, record.args, record.workspace, record.owner);
348
- if (record.backend) {
349
- try {
350
- await record.backend.kill();
600
+ const ptyHandle = record.pty;
601
+ const backendHandle = record.backend;
602
+ // Mark terminal synchronously FIRST: a backend kill may resolve the
603
+ // wait() promise, whose handler must not race into a fabricated
604
+ // 'exited' before the kill is recorded. The explicit call below is
605
+ // the existing parity double-kill; its result is ignored.
606
+ if (ptyHandle || backendHandle) {
607
+ terminateRecord(record, "killed", null);
608
+ if (ptyHandle) {
609
+ try {
610
+ await ptyHandle.kill();
611
+ }
612
+ catch {
613
+ // still marked killed
614
+ }
351
615
  }
352
- catch {
353
- // still mark killed
616
+ else {
617
+ try {
618
+ await backendHandle.kill();
619
+ }
620
+ catch {
621
+ // still marked killed
622
+ }
354
623
  }
624
+ return;
355
625
  }
356
626
  terminateRecord(record, "killed", null);
357
627
  },
@@ -361,17 +631,67 @@ export function createProcessSessions(options) {
361
631
  if (record.state !== "running" && record.state !== "starting") {
362
632
  throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot release in state ${record.state}`);
363
633
  }
364
- if (record.backend) {
365
- try {
366
- await record.backend.release();
634
+ const ptyRelease = record.pty;
635
+ const backendRelease = record.backend;
636
+ if (ptyRelease || backendRelease) {
637
+ terminateRecord(record, "released", null);
638
+ if (ptyRelease) {
639
+ try {
640
+ await ptyRelease.release();
641
+ }
642
+ catch {
643
+ // still marked released
644
+ }
367
645
  }
368
- catch {
369
- // still mark released
646
+ else {
647
+ try {
648
+ await backendRelease.release();
649
+ }
650
+ catch {
651
+ // still marked released
652
+ }
370
653
  }
654
+ return;
371
655
  }
372
656
  terminateRecord(record, "released", null);
373
657
  },
374
658
  };
659
+ if (ptyResizeCapable) {
660
+ handle.resize = async (dimensions) => {
661
+ assertAttached();
662
+ sweepExpired();
663
+ await checkSandboxAlive();
664
+ if (record.state !== "running") {
665
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", `cannot resize in state ${record.state}`);
666
+ }
667
+ const columns = dimensions.columns;
668
+ const rows = dimensions.rows;
669
+ if (!Number.isSafeInteger(columns) || columns < 1 || columns > limits.maxTerminalColumns) {
670
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `columns must be 1..${limits.maxTerminalColumns}`);
671
+ }
672
+ if (!Number.isSafeInteger(rows) || rows < 1 || rows > limits.maxTerminalRows) {
673
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `rows must be 1..${limits.maxTerminalRows}`);
674
+ }
675
+ if (!record.pty || typeof record.pty.resize !== "function") {
676
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_STATE", "session does not support resize");
677
+ }
678
+ const now = Date.now();
679
+ record.ptyResizeAt = record.ptyResizeAt.filter((t) => now - t < 60_000);
680
+ if (record.ptyResizeAt.length >= limits.maxTerminalResizesPerMinute) {
681
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `resize rate exceeds maxTerminalResizesPerMinute (${limits.maxTerminalResizesPerMinute})`);
682
+ }
683
+ await assertPolicy("process_resize", record.command, record.args, record.workspace, record.owner);
684
+ record.ptyResizeAt.push(now);
685
+ try {
686
+ await record.pty.resize({ columns, rows });
687
+ }
688
+ catch {
689
+ terminateRecord(record, "unknown", null);
690
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_BACKEND", "PTY backend resize failed");
691
+ }
692
+ record.ptyTerminal = { ...record.ptyTerminal, columns, rows };
693
+ };
694
+ }
375
695
  return handle;
376
696
  };
377
697
  return {
@@ -379,12 +699,13 @@ export function createProcessSessions(options) {
379
699
  assertNotDisposed();
380
700
  sweepExpired();
381
701
  await checkSandboxAlive();
382
- if (request.pty) {
702
+ if (request.pty && (!ptyBackend || typeof ptyBackend.startPty !== "function")) {
383
703
  throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_UNSUPPORTED", "PTY not supported on this host");
384
704
  }
385
705
  if (!request.command || typeof request.command !== "string") {
386
706
  throw new ProcessSessionError("ERR_PRISM_PROCESS_POLICY", "command required");
387
707
  }
708
+ const ptyTerminal = request.pty ? resolveTerminal(request.terminal) : undefined;
388
709
  if (sandbox && typeof sandbox.startProcess !== "function") {
389
710
  throw new ProcessSessionError("ERR_PRISM_PROCESS_UNSUPPORTED", "sandbox adapter does not support startProcess");
390
711
  }
@@ -427,18 +748,117 @@ export function createProcessSessions(options) {
427
748
  expiresAt: Date.now() + lifetimeMs,
428
749
  state: "starting",
429
750
  exitCode: null,
751
+ ptyTerminal,
752
+ ptyResizeAt: [],
430
753
  accumulator,
431
754
  waiters: [],
432
755
  stdinClosed: false,
433
756
  handle: null,
757
+ recoveryFencingToken: 0,
758
+ recoveryVersion: 0,
434
759
  };
435
760
  record.handle = makeHandle(record);
436
761
  sessions.set(id, record);
762
+ // Durable recovery: intent is persisted BEFORE spawn. The per-record lease
763
+ // fences replica coordination; the fencing token is stored in the record
764
+ // so every later CAS write is monotonic. On any write/fence failure the
765
+ // start fails closed (record removed, no half-durable process).
766
+ let recoveryLease;
767
+ if (durable) {
768
+ const lease = await acquireRecordLease({
769
+ leases: leases,
770
+ id,
771
+ ownerId: ownerId,
772
+ ttlMs: recoveryLimits.leaseTtlMs,
773
+ ownership: options.ownership,
774
+ signal: request.signal,
775
+ });
776
+ if (!lease) {
777
+ sessions.delete(id);
778
+ throw new ProcessRecoveryError("ERR_PRISM_RECOVERY_FENCE", "recovery lease for new session is held by another replica");
779
+ }
780
+ recoveryLease = { token: lease.token };
781
+ record.recoveryLeaseToken = lease.token;
782
+ record.recoveryFencingToken = lease.fencingToken;
783
+ const intent = buildProcessRecoveryRecord({
784
+ id,
785
+ owner,
786
+ workspace,
787
+ command: request.command,
788
+ args,
789
+ commandFingerprint: commandFingerprint(request.command, args),
790
+ policyDecision,
791
+ startedAt: record.startedAt,
792
+ state: "starting",
793
+ exitCode: null,
794
+ releaseOnCancel: record.releaseOnCancel,
795
+ expiresAt: record.expiresAt,
796
+ ...(ptyTerminal !== undefined ? { pty: ptyTerminal } : {}),
797
+ fencingToken: lease.fencingToken,
798
+ });
799
+ const saved = await saveProcessRecoveryRecord({
800
+ checkpoints: checkpoints,
801
+ record: intent,
802
+ expectedVersion: 0,
803
+ version: 1,
804
+ ownership: options.ownership,
805
+ signal: request.signal,
806
+ });
807
+ record.recoveryVersion = saved.version;
808
+ }
437
809
  const onData = (buf) => {
438
- accumulator.append(buf);
810
+ // PTY hosts can deliver trailing bytes after the session already went
811
+ // terminal (host wait() resolving ahead of the last pty drain); ignore
812
+ // them instead of appending to a finished accumulator.
813
+ if (record.state === "running" || record.state === "starting") {
814
+ accumulator.append(buf);
815
+ }
439
816
  };
440
817
  try {
441
- if (sandbox?.startProcess) {
818
+ if (request.pty) {
819
+ try {
820
+ const handle = await withPtyAttachTimeout(ptyBackend.startPty({
821
+ file: request.command,
822
+ args,
823
+ cwd,
824
+ env: request.env,
825
+ columns: ptyTerminal.columns,
826
+ rows: ptyTerminal.rows,
827
+ term: ptyTerminal.term,
828
+ onData,
829
+ }), limits.maxPtyAttachTimeoutMs);
830
+ const metadataJson = handle.metadata !== undefined ? JSON.stringify(handle.metadata) : undefined;
831
+ if (metadataJson !== undefined && Buffer.byteLength(metadataJson, "utf8") > limits.maxPtyBackendMetadataBytes) {
832
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_LIMIT", `pty backend metadata exceeds maxPtyBackendMetadataBytes (${limits.maxPtyBackendMetadataBytes})`);
833
+ }
834
+ if (handle.ref !== undefined)
835
+ record.backendRef = validateBackendRef(handle.ref, recoveryLimits);
836
+ record.pty = handle;
837
+ record.state = "running";
838
+ persistTransition(record);
839
+ void handle
840
+ .wait()
841
+ .then((result) => {
842
+ if (record.state !== "running" && record.state !== "starting")
843
+ return;
844
+ terminateRecord(record, "exited", result.exitCode);
845
+ })
846
+ .catch(() => {
847
+ if (record.state !== "running" && record.state !== "starting")
848
+ return;
849
+ terminateRecord(record, "unknown", null);
850
+ });
851
+ }
852
+ catch (error) {
853
+ sessions.delete(id);
854
+ if (error instanceof ProcessSessionError)
855
+ throw error;
856
+ if (error instanceof ProcessRecoveryError)
857
+ throw error;
858
+ throw new ProcessSessionError("ERR_PRISM_PROCESS_PTY_BACKEND", "PTY backend failed to start");
859
+ }
860
+ }
861
+ else if (sandbox?.startProcess) {
442
862
  const handle = await sandbox.startProcess({
443
863
  file: request.command,
444
864
  args,
@@ -446,8 +866,11 @@ export function createProcessSessions(options) {
446
866
  env: request.env,
447
867
  onData,
448
868
  });
869
+ if (handle.ref !== undefined)
870
+ record.backendRef = validateBackendRef(handle.ref, recoveryLimits);
449
871
  record.backend = handle;
450
872
  record.state = "running";
873
+ persistTransition(record);
451
874
  void handle
452
875
  .wait()
453
876
  .then((result) => {
@@ -489,10 +912,30 @@ export function createProcessSessions(options) {
489
912
  else
490
913
  terminateRecord(record, "exited", code);
491
914
  });
915
+ persistTransition(record);
492
916
  }
493
917
  }
494
918
  catch (error) {
495
919
  sessions.delete(id);
920
+ if (durable) {
921
+ // No half-durable process: drop the intent/running record and release
922
+ // the recovery lease. The host store is authoritative.
923
+ await deleteProcessRecoveryRecord({ checkpoints: checkpoints, id, ownership: options.ownership, signal: request.signal });
924
+ if (recoveryLease) {
925
+ await releaseRecordLease({
926
+ leases: leases,
927
+ id,
928
+ ownerId: ownerId,
929
+ token: recoveryLease.token,
930
+ ownership: options.ownership,
931
+ signal: request.signal,
932
+ });
933
+ }
934
+ }
935
+ if (error instanceof ProcessSessionError)
936
+ throw error;
937
+ if (error instanceof ProcessRecoveryError)
938
+ throw error;
496
939
  throw new ProcessSessionError("ERR_PRISM_PROCESS_UNSUPPORTED", error instanceof Error ? error.message : String(error));
497
940
  }
498
941
  emit({
@@ -517,27 +960,95 @@ export function createProcessSessions(options) {
517
960
  continue;
518
961
  if (record.state !== "running" && record.state !== "starting")
519
962
  continue;
963
+ const cancelPty = record.pty;
964
+ const cancelBackend = record.backend;
520
965
  if (release || record.releaseOnCancel) {
521
- if (record.backend) {
522
- try {
523
- await record.backend.release();
966
+ if (cancelPty || cancelBackend) {
967
+ terminateRecord(record, "released", null);
968
+ if (cancelPty) {
969
+ try {
970
+ await cancelPty.release();
971
+ }
972
+ catch {
973
+ // continue
974
+ }
524
975
  }
525
- catch {
526
- // continue
976
+ else {
977
+ try {
978
+ await cancelBackend.release();
979
+ }
980
+ catch {
981
+ // continue
982
+ }
527
983
  }
528
984
  }
529
- terminateRecord(record, "released", null);
985
+ else {
986
+ terminateRecord(record, "released", null);
987
+ }
530
988
  }
531
989
  else {
532
- if (record.backend) {
533
- try {
534
- await record.backend.kill();
990
+ if (cancelPty || cancelBackend) {
991
+ terminateRecord(record, "killed", null);
992
+ if (cancelPty) {
993
+ try {
994
+ await cancelPty.kill();
995
+ }
996
+ catch {
997
+ // continue
998
+ }
535
999
  }
536
- catch {
537
- // continue
1000
+ else {
1001
+ try {
1002
+ await cancelBackend.kill();
1003
+ }
1004
+ catch {
1005
+ // continue
1006
+ }
538
1007
  }
539
1008
  }
540
- terminateRecord(record, "killed", null);
1009
+ else {
1010
+ terminateRecord(record, "killed", null);
1011
+ }
1012
+ }
1013
+ }
1014
+ // Durable pass (plan 026 Task 5): cancellation of a recovered/unattached
1015
+ // process either reaches the attached backend (above) or records unknown —
1016
+ // never a fabricated exit. Lease + CAS guard every mutation so two replicas
1017
+ // cannot cancel the same record into different outcomes.
1018
+ if (durable) {
1019
+ const { records } = await loadProcessRecoveryRecords({
1020
+ checkpoints: checkpoints,
1021
+ limits: recoveryLimits,
1022
+ ownership: options.ownership,
1023
+ });
1024
+ for (const { record, version } of records) {
1025
+ if (record.owner !== owner)
1026
+ continue;
1027
+ if (isTerminalState(record.state))
1028
+ continue;
1029
+ if (sessions.has(record.id))
1030
+ continue; // handled by the live pass above
1031
+ const lease = await acquireRecordLease({
1032
+ leases: leases,
1033
+ id: record.id,
1034
+ ownerId: ownerId,
1035
+ ttlMs: recoveryLimits.leaseTtlMs,
1036
+ ownership: options.ownership,
1037
+ });
1038
+ if (!lease)
1039
+ continue; // another replica owns or is recovering it
1040
+ try {
1041
+ await persistRecoveryUnknown(record, version);
1042
+ }
1043
+ finally {
1044
+ await releaseRecordLease({
1045
+ leases: leases,
1046
+ id: record.id,
1047
+ ownerId: ownerId,
1048
+ token: lease.token,
1049
+ ownership: options.ownership,
1050
+ });
1051
+ }
541
1052
  }
542
1053
  }
543
1054
  },
@@ -553,12 +1064,180 @@ export function createProcessSessions(options) {
553
1064
  const markedUnknown = reconcileAllUnknown();
554
1065
  return { markedUnknown };
555
1066
  },
1067
+ async recover(recoverOptions) {
1068
+ assertNotDisposed();
1069
+ if (!durable) {
1070
+ throw new ProcessRecoveryError("ERR_PRISM_RECOVERY_UNSUPPORTED", "durable process recovery is not configured on this host");
1071
+ }
1072
+ sweepExpired();
1073
+ const { records } = await loadProcessRecoveryRecords({
1074
+ checkpoints: checkpoints,
1075
+ limits: recoveryLimits,
1076
+ ownership: options.ownership,
1077
+ signal: recoverOptions?.signal,
1078
+ });
1079
+ const report = [];
1080
+ let attached = 0;
1081
+ let terminal = 0;
1082
+ let unknown = 0;
1083
+ for (const { record } of records) {
1084
+ const live = sessions.get(record.id);
1085
+ if (live) {
1086
+ // Already live in this registry: nothing to do, report the truth.
1087
+ report.push({ id: record.id, outcome: "attached", state: live.state, exitCode: live.exitCode });
1088
+ if (live.state === "running" || live.state === "starting")
1089
+ attached += 1;
1090
+ else
1091
+ terminal += 1;
1092
+ continue;
1093
+ }
1094
+ if (record.state === "exited" ||
1095
+ record.state === "killed" ||
1096
+ record.state === "released" ||
1097
+ record.state === "expired" ||
1098
+ record.state === "unknown") {
1099
+ report.push({ id: record.id, outcome: "terminal", state: record.state, exitCode: record.exitCode });
1100
+ terminal += 1;
1101
+ continue;
1102
+ }
1103
+ // starting | running durable record without a live handle: attach-if-
1104
+ // attested, else atomic unknown. Never fabricate an exit code.
1105
+ const lease = await acquireRecordLease({
1106
+ leases: leases,
1107
+ id: record.id,
1108
+ ownerId: ownerId,
1109
+ ttlMs: recoveryLimits.leaseTtlMs,
1110
+ ownership: options.ownership,
1111
+ signal: recoverOptions?.signal,
1112
+ });
1113
+ if (!lease) {
1114
+ // Another replica owns or is recovering this record.
1115
+ report.push({ id: record.id, outcome: "unknown", state: record.state, exitCode: null });
1116
+ unknown += 1;
1117
+ continue;
1118
+ }
1119
+ try {
1120
+ // Fresh CAS read under the lease: another replica may have moved the record.
1121
+ const fresh = await loadProcessRecoveryRecord({
1122
+ checkpoints: checkpoints,
1123
+ id: record.id,
1124
+ limits: recoveryLimits,
1125
+ ownership: options.ownership,
1126
+ signal: recoverOptions?.signal,
1127
+ });
1128
+ if (!fresh) {
1129
+ report.push({ id: record.id, outcome: "terminal", state: "unknown", exitCode: null, error: "ERR_PRISM_RECOVERY_UNKNOWN" });
1130
+ terminal += 1;
1131
+ continue;
1132
+ }
1133
+ const current = fresh.record;
1134
+ if (current.state === "exited" ||
1135
+ current.state === "killed" ||
1136
+ current.state === "released" ||
1137
+ current.state === "expired" ||
1138
+ current.state === "unknown") {
1139
+ report.push({ id: current.id, outcome: "terminal", state: current.state, exitCode: current.exitCode });
1140
+ terminal += 1;
1141
+ continue;
1142
+ }
1143
+ if (Date.now() >= current.expiresAt) {
1144
+ // Lifetime lapsed while unrecovered: persist the expiry.
1145
+ const expired = buildProcessRecoveryRecord({
1146
+ ...current,
1147
+ state: "expired",
1148
+ exitCode: null,
1149
+ updatedAt: nowIso(),
1150
+ });
1151
+ await saveProcessRecoveryRecord({
1152
+ checkpoints: checkpoints,
1153
+ record: expired,
1154
+ expectedVersion: fresh.version,
1155
+ version: fresh.version + 1,
1156
+ ownership: options.ownership,
1157
+ signal: recoverOptions?.signal,
1158
+ });
1159
+ report.push({ id: current.id, outcome: "terminal", state: "expired", exitCode: null });
1160
+ terminal += 1;
1161
+ continue;
1162
+ }
1163
+ const active = [...sessions.values()].filter((s) => s.state === "running" || s.state === "starting").length;
1164
+ if (active >= limits.maxSessions) {
1165
+ // Cannot admit another live session: record unknown (fails closed).
1166
+ await persistRecoveryUnknown(current, fresh.version, recoverOptions?.signal);
1167
+ report.push({ id: current.id, outcome: "unknown", state: "unknown", exitCode: null });
1168
+ unknown += 1;
1169
+ continue;
1170
+ }
1171
+ let attachErrorCode;
1172
+ if (current.backendRef !== undefined && recoveryBackend) {
1173
+ try {
1174
+ const handle = await attachWithTimeout(recoveryBackend, current.backendRef, recoveryLimits.attachTimeoutMs);
1175
+ if (handle) {
1176
+ attachRecoveredHandle(current, handle, fresh.version);
1177
+ attached += 1;
1178
+ report.push({ id: current.id, outcome: "attached", state: "running", exitCode: null });
1179
+ continue;
1180
+ }
1181
+ }
1182
+ catch (attachError) {
1183
+ attachErrorCode = attachError instanceof ProcessRecoveryError ? attachError.code : "ERR_PRISM_RECOVERY_UNKNOWN";
1184
+ }
1185
+ }
1186
+ // No ref, no backend, unattested attach, or attach failure: atomic unknown.
1187
+ const saved = await persistRecoveryUnknown(current, fresh.version, recoverOptions?.signal);
1188
+ if (!saved) {
1189
+ // CAS/fence conflict: another replica moved the record; re-report its state.
1190
+ const again = await loadProcessRecoveryRecord({
1191
+ checkpoints: checkpoints,
1192
+ id: current.id,
1193
+ limits: recoveryLimits,
1194
+ ownership: options.ownership,
1195
+ signal: recoverOptions?.signal,
1196
+ });
1197
+ const reported = again?.record;
1198
+ report.push({
1199
+ id: current.id,
1200
+ outcome: reported && isTerminalState(reported.state) ? "terminal" : "unknown",
1201
+ state: reported?.state ?? "unknown",
1202
+ exitCode: reported?.exitCode ?? null,
1203
+ error: attachErrorCode,
1204
+ });
1205
+ if (reported && isTerminalState(reported.state))
1206
+ terminal += 1;
1207
+ else
1208
+ unknown += 1;
1209
+ continue;
1210
+ }
1211
+ report.push({ id: current.id, outcome: "unknown", state: "unknown", exitCode: null, error: attachErrorCode });
1212
+ unknown += 1;
1213
+ }
1214
+ finally {
1215
+ await releaseRecordLease({
1216
+ leases: leases,
1217
+ id: record.id,
1218
+ ownerId: ownerId,
1219
+ token: lease.token,
1220
+ ownership: options.ownership,
1221
+ signal: recoverOptions?.signal,
1222
+ });
1223
+ }
1224
+ }
1225
+ return { records: report, attached, terminal, unknown };
1226
+ },
556
1227
  async dispose() {
557
1228
  if (disposed)
558
1229
  return;
559
1230
  disposed = true;
560
1231
  for (const record of [...sessions.values()]) {
561
1232
  if (record.state === "running" || record.state === "starting") {
1233
+ if (record.pty) {
1234
+ try {
1235
+ await record.pty.kill();
1236
+ }
1237
+ catch {
1238
+ // best effort
1239
+ }
1240
+ }
562
1241
  if (record.backend) {
563
1242
  try {
564
1243
  await record.backend.kill();
@@ -584,6 +1263,16 @@ export function createProcessSessions(options) {
584
1263
  catch {
585
1264
  // best effort
586
1265
  }
1266
+ if (record.recoveryLeaseToken) {
1267
+ void releaseRecordLease({
1268
+ leases: leases,
1269
+ id: record.id,
1270
+ ownerId: ownerId,
1271
+ token: record.recoveryLeaseToken,
1272
+ ownership: options.ownership,
1273
+ });
1274
+ record.recoveryLeaseToken = undefined;
1275
+ }
587
1276
  }
588
1277
  sessions.clear();
589
1278
  },