@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.
- package/dist/clients/attach/viewer.js +400 -400
- package/dist/commands/node/bash.js +69 -5
- package/dist/commands/sys/context/admin.js +4 -2
- package/dist/commands/sys/context/resolve.js +5 -1
- package/dist/core/bash-jobs.d.ts +23 -0
- package/dist/core/bash-jobs.js +44 -2
- package/dist/core/wake.d.ts +3 -0
- package/dist/core/wake.js +3 -2
- package/dist/daemon/crtrd.js +3 -0
- package/dist/daemon/reconcilers/bash-deadline.d.ts +17 -0
- package/dist/daemon/reconcilers/bash-deadline.js +73 -0
- package/dist/pi-extensions/canvas-bash-valve.js +15 -2
- package/dist/shared/env.d.ts +4 -0
- package/dist/shared/env.js +7 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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 {
|
|
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
|
|
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:
|
|
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'])
|
|
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: {
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -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,
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -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({
|
|
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. */
|
package/dist/core/wake.d.ts
CHANGED
|
@@ -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
|
|
47
|
-
|
|
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;
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -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));
|
package/dist/shared/env.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/env.js
CHANGED
|
@@ -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
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
9
|
+
"version": "0.3.240",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|