@crouton-kit/crouter 0.3.62 → 0.3.64

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.
@@ -72,14 +72,28 @@ test('CRASH → grace-revive RESUME (one-writer), then clean teardown on close',
72
72
  // --- Items 4 + 6: kill the broker out from under the daemon (a crash). Drive a
73
73
  // FIXED clock so the grace window is exercised deterministically. ---
74
74
  process.kill(oldPid, 'SIGKILL');
75
+ // ONE-WRITER: the old engine pid is dead BEFORE any revive can launch. Assert
76
+ // this via the waitFor alone — a raw isPidAlive RE-CHECK immediately after
77
+ // would re-probe the same numeric pid a moment later, and on a heavily
78
+ // forking CI runner (test-concurrency=4 x full-tier broker spawns) the OS can
79
+ // recycle a just-freed low pid to an unrelated fresh process inside that gap,
80
+ // reporting "alive" for a pid that never resurrected (proven by a real
81
+ // Actions run: crouter-development/prove-ci-fixes-in-actions). The waitFor's
82
+ // successful resolution already IS the proof of deadness at this point.
75
83
  await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
76
- // ONE-WRITER: the old engine pid is dead BEFORE any revive can launch.
77
- assert.equal(isPidAlive(oldPid), false, 'crashed pid dead before the daemon revives');
78
84
  const NOW = 5_000_000;
79
85
  await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
80
86
  assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
81
87
  await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
82
- const boot2 = await h.awaitBoot(id, { minCount: boots + 1 });
88
+ // This is the file's SECOND real broker boot (crash-revive), on an
89
+ // oversubscribed CI runner sharing the host with the other full-tier files'
90
+ // own broker spawns. Locally this boot lands in ~16s total for the whole
91
+ // test; real Actions runs proved the harness's normal 30s awaitBoot default
92
+ // insufficient, and on a genuinely noisy runner even 60s (measured: one real
93
+ // run's boot took ~65–75s) — crouter-development/prove-ci-fixes-in-actions.
94
+ // Give it a wide, still-bounded ceiling rather than tuning down the shared
95
+ // harness default for every other (cheaper, single-boot) full-tier test.
96
+ const boot2 = await h.awaitBoot(id, { minCount: boots + 1, timeoutMs: 120_000 });
83
97
  assert.equal(boot2.resuming, true, 'grace-revive RESUMES the saved .jsonl');
84
98
  const newPid = h.node(id).pi_pid;
85
99
  assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
@@ -415,14 +415,32 @@ export async function createHarness(opts = {}) {
415
415
  const minCount = o.minCount ?? 1;
416
416
  const bootsPath = join(nodeDir(nodeId), 'fake-pi.boots.jsonl');
417
417
  const errPath = join(nodeDir(nodeId), 'fake-pi.error');
418
- const lines = await waitFor(() => {
419
- const ls = readLines(bootsPath);
420
- return ls.length >= minCount ? ls : null;
421
- }, {
422
- timeoutMs: o.timeoutMs ?? 30_000,
423
- label: `fake-pi boot >= ${minCount} for ${nodeId}` +
424
- (existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''),
425
- });
418
+ let lines;
419
+ try {
420
+ lines = await waitFor(() => {
421
+ const ls = readLines(bootsPath);
422
+ return ls.length >= minCount ? ls : null;
423
+ }, {
424
+ timeoutMs: o.timeoutMs ?? 30_000,
425
+ label: `fake-pi boot >= ${minCount} for ${nodeId}` +
426
+ (existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''),
427
+ });
428
+ }
429
+ catch (err) {
430
+ // Failure-path-only diagnostic: an intermittent, Linux-CI-only hang
431
+ // here (recorded-pid-reuse fooling the daemon's revive liveness gate)
432
+ // was invisible for three CI iterations because the timeout error
433
+ // alone doesn't say WHY the boot never happened. Dump the node row's
434
+ // recorded liveness fields plus the current boot count so a future
435
+ // failing run on Actions is self-explaining from the log alone.
436
+ closeDb();
437
+ const n = getNode(nodeId);
438
+ process.stderr.write(`[awaitBoot] timeout for ${nodeId}: pi_pid=${n?.pi_pid ?? 'null'} ` +
439
+ `pi_pid_identity=${n?.pi_pid_identity ?? 'null'} status=${n?.status ?? 'null'} ` +
440
+ `intent=${n?.intent ?? 'null'} pi_session_id=${n?.pi_session_id ?? 'null'} ` +
441
+ `bootCount=${bootCount(nodeId)} minCount=${minCount}\n`);
442
+ throw err;
443
+ }
426
444
  const boot = JSON.parse(lines[lines.length - 1]);
427
445
  if (typeof boot.pid === 'number')
428
446
  pidsToKill.add(boot.pid);
@@ -13,6 +13,20 @@
13
13
  * out under a long-lived spawning process (see `isZombie`'s doc) never comes
14
14
  * back on its own. */
15
15
  export declare function isPidAlive(pid: number | null | undefined): boolean;
16
+ /** Like `isPidAlive`, but guards a RECORDED node pid against PID REUSE using the
17
+ * launch-time identity baseline (`pi_pid_identity`, captured by `recordPid`).
18
+ * On a heavily-forking host — esp. Linux, where low pids recycle fast — a
19
+ * broker's recorded pid can be reused by an unrelated process after the broker
20
+ * dies; a bare `isPidAlive` then reports the STRANGER as "our broker alive",
21
+ * wedging every daemon supervise/revive decision (the daemon never revives a
22
+ * row that looks alive; `reviveNode`'s double-launch guard never relaunches).
23
+ * Returns false the instant the pid's CURRENT identity is POSITIVELY known to
24
+ * differ from `expectedIdentity` (the recycle case). Fail-open everywhere else
25
+ * — no baseline (legacy row / capture failed at launch), `ps` probe failure, or
26
+ * a pid present-but-unreadable — exactly matching `captureTeardownSnapshot`'s
27
+ * discipline: a MISMATCH is the ONLY thing that reads dead, never an absence of
28
+ * evidence. */
29
+ export declare function isRecordedPidAlive(pid: number | null | undefined, expectedIdentity: string | null | undefined): boolean;
16
30
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
17
31
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
18
32
  * an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
@@ -60,6 +60,32 @@ export function isPidAlive(pid) {
60
60
  }
61
61
  return !isZombie(pid);
62
62
  }
63
+ /** Like `isPidAlive`, but guards a RECORDED node pid against PID REUSE using the
64
+ * launch-time identity baseline (`pi_pid_identity`, captured by `recordPid`).
65
+ * On a heavily-forking host — esp. Linux, where low pids recycle fast — a
66
+ * broker's recorded pid can be reused by an unrelated process after the broker
67
+ * dies; a bare `isPidAlive` then reports the STRANGER as "our broker alive",
68
+ * wedging every daemon supervise/revive decision (the daemon never revives a
69
+ * row that looks alive; `reviveNode`'s double-launch guard never relaunches).
70
+ * Returns false the instant the pid's CURRENT identity is POSITIVELY known to
71
+ * differ from `expectedIdentity` (the recycle case). Fail-open everywhere else
72
+ * — no baseline (legacy row / capture failed at launch), `ps` probe failure, or
73
+ * a pid present-but-unreadable — exactly matching `captureTeardownSnapshot`'s
74
+ * discipline: a MISMATCH is the ONLY thing that reads dead, never an absence of
75
+ * evidence. */
76
+ export function isRecordedPidAlive(pid, expectedIdentity) {
77
+ if (!isPidAlive(pid))
78
+ return false; // gone/zombie → dead (unchanged path)
79
+ if (expectedIdentity == null)
80
+ return true; // no baseline → trust isPidAlive (legacy behavior)
81
+ const current = capturePidIdentities([pid]);
82
+ if (current == null)
83
+ return true; // probe itself failed → fail-open
84
+ const actual = current.get(pid);
85
+ if (actual === undefined)
86
+ return true; // present but no identity row (race) → fail-open
87
+ return actual === expectedIdentity; // different identity → REUSED → dead
88
+ }
63
89
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
64
90
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
65
91
  * an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
@@ -29,7 +29,7 @@ import { fanDoctrineWake } from './close.js';
29
29
  import { buildPiArgv } from './launch.js';
30
30
  import { buildReviveKickoff, drainBearings } from './kickoff.js';
31
31
  import { headlessBrokerHost } from './host.js';
32
- import { isPidAlive } from '../canvas/pid.js';
32
+ import { isRecordedPidAlive } from '../canvas/pid.js';
33
33
  import { reconcileBootLiveness } from '../canvas/boot.js';
34
34
  import { clearFault } from './fault.js';
35
35
  import { clearInjectedDocs } from '../substrate/injected-store.js';
@@ -89,11 +89,14 @@ export function reviveNode(nodeId, opts) {
89
89
  // actually matters, so it's safe to call on every revive regardless of
90
90
  // entry point (daemon tick, or a direct/manual `node lifecycle revive`).
91
91
  reconcileBootLiveness();
92
- // Double-revive guard: the broker's isAlive IS isPidAlive(pi_pid), so a node
93
- // whose broker pid is still running was already revived by another path —
94
- // re-launching would put a SECOND broker on the same session file. No-op.
92
+ // Double-revive guard: the broker's isAlive is identity-aware liveness on
93
+ // pi_pid, so a node whose broker pid is still running (and CONFIRMED to
94
+ // still be that same broker, not a reused pid) was already revived by
95
+ // another path — re-launching would put a SECOND broker on the same
96
+ // session file. No-op. A bare isPidAlive would misread a reused pid (heavy
97
+ // forking, esp. Linux) as "still alive" and skip the relaunch forever.
95
98
  const live = getNode(nodeId) ?? meta;
96
- if (isPidAlive(live.pi_pid)) {
99
+ if (isRecordedPidAlive(live.pi_pid, live.pi_pid_identity)) {
97
100
  return {
98
101
  window: null,
99
102
  session: live.tmux_session ?? null,
@@ -430,7 +430,8 @@ const MEMORY_USAGE_GUIDANCE = 'When your task matches a knowledge doc or prefere
430
430
  "will make without it, so doing the relevant work without consulting it defeats the doc's " +
431
431
  'whole purpose. ' +
432
432
  'Before saving any memory, check for an existing doc that already covers it — update that ' +
433
- 'doc rather than creating a duplicate; delete memories that turn out to be wrong. ' +
433
+ 'doc rather than creating a duplicate, rewriting it as if for the first time instead of ' +
434
+ 'appending the new truth beneath the old; delete memories that turn out to be wrong. ' +
434
435
  "Don't save what the repo already records (code structure, past fixes, git history, " +
435
436
  'CLAUDE.md) or what only matters to this conversation; if asked to remember one of those, ' +
436
437
  'ask what was non-obvious about it and save that instead. Docs auto-surfaced in ' +
@@ -64,7 +64,7 @@ import { FAULT_QUIET_MS, readFault, recordFault } from '../core/runtime/fault.js
64
64
  import { fanDoctrineWake } from '../core/runtime/close.js';
65
65
  import { logger } from '../core/log.js';
66
66
  export { FAULT_QUIET_MS };
67
- import { isPidAlive } from '../core/canvas/pid.js';
67
+ import { isPidAlive, isRecordedPidAlive } from '../core/canvas/pid.js';
68
68
  import { reconcileBootLiveness } from '../core/canvas/boot.js';
69
69
  import { listLivePanes, tearDownNode } from '../core/runtime/placement.js';
70
70
  import { reviveNode } from '../core/runtime/revive.js';
@@ -704,7 +704,15 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
704
704
  // enforce §H: handleYieldStall no-ops unless this node yielded (intent=refresh),
705
705
  // its turn is over, and the engine never exited — past the grace it kills the
706
706
  // engine so the dead-pid refresh branch below revives it fresh.
707
- if (pid != null && isPidAlive(pid)) {
707
+ //
708
+ // Liveness here is IDENTITY-aware, not a bare isPidAlive: on a heavily-
709
+ // forking host (esp. Linux, where low pids recycle fast) a just-dead
710
+ // broker's pid can be reused by an unrelated process before this tick
711
+ // runs, and a bare isPidAlive would report the STRANGER as "our broker
712
+ // alive" — clearing the grace clock forever and wedging revive (the
713
+ // recorded-pid-reuse hang). isRecordedPidAlive guards against that with
714
+ // the launch-time identity baseline (`row.pi_pid_identity`).
715
+ if (pid != null && isRecordedPidAlive(pid, row.pi_pid_identity)) {
708
716
  unhealthySince.delete(id);
709
717
  clearStrandedRelaunchState(id); // a live engine means the stranding resolved
710
718
  handleYieldStall(row, pid, now);
@@ -1149,8 +1157,10 @@ export async function superviseTick(now = Date.now()) {
1149
1157
  // The in-process inbox-watcher only owns delivery while the engine is LIVE.
1150
1158
  // A released node's broker is DEAD (it freed itself while dormant), so it
1151
1159
  // has no watcher and the daemon must wake it. Gate the skip on engine
1152
- // liveness alone — viewer panes are irrelevant here.
1153
- if (r.pi_pid != null && isPidAlive(r.pi_pid))
1160
+ // liveness alone — viewer panes are irrelevant here. Identity-aware: a
1161
+ // bare isPidAlive would misread a reused pid as "still alive" and skip
1162
+ // the revive forever, stranding the node on its unseen inbox entry.
1163
+ if (r.pi_pid != null && isRecordedPidAlive(r.pi_pid, r.pi_pid_identity))
1154
1164
  continue;
1155
1165
  const entries = readInboxSince(row.node_id, readCursor(row.node_id));
1156
1166
  if (entries.length > 0) {
@@ -1214,7 +1224,9 @@ export async function superviseTick(now = Date.now()) {
1214
1224
  }
1215
1225
  if (revivedThisTick.has(target.node_id))
1216
1226
  continue;
1217
- if (target.pi_pid != null && isPidAlive(target.pi_pid))
1227
+ // Identity-aware: a bare isPidAlive would misread a reused pid as
1228
+ // "still alive" and skip the wake, stranding the scheduled trigger.
1229
+ if (target.pi_pid != null && isRecordedPidAlive(target.pi_pid, target.pi_pid_identity))
1218
1230
  continue;
1219
1231
  process.stderr.write(`[crtrd] wake ${target.node_id} (bare alarm)\n`);
1220
1232
  reviveNode(target.node_id, { resume: false, wakeReason: wakeOriginFrom(t) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.62",
3
+ "version": "0.3.64",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",