@crouton-kit/crouter 0.3.82 → 0.3.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/README.md +3 -3
  2. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
  3. package/dist/clients/attach/__tests__/attach-keybindings.test.js +21 -4
  4. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
  5. package/dist/clients/attach/__tests__/crtr-output-render.test.js +17 -44
  6. package/dist/clients/attach/attach-cmd.d.ts +2 -3
  7. package/dist/clients/attach/attach-cmd.js +689 -689
  8. package/dist/clients/attach/attach-def.d.ts +2 -0
  9. package/dist/clients/attach/attach-def.js +93 -0
  10. package/dist/clients/attach/canvas-panels.d.ts +10 -3
  11. package/dist/clients/attach/canvas-panels.js +42 -16
  12. package/dist/clients/attach/chat-view.d.ts +20 -1
  13. package/dist/clients/attach/chat-view.js +85 -2
  14. package/dist/commands/__tests__/human.test.js +7 -22
  15. package/dist/commands/attention.js +31 -30
  16. package/dist/commands/human/prompts.js +57 -38
  17. package/dist/commands/human/queue.d.ts +1 -28
  18. package/dist/commands/human/queue.js +156 -520
  19. package/dist/commands/human/shared.d.ts +28 -68
  20. package/dist/commands/human/shared.js +37 -120
  21. package/dist/commands/human.js +15 -17
  22. package/dist/commands/node.js +5 -2
  23. package/dist/commands/surface.js +1 -1
  24. package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
  25. package/dist/commands/sys/__tests__/setup-front-door.test.js +85 -0
  26. package/dist/commands/sys/setup-core.d.ts +10 -0
  27. package/dist/commands/sys/setup-core.js +39 -1
  28. package/dist/commands/sys/setup.js +10 -1
  29. package/dist/core/__tests__/boot.test.js +141 -6
  30. package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
  31. package/dist/core/__tests__/human-deliver.test.js +332 -0
  32. package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
  33. package/dist/core/__tests__/pid-identity-match.test.js +145 -0
  34. package/dist/core/__tests__/tmux-surface.test.js +180 -2
  35. package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
  36. package/dist/core/canvas/__tests__/attention.test.js +126 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
  38. package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
  39. package/dist/core/canvas/attention.d.ts +16 -14
  40. package/dist/core/canvas/attention.js +34 -29
  41. package/dist/core/canvas/boot.d.ts +45 -0
  42. package/dist/core/canvas/boot.js +36 -3
  43. package/dist/core/canvas/browse/app.js +3 -3
  44. package/dist/core/canvas/canvas.d.ts +40 -0
  45. package/dist/core/canvas/canvas.js +65 -1
  46. package/dist/core/canvas/pid.d.ts +86 -17
  47. package/dist/core/canvas/pid.js +152 -37
  48. package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
  49. package/dist/core/canvas/remote-canvas-source.js +4 -2
  50. package/dist/core/canvas/render.d.ts +5 -5
  51. package/dist/core/canvas/render.js +14 -14
  52. package/dist/core/canvas/source.d.ts +2 -2
  53. package/dist/core/canvas/source.js +3 -3
  54. package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
  55. package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
  56. package/dist/core/keybindings/__tests__/resolve.test.js +2 -1
  57. package/dist/core/keybindings/catalog.d.ts +2 -2
  58. package/dist/core/keybindings/catalog.js +4 -2
  59. package/dist/core/keybindings/inbox.d.ts +21 -0
  60. package/dist/core/keybindings/inbox.js +35 -0
  61. package/dist/core/keybindings/index.d.ts +1 -0
  62. package/dist/core/keybindings/index.js +1 -0
  63. package/dist/core/preview-registry.js +23 -29
  64. package/dist/core/profiles/select.js +4 -4
  65. package/dist/core/runtime/pi-vendored.js +15 -1
  66. package/dist/core/runtime/placement.d.ts +1 -10
  67. package/dist/core/runtime/placement.js +43 -26
  68. package/dist/core/runtime/session-list-cache.d.ts +22 -2
  69. package/dist/core/runtime/session-list-cache.js +92 -24
  70. package/dist/core/runtime/spawn.js +2 -1
  71. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  72. package/dist/core/runtime/tmux-chrome.js +1 -1
  73. package/dist/core/runtime/tmux.d.ts +1 -0
  74. package/dist/core/runtime/tmux.js +55 -1
  75. package/dist/core/spawn.d.ts +0 -59
  76. package/dist/core/spawn.js +5 -163
  77. package/dist/daemon/crtrd.js +36 -11
  78. package/dist/index.d.ts +2 -2
  79. package/dist/index.js +1 -1
  80. package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
  81. package/dist/web-client/index.html +1 -1
  82. package/dist/web-client/sw.js +1 -1
  83. package/package.json +2 -2
  84. package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
  85. package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
  86. package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
  87. package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
  88. package/dist/core/__tests__/human-surface-target.test.js +0 -98
  89. package/dist/web-client/assets/index--md2ylfi.js +0 -78
  90. /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
  91. /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
  92. /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
  93. /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
  94. /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
@@ -1,13 +1,16 @@
1
- // attention.ts — pending human-ask counters across the canvas.
1
+ // attention.ts — pending human-ticket counters across the canvas.
2
2
  //
3
- // Human asks are stored per-cwd, not per-node (interactionsRoot is keyed by
4
- // the cwd the agent ran in, same pattern as humanloop's human list command).
5
- // A cwd can be shared by multiple nodes, so we de-dup on cwd before summing to
6
- // avoid counting the same pending ask N times.
3
+ // A "ticket" is any pending humanloop interaction: a deck (ask), a notify deck,
4
+ // or an anchored review. `scanInbox` returns the sealed deck/review summary
5
+ // union (notify is a durable deck), so every count here is "all pending human
6
+ // tickets", not just asks. Tickets are stored per-cwd, not per-node
7
+ // (interactionsRoot is keyed by the cwd the agent ran in), so a cwd shared by
8
+ // multiple nodes is de-duped before summing to avoid counting the same pending
9
+ // ticket N times.
7
10
  //
8
11
  // All public functions are best-effort: scanInbox failures return 0 / empty.
9
- // Callers are display code (dashboard, attention queue) that must not blow up
10
- // on a cold canvas or missing humanloop state.
12
+ // Callers are display code (dashboard, attention queue, attach chrome) that must
13
+ // not blow up on a cold canvas or missing humanloop state.
11
14
  import { scanInbox } from '@crouton-kit/humanloop';
12
15
  import { interactionsRoot } from '../artifact.js';
13
16
  import { getNode, listNodes, view } from './canvas.js';
@@ -15,21 +18,21 @@ import { getNode, listNodes, view } from './canvas.js';
15
18
  // Helpers
16
19
  // ---------------------------------------------------------------------------
17
20
  /**
18
- * Count pending asks for a single cwd root. Never throws.
21
+ * Count pending tickets for a single cwd root. Never throws.
19
22
  *
20
- * When `nodeId` is given, count only asks raised by THAT node — humanloop
21
- * stamps `deck.source.nodeId` with the originating CRTR_NODE_ID, so two nodes
22
- * sharing a cwd no longer pollute each other's count. Asks with no stamp
23
- * (legacy, or raised outside a canvas node) are not attributable to any node
24
- * and are excluded from the per-node count. Read via a cast so this doesn't
25
- * hard-depend on a humanloop type bump.
23
+ * When `nodeId` is given, count only tickets raised by THAT node — humanloop
24
+ * stamps `source.nodeId` (deck AND review submissions alike) with the
25
+ * originating CRTR_NODE_ID, so two nodes sharing a cwd no longer pollute each
26
+ * other's count. Tickets with no stamp (raised from a bare shell, or outside a
27
+ * canvas node) are not attributable to any node and are excluded from the
28
+ * per-node count.
26
29
  */
27
30
  function countForCwd(cwd, nodeId) {
28
31
  try {
29
32
  const items = scanInbox([interactionsRoot(cwd)]);
30
33
  if (nodeId === undefined)
31
34
  return items.length;
32
- return items.filter((i) => i.source?.nodeId === nodeId).length;
35
+ return items.filter((i) => i.source.nodeId === nodeId).length;
33
36
  }
34
37
  catch {
35
38
  // humanloop not installed, or interactions dir doesn't exist — both fine.
@@ -40,23 +43,23 @@ function countForCwd(cwd, nodeId) {
40
43
  // Public API
41
44
  // ---------------------------------------------------------------------------
42
45
  /**
43
- * Count pending asks for the cwd of a single node.
46
+ * Count pending tickets for the cwd of a single node.
44
47
  * Returns 0 when the node is unknown or humanloop is unavailable.
45
48
  */
46
- export function countAsks(nodeId) {
49
+ export function countTickets(nodeId) {
47
50
  const node = getNode(nodeId);
48
51
  if (node === null)
49
52
  return 0;
50
53
  return countForCwd(node.cwd, nodeId);
51
54
  }
52
55
  /**
53
- * Pending asks for all nodes reachable in the subscription sub-DAG from
56
+ * Pending tickets for all nodes reachable in the subscription sub-DAG from
54
57
  * `rootId` (including root itself). De-duped by cwd: when multiple nodes
55
58
  * share a cwd the first one encountered claims the entry.
56
59
  *
57
60
  * Returns only entries with count > 0.
58
61
  */
59
- export function pendingAsksForView(rootId) {
62
+ export function pendingTicketsForView(rootId) {
60
63
  // view() returns children only (excludes root), so prepend root.
61
64
  const ids = [rootId, ...view(rootId)];
62
65
  const seen = new Map(); // cwd → entry
@@ -78,15 +81,17 @@ export function pendingAsksForView(rootId) {
78
81
  return Array.from(seen.values()).filter((e) => e.count > 0);
79
82
  }
80
83
  /**
81
- * Per-node pending ask counts for an explicit set of node ids — the batched
82
- * counterpart to `countAsks`, used by the nav chrome to label every visible
84
+ * Per-node pending ticket counts for an explicit set of node ids — the batched
85
+ * counterpart to `countTickets`, used by the nav chrome to label every visible
83
86
  * node in ONE pass. Groups ids by their cwd so each distinct interactions dir
84
- * is scanned exactly once, then buckets the decks by the `source.nodeId` stamp
85
- * (same attribution as `countForCwd(cwd, nodeId)`). Asks with no node stamp are
86
- * not attributable to any node and are excluded. Every requested id appears in
87
- * the result (0 when it has none). Never throws.
87
+ * is scanned exactly once, de-duplicates by canonical ticket dir (scanInbox
88
+ * already returns one summary per dir), then buckets the tickets by the
89
+ * `source.nodeId` stamp (same attribution as `countForCwd(cwd, nodeId)`).
90
+ * Tickets with no node stamp (bare-shell / unattributed) belong to no node and
91
+ * are excluded from the map. Every requested id appears in the result (0 when
92
+ * it has none). Never throws.
88
93
  */
89
- export function asksForNodes(ids) {
94
+ export function ticketCountsForNodes(ids) {
90
95
  const counts = {};
91
96
  for (const id of ids)
92
97
  counts[id] = 0;
@@ -110,7 +115,7 @@ export function asksForNodes(ids) {
110
115
  }
111
116
  const want = new Set(cwdIds);
112
117
  for (const i of items) {
113
- const nid = i.source?.nodeId;
118
+ const nid = i.source.nodeId;
114
119
  if (nid !== undefined && want.has(nid))
115
120
  counts[nid] = (counts[nid] ?? 0) + 1;
116
121
  }
@@ -118,10 +123,10 @@ export function asksForNodes(ids) {
118
123
  return counts;
119
124
  }
120
125
  /**
121
- * Pending asks across the entire canvas — every distinct cwd among all known
126
+ * Pending tickets across the entire canvas — every distinct cwd among all known
122
127
  * nodes. Returns only entries with count > 0.
123
128
  */
124
- export function asksAcrossCanvas() {
129
+ export function ticketsAcrossCanvas() {
125
130
  const rows = listNodes();
126
131
  const seen = new Map(); // cwd → entry
127
132
  for (const row of rows) {
@@ -7,6 +7,7 @@ export interface BootIdentity {
7
7
  * discriminator only, used when `bootId` is null on either side. */
8
8
  bootEpochMs: number;
9
9
  }
10
+ export declare function readKernelBootId(): string | null;
10
11
  /** Identity of the CURRENT OS boot. `now` is injectable for tests. */
11
12
  export declare function currentBootIdentity(now?: number): BootIdentity;
12
13
  /** Pure decision core: does `current` belong to a DIFFERENT boot than the
@@ -21,11 +22,29 @@ export declare function currentBootIdentity(now?: number): BootIdentity;
21
22
  * on either side (non-Linux dev) does it fall back to the epoch-drift window.
22
23
  * Unit-testable without real uptime/fs. */
23
24
  export declare function bootChanged(current: BootIdentity, known: BootIdentity | null): boolean;
25
+ /** POSITIVE proof that `current` is the SAME boot the persisted `known` marker
26
+ * recorded — as distinct from `!bootChanged(...)`, which is merely "no change
27
+ * detected" and is also true when there is no history, a malformed marker, or
28
+ * only the coarse wall-clock fallback to go on. Proof requires an exact kernel
29
+ * `boot_id` present and equal on BOTH sides. This gates the one-time legacy
30
+ * identity migration: that sweep rewrites a legacy row's baseline as
31
+ * `<currentBootId>#<ticks>`, permanently blessing the recorded pid as a
32
+ * current-boot process, so it must run ONLY when we can prove the recorded pid
33
+ * really is from this boot — never on a mere ticks match under an unproven
34
+ * boot (the epoch fallback or an absent/malformed marker could otherwise bless
35
+ * an unrelated stranger process that happens to collide on pid+ticks). */
36
+ export declare function bootProvenSame(current: BootIdentity, known: BootIdentity | null): boolean;
24
37
  export interface BootReconcileResult {
25
38
  /** True when a boot change was detected (and, therefore, pids were cleared). */
26
39
  changed: boolean;
27
40
  /** node_ids whose stale `pi_pid` was cleared. */
28
41
  cleared: string[];
42
+ /** True ONLY when this run positively proved same-boot provenance against the
43
+ * persisted marker (exact `boot_id` present and equal on both sides). Gates
44
+ * the legacy identity migration — see `bootProvenSame`. False on a boot
45
+ * change, a first-ever/absent/malformed marker, or a wall-clock-fallback
46
+ * match. */
47
+ sameBootProven: boolean;
29
48
  }
30
49
  /** Idempotent and safe to call from every revive entry point (daemon startup,
31
50
  * reviveNode): compares the current boot's identity against the last one
@@ -46,3 +65,29 @@ export interface BootReconcileResult {
46
65
  * `current` defaults to the real identity; tests inject a synthetic one against
47
66
  * an isolated CRTR_HOME to simulate a boot change without a real reboot. */
48
67
  export declare function reconcileBootLiveness(current?: BootIdentity): BootReconcileResult;
68
+ /** The one-time legacy pid-identity migration, injected into the startup
69
+ * composition. In production this is `migrateLegacyPidIdentities` from
70
+ * canvas.ts; it is a parameter (not a direct import) because canvas.ts already
71
+ * depends on pid.ts which depends on this module — injecting it keeps the
72
+ * dependency one-directional and lets tests exercise this exact composition. */
73
+ export type MigrateLegacyPidIdentities = (captureIdentity?: (pid: number) => string | undefined) => {
74
+ migrated: string[];
75
+ };
76
+ export interface BootStartupResult {
77
+ /** The pid-liveness reconciliation result (boot-change sweep + provenance). */
78
+ reconcile: BootReconcileResult;
79
+ /** node_ids whose legacy identity baseline was migrated — empty when the
80
+ * provenance gate skipped the sweep. */
81
+ migrated: string[];
82
+ }
83
+ /** The daemon's one-time startup boot composition, the single source of truth
84
+ * for its ORDERING and GATE: reconcile pid liveness FIRST (clearing any pids
85
+ * recorded before this boot), then run the legacy identity migration ONLY when
86
+ * reconciliation positively PROVED same-boot provenance (`sameBootProven`).
87
+ * Both `runDaemon` and the migration-gate regression tests call this, so a
88
+ * revert that inverts the gate, drops it, or reorders migration ahead of
89
+ * reconciliation is caught here rather than only in a test-side duplicate.
90
+ *
91
+ * `migrate` is injected (see `MigrateLegacyPidIdentities`); `current` and
92
+ * `captureIdentity` pass through to the two steps for test injection. */
93
+ export declare function reconcileAndMigrateBoot(migrate: MigrateLegacyPidIdentities, current?: BootIdentity, captureIdentity?: (pid: number) => string | undefined): BootStartupResult;
@@ -47,7 +47,7 @@ import { openDb } from './db.js';
47
47
  * dangerous direction. The production target (Linux microVM guests) never
48
48
  * reaches this branch; there `boot_id` is exact. */
49
49
  const BOOT_EPOCH_TOLERANCE_MS = 60_000;
50
- function readKernelBootId() {
50
+ export function readKernelBootId() {
51
51
  try {
52
52
  const id = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
53
53
  return id.length > 0 ? id : null;
@@ -79,6 +79,23 @@ export function bootChanged(current, known) {
79
79
  }
80
80
  return Math.abs(current.bootEpochMs - known.bootEpochMs) > BOOT_EPOCH_TOLERANCE_MS;
81
81
  }
82
+ /** POSITIVE proof that `current` is the SAME boot the persisted `known` marker
83
+ * recorded — as distinct from `!bootChanged(...)`, which is merely "no change
84
+ * detected" and is also true when there is no history, a malformed marker, or
85
+ * only the coarse wall-clock fallback to go on. Proof requires an exact kernel
86
+ * `boot_id` present and equal on BOTH sides. This gates the one-time legacy
87
+ * identity migration: that sweep rewrites a legacy row's baseline as
88
+ * `<currentBootId>#<ticks>`, permanently blessing the recorded pid as a
89
+ * current-boot process, so it must run ONLY when we can prove the recorded pid
90
+ * really is from this boot — never on a mere ticks match under an unproven
91
+ * boot (the epoch fallback or an absent/malformed marker could otherwise bless
92
+ * an unrelated stranger process that happens to collide on pid+ticks). */
93
+ export function bootProvenSame(current, known) {
94
+ return (known !== null &&
95
+ current.bootId !== null &&
96
+ known.bootId !== null &&
97
+ current.bootId === known.bootId);
98
+ }
82
99
  const BOOT_MARKER_KEY = 'boot_identity';
83
100
  function readBootMarker(db) {
84
101
  const row = db
@@ -129,8 +146,9 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
129
146
  if (!bootChanged(current, known)) {
130
147
  if (known === null)
131
148
  writeBootMarker(db, current); // seed first-ever marker
149
+ const sameBootProven = bootProvenSame(current, known);
132
150
  db.exec('COMMIT');
133
- return { changed: false, cleared: [] };
151
+ return { changed: false, cleared: [], sameBootProven };
134
152
  }
135
153
  const rows = db
136
154
  .prepare('SELECT node_id FROM nodes WHERE pi_pid IS NOT NULL')
@@ -148,7 +166,7 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
148
166
  }
149
167
  writeBootMarker(db, current);
150
168
  db.exec('COMMIT');
151
- return { changed: true, cleared };
169
+ return { changed: true, cleared, sameBootProven: false };
152
170
  }
153
171
  catch (err) {
154
172
  try {
@@ -160,3 +178,18 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
160
178
  throw err;
161
179
  }
162
180
  }
181
+ /** The daemon's one-time startup boot composition, the single source of truth
182
+ * for its ORDERING and GATE: reconcile pid liveness FIRST (clearing any pids
183
+ * recorded before this boot), then run the legacy identity migration ONLY when
184
+ * reconciliation positively PROVED same-boot provenance (`sameBootProven`).
185
+ * Both `runDaemon` and the migration-gate regression tests call this, so a
186
+ * revert that inverts the gate, drops it, or reorders migration ahead of
187
+ * reconciliation is caught here rather than only in a test-side duplicate.
188
+ *
189
+ * `migrate` is injected (see `MigrateLegacyPidIdentities`); `current` and
190
+ * `captureIdentity` pass through to the two steps for test injection. */
191
+ export function reconcileAndMigrateBoot(migrate, current, captureIdentity) {
192
+ const reconcile = reconcileBootLiveness(current);
193
+ const migrated = reconcile.sameBootProven ? migrate(captureIdentity).migrated : [];
194
+ return { reconcile, migrated };
195
+ }
@@ -84,8 +84,8 @@ export async function runBrowse(opts = {}) {
84
84
  let childIds;
85
85
  let tree;
86
86
  let totalNodes;
87
- // Remote-only: pending-ask counts per node, merged from one
88
- // `askCountsForView` call per forest root (RemoteCanvasSource.askCountsForView
87
+ // Remote-only: pending human-ticket counts per node, merged from one
88
+ // `ticketCountsForView` call per forest root (RemoteCanvasSource.ticketCountsForView
89
89
  // already shells a single `canvas attention map --view <root>` per root, same
90
90
  // cost class as the per-node `subscriptionsOf` fan-out this snapshot already
91
91
  // does below) — fetched once per snapshot rebuild, not per flush/keystroke, so
@@ -105,7 +105,7 @@ export async function runBrowse(opts = {}) {
105
105
  tree = buildTree(rows, rootIds, (id) => childIds.get(id) ?? []);
106
106
  totalNodes = tree.nodes.size;
107
107
  if (isRemote) {
108
- const maps = await Promise.all(rootIds.map((root) => source.askCountsForView(root).catch(() => ({}))));
108
+ const maps = await Promise.all(rootIds.map((root) => source.ticketCountsForView(root).catch(() => ({}))));
109
109
  remoteAskCounts = Object.assign({}, ...maps);
110
110
  }
111
111
  };
@@ -50,6 +50,46 @@ export declare function recordPid(nodeId: string, pid: number): void;
50
50
  * reuses the pid namespace could compare its own fresh pid against a STALE
51
51
  * identity left over from the pid this row held before. */
52
52
  export declare function clearPid(nodeId: string): void;
53
+ /** ONE-TIME completion of the boot-scoped-identity format migration, run once
54
+ * at daemon startup (the natural boot-reconciliation chokepoint, right after
55
+ * `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
56
+ * lane only TOLERATES: a broker that survives an in-place crouter upgrade
57
+ * keeps its old `lstart#ticks` row indefinitely (`recordPid` only fires on
58
+ * launch/revive/session_start), so the cross-boot ticks-collision exposure the
59
+ * boot-scoped base was added to close never provably shuts for those rows.
60
+ *
61
+ * For every row whose RECORDED identity is legacy-format while this platform
62
+ * now composes new-format (a readable kernel boot_id):
63
+ * - if the recorded pid's CURRENT live identity matches the recorded one via
64
+ * the migration lane (same live process, `identitiesMatch` true) AND that
65
+ * current identity is itself boot-scoped — re-record it with the new
66
+ * composer, completing the migration in place;
67
+ * - otherwise (pid gone, probe failed, reused — no match, or no readable
68
+ * boot_id for it) — leave the row untouched for the normal liveness
69
+ * handling that already runs each tick.
70
+ *
71
+ * After this runs once, the legacy compare lane only ever engages for
72
+ * genuinely no-boot_id platforms. NOT a per-caller reconciliation layer — a
73
+ * single startup sweep. `captureIdentity` is injectable for unit tests.
74
+ *
75
+ * GATED on proven same-boot provenance by its caller (daemon startup runs it
76
+ * only when `reconcileBootLiveness` returned `sameBootProven` — see
77
+ * `bootProvenSame`). This is what makes a bare ticks match safe to bless as a
78
+ * current-boot identity: without that proof an equal-ticks collision with an
79
+ * unrelated process from a DIFFERENT boot could be rewritten as
80
+ * `<currentBootId>#<ticks>`, permanently baptizing a stranger.
81
+ *
82
+ * Not transactional by design: it never HOLDS the write lock across the (slow,
83
+ * per-row) `ps` probe. Each re-record is a compare-and-swap single-row UPDATE
84
+ * constrained by ALL three snapshotted values (`node_id`, `pi_pid`,
85
+ * `pi_pid_identity`), counted only when a row actually changed. So if the old
86
+ * broker exits and a concurrent revive atomically records a fresh
87
+ * `(pi_pid, pi_pid_identity)` between this sweep's probe and its update, the
88
+ * CAS matches zero rows and the migration is skipped rather than clobbering
89
+ * the newer identity with the stale one. */
90
+ export declare function migrateLegacyPidIdentities(captureIdentity?: (pid: number) => string | undefined): {
91
+ migrated: string[];
92
+ };
53
93
  /** All rows, optionally filtered by status. */
54
94
  export declare function listNodes(filter?: {
55
95
  status?: NodeStatus | NodeStatus[];
@@ -7,7 +7,7 @@
7
7
  import { existsSync, readFileSync, renameSync, readdirSync, rmSync, openSync, writeSync, fsyncSync, closeSync, statSync, } from 'node:fs';
8
8
  import { join, dirname } from 'node:path';
9
9
  import { openDb } from './db.js';
10
- import { isPidAlive, capturePidIdentities } from './pid.js';
10
+ import { isPidAlive, capturePidIdentities, identitiesMatch, isLegacyTicksIdentity, isBootScopedIdentity, } from './pid.js';
11
11
  import { cancelTriggersFor } from './triggers.js';
12
12
  import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, } from './paths.js';
13
13
  // ---------------------------------------------------------------------------
@@ -272,6 +272,70 @@ export function recordPid(nodeId, pid) {
272
272
  export function clearPid(nodeId) {
273
273
  openDb().prepare('UPDATE nodes SET pi_pid = NULL, pi_pid_identity = NULL WHERE node_id = ?').run(nodeId);
274
274
  }
275
+ /** ONE-TIME completion of the boot-scoped-identity format migration, run once
276
+ * at daemon startup (the natural boot-reconciliation chokepoint, right after
277
+ * `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
278
+ * lane only TOLERATES: a broker that survives an in-place crouter upgrade
279
+ * keeps its old `lstart#ticks` row indefinitely (`recordPid` only fires on
280
+ * launch/revive/session_start), so the cross-boot ticks-collision exposure the
281
+ * boot-scoped base was added to close never provably shuts for those rows.
282
+ *
283
+ * For every row whose RECORDED identity is legacy-format while this platform
284
+ * now composes new-format (a readable kernel boot_id):
285
+ * - if the recorded pid's CURRENT live identity matches the recorded one via
286
+ * the migration lane (same live process, `identitiesMatch` true) AND that
287
+ * current identity is itself boot-scoped — re-record it with the new
288
+ * composer, completing the migration in place;
289
+ * - otherwise (pid gone, probe failed, reused — no match, or no readable
290
+ * boot_id for it) — leave the row untouched for the normal liveness
291
+ * handling that already runs each tick.
292
+ *
293
+ * After this runs once, the legacy compare lane only ever engages for
294
+ * genuinely no-boot_id platforms. NOT a per-caller reconciliation layer — a
295
+ * single startup sweep. `captureIdentity` is injectable for unit tests.
296
+ *
297
+ * GATED on proven same-boot provenance by its caller (daemon startup runs it
298
+ * only when `reconcileBootLiveness` returned `sameBootProven` — see
299
+ * `bootProvenSame`). This is what makes a bare ticks match safe to bless as a
300
+ * current-boot identity: without that proof an equal-ticks collision with an
301
+ * unrelated process from a DIFFERENT boot could be rewritten as
302
+ * `<currentBootId>#<ticks>`, permanently baptizing a stranger.
303
+ *
304
+ * Not transactional by design: it never HOLDS the write lock across the (slow,
305
+ * per-row) `ps` probe. Each re-record is a compare-and-swap single-row UPDATE
306
+ * constrained by ALL three snapshotted values (`node_id`, `pi_pid`,
307
+ * `pi_pid_identity`), counted only when a row actually changed. So if the old
308
+ * broker exits and a concurrent revive atomically records a fresh
309
+ * `(pi_pid, pi_pid_identity)` between this sweep's probe and its update, the
310
+ * CAS matches zero rows and the migration is skipped rather than clobbering
311
+ * the newer identity with the stale one. */
312
+ export function migrateLegacyPidIdentities(captureIdentity = (pid) => capturePidIdentities([pid])?.get(pid)) {
313
+ const db = openDb();
314
+ const rows = db
315
+ .prepare('SELECT node_id, pi_pid, pi_pid_identity FROM nodes WHERE pi_pid IS NOT NULL AND pi_pid_identity IS NOT NULL')
316
+ .all();
317
+ // Compare-and-swap: rewrite only if the row STILL carries the exact pid +
318
+ // legacy identity we probed. A concurrent revive between probe and update
319
+ // records a new (pi_pid, pi_pid_identity) pair, so this WHERE fails to match
320
+ // and we skip rather than clobber the fresh baseline.
321
+ const upd = db.prepare('UPDATE nodes SET pi_pid_identity = ? WHERE node_id = ? AND pi_pid = ? AND pi_pid_identity = ?');
322
+ const migrated = [];
323
+ for (const row of rows) {
324
+ if (!isLegacyTicksIdentity(row.pi_pid_identity))
325
+ continue;
326
+ const current = captureIdentity(row.pi_pid);
327
+ if (current === undefined)
328
+ continue; // pid gone / probe failed → normal liveness handling
329
+ if (!isBootScopedIdentity(current))
330
+ continue; // no readable boot_id for it → nothing to upgrade
331
+ if (!identitiesMatch(current, row.pi_pid_identity))
332
+ continue; // reused → leave to liveness
333
+ const res = upd.run(current, row.node_id, row.pi_pid, row.pi_pid_identity);
334
+ if (res.changes > 0)
335
+ migrated.push(row.node_id); // skipped if a concurrent revive re-recorded
336
+ }
337
+ return { migrated };
338
+ }
275
339
  /** All rows, optionally filtered by status. */
276
340
  export function listNodes(filter) {
277
341
  const db = openDb();
@@ -20,24 +20,50 @@ export declare function isPidAlive(pid: number | null | undefined): boolean;
20
20
  * drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
21
21
  * fix).
22
22
  *
23
- * The hazard this closes: `composeIdentity` appends the `#<ticks>` Linux
24
- * discriminator only when `/proc/<pid>/stat` happens to be readable at
25
- * capture time so the SAME live process can be recorded plain `lstart` on
26
- * one capture and `lstart#ticks` on another (a transient `/proc` read
27
- * failure, or capturing on a platform/container without procfs). A naive
28
- * `===` then reads that as a reuse (different string) and reports `dead` for
29
- * a broker that never actually died — the false-DEAD direction, which can
30
- * make `reviveNode`'s double-launch guard wrongly relaunch a SECOND broker
31
- * onto a still-live session.
23
+ * Two hazards this closes, both in the false-DEAD direction (the dangerous
24
+ * one a false DEAD lets `reviveNode`'s double-launch guard relaunch a
25
+ * SECOND broker onto a still-live session):
32
26
  *
33
- * Rule: split both on `#`. Bases (`lstart`) differ definitely NOT a match
34
- * (different process, or the same pid reused in a different second). Bases
35
- * match and BOTH carry a `#ticks` suffix match iff the suffixes are equal
36
- * (this still catches genuine same-second reuse, the case `procStartTicks`
37
- * exists for). Bases match and EITHER lacks the suffix → coarse-vs-fine,
38
- * can't distinguish MATCH (fail-open to same-process, matching this
39
- * module's universal discipline: only a POSITIVE mismatch counts as reuse,
40
- * never an absence of evidence). */
27
+ * 1. `composeIdentity` appends the `#<ticks>` Linux discriminator only when
28
+ * `/proc/<pid>/stat` happens to be readable at capture time so the SAME
29
+ * live process can be recorded plain `lstart` on one capture and
30
+ * `lstart#ticks` on another (a transient `/proc` read failure, or
31
+ * capturing on a platform/container without procfs). A naive `===` reads
32
+ * that as a reuse (different string) and reports `dead` for a broker that
33
+ * never died.
34
+ * 2. `lstart` is NOT stable for a live process on every host. It is derived
35
+ * from `/proc/stat` btime (wall-clock boot epoch) + starttime jiffies, so
36
+ * a clock correction / suspend-resume shifts btime and re-anchors the
37
+ * SAME live process's `lstart` by seconds between two captures. Observed
38
+ * on Blaxel unikraft guests: pid+ticks identical across probes, `lstart`
39
+ * drifting +81s — a base-first compare then reads the live home-node
40
+ * broker as reused/dead every cycle, driving an uncapped revive loop.
41
+ *
42
+ * A third hazard the base itself once carried: `ticks` (jiffies-since-boot)
43
+ * RESET on every boot and can repeat across boots, so ticks-alone is only
44
+ * safe WITHIN one boot. `composeIdentity` therefore boot-SCOPES the base on
45
+ * Linux-with-procfs — the base IS the kernel `boot_id` UUID — so a
46
+ * new-format identity is `<bootId>#<ticks>` and equality means same boot AND
47
+ * same process. This closes the cross-boot false match at the root, without
48
+ * plumbing boot reconciliation ahead of every caller.
49
+ *
50
+ * Rule: split both on `#`.
51
+ * - BOTH sides carry `#ticks` AND both bases are `boot_id` UUIDs
52
+ * (new-format vs new-format): match iff base AND ticks are equal. A
53
+ * different boot_id with equal ticks is NOT a match (the cross-boot
54
+ * collision).
55
+ * - BOTH sides carry `#ticks` but a base is NOT a boot_id UUID on EITHER
56
+ * side — a LEGACY `lstart#ticks` row recorded before this repo boot-scoped
57
+ * the base, or a Linux host that has procfs ticks but no readable boot_id:
58
+ * ticks DECIDE it alone (the prior landed semantics). This is a NARROW
59
+ * format-migration path so an in-place upgrade never false-kills a live
60
+ * broker whose row still holds `lstart#ticks`; it is NOT a general
61
+ * fallback — new-format vs new-format always demands base+ticks above.
62
+ * - EITHER side lacks a valid `#ticks` suffix (macOS / no procfs / transient
63
+ * `/proc` miss / malformed suffix): fall back to the coarse base —
64
+ * matching iff the bases agree (fail-open to same-process, matching this
65
+ * module's universal discipline: only a POSITIVE mismatch counts as reuse,
66
+ * never an absence of evidence). */
41
67
  export declare function identitiesMatch(a: string, b: string): boolean;
42
68
  /** Three-valued liveness verdict for a RECORDED node pid, identity-guarded
43
69
  * against PID REUSE using the launch-time baseline (`pi_pid_identity`,
@@ -97,6 +123,49 @@ export declare function isRecordedPidAlive(pid: number | null | undefined, expec
97
123
  * Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
98
124
  * (which means "my own group") or a negative/non-integer value. */
99
125
  export declare function killProcessGroup(pid: number, signal?: NodeJS.Signals): void;
126
+ /** The kernel `boot_id` (`/proc/sys/kernel/random/boot_id`) is stable for the
127
+ * whole lifetime of this process — it only changes on a kernel boot, which
128
+ * necessarily kills this process — so once a real value is read it is cached
129
+ * for good, rather than re-read for every row of a whole-process-table `ps`
130
+ * scan. Only SUCCESSFUL reads are cached forever: a `null` read is negative
131
+ * for `BOOT_ID_NEGATIVE_TTL_MS`, after which the next call retries — so a
132
+ * transient `/proc` miss on a boot_id-capable host still recovers (never
133
+ * permanently downgraded to legacy `lstart#ticks`), while a genuinely
134
+ * no-boot_id host is not hammered with a synchronous throwing read on every
135
+ * identity composition. `now` is injectable for tests. */
136
+ export declare function makeBootIdCache(read: () => string | null, now?: () => number, negativeTtlMs?: number): () => string | null;
137
+ /** Is `identity` a LEGACY (pre-boot-scoped) baseline — a `<lstart>#<ticks>` row
138
+ * whose base is NOT a boot_id UUID but which DOES carry ticks? These are the
139
+ * rows the one-time startup migration re-records once this platform can
140
+ * compose boot-scoped identities (see `migrateLegacyPidIdentities`). */
141
+ export declare function isLegacyTicksIdentity(identity: string): boolean;
142
+ /** Is `identity` a NEW-format boot-scoped baseline — `<bootId>#<ticks>` with a
143
+ * real boot_id UUID base AND ticks? The startup migration only re-records a
144
+ * legacy row when the pid's CURRENT identity comes out in this shape (i.e. the
145
+ * platform now composes new-format), never otherwise. */
146
+ export declare function isBootScopedIdentity(identity: string): boolean;
147
+ /** Compose the stable, BOOT-SCOPED process-identity fingerprint used
148
+ * everywhere in this module. ALWAYS used to build an identity string, never a
149
+ * raw field directly, so every identity captured anywhere (launch-time
150
+ * `recordPid`, the teardown snapshot, and the escalation-window re-check) is
151
+ * comparable apples-to-apples.
152
+ *
153
+ * Platform capability, not layered fallbacks — the base is the finest STABLE
154
+ * per-boot anchor available:
155
+ * - Linux-with-procfs (the production guest shape): `<bootId>#<ticks>` — the
156
+ * per-boot `boot_id` UUID plus the per-process jiffies-since-boot. Ticks
157
+ * reset per boot and can repeat, so scoping the base to the boot is what
158
+ * makes an equal-ticks compare safe ACROSS boots (see `identitiesMatch`).
159
+ * - Linux-with-procfs but no readable `boot_id` (if that combination
160
+ * exists): `<lstart>#<ticks>` — the prior landed behavior.
161
+ * - No procfs ticks (macOS / containers without procfs): `<lstart>` alone —
162
+ * unchanged coarse behavior. The base is only ever a `boot_id` when a
163
+ * `ticks` discriminator is ALSO present; a bare `boot_id` (shared by every
164
+ * process on the boot) would be a useless per-process identity. */
165
+ export declare function composeIdentity(pid: number, lstart: string, deps?: {
166
+ ticks?: string | null;
167
+ bootId?: string | null;
168
+ }): string;
100
169
  /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
101
170
  * pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
102
171
  * crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with