@north-light/crouter 0.3.239 → 0.3.240

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.
@@ -4,7 +4,9 @@ import { defineBranch, defineLeaf } from '../../core/command.js';
4
4
  import { InputError } from '../../core/io.js';
5
5
  import { displayMessage } from '../../core/runtime/tmux-chrome.js';
6
6
  import { contextDir } from '../../core/canvas/paths.js';
7
- import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobCommandLine, bashJobPaths, formatBashElapsed, isSafeBashJobId, jobStartedAtMs, readJobPgid, stopBackgroundBashJob, tailJobLog } from '../../core/bash-jobs.js';
7
+ import { existsSync } from 'node:fs';
8
+ import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobCommandLine, bashJobPaths, clearJobDeadline, formatBashElapsed, isSafeBashJobId, jobStartedAtMs, readJobPgid, stopBackgroundBashJob, tailJobLog, writeJobDeadlineMs } from '../../core/bash-jobs.js';
9
+ import { parseDurationMs } from '../../core/wake.js';
8
10
  import { cliClient, getNodeOrNull } from '../api-client.js';
9
11
  import { nodeInPane } from '../surface/node/placement.js';
10
12
  function resolveBashTarget(input, missingMessage) {
@@ -77,7 +79,7 @@ const nodeBashList = defineLeaf({
77
79
  ],
78
80
  output: [
79
81
  { name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
80
- { name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, purpose, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job; purpose is a human-readable label or null.' },
82
+ { name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, purpose, started_at, elapsed_ms, deadline_at, log_path, pgid, log_tail} for every live background job; purpose is a human-readable label or null; deadline_at is the auto-cancel time or null for a job that runs to completion.' },
81
83
  ],
82
84
  outputKind: 'object',
83
85
  effects: ['Reads the node context jobs directory. Changes nothing.'],
@@ -97,6 +99,7 @@ const nodeBashList = defineLeaf({
97
99
  purpose: job.purpose,
98
100
  started_at: new Date(job.startedAtMs).toISOString(),
99
101
  elapsed_ms: now - job.startedAtMs,
102
+ deadline_at: job.deadlineAtMs === null ? null : new Date(job.deadlineAtMs).toISOString(),
100
103
  log_path: job.logPath,
101
104
  pgid: readJobPgid(dir, job.jobId) ?? null,
102
105
  log_tail: tailJobLog(job.logPath, tail),
@@ -107,7 +110,7 @@ const nodeBashList = defineLeaf({
107
110
  const jobs = result['jobs'];
108
111
  if (jobs.length === 0)
109
112
  return `No background bash jobs for ${result['node_id']}.`;
110
- return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${bashJobCommandLine(job.command)}${job.purpose === null ? '' : `\n purpose: ${job.purpose}`}\n log: ${job.log_path}`).join('\n');
113
+ return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${bashJobCommandLine(job.command)}${job.purpose === null ? '' : `\n purpose: ${job.purpose}`}${job.deadline_at === null ? '' : `\n cancels at: ${job.deadline_at}`}\n log: ${job.log_path}`).join('\n');
111
114
  },
112
115
  });
113
116
  const nodeBashKill = defineLeaf({
@@ -171,14 +174,75 @@ const nodeBashKill = defineLeaf({
171
174
  },
172
175
  render: (result) => `Canceled a background bash job for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
173
176
  });
177
+ const nodeBashExtend = defineLeaf({
178
+ name: 'extend',
179
+ description: 'claim an auto-backgrounded bash job so the daemon does not cancel it at its deadline',
180
+ whenToUse: 'a command the valve auto-backgrounded is worth keeping past its cancel deadline — give it more time with --for, or let it run to completion with --keep',
181
+ tier: 'hidden',
182
+ help: {
183
+ name: 'node bash extend',
184
+ summary: 'move or clear the auto-cancel deadline on one of a node’s live background bash jobs',
185
+ params: [
186
+ { kind: 'positional', name: 'job', required: true, constraint: 'Job id, as reported by node bash list or the auto-background notice.' },
187
+ { kind: 'flag', name: 'for', type: 'string', required: false, constraint: 'New window from now before the job is canceled, as a duration (e.g. 90s, 10m, 1h30m). Mutually exclusive with --keep; give exactly one.' },
188
+ { kind: 'flag', name: 'keep', type: 'bool', required: false, constraint: 'Remove the deadline entirely so the job runs to completion. Mutually exclusive with --for; give exactly one.' },
189
+ { kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node owning the job. Defaults to the node in --pane, else the caller (CRTR_NODE_ID).' },
190
+ { kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane showing the target node, when --node is omitted. Defaults to $TMUX_PANE.' },
191
+ ],
192
+ output: [
193
+ { name: 'node_id', type: 'string', required: true, constraint: 'The owning node.' },
194
+ { name: 'deadline_at', type: 'string', required: false, constraint: 'The job’s new auto-cancel time (ISO), or absent when --keep cleared the deadline so the job runs to completion.' },
195
+ ],
196
+ outputKind: 'object',
197
+ effects: ['Rewrites or removes the job’s deadline file. The daemon’s deadline lane reads it on its next scan. Signals nothing.'],
198
+ },
199
+ run: async (input) => {
200
+ const { id } = resolveBashTarget(input, 'no node found for the bash job');
201
+ const node = await getNodeOrNull(id);
202
+ if (node === null) {
203
+ throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
204
+ }
205
+ const jobId = input['job'];
206
+ if (!isSafeBashJobId(jobId)) {
207
+ throw new InputError({ error: 'invalid_job_id', message: `invalid background job id: ${jobId}`, next: 'Use a job id reported by `crtr node bash list`.' });
208
+ }
209
+ const forDuration = input['for'];
210
+ const keep = input['keep'] === true;
211
+ if (keep === (forDuration !== undefined && forDuration !== '')) {
212
+ throw new InputError({ error: 'invalid_args', message: 'give exactly one of --for <duration> or --keep', next: 'Extend by a window with --for 10m, or drop the deadline with --keep.' });
213
+ }
214
+ const dir = contextDir(node.node_id);
215
+ const paths = bashJobPaths(dir, jobId);
216
+ if (!existsSync(paths.dir)) {
217
+ throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
218
+ }
219
+ if (!existsSync(paths.jobBg) || existsSync(paths.jobExit)) {
220
+ throw new InputError({ error: 'not_running', message: `background job ${jobId} for ${node.node_id} is no longer running`, next: `It finished or was stopped, so there is no deadline to change. Log: ${paths.jobLog}` });
221
+ }
222
+ if (keep) {
223
+ clearJobDeadline(paths);
224
+ return { node_id: node.node_id };
225
+ }
226
+ const durationMs = parseDurationMs(forDuration);
227
+ if (durationMs === null || durationMs <= 0) {
228
+ throw new InputError({ error: 'invalid_duration', message: `not a duration: ${forDuration}`, next: 'Use a value like 90s, 10m, or 1h30m.' });
229
+ }
230
+ const deadlineMs = Date.now() + durationMs;
231
+ writeJobDeadlineMs(paths, deadlineMs);
232
+ return { node_id: node.node_id, deadline_at: new Date(deadlineMs).toISOString() };
233
+ },
234
+ render: (result) => result['deadline_at'] === undefined
235
+ ? `Cleared the deadline — the background job will run to completion for ${result['node_id']}.`
236
+ : `Background job for ${result['node_id']} will be canceled at ${result['deadline_at']} unless extended again.`,
237
+ });
174
238
  export const nodeBash = defineBranch({
175
239
  name: 'bash',
176
240
  description: 'control file-backed bash jobs owned by a node',
177
- whenToUse: 'a running bash command must be handed off, listed, or stopped — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
241
+ whenToUse: 'a running bash command must be handed off, listed, stopped, or its auto-cancel deadline changed — the tmux prefix menu backgrounds, the Inspector’s jobs section lists and cancels',
178
242
  tier: 'hidden',
179
243
  help: {
180
244
  name: 'node bash',
181
245
  summary: 'control file-backed bash jobs owned by a node',
182
246
  },
183
- children: [nodeBashBackground, nodeBashList, nodeBashKill],
247
+ children: [nodeBashBackground, nodeBashList, nodeBashKill, nodeBashExtend],
184
248
  });
@@ -10,6 +10,7 @@ import { ApiError } from '../../../api/index.js';
10
10
  import { cliClient } from '../../api-client.js';
11
11
  import { SURFACE_EVENTS } from '../../../core/substrate/schema.js';
12
12
  import { ambientMemoryTarget } from '../../../core/memory-resolver.js';
13
+ import { getDefaultProfileId } from '../../../core/profiles/default-binding.js';
13
14
  import { resolveProfileOperand } from '../../../core/profiles/manifest.js';
14
15
  export const adminLeaf = defineLeaf({
15
16
  name: 'admin',
@@ -49,15 +50,16 @@ export const adminLeaf = defineLeaf({
49
50
  const ambient = ambientMemoryTarget();
50
51
  const profileId = input['profile']?.trim();
51
52
  const cwd = input['cwd']?.trim();
53
+ const resolvedCwd = cwd === undefined || cwd === '' ? (node?.cwd ?? ambient.cwd) : resolvePath(cwd);
52
54
  const seed = {
53
55
  kind: input['kind']?.trim() || node?.kind || 'general',
54
56
  mode: input['mode'] ?? node?.mode ?? 'base',
55
57
  lifecycle: node?.lifecycle ?? 'terminal',
56
58
  hasManager: node?.parent != null,
57
59
  profileId: profileId === undefined || profileId === ''
58
- ? (node?.profile_id ?? ambient.profileId)
60
+ ? (node?.profile_id ?? ambient.profileId ?? getDefaultProfileId(resolvedCwd))
59
61
  : resolveProfileOperand(profileId).profileId,
60
- cwd: cwd === undefined || cwd === '' ? (node?.cwd ?? ambient.cwd) : resolvePath(cwd),
62
+ cwd: resolvedCwd,
61
63
  nodeId: node?.node_id ?? null,
62
64
  depth: node === null ? 0 : await spineDepth(node),
63
65
  event: (input['event'] ?? 'boot'),
@@ -23,6 +23,7 @@ import { clearSessionCache } from '../../../core/substrate/session-cache.js';
23
23
  import { rungAtLeast, SURFACE_EVENTS, } from '../../../core/substrate/schema.js';
24
24
  import { scopeForCwd, profileNameFor } from '../../../core/substrate/subject-fields.js';
25
25
  import { resetScopeCache } from '../../../core/scope.js';
26
+ import { getDefaultProfileId } from '../../../core/profiles/default-binding.js';
26
27
  export const resolveLeaf = defineLeaf({
27
28
  name: 'resolve',
28
29
  description: 'resolve what a configuration snapshot loads for one surface event',
@@ -95,7 +96,10 @@ export async function buildSnapshot(input) {
95
96
  ?? derived?.lifecycle
96
97
  ?? (mode === 'orchestrator' ? 'resident' : 'terminal');
97
98
  const cwd = resolveCwd(optionalString(input['cwd']) ?? derived?.cwd ?? process.cwd());
98
- const profileId = optionalString(input['profile']) ?? node?.profile_id ?? ambientMemoryTarget().profileId;
99
+ const profileId = optionalString(input['profile'])
100
+ ?? node?.profile_id
101
+ ?? ambientMemoryTarget().profileId
102
+ ?? getDefaultProfileId(cwd);
99
103
  const hasManagerFlag = optionalString(input['hasManager']);
100
104
  return {
101
105
  subject: {
@@ -14,6 +14,11 @@ export interface BashJobPaths {
14
14
  jobPgid: string;
15
15
  /** Human-readable label supplied with the bash call, when any. */
16
16
  jobPurpose: string;
17
+ /** Epoch-ms cancel deadline for an AUTO-backgrounded job. Present only when
18
+ * the valve backgrounded the job on its own; its presence marks the job as
19
+ * subject to the daemon's claim-or-cancel deadline. Absent for a deliberate
20
+ * handoff (tmux menu, broker termination), which runs to completion. */
21
+ jobDeadline: string;
17
22
  }
18
23
  /** A purpose is display text, never shell input. Keep the file-backed control
19
24
  * plane to one bounded, visible line so old or hand-written job directories
@@ -31,6 +36,17 @@ export declare function readJobPgid(contextDir: string, jobId: string): number |
31
36
  /** The optional human-readable job label. Jobs created before labels existed
32
37
  * have no file and deliberately project null. */
33
38
  export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
39
+ /** The auto-background cancel deadline (epoch ms), or undefined for a job with
40
+ * no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
41
+ * malformed file reads as no deadline rather than an instant expiry. */
42
+ export declare function readJobDeadlineMs(contextDir: string, jobId: string): number | undefined;
43
+ /** Arm or move an auto-backgrounded job's cancel deadline to `deadlineMs`
44
+ * (epoch ms). Written by the valve at auto-handoff and rewritten when the
45
+ * owning node claims more time. */
46
+ export declare function writeJobDeadlineMs(paths: BashJobPaths, deadlineMs: number): void;
47
+ /** Drop a job's cancel deadline so it runs to completion — the node has claimed
48
+ * it. Best-effort: a job with no deadline file is already in this state. */
49
+ export declare function clearJobDeadline(paths: BashJobPaths): void;
34
50
  /** Whether a persisted job supervisor process group is still present. EPERM
35
51
  * means the group exists but cannot be signaled by this process; it is still
36
52
  * live for the status surface. */
@@ -88,12 +104,19 @@ export interface BashJobStatus {
88
104
  purpose: string | null;
89
105
  startedAtMs: number;
90
106
  logPath: string;
107
+ /** Auto-background cancel deadline (epoch ms), or null for a job that runs to
108
+ * completion (a deliberate handoff, or one the node has claimed). */
109
+ deadlineAtMs: number | null;
91
110
  }
92
111
  /** Every live backgrounded bash job for this node — job.bg present, job.exit
93
112
  * absent — whether handed off by the menu, the valve deadline, or broker
94
113
  * termination. Read-only; never mutates. Skips malformed/partially-written
95
114
  * directories. Returns oldest-first by startedAtMs. */
96
115
  export declare function activeBackgroundBashJobs(contextDir: string): BashJobStatus[];
116
+ /** Every live background job for this node whose auto-cancel deadline has
117
+ * passed at `now`. The daemon's deadline lane stops exactly these. A job with
118
+ * no deadline (deliberate handoff, or one the node claimed) never appears. */
119
+ export declare function expiredAutoBackgroundBashJobs(contextDir: string, now: number): BashJobStatus[];
97
120
  export type BackgroundBashJobResult = 'backgrounded' | 'already-backgrounded' | 'finished' | 'missing';
98
121
  /** Atomically hand one foreground job to its detached supervisor. The rename
99
122
  * is the ownership decision: exactly one caller can move job.run to job.bg,
@@ -3,7 +3,7 @@
3
3
  // job.bg, while foreground completion renames it to job.done. The detached
4
4
  // supervisor can therefore outlive pi and still decide whether to report exit.
5
5
  import { randomBytes } from 'node:crypto';
6
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
6
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  const MAX_BASH_JOB_PURPOSE_BYTES = 280;
9
9
  /** A purpose is display text, never shell input. Keep the file-backed control
@@ -42,6 +42,7 @@ export function bashJobPaths(contextDir, jobId) {
42
42
  jobDone: join(dir, 'job.done'),
43
43
  jobPgid: join(dir, 'job.pgid'),
44
44
  jobPurpose: join(dir, 'purpose'),
45
+ jobDeadline: join(dir, 'job.deadline'),
45
46
  };
46
47
  }
47
48
  /** The job's supervisor process group, or undefined for a job started before
@@ -65,6 +66,34 @@ export function readJobPurpose(contextDir, jobId) {
65
66
  return null;
66
67
  }
67
68
  }
69
+ /** The auto-background cancel deadline (epoch ms), or undefined for a job with
70
+ * no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
71
+ * malformed file reads as no deadline rather than an instant expiry. */
72
+ export function readJobDeadlineMs(contextDir, jobId) {
73
+ try {
74
+ const n = Number(readFileSync(bashJobPaths(contextDir, jobId).jobDeadline, 'utf8').trim());
75
+ return Number.isFinite(n) ? n : undefined;
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
81
+ /** Arm or move an auto-backgrounded job's cancel deadline to `deadlineMs`
82
+ * (epoch ms). Written by the valve at auto-handoff and rewritten when the
83
+ * owning node claims more time. */
84
+ export function writeJobDeadlineMs(paths, deadlineMs) {
85
+ writeFileSync(paths.jobDeadline, `${Math.trunc(deadlineMs)}\n`);
86
+ }
87
+ /** Drop a job's cancel deadline so it runs to completion — the node has claimed
88
+ * it. Best-effort: a job with no deadline file is already in this state. */
89
+ export function clearJobDeadline(paths) {
90
+ try {
91
+ rmSync(paths.jobDeadline, { force: true });
92
+ }
93
+ catch {
94
+ /* best-effort — worst case the daemon cancels it at the old deadline */
95
+ }
96
+ }
68
97
  /** Whether a persisted job supervisor process group is still present. EPERM
69
98
  * means the group exists but cannot be signaled by this process; it is still
70
99
  * live for the status surface. */
@@ -222,10 +251,23 @@ export function activeBackgroundBashJobs(contextDir) {
222
251
  catch {
223
252
  continue;
224
253
  }
225
- jobs.push({ jobId, command, purpose: readJobPurpose(contextDir, jobId), startedAtMs, logPath: paths.jobLog });
254
+ jobs.push({
255
+ jobId,
256
+ command,
257
+ purpose: readJobPurpose(contextDir, jobId),
258
+ startedAtMs,
259
+ logPath: paths.jobLog,
260
+ deadlineAtMs: readJobDeadlineMs(contextDir, jobId) ?? null,
261
+ });
226
262
  }
227
263
  return jobs.sort((a, b) => a.startedAtMs - b.startedAtMs);
228
264
  }
265
+ /** Every live background job for this node whose auto-cancel deadline has
266
+ * passed at `now`. The daemon's deadline lane stops exactly these. A job with
267
+ * no deadline (deliberate handoff, or one the node claimed) never appears. */
268
+ export function expiredAutoBackgroundBashJobs(contextDir, now) {
269
+ return activeBackgroundBashJobs(contextDir).filter((job) => job.deadlineAtMs !== null && now >= job.deadlineAtMs);
270
+ }
229
271
  /** Atomically hand one foreground job to its detached supervisor. The rename
230
272
  * is the ownership decision: exactly one caller can move job.run to job.bg,
231
273
  * while the supervisor races to move the same source to job.done. */
@@ -35,6 +35,9 @@ export type Recur = {
35
35
  };
36
36
  /** Cadence floor: reject any interval / cron min-spacing below this (AC-N4). */
37
37
  export declare const CADENCE_FLOOR_MS = 60000;
38
+ /** Total ms for a relative-duration string (`90s`, `10m`, `1h30m`), or null if
39
+ * it is not one. */
40
+ export declare function parseDurationMs(s: string): number | null;
38
41
  /**
39
42
  * Resolve a `<when>` to a single UTC `fireAt`:
40
43
  * - relative duration (`90s`, `1h30m`) -> now + Σ
package/dist/core/wake.js CHANGED
@@ -43,8 +43,9 @@ function whenError(code, message, received) {
43
43
  function cadenceError(code, message, received) {
44
44
  return { error: { code, message, received } };
45
45
  }
46
- /** Total ms for a relative-duration string, or null if it is not one. */
47
- function parseDurationMs(s) {
46
+ /** Total ms for a relative-duration string (`90s`, `10m`, `1h30m`), or null if
47
+ * it is not one. */
48
+ export function parseDurationMs(s) {
48
49
  if (!DURATION_RE.test(s))
49
50
  return null;
50
51
  let total = 0;
@@ -62,6 +62,7 @@ import { CronLaneReconciler } from './reconcilers/cron-lane.js';
62
62
  import { HumanDeliveryLaneReconciler } from './reconcilers/human-delivery-lane.js';
63
63
  import { PendingReviewSubmitReconciler } from './reconcilers/pending-review-submit.js';
64
64
  import { StorageMaintenanceReconciler } from './reconcilers/storage-maintenance.js';
65
+ import { BashDeadlineReconciler } from './reconcilers/bash-deadline.js';
65
66
  import { captureLivenessSnapshot, capturePidCommand, captureTeardownSnapshot, isPidAlive, killProcessTreePids, } from '../core/canvas/pid.js';
66
67
  import { bindFleet, boundFleet } from '../core/runtime/fleet.js';
67
68
  import { bindDaemonControl, clearDaemonControl } from './control.js';
@@ -276,6 +277,7 @@ const cronLane = new CronLaneReconciler();
276
277
  const humanDeliveryLane = new HumanDeliveryLaneReconciler();
277
278
  const pendingReviewSubmit = new PendingReviewSubmitReconciler();
278
279
  const storageMaintenance = new StorageMaintenanceReconciler();
280
+ const bashDeadline = new BashDeadlineReconciler();
279
281
  /** Supervision tick over the daemon's current live set. */
280
282
  export async function superviseTick(now = Date.now(), lifecycle = directTickLifecycle) {
281
283
  const fleet = boundFleet();
@@ -304,6 +306,7 @@ export async function superviseTick(now = Date.now(), lifecycle = directTickLife
304
306
  humanDeliveryLane.run(now, { lifecycle });
305
307
  pendingReviewSubmit.run(now, { lifecycle });
306
308
  storageMaintenance.run(now, {});
309
+ await bashDeadline.run(now, { rows });
307
310
  // Last: the saturation figure is only true once the freeze lane has spent
308
311
  // this tick's free slots.
309
312
  brokerSupervision.warnIfSaturated({ fleet, lifecycle, thresholds, frozen });
@@ -0,0 +1,17 @@
1
+ import type { NodeRow } from '../../core/canvas/index.js';
2
+ export interface BashDeadlineContext {
3
+ rows: NodeRow[];
4
+ }
5
+ /** Cancel AUTO-backgrounded bash jobs whose claim-or-cancel deadline has passed.
6
+ *
7
+ * The valve backgrounds a command it could not finish waiting for and arms a
8
+ * deadline the owning node must extend (`crtr node bash extend`) to keep the job
9
+ * alive. This lane is the enforcer: it survives a broker restart and fires while
10
+ * the node is idle, so a runaway command cannot outlive its window just because
11
+ * the agent moved on. A deliberately backgrounded job carries no deadline and is
12
+ * never touched here. */
13
+ export declare class BashDeadlineReconciler {
14
+ private lastScanAt;
15
+ run(now: number, ctx: BashDeadlineContext): Promise<void>;
16
+ private cancel;
17
+ }
@@ -0,0 +1,73 @@
1
+ import { bashJobPaths, expiredAutoBackgroundBashJobs, formatBashElapsed, stopBackgroundBashJob, } from '../../core/bash-jobs.js';
2
+ import { isSafeNodeId } from '../../core/canvas/paths.js';
3
+ import { contextDir } from '../../core/canvas/paths.js';
4
+ import { appendInbox } from '../../core/feed/inbox.js';
5
+ import { emitEvent } from '../../core/events/emit.js';
6
+ import { operationIdContext } from '../../core/events/operation-id.js';
7
+ // How often the deadline scan runs. The valve arms a 60s default deadline, so a
8
+ // few seconds of enforcement latency is immaterial; scanning every tick would
9
+ // readdir every node's jobs dir needlessly.
10
+ const SCAN_INTERVAL_MS = 5 * 1000;
11
+ /** Cancel AUTO-backgrounded bash jobs whose claim-or-cancel deadline has passed.
12
+ *
13
+ * The valve backgrounds a command it could not finish waiting for and arms a
14
+ * deadline the owning node must extend (`crtr node bash extend`) to keep the job
15
+ * alive. This lane is the enforcer: it survives a broker restart and fires while
16
+ * the node is idle, so a runaway command cannot outlive its window just because
17
+ * the agent moved on. A deliberately backgrounded job carries no deadline and is
18
+ * never touched here. */
19
+ export class BashDeadlineReconciler {
20
+ lastScanAt = Number.NEGATIVE_INFINITY;
21
+ async run(now, ctx) {
22
+ if (now - this.lastScanAt < SCAN_INTERVAL_MS)
23
+ return;
24
+ this.lastScanAt = now;
25
+ // A dead node's supervisor was already reaped with its broker; its jobs
26
+ // cannot still be live, and its inbox notice would reach no one.
27
+ for (const row of ctx.rows) {
28
+ if (row.status === 'dead')
29
+ continue;
30
+ const dir = contextDir(row.node_id);
31
+ let expired;
32
+ try {
33
+ expired = expiredAutoBackgroundBashJobs(dir, now);
34
+ }
35
+ catch (err) {
36
+ operationIdContext.fresh(() => {
37
+ emitEvent({ level: 'error', event: 'bash_deadline.scan_failed', ...(isSafeNodeId(row.node_id) ? { node_id: row.node_id } : {}), error: err });
38
+ });
39
+ continue;
40
+ }
41
+ for (const job of expired) {
42
+ await this.cancel(row.node_id, dir, job.jobId, now - job.startedAtMs);
43
+ }
44
+ }
45
+ }
46
+ async cancel(nodeId, dir, jobId, elapsedMs) {
47
+ const stopped = await stopBackgroundBashJob(dir, jobId);
48
+ // Only a job we actually signaled earns a notice; a retired/missing job
49
+ // finished or was stopped by another path and already reported itself.
50
+ if (stopped.kind !== 'stopped')
51
+ return;
52
+ const paths = bashJobPaths(dir, jobId);
53
+ operationIdContext.fresh(() => {
54
+ try {
55
+ emitEvent({ level: 'info', event: 'bash_deadline.canceled', ...(isSafeNodeId(nodeId) ? { node_id: nodeId } : {}), fields: { job_id: jobId } });
56
+ appendInbox(nodeId, {
57
+ from: null,
58
+ tier: 'urgent',
59
+ kind: 'urgent',
60
+ label: 'background bash job canceled at its deadline',
61
+ data: {
62
+ body: `Background bash job ${jobId} was canceled after ${formatBashElapsed(elapsedMs)} because its background deadline passed and you did not claim it. ` +
63
+ `Next time, give a long command more room up front (a larger inline timeout), or claim the job with \`crtr node bash extend ${jobId} --for <duration>\` before the deadline. ` +
64
+ `Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
65
+ },
66
+ });
67
+ }
68
+ catch (err) {
69
+ emitEvent({ level: 'error', event: 'bash_deadline.notice_failed', ...(isSafeNodeId(nodeId) ? { node_id: nodeId } : {}), error: err, fields: { job_id: jobId } });
70
+ }
71
+ });
72
+ }
73
+ }
@@ -24,13 +24,13 @@
24
24
  // Deviation from upstream: this backend pins `bash` as the shell (upstream
25
25
  // consults user shell config via non-exported helpers). Acceptable — crtr's
26
26
  // agents are bash-contract anyway.
27
- import { envBashValveMs, envNodeId } from '../shared/env.js';
27
+ import { envBashAutoBgDeadlineMs, envBashValveMs, envNodeId } from '../shared/env.js';
28
28
  import { spawn } from 'node:child_process';
29
29
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, } from 'node:fs';
30
30
  import { homedir } from 'node:os';
31
31
  import { dirname } from 'node:path';
32
32
  import { performance } from 'node:perf_hooks';
33
- import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose } from '../core/bash-jobs.js';
33
+ import { backgroundBashJob, bashJobPaths, formatBashElapsed, newBashJobId, normalizeBashJobPurpose, writeJobDeadlineMs } from '../core/bash-jobs.js';
34
34
  import { readConfig } from '../core/config.js';
35
35
  import { PREVIEW_RESULT_PATH_ENV, previewResultPath } from '../core/preview-result-path.js';
36
36
  import { resolveAdmittedHostCommand } from '../core/runtime/spawn-env.js';
@@ -257,7 +257,20 @@ export function createValveOperations(nodeId, contextDir, takePurpose = () => nu
257
257
  };
258
258
  function finishBackground(auto = false) {
259
259
  const elapsed = formatBashElapsed(performance.now() - startedAt);
260
+ // An AUTO handoff (the agent did not choose to background this) is a
261
+ // runaway risk: arm a cancel deadline the daemon enforces, and tell
262
+ // the agent to claim it or lose it. A deliberate handoff runs to
263
+ // completion as before — no deadline file, so the daemon skips it.
264
+ let deadlineNotice = '';
265
+ if (auto) {
266
+ const deadlineMs = envBashAutoBgDeadlineMs();
267
+ writeJobDeadlineMs(paths, Date.now() + deadlineMs);
268
+ deadlineNotice =
269
+ `It will be CANCELED in ${formatBashElapsed(deadlineMs)} unless you claim it: ` +
270
+ `crtr node bash extend ${paths.jobId} --for <duration> (or --keep to run to completion). `;
271
+ }
260
272
  const notice = `\n[backgrounded as job ${paths.jobId} after ${elapsed} in the foreground${auto ? ' (auto)' : ''}. ` +
273
+ deadlineNotice +
261
274
  `It will interrupt you with a message when it exits. ` +
262
275
  `Watch: tail -f ${paths.jobLog} ; stop: kill -- -${pgid}]`;
263
276
  onData(Buffer.from(notice));
@@ -30,6 +30,10 @@ export declare function envRecapModelOverride(): string | undefined;
30
30
  * default; undocumented test-only override so the E2E valve check doesn't
31
31
  * sleep 5 minutes. */
32
32
  export declare function envBashValveMs(): number;
33
+ /** How long an AUTO-backgrounded bash job may run before the daemon cancels it
34
+ * unless the agent claims it (`CRTR_BASH_AUTO_BG_DEADLINE_MS`), 60s by default;
35
+ * undocumented test-only override so the deadline check doesn't wait a minute. */
36
+ export declare function envBashAutoBgDeadlineMs(): number;
33
37
  /** Explicit pidfile path override (`CRTR_PIDFILE`, tests only); undefined if
34
38
  * unset or blank. */
35
39
  export declare function envPidfileOverride(): string | undefined;
@@ -92,6 +92,13 @@ const DEFAULT_BASH_VALVE_MS = 5 * 60 * 1000;
92
92
  export function envBashValveMs() {
93
93
  return parsePositiveMs(process.env['CRTR_BASH_VALVE_MS'], DEFAULT_BASH_VALVE_MS);
94
94
  }
95
+ const DEFAULT_BASH_AUTO_BG_DEADLINE_MS = 60 * 1000;
96
+ /** How long an AUTO-backgrounded bash job may run before the daemon cancels it
97
+ * unless the agent claims it (`CRTR_BASH_AUTO_BG_DEADLINE_MS`), 60s by default;
98
+ * undocumented test-only override so the deadline check doesn't wait a minute. */
99
+ export function envBashAutoBgDeadlineMs() {
100
+ return parsePositiveMs(process.env['CRTR_BASH_AUTO_BG_DEADLINE_MS'], DEFAULT_BASH_AUTO_BG_DEADLINE_MS);
101
+ }
95
102
  /** Explicit pidfile path override (`CRTR_PIDFILE`, tests only); undefined if
96
103
  * unset or blank. */
97
104
  export function envPidfileOverride() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.239",
3
+ "version": "0.3.240",
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.239",
3
+ "version": "0.3.240",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.239",
9
+ "version": "0.3.240",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {