@spexcode/spec-cli 0.6.5 → 0.6.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.
- package/bin/spex.mjs +2 -1
- package/dist/claude-headless.d.ts +4 -1
- package/dist/claude-headless.js +13 -4
- package/dist/cli.js +72 -23
- package/dist/client.d.ts +2 -1
- package/dist/client.js +13 -8
- package/dist/codex-runtime-generations.d.ts +5 -0
- package/dist/codex-runtime-generations.js +112 -0
- package/dist/doctor.js +7 -1
- package/dist/gateway-hub.js +7 -5
- package/dist/gateway.d.ts +1 -0
- package/dist/gateway.js +44 -20
- package/dist/graphCache.js +2 -1
- package/dist/graphSnapshot.js +2 -1
- package/dist/harness.d.ts +15 -2
- package/dist/harness.js +174 -54
- package/dist/help.d.ts +5 -0
- package/dist/help.js +26 -6
- package/dist/index.js +3 -3
- package/dist/listen.d.ts +2 -1
- package/dist/listen.js +10 -10
- package/dist/opencode-headless.d.ts +1 -0
- package/dist/opencode-headless.js +7 -0
- package/dist/runtime-rotate.d.ts +1 -0
- package/dist/runtime-rotate.js +58 -0
- package/dist/session-follow.js +1 -1
- package/dist/session-timeline.d.ts +6 -46
- package/dist/session-timeline.js +8 -221
- package/dist/sessions.d.ts +25 -4
- package/dist/sessions.js +822 -394
- package/dist/supervise.js +3 -3
- package/package.json +6 -4
- package/dist/delivery-queue.d.ts +0 -23
- package/dist/delivery-queue.js +0 -179
- package/dist/session-cursors.d.ts +0 -14
- package/dist/session-cursors.js +0 -82
package/dist/sessions.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync,
|
|
4
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
|
|
5
5
|
import { join, dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { seedWorktreeHostState } from './worktree-sources.js';
|
|
8
8
|
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
|
|
9
9
|
import { loadConfig, loadSpecs, loadSpecsLite } from '@spexcode/spec-core';
|
|
10
|
-
import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
|
|
10
|
+
import { adapterLoadedReferenceState, assertRvSockPath, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
|
|
11
11
|
import { materialize } from './materialize.js';
|
|
12
12
|
import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal } from '@spexcode/spec-core';
|
|
13
13
|
import { readSessionFiles } from './session-files.js';
|
|
14
14
|
import { readSessionWebs } from './session-web.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { acceptMessage, drain, recordStatus, lastHumanSendVia, owesDelivery, pendingMessages } from '@spexcode/session-core';
|
|
16
|
+
import { pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, withDeliveryLocks, trySessionRecordLockSync, withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from '@spexcode/session-core/internal';
|
|
17
17
|
import { stripRefSigil } from './mentions.js';
|
|
18
18
|
import { shQuote } from './sh.js';
|
|
19
19
|
import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js';
|
|
@@ -157,12 +157,10 @@ function readPromptFile(id) {
|
|
|
157
157
|
return null;
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
-
//
|
|
160
|
+
// The resolved first-turn payload is authoritative across queue drain and recovery. Adapters that mint native
|
|
161
|
+
// identity keep it until they prove identity + first-turn durability; other adapters consume on submission.
|
|
161
162
|
function writeLaunchFile(id, prompt) {
|
|
162
|
-
|
|
163
|
-
writeFileSync(join(storeDir(id), 'launch'), prompt);
|
|
164
|
-
}
|
|
165
|
-
catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
|
|
163
|
+
writeFileSync(join(storeDir(id), 'launch'), prompt);
|
|
166
164
|
}
|
|
167
165
|
function readLaunchFile(id) {
|
|
168
166
|
try {
|
|
@@ -244,7 +242,7 @@ export function rawLifecycleStatus(rec) {
|
|
|
244
242
|
return rec.status === 'queued' && rec.launchOwner ? OWNED_QUEUE_RAW_STATUS : rec.status;
|
|
245
243
|
}
|
|
246
244
|
export function canDrainQueued(rec, authority = backendLaunchAuthority()) {
|
|
247
|
-
return rec.status === 'queued' && (rec.launchOwner === null || rec.launchOwner === authority);
|
|
245
|
+
return rec.status === 'queued' && !rec.stopped && (rec.launchOwner === null || rec.launchOwner === authority);
|
|
248
246
|
}
|
|
249
247
|
// typed read of a session's record from the global store (null if it has none — a self-launched session that
|
|
250
248
|
// only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
|
|
@@ -288,157 +286,18 @@ function readLiveRecord(id) {
|
|
|
288
286
|
throw new SessionRecordUnusable('retired', rec.session, retired);
|
|
289
287
|
return rec;
|
|
290
288
|
}
|
|
291
|
-
|
|
292
|
-
// in-memory transition tail is only an optimization. This lock covers each read/modify/write or destructive
|
|
293
|
-
// transition across archive/resume/stop/close and hook writers. It lives outside the record directory so close
|
|
294
|
-
// may remove the record while its lock is held. A dead writer's lock is reclaimed; a live writer is waited for
|
|
295
|
-
// with a bounded wall and then fails loudly rather than allowing a stale write to win.
|
|
296
|
-
const recordLockRoot = () => join(runtimeRoot(), '.session-locks');
|
|
297
|
-
const recordLockPath = (id) => join(recordLockRoot(), `${id}.lock`);
|
|
298
|
-
const syncPause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
299
|
-
function acquireRecordLockSync(id, timeoutMs = 30_000) {
|
|
300
|
-
mkdirSync(recordLockRoot(), { recursive: true });
|
|
301
|
-
const path = recordLockPath(id), deadline = Date.now() + timeoutMs;
|
|
302
|
-
for (;;) {
|
|
303
|
-
try {
|
|
304
|
-
const fd = openSync(path, 'wx');
|
|
305
|
-
writeSync(fd, String(process.pid));
|
|
306
|
-
closeSync(fd);
|
|
307
|
-
return () => { try {
|
|
308
|
-
unlinkSync(path);
|
|
309
|
-
}
|
|
310
|
-
catch { /* another recovery already removed it */ } };
|
|
311
|
-
}
|
|
312
|
-
catch (e) {
|
|
313
|
-
if (e.code !== 'EEXIST')
|
|
314
|
-
throw e;
|
|
315
|
-
let owner = 0;
|
|
316
|
-
try {
|
|
317
|
-
owner = Number(readFileSync(path, 'utf8').trim()) || 0;
|
|
318
|
-
}
|
|
319
|
-
catch { /* race with creator/releaser */ }
|
|
320
|
-
if (owner && owner !== process.pid) {
|
|
321
|
-
try {
|
|
322
|
-
process.kill(owner, 0);
|
|
323
|
-
}
|
|
324
|
-
catch {
|
|
325
|
-
try {
|
|
326
|
-
unlinkSync(path);
|
|
327
|
-
}
|
|
328
|
-
catch { /* race */ }
|
|
329
|
-
;
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
if (Date.now() >= deadline)
|
|
334
|
-
throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`);
|
|
335
|
-
syncPause(10);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
const abortedOperation = (signal) => signal.reason instanceof Error
|
|
340
|
-
? signal.reason
|
|
341
|
-
: Object.assign(new Error('The operation was aborted'), { name: 'AbortError', code: 'ABORT_ERR' });
|
|
342
|
-
async function recordLockPause(signal) {
|
|
343
|
-
if (!signal) {
|
|
344
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (signal.aborted)
|
|
348
|
-
throw abortedOperation(signal);
|
|
349
|
-
await new Promise((resolve, reject) => {
|
|
350
|
-
const timer = setTimeout(done, 10);
|
|
351
|
-
const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(abortedOperation(signal)); };
|
|
352
|
-
function done() { signal.removeEventListener('abort', abort); resolve(); }
|
|
353
|
-
signal.addEventListener('abort', abort, { once: true });
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
async function acquireRecordLock(id, timeoutMs = 30_000, signal) {
|
|
357
|
-
mkdirSync(recordLockRoot(), { recursive: true });
|
|
358
|
-
const path = recordLockPath(id), deadline = Date.now() + timeoutMs;
|
|
359
|
-
for (;;) {
|
|
360
|
-
if (signal?.aborted)
|
|
361
|
-
throw abortedOperation(signal);
|
|
362
|
-
try {
|
|
363
|
-
const fd = openSync(path, 'wx');
|
|
364
|
-
writeSync(fd, String(process.pid));
|
|
365
|
-
closeSync(fd);
|
|
366
|
-
return () => { try {
|
|
367
|
-
unlinkSync(path);
|
|
368
|
-
}
|
|
369
|
-
catch { /* another recovery already removed it */ } };
|
|
370
|
-
}
|
|
371
|
-
catch (e) {
|
|
372
|
-
if (e.code !== 'EEXIST')
|
|
373
|
-
throw e;
|
|
374
|
-
let owner = 0;
|
|
375
|
-
try {
|
|
376
|
-
owner = Number(readFileSync(path, 'utf8').trim()) || 0;
|
|
377
|
-
}
|
|
378
|
-
catch { /* race with creator/releaser */ }
|
|
379
|
-
if (owner && owner !== process.pid) {
|
|
380
|
-
try {
|
|
381
|
-
process.kill(owner, 0);
|
|
382
|
-
}
|
|
383
|
-
catch {
|
|
384
|
-
try {
|
|
385
|
-
unlinkSync(path);
|
|
386
|
-
}
|
|
387
|
-
catch { /* race */ }
|
|
388
|
-
;
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
if (Date.now() >= deadline)
|
|
393
|
-
throw new ResourceConflict(`session ${id}: lifecycle transition lock timed out; refusing a stale write`);
|
|
394
|
-
await recordLockPause(signal);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
async function withRecordLock(id, body, signal) {
|
|
399
|
-
const release = await acquireRecordLock(id, 30_000, signal);
|
|
400
|
-
try {
|
|
401
|
-
return await body();
|
|
402
|
-
}
|
|
403
|
-
finally {
|
|
404
|
-
release();
|
|
405
|
-
}
|
|
406
|
-
}
|
|
289
|
+
const withRecordLock = withSessionRecordLock;
|
|
407
290
|
export function withSessionRecordLockSync(id, body) {
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
return body();
|
|
411
|
-
}
|
|
412
|
-
finally {
|
|
413
|
-
release();
|
|
414
|
-
}
|
|
291
|
+
return coreWithSessionRecordLockSync(id, body);
|
|
415
292
|
}
|
|
416
293
|
const withRecordLockSync = withSessionRecordLockSync;
|
|
417
|
-
function tryRecordLockSync(id) {
|
|
418
|
-
mkdirSync(recordLockRoot(), { recursive: true });
|
|
419
|
-
const path = recordLockPath(id);
|
|
420
|
-
try {
|
|
421
|
-
const fd = openSync(path, 'wx');
|
|
422
|
-
writeSync(fd, String(process.pid));
|
|
423
|
-
closeSync(fd);
|
|
424
|
-
return () => { try {
|
|
425
|
-
unlinkSync(path);
|
|
426
|
-
}
|
|
427
|
-
catch { /* another recovery already removed it */ } };
|
|
428
|
-
}
|
|
429
|
-
catch (e) {
|
|
430
|
-
if (e.code === 'EEXIST')
|
|
431
|
-
return null;
|
|
432
|
-
throw e;
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
294
|
// Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
|
|
436
295
|
// enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
|
|
437
296
|
// and then race a just-queued TUI turn.
|
|
438
297
|
export function withSessionInputLock(id, body) {
|
|
439
298
|
// PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
|
|
440
299
|
// regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
|
|
441
|
-
const release =
|
|
300
|
+
const release = trySessionRecordLockSync(id);
|
|
442
301
|
if (!release)
|
|
443
302
|
return null;
|
|
444
303
|
try {
|
|
@@ -619,7 +478,10 @@ function readWatchEntries(target) {
|
|
|
619
478
|
// The former one-source format cannot name an origin. Its child pointer is the only durable witness
|
|
620
479
|
// that this watcher was installed for parent supervision; every other legacy row is a manual watch.
|
|
621
480
|
: [watcher === parent ? 'parent' : 'manual'];
|
|
622
|
-
|
|
481
|
+
const snapshotPending = entry.snapshotPending;
|
|
482
|
+
return sources.length ? [{ watcher, createdAt, sources,
|
|
483
|
+
...(sources.includes('parent') && typeof snapshotPending === 'string' && snapshotPending ? { snapshotPending } : {}),
|
|
484
|
+
}] : [];
|
|
623
485
|
});
|
|
624
486
|
}
|
|
625
487
|
catch {
|
|
@@ -642,14 +504,19 @@ function writeWatchEntries(target, entries) {
|
|
|
642
504
|
writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
|
|
643
505
|
renameSync(tmp, path);
|
|
644
506
|
}
|
|
645
|
-
function addWatchSource(entries, watcher, source) {
|
|
507
|
+
function addWatchSource(entries, watcher, source, deferInitialSnapshot = false) {
|
|
646
508
|
const existing = entries.find((entry) => entry.watcher === watcher);
|
|
509
|
+
const snapshotPending = deferInitialSnapshot && !existing?.sources.includes('manual') ? randomUUID() : undefined;
|
|
647
510
|
if (!existing)
|
|
648
|
-
return { entries: [...entries, {
|
|
511
|
+
return { entries: [...entries, {
|
|
512
|
+
watcher, createdAt: new Date().toISOString(), sources: [source], ...(snapshotPending ? { snapshotPending } : {}),
|
|
513
|
+
}], added: true };
|
|
649
514
|
if (existing.sources.includes(source))
|
|
650
515
|
return { entries, added: false };
|
|
651
516
|
return {
|
|
652
|
-
entries: entries.map((entry) => entry === existing ? {
|
|
517
|
+
entries: entries.map((entry) => entry === existing ? {
|
|
518
|
+
...entry, sources: [...entry.sources, source], ...(snapshotPending ? { snapshotPending } : {}),
|
|
519
|
+
} : entry),
|
|
653
520
|
added: true,
|
|
654
521
|
};
|
|
655
522
|
}
|
|
@@ -660,7 +527,12 @@ function removeWatchSource(entries, watcher, source) {
|
|
|
660
527
|
return [entry];
|
|
661
528
|
removed = true;
|
|
662
529
|
const sources = entry.sources.filter((candidate) => candidate !== source);
|
|
663
|
-
|
|
530
|
+
if (!sources.length)
|
|
531
|
+
return [];
|
|
532
|
+
if (source !== 'parent')
|
|
533
|
+
return [{ ...entry, sources }];
|
|
534
|
+
const { snapshotPending: _pending, ...withoutParentDebt } = entry;
|
|
535
|
+
return [{ ...withoutParentDebt, sources }];
|
|
664
536
|
});
|
|
665
537
|
return { entries: next, removed };
|
|
666
538
|
}
|
|
@@ -677,8 +549,14 @@ function watchMessage(target) {
|
|
|
677
549
|
const note = target.note ? ` — ${target.note}` : '';
|
|
678
550
|
return `[spex watch] ${target.session} is ${status}${note}`;
|
|
679
551
|
}
|
|
552
|
+
function shouldDeliverWatchTransition(target, sources) {
|
|
553
|
+
// @@@watch-delivery-policy - Relationship setup sends current state; manual opts into working changes.
|
|
554
|
+
return target.status !== 'active' || sources.includes('manual');
|
|
555
|
+
}
|
|
680
556
|
function scheduleWatchNotifications(target) {
|
|
681
|
-
const watchers = readWatchEntries(target.session)
|
|
557
|
+
const watchers = readWatchEntries(target.session)
|
|
558
|
+
.filter((entry) => !entry.snapshotPending && shouldDeliverWatchTransition(target, entry.sources))
|
|
559
|
+
.map((entry) => entry.watcher);
|
|
682
560
|
if (!watchers.length)
|
|
683
561
|
return;
|
|
684
562
|
queueMicrotask(() => {
|
|
@@ -690,22 +568,130 @@ function scheduleWatchNotifications(target) {
|
|
|
690
568
|
}
|
|
691
569
|
});
|
|
692
570
|
}
|
|
571
|
+
const watchSnapshotState = (target) => JSON.stringify([target.status, target.proposal, target.note]);
|
|
572
|
+
async function deliverPendingWatchSnapshots(targetId, forceCurrent = true) {
|
|
573
|
+
const pending = readWatchEntries(targetId).filter((entry) => entry.snapshotPending);
|
|
574
|
+
for (const original of pending) {
|
|
575
|
+
const token = original.snapshotPending;
|
|
576
|
+
let force = forceCurrent;
|
|
577
|
+
for (;;) {
|
|
578
|
+
const target = readRecord(targetId);
|
|
579
|
+
const entry = readWatchEntries(targetId)
|
|
580
|
+
.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
|
|
581
|
+
if (!target || !entry)
|
|
582
|
+
break;
|
|
583
|
+
const state = watchSnapshotState(target);
|
|
584
|
+
const shouldDeliver = force || shouldDeliverWatchTransition(target, entry.sources);
|
|
585
|
+
if (!shouldDeliver) {
|
|
586
|
+
let settled = false;
|
|
587
|
+
await withRecordLock(targetId, async () => {
|
|
588
|
+
const current = readRecord(targetId);
|
|
589
|
+
const entries = readWatchEntries(targetId);
|
|
590
|
+
const pendingEntry = entries.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
|
|
591
|
+
if (!current || !pendingEntry || watchSnapshotState(current) !== state)
|
|
592
|
+
return;
|
|
593
|
+
const next = entries.map((candidate) => {
|
|
594
|
+
if (candidate !== pendingEntry)
|
|
595
|
+
return candidate;
|
|
596
|
+
const { snapshotPending: _pending, ...cleared } = candidate;
|
|
597
|
+
return cleared;
|
|
598
|
+
});
|
|
599
|
+
writeWatchEntries(targetId, next);
|
|
600
|
+
settled = true;
|
|
601
|
+
});
|
|
602
|
+
if (settled)
|
|
603
|
+
break;
|
|
604
|
+
force = false;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const identity = `${targetId}\0${entry.watcher}\0${token}\0${state}`;
|
|
608
|
+
const delivered = await sendText(entry.watcher, watchMessage(target), targetId, {
|
|
609
|
+
idempotency: {
|
|
610
|
+
operation: 'watch-initial-snapshot',
|
|
611
|
+
requestDigest: digest(identity),
|
|
612
|
+
payloadHash: digest(`watch-initial-snapshot\0${identity}\0${watchMessage(target)}`),
|
|
613
|
+
},
|
|
614
|
+
acceptGuard: async () => {
|
|
615
|
+
const current = readRecord(targetId);
|
|
616
|
+
const stillPending = readWatchEntries(targetId)
|
|
617
|
+
.some((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
|
|
618
|
+
if (!current || !stillPending || watchSnapshotState(current) !== state)
|
|
619
|
+
throw new ResourceConflict('watch initial snapshot changed before acceptance');
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
if (!delivered.ok) {
|
|
623
|
+
if (delivered.error?.includes('watch initial snapshot changed before acceptance'))
|
|
624
|
+
continue;
|
|
625
|
+
console.error(`spex session watch: could not deliver initial ${targetId} state to ${entry.watcher}: ${delivered.error}`);
|
|
626
|
+
break;
|
|
627
|
+
}
|
|
628
|
+
force = false;
|
|
629
|
+
let settled = false;
|
|
630
|
+
await withRecordLock(targetId, async () => {
|
|
631
|
+
const current = readRecord(targetId);
|
|
632
|
+
const entries = readWatchEntries(targetId);
|
|
633
|
+
const pendingEntry = entries.find((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
|
|
634
|
+
if (!current || !pendingEntry) {
|
|
635
|
+
settled = true;
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const currentState = watchSnapshotState(current);
|
|
639
|
+
if (currentState !== state && shouldDeliverWatchTransition(current, pendingEntry.sources))
|
|
640
|
+
return;
|
|
641
|
+
const next = entries.map((candidate) => {
|
|
642
|
+
if (candidate !== pendingEntry)
|
|
643
|
+
return candidate;
|
|
644
|
+
const { snapshotPending: _pending, ...cleared } = candidate;
|
|
645
|
+
return cleared;
|
|
646
|
+
});
|
|
647
|
+
writeWatchEntries(targetId, next);
|
|
648
|
+
settled = true;
|
|
649
|
+
});
|
|
650
|
+
if (settled)
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async function clearPendingWatchSnapshots(targetId) {
|
|
656
|
+
await withRecordLock(targetId, async () => {
|
|
657
|
+
const entries = readWatchEntries(targetId);
|
|
658
|
+
const next = entries.map((entry) => {
|
|
659
|
+
if (!entry.snapshotPending)
|
|
660
|
+
return entry;
|
|
661
|
+
const { snapshotPending: _pending, ...settled } = entry;
|
|
662
|
+
return settled;
|
|
663
|
+
});
|
|
664
|
+
if (next.some((entry, index) => entry !== entries[index]))
|
|
665
|
+
writeWatchEntries(targetId, next);
|
|
666
|
+
});
|
|
667
|
+
}
|
|
693
668
|
export async function subscribeSessionWatch(watcher, targets, source = 'manual') {
|
|
694
669
|
managedWatchRecord(watcher);
|
|
695
670
|
const watched = [];
|
|
696
671
|
for (const target of [...new Set(targets)]) {
|
|
697
672
|
if (target === watcher)
|
|
698
673
|
throw new ResourceConflict('a session cannot watch itself');
|
|
699
|
-
|
|
674
|
+
let targetRecord = null;
|
|
675
|
+
let added = false;
|
|
676
|
+
let pending = false;
|
|
700
677
|
withRecordLockSync(target, () => {
|
|
678
|
+
targetRecord = managedWatchRecord(target);
|
|
701
679
|
const entries = readWatchEntries(target);
|
|
702
|
-
const next = addWatchSource(entries, watcher, source);
|
|
680
|
+
const next = addWatchSource(entries, watcher, source, source === 'parent' && targetRecord.status === 'queued');
|
|
703
681
|
if (next.added)
|
|
704
682
|
writeWatchEntries(target, next.entries);
|
|
683
|
+
added = next.added;
|
|
684
|
+
pending = next.entries.some((entry) => entry.watcher === watcher && !!entry.snapshotPending);
|
|
705
685
|
});
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
686
|
+
if (pending) {
|
|
687
|
+
if (source === 'manual')
|
|
688
|
+
await deliverPendingWatchSnapshots(target);
|
|
689
|
+
}
|
|
690
|
+
else if (source === 'manual' || added) {
|
|
691
|
+
const delivered = await sendText(watcher, watchMessage(targetRecord), target);
|
|
692
|
+
if (!delivered.ok)
|
|
693
|
+
throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`);
|
|
694
|
+
}
|
|
709
695
|
watched.push(target);
|
|
710
696
|
}
|
|
711
697
|
return { watched };
|
|
@@ -827,13 +813,15 @@ export async function reparentSessionRecords(rawChildren, parent) {
|
|
|
827
813
|
}
|
|
828
814
|
}));
|
|
829
815
|
});
|
|
816
|
+
const notified = [];
|
|
830
817
|
if (parent)
|
|
831
818
|
for (const child of notify) {
|
|
832
819
|
const delivered = await sendText(parent, watchMessage(child), child.session);
|
|
833
820
|
if (!delivered.ok)
|
|
834
821
|
throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`);
|
|
822
|
+
notified.push(child.session);
|
|
835
823
|
}
|
|
836
|
-
return { children, parent, notified
|
|
824
|
+
return { children, parent, notified };
|
|
837
825
|
}
|
|
838
826
|
// tmux rewrites CONTROL characters in a format string before printing them — 3.6a turns both a tab and a raw
|
|
839
827
|
// 0x1f into `_`, while 3.4 turns a raw 0x1f into the printable escape `\037`. So the field separator is ASKED
|
|
@@ -1581,7 +1569,8 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
|
|
|
1581
1569
|
// invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
|
|
1582
1570
|
// parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
|
|
1583
1571
|
const pidPath = join(storeDir(id), 'agent.pid');
|
|
1584
|
-
const
|
|
1572
|
+
const receiptPath = join(storeDir(id), 'agent.identity.json');
|
|
1573
|
+
const born = `sh -c ${shQuote(`rm -f ${shQuote(receiptPath)}; printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`;
|
|
1585
1574
|
// Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
|
|
1586
1575
|
// ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
|
|
1587
1576
|
// has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
|
|
@@ -1686,21 +1675,105 @@ async function withSessionTransition(id, body) {
|
|
|
1686
1675
|
}
|
|
1687
1676
|
}
|
|
1688
1677
|
let draining = false; // re-entrancy guard: only one drain pass runs at a time (no double-launch)
|
|
1689
|
-
|
|
1690
|
-
|
|
1678
|
+
function noteQueuedLaunchFailureUnlocked(id, error) {
|
|
1679
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1680
|
+
const note = `queued launch readiness failed: ${reason}`;
|
|
1681
|
+
console.error(`spex: session ${id}: ${note}`);
|
|
1682
|
+
const rec = readRecord(id);
|
|
1683
|
+
if (rec && rec.note !== note)
|
|
1684
|
+
writeRecord({ ...rec, note });
|
|
1685
|
+
}
|
|
1686
|
+
function observeQueuedLaunchReadiness(id, harness) {
|
|
1687
|
+
void waitForReady(id, harness)
|
|
1688
|
+
.then(async (readiness) => {
|
|
1689
|
+
if (!readiness) {
|
|
1690
|
+
const committed = !!readRecord(id)?.harnessSessionId;
|
|
1691
|
+
throw new ResourceConflict(harness.launchPayloadProof
|
|
1692
|
+
? committed
|
|
1693
|
+
? 'post-proof adapter liveness did not become ready before launch readiness timed out'
|
|
1694
|
+
: 'native identity and first-turn rollout proof did not arrive before launch readiness timed out'
|
|
1695
|
+
: 'adapter liveness did not become ready before launch readiness timed out');
|
|
1696
|
+
}
|
|
1697
|
+
let readyToPublish = false;
|
|
1698
|
+
await withRecordLock(id, async () => {
|
|
1699
|
+
const candidate = readRecord(id);
|
|
1700
|
+
if (!candidate)
|
|
1701
|
+
return;
|
|
1702
|
+
const stillReady = await readiness.validate(() => {
|
|
1703
|
+
const current = readRecord(id);
|
|
1704
|
+
return current ? { ...current, runtimeDir: runtimeRoot() } : null;
|
|
1705
|
+
});
|
|
1706
|
+
if (!stillReady)
|
|
1707
|
+
throw new ResourceConflict('launch readiness changed before queued publication');
|
|
1708
|
+
const current = readRecord(id);
|
|
1709
|
+
if (!current)
|
|
1710
|
+
return;
|
|
1711
|
+
if (current.status === 'queued')
|
|
1712
|
+
writeRecord({ ...current, status: 'active', proposal: null, note: null, launchOwner: null });
|
|
1713
|
+
readyToPublish = true;
|
|
1714
|
+
});
|
|
1715
|
+
if (!readyToPublish)
|
|
1716
|
+
return;
|
|
1717
|
+
await deliverPendingWatchSnapshots(id);
|
|
1718
|
+
await drainSession(id);
|
|
1719
|
+
})
|
|
1720
|
+
.catch(async (error) => {
|
|
1721
|
+
try {
|
|
1722
|
+
await withRecordLock(id, async () => noteQueuedLaunchFailureUnlocked(id, error));
|
|
1723
|
+
}
|
|
1724
|
+
catch (recordError) {
|
|
1725
|
+
console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${error instanceof Error ? error.message : String(error)}`);
|
|
1726
|
+
}
|
|
1727
|
+
await clearPendingWatchSnapshots(id);
|
|
1728
|
+
})
|
|
1729
|
+
.finally(() => launching.delete(id));
|
|
1730
|
+
}
|
|
1731
|
+
// Launch a prepared `queued` worktree. Deterministic blockers retire its creation snapshot debt; a transport
|
|
1732
|
+
// attempt that may succeed on the next drain keeps that durable debt for the eventual real outcome.
|
|
1691
1733
|
async function startQueuedUnlocked(id) {
|
|
1692
1734
|
if (archiving.has(id))
|
|
1693
|
-
return
|
|
1735
|
+
return 'retryable';
|
|
1694
1736
|
const wt = await findWorktree(id);
|
|
1695
1737
|
if (!wt)
|
|
1696
|
-
return
|
|
1697
|
-
if (archiving.has(id)
|
|
1698
|
-
return
|
|
1738
|
+
return 'blocked';
|
|
1739
|
+
if (archiving.has(id))
|
|
1740
|
+
return 'retryable';
|
|
1741
|
+
if (wt.rec.archived)
|
|
1742
|
+
return 'blocked';
|
|
1699
1743
|
if (!canDrainQueued(wt.rec))
|
|
1700
|
-
return
|
|
1744
|
+
return 'retryable';
|
|
1745
|
+
const h = harnessById(wt.rec.harness || defaultHarness.id);
|
|
1746
|
+
if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
|
|
1747
|
+
launching.add(id);
|
|
1748
|
+
let readinessOwnsSlot = false;
|
|
1749
|
+
try {
|
|
1750
|
+
try {
|
|
1751
|
+
consumeHarnessLaunchProofUnlocked(id);
|
|
1752
|
+
}
|
|
1753
|
+
catch (error) {
|
|
1754
|
+
noteQueuedLaunchFailureUnlocked(id, error);
|
|
1755
|
+
throw error;
|
|
1756
|
+
}
|
|
1757
|
+
const recovered = readRecord(id) || wt.rec;
|
|
1758
|
+
writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null });
|
|
1759
|
+
observeQueuedLaunchReadiness(id, h);
|
|
1760
|
+
readinessOwnsSlot = true;
|
|
1761
|
+
return 'started';
|
|
1762
|
+
}
|
|
1763
|
+
finally {
|
|
1764
|
+
if (!readinessOwnsSlot)
|
|
1765
|
+
launching.delete(id);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1701
1768
|
const launchPrompt = readLaunchFile(id);
|
|
1702
|
-
if (launchPrompt == null)
|
|
1703
|
-
|
|
1769
|
+
if (launchPrompt == null) {
|
|
1770
|
+
const message = `authoritative resolved launch payload is missing for queued session ${id}; refusing to create an empty thread`;
|
|
1771
|
+
if (wt.rec.note !== message) {
|
|
1772
|
+
console.error(`spex: ${message}`);
|
|
1773
|
+
writeRecord({ ...wt.rec, note: message });
|
|
1774
|
+
}
|
|
1775
|
+
return 'blocked';
|
|
1776
|
+
}
|
|
1704
1777
|
// a queued worktree can go missing while it waits (a human cleaned up, a disk moved). Draining it would open
|
|
1705
1778
|
// a window that fast-exits and burn the retry budget every tick, so refuse ONCE, loudly, and stamp the reason
|
|
1706
1779
|
// on the record — the drainer then leaves it alone instead of spinning on a launch that cannot work.
|
|
@@ -1710,28 +1783,36 @@ async function startQueuedUnlocked(id) {
|
|
|
1710
1783
|
console.error(`spex: not launching queued session ${id}: ${blocked.message}`);
|
|
1711
1784
|
writeRecord({ ...wt.rec, note: blocked.message });
|
|
1712
1785
|
}
|
|
1713
|
-
return
|
|
1786
|
+
return 'blocked';
|
|
1714
1787
|
}
|
|
1715
1788
|
launching.add(id); // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
|
|
1716
|
-
|
|
1789
|
+
let readinessOwnsSlot = false;
|
|
1717
1790
|
try {
|
|
1718
|
-
|
|
1719
|
-
|
|
1791
|
+
try {
|
|
1792
|
+
const sq = shQuote(launchPrompt);
|
|
1793
|
+
await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec));
|
|
1794
|
+
}
|
|
1795
|
+
catch {
|
|
1796
|
+
return 'retryable'; // launch failed → stays `queued`, with its initial debt, for the next drain tick
|
|
1797
|
+
}
|
|
1798
|
+
// the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
|
|
1799
|
+
// launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
|
|
1800
|
+
// belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
|
|
1801
|
+
// headline precedence stands on.
|
|
1802
|
+
const launched = readRecord(id) || wt.rec;
|
|
1803
|
+
writeRecord({ ...launched, status: 'active', proposal: null, note: null, launchOwner: null });
|
|
1804
|
+
if (!h.launchPayloadProof)
|
|
1805
|
+
removeLaunchFile(id);
|
|
1806
|
+
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
1807
|
+
// wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
|
|
1808
|
+
observeQueuedLaunchReadiness(id, h);
|
|
1809
|
+
readinessOwnsSlot = true;
|
|
1810
|
+
return 'started';
|
|
1811
|
+
}
|
|
1812
|
+
finally {
|
|
1813
|
+
if (!readinessOwnsSlot)
|
|
1814
|
+
launching.delete(id);
|
|
1720
1815
|
}
|
|
1721
|
-
catch {
|
|
1722
|
-
launching.delete(id);
|
|
1723
|
-
return false; // launch failed → stays `queued`, retried on the next drain tick
|
|
1724
|
-
}
|
|
1725
|
-
// the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
|
|
1726
|
-
// launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
|
|
1727
|
-
// belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
|
|
1728
|
-
// headline precedence stands on.
|
|
1729
|
-
writeRecord({ ...wt.rec, status: 'active', proposal: null, note: null, launchOwner: null });
|
|
1730
|
-
removeLaunchFile(id); // consumed
|
|
1731
|
-
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
1732
|
-
// wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
|
|
1733
|
-
void waitForReady(id, h).finally(() => launching.delete(id));
|
|
1734
|
-
return true;
|
|
1735
1816
|
}
|
|
1736
1817
|
const startQueued = (id) => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)));
|
|
1737
1818
|
async function drainQueueUnlocked() {
|
|
@@ -1742,6 +1823,19 @@ async function drainQueueUnlocked() {
|
|
|
1742
1823
|
const cap = maxActive(); // read once per drain pass (spexcode.json → env → default); won't shift mid-burst
|
|
1743
1824
|
for (;;) {
|
|
1744
1825
|
const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()]);
|
|
1826
|
+
for (const session of sessions) {
|
|
1827
|
+
const rec = readRecord(session.id);
|
|
1828
|
+
if (!rec || launching.has(session.id) || !readWatchEntries(session.id).some((entry) => entry.snapshotPending))
|
|
1829
|
+
continue;
|
|
1830
|
+
if (rec.status === 'queued')
|
|
1831
|
+
continue;
|
|
1832
|
+
if (rec.status === 'active' && !rec.stopped && !rec.archived) {
|
|
1833
|
+
launching.add(session.id);
|
|
1834
|
+
observeQueuedLaunchReadiness(session.id, harnessById(rec.harness || defaultHarness.id));
|
|
1835
|
+
continue;
|
|
1836
|
+
}
|
|
1837
|
+
await deliverPendingWatchSnapshots(session.id, false);
|
|
1838
|
+
}
|
|
1745
1839
|
// if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
|
|
1746
1840
|
// session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
|
|
1747
1841
|
// MORE compute onto an already-thrashing box. Under load, do the safe thing — launch nothing this pass and
|
|
@@ -1749,8 +1843,16 @@ async function drainQueueUnlocked() {
|
|
|
1749
1843
|
if (snap.probeFailed)
|
|
1750
1844
|
break;
|
|
1751
1845
|
const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0);
|
|
1752
|
-
if (occupied >= cap)
|
|
1846
|
+
if (occupied >= cap) {
|
|
1847
|
+
const authority = backendLaunchAuthority();
|
|
1848
|
+
await Promise.all(sessions.filter((session) => {
|
|
1849
|
+
if (session.status !== 'queued')
|
|
1850
|
+
return false;
|
|
1851
|
+
const rec = readRecord(session.id);
|
|
1852
|
+
return !!rec && canDrainQueued(rec, authority);
|
|
1853
|
+
}).map((session) => deliverPendingWatchSnapshots(session.id)));
|
|
1753
1854
|
break;
|
|
1855
|
+
}
|
|
1754
1856
|
const authority = backendLaunchAuthority();
|
|
1755
1857
|
const next = sessions.find((s) => {
|
|
1756
1858
|
if (s.status !== 'queued' || launching.has(s.id))
|
|
@@ -1760,8 +1862,12 @@ async function drainQueueUnlocked() {
|
|
|
1760
1862
|
});
|
|
1761
1863
|
if (!next)
|
|
1762
1864
|
break;
|
|
1763
|
-
|
|
1865
|
+
const started = await startQueued(next.id);
|
|
1866
|
+
if (started !== 'started') {
|
|
1867
|
+
if (started === 'blocked')
|
|
1868
|
+
await clearPendingWatchSnapshots(next.id);
|
|
1764
1869
|
break; // launch failed → stop this pass; a later tick retries
|
|
1870
|
+
}
|
|
1765
1871
|
}
|
|
1766
1872
|
}
|
|
1767
1873
|
finally {
|
|
@@ -2491,6 +2597,8 @@ async function prepareSession(prompt, parent, launcher, name, context) {
|
|
|
2491
2597
|
chosen = resolveLauncher(lname);
|
|
2492
2598
|
h = harnessById(chosen.harness);
|
|
2493
2599
|
pinned = h.baseCmd(chosen.cmd);
|
|
2600
|
+
if (h.ownsRendezvous)
|
|
2601
|
+
assertRvSockPath(id);
|
|
2494
2602
|
}
|
|
2495
2603
|
catch (error) {
|
|
2496
2604
|
throw new SessionCreateError('session_create_failed', phase, error instanceof Error ? error.message : String(error), 400);
|
|
@@ -2613,6 +2721,12 @@ async function prepareSession(prompt, parent, launcher, name, context) {
|
|
|
2613
2721
|
throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500);
|
|
2614
2722
|
throwIfCreateAborted(signal, phase);
|
|
2615
2723
|
writeRecord(rec);
|
|
2724
|
+
if (rec.parent && readRecord(rec.parent)?.governed) {
|
|
2725
|
+
const watchers = readWatchEntries(id);
|
|
2726
|
+
const next = addWatchSource(watchers, rec.parent, 'parent', true);
|
|
2727
|
+
if (next.added)
|
|
2728
|
+
writeWatchEntries(id, next.entries);
|
|
2729
|
+
}
|
|
2616
2730
|
published = true;
|
|
2617
2731
|
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root);
|
|
2618
2732
|
if (receiptFailure)
|
|
@@ -2694,7 +2808,7 @@ const SOCKET_READY_TIMEOUT_MS = 30000; // spans launchScript's bounded fast-fail
|
|
|
2694
2808
|
// waitForReady (slot-hold + resume) waits through a daemon-race retry
|
|
2695
2809
|
// instead of returning before a recovering socket
|
|
2696
2810
|
const SOCKET_POLL_MS = 200;
|
|
2697
|
-
async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEOUT_MS) {
|
|
2811
|
+
async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEOUT_MS, recordLockHeld = false) {
|
|
2698
2812
|
const current = () => {
|
|
2699
2813
|
const stored = readRecord(id);
|
|
2700
2814
|
const rec = stored && pending
|
|
@@ -2703,6 +2817,20 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
|
|
|
2703
2817
|
return rec ? { ...rec, runtimeDir: runtimeRoot() } : null;
|
|
2704
2818
|
};
|
|
2705
2819
|
const deadline = Date.now() + timeoutMs;
|
|
2820
|
+
if (harness.launchPayloadProof && !current()?.harnessSessionId) {
|
|
2821
|
+
for (;;) {
|
|
2822
|
+
if (existsSync(sessionArtifactPath(id, 'launch.proof'))) {
|
|
2823
|
+
if (recordLockHeld)
|
|
2824
|
+
consumeHarnessLaunchProofUnlocked(id);
|
|
2825
|
+
else
|
|
2826
|
+
await withRecordLock(id, async () => consumeHarnessLaunchProofUnlocked(id));
|
|
2827
|
+
break;
|
|
2828
|
+
}
|
|
2829
|
+
if (Date.now() >= deadline)
|
|
2830
|
+
return null;
|
|
2831
|
+
await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2706
2834
|
if (harness.launchReady)
|
|
2707
2835
|
return harness.launchReady(current, deadline);
|
|
2708
2836
|
const genericFence = () => ({
|
|
@@ -2723,6 +2851,7 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
|
|
|
2723
2851
|
await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
|
|
2724
2852
|
}
|
|
2725
2853
|
}
|
|
2854
|
+
const restingLifecycle = (status) => status === 'active' || status === 'queued' ? 'idle' : status;
|
|
2726
2855
|
async function resumeSessionUnlocked(id, opts = {}) {
|
|
2727
2856
|
const { force = false, guard = true } = opts;
|
|
2728
2857
|
let wt;
|
|
@@ -2759,6 +2888,15 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2759
2888
|
if (blocked)
|
|
2760
2889
|
return { ok: false, refused: true, error: blocked.message };
|
|
2761
2890
|
const h = harnessById(wt.rec.harness || defaultHarness.id);
|
|
2891
|
+
// A prior adapter process may have proven identity + first-turn durability just before its session owner
|
|
2892
|
+
// died. Consume that receipt before choosing a recovery tail, so retry resumes the proven thread instead of
|
|
2893
|
+
// creating another one with the same first prompt.
|
|
2894
|
+
if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
|
|
2895
|
+
consumeHarnessLaunchProofUnlocked(id);
|
|
2896
|
+
wt = await findWorktree(id);
|
|
2897
|
+
if (!wt)
|
|
2898
|
+
return { ok: false, error: `session ${id} disappeared while recovering native launch proof` };
|
|
2899
|
+
}
|
|
2762
2900
|
// An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
|
|
2763
2901
|
// it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
|
|
2764
2902
|
// resume can never kill a live leaf merely because the record was hidden.
|
|
@@ -2799,21 +2937,28 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2799
2937
|
// Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
|
|
2800
2938
|
// starting -> online launch path recreates the same conversation.
|
|
2801
2939
|
const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec;
|
|
2802
|
-
const resumed = { ...current, archived: false, coldProof: null, status: current.status
|
|
2940
|
+
const resumed = { ...current, archived: false, coldProof: null, status: restingLifecycle(current.status), stopped: false };
|
|
2803
2941
|
if (force || lv === 'offline') {
|
|
2942
|
+
let resumeTail;
|
|
2943
|
+
try {
|
|
2944
|
+
resumeTail = h.resumeArg(wt.rec, readLaunchFile(id)).trim();
|
|
2945
|
+
}
|
|
2946
|
+
catch (error) {
|
|
2947
|
+
return { ok: false, refused: true, error: error instanceof Error ? error.message : String(error) };
|
|
2948
|
+
}
|
|
2804
2949
|
await tmuxOk(['kill-session', '-t', id]); // drop a dead/offline pane (or a force-killed live one)
|
|
2805
|
-
await launch(id, wt.path,
|
|
2950
|
+
await launch(id, wt.path, resumeTail, h, launcherCmd(wt.rec));
|
|
2806
2951
|
let readiness = null;
|
|
2807
2952
|
let readinessError = '';
|
|
2808
2953
|
try {
|
|
2809
|
-
readiness = await waitForReady(id, h, resumed);
|
|
2954
|
+
readiness = await waitForReady(id, h, resumed, SOCKET_READY_TIMEOUT_MS, true);
|
|
2810
2955
|
}
|
|
2811
2956
|
catch (error) {
|
|
2812
2957
|
readinessError = error instanceof Error ? error.message : String(error);
|
|
2813
2958
|
}
|
|
2814
2959
|
if (!readiness) {
|
|
2815
2960
|
const failed = readRecord(id) || current;
|
|
2816
|
-
writeRecord({ ...failed, ...preResume, launchReadinessPending: null });
|
|
2961
|
+
writeRecord({ ...failed, ...preResume, harnessSessionId: failed.harnessSessionId, launchReadinessPending: null });
|
|
2817
2962
|
return {
|
|
2818
2963
|
ok: false,
|
|
2819
2964
|
refused: true,
|
|
@@ -2825,7 +2970,7 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2825
2970
|
...latest,
|
|
2826
2971
|
archived: false,
|
|
2827
2972
|
coldProof: null,
|
|
2828
|
-
status: latest.status
|
|
2973
|
+
status: restingLifecycle(latest.status),
|
|
2829
2974
|
stopped: false,
|
|
2830
2975
|
launchReadinessPending: launchReadinessPending(preResume),
|
|
2831
2976
|
};
|
|
@@ -2856,7 +3001,12 @@ async function resumeSessionUnlocked(id, opts = {}) {
|
|
|
2856
3001
|
writeRecord(resumed);
|
|
2857
3002
|
return { ok: true };
|
|
2858
3003
|
}
|
|
2859
|
-
export const resumeSession = (id, opts = {}) => withSessionTransition(id,
|
|
3004
|
+
export const resumeSession = (id, opts = {}) => withSessionTransition(id, async () => {
|
|
3005
|
+
const result = await withRecordLock(id, () => resumeSessionUnlocked(id, opts));
|
|
3006
|
+
if (result.ok)
|
|
3007
|
+
await drainSession(id);
|
|
3008
|
+
return result;
|
|
3009
|
+
});
|
|
2860
3010
|
export function markState(status, opts = {}) {
|
|
2861
3011
|
const id = opts.sessionId || ownSessionId();
|
|
2862
3012
|
if (!id)
|
|
@@ -2893,50 +3043,158 @@ export function markHeadlessTurnFailure(sessionId, harness, exitCode) {
|
|
|
2893
3043
|
const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`;
|
|
2894
3044
|
return markTurnFailure(sessionId, `${harness} turn exited with ${outcome}`);
|
|
2895
3045
|
}
|
|
2896
|
-
|
|
3046
|
+
function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim()) {
|
|
3047
|
+
const id = rec.session;
|
|
3048
|
+
if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
|
|
3049
|
+
throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`);
|
|
3050
|
+
const codex = rec.harness === 'codex' || rec.harness === 'codex-headless';
|
|
3051
|
+
const root = runtimeRoot();
|
|
3052
|
+
let priorBinding = null;
|
|
3053
|
+
let registrationPrepared = false;
|
|
3054
|
+
if (codex) {
|
|
3055
|
+
const ledger = readCodexGenerationLedger(root);
|
|
3056
|
+
if (ledger.revision > 0 && !generationId)
|
|
3057
|
+
throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`);
|
|
3058
|
+
priorBinding = codexGenerationBindingForSession(root, id);
|
|
3059
|
+
if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
|
|
3060
|
+
throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`);
|
|
3061
|
+
if (generationId && !priorBinding) {
|
|
3062
|
+
prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId);
|
|
3063
|
+
registrationPrepared = true;
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
try {
|
|
3067
|
+
writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null });
|
|
3068
|
+
}
|
|
3069
|
+
catch (error) {
|
|
3070
|
+
if (codex && generationId && registrationPrepared) {
|
|
3071
|
+
try {
|
|
3072
|
+
bindCodexGeneration(root, id, harnessSessionId, null);
|
|
3073
|
+
}
|
|
3074
|
+
catch (rollback) {
|
|
3075
|
+
throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
throw error;
|
|
3079
|
+
}
|
|
3080
|
+
if (codex && generationId)
|
|
3081
|
+
commitCodexGenerationRegistration(root, id, harnessSessionId, generationId);
|
|
3082
|
+
}
|
|
3083
|
+
function readHarnessLaunchProof(id) {
|
|
3084
|
+
try {
|
|
3085
|
+
const proof = JSON.parse(readFileSync(sessionArtifactPath(id, 'launch.proof'), 'utf8'));
|
|
3086
|
+
if (!proof || proof.version !== 1 || typeof proof.sessionId !== 'string'
|
|
3087
|
+
|| typeof proof.harnessId !== 'string' || typeof proof.harnessSessionId !== 'string' || !proof.harnessSessionId
|
|
3088
|
+
|| typeof proof.launchPayloadHash !== 'string'
|
|
3089
|
+
|| (proof.generationId !== null && typeof proof.generationId !== 'string'))
|
|
3090
|
+
throw new Error('invalid receipt shape');
|
|
3091
|
+
return proof;
|
|
3092
|
+
}
|
|
3093
|
+
catch (error) {
|
|
3094
|
+
throw new ResourceConflict(`native launch proof for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
function sameHarnessLaunchProof(left, right) {
|
|
3098
|
+
return left.version === right.version && left.sessionId === right.sessionId && left.harnessId === right.harnessId
|
|
3099
|
+
&& left.harnessSessionId === right.harnessSessionId && left.launchPayloadHash === right.launchPayloadHash
|
|
3100
|
+
&& left.generationId === right.generationId;
|
|
3101
|
+
}
|
|
3102
|
+
export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPayload) {
|
|
2897
3103
|
const id = sessionId || ownSessionId();
|
|
2898
3104
|
if (!id || !harnessSessionId)
|
|
2899
3105
|
return false;
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
3106
|
+
const rec = readLiveRecord(id);
|
|
3107
|
+
if (!rec)
|
|
3108
|
+
return false;
|
|
3109
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3110
|
+
if (!harness.launchPayloadProof)
|
|
3111
|
+
throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload proof`);
|
|
3112
|
+
const pending = readLaunchFile(id);
|
|
3113
|
+
if (pending == null)
|
|
3114
|
+
throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
|
|
3115
|
+
if (pending !== launchPayload)
|
|
3116
|
+
throw new ResourceConflict(`refusing native launch proof for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
|
|
3117
|
+
const generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim() || null;
|
|
3118
|
+
if (rec.harness === 'codex' || rec.harness === 'codex-headless') {
|
|
3119
|
+
const ledger = readCodexGenerationLedger(runtimeRoot());
|
|
3120
|
+
if (ledger.revision > 0 && !generationId)
|
|
3121
|
+
throw new ResourceConflict(`refusing native launch proof for ${id}: launch did not provide an exact Codex generation id`);
|
|
3122
|
+
if (generationId && (!ledger.generations[generationId] || ledger.generations[generationId].state === 'reclaimed'))
|
|
3123
|
+
throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: generation ${generationId} is absent or reclaimed`);
|
|
3124
|
+
}
|
|
3125
|
+
const proof = {
|
|
3126
|
+
version: 1,
|
|
3127
|
+
sessionId: id,
|
|
3128
|
+
harnessId: harness.id,
|
|
3129
|
+
harnessSessionId,
|
|
3130
|
+
launchPayloadHash: createHash('sha256').update(launchPayload).digest('hex'),
|
|
3131
|
+
generationId,
|
|
3132
|
+
};
|
|
3133
|
+
const path = sessionArtifactPath(id, 'launch.proof');
|
|
3134
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
3135
|
+
writeFileSync(temp, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
|
3136
|
+
try {
|
|
3137
|
+
linkSync(temp, path);
|
|
3138
|
+
return true;
|
|
3139
|
+
}
|
|
3140
|
+
catch (error) {
|
|
3141
|
+
if (error.code !== 'EEXIST')
|
|
3142
|
+
throw error;
|
|
3143
|
+
const staged = readHarnessLaunchProof(id);
|
|
3144
|
+
if (sameHarnessLaunchProof(staged, proof))
|
|
3145
|
+
return true;
|
|
3146
|
+
throw new ResourceConflict(`refusing to replace native launch proof for ${id}: the staged session, thread, payload, or generation differs`);
|
|
3147
|
+
}
|
|
3148
|
+
finally {
|
|
3149
|
+
rmSync(temp, { force: true });
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
function consumeHarnessLaunchProofUnlocked(id) {
|
|
3153
|
+
const rec = readLiveRecord(id);
|
|
3154
|
+
if (!rec)
|
|
3155
|
+
return false;
|
|
3156
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3157
|
+
const proof = readHarnessLaunchProof(id);
|
|
3158
|
+
if (proof.sessionId !== id || proof.harnessId !== harness.id)
|
|
3159
|
+
throw new ResourceConflict(`native launch proof for ${id} does not match the governed adapter identity`);
|
|
3160
|
+
const pending = readLaunchFile(id);
|
|
3161
|
+
if (pending == null && rec.harnessSessionId !== proof.harnessSessionId)
|
|
3162
|
+
throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
|
|
3163
|
+
if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
|
|
3164
|
+
throw new ResourceConflict(`native launch proof for ${id} does not match the authoritative resolved launch payload`);
|
|
3165
|
+
bindHarnessSessionIdUnlocked(rec, proof.harnessSessionId, proof.generationId || undefined);
|
|
3166
|
+
if (pending != null) {
|
|
2924
3167
|
try {
|
|
2925
|
-
|
|
3168
|
+
rmSync(sessionArtifactPath(id, 'launch'));
|
|
2926
3169
|
}
|
|
2927
3170
|
catch (error) {
|
|
2928
|
-
if (
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
}
|
|
2932
|
-
catch (rollback) {
|
|
2933
|
-
throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`);
|
|
2934
|
-
}
|
|
3171
|
+
if (error.code !== 'ENOENT') {
|
|
3172
|
+
console.error(`spex: native launch proof committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3173
|
+
return true;
|
|
2935
3174
|
}
|
|
2936
|
-
throw error;
|
|
2937
3175
|
}
|
|
2938
|
-
|
|
2939
|
-
|
|
3176
|
+
}
|
|
3177
|
+
try {
|
|
3178
|
+
rmSync(sessionArtifactPath(id, 'launch.proof'));
|
|
3179
|
+
}
|
|
3180
|
+
catch (error) {
|
|
3181
|
+
if (error.code !== 'ENOENT')
|
|
3182
|
+
console.error(`spex: native launch proof committed for ${id}, but launch.proof could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3183
|
+
}
|
|
3184
|
+
return true;
|
|
3185
|
+
}
|
|
3186
|
+
export function markHarnessSessionId(sessionId, harnessSessionId) {
|
|
3187
|
+
const id = sessionId || ownSessionId();
|
|
3188
|
+
if (!id || !harnessSessionId)
|
|
3189
|
+
return false;
|
|
3190
|
+
return withRecordLockSync(id, () => {
|
|
3191
|
+
const rec = readLiveRecord(id);
|
|
3192
|
+
if (!rec)
|
|
3193
|
+
return false;
|
|
3194
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3195
|
+
if (harness.launchPayloadProof)
|
|
3196
|
+
throw new ResourceConflict(`harness ${harness.id} must stage native identity together with authoritative first-turn payload proof`);
|
|
3197
|
+
bindHarnessSessionIdUnlocked(rec, harnessSessionId);
|
|
2940
3198
|
return true;
|
|
2941
3199
|
});
|
|
2942
3200
|
}
|
|
@@ -3116,24 +3374,119 @@ export async function mergeSession(id) {
|
|
|
3116
3374
|
// then SIGKILL, each bounded. This is also what lets the socket sweep run at all — a still-answering listener
|
|
3117
3375
|
// is never ours to unlink, so an un-killed agent would otherwise strand its own socket forever.
|
|
3118
3376
|
// The escalation is IDENTITY-GUARDED: a recorded pid can have been recycled by an unrelated process, so we
|
|
3119
|
-
// signal only
|
|
3120
|
-
// adapter's proof-of-death rule leave the transport alone
|
|
3377
|
+
// signal only the immutable pid/start instance whose ownership was witnessed in the exact tmux pane closure.
|
|
3378
|
+
// Unidentifiable → we signal nothing and let the adapter's proof-of-death rule leave the transport alone.
|
|
3121
3379
|
const AGENT_EXIT_GRACE_MS = 3000;
|
|
3380
|
+
const SESSION_LEAF_RECEIPT_VERSION = 1;
|
|
3381
|
+
const SESSION_LEAF_RECEIPT_KIND = 'session-leaf';
|
|
3382
|
+
export function parseSessionLeafReceipt(raw, sessionId) {
|
|
3383
|
+
let value;
|
|
3384
|
+
try {
|
|
3385
|
+
value = JSON.parse(raw);
|
|
3386
|
+
}
|
|
3387
|
+
catch {
|
|
3388
|
+
return null;
|
|
3389
|
+
}
|
|
3390
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3391
|
+
return null;
|
|
3392
|
+
const row = value;
|
|
3393
|
+
const keys = Object.keys(row).sort();
|
|
3394
|
+
if (keys.join(',') !== 'kind,pid,sessionId,startToken,version')
|
|
3395
|
+
return null;
|
|
3396
|
+
if (row.version !== SESSION_LEAF_RECEIPT_VERSION || row.kind !== SESSION_LEAF_RECEIPT_KIND || row.sessionId !== sessionId)
|
|
3397
|
+
return null;
|
|
3398
|
+
if (!Number.isSafeInteger(row.pid) || row.pid <= 0 || typeof row.startToken !== 'string' || !row.startToken)
|
|
3399
|
+
return null;
|
|
3400
|
+
return row;
|
|
3401
|
+
}
|
|
3402
|
+
function pidInPaneClosure(pid, panePid, procs) {
|
|
3403
|
+
const seen = new Set();
|
|
3404
|
+
for (let current = pid; current > 0 && !seen.has(current);) {
|
|
3405
|
+
if (current === panePid)
|
|
3406
|
+
return true;
|
|
3407
|
+
seen.add(current);
|
|
3408
|
+
const row = procs.get(current);
|
|
3409
|
+
if (!row || row.ppid === current)
|
|
3410
|
+
return false;
|
|
3411
|
+
current = row.ppid;
|
|
3412
|
+
}
|
|
3413
|
+
return false;
|
|
3414
|
+
}
|
|
3415
|
+
export function sessionLeafReceiptCandidate(sessionId, pid, panePid, procs, startBefore, startAfter) {
|
|
3416
|
+
if (!panePid)
|
|
3417
|
+
return { ok: false, reason: 'exact target pane PID is unavailable' };
|
|
3418
|
+
if (!procs)
|
|
3419
|
+
return { ok: false, reason: 'process snapshot is unavailable' };
|
|
3420
|
+
if (!startBefore || !startAfter)
|
|
3421
|
+
return { ok: false, reason: 'leaf process-start identity is unreadable' };
|
|
3422
|
+
if (startBefore !== startAfter)
|
|
3423
|
+
return { ok: false, reason: 'leaf process-start identity changed during ancestry observation' };
|
|
3424
|
+
if (!pidInPaneClosure(pid, panePid, procs))
|
|
3425
|
+
return { ok: false, reason: `registered leaf PID ${pid} is not in exact target pane ${panePid} descendant closure` };
|
|
3426
|
+
return {
|
|
3427
|
+
ok: true,
|
|
3428
|
+
receipt: { version: SESSION_LEAF_RECEIPT_VERSION, kind: SESSION_LEAF_RECEIPT_KIND, sessionId, pid, startToken: startAfter },
|
|
3429
|
+
};
|
|
3430
|
+
}
|
|
3431
|
+
export function sessionLeafReceiptIdentityState(receipt, registeredPid, currentStartToken, liveness) {
|
|
3432
|
+
if (registeredPid == null)
|
|
3433
|
+
return 'registration-missing';
|
|
3434
|
+
if (registeredPid !== receipt.pid)
|
|
3435
|
+
return 'registration-changed';
|
|
3436
|
+
if (liveness === 'unknown')
|
|
3437
|
+
return 'unknown';
|
|
3438
|
+
if (liveness === 'dead')
|
|
3439
|
+
return currentStartToken ? 'unknown' : 'gone';
|
|
3440
|
+
if (!currentStartToken)
|
|
3441
|
+
return 'unknown';
|
|
3442
|
+
return currentStartToken === receipt.startToken ? 'same-live' : 'pid-reused';
|
|
3443
|
+
}
|
|
3444
|
+
const sessionLeafReceiptPath = (id) => sessionArtifactPath(id, 'agent.identity.json');
|
|
3445
|
+
function readSessionLeafReceipt(id) {
|
|
3446
|
+
const path = sessionLeafReceiptPath(id);
|
|
3447
|
+
let raw;
|
|
3448
|
+
try {
|
|
3449
|
+
raw = readFileSync(path, 'utf8');
|
|
3450
|
+
}
|
|
3451
|
+
catch (error) {
|
|
3452
|
+
if (error.code === 'ENOENT')
|
|
3453
|
+
return { state: 'missing' };
|
|
3454
|
+
return { state: 'invalid', reason: `leaf birth receipt is unreadable (${error instanceof Error ? error.message : String(error)})` };
|
|
3455
|
+
}
|
|
3456
|
+
const receipt = parseSessionLeafReceipt(raw, id);
|
|
3457
|
+
return receipt ? { state: 'valid', receipt } : { state: 'invalid', reason: 'leaf birth receipt is malformed or names a different session' };
|
|
3458
|
+
}
|
|
3459
|
+
function writeSessionLeafReceipt(id, receipt) {
|
|
3460
|
+
const path = sessionLeafReceiptPath(id);
|
|
3461
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
3462
|
+
try {
|
|
3463
|
+
writeFileSync(temp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 });
|
|
3464
|
+
renameSync(temp, path);
|
|
3465
|
+
}
|
|
3466
|
+
finally {
|
|
3467
|
+
rmSync(temp, { force: true });
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
function clearSessionLeafArtifacts(id) {
|
|
3471
|
+
rmSync(sessionArtifactPath(id, 'agent.pid'), { force: true });
|
|
3472
|
+
pidRegistry.delete(id);
|
|
3473
|
+
rmSync(sessionLeafReceiptPath(id), { force: true });
|
|
3474
|
+
}
|
|
3475
|
+
const sameSessionLeafReceipt = (left, right) => left.version === right.version && left.kind === right.kind && left.sessionId === right.sessionId
|
|
3476
|
+
&& left.pid === right.pid && left.startToken === right.startToken;
|
|
3122
3477
|
async function killAgentProcess(id, beforeSignal, leaf) {
|
|
3123
|
-
const pid =
|
|
3124
|
-
if (pid !== leaf.pid)
|
|
3125
|
-
throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`);
|
|
3126
|
-
if (!Number.isFinite(pid) || pid <= 0)
|
|
3127
|
-
return;
|
|
3478
|
+
const pid = leaf.pid;
|
|
3128
3479
|
const startToken = leaf.startToken;
|
|
3129
3480
|
const alive = () => leafAlive(pid);
|
|
3130
3481
|
const identityState = () => {
|
|
3131
|
-
|
|
3482
|
+
const stored = readSessionLeafReceipt(id);
|
|
3483
|
+
if (stored.state !== 'valid' || !sameSessionLeafReceipt(stored.receipt, leaf.receipt))
|
|
3132
3484
|
return 'changed';
|
|
3133
|
-
const
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3485
|
+
const registered = readAgentPid(sessionArtifactPath(id, 'agent.pid'));
|
|
3486
|
+
const liveness = leafProcessLiveness(pid);
|
|
3487
|
+
const currentStartToken = sessionLeafStartToken(pid);
|
|
3488
|
+
const state = sessionLeafReceiptIdentityState(stored.receipt, Number.isSafeInteger(registered) ? registered : null, currentStartToken, liveness);
|
|
3489
|
+
return state === 'same-live' ? 'same' : state === 'gone' ? 'gone' : 'changed';
|
|
3137
3490
|
};
|
|
3138
3491
|
const initialState = identityState();
|
|
3139
3492
|
if (initialState === 'gone')
|
|
@@ -3150,12 +3503,6 @@ async function killAgentProcess(id, beforeSignal, leaf) {
|
|
|
3150
3503
|
};
|
|
3151
3504
|
if (await gone(AGENT_EXIT_GRACE_MS))
|
|
3152
3505
|
return; // the pane's SIGHUP took it — the normal path
|
|
3153
|
-
const sameAgentInstance = async () => {
|
|
3154
|
-
if (processStartToken(pid) !== startToken)
|
|
3155
|
-
return false;
|
|
3156
|
-
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '');
|
|
3157
|
-
return argv.includes(leaf.ownerNeedle) && processStartToken(pid) === startToken;
|
|
3158
|
-
};
|
|
3159
3506
|
for (const sig of ['SIGTERM', 'SIGKILL']) {
|
|
3160
3507
|
await beforeSignal();
|
|
3161
3508
|
const state = identityState();
|
|
@@ -3163,8 +3510,6 @@ async function killAgentProcess(id, beforeSignal, leaf) {
|
|
|
3163
3510
|
return;
|
|
3164
3511
|
if (state === 'changed')
|
|
3165
3512
|
throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed during escalation`);
|
|
3166
|
-
if (!await sameAgentInstance())
|
|
3167
|
-
throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${pid}@${startToken} no longer proves ownership`);
|
|
3168
3513
|
try {
|
|
3169
3514
|
process.kill(pid, sig);
|
|
3170
3515
|
}
|
|
@@ -3174,29 +3519,36 @@ async function killAgentProcess(id, beforeSignal, leaf) {
|
|
|
3174
3519
|
if (await gone(sig === 'SIGTERM' ? AGENT_EXIT_GRACE_MS : 1000))
|
|
3175
3520
|
return;
|
|
3176
3521
|
}
|
|
3522
|
+
throw new ResourceConflict(`refusing to stop ${id}: exact leaf PID ${pid}@${startToken} remains live after escalation`);
|
|
3177
3523
|
}
|
|
3178
|
-
|
|
3179
|
-
// two: kill the agent's tmux client, make sure the agent itself actually went with it, drop its boot-window
|
|
3180
|
-
// stamp (else a just-launched id lingers in the grace window reading `starting` instead of `offline`), and ask
|
|
3181
|
-
// the resolved adapter to sweep its ephemeral runtime transport — in that order, because the adapter only
|
|
3182
|
-
// removes a transport whose listener is PROVEN dead.
|
|
3183
|
-
// Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
|
|
3184
|
-
// @@@ leafAlive - does this pid name a live process? EPERM counts as alive (a process we may not signal is
|
|
3185
|
-
// still a process); only ESRCH is absence. Kept local: git.ts carries its own copy for lock reclamation, and
|
|
3186
|
-
// collapsing the two is part of the spec/eval unification lane, not of this fix.
|
|
3187
|
-
const leafAlive = (pid) => {
|
|
3524
|
+
const hostLeafProcessLiveness = (pid) => {
|
|
3188
3525
|
try {
|
|
3189
3526
|
process.kill(pid, 0);
|
|
3190
|
-
return
|
|
3527
|
+
return 'alive';
|
|
3191
3528
|
}
|
|
3192
3529
|
catch (error) {
|
|
3193
|
-
|
|
3530
|
+
const code = error?.code;
|
|
3531
|
+
if (code === 'ESRCH')
|
|
3532
|
+
return 'dead';
|
|
3533
|
+
if (code === 'EPERM')
|
|
3534
|
+
return 'alive';
|
|
3535
|
+
return 'unknown';
|
|
3194
3536
|
}
|
|
3195
3537
|
};
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3538
|
+
let sessionLeafProcessProbe = {
|
|
3539
|
+
startToken: processStartToken,
|
|
3540
|
+
liveness: hostLeafProcessLiveness,
|
|
3541
|
+
};
|
|
3542
|
+
export function installSessionLeafProcessProbeForTest(probe) {
|
|
3543
|
+
const previous = sessionLeafProcessProbe;
|
|
3544
|
+
sessionLeafProcessProbe = probe;
|
|
3545
|
+
return () => { sessionLeafProcessProbe = previous; };
|
|
3546
|
+
}
|
|
3547
|
+
const sessionLeafStartToken = (pid) => sessionLeafProcessProbe.startToken(pid);
|
|
3548
|
+
const leafProcessLiveness = (pid) => sessionLeafProcessProbe.liveness(pid);
|
|
3549
|
+
const leafAlive = (pid) => leafProcessLiveness(pid) !== 'dead';
|
|
3550
|
+
// A retained leaf can mint durable ownership only while its registered PID is in the exact target pane's process
|
|
3551
|
+
// closure. The receipt then binds that one immutable process instance across tmux reparenting and crash retry.
|
|
3200
3552
|
async function inspectSessionLeafIdentity(id, rec) {
|
|
3201
3553
|
if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter')
|
|
3202
3554
|
return { state: 'missing' };
|
|
@@ -3206,34 +3558,84 @@ async function inspectSessionLeafIdentity(id, rec) {
|
|
|
3206
3558
|
raw = readFileSync(path, 'utf8');
|
|
3207
3559
|
}
|
|
3208
3560
|
catch (error) {
|
|
3209
|
-
if (error?.code === 'ENOENT')
|
|
3210
|
-
|
|
3561
|
+
if (error?.code === 'ENOENT') {
|
|
3562
|
+
const stored = readSessionLeafReceipt(id);
|
|
3563
|
+
if (stored.state === 'invalid')
|
|
3564
|
+
return { state: 'unknown', reason: stored.reason };
|
|
3565
|
+
if (stored.state === 'missing')
|
|
3566
|
+
return { state: 'missing' };
|
|
3567
|
+
const liveness = leafProcessLiveness(stored.receipt.pid);
|
|
3568
|
+
const current = sessionLeafStartToken(stored.receipt.pid);
|
|
3569
|
+
const state = sessionLeafReceiptIdentityState(stored.receipt, stored.receipt.pid, current, liveness);
|
|
3570
|
+
if (state === 'same-live')
|
|
3571
|
+
return { state: 'unknown', pid: stored.receipt.pid, reason: 'leaf birth receipt remains live but agent.pid registration is missing' };
|
|
3572
|
+
if (state === 'unknown')
|
|
3573
|
+
return { state: 'unknown', pid: stored.receipt.pid, reason: 'leaf birth receipt PID is live but its process-start identity is unreadable' };
|
|
3574
|
+
if (state !== 'gone' && state !== 'pid-reused')
|
|
3575
|
+
return { state: 'unknown', pid: stored.receipt.pid, reason: `leaf birth receipt cannot reconcile a missing registration (${state})` };
|
|
3576
|
+
rmSync(sessionLeafReceiptPath(id), { force: true });
|
|
3577
|
+
return { state: 'dead', pid: stored.receipt.pid };
|
|
3578
|
+
}
|
|
3211
3579
|
return { state: 'unknown', reason: `leaf PID artifact is unreadable (${error instanceof Error ? error.message : String(error)})` };
|
|
3212
3580
|
}
|
|
3213
3581
|
const pid = Number(raw.trim());
|
|
3214
3582
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
3215
3583
|
return { state: 'unknown', reason: 'leaf PID artifact is malformed' };
|
|
3216
|
-
const
|
|
3217
|
-
if (
|
|
3218
|
-
return
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3584
|
+
const stored = readSessionLeafReceipt(id);
|
|
3585
|
+
if (stored.state === 'invalid')
|
|
3586
|
+
return { state: 'unknown', pid, reason: stored.reason };
|
|
3587
|
+
if (stored.state === 'valid') {
|
|
3588
|
+
const liveness = leafProcessLiveness(pid);
|
|
3589
|
+
const current = sessionLeafStartToken(pid);
|
|
3590
|
+
const state = sessionLeafReceiptIdentityState(stored.receipt, pid, current, liveness);
|
|
3591
|
+
if (state === 'same-live')
|
|
3592
|
+
return { state: 'owned', identity: { pid, startToken: current, receipt: stored.receipt } };
|
|
3593
|
+
if (state === 'gone') {
|
|
3594
|
+
clearSessionLeafArtifacts(id);
|
|
3595
|
+
return { state: 'dead', pid };
|
|
3596
|
+
}
|
|
3597
|
+
if (state === 'pid-reused') {
|
|
3598
|
+
const snap = await liveSnapshot(id);
|
|
3599
|
+
if (snap.probeFailed)
|
|
3600
|
+
return { state: 'unknown', pid, reason: 'target pane state is unreadable while reconciling a retired leaf receipt' };
|
|
3601
|
+
if (snap.windows.has(id))
|
|
3602
|
+
return { state: 'unknown', pid, reason: `leaf birth receipt no longer matches PID ${pid} while the exact target pane remains live` };
|
|
3603
|
+
clearSessionLeafArtifacts(id);
|
|
3604
|
+
return { state: 'dead', pid };
|
|
3605
|
+
}
|
|
3606
|
+
if (state === 'unknown')
|
|
3607
|
+
return { state: 'unknown', pid, reason: `leaf PID ${pid} is live but its process-start identity is unreadable` };
|
|
3608
|
+
return { state: 'unknown', pid, reason: 'leaf birth receipt and agent.pid registration disagree' };
|
|
3228
3609
|
}
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
if (
|
|
3235
|
-
|
|
3236
|
-
|
|
3610
|
+
const snap = await liveSnapshot(id);
|
|
3611
|
+
if (snap.probeFailed)
|
|
3612
|
+
return { state: 'unknown', pid, reason: 'exact target pane is unreadable; leaf birth receipt cannot be minted' };
|
|
3613
|
+
const panePid = snap.windows.get(id)?.panePid ?? null;
|
|
3614
|
+
const startBefore = sessionLeafStartToken(pid);
|
|
3615
|
+
if (!startBefore) {
|
|
3616
|
+
const live = leafProcessLiveness(pid);
|
|
3617
|
+
if (live !== 'dead')
|
|
3618
|
+
return { state: 'unknown', pid, reason: `leaf PID ${pid} process-start identity is unreadable while liveness is ${live}` };
|
|
3619
|
+
clearSessionLeafArtifacts(id);
|
|
3620
|
+
return { state: 'dead', pid };
|
|
3621
|
+
}
|
|
3622
|
+
let procs = null;
|
|
3623
|
+
try {
|
|
3624
|
+
procs = await procSnapshot();
|
|
3625
|
+
}
|
|
3626
|
+
catch { /* fail closed below */ }
|
|
3627
|
+
const startAfter = sessionLeafStartToken(pid);
|
|
3628
|
+
const candidate = sessionLeafReceiptCandidate(id, pid, panePid, procs, startBefore, startAfter);
|
|
3629
|
+
if (!candidate.ok || !candidate.receipt)
|
|
3630
|
+
return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt proof failed' };
|
|
3631
|
+
if (readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
|
|
3632
|
+
return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed before receipt commit` };
|
|
3633
|
+
writeSessionLeafReceipt(id, candidate.receipt);
|
|
3634
|
+
const committed = readSessionLeafReceipt(id);
|
|
3635
|
+
if (committed.state !== 'valid' || !sameSessionLeafReceipt(committed.receipt, candidate.receipt)
|
|
3636
|
+
|| readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
|
|
3637
|
+
return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed while receipt committed` };
|
|
3638
|
+
return { state: 'owned', identity: { pid, startToken: candidate.receipt.startToken, receipt: candidate.receipt } };
|
|
3237
3639
|
}
|
|
3238
3640
|
async function assertSessionLeafOwned(id, rec) {
|
|
3239
3641
|
if (harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter')
|
|
@@ -3248,8 +3650,6 @@ async function assertSessionLeafOwned(id, rec) {
|
|
|
3248
3650
|
return null;
|
|
3249
3651
|
if (observed.state === 'owned')
|
|
3250
3652
|
return observed.identity;
|
|
3251
|
-
if (observed.state === 'unrelated')
|
|
3252
|
-
throw new ResourceConflict(`refusing to stop ${id}: leaf PID ${observed.pid}@${observed.startToken} does not prove argv ownership`);
|
|
3253
3653
|
throw new ResourceConflict(`refusing to stop ${id}: ${observed.reason}`);
|
|
3254
3654
|
}
|
|
3255
3655
|
async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
|
|
@@ -3262,12 +3662,20 @@ async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
|
|
|
3262
3662
|
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3263
3663
|
const leaf = await assertSessionLeafOwned(id, rec);
|
|
3264
3664
|
// Adapter-owned headless sessions may have no live leaf PID, but launch still created an exact tmux session
|
|
3265
|
-
// wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the
|
|
3665
|
+
// wrapper. Kill that session-id unconditionally; runtimeOwnership only changes the leaf receipt proof, never the
|
|
3266
3666
|
// exact tmux teardown.
|
|
3267
3667
|
await tmuxOk(['kill-session', '-t', id]);
|
|
3268
3668
|
await assertTargetTmuxAbsent(id, 'after kill');
|
|
3269
|
-
if (leaf)
|
|
3669
|
+
if (leaf) {
|
|
3270
3670
|
await killAgentProcess(id, assertOwned, leaf);
|
|
3671
|
+
const registered = readAgentPid(sessionArtifactPath(id, 'agent.pid'));
|
|
3672
|
+
const liveness = leafProcessLiveness(leaf.pid);
|
|
3673
|
+
const currentStartToken = sessionLeafStartToken(leaf.pid);
|
|
3674
|
+
const finalState = sessionLeafReceiptIdentityState(leaf.receipt, Number.isSafeInteger(registered) ? registered : null, currentStartToken, liveness);
|
|
3675
|
+
if (finalState !== 'gone' && finalState !== 'pid-reused')
|
|
3676
|
+
throw new ResourceConflict(`refusing to stop ${id}: exact leaf teardown remains ${finalState}`);
|
|
3677
|
+
clearSessionLeafArtifacts(id);
|
|
3678
|
+
}
|
|
3271
3679
|
launchedAt.delete(id);
|
|
3272
3680
|
await harness.cleanupRuntime(rec);
|
|
3273
3681
|
if (requireCold) {
|
|
@@ -3293,7 +3701,8 @@ async function stopSessionUnlocked(id) {
|
|
|
3293
3701
|
const rec = readRecord(id);
|
|
3294
3702
|
if (rec)
|
|
3295
3703
|
writeRecord({ ...rec, stopped: true });
|
|
3296
|
-
|
|
3704
|
+
if (wt.rec.status !== 'queued')
|
|
3705
|
+
requestQueueDrain(); // a live stop frees a slot; a prepared queue never held one
|
|
3297
3706
|
return !!wt;
|
|
3298
3707
|
}
|
|
3299
3708
|
export const stopSession = (id) => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)));
|
|
@@ -3489,6 +3898,32 @@ async function assertColdRetirementSafe(id, rec) {
|
|
|
3489
3898
|
throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`);
|
|
3490
3899
|
}
|
|
3491
3900
|
}
|
|
3901
|
+
async function assertDiscardableWorktree(id, path, branch, kind) {
|
|
3902
|
+
if (existsSync(path)) {
|
|
3903
|
+
const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all']);
|
|
3904
|
+
if (!status.ok)
|
|
3905
|
+
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree status is unreadable`);
|
|
3906
|
+
if (status.stdout.trim())
|
|
3907
|
+
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree has dirty work`);
|
|
3908
|
+
}
|
|
3909
|
+
if (branch) {
|
|
3910
|
+
const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`]);
|
|
3911
|
+
if (resolved.ok) {
|
|
3912
|
+
const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`]);
|
|
3913
|
+
if (!count.ok)
|
|
3914
|
+
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch ancestry is unreadable`);
|
|
3915
|
+
const ahead = Number(count.stdout.trim());
|
|
3916
|
+
if (!Number.isFinite(ahead) || ahead !== 0)
|
|
3917
|
+
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`);
|
|
3918
|
+
}
|
|
3919
|
+
else if (resolved.failure !== 'exit') {
|
|
3920
|
+
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch identity is unreadable`);
|
|
3921
|
+
}
|
|
3922
|
+
else if (existsSync(path)) {
|
|
3923
|
+
throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree exists but branch ${branch} is missing`);
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3492
3927
|
// A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
|
|
3493
3928
|
// this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
|
|
3494
3929
|
// No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
|
|
@@ -3511,32 +3946,31 @@ async function assertQueuedRetirementSafe(id, rec, path, branch) {
|
|
|
3511
3946
|
const pid = readAgentPid(pidPath);
|
|
3512
3947
|
throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`);
|
|
3513
3948
|
}
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
if (
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
}
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
}
|
|
3949
|
+
await assertDiscardableWorktree(id, path, branch, 'prepared');
|
|
3950
|
+
}
|
|
3951
|
+
// A launch may leave its row active before Codex publishes the native thread binding. This close path owns
|
|
3952
|
+
// only the record's dead local launch residue; an unbound app-server peer stays unowned and untouched.
|
|
3953
|
+
async function assertUnboundRetirementSafe(id, rec, path, branch) {
|
|
3954
|
+
if (harnessById(rec.harness || defaultHarness.id).exactNativeTargetId(rec) || rec.status === 'queued' || rec.archived)
|
|
3955
|
+
throw new ResourceConflict(`refusing to close unbound session ${id}: it is not an unbound live-record residue`);
|
|
3956
|
+
if (rec.adapterRecovery || rec.launchReadinessPending || launching.has(id) || existsSync(sessionArtifactPath(id, 'launch')))
|
|
3957
|
+
throw new ResourceConflict(`refusing to close unbound session ${id}: launch or recovery is still in progress`);
|
|
3958
|
+
const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
|
|
3959
|
+
if (snap.probeFailed)
|
|
3960
|
+
throw new ResourceConflict(`refusing to close unbound session ${id}: liveness probe failed; local worker absence is unproven`);
|
|
3961
|
+
const harness = harnessById(rec.harness || defaultHarness.id);
|
|
3962
|
+
if (harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online')
|
|
3963
|
+
throw new ResourceConflict(`refusing to close unbound session ${id}: its adapter still reports a live worker`);
|
|
3964
|
+
if (socket === 'live')
|
|
3965
|
+
throw new ResourceConflict(`refusing to close unbound session ${id}: target rendezvous transport already exists`);
|
|
3966
|
+
if (socket === 'unproven')
|
|
3967
|
+
throw new ResourceConflict(`refusing to close unbound session ${id}: target rendezvous state is ambiguous`);
|
|
3968
|
+
const leaf = await inspectSessionLeafIdentity(id, rec);
|
|
3969
|
+
if (leaf.state !== 'missing' && leaf.state !== 'dead')
|
|
3970
|
+
throw new ResourceConflict(`refusing to close unbound session ${id}: ${leaf.state === 'unknown' ? leaf.reason : 'target leaf identity is live or ambiguous'}`);
|
|
3971
|
+
await assertDiscardableWorktree(id, path, branch, 'unbound');
|
|
3538
3972
|
}
|
|
3539
|
-
async function closeOwnedSessionUnlocked(id, wt, source) {
|
|
3973
|
+
async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false) {
|
|
3540
3974
|
const root = mainRoot();
|
|
3541
3975
|
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root);
|
|
3542
3976
|
if (receiptFailure)
|
|
@@ -3549,7 +3983,7 @@ async function closeOwnedSessionUnlocked(id, wt, source) {
|
|
|
3549
3983
|
await assertColdRetirementSafe(id, wt.rec);
|
|
3550
3984
|
else if (wt.rec.status === 'queued')
|
|
3551
3985
|
await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch);
|
|
3552
|
-
else
|
|
3986
|
+
else if (!unboundRetired)
|
|
3553
3987
|
throw new ResourceConflict(`refusing to close ${id}: target runtime was not cold-retired first`);
|
|
3554
3988
|
}
|
|
3555
3989
|
// The marker protects only the destructive half. A failed cold proof must leave a normal, resumable binding.
|
|
@@ -3631,28 +4065,38 @@ async function closeSessionUnlocked(id, source) {
|
|
|
3631
4065
|
}
|
|
3632
4066
|
if (!wt)
|
|
3633
4067
|
return false;
|
|
4068
|
+
let unboundRetired = false;
|
|
3634
4069
|
if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
|
|
3635
|
-
// A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
|
|
3636
|
-
// remains non-destructive while a turn is active; close already means discard this session's work.
|
|
3637
4070
|
const harness = harnessById(wt.rec.harness || defaultHarness.id);
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
4071
|
+
if (!harness.exactNativeTargetId(wt.rec)) {
|
|
4072
|
+
await assertUnboundRetirementSafe(id, wt.rec, wt.path, wt.branch);
|
|
4073
|
+
await tmuxOk(['kill-session', '-t', id]);
|
|
4074
|
+
await assertTargetTmuxAbsent(id, 'after unbound residue retirement');
|
|
4075
|
+
await harness.cleanupRuntime(wt.rec);
|
|
4076
|
+
unboundRetired = true;
|
|
4077
|
+
}
|
|
4078
|
+
else {
|
|
4079
|
+
// A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
|
|
4080
|
+
// remains non-destructive while a turn is active; close already means discard this session's work.
|
|
4081
|
+
assertSessionOwnerSafe(id, harness.id);
|
|
4082
|
+
const interrupt = harness.interrupt;
|
|
4083
|
+
if (interrupt) {
|
|
4084
|
+
const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() });
|
|
4085
|
+
if (!result.ok)
|
|
4086
|
+
throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`);
|
|
4087
|
+
}
|
|
4088
|
+
const archived = await archiveSessionUnlocked(id);
|
|
4089
|
+
if (!archived)
|
|
4090
|
+
return false;
|
|
4091
|
+
wt = await findWorktree(id);
|
|
4092
|
+
if (!wt)
|
|
4093
|
+
return false;
|
|
3644
4094
|
}
|
|
3645
|
-
const archived = await archiveSessionUnlocked(id);
|
|
3646
|
-
if (!archived)
|
|
3647
|
-
return false;
|
|
3648
|
-
wt = await findWorktree(id);
|
|
3649
|
-
if (!wt)
|
|
3650
|
-
return false;
|
|
3651
4095
|
}
|
|
3652
4096
|
const target = wt;
|
|
3653
4097
|
return target.branch
|
|
3654
|
-
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source))
|
|
3655
|
-
: closeOwnedSessionUnlocked(id, target, source);
|
|
4098
|
+
? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundRetired))
|
|
4099
|
+
: closeOwnedSessionUnlocked(id, target, source, unboundRetired);
|
|
3656
4100
|
}
|
|
3657
4101
|
export const closeSession = (id, rawSource) => {
|
|
3658
4102
|
const source = normalizeCloseSource(rawSource);
|
|
@@ -4042,13 +4486,18 @@ export function formatTable(sessions, color = true, scope = { kind: 'sessions' }
|
|
|
4042
4486
|
});
|
|
4043
4487
|
return [heading, header, ...rows, statusLegend(color)].join('\n');
|
|
4044
4488
|
}
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
};
|
|
4489
|
+
class StrandedDeliveryError extends Error {
|
|
4490
|
+
}
|
|
4491
|
+
async function strandedDeliveryError(rec) {
|
|
4492
|
+
const h = harnessById(rec.harness || defaultHarness.id);
|
|
4493
|
+
if (!h.deliveryTransport)
|
|
4494
|
+
return null;
|
|
4495
|
+
const transport = await h.deliveryTransport({ ...rec, runtimeDir: runtimeRoot() });
|
|
4496
|
+
if (transport.kind !== 'unreachable' || agentAlive(rec.session) !== true)
|
|
4497
|
+
return null;
|
|
4498
|
+
const queued = pendingMessages(rec.session).length;
|
|
4499
|
+
const noun = queued === 1 ? 'message is' : 'messages are';
|
|
4500
|
+
return new StrandedDeliveryError(`session ${rec.session} is stranded: ${transport.reason} while its registered agent process is still alive; ${queued} queued ${noun} waiting with no transport to claim them. Use \`spex session send ${rec.session} --keys "<keys>"\` to steer the live tmux pane, then repair the control transport before sending text.`);
|
|
4052
4501
|
}
|
|
4053
4502
|
export async function sendText(id, text, from, opts = {}) {
|
|
4054
4503
|
if (!text)
|
|
@@ -4059,52 +4508,39 @@ export async function sendText(id, text, from, opts = {}) {
|
|
|
4059
4508
|
// a send either appends before close obtains the fence (and close's revocation voids its debt), or sees
|
|
4060
4509
|
// the terminal marker before it records anything. Arbitrary legacy `from` values keep working; they just
|
|
4061
4510
|
// name an otherwise-unused lock until a matching session is closed.
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
Object.assign(conflict, { code: 'dispatch_key_reused' });
|
|
4075
|
-
throw conflict;
|
|
4076
|
-
}
|
|
4077
|
-
if (prior.delivery && !prior.delivered) {
|
|
4078
|
-
ensurePendingWhileLocked(id, keyedPendingMessage(opts.idempotency, prior.mid, prior.delivery));
|
|
4079
|
-
}
|
|
4080
|
-
replayed = true;
|
|
4081
|
-
return;
|
|
4082
|
-
}
|
|
4083
|
-
}
|
|
4511
|
+
let rec = null;
|
|
4512
|
+
const accepted = await acceptMessage({
|
|
4513
|
+
target: id,
|
|
4514
|
+
text,
|
|
4515
|
+
from,
|
|
4516
|
+
idempotency: opts.idempotency,
|
|
4517
|
+
validate: async () => {
|
|
4518
|
+
rec = readRecord(id);
|
|
4519
|
+
if (!rec)
|
|
4520
|
+
throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
|
|
4521
|
+
},
|
|
4522
|
+
prepare: async () => {
|
|
4084
4523
|
await opts.acceptGuard?.(rec);
|
|
4524
|
+
const stranded = await strandedDeliveryError(rec);
|
|
4525
|
+
if (stranded)
|
|
4526
|
+
throw stranded;
|
|
4085
4527
|
// Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
|
|
4086
4528
|
// the queue keeps the transport form. Composing again at handover would let a later send change the hints on
|
|
4087
4529
|
// a message that was already accepted.
|
|
4088
4530
|
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
: undefined;
|
|
4092
|
-
const appended = appendSent(id, text, from ?? null, prompt.replyVia, dispatchReceipt);
|
|
4093
|
-
enqueue(id, opts.idempotency
|
|
4094
|
-
? keyedPendingMessage(opts.idempotency, appended.mid, dispatchReceipt.delivery)
|
|
4095
|
-
: { mid: appended.mid, text: prompt.text, from: from ?? null });
|
|
4096
|
-
};
|
|
4097
|
-
if (opts.idempotency)
|
|
4098
|
-
await withDeliveryLocks([id], accept);
|
|
4099
|
-
else
|
|
4100
|
-
await accept();
|
|
4531
|
+
return { text: prompt.text, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) };
|
|
4532
|
+
},
|
|
4101
4533
|
});
|
|
4534
|
+
replayed = accepted.replayed;
|
|
4102
4535
|
}
|
|
4103
4536
|
catch (error) {
|
|
4104
4537
|
const code = error?.code;
|
|
4538
|
+
const detail = error instanceof StrandedDeliveryError
|
|
4539
|
+
? error.message
|
|
4540
|
+
: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`;
|
|
4105
4541
|
return {
|
|
4106
4542
|
ok: false,
|
|
4107
|
-
error:
|
|
4543
|
+
error: detail,
|
|
4108
4544
|
...(code ? { code } : {}),
|
|
4109
4545
|
};
|
|
4110
4546
|
}
|
|
@@ -4125,15 +4561,9 @@ export async function drainSession(id) {
|
|
|
4125
4561
|
if (!rec)
|
|
4126
4562
|
return;
|
|
4127
4563
|
const h = harnessById(rec.harness || defaultHarness.id);
|
|
4564
|
+
if (h.launchPayloadProof && !rec.harnessSessionId)
|
|
4565
|
+
return;
|
|
4128
4566
|
await drain(id, async (msg) => {
|
|
4129
|
-
if (msg.dispatch) {
|
|
4130
|
-
const receipt = sentDispatchReceipt(id, msg.dispatch.operation, msg.dispatch.requestDigest);
|
|
4131
|
-
if (!receipt || receipt.mid !== msg.mid || !receipt.delivery
|
|
4132
|
-
|| receipt.delivery.text !== msg.text || receipt.delivery.from !== msg.from)
|
|
4133
|
-
return false;
|
|
4134
|
-
if (receipt.delivered)
|
|
4135
|
-
return true;
|
|
4136
|
-
}
|
|
4137
4567
|
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
4138
4568
|
// prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
|
|
4139
4569
|
// rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
|
|
@@ -4145,8 +4575,6 @@ export async function drainSession(id) {
|
|
|
4145
4575
|
catch { /* no pane to consult — let the insert itself decide */ }
|
|
4146
4576
|
}
|
|
4147
4577
|
const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text);
|
|
4148
|
-
if (delivered.ok && msg.dispatch)
|
|
4149
|
-
settleSentDispatch(id, msg.mid);
|
|
4150
4578
|
return delivered.ok;
|
|
4151
4579
|
});
|
|
4152
4580
|
}
|