@spexcode/spec-cli 0.7.0-next.3 → 0.7.0-next.5

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.
@@ -46,6 +46,10 @@ export declare function prepareCodexGenerationClose(root: string, sessionId: str
46
46
  export declare function repinCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string): void;
47
47
  export declare function resolveCodexGenerationForResume(root: string, sessionId: string, threadId: string, start: (endpoint: CodexGenerationEndpoint) => Promise<void>): Promise<CodexGenerationEndpoint | null>;
48
48
  export declare function resolveCodexGenerationForSession(root: string, sessionId: string, threadId: string): CodexGenerationEndpoint | null;
49
+ export declare function resolveCodexGenerationForClose(root: string, sessionId: string, threadId: string): {
50
+ endpoint: CodexGenerationEndpoint;
51
+ gone: boolean;
52
+ } | null;
49
53
  export declare function codexGenerationBindingForSession(root: string, sessionId: string): CodexGenerationBinding | null;
50
54
  export declare function currentCodexGeneration(root: string): CodexGenerationEndpoint | null;
51
55
  export declare function codexGenerationEndpoints(root: string): CodexGenerationEndpoint[];
@@ -765,6 +765,18 @@ export function resolveCodexGenerationForSession(root, sessionId, threadId) {
765
765
  const generation = ledger.generations[binding.generationId];
766
766
  return generation && generation.state !== 'reclaimed' ? generation.endpoint : null;
767
767
  }
768
+ // Close still needs the immutable endpoint record to recognize a positively retired generation. The
769
+ // endpoint is never used for native I/O in this state; it only identifies the empty control plane.
770
+ export function resolveCodexGenerationForClose(root, sessionId, threadId) {
771
+ const ledger = readCodexGenerationLedger(root);
772
+ const binding = ledger.bindings[sessionId];
773
+ if (!binding || binding.threadId !== threadId)
774
+ return null;
775
+ const generation = ledger.generations[binding.generationId];
776
+ if (!generation)
777
+ return null;
778
+ return { endpoint: generation.endpoint, gone: generation.state === 'reclaimed' };
779
+ }
768
780
  export function codexGenerationBindingForSession(root, sessionId) {
769
781
  return readCodexGenerationLedger(root).bindings[sessionId] ?? null;
770
782
  }
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { loadSpecs, requireGitWorkspace, headSha } from '@spexcode/spec-core';
4
4
  import { resolveLayout } from '@spexcode/spec-core';
5
5
  import { listSessions } from './sessions.js';
6
- import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
6
+ import { driftIndex, historyIndex, pruneHistoryCaches, repoRoot } from '@spexcode/spec-core';
7
7
  import { residentForgeRevision, residentForgeState } from '@spexcode/spec-forge/resident';
8
8
  import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
9
9
  import { boardThreads } from './issues.js';
@@ -56,6 +56,9 @@ export async function boardSnapshot() {
56
56
  const root = repoRoot();
57
57
  requireGitWorkspace(root);
58
58
  const [specs, sessions] = await Promise.all([loadSpecs(), listSessions()]);
59
+ // Session worktrees are the live-root census for immutable history caches. Reconcile before the snapshot
60
+ // returns so closing a session releases its full index immediately rather than waiting for an LRU slot.
61
+ pruneHistoryCaches([root, ...sessions.map((session) => session.path)]);
59
62
  const layout = await resolveLayout({ activeSessionIds: sessions.map((session) => session.id) });
60
63
  const nodeIds = [...new Set([
61
64
  ...specs.map((node) => node.id),
package/dist/harness.js CHANGED
@@ -18,7 +18,7 @@ import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHom
18
18
  import { git } from '@spexcode/spec-core';
19
19
  import { shQuote } from './sh.js';
20
20
  import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
21
- import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
21
+ import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, prepareCodexGenerationClose, resolveCodexGenerationForClose, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
22
22
  import { writeFileIfChanged } from './file-write.js';
23
23
  import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
24
24
  import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
@@ -220,12 +220,14 @@ function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGene
220
220
  return codexRuntimeGeneration(dir, endpoint);
221
221
  }
222
222
  const codexDescriptorKey = (endpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`;
223
- function codexEndpointForRecord(rec, dir = runtimeRoot()) {
223
+ function codexEndpointForRecord(rec, dir = runtimeRoot(), includeGone = false) {
224
224
  if (!rec.harnessSessionId)
225
225
  return null;
226
226
  const ledger = readCodexGenerationLedger(dir);
227
227
  if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length)
228
228
  return legacyCodexGenerationEndpoint(dir);
229
+ if (includeGone)
230
+ return resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId)?.endpoint ?? null;
229
231
  return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId);
230
232
  }
231
233
  // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
@@ -2784,7 +2786,9 @@ const socketListenerLiveness = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive)
2784
2786
  const socketListenerOrPidAliveLiveness = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) => (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline');
2785
2787
  const panePidLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
2786
2788
  const recordOnline = (rec) => rec.stopped ? 'offline' : 'online';
2787
- const sessionHomeLiveness = (_rec, tmuxAlive) => tmuxAlive ? 'online' : 'offline';
2789
+ // Leaf-backed headless sessions are the controller process, not the tmux pane that hosts it. The pane can
2790
+ // survive a SIGKILL as a bare shell, so tmux presence without the launch-registered PID is never online.
2791
+ const sessionHomeLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
2788
2792
  // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
2789
2793
  // socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
2790
2794
  // faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
@@ -2889,6 +2893,7 @@ export const claudeHeadlessHarness = {
2889
2893
  paneTitleIsSelfSummary: false,
2890
2894
  launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
2891
2895
  launchEnv: noLaunchEnv,
2896
+ // The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
2892
2897
  liveness: sessionHomeLiveness,
2893
2898
  deliveryTransport: unprovenDeliveryTransport,
2894
2899
  deliver: deliverViaClaudeHeadless,
@@ -3025,7 +3030,7 @@ export const codexHarness = {
3025
3030
  interrupt: interruptCodexTurn,
3026
3031
  cleanupRuntime: async () => { },
3027
3032
  targetDescriptorKey: (rec) => {
3028
- const endpoint = codexEndpointForRecord(rec);
3033
+ const endpoint = codexEndpointForRecord(rec, runtimeRoot(), true);
3029
3034
  return endpoint ? codexDescriptorKey(endpoint) : null;
3030
3035
  },
3031
3036
  coldRetirementPreflight: async (rec) => {
@@ -3049,8 +3054,13 @@ export const codexHarness = {
3049
3054
  coldPreflight: async (rec) => {
3050
3055
  if (!rec.harnessSessionId)
3051
3056
  return { ok: false, reason: 'no exact Codex thread identity is registered' };
3052
- const endpoint = codexEndpointForRecord(rec);
3053
- return endpoint ? codexColdPreflight(rec.harnessSessionId, runtimeRoot(), undefined, endpoint)
3057
+ const dir = runtimeRoot();
3058
+ const binding = resolveCodexGenerationForClose(dir, rec.session, rec.harnessSessionId);
3059
+ if (binding?.gone) {
3060
+ return { ok: true, alreadyCold: true };
3061
+ }
3062
+ const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
3063
+ return endpoint ? codexColdPreflight(rec.harnessSessionId, dir, undefined, endpoint)
3054
3064
  : { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
3055
3065
  },
3056
3066
  coldRuntime: async (rec, suppliedReceipt) => {
@@ -3058,7 +3068,12 @@ export const codexHarness = {
3058
3068
  return { ok: false, reason: 'no exact Codex thread identity is registered' };
3059
3069
  const threadId = rec.harnessSessionId;
3060
3070
  const dir = runtimeRoot();
3061
- const endpoint = codexEndpointForRecord(rec, dir);
3071
+ const binding = resolveCodexGenerationForClose(dir, rec.session, threadId);
3072
+ if (binding?.gone) {
3073
+ prepareCodexGenerationClose(dir, rec.session, threadId);
3074
+ return { ok: true };
3075
+ }
3076
+ const endpoint = binding?.endpoint ?? codexEndpointForRecord(rec, dir);
3062
3077
  if (!endpoint)
3063
3078
  return { ok: false, reason: 'no exact Codex generation binding is registered for this target' };
3064
3079
  const sock = endpoint.socketPath;
@@ -3400,6 +3415,7 @@ export const piHeadlessHarness = {
3400
3415
  runtimeOwnership: 'leaf',
3401
3416
  paneTitleIsSelfSummary: false,
3402
3417
  launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
3418
+ // The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
3403
3419
  liveness: sessionHomeLiveness,
3404
3420
  deliver: deliverViaPiHeadless,
3405
3421
  // the controller aborts its own turn child natively and confirms only once that child is gone
@@ -3513,6 +3529,7 @@ export const opencodeHeadlessHarness = {
3513
3529
  runtimeOwnership: 'leaf',
3514
3530
  deliveryTransport: unprovenDeliveryTransport,
3515
3531
  launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
3532
+ // The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
3516
3533
  liveness: sessionHomeLiveness,
3517
3534
  coldRuntime: async (rec) => {
3518
3535
  const result = await opencodeHeadlessColdRuntime(rec);
@@ -216,6 +216,29 @@ const unresolvedHarnessOwners = (recs, budgets, have) => {
216
216
  }
217
217
  return out;
218
218
  };
219
+ // A leaf-backed headless controller is the session's runtime. Keep its durable row visible when the controller
220
+ // disappears before the next resources sweep can attribute a process, and name the status/liveness contradiction
221
+ // explicitly so a supervisor cannot mistake the missing process for a cleanly idle session.
222
+ const missingHeadlessControllerOwners = (recs, procs, budgets, have) => {
223
+ const out = [];
224
+ for (const rec of recs) {
225
+ const harness = harnessByIdOrNull(rec.harness || defaultHarness.id);
226
+ if (!rec.governed || rec.stopped || rec.archived || rec.status !== 'active' && rec.status !== 'idle'
227
+ || harness?.runtimeOwnership !== 'leaf' || have(rec.session_id))
228
+ continue;
229
+ const pid = runtimePid(join(runtimeRoot(), 'sessions', rec.session_id, 'agent.pid'));
230
+ if (pid && procs.has(pid))
231
+ continue;
232
+ out.push({
233
+ kind: 'session', id: rec.session_id, label: `session ${rec.session_id.slice(0, 8)}`, status: rec.status, liveness: 'offline',
234
+ processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
235
+ budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
236
+ findings: [`headless-liveness-contradiction:${rec.status}/offline`],
237
+ reclaim: { eligible: false, reason: 'headless controller is absent; lifecycle repair must be explicit' },
238
+ });
239
+ }
240
+ return out;
241
+ };
219
242
  const sharedDescriptors = (recs, retainRegistry = false) => {
220
243
  const out = new Map();
221
244
  for (const harness of HARNESSES)
@@ -448,6 +471,17 @@ const sessionStopBlocker = async (id, harnessId, recs = rawRecords(), knownProbe
448
471
  const entryTargetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id;
449
472
  if (entryTargetThread && ownerCounts.get(entryTargetThread) !== 1)
450
473
  return `${descriptor.label} target thread ${entryTargetThread} has no one exact governed session owner`;
474
+ // A retired shared generation is positive death, not an unknown control plane. Its residency census is
475
+ // the only proof needed here; there is no native target left to signal or unload.
476
+ if (descriptor.residency && entryTargetThread) {
477
+ let residency = null;
478
+ try {
479
+ residency = await descriptor.residency();
480
+ }
481
+ catch { /* retain the normal fail-closed path */ }
482
+ if (residency?.healthy && residency.rootAbsent === true && residency.referenceIds.length === 0)
483
+ continue;
484
+ }
451
485
  if (!knownProbes && descriptor.mutationGuard) {
452
486
  if (!targetThread)
453
487
  return `${descriptor.label} target has no exact governed thread identity`;
@@ -722,6 +756,7 @@ export async function collectResourceReport(opts = {}) {
722
756
  budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
723
757
  findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
724
758
  });
759
+ owners.push(...missingHeadlessControllerOwners(inv.recs, inv.procs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
725
760
  owners.push(...unresolvedHarnessOwners(inv.recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
726
761
  // A referenced shared runtime with no readable process is still operationally important: keep its live or
727
762
  // unknown refcount visible instead of silently omitting it from a process-derived report.
package/dist/sessions.js CHANGED
@@ -2538,12 +2538,19 @@ async function establishBackendConnection(target) {
2538
2538
  socket.destroy();
2539
2539
  fn();
2540
2540
  };
2541
- const timer = setTimeout(() => finish(() => {
2542
- const error = new Error(`backend connection was not accepted at ${target.url} within 1500ms`);
2543
- error.name = 'BackendError';
2544
- Object.assign(error, { code: 'backend_availability_indeterminate' });
2545
- reject(error);
2546
- }), 1500);
2541
+ const timer = setTimeout(() => {
2542
+ // The event loop may have been blocked past the wall after the kernel completed the handshake. In that
2543
+ // case Node has not emitted `connect` yet, but `connecting` is already false; acceptance still proves
2544
+ // presence and must not be relabelled as an unavailable backend.
2545
+ if (!socket.connecting || socket.readyState === 'open')
2546
+ return finish(() => resolve(false));
2547
+ finish(() => {
2548
+ const error = new Error(`backend connection was not accepted at ${target.url} within 1500ms`);
2549
+ error.name = 'BackendError';
2550
+ Object.assign(error, { code: 'backend_availability_indeterminate' });
2551
+ reject(error);
2552
+ });
2553
+ }, 1500);
2547
2554
  timer.unref?.();
2548
2555
  socket.once('connect', () => finish(() => resolve(false)));
2549
2556
  socket.once('error', (error) => finish(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.7.0-next.3",
3
+ "version": "0.7.0-next.5",
4
4
  "type": "module",
5
5
  "description": "SpexCode CLI + server. The root spexcode package delegates to this compiled package; dashboard assets live in @spexcode/spec-dashboard.",
6
6
  "bin": {
@@ -35,12 +35,12 @@
35
35
  "test": "tsx --import ../scripts/test-home.mjs --test src/*.test.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@spexcode/session-application": "0.7.0-next.3",
39
- "@spexcode/session-selflaunch": "0.7.0-next.3",
40
- "@spexcode/spec-core": "0.7.0-next.3",
41
- "@spexcode/spec-eval": "0.7.0-next.3",
42
- "@spexcode/spec-forge": "0.7.0-next.3",
43
- "@spexcode/transcript": "0.7.0-next.3",
38
+ "@spexcode/session-application": "0.7.0-next.5",
39
+ "@spexcode/session-selflaunch": "0.7.0-next.5",
40
+ "@spexcode/spec-core": "0.7.0-next.5",
41
+ "@spexcode/spec-eval": "0.7.0-next.5",
42
+ "@spexcode/spec-forge": "0.7.0-next.5",
43
+ "@spexcode/transcript": "0.7.0-next.5",
44
44
  "smol-toml": "^1.8.0"
45
45
  },
46
46
  "devDependencies": {