@north-light/crouter 0.3.303 → 0.3.305

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 (102) hide show
  1. package/dist/api/__tests__/integration/client.test.js +37 -36
  2. package/dist/api/client.d.ts +32 -55
  3. package/dist/api/client.js +102 -98
  4. package/dist/api/dto/messages.d.ts +6 -5
  5. package/dist/api/errors.d.ts +3 -0
  6. package/dist/api/errors.js +10 -0
  7. package/dist/api/index.d.ts +1 -1
  8. package/dist/api/index.js +1 -1
  9. package/dist/builtin-memory/internal/plugins.md +1 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +87 -0
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +31 -26
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +33 -26
  13. package/dist/clients/attach/viewer.js +584 -584
  14. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  15. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  16. package/dist/commands/api-client.d.ts +3 -3
  17. package/dist/commands/api-client.js +3 -3
  18. package/dist/commands/memory/read.js +2 -1
  19. package/dist/commands/node/bash.js +6 -6
  20. package/dist/commands/node/create.js +7 -1
  21. package/dist/commands/node/message.js +3 -3
  22. package/dist/commands/node/wait.js +1 -1
  23. package/dist/commands/node-worktree.js +5 -1
  24. package/dist/commands/sys/daemon.js +11 -13
  25. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  26. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  27. package/dist/core/__tests__/fixtures/fake-engine.js +14 -15
  28. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  29. package/dist/core/__tests__/helpers/harness.js +9 -0
  30. package/dist/core/__tests__/integration/tmux-surface.test.js +53 -0
  31. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  32. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  33. package/dist/core/__tests__/kickoff.test.js +40 -0
  34. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  35. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  36. package/dist/core/__tests__/seam/broker-provider-retry.test.js +25 -0
  37. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  38. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  39. package/dist/core/__tests__/seam/deadline-wake-delivery.test.d.ts +1 -0
  40. package/dist/core/__tests__/seam/deadline-wake-delivery.test.js +138 -0
  41. package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.d.ts +1 -0
  42. package/dist/core/__tests__/seam/reopen-invalidated-worktree.test.js +60 -0
  43. package/dist/core/bash-jobs.d.ts +20 -8
  44. package/dist/core/bash-jobs.js +40 -21
  45. package/dist/core/canvas/types.d.ts +4 -2
  46. package/dist/core/command.js +1 -1
  47. package/dist/core/fault-classifier.d.ts +7 -1
  48. package/dist/core/fault-classifier.js +9 -4
  49. package/dist/core/provider-failure.d.ts +30 -0
  50. package/dist/core/provider-failure.js +86 -0
  51. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  52. package/dist/core/runtime/broker/event-projection.js +2 -15
  53. package/dist/core/runtime/broker/fault-retry.js +16 -0
  54. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  55. package/dist/core/runtime/broker.js +0 -5
  56. package/dist/core/runtime/fault.d.ts +2 -1
  57. package/dist/core/runtime/fault.js +39 -1
  58. package/dist/core/runtime/host.js +10 -1
  59. package/dist/core/runtime/kickoff.d.ts +3 -0
  60. package/dist/core/runtime/kickoff.js +24 -4
  61. package/dist/core/runtime/reopen.d.ts +2 -2
  62. package/dist/core/runtime/reopen.js +6 -4
  63. package/dist/core/runtime/revive.js +3 -3
  64. package/dist/core/runtime/spawn.js +5 -6
  65. package/dist/core/runtime/tmux-bindings.js +14 -3
  66. package/dist/core/termrender/version.d.ts +1 -1
  67. package/dist/core/termrender/version.js +1 -1
  68. package/dist/core/worktree-close.d.ts +4 -0
  69. package/dist/core/worktree-close.js +228 -0
  70. package/dist/core/worktree-containment.d.ts +14 -0
  71. package/dist/core/worktree-containment.js +48 -0
  72. package/dist/core/worktree-mutation-async.d.ts +13 -0
  73. package/dist/core/worktree-mutation-async.js +281 -0
  74. package/dist/core/worktree-sweep.js +24 -82
  75. package/dist/core/worktree.d.ts +1 -0
  76. package/dist/core/worktree.js +7 -4
  77. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  78. package/dist/daemon/api/__tests__/reopen-delivery.test.js +31 -0
  79. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  80. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +36 -4
  81. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  82. package/dist/daemon/api/handlers/messages.js +33 -14
  83. package/dist/daemon/api/handlers/reports.js +3 -2
  84. package/dist/daemon/api/handlers/worktree.js +7 -6
  85. package/dist/daemon/fleet.d.ts +1 -1
  86. package/dist/daemon/fleet.js +30 -12
  87. package/dist/daemon/manage.d.ts +8 -5
  88. package/dist/daemon/manage.js +63 -40
  89. package/dist/daemon/reconcilers/broker-supervision.js +10 -1
  90. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  91. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  92. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  93. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  94. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  95. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  96. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  97. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  98. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  99. package/dist/pi-extensions/canvas-bash-valve.js +73 -43
  100. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  101. package/package.json +1 -1
  102. package/runtime.lock.json +5 -5
@@ -573,6 +573,14 @@ export class BrokerSupervisionReconciler {
573
573
  const name = meta !== null ? fullName(meta) : id;
574
574
  const isAuth = fault.kind === 'auth';
575
575
  const firstLine = (fault.message.split('\n')[0] ?? '').slice(0, 200);
576
+ const providerError = fault.providerError;
577
+ const providerDetail = providerError === undefined
578
+ ? ''
579
+ : ` Provider: ${providerError.provider}/${providerError.model}.` +
580
+ (providerError.status === undefined ? '' : ` HTTP status: ${providerError.status}.`) +
581
+ (providerError.errno === undefined ? '' : ` Errno: ${providerError.errno}.`) +
582
+ (providerError.code === undefined ? '' : ` Code: ${providerError.code}.`) +
583
+ (providerError.body === undefined ? '' : ` Body: ${providerError.body.slice(0, 200)}.`);
576
584
  const terminalOverflow = fault.kind === 'context-overflow';
577
585
  const label = terminalOverflow
578
586
  ? `Child failed — ${name} (${id}) exceeded its model context window and is now dead. ` +
@@ -582,7 +590,7 @@ export class BrokerSupervisionReconciler {
582
590
  (isAuth
583
591
  ? `Its provider credential is dead — re-authenticate (\`/login\`, or re-auth crtr); the child recovers on its next wake once the auth reloads. `
584
592
  : ``) +
585
- `Error: ${firstLine}`;
593
+ `Error: ${firstLine}.${providerDetail}`;
586
594
  if (terminalOverflow) {
587
595
  try {
588
596
  transition(id, 'crash', {
@@ -618,6 +626,7 @@ export class BrokerSupervisionReconciler {
618
626
  child: id,
619
627
  kind: fault.kind,
620
628
  message: firstLine,
629
+ ...(providerError === undefined ? {} : { provider_error: providerError }),
621
630
  });
622
631
  }
623
632
  async handleAuthReload(fleet) {
@@ -30,6 +30,12 @@ const TERMINAL_STATUSES = ['done', 'dead', 'canceled'];
30
30
  function isTerminal(row) {
31
31
  return TERMINAL_STATUSES.includes(row.status);
32
32
  }
33
+ /** A closed record already has an explicit close disposition. An open record
34
+ * reaches the sweep only when its owner published a final result; a crash or
35
+ * cancellation leaves its managed checkout available for explicit recovery. */
36
+ function hasSweepDisposition(row, wt) {
37
+ return wt?.state === 'closed' || (wt?.state === 'open' && row.status === 'done' && row.final_report !== null);
38
+ }
33
39
  /** Is this node's engine definitively gone?
34
40
  *
35
41
  * Terminal status alone is not enough: `push final` marks a node `done` while
@@ -49,6 +55,8 @@ function engineIsGone(row, fleet, snapshot) {
49
55
  return false;
50
56
  if (!isTerminal(row))
51
57
  return false;
58
+ if (!hasSweepDisposition(row, getNode(row.node_id)?.managed_worktree))
59
+ return false;
52
60
  return recordedPidLiveness(row.pi_pid, row.pi_pid_identity, snapshot) === 'dead';
53
61
  }
54
62
  /** The eligibility recheck the reconciliation runs immediately before EVERY Git
@@ -70,6 +78,8 @@ function eligibilityGuard(row, fleet) {
70
78
  const current = getRow(nodeId);
71
79
  if (current === null || !isTerminal(current) || current.frozen_at !== null)
72
80
  return false;
81
+ if (!hasSweepDisposition(current, getNode(nodeId)?.managed_worktree))
82
+ return false;
73
83
  return current.pi_pid === pid && current.pi_pid_identity === identity;
74
84
  },
75
85
  };
@@ -120,6 +130,8 @@ export class ManagedWorktreeSweepReconciler {
120
130
  }
121
131
  if (!isReconcilable(wt) || wt == null)
122
132
  continue;
133
+ if (!hasSweepDisposition(row, wt))
134
+ continue;
123
135
  if (!isDueForSweep(wt, now))
124
136
  continue;
125
137
  due.push(row.node_id);
@@ -7,12 +7,6 @@ export interface NodeLifecycleContext {
7
7
  rows: readonly NodeRow[];
8
8
  fleet: FleetRegistry;
9
9
  }
10
- /** Whether this row should have a live engine right now. An active row always
11
- * should — that status IS the claim that it is executing. An idle row was
12
- * deliberately released and holds no engine on purpose: only mail it has not
13
- * seen brings one back, because a live obligation keeps a node WAKEABLE rather
14
- * than awake, and relaunching on one would re-release it every tick. */
15
- export declare function wantsExecution(row: NodeRow): boolean;
16
10
  export declare class NodeLifecycleReconciler {
17
11
  private readonly freezeLane;
18
12
  run(now: number, ctx: NodeLifecycleContext): Promise<FrozenCounts>;
@@ -12,7 +12,6 @@ import { isSafeNodeId } from '../../../core/canvas/paths.js';
12
12
  import { recordedPidLiveness } from '../../../core/canvas/pid.js';
13
13
  import { emitEvent } from '../../../core/events/emit.js';
14
14
  import { operationIdContext } from '../../../core/events/operation-id.js';
15
- import { hasUnseenWakeMail } from '../../../core/feed/inbox.js';
16
15
  import { hasCleanAbort, isBusy } from '../../../core/runtime/busy.js';
17
16
  import { isProfilePaused } from '../../../core/profiles/manifest.js';
18
17
  import { classifyDeadNode } from '../../fleet.js';
@@ -20,21 +19,10 @@ import { applyParkEvent, hasPendingWake, parkEvent } from '../live-obligation.js
20
19
  import { FreezeLaneReconciler } from './freeze-lane.js';
21
20
  import { isInBackoff } from './respawn-policy.js';
22
21
  import { handleTerminatingRow } from './terminating.js';
22
+ import { wantsExecution } from './wants-execution.js';
23
23
  function isTerminal(row) {
24
24
  return row.status === 'done' || row.status === 'canceled' || row.status === 'dead';
25
25
  }
26
- /** Whether this row should have a live engine right now. An active row always
27
- * should — that status IS the claim that it is executing. An idle row was
28
- * deliberately released and holds no engine on purpose: only mail it has not
29
- * seen brings one back, because a live obligation keeps a node WAKEABLE rather
30
- * than awake, and relaunching on one would re-release it every tick. */
31
- export function wantsExecution(row) {
32
- if (row.status === 'active')
33
- return true;
34
- if (row.status !== 'idle')
35
- return false;
36
- return hasUnseenWakeMail(row.node_id);
37
- }
38
26
  export class NodeLifecycleReconciler {
39
27
  freezeLane = new FreezeLaneReconciler();
40
28
  async run(now, ctx) {
@@ -0,0 +1,5 @@
1
+ import type { NodeRow } from '../../../core/canvas/index.js';
2
+ /** Whether the lifecycle tick must ensure this row has an engine. The durable
3
+ * message handler calls this after appending its entry: that append makes the
4
+ * idle branch's unseen-wake-mail check true for a wake-capable entry. */
5
+ export declare function wantsExecution(row: Pick<NodeRow, 'node_id' | 'status'>): boolean;
@@ -0,0 +1,11 @@
1
+ import { hasUnseenWakeMail } from '../../../core/feed/inbox.js';
2
+ /** Whether the lifecycle tick must ensure this row has an engine. The durable
3
+ * message handler calls this after appending its entry: that append makes the
4
+ * idle branch's unseen-wake-mail check true for a wake-capable entry. */
5
+ export function wantsExecution(row) {
6
+ if (row.status === 'active')
7
+ return true;
8
+ if (row.status !== 'idle')
9
+ return false;
10
+ return hasUnseenWakeMail(row.node_id);
11
+ }
@@ -5,12 +5,16 @@
5
5
  import { test, before, after, beforeEach, afterEach } from 'node:test';
6
6
  import assert from 'node:assert/strict';
7
7
  import { existsSync, mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { createServer } from 'node:http';
8
9
  import { tmpdir } from 'node:os';
9
10
  import { join } from 'node:path';
10
11
  import { registerCanvasContextIntro, CONTEXT_INTRO_CUSTOM_TYPE, } from '../canvas-context-intro.js';
11
12
  import { registerCanvasDocSubstrate } from '../canvas-doc-substrate.js';
12
13
  import { buildContextBearings as buildContextIntro } from '../../core/runtime/bearings.js';
13
14
  import { installPersonaTransitionGate } from '../../core/runtime/broker-persona-guidance.js';
15
+ import { FaultRetry } from '../../core/runtime/broker/fault-retry.js';
16
+ import { readFault } from '../../core/runtime/fault.js';
17
+ import { ApiError } from '../../api/errors.js';
14
18
  import { createNode, deleteNode, getNode, updateNode } from '../../core/canvas/canvas.js';
15
19
  import { apiSocketPath } from '../../core/canvas/paths.js';
16
20
  import { createApiServer } from '../../daemon/api/server.js';
@@ -32,6 +36,23 @@ async function waitForSocket(path) {
32
36
  }
33
37
  throw new Error(`api socket never bound at ${path}`);
34
38
  }
39
+ async function listenUnavailableApi(path) {
40
+ const unavailable = createServer((_request, response) => {
41
+ response.writeHead(503, { 'content-type': 'application/json' });
42
+ response.end(JSON.stringify({ error: { code: 'daemon_unavailable', message: 'local daemon connection refused' } }));
43
+ });
44
+ await new Promise((resolve, reject) => {
45
+ unavailable.once('error', reject);
46
+ unavailable.listen(path, () => {
47
+ unavailable.off('error', reject);
48
+ resolve();
49
+ });
50
+ });
51
+ return unavailable;
52
+ }
53
+ function closeHttpServer(server) {
54
+ return new Promise((resolve, reject) => server.close((error) => error === undefined ? resolve() : reject(error)));
55
+ }
35
56
  function node(id, over = {}) {
36
57
  return {
37
58
  node_id: id,
@@ -322,6 +343,68 @@ test('child /new commits its baseline before ack and the provider gate delivers
322
343
  process.env['CRTR_PROFILE_ID'] = previousProfile;
323
344
  }
324
345
  });
346
+ test('a typed local API error remains a retryable broker-to-daemon fault after settlement', async () => {
347
+ createNode(node('persona-transport', {
348
+ persona_ack: { kind: 'review', mode: 'base', lifecycle: 'terminal' },
349
+ }));
350
+ await server.close();
351
+ let unavailable;
352
+ try {
353
+ unavailable = await listenUnavailableApi(apiSocketPath());
354
+ const messages = [];
355
+ const agent = {
356
+ state: { messages },
357
+ transformContext: async (context) => context,
358
+ };
359
+ installPersonaTransitionGate('persona-transport', {
360
+ agent,
361
+ sessionManager: {
362
+ appendCustomMessageEntry: () => undefined,
363
+ },
364
+ });
365
+ await assert.rejects(() => agent.transformContext([{ role: 'user', content: 'continue' }]), (error) => error instanceof ApiError && error.status === 503 && error.code === 'daemon_unavailable');
366
+ assert.deepEqual(readFault('persona-transport'), {
367
+ link: 'broker↔crtrd',
368
+ op: 'persona transition',
369
+ kind: 'connection',
370
+ retry: { disposition: 'manual' },
371
+ message: 'local daemon connection refused',
372
+ since: readFault('persona-transport')?.since,
373
+ operation_id: readFault('persona-transport')?.operation_id,
374
+ }, 'the typed daemon transport error is recorded before Pi turns it into a generic agent error');
375
+ const session = {
376
+ sessionManager: { getLeafId: () => 'persona-transport-leaf' },
377
+ model: undefined,
378
+ };
379
+ const generation = {
380
+ id: 1,
381
+ session,
382
+ fallback: { blocked: false },
383
+ stagedProviderAgentEnd: {
384
+ messages: [{ role: 'assistant', stopReason: 'error', errorMessage: 'local daemon connection refused' }],
385
+ },
386
+ stagedWatchdogAbort: false,
387
+ stagedRefreshAbort: false,
388
+ stagedOverflowFailure: null,
389
+ executingTools: new Set(),
390
+ };
391
+ new FaultRetry({
392
+ nodeId: 'persona-transport',
393
+ currentSession: () => session,
394
+ installedGeneration: () => generation,
395
+ }).publishSettledProviderFault(generation);
396
+ const fault = readFault('persona-transport');
397
+ assert.equal(fault?.link, 'broker↔crtrd');
398
+ assert.equal(fault?.kind, 'connection');
399
+ assert.equal(fault?.retry.disposition, 'manual');
400
+ }
401
+ finally {
402
+ if (unavailable !== undefined)
403
+ await closeHttpServer(unavailable);
404
+ server = createApiServer();
405
+ await waitForSocket(apiSocketPath());
406
+ }
407
+ });
325
408
  test('resume idempotency uses the exact details stamp independent of block text', async () => {
326
409
  createNode(node('stampD'));
327
410
  // The exact node-id stamp is authoritative even when prose carries no id.
@@ -0,0 +1,133 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { backgroundRunningBashJobs, bashJobsDir, bashJobPaths, readJobPgid, stopBackgroundBashJob } from '../../../core/bash-jobs.js';
8
+ import { capturePidIdentities, isPidAlive } from '../../../core/canvas/pid.js';
9
+ import { BACKGROUND_BASH_LOG_TRUNCATION_MARKER, MAX_BACKGROUND_BASH_LOG_BYTES, createValveOperations } from '../../canvas-bash-valve.js';
10
+ const POLL_MS = 20;
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+ async function waitFor(condition, message) {
15
+ const deadline = Date.now() + 4_000;
16
+ while (Date.now() < deadline) {
17
+ if (condition())
18
+ return;
19
+ await sleep(POLL_MS);
20
+ }
21
+ throw new Error(message);
22
+ }
23
+ async function backgroundOneJob(contextDir) {
24
+ await waitFor(() => backgroundRunningBashJobs(contextDir).length === 1, 'bash job did not start');
25
+ await waitFor(() => {
26
+ try {
27
+ return readdirSync(bashJobsDir(contextDir)).length === 1;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }, 'background job directory was not created');
33
+ return readdirSync(bashJobsDir(contextDir))[0];
34
+ }
35
+ function killOwnedProcess(pid) {
36
+ if (pid === undefined)
37
+ return;
38
+ try {
39
+ process.kill(pid, 'SIGKILL');
40
+ }
41
+ catch {
42
+ // The test already stopped this exact process.
43
+ }
44
+ }
45
+ test('background cancellation reaches a descendant that changed process groups', { timeout: 10_000 }, async () => {
46
+ const root = mkdtempSync(join(tmpdir(), 'crtr-bash-tree-'));
47
+ const contextDir = join(root, 'context');
48
+ const escapedPidPath = join(root, 'escaped.pid');
49
+ let escapedPid;
50
+ try {
51
+ const python = [
52
+ 'import os, pathlib, time',
53
+ 'os.setpgrp()',
54
+ `pathlib.Path(${JSON.stringify(escapedPidPath)}).write_text(f"{os.getpid()} {os.getpgrp()}")`,
55
+ 'time.sleep(30)',
56
+ ].join('; ');
57
+ const run = createValveOperations('test-node', contextDir).exec(`python3 -c ${JSON.stringify(python)}`, root, { onData: () => { } });
58
+ const jobId = await backgroundOneJob(contextDir);
59
+ await run;
60
+ await waitFor(() => existsSync(escapedPidPath), 'escaped descendant did not start');
61
+ const [pidText, pgidText] = readFileSync(escapedPidPath, 'utf8').split(' ');
62
+ escapedPid = Number(pidText);
63
+ const escapedPgid = Number(pgidText);
64
+ assert.ok(Number.isInteger(escapedPid) && escapedPid > 1);
65
+ assert.notEqual(escapedPgid, readJobPgid(contextDir, jobId));
66
+ const stopped = await stopBackgroundBashJob(contextDir, jobId);
67
+ assert.equal(stopped.kind, 'stopped');
68
+ assert.equal(stopped.signaled, true);
69
+ await waitFor(() => {
70
+ try {
71
+ process.kill(escapedPid, 0);
72
+ return false;
73
+ }
74
+ catch {
75
+ return true;
76
+ }
77
+ }, 'escaped descendant survived background cancellation');
78
+ }
79
+ finally {
80
+ killOwnedProcess(escapedPid);
81
+ rmSync(root, { recursive: true, force: true });
82
+ }
83
+ });
84
+ test('stale background metadata never signals a reused supervisor PID', async () => {
85
+ const root = mkdtempSync(join(tmpdir(), 'crtr-bash-stale-pid-'));
86
+ const contextDir = join(root, 'context');
87
+ const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 30_000)']);
88
+ try {
89
+ assert.notEqual(child.pid, undefined);
90
+ const actualIdentity = capturePidIdentities([child.pid])?.get(child.pid);
91
+ assert.notEqual(actualIdentity, undefined);
92
+ const paths = bashJobPaths(contextDir, '1234567890-stale');
93
+ mkdirSync(paths.dir, { recursive: true });
94
+ writeFileSync(paths.jobBg, '');
95
+ writeFileSync(paths.jobPgid, String(child.pid));
96
+ writeFileSync(paths.jobPgidIdentity, 'stale-supervisor');
97
+ const stopped = await stopBackgroundBashJob(contextDir, paths.jobId);
98
+ assert.deepEqual(stopped, { kind: 'stopped', signaled: false, pgid: child.pid, logPath: paths.jobLog });
99
+ assert.equal(isPidAlive(child.pid), true, 'a process with a reused supervisor PID was signaled');
100
+ }
101
+ finally {
102
+ child.kill('SIGKILL');
103
+ rmSync(root, { recursive: true, force: true });
104
+ }
105
+ });
106
+ test('background log retains a capped prefix, truncation marker, and does not grow afterward', { timeout: 10_000 }, async () => {
107
+ const root = mkdtempSync(join(tmpdir(), 'crtr-bash-log-'));
108
+ const contextDir = join(root, 'context');
109
+ try {
110
+ const writer = [
111
+ 'const chunk = Buffer.alloc(1024 * 1024, 120)',
112
+ 'setTimeout(() => {',
113
+ ' for (let i = 0; i < 11; i += 1) process.stdout.write(chunk)',
114
+ ' setTimeout(() => process.stdout.write("after-cap"), 200)',
115
+ '}, 200)',
116
+ 'setTimeout(() => {}, 1_000)',
117
+ ].join('; ');
118
+ const run = createValveOperations('test-node', contextDir).exec(`${process.execPath} -e ${JSON.stringify(writer)}`, root, { onData: () => { } });
119
+ const jobId = await backgroundOneJob(contextDir);
120
+ await run;
121
+ const { jobExit, jobLog } = bashJobPaths(contextDir, jobId);
122
+ await waitFor(() => existsSync(jobLog) && readFileSync(jobLog, 'utf8').includes(BACKGROUND_BASH_LOG_TRUNCATION_MARKER), 'log did not record truncation');
123
+ const sizeAtCap = statSync(jobLog).size;
124
+ assert.ok(sizeAtCap <= MAX_BACKGROUND_BASH_LOG_BYTES);
125
+ await sleep(500);
126
+ assert.equal(statSync(jobLog).size, sizeAtCap, 'discarded output continued growing the log');
127
+ await waitFor(() => existsSync(jobExit), 'writer did not finish');
128
+ assert.equal(readFileSync(jobExit, 'utf8').trim(), '0', 'collector changed the writer exit status');
129
+ }
130
+ finally {
131
+ rmSync(root, { recursive: true, force: true });
132
+ }
133
+ });
@@ -1,5 +1,7 @@
1
1
  import { createBashToolDefinition, type BashOperations } from '@earendil-works/pi-coding-agent';
2
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
+ export declare const MAX_BACKGROUND_BASH_LOG_BYTES: number;
4
+ export declare const BACKGROUND_BASH_LOG_TRUNCATION_MARKER = "\n[background bash job output truncated at 10 MiB; remaining output discarded]\n";
3
5
  /** The valve's BashOperations backend. Each tool execution closes over its own
4
6
  * `takePurpose` and its own preview-result path, so concurrent calls cannot
5
7
  * exchange labels or result mirrors. The purpose is consumed even when the
@@ -18,8 +18,9 @@
18
18
  // SIGPIPE, and its exit code is observable by nobody. So every command is
19
19
  // file-backed from birth via a tiny detached supervisor (a `bash -c` process
20
20
  // group leader) that redirects the real command's stdout+stderr to a log
21
- // file and records its exit code to a sentinel file — both readable, and
22
- // tail-able, independent of whether this pi process is even still alive.
21
+ // file through a bounded collector and records its exit code to a sentinel
22
+ // file — both readable and tail-able, independent of whether this pi process
23
+ // is even still alive.
23
24
  //
24
25
  // Deviation from upstream: this backend pins `bash` as the shell (upstream
25
26
  // consults user shell config via non-exported helpers). Acceptable — crtr's
@@ -30,8 +31,9 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmS
30
31
  import { homedir } from 'node:os';
31
32
  import { dirname } from 'node:path';
32
33
  import { performance } from 'node:perf_hooks';
33
- import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
34
+ import { backgroundBashJob, bashJobPaths, cancelBashJobProcessTree, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, readBashNiceness, writeJobDeadlineMs } from '../core/bash-jobs.js';
34
35
  import { bashCommandRefusal } from '../core/bash-guard.js';
36
+ import { capturePidIdentities } from '../core/canvas/pid.js';
35
37
  import { readConfig } from '../core/config.js';
36
38
  import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
37
39
  import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
@@ -43,18 +45,44 @@ const POLL_MS = 200;
43
45
  function resolveValveMs() {
44
46
  return envBashValveMs();
45
47
  }
46
- // The detached supervisor. Spawned with `detached: true` so it becomes its
47
- // own process-group leader (pgid === its pid) — killing `-pgid` reaches the
48
- // real command and anything it forked, even after this pi process exits.
49
- //
50
- // Positional argv (never interpolated into the script — the command text
51
- // lives only in cmd.sh):
48
+ export const MAX_BACKGROUND_BASH_LOG_BYTES = 10 * 1024 * 1024;
49
+ export const BACKGROUND_BASH_LOG_TRUNCATION_MARKER = '\n[background bash job output truncated at 10 MiB; remaining output discarded]\n';
50
+ // This process owns one bounded prefix of the command stream. It keeps
51
+ // draining after the cap so the command never sees a closed pipe/SIGPIPE, and
52
+ // uses synchronous fixed-size stdin chunks plus direct file writes so neither
53
+ // the collector nor the log can grow without bound.
54
+ const LOG_COLLECTOR_SCRIPT = [
55
+ 'const fs = require("node:fs");',
56
+ `const cap = ${MAX_BACKGROUND_BASH_LOG_BYTES};`,
57
+ `const marker = Buffer.from(${JSON.stringify(BACKGROUND_BASH_LOG_TRUNCATION_MARKER)});`,
58
+ 'const prefixCap = cap - marker.length;',
59
+ 'const fd = fs.openSync(process.argv[1], "w");',
60
+ 'let written = 0;',
61
+ 'let truncated = false;',
62
+ 'process.stdin.on("data", (chunk) => {',
63
+ ' if (truncated) return;',
64
+ ' const available = prefixCap - written;',
65
+ ' if (available > 0) {',
66
+ ' const prefix = chunk.subarray(0, available);',
67
+ ' fs.writeSync(fd, prefix);',
68
+ ' written += prefix.length;',
69
+ ' if (prefix.length === chunk.length) return;',
70
+ ' }',
71
+ ' fs.writeSync(fd, marker);',
72
+ ' truncated = true;',
73
+ '});',
74
+ 'process.stdin.on("end", () => fs.closeSync(fd));',
75
+ ].join(' ');
76
+ // The detached supervisor owns the command and its bounded stream collector.
77
+ // Positional argv (never interpolated into the script — the command text lives
78
+ // only in cmd.sh):
52
79
  // $1 cmd.sh $2 job.log $3 job.exit $4 job.run
53
80
  // $5 job.done $6 job.bg $7 jobId $8 nodeId $9 shell path
54
- // ${10} niceness
81
+ // ${10} niceness ${11} node path
55
82
  //
56
83
  // `--` before the paths is a dummy $0 (a standard `bash -c script -- args`
57
84
  // idiom) so the real payload lands at $1 onward.
85
+ //
58
86
  // The elapsed rendering mirrors formatBashElapsed() in core/bash-jobs.ts
59
87
  // (`12s` / `3m 40s` / `1h 04m`) so the completion message reads the same as the
60
88
  // handoff notice; it measures TOTAL runtime, foreground portion included.
@@ -64,8 +92,8 @@ function resolveValveMs() {
64
92
  const SUPERVISOR_SCRIPT = [
65
93
  'SECONDS=0',
66
94
  'n=$(command -v nice)',
67
- '${n:+"$n" -n "${10}"} "${9}" "$1" > "$2" 2>&1',
68
- 'ec=$?',
95
+ '${n:+"$n" -n "${10}"} "${9}" "$1" 2>&1 | "${11}" -e ' + JSON.stringify(LOG_COLLECTOR_SCRIPT) + ' "$2"',
96
+ 'ec=${PIPESTATUS[0]}',
69
97
  'd=$SECONDS',
70
98
  'echo "$ec" > "$3"',
71
99
  'if [ "$d" -lt 60 ]; then',
@@ -107,25 +135,9 @@ function installTerminationHandoff() {
107
135
  if (process.platform !== 'win32')
108
136
  process.prependListener('SIGHUP', backgroundLiveJobsForTermination);
109
137
  }
110
- function killGroup(pgid) {
111
- try {
112
- process.kill(-pgid, 'SIGTERM');
113
- }
114
- catch {
115
- /* already gone */
116
- }
117
- const killer = setTimeout(() => {
118
- try {
119
- process.kill(-pgid, 'SIGKILL');
120
- }
121
- catch {
122
- /* already gone */
123
- }
124
- }, 2000);
125
- killer.unref?.();
126
- }
138
+ const TAIL_CHUNK_BYTES = 64 * 1024;
127
139
  /** Read bytes appended to `path` since `offset`, feed them to onData, return
128
- * the new offset. Best-effort — a not-yet-created log file is not an error. */
140
+ * the new offset. Best-effort — a not-yet-created log file is not an error. */
129
141
  function tailOnce(path, offset, onData) {
130
142
  let stat;
131
143
  try {
@@ -138,10 +150,16 @@ function tailOnce(path, offset, onData) {
138
150
  return offset;
139
151
  const fd = openSync(path, 'r');
140
152
  try {
141
- const buf = Buffer.alloc(stat.size - offset);
142
- readSync(fd, buf, 0, buf.length, offset);
143
- onData(buf);
144
- return stat.size;
153
+ while (offset < stat.size) {
154
+ const length = Math.min(TAIL_CHUNK_BYTES, stat.size - offset);
155
+ const buf = Buffer.allocUnsafe(length);
156
+ const bytesRead = readSync(fd, buf, 0, length, offset);
157
+ if (bytesRead === 0)
158
+ break;
159
+ onData(buf.subarray(0, bytesRead));
160
+ offset += bytesRead;
161
+ }
162
+ return offset;
145
163
  }
146
164
  finally {
147
165
  closeSync(fd);
@@ -211,6 +229,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
211
229
  let offset = 0;
212
230
  let settled = false;
213
231
  let pgid;
232
+ let pgidIdentity;
214
233
  const startedAt = performance.now();
215
234
  const valveMs = resolveValveMs();
216
235
  let interval;
@@ -245,7 +264,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
245
264
  const notice = `\n[backgrounded as job ${paths.jobId} after ${elapsed} in the foreground${auto ? ' (auto)' : ''}. ` +
246
265
  deadlineNotice +
247
266
  `It will interrupt you with a message when it exits. ` +
248
- `Watch: tail -f ${paths.jobLog} ; stop: kill -- -${pgid}]`;
267
+ `Watch: tail -f ${paths.jobLog} ; stop: crtr node bash kill ${paths.jobId}]`;
249
268
  onData(Buffer.from(notice));
250
269
  finish(() => resolve({ exitCode: 0 }));
251
270
  // Backgrounded — job dir persists (inspectable); no cleanup here.
@@ -279,8 +298,8 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
279
298
  // finish the job.run → job.done transition before cleanup.
280
299
  if (existsSync(paths.jobExit))
281
300
  return;
282
- if (pgid)
283
- killGroup(pgid);
301
+ if (pgid !== undefined && pgidIdentity !== undefined)
302
+ void cancelBashJobProcessTree(pgid, pgidIdentity);
284
303
  finish(() => reject(new Error('aborted')));
285
304
  cleanupJobDir(paths.dir);
286
305
  }
@@ -302,7 +321,7 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
302
321
  childEnv = { ...childEnv, [PREVIEW_RESULT_PATH_ENV]: previewPath };
303
322
  }
304
323
  const shellPath = resolveAdmittedHostCommand('bash', childEnv, execution.cwd);
305
- const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath, String(readBashNiceness(contextDir))], {
324
+ const child = spawn(shellPath, ['-c', SUPERVISOR_SCRIPT, '--', paths.cmdSh, paths.jobLog, paths.jobExit, paths.jobRun, paths.jobDone, paths.jobBg, paths.jobId, nodeId, shellPath, String(readBashNiceness(contextDir)), process.execPath], {
306
325
  cwd: execution.cwd,
307
326
  detached: true,
308
327
  stdio: 'ignore',
@@ -313,11 +332,22 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
313
332
  cleanupJobDir(paths.dir);
314
333
  });
315
334
  pgid = child.pid;
316
- // The detached supervisor IS the group leader, so its pid is the pgid.
317
- // Persisting it is what lets a human stop the job from the Inspector
318
- // later; the agent's own handoff notice quotes the same number.
335
+ // The detached supervisor starts this process tree. Its launch-time
336
+ // identity lets cancellation reject a stale PID that now belongs to an
337
+ // unrelated process; it is not a containment guarantee.
319
338
  if (pgid !== undefined) {
339
+ pgidIdentity = capturePidIdentities([pgid])?.get(pgid);
340
+ if (pgidIdentity === undefined) {
341
+ try {
342
+ process.kill(-pgid, 'SIGKILL');
343
+ }
344
+ catch { /* the just-started supervisor already exited */ }
345
+ finish(() => reject(new Error(`bash job ${paths.jobId} started without a process identity`)));
346
+ cleanupJobDir(paths.dir);
347
+ return;
348
+ }
320
349
  writeFileSync(paths.jobPgid, String(pgid));
350
+ writeFileSync(paths.jobPgidIdentity, pgidIdentity);
321
351
  liveForegroundJobs.add(paths);
322
352
  }
323
353
  child.unref();
@@ -338,8 +368,8 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
338
368
  const elapsedMs = performance.now() - startedAt;
339
369
  if (timeout !== undefined && timeout > 0) {
340
370
  if (elapsedMs >= timeout * 1000) {
341
- if (pgid)
342
- killGroup(pgid);
371
+ if (pgid !== undefined && pgidIdentity !== undefined)
372
+ void cancelBashJobProcessTree(pgid, pgidIdentity);
343
373
  finish(() => reject(new Error(`timeout:${timeout}`)));
344
374
  cleanupJobDir(paths.dir);
345
375
  }
@@ -344,8 +344,6 @@ class CanvasInboxWatcher {
344
344
  * reports would render as bare refs instead of inlined bodies. One refresh
345
345
  * per unknown node id keeps a genuinely missing node from stalling delivery. */
346
346
  refreshForUnknownReportRefs() {
347
- if (this.reportNodesRefreshing)
348
- return true;
349
347
  const unknown = [];
350
348
  for (const entry of this.buffer) {
351
349
  const refNodeId = reportRefNodeId(entry.ref);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.303",
3
+ "version": "0.3.305",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.303",
3
+ "version": "0.3.305",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.303",
9
+ "version": "0.3.305",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.303",
5209
+ "version": "0.3.305",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.303",
5214
+ "version": "0.3.305",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.303",
5219
+ "version": "0.3.305",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"