@north-light/crouter 0.3.193 → 0.3.194

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 (31) hide show
  1. package/dist/api/dto/bash-jobs.d.ts +2 -0
  2. package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
  3. package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  5. package/dist/clients/attach/viewer.js +377 -377
  6. package/dist/commands/node/bash.js +3 -2
  7. package/dist/commands/node/lifecycle.js +2 -2
  8. package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
  9. package/dist/core/bash-jobs.d.ts +11 -0
  10. package/dist/core/bash-jobs.js +24 -1
  11. package/dist/core/canvas/types.d.ts +2 -1
  12. package/dist/core/inspector/model.d.ts +5 -0
  13. package/dist/core/inspector/model.js +5 -0
  14. package/dist/core/inspector/text.js +2 -2
  15. package/dist/core/inspector/tui.js +10 -3
  16. package/dist/core/keybindings/catalog.d.ts +1 -1
  17. package/dist/core/keybindings/catalog.js +4 -2
  18. package/dist/core/runtime/persona.js +2 -1
  19. package/dist/core/runtime/tmux-bindings.js +8 -1
  20. package/dist/core/termrender/version.d.ts +1 -1
  21. package/dist/core/termrender/version.js +1 -1
  22. package/dist/daemon/api/handlers/bash-jobs.js +1 -0
  23. package/dist/daemon/reconcilers/broker-supervision.js +7 -1
  24. package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
  25. package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
  26. package/dist/daemon/reconcilers/live-obligation.js +11 -0
  27. package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
  28. package/dist/pi-extensions/canvas-bash-valve.js +25 -7
  29. package/package.json +1 -1
  30. package/runtime.lock.json +2 -2
  31. package/scripts/install-runtime.mjs +172 -5
@@ -2,6 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
5
+ import { hostname } from 'node:os';
5
6
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { ENTRY_NAMES, GENERATION_ID, MANIFEST_KEYS, PACKAGE_NAME, MANIFEST_SCHEMA, contained, isSealed, publishedMarkerPath, runtimeHome, sortInventory, validateManifestShape } from '../bin/runtime-generation-manifest.mjs';
@@ -11,9 +12,20 @@ const PACKAGE_ROOT = process.argv[2] ? resolve(process.argv[2]) : resolve(dirnam
11
12
  const RUNTIME_HOME = runtimeHome();
12
13
  const GENERATIONS = join(RUNTIME_HOME, 'generations');
13
14
  const LOCK = join(RUNTIME_HOME, 'install.lock');
15
+ const LOCK_OWNER_FILE = 'owner.json';
14
16
  const RUNTIME_LOCK = 'runtime.lock.json';
15
17
  const KEEP_RECENT_GENERATIONS = 3;
16
18
  const FS_CONCURRENCY = 64;
19
+ // How long an install.lock with no readable owner record is given the
20
+ // benefit of the doubt (the owner is mid-write, or on an ancient version of
21
+ // this script that never wrote one) before it is treated as abandoned.
22
+ const STALE_LOCK_RECORD_THRESHOLD_MS = 15 * 60 * 1000;
23
+ // How long a `.staging-*`/`.archive-*` temp dir sits before the sweep in
24
+ // step 4 treats it as wreckage from an earlier killed install rather than
25
+ // work in progress under a lock we don't hold (impossible while we hold the
26
+ // lock, but a conservative margin costs nothing).
27
+ const STALE_TEMP_DIR_THRESHOLD_MS = 60 * 60 * 1000;
28
+ const LOCK_ACQUIRE_MAX_ATTEMPTS = 50;
17
29
 
18
30
  function run(command, args, cwd, capture = false, env = process.env) {
19
31
  return new Promise((resolveRun, reject) => {
@@ -235,14 +247,162 @@ async function pruneGenerations() {
235
247
  }
236
248
  }
237
249
 
238
- async function install() {
239
- await mkdir(GENERATIONS, { recursive: true });
250
+ /** A process's start time as `ps` reports it — the cheap, boot-unique-enough
251
+ * fellow-traveler of a pid that lets a reused pid be told apart from the
252
+ * process that actually wrote a lock owner record. Empty string when the
253
+ * pid does not exist (or `ps` cannot be run), never rejects. */
254
+ async function pidStartTime(pid) {
255
+ const output = await probe('ps', ['-o', 'lstart=', '-p', String(pid)], RUNTIME_HOME);
256
+ return output.trim();
257
+ }
258
+
259
+ /** Whether `pid` currently identifies a live process, from this user's
260
+ * vantage point. `EPERM` means the pid exists but is owned by someone
261
+ * else — still alive, just not ours to signal. */
262
+ function pidAlive(pid) {
263
+ try {
264
+ process.kill(pid, 0);
265
+ return true;
266
+ } catch (error) {
267
+ return error?.code === 'EPERM';
268
+ }
269
+ }
270
+
271
+ /** Write the lock owner record via temp-file + rename so a concurrent reader
272
+ * never observes a half-written file. Must be called only once `LOCK` is
273
+ * known to be ours (immediately after `mkdir(LOCK)` succeeds). */
274
+ async function writeLockOwner(lockDir) {
275
+ const owner = { pid: process.pid, startTime: await pidStartTime(process.pid), hostname: hostname(), startedAt: new Date().toISOString() };
276
+ const temporary = join(lockDir, `.${LOCK_OWNER_FILE}.${process.pid}-${Date.now()}.tmp`);
277
+ await writeFile(temporary, JSON.stringify(owner));
278
+ await rename(temporary, join(lockDir, LOCK_OWNER_FILE));
279
+ }
280
+
281
+ async function readLockOwner(lockDir) {
282
+ try {
283
+ const owner = JSON.parse(await readFile(join(lockDir, LOCK_OWNER_FILE), 'utf8'));
284
+ return Number.isInteger(owner?.pid) ? owner : null;
285
+ } catch {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /** True only when `owner`'s pid is both alive AND (when a start time was
291
+ * recorded) still the same process that wrote the record — a recycled pid
292
+ * must not masquerade as a live owner. */
293
+ async function ownerIsAlive(owner) {
294
+ if (!owner || !pidAlive(owner.pid)) return false;
295
+ if (!owner.startTime) return true;
296
+ const currentStartTime = await pidStartTime(owner.pid);
297
+ return currentStartTime === '' || currentStartTime === owner.startTime;
298
+ }
299
+
300
+ /** Take `LOCK`, reclaiming it first if its owner is provably dead. A dead
301
+ * owner is reclaimed unconditionally; a missing/corrupt owner record is
302
+ * reclaimed only once the lock dir is older than a conservative threshold
303
+ * (it may belong to a process that has not written its record yet). The
304
+ * reclaim itself is race-safe: the stale dir is renamed to a private,
305
+ * unique path first, and only the process whose rename wins takes the
306
+ * fresh lock — a loser simply retries the ordinary contended path rather
307
+ * than deleting a dir a concurrent winner might still be reading. */
308
+ async function acquireLock() {
309
+ for (let attempt = 0; attempt < LOCK_ACQUIRE_MAX_ATTEMPTS; attempt++) {
310
+ try {
311
+ await mkdir(LOCK);
312
+ await writeLockOwner(LOCK);
313
+ return;
314
+ } catch (error) {
315
+ if (error?.code !== 'EEXIST') throw error;
316
+ const owner = await readLockOwner(LOCK);
317
+ if (owner && (await ownerIsAlive(owner))) {
318
+ throw new Error(`runtime installer lock is held by a live process: pid=${owner.pid} started=${owner.startTime || 'unknown'} host=${owner.hostname || 'unknown'}; refusing to install concurrently`);
319
+ }
320
+ if (!owner) {
321
+ let stat;
322
+ try {
323
+ stat = await lstat(LOCK);
324
+ } catch (statError) {
325
+ if (statError?.code === 'ENOENT') continue; // lock vanished between mkdir and lstat; retry
326
+ throw statError;
327
+ }
328
+ const ageMs = Date.now() - stat.mtimeMs;
329
+ if (ageMs < STALE_LOCK_RECORD_THRESHOLD_MS) {
330
+ throw new Error(`runtime installer lock exists without a readable owner record and is only ${Math.round(ageMs / 1000)}s old: ${LOCK}; inspect and remove only after confirming no installation is running`);
331
+ }
332
+ }
333
+ const graveyard = join(RUNTIME_HOME, `.lock-stale-${process.pid}-${Date.now()}`);
334
+ try {
335
+ await rename(LOCK, graveyard);
336
+ } catch (renameError) {
337
+ if (renameError?.code === 'ENOENT') continue; // another process already reclaimed it; fall back to the contended path
338
+ throw renameError;
339
+ }
340
+ process.stderr.write(`runtime installer: reclaimed a stale lock (dead owner pid=${owner?.pid ?? 'unknown'})\n`);
341
+ await removeStage(graveyard);
342
+ // Loop back and take the now-vacant lock.
343
+ }
344
+ }
345
+ throw new Error(`runtime installer lock: gave up after ${LOCK_ACQUIRE_MAX_ATTEMPTS} contended attempts: ${LOCK}`);
346
+ }
347
+
348
+ /** Sweep `.staging-*`/`.archive-*` temp dirs left behind by an install that
349
+ * was killed before its own `finally` (or signal handler) could run. Only
350
+ * called once this process holds `LOCK`, so any dir old enough to clear
351
+ * the threshold cannot belong to a still-running install. */
352
+ async function sweepOrphanTempDirs() {
353
+ let entries;
240
354
  try {
241
- await mkdir(LOCK);
355
+ entries = await readdir(RUNTIME_HOME, { withFileTypes: true });
356
+ } catch {
357
+ return;
358
+ }
359
+ for (const entry of entries) {
360
+ if (!entry.isDirectory() || !/^\.(?:staging|archive)-/.test(entry.name)) continue;
361
+ const absolute = join(RUNTIME_HOME, entry.name);
362
+ let stat;
363
+ try {
364
+ stat = await lstat(absolute);
365
+ } catch {
366
+ continue;
367
+ }
368
+ if (Date.now() - stat.mtimeMs < STALE_TEMP_DIR_THRESHOLD_MS) continue;
369
+ process.stderr.write(`runtime installer: sweeping orphan temp dir from an earlier aborted install: ${absolute}\n`);
370
+ await removeStage(absolute);
371
+ }
372
+ }
373
+
374
+ // Populated as install() proceeds so a SIGINT/SIGTERM handler can undo
375
+ // exactly what the normal `finally` below would have undone. SIGKILL
376
+ // remains uncatchable, which is exactly why acquireLock() can reclaim.
377
+ const cleanupState = { lockTaken: false, stage: undefined, archiveDir: undefined, publishedRoot: undefined, committed: false, temporary: undefined, id: undefined, exiting: false };
378
+
379
+ async function cleanupOnSignal(signal) {
380
+ if (cleanupState.exiting) return;
381
+ cleanupState.exiting = true;
382
+ process.stderr.write(`runtime installer: received ${signal}, cleaning up before exit\n`);
383
+ try {
384
+ if (cleanupState.temporary) await rm(cleanupState.temporary, { force: true });
385
+ if (cleanupState.stage) await removeStage(cleanupState.stage);
386
+ if (cleanupState.publishedRoot && !cleanupState.committed) {
387
+ await removeStage(cleanupState.publishedRoot);
388
+ if (cleanupState.id) await rm(publishedMarkerPath(GENERATIONS, cleanupState.id), { force: true });
389
+ }
390
+ if (cleanupState.archiveDir) await rm(cleanupState.archiveDir, { recursive: true, force: true });
391
+ if (cleanupState.lockTaken) await rm(LOCK, { recursive: true, force: true });
242
392
  } catch (error) {
243
- if (error?.code === 'EEXIST') throw new Error(`runtime installer lock exists: ${LOCK}; inspect and remove only after confirming no installation is running`);
244
- throw error;
393
+ process.stderr.write(`runtime installer: cleanup on ${signal} failed: ${error?.message ?? error}\n`);
245
394
  }
395
+ process.exit(1);
396
+ }
397
+
398
+ process.on('SIGINT', () => { cleanupOnSignal('SIGINT'); });
399
+ process.on('SIGTERM', () => { cleanupOnSignal('SIGTERM'); });
400
+
401
+ async function install() {
402
+ await mkdir(GENERATIONS, { recursive: true });
403
+ await acquireLock();
404
+ cleanupState.lockTaken = true;
405
+ await sweepOrphanTempDirs();
246
406
  let stage;
247
407
  let archiveDir;
248
408
  let publishedRoot;
@@ -251,9 +411,11 @@ async function install() {
251
411
  let id;
252
412
  try {
253
413
  archiveDir = await mkdtemp(join(RUNTIME_HOME, '.archive-'));
414
+ cleanupState.archiveDir = archiveDir;
254
415
  const archive = await pack(archiveDir);
255
416
  const archiveHash = digest(await readFile(archive));
256
417
  stage = await mkdtemp(join(RUNTIME_HOME, '.staging-'));
418
+ cleanupState.stage = stage;
257
419
  await run('tar', ['-xzf', archive, '--strip-components=1', '-C', stage], PACKAGE_ROOT);
258
420
  await copyFile(join(stage, RUNTIME_LOCK), join(stage, 'package-lock.json'));
259
421
  // Humanloop's postinstall only prewarms its external termrender cache; runtime rendering retries lazily with a plaintext fallback.
@@ -265,6 +427,7 @@ async function install() {
265
427
  const pkg = JSON.parse(await readFile(join(stage, 'package.json'), 'utf8'));
266
428
  const files = sortInventory(await inventory(stage));
267
429
  id = digest(`${archiveHash}\n${digest(await readFile(join(stage, 'package-lock.json')))}\n${digest(JSON.stringify(files))}\n${process.version}\n${process.platform}\n${process.arch}\ninstaller-schema-1`);
430
+ cleanupState.id = id;
268
431
  const finalRoot = join(GENERATIONS, id);
269
432
  const manifest = {
270
433
  schema: MANIFEST_SCHEMA,
@@ -294,8 +457,10 @@ async function install() {
294
457
  await chmod(stage, 0o755);
295
458
  await rename(stage, finalRoot);
296
459
  publishedRoot = finalRoot;
460
+ cleanupState.publishedRoot = publishedRoot;
297
461
  await chmod(finalRoot, 0o555);
298
462
  stage = undefined;
463
+ cleanupState.stage = undefined;
299
464
  await validatePublished(finalRoot, id);
300
465
  }
301
466
  // Only after the full-hash check above has succeeded for this exact id —
@@ -303,9 +468,11 @@ async function install() {
303
468
  await markPublished(GENERATIONS, id);
304
469
  const selected = join(RUNTIME_HOME, 'selected');
305
470
  temporary = join(RUNTIME_HOME, `.selected-${process.pid}-${Date.now()}`);
471
+ cleanupState.temporary = temporary;
306
472
  await symlink(`generations/${id}`, temporary);
307
473
  await rename(temporary, selected);
308
474
  committed = true;
475
+ cleanupState.committed = true;
309
476
  try {
310
477
  await pruneGenerations();
311
478
  } catch (error) {