@crouton-kit/crouter 0.3.63 → 0.3.65

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.
@@ -44,7 +44,21 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
44
44
  import { join } from 'node:path';
45
45
  import { createHarness } from '../helpers/harness.js';
46
46
  import { subscribe } from '../../canvas/canvas.js';
47
- import { isPidAlive } from '../../canvas/pid.js';
47
+ import { isPidAlive, capturePidIdentities } from '../../canvas/pid.js';
48
+ // Diagnostic-only, gated behind CRTR_DEBUG_PID_LIVENESS (same env var as the
49
+ // isRecordedPidAlive trace in pid.ts): logs whether the SIGKILLed pid reads
50
+ // alive and what identity ps reports for it, right at the two moments that
51
+ // matter for the intermittent Linux-only crash-revive wedge — directly after
52
+ // confirming the kill and again just before the grace-elapsed tick — so a real
53
+ // run shows whether the pid was reused during the grace window. No effect on
54
+ // test behavior when the env var is unset.
55
+ function debugLogPidState(label, pid) {
56
+ if (!process.env.CRTR_DEBUG_PID_LIVENESS)
57
+ return;
58
+ const identities = capturePidIdentities([pid]);
59
+ const identity = identities === null ? 'probe-null' : identities.has(pid) ? JSON.stringify(identities.get(pid)) : 'no-entry';
60
+ process.stderr.write(`[broker-crash-teardown] ${label}: pid=${pid} isPidAlive=${isPidAlive(pid)} capturePidIdentities=${identity}\n`);
61
+ }
48
62
  // crtrd.ts module const (not exported): the fresh-pi-boot grace window the daemon
49
63
  // waits before grace-reviving / boot-failing a pi observed dead. Reference:
50
64
  // crtrd.ts `REVIVE_GRACE_MS = 20_000`.
@@ -81,9 +95,11 @@ test('CRASH → grace-revive RESUME (one-writer), then clean teardown on close',
81
95
  // Actions run: crouter-development/prove-ci-fixes-in-actions). The waitFor's
82
96
  // successful resolution already IS the proof of deadness at this point.
83
97
  await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
98
+ debugLogPidState('after waitFor(!isPidAlive(oldPid))', oldPid);
84
99
  const NOW = 5_000_000;
85
100
  await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
86
101
  assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
102
+ debugLogPidState('before grace-elapsed tick', oldPid);
87
103
  await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
88
104
  // This is the file's SECOND real broker boot (crash-revive), on an
89
105
  // oversubscribed CI runner sharing the host with the other full-tier files'
@@ -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,77 @@ 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
+ const debug = Boolean(process.env.CRTR_DEBUG_PID_LIVENESS);
78
+ const alive = isPidAlive(pid);
79
+ if (!alive) {
80
+ if (debug)
81
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'isPidAlive=false → dead', false);
82
+ return false; // gone/zombie → dead (unchanged path)
83
+ }
84
+ if (expectedIdentity == null) {
85
+ if (debug)
86
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'no baseline → trust isPidAlive', true);
87
+ return true; // no baseline → trust isPidAlive (legacy behavior)
88
+ }
89
+ const current = capturePidIdentities([pid]);
90
+ if (current == null) {
91
+ if (debug)
92
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'probe-null', 'ps probe failed → fail-open alive', true);
93
+ return true; // probe itself failed → fail-open
94
+ }
95
+ const actual = current.get(pid);
96
+ if (actual === undefined) {
97
+ if (debug)
98
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'no-entry', 'no identity row for pid (race) → fail-open alive', true);
99
+ return true; // present but no identity row (race) → fail-open
100
+ }
101
+ const result = actual === expectedIdentity; // different identity → REUSED → dead
102
+ if (debug)
103
+ logPidLivenessDecision(pid, alive, expectedIdentity, 'value', result ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', result, actual);
104
+ return result;
105
+ }
106
+ /** Diagnostic-only decision trace for `isRecordedPidAlive`, gated entirely
107
+ * behind `CRTR_DEBUG_PID_LIVENESS` — callers must check the env var
108
+ * themselves before calling this so the (tiny) formatting cost is paid ONLY
109
+ * when the env var is set; this function does no env check of its own.
110
+ * Written to trace the intermittent Linux-only CI crash-revive wedge
111
+ * (`broker-crash-teardown.test.ts`'s 'CRASH → grace-revive RESUME'): the
112
+ * goal is for a single stderr line per call to make BOTH a passing run ("pid
113
+ * read dead → revive") and a wedged run ("read alive via <branch>") fully
114
+ * self-explaining, without changing any return value. `capturePidIdentities`
115
+ * outcome is reported as one of three DISTINCT cases — `probe-null` (the
116
+ * `ps` probe itself failed), `no-entry` (probe succeeded but the map has no
117
+ * row for this pid), or `value` (probe succeeded and found an identity,
118
+ * printed verbatim) — plus `no-probe` for the two branches that return
119
+ * before `capturePidIdentities` is ever called. Kept as permanent
120
+ * observability for this primitive, not a throwaway debug print: pid-reuse
121
+ * liveness is subtle enough that the next person chasing a wedge here should
122
+ * find this already in place. */
123
+ function logPidLivenessDecision(pid, isPidAliveResult, expectedIdentity, probeOutcome, branch, returned, actualIdentity) {
124
+ const parts = [
125
+ `pid=${String(pid)}`,
126
+ `isPidAlive=${isPidAliveResult}`,
127
+ `expectedIdentity=${expectedIdentity === undefined ? 'undefined' : expectedIdentity === null ? 'null' : JSON.stringify(expectedIdentity)}`,
128
+ `capturePidIdentities=${probeOutcome}${probeOutcome === 'value' ? `(${JSON.stringify(actualIdentity)})` : ''}`,
129
+ `branch=${JSON.stringify(branch)}`,
130
+ `returned=${returned}`,
131
+ ];
132
+ process.stderr.write(`[isRecordedPidAlive] ${parts.join(' ')}\n`);
133
+ }
63
134
  /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
64
135
  * swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
65
136
  * 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.63",
3
+ "version": "0.3.65",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",