@north-light/crouter 0.3.303 → 0.3.304

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 (73) 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/clients/attach/viewer.js +532 -532
  11. package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
  12. package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
  13. package/dist/commands/api-client.d.ts +3 -3
  14. package/dist/commands/api-client.js +3 -3
  15. package/dist/commands/memory/read.js +2 -1
  16. package/dist/commands/node/bash.js +6 -6
  17. package/dist/commands/node/message.js +3 -3
  18. package/dist/commands/sys/daemon.js +11 -13
  19. package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
  20. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  21. package/dist/core/__tests__/helpers/harness.d.ts +2 -0
  22. package/dist/core/__tests__/helpers/harness.js +9 -0
  23. package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
  24. package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
  25. package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
  26. package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
  27. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
  28. package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
  29. package/dist/core/bash-jobs.d.ts +20 -8
  30. package/dist/core/bash-jobs.js +40 -21
  31. package/dist/core/canvas/types.d.ts +4 -2
  32. package/dist/core/command.js +1 -1
  33. package/dist/core/fault-classifier.d.ts +1 -1
  34. package/dist/core/runtime/broker/event-projection.d.ts +0 -3
  35. package/dist/core/runtime/broker/event-projection.js +2 -15
  36. package/dist/core/runtime/broker/fault-retry.js +8 -0
  37. package/dist/core/runtime/broker-persona-guidance.js +12 -0
  38. package/dist/core/runtime/broker.js +0 -5
  39. package/dist/core/runtime/fault.js +1 -1
  40. package/dist/core/runtime/host.js +10 -1
  41. package/dist/core/runtime/spawn.js +5 -6
  42. package/dist/core/worktree-close.d.ts +4 -0
  43. package/dist/core/worktree-close.js +225 -0
  44. package/dist/core/worktree-containment.d.ts +14 -0
  45. package/dist/core/worktree-containment.js +48 -0
  46. package/dist/core/worktree-mutation-async.d.ts +13 -0
  47. package/dist/core/worktree-mutation-async.js +281 -0
  48. package/dist/core/worktree-sweep.js +17 -79
  49. package/dist/core/worktree.d.ts +1 -0
  50. package/dist/core/worktree.js +1 -1
  51. package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
  52. package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
  53. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  54. package/dist/daemon/api/handlers/messages.js +7 -5
  55. package/dist/daemon/api/handlers/reports.js +3 -2
  56. package/dist/daemon/api/handlers/worktree.js +7 -6
  57. package/dist/daemon/fleet.d.ts +1 -1
  58. package/dist/daemon/fleet.js +30 -12
  59. package/dist/daemon/manage.d.ts +8 -5
  60. package/dist/daemon/manage.js +63 -40
  61. package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
  62. package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
  63. package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
  64. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
  65. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
  66. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
  67. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
  68. package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
  69. package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
  70. package/dist/pi-extensions/canvas-bash-valve.js +73 -43
  71. package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
  72. package/package.json +1 -1
  73. package/runtime.lock.json +5 -5
@@ -19,7 +19,7 @@ import { clearDaemonStartupBlocked, daemonStartupBlocked } from './startup-block
19
19
  import { exclusiveLockOwnerPid } from '../core/exclusive-lock.js';
20
20
  import { onDiskMigrationLockPath } from '../core/canvas/paths.js';
21
21
  import { envNoDaemonAutostart } from '../shared/env.js';
22
- import { CrtrClient } from '../api/index.js';
22
+ import { CrtrClient, waitForDaemonAvailability } from '../api/index.js';
23
23
  // Daemon env sanitization
24
24
  /** Env keys that must NEVER reach the daemon process. Restarting crtrd is
25
25
  * overwhelmingly done from inside an agent node's own bash tool (`crtr sys
@@ -208,16 +208,20 @@ const DAEMON_VERIFY_POLL_MS = 10;
208
208
  /** A status/start readiness probe must not hang behind a daemon that accepts a
209
209
  * Unix connection but never answers it. */
210
210
  const DAEMON_HEALTH_PROBE_TIMEOUT_MS = 1_000;
211
- /** True only when the mandatory Unix API socket answers `/healthz`. This is
212
- * deliberately separate from `isDaemonRunning()`: a pidfile remains useful for
213
- * identifying the process an operator must stop, but it is not readiness. */
211
+ /** Make one `/healthz` observation without client-side recovery. The caller
212
+ * owns the availability window, so this probe cannot extend it. */
213
+ export async function probeDaemonServing(timeoutMs = DAEMON_HEALTH_PROBE_TIMEOUT_MS) {
214
+ const probeTimeoutMs = Math.min(timeoutMs, DAEMON_HEALTH_PROBE_TIMEOUT_MS);
215
+ const health = await CrtrClient.forLocalSocket({ autostart: false, timeoutMs: probeTimeoutMs }).probeHealthz(probeTimeoutMs);
216
+ if (!health.ok)
217
+ throw new Error('crtrd health check reported not ready');
218
+ }
219
+ /** True only when one mandatory Unix API socket probe answers `/healthz`.
220
+ * A pidfile identifies an owner; it is never readiness. */
214
221
  export async function isDaemonServing() {
215
222
  try {
216
- const health = await CrtrClient.forLocalSocket({
217
- autostart: false,
218
- timeoutMs: DAEMON_HEALTH_PROBE_TIMEOUT_MS,
219
- }).healthz();
220
- return health.ok;
223
+ await probeDaemonServing();
224
+ return true;
221
225
  }
222
226
  catch {
223
227
  return false;
@@ -255,32 +259,47 @@ export async function verifyDaemonStartup(pid, timeoutMs = DAEMON_STARTUP_WINDOW
255
259
  const read = deps.readPidfile ?? readPidfile;
256
260
  const alive = deps.isPidAlive ?? isPidAlive;
257
261
  const exited = deps.childExited;
258
- const serving = deps.isDaemonServing ?? isDaemonServing;
262
+ const serving = deps.isDaemonServing;
263
+ const probeServing = deps.probeDaemonServing ?? (async (remainingMs) => {
264
+ if (serving !== undefined) {
265
+ if (await serving())
266
+ return;
267
+ throw new Error('crtrd health check reported not ready');
268
+ }
269
+ await probeDaemonServing(remainingMs);
270
+ });
259
271
  const sleep = deps.sleepMs ?? sleepMs;
260
272
  const now = deps.now ?? Date.now;
261
- const deadline = now() + timeoutMs;
262
- while (now() <= deadline) {
263
- const owner = read();
264
- if (owner !== null && alive(owner) && await serving())
265
- return owner === pid ? null : owner;
266
- const exitState = exited?.();
267
- if (exitState !== null && exitState !== undefined) {
268
- if (exitState.signal !== null) {
269
- throw new Error(`daemon ${pid} exited before becoming ready (by ${exitState.signal})`);
270
- }
271
- if (exitState.code === DAEMON_EXIT_STARTUP_BLOCKED) {
272
- throw new DaemonStartupBlockedError(pid);
273
- }
274
- if (exitState.code !== 0) {
275
- throw new Error(`daemon ${pid} exited before becoming ready (with exit code ${exitState.code ?? '?'})`);
273
+ let owner = null;
274
+ let terminalError = null;
275
+ await waitForDaemonAvailability({
276
+ windowMs: timeoutMs,
277
+ pollIntervalMs: DAEMON_VERIFY_POLL_MS,
278
+ now,
279
+ sleep,
280
+ retry: (error) => error !== terminalError,
281
+ probe: async (remainingMs) => {
282
+ const candidate = read();
283
+ if (candidate === null || !alive(candidate)) {
284
+ const exitState = exited?.();
285
+ if (exitState !== null && exitState !== undefined) {
286
+ if (exitState.signal !== null)
287
+ terminalError = new Error(`daemon ${pid} exited before becoming ready (by ${exitState.signal})`);
288
+ else if (exitState.code === DAEMON_EXIT_STARTUP_BLOCKED)
289
+ terminalError = new DaemonStartupBlockedError(pid);
290
+ else if (exitState.code !== 0)
291
+ terminalError = new Error(`daemon ${pid} exited before becoming ready (with exit code ${exitState.code ?? '?'})`);
292
+ // A clean exit can have lost the ownership claim, so keep observing.
293
+ if (terminalError !== null)
294
+ throw terminalError;
295
+ }
296
+ throw new Error(`daemon ${pid} has no live pidfile owner`);
276
297
  }
277
- // Clean code-0 exit: a possible ownership-claim loser, not readiness.
278
- // Keep polling the same deadline for a different live pidfile owner; the
279
- // loop's timeout throw below fires if none appears.
280
- }
281
- await sleep(DAEMON_VERIFY_POLL_MS);
282
- }
283
- throw new Error(`daemon ${pid} did not become ready within ${timeoutMs}ms`);
298
+ await probeServing(remainingMs);
299
+ owner = candidate;
300
+ },
301
+ });
302
+ return owner === pid ? null : owner;
284
303
  }
285
304
  /** Wait until a signaled daemon has exited and relinquished its pidfile. This
286
305
  * makes a following start safe to claim the singleton rather than racing its
@@ -355,15 +374,19 @@ export async function stopDaemonProcess(pid, timeoutMs = DAEMON_SHUTDOWN_WINDOW_
355
374
  export async function spawnDaemon() {
356
375
  const recordedPid = readPidfile();
357
376
  if (recordedPid !== null && isPidAlive(recordedPid)) {
358
- if (await isDaemonServing())
377
+ try {
378
+ await probeDaemonServing();
359
379
  return { started: false, existing_pid: recordedPid };
360
- return {
361
- started: false,
362
- existing_pid: recordedPid,
363
- running: true,
364
- serving: false,
365
- next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
366
- };
380
+ }
381
+ catch (error) {
382
+ return {
383
+ started: false,
384
+ existing_pid: recordedPid,
385
+ running: true,
386
+ serving: false,
387
+ probe_error: error.message,
388
+ };
389
+ }
367
390
  }
368
391
  // Ensure the canvas home directory exists so the daemon can write its pidfile.
369
392
  mkdirSync(crtrHome(), { recursive: true });
@@ -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