@spexcode/spec-cli 0.7.0-next.2 → 0.7.0-next.4

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.
@@ -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
@@ -2784,7 +2784,9 @@ const socketListenerLiveness = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive)
2784
2784
  const socketListenerOrPidAliveLiveness = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) => (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline');
2785
2785
  const panePidLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
2786
2786
  const recordOnline = (rec) => rec.stopped ? 'offline' : 'online';
2787
- const sessionHomeLiveness = (_rec, tmuxAlive) => tmuxAlive ? 'online' : 'offline';
2787
+ // Leaf-backed headless sessions are the controller process, not the tmux pane that hosts it. The pane can
2788
+ // survive a SIGKILL as a bare shell, so tmux presence without the launch-registered PID is never online.
2789
+ const sessionHomeLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
2788
2790
  // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
2789
2791
  // socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
2790
2792
  // faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
@@ -2889,6 +2891,7 @@ export const claudeHeadlessHarness = {
2889
2891
  paneTitleIsSelfSummary: false,
2890
2892
  launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
2891
2893
  launchEnv: noLaunchEnv,
2894
+ // The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
2892
2895
  liveness: sessionHomeLiveness,
2893
2896
  deliveryTransport: unprovenDeliveryTransport,
2894
2897
  deliver: deliverViaClaudeHeadless,
@@ -3400,6 +3403,7 @@ export const piHeadlessHarness = {
3400
3403
  runtimeOwnership: 'leaf',
3401
3404
  paneTitleIsSelfSummary: false,
3402
3405
  launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
3406
+ // The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
3403
3407
  liveness: sessionHomeLiveness,
3404
3408
  deliver: deliverViaPiHeadless,
3405
3409
  // the controller aborts its own turn child natively and confirms only once that child is gone
@@ -3513,6 +3517,7 @@ export const opencodeHeadlessHarness = {
3513
3517
  runtimeOwnership: 'leaf',
3514
3518
  deliveryTransport: unprovenDeliveryTransport,
3515
3519
  launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
3520
+ // The controller's registered PID is the liveness witness; the tmux home is only the address boundary.
3516
3521
  liveness: sessionHomeLiveness,
3517
3522
  coldRuntime: async (rec) => {
3518
3523
  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)
@@ -722,6 +745,7 @@ export async function collectResourceReport(opts = {}) {
722
745
  budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
723
746
  findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
724
747
  });
748
+ owners.push(...missingHeadlessControllerOwners(inv.recs, inv.procs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
725
749
  owners.push(...unresolvedHarnessOwners(inv.recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)));
726
750
  // A referenced shared runtime with no readable process is still operationally important: keep its live or
727
751
  // unknown refcount visible instead of silently omitting it from a process-derived report.
package/dist/sessions.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile, execFileSync, spawn } from 'node:child_process';
2
+ import { createConnection } from 'node:net';
2
3
  import { promisify } from 'node:util';
3
4
  import { createHash, randomUUID } from 'node:crypto';
4
5
  import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
@@ -15,6 +16,7 @@ import { readSessionFiles } from './session-files.js';
15
16
  import { readSessionWebs } from './session-web.js';
16
17
  import { acquireFreshSessionApplicationForCreate, configuredSessionApplicationIfCutover, initializeFreshSessionApplication, releaseFreshSessionApplicationForCreate, sessionApplicationCutoverState, setSessionApplicationCommitWake } from './session-application.js';
17
18
  import { jsonMigrationFencePath } from '@spexcode/session-application';
19
+ import { decodeEventJson } from '@spexcode/session-events';
18
20
  import { withDeliveryLocks } from './delivery-lock.js';
19
21
  import { withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from './session-record-lock.js';
20
22
  import { stripRefSigil } from './mentions.js';
@@ -1704,11 +1706,45 @@ let draining = false; // re-entrancy guard: only one drain pass runs at a time (
1704
1706
  // A native receipt is bound before the readiness fence validates it. Suppress only that immediate wake so
1705
1707
  // queued prompts cannot drain during the candidate window; the successful publication path drains normally.
1706
1708
  const readinessWakeSuppressed = new Set();
1709
+ // A readiness timeout is launch-phase evidence only until the session produces another durable event. The
1710
+ // first event at/after the readiness marker is the launch transition itself; anything after that proves the
1711
+ // worker progressed (including a declaration), so a late diagnostic is moot and must not replace its note.
1712
+ function hasLaterLaunchReadinessEvent(rec) {
1713
+ const startedAt = rec.launchReadinessStartedAt;
1714
+ if (!Number.isFinite(startedAt))
1715
+ return false;
1716
+ const application = configuredSessionApplicationIfCutover();
1717
+ if (!application?.readState(rec.session))
1718
+ return false;
1719
+ const events = application.readEvents(rec.session);
1720
+ const statusPayload = (event) => {
1721
+ const payload = decodeEventJson(event.payload);
1722
+ return payload && typeof payload === 'object' && !Array.isArray(payload) && 'status' in payload
1723
+ ? payload : null;
1724
+ };
1725
+ const baseline = events.find((event) => {
1726
+ if (event.occurredAtMs < Number(startedAt))
1727
+ return false;
1728
+ const payload = statusPayload(event);
1729
+ return payload?.status === 'active';
1730
+ });
1731
+ return baseline ? events.some((event) => {
1732
+ if (event.eventSeq <= baseline.eventSeq)
1733
+ return false;
1734
+ const payload = statusPayload(event);
1735
+ if (!payload)
1736
+ return false;
1737
+ const note = payload.note;
1738
+ return !(typeof note === 'string' && /^(?:queued launch readiness failed|launch readiness warning):/.test(note));
1739
+ }) : false;
1740
+ }
1707
1741
  function noteQueuedLaunchFailureUnlocked(id, error, terminal = true, label, live = false) {
1742
+ const rec = readRecord(id);
1743
+ if (rec && (terminal || label === 'launch readiness warning') && hasLaterLaunchReadinessEvent(rec))
1744
+ return;
1708
1745
  const reason = error instanceof Error ? error.message : String(error);
1709
1746
  const note = `${label ?? (terminal ? 'queued launch readiness failed' : 'launch readiness warning')}: ${reason}`;
1710
1747
  console.error(`spex: session ${id}: ${note}`);
1711
- const rec = readRecord(id);
1712
1748
  if (rec && !retirementReason(rec) && (rec.note !== note
1713
1749
  || (terminal && (rec.status !== 'error' || !rec.stopped || rec.launchReadinessStartedAt != null))
1714
1750
  || (!terminal && live && (rec.status === 'error' || rec.stopped)))) {
@@ -2488,9 +2524,53 @@ function isExplicitConnectionRefused(error) {
2488
2524
  return errors.length > 0 && errors.every(isExplicitConnectionRefused);
2489
2525
  return isExplicitConnectionRefused(error.cause);
2490
2526
  }
2527
+ async function establishBackendConnection(target) {
2528
+ const parsed = new URL(target.url);
2529
+ const port = Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80);
2530
+ return await new Promise((resolve, reject) => {
2531
+ const socket = createConnection({ host: parsed.hostname, port });
2532
+ let settled = false;
2533
+ const finish = (fn) => {
2534
+ if (settled)
2535
+ return;
2536
+ settled = true;
2537
+ clearTimeout(timer);
2538
+ socket.destroy();
2539
+ fn();
2540
+ };
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);
2554
+ timer.unref?.();
2555
+ socket.once('connect', () => finish(() => resolve(false)));
2556
+ socket.once('error', (error) => finish(() => {
2557
+ if (isExplicitConnectionRefused(error))
2558
+ return resolve(true);
2559
+ const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
2560
+ failed.name = 'BackendError';
2561
+ Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error });
2562
+ reject(failed);
2563
+ }));
2564
+ });
2565
+ }
2491
2566
  async function probeSessionCreateAuthority(target) {
2567
+ // TCP acceptance establishes presence. The identity route can be delayed by a busy backend event loop,
2568
+ // so it gets the ordinary create request deadline instead of a short availability deadline.
2569
+ const refused = await establishBackendConnection(target);
2570
+ if (refused)
2571
+ return true;
2492
2572
  const controller = new AbortController();
2493
- const timer = setTimeout(() => controller.abort(), 1500);
2573
+ const timer = setTimeout(() => controller.abort(new Error('backend authority request timed out')), sessionCreateTimeoutMs() + 5_000);
2494
2574
  timer.unref?.();
2495
2575
  let response;
2496
2576
  try {
@@ -2498,11 +2578,9 @@ async function probeSessionCreateAuthority(target) {
2498
2578
  }
2499
2579
  catch (error) {
2500
2580
  clearTimeout(timer);
2501
- if (isExplicitConnectionRefused(error))
2502
- return true;
2503
- const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
2581
+ const failed = new Error(`backend authority read failed after connection at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`);
2504
2582
  failed.name = 'BackendError';
2505
- Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error });
2583
+ Object.assign(failed, { code: 'backend_authority_read_failed', cause: error });
2506
2584
  throw failed;
2507
2585
  }
2508
2586
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spexcode/spec-cli",
3
- "version": "0.7.0-next.2",
3
+ "version": "0.7.0-next.4",
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.2",
39
- "@spexcode/session-selflaunch": "0.7.0-next.2",
40
- "@spexcode/spec-core": "0.7.0-next.2",
41
- "@spexcode/spec-eval": "0.7.0-next.2",
42
- "@spexcode/spec-forge": "0.7.0-next.2",
43
- "@spexcode/transcript": "0.7.0-next.2",
38
+ "@spexcode/session-application": "0.7.0-next.4",
39
+ "@spexcode/session-selflaunch": "0.7.0-next.4",
40
+ "@spexcode/spec-core": "0.7.0-next.4",
41
+ "@spexcode/spec-eval": "0.7.0-next.4",
42
+ "@spexcode/spec-forge": "0.7.0-next.4",
43
+ "@spexcode/transcript": "0.7.0-next.4",
44
44
  "smol-toml": "^1.8.0"
45
45
  },
46
46
  "devDependencies": {