@north-light/crouter 0.3.300 → 0.3.302
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/api/dto/reports.d.ts +8 -8
- package/dist/clients/attach/session/profile-files.d.ts +6 -3
- package/dist/clients/attach/session/profile-files.js +39 -10
- package/dist/clients/attach/viewer.js +663 -663
- package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +30 -1
- package/dist/commands/memory/read.js +12 -14
- package/dist/commands/node/create.js +1 -1
- package/dist/commands/node/lifecycle.js +36 -8
- package/dist/commands/push.js +4 -4
- package/dist/core/__tests__/human-action-delivery.test.js +13 -11
- package/dist/core/__tests__/integration/revive.test.js +3 -3
- package/dist/core/__tests__/node-outcome.test.js +1 -6
- package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
- package/dist/core/__tests__/stop-guard.test.js +64 -0
- package/dist/core/bash-jobs.d.ts +22 -0
- package/dist/core/bash-jobs.js +38 -1
- package/dist/core/canvas/crons.d.ts +20 -4
- package/dist/core/canvas/crons.js +54 -7
- package/dist/core/canvas/delivery-contract.d.ts +19 -0
- package/dist/core/canvas/delivery-contract.js +8 -0
- package/dist/core/canvas/human-deliveries.d.ts +3 -17
- package/dist/core/canvas/node-outcome-deliveries.d.ts +3 -19
- package/dist/core/canvas/node-outcome-deliveries.js +0 -4
- package/dist/core/keybindings/catalog.js +1 -1
- package/dist/core/memory-resolver.d.ts +3 -1
- package/dist/core/memory-resolver.js +3 -3
- package/dist/core/nested-stores.d.ts +1 -1
- package/dist/core/nested-stores.js +3 -3
- package/dist/core/runtime/bearings-render.js +1 -1
- package/dist/core/runtime/close.js +8 -6
- package/dist/core/runtime/stop-guard.js +6 -6
- package/dist/core/worktree.js +3 -6
- package/dist/daemon/__tests__/node-outcome-delivery.test.js +3 -2
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +21 -11
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +2 -2
- package/dist/daemon/cron/sinks.d.ts +1 -1
- package/dist/daemon/cron/sinks.js +2 -2
- package/dist/daemon/delivery/run-delivery.d.ts +25 -0
- package/dist/daemon/delivery/run-delivery.js +164 -0
- package/dist/daemon/human/deliver-action.d.ts +1 -14
- package/dist/daemon/human/deliver-action.js +5 -165
- package/dist/daemon/node-outcome/deliver-outcome.d.ts +1 -12
- package/dist/daemon/node-outcome/deliver-outcome.js +6 -152
- package/dist/daemon/reconcilers/live-obligation.js +2 -2
- package/dist/pi-extensions/canvas-bash-valve.js +7 -10
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
- package/dist/daemon/cron-sink.d.ts +0 -26
- package/dist/daemon/cron-sink.js +0 -43
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Run: node --conditions=crtr-src --import tsx/esm --test --test-concurrency=4 --test-timeout=10000 src/commands/memory/__tests__/command-selector-and-mutation-guards.test.ts
|
|
15
15
|
import { test, beforeEach, after } from 'node:test';
|
|
16
16
|
import assert from 'node:assert/strict';
|
|
17
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
20
|
import { resetScopeCache } from '../../../core/scope.js';
|
|
@@ -23,6 +23,7 @@ import { createProfile, profileMemoryDir } from '../../../core/profiles/manifest
|
|
|
23
23
|
import { openProjectMemoryStore } from '../../../core/memory-resolver.js';
|
|
24
24
|
import { appendHistoryRecord, buildHistoryRecord, readHistoryRecords } from '../../../core/memory/history.js';
|
|
25
25
|
import { CrtrError } from '../../../core/errors.js';
|
|
26
|
+
import { gitSync } from '../../../core/git.js';
|
|
26
27
|
import { deleteLeaf } from '../delete.js';
|
|
27
28
|
import { editLeaf } from '../edit.js';
|
|
28
29
|
import { historyLeaf } from '../history.js';
|
|
@@ -319,6 +320,34 @@ test('a targeted read suppresses malformed descendant-store warnings', async ()
|
|
|
319
320
|
}
|
|
320
321
|
assert.equal(writes.some((line) => line.includes('memory store not mounted')), false, writes.join(''));
|
|
321
322
|
});
|
|
323
|
+
test('a truncated nested-store read reports that its corpus was incomplete', async () => {
|
|
324
|
+
assert.equal(gitSync(['init', '-q'], root).status, 0, 'fixture repository initializes');
|
|
325
|
+
const fakeBin = join(base, 'slow-git');
|
|
326
|
+
mkdirSync(fakeBin);
|
|
327
|
+
const originalPath = process.env['PATH'] ?? '';
|
|
328
|
+
const fakeGit = join(fakeBin, 'git');
|
|
329
|
+
writeFileSync(fakeGit, `#!/bin/sh\nfor arg in "$@"; do\n if [ "$arg" = "ls-files" ]; then sleep 1; exit 0; fi\ndone\nPATH=${JSON.stringify(originalPath)}\nexport PATH\nexec git "$@"\n`, 'utf8');
|
|
330
|
+
chmodSync(fakeGit, 0o755);
|
|
331
|
+
process.chdir(root);
|
|
332
|
+
resetScopeCache();
|
|
333
|
+
const stderr = process.stderr;
|
|
334
|
+
const originalWrite = stderr.write;
|
|
335
|
+
const writes = [];
|
|
336
|
+
stderr.write = ((chunk) => {
|
|
337
|
+
writes.push(String(chunk));
|
|
338
|
+
return true;
|
|
339
|
+
});
|
|
340
|
+
process.env['PATH'] = `${fakeBin}:${originalPath}`;
|
|
341
|
+
try {
|
|
342
|
+
const error = await thrownByAsync(() => readLeaf.run({ name: 'acme/child/foo', kind: undefined, frontmatter: false, scope: undefined, dir: undefined }));
|
|
343
|
+
assert.equal(error.code, 'not_found');
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
process.env['PATH'] = originalPath;
|
|
347
|
+
stderr.write = originalWrite;
|
|
348
|
+
}
|
|
349
|
+
assert.match(writes.join(''), /nested-store discovery.*result may omit nested stores.*--dir <owner>/s, `a timed-out scan must tell a failed read how to bypass discovery; got:\n${writes.join('')}`);
|
|
350
|
+
});
|
|
322
351
|
test('deleted history points at the revision holding the retained text, never a read that cannot answer', async () => {
|
|
323
352
|
await deleteLeaf.run({ name: 'acme/disposable', dir: root });
|
|
324
353
|
const result = (await historyLeaf.run({ name: 'acme/disposable', dir: root }));
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { envNodeId } from '../../shared/env.js';
|
|
2
2
|
import { CrtrClient } from '../../api/index.js';
|
|
3
3
|
import { defineLeaf } from '../../core/command.js';
|
|
4
|
-
import { CrtrError } from '../../core/errors.js';
|
|
5
4
|
import { memoryExtensionEffectiveCatalog, projectEffectiveMemoryExtensions } from '../../core/memory/extensions.js';
|
|
6
5
|
import { docLinkNames } from '../../core/memory/doc-link-grammar.js';
|
|
7
|
-
import { ambientMemoryTarget, createMemoryDocSnapshot, listAllMemoryDocs, loadMemoryStoreView,
|
|
6
|
+
import { ambientMemoryTarget, createMemoryDocSnapshot, listAllMemoryDocs, loadMemoryStoreView, loadMemoryTargetView, readMemoryDocContent, resolveMemoryDocs, } from '../../core/memory-resolver.js';
|
|
8
7
|
import { expandShellBlocks, hasShellBlocks, makeNodeShellRunner } from '../../core/runtime/shell-expansion.js';
|
|
9
8
|
import { emptyContextExposureState, exposureTarget, loadContextExposureState, registerDocumentExposure, registerExposure, saveContextExposureState, } from '../../core/substrate/injected-store.js';
|
|
10
9
|
import { dirDedupKey, docsByName, isDirName, renderDirListing } from '../../core/substrate/listings.js';
|
|
@@ -121,18 +120,17 @@ export const readLeaf = defineLeaf({
|
|
|
121
120
|
...(selector.scope === undefined ? {} : { scope: selector.scope }),
|
|
122
121
|
includeDescendants: true,
|
|
123
122
|
};
|
|
124
|
-
// A named read
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
fail = () => resolveMemoryCandidates(nameRaw, resolveOpts);
|
|
123
|
+
// A named read keeps unrelated malformed documents quiet, but a timed
|
|
124
|
+
// discovery leaves its corpus incomplete and must remain visible before
|
|
125
|
+
// this branch can report not_found.
|
|
126
|
+
const view = loadMemoryTargetView(ambientMemoryTarget(), {
|
|
127
|
+
...resolveOpts,
|
|
128
|
+
quiet: true,
|
|
129
|
+
reportDiscovery: true,
|
|
130
|
+
});
|
|
131
|
+
listingByName = linkByName = docsByName(view.docs);
|
|
132
|
+
set = view.candidates(nameRaw, kindOpts);
|
|
133
|
+
fail = () => view.resolve(nameRaw, kindOpts);
|
|
136
134
|
}
|
|
137
135
|
const nodeId = envNodeId() || undefined;
|
|
138
136
|
if (set === null) {
|
|
@@ -83,7 +83,7 @@ function nodeNewParams() {
|
|
|
83
83
|
{ kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node.' },
|
|
84
84
|
{ kind: 'flag', name: 'node-id', type: 'string', required: false, constraint: 'Exact id for the new node; lowercase letters, digits, and hyphens only, at most 128 characters. Rejected if already present.' },
|
|
85
85
|
{ kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an independent resident root with no parent or subscription. Mutually exclusive with --worktree.' },
|
|
86
|
-
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Applies to managed children and roots; roots default to resident.' },
|
|
86
|
+
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'Birth the node as resident: it parks and stays wakeable without being forced to submit a final. Pass it ONLY when the user will open this node’s viewer pane and work with it back and forth; a child that only other nodes message stays terminal. Applies to managed children and roots; roots default to resident.' },
|
|
87
87
|
{ kind: 'flag', name: 'worktree', type: 'string', required: false, constraint: 'Local base branch for a crouter-managed worktree for this child. Mutually exclusive with --root.' },
|
|
88
88
|
{ kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'Fork the new node from an existing node id, absolute session path, or partial pi session uuid.' },
|
|
89
89
|
{
|
|
@@ -3,6 +3,8 @@ import { envNodeId } from '../../shared/env.js';
|
|
|
3
3
|
import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
4
4
|
import { InputError } from '../../core/io.js';
|
|
5
5
|
import { cliClient, getNodeOrNull, rethrowAsCliError } from '../api-client.js';
|
|
6
|
+
import { contextDir } from '../../core/canvas/paths.js';
|
|
7
|
+
import { DEFAULT_BASH_NICENESS, MAX_BASH_NICENESS, writeBashNiceness } from '../../core/bash-jobs.js';
|
|
6
8
|
import { nodeReviveLeaf } from '../node-lifecycle-revive.js';
|
|
7
9
|
import { assertKind, kindsStateBlock } from './create.js';
|
|
8
10
|
import { MODEL_SPEC_FORMS } from '../../core/runtime/model-selection.js';
|
|
@@ -69,11 +71,11 @@ const nodeRecycle = defineLeaf({
|
|
|
69
71
|
// node lifecycle close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
|
|
70
72
|
const nodeClose = defineLeaf({
|
|
71
73
|
name: 'close',
|
|
72
|
-
description: 'close a node + cascade-cancel its exclusive subtree
|
|
73
|
-
whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need.
|
|
74
|
+
description: 'close a node + cascade-cancel its exclusive subtree',
|
|
75
|
+
whenToUse: 'you want to tear a node down WITHOUT finishing it, cascade-cancelling every descendant it exclusively owns: abandoning a line of work, killing a stuck or wrong-turn subtree, clearing a branch you no longer need. A node that never produced substantive assistant output is deleted and cannot be revived; streaming nodes and nodes with an open managed worktree are retained. Other closed nodes keep their pi session and can be revived later (`node lifecycle revive`). Use `node lifecycle recycle` instead to FINISH the agent in your pane with a final report, and `push final` when a worker should end its own work normally (Alt+C → x)',
|
|
74
76
|
help: {
|
|
75
77
|
name: 'node lifecycle close',
|
|
76
|
-
summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark
|
|
78
|
+
summary: 'close a node and cascade-cancel its subtree — kill its tmux window plus those of every descendant it EXCLUSIVELY owns (down the subscribes_to spine), mark retained nodes canceled, and leave them a notice they read on resume. A descendant still subscribed to by a manager outside the subtree is left running. A node that never produced substantive assistant output is deleted and cannot be revived; streaming nodes and nodes with an open managed worktree are retained. Other closed nodes keep their pi session and can be revived later (`crtr node lifecycle revive`)',
|
|
77
79
|
params: [
|
|
78
80
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
|
|
79
81
|
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id whose node to close. Defaults to $TMUX_PANE / your current pane. The Alt+C menu passes this for you.' },
|
|
@@ -86,9 +88,10 @@ const nodeClose = defineLeaf({
|
|
|
86
88
|
],
|
|
87
89
|
outputKind: 'object',
|
|
88
90
|
effects: [
|
|
89
|
-
'Marks
|
|
90
|
-
'
|
|
91
|
-
'
|
|
91
|
+
'Marks retained nodes and their exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
|
|
92
|
+
'Deletes a non-streaming node with no substantive assistant output unless it has an open managed worktree; a deleted node cannot be revived.',
|
|
93
|
+
'Kills each closed node\'s tmux window; retained nodes keep their pi sessions and canvas edges for a later revive.',
|
|
94
|
+
'Appends a cancellation notice to each retained node\'s inbox, surfaced on its next resume.',
|
|
92
95
|
],
|
|
93
96
|
},
|
|
94
97
|
run: async (input) => {
|
|
@@ -140,6 +143,7 @@ export const nodeConfig = defineLeaf({
|
|
|
140
143
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Persona kind. The <kinds> list below names every top-level installable kind and when to use each; a registered sub-kind is valid too by exact path but not listed here.' },
|
|
141
144
|
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, constraint: 'Set persona mode headlessly. base is hands-on; orchestrator holds a roadmap and delegates. orchestrator seeds a roadmap scaffold if absent.' },
|
|
142
145
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
|
|
146
|
+
{ kind: 'flag', name: 'nice', type: 'int', required: false, constraint: `Scheduler niceness every command this node's bash tool starts at, 0–${MAX_BASH_NICENESS} (default ${DEFAULT_BASH_NICENESS}). ${DEFAULT_BASH_NICENESS} keeps the node's builds and test suites below the daemon, the brokers, the viewers, and the person's own shell; 0 lets them compete on equal terms with everything else on the host; ${MAX_BASH_NICENESS} makes them yield to it. Applies to the node's very next command, foreground and background alike.` },
|
|
143
147
|
{ kind: 'flag', name: 'cwd', type: 'string', required: false, constraint: 'Repair-only replacement launch directory. Must be a non-empty absolute existing directory and used alone; the daemon accepts it only for a dormant, non-forked node with no open managed worktree whose recorded cwd is gone. Clears the saved Pi session, so the next revive starts fresh while durable node work remains.' },
|
|
144
148
|
],
|
|
145
149
|
output: [
|
|
@@ -149,6 +153,7 @@ export const nodeConfig = defineLeaf({
|
|
|
149
153
|
{ name: 'kind', type: 'string', required: false, constraint: 'The new kind, present only when --kind changed it.' },
|
|
150
154
|
{ name: 'mode', type: 'string', required: false, constraint: 'The new mode, present only when --mode changed it.' },
|
|
151
155
|
{ name: 'name', type: 'string', required: false, constraint: 'The new name, present only when --name changed it.' },
|
|
156
|
+
{ name: 'nice', type: 'number', required: false, constraint: 'The new bash niceness, present only when --nice changed it.' },
|
|
152
157
|
{ name: 'cwd', type: 'string', required: false, constraint: 'The lexically resolved replacement directory, present only after a cwd repair.' },
|
|
153
158
|
],
|
|
154
159
|
outputKind: 'object',
|
|
@@ -157,6 +162,7 @@ export const nodeConfig = defineLeaf({
|
|
|
157
162
|
'Kind, mode, and lifecycle changes rebuild the launch spec from the fresh node meta; setting mode to orchestrator seeds a roadmap scaffold if absent.',
|
|
158
163
|
'Name changes update the row and, when the node already has a live window, rename that viewer window to the new full name.',
|
|
159
164
|
'A cwd repair writes only the lexically resolved cwd and clears saved Pi session pointers; the old transcript and durable node work remain untouched.',
|
|
165
|
+
'A niceness change is stored with the node\'s own bash-job state, so it takes effect on the next command without a revive and survives one.',
|
|
160
166
|
],
|
|
161
167
|
dynamicState: () => kindsStateBlock(),
|
|
162
168
|
},
|
|
@@ -169,7 +175,7 @@ export const nodeConfig = defineLeaf({
|
|
|
169
175
|
// clean listing error, and re-validated server-side against the TARGET
|
|
170
176
|
// node's scope as a backstop.
|
|
171
177
|
const cwdSpec = input['cwd'];
|
|
172
|
-
if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name'].some((flag) => input[flag] !== undefined)) {
|
|
178
|
+
if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name', 'nice'].some((flag) => input[flag] !== undefined)) {
|
|
173
179
|
throw new InputError({ error: 'cwd_not_exclusive', message: 'cwd repair cannot be combined with another config flag', field: 'cwd', next: 'Pass --cwd alone.' });
|
|
174
180
|
}
|
|
175
181
|
const modelSpec = input['model']?.trim();
|
|
@@ -180,6 +186,15 @@ export const nodeConfig = defineLeaf({
|
|
|
180
186
|
const lifecycleSpec = input['lifecycle']?.trim();
|
|
181
187
|
const modeSpec = input['mode']?.trim();
|
|
182
188
|
const nameSpec = input['name'];
|
|
189
|
+
const niceSpec = input['nice'];
|
|
190
|
+
if (niceSpec !== undefined && (niceSpec < 0 || niceSpec > MAX_BASH_NICENESS)) {
|
|
191
|
+
throw new InputError({
|
|
192
|
+
error: 'bad_nice',
|
|
193
|
+
message: `niceness must be between 0 and ${MAX_BASH_NICENESS}: ${niceSpec}`,
|
|
194
|
+
field: 'nice',
|
|
195
|
+
next: `A negative niceness needs root, so pass 0–${MAX_BASH_NICENESS}.`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
183
198
|
if (kindSpec !== undefined)
|
|
184
199
|
assertKind(kindSpec);
|
|
185
200
|
if (lifecycleSpec !== undefined && lifecycleSpec.toLowerCase() !== 'terminal' && lifecycleSpec.toLowerCase() !== 'resident') {
|
|
@@ -214,8 +229,17 @@ export const nodeConfig = defineLeaf({
|
|
|
214
229
|
patch.cwd = cwdSpec;
|
|
215
230
|
applied.push('cwd');
|
|
216
231
|
}
|
|
232
|
+
// Niceness is node-local bash-lane state, not canvas state: it is stored
|
|
233
|
+
// beside this node's job dirs so the valve reads it when it starts the next
|
|
234
|
+
// command, rather than waiting for a launch recipe to be rebuilt.
|
|
235
|
+
if (niceSpec !== undefined) {
|
|
236
|
+
writeBashNiceness(contextDir(nodeId), niceSpec);
|
|
237
|
+
applied.push('nice');
|
|
238
|
+
if (applied.length === 1)
|
|
239
|
+
return { node_id: nodeId, nice: niceSpec };
|
|
240
|
+
}
|
|
217
241
|
if (applied.length === 0) {
|
|
218
|
-
throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --cwd.' });
|
|
242
|
+
throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --nice, --cwd.' });
|
|
219
243
|
}
|
|
220
244
|
const detail = await cliClient()
|
|
221
245
|
.patchConfig(nodeId, patch)
|
|
@@ -233,6 +257,8 @@ export const nodeConfig = defineLeaf({
|
|
|
233
257
|
result.mode = detail.mode;
|
|
234
258
|
if (applied.includes('name'))
|
|
235
259
|
result.name = detail.name;
|
|
260
|
+
if (applied.includes('nice'))
|
|
261
|
+
result.nice = niceSpec;
|
|
236
262
|
return result;
|
|
237
263
|
},
|
|
238
264
|
render: (r) => {
|
|
@@ -249,6 +275,8 @@ export const nodeConfig = defineLeaf({
|
|
|
249
275
|
bits.push(`mode=${r['mode']}`);
|
|
250
276
|
if (r['name'] !== undefined)
|
|
251
277
|
bits.push(`name=${r['name']}`);
|
|
278
|
+
if (r['nice'] !== undefined)
|
|
279
|
+
bits.push(`nice=${r['nice']}`);
|
|
252
280
|
return `Configured ${r['node_id']} — ${bits.join(', ')}`;
|
|
253
281
|
},
|
|
254
282
|
});
|
package/dist/commands/push.js
CHANGED
|
@@ -160,8 +160,8 @@ function makePushLeaf(kind) {
|
|
|
160
160
|
...(kind === 'final'
|
|
161
161
|
? [
|
|
162
162
|
{ name: 'transitioned', type: 'object', required: false, constraint: 'Present only for a final that flipped the node — { from, to } lifecycle-status transition applied server-side.' },
|
|
163
|
-
{ name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when this node
|
|
164
|
-
{ name: 'worktree_auto_dropped_path', type: 'string', required: false, constraint: 'Present only alongside worktree_auto_dropped: the
|
|
163
|
+
{ name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when the server closed this node’s managed-worktree record without requiring `node worktree close`; cleanup of an existing checkout remains pending (see worktree_auto_dropped_path).' },
|
|
164
|
+
{ name: 'worktree_auto_dropped_path', type: 'string', required: false, constraint: 'Present only alongside worktree_auto_dropped: the closed worktree path; an existing checkout remains until pending cleanup.' },
|
|
165
165
|
]
|
|
166
166
|
: []),
|
|
167
167
|
],
|
|
@@ -172,7 +172,7 @@ function makePushLeaf(kind) {
|
|
|
172
172
|
...(kind === 'final'
|
|
173
173
|
? [
|
|
174
174
|
'Marks the node done (status + intent) server-side; its engine shuts down on next stop.',
|
|
175
|
-
'When this node owns a clean managed worktree already contained in its local base, the server
|
|
175
|
+
'When this node owns a clean managed worktree already contained in its local base, the server closes its record with cleanup pending instead of blocking on `node worktree close`. A clean worktree whose exact tip exists on any branch on origin may also finalize while retaining its branch and checkout; uncommitted changes or a tip absent from origin still block with open_managed_worktree.',
|
|
176
176
|
]
|
|
177
177
|
: []),
|
|
178
178
|
],
|
|
@@ -242,7 +242,7 @@ function makePushLeaf(kind) {
|
|
|
242
242
|
const line = kind === 'final'
|
|
243
243
|
? 'Result recorded — node finished; its engine shuts down on next stop. Nothing more to do here: STOP your turn immediately. Reply with just "Done." and nothing else.'
|
|
244
244
|
: `Progress report fanned to ${n} subscriber(s).`;
|
|
245
|
-
const dropNote = r['worktree_auto_dropped'] === true ? `\n\
|
|
245
|
+
const dropNote = r['worktree_auto_dropped'] === true ? `\n\nClosed a clean managed-worktree record at ${String(r['worktree_auto_dropped_path'])} — nothing to land; cleanup remains pending if the checkout still exists.` : '';
|
|
246
246
|
return `${line}\n\n- report: ${r['report_path']}${dropNote}`;
|
|
247
247
|
},
|
|
248
248
|
});
|
|
@@ -5,7 +5,9 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { closeDb } from '../canvas/db.js';
|
|
7
7
|
import { claimActionDelivery, enqueueActionDelivery, readActionDelivery, } from '../canvas/human-deliveries.js';
|
|
8
|
-
import {
|
|
8
|
+
import { deliveryBackoffMs } from '../canvas/delivery-contract.js';
|
|
9
|
+
import { deliverySettlementForOutcome } from '../../daemon/delivery/run-delivery.js';
|
|
10
|
+
import { deliverAction } from '../../daemon/human/deliver-action.js';
|
|
9
11
|
let home;
|
|
10
12
|
const priorHome = process.env['CRTR_HOME'];
|
|
11
13
|
beforeEach(() => {
|
|
@@ -25,14 +27,14 @@ after(() => {
|
|
|
25
27
|
else
|
|
26
28
|
process.env['CRTR_HOME'] = priorHome;
|
|
27
29
|
});
|
|
28
|
-
test('
|
|
29
|
-
assert.equal(
|
|
30
|
-
assert.equal(
|
|
31
|
-
assert.equal(
|
|
32
|
-
assert.equal(
|
|
33
|
-
assert.equal(
|
|
30
|
+
test('delivery retries follow the durable backoff and exit boundary', () => {
|
|
31
|
+
assert.equal(deliveryBackoffMs(1), 5_000);
|
|
32
|
+
assert.equal(deliveryBackoffMs(2), 10_000);
|
|
33
|
+
assert.equal(deliveryBackoffMs(3), 20_000);
|
|
34
|
+
assert.equal(deliveryBackoffMs(11), 3_600_000);
|
|
35
|
+
assert.equal(deliveryBackoffMs(100), 3_600_000);
|
|
34
36
|
const now = 1_000_000;
|
|
35
|
-
const outcome = (exitCode, signal = null) =>
|
|
37
|
+
const outcome = (exitCode, signal = null) => deliverySettlementForOutcome({
|
|
36
38
|
exitCode,
|
|
37
39
|
signal,
|
|
38
40
|
timedOut: false,
|
|
@@ -50,14 +52,14 @@ test('action delivery retries follow the durable backoff and exit boundary', ()
|
|
|
50
52
|
});
|
|
51
53
|
assert.equal(outcome(79).state, 'pending');
|
|
52
54
|
assert.equal(outcome(null, 'SIGTERM').state, 'pending');
|
|
53
|
-
assert.equal(
|
|
55
|
+
assert.equal(deliverySettlementForOutcome({
|
|
54
56
|
exitCode: null,
|
|
55
57
|
signal: null,
|
|
56
58
|
timedOut: true,
|
|
57
59
|
stderr: '',
|
|
58
60
|
}, 1, now).state, 'pending');
|
|
59
61
|
// The kill escalation's signal is the only way to tell the graceful term from the SIGKILL.
|
|
60
|
-
assert.deepEqual(
|
|
62
|
+
assert.deepEqual(deliverySettlementForOutcome({
|
|
61
63
|
exitCode: null,
|
|
62
64
|
signal: 'SIGKILL',
|
|
63
65
|
timedOut: true,
|
|
@@ -67,7 +69,7 @@ test('action delivery retries follow the durable backoff and exit boundary', ()
|
|
|
67
69
|
nextAttemptAt: now + 5_000,
|
|
68
70
|
failure: { kind: 'timeout', signal: 'SIGKILL', stderr: 'stalled' },
|
|
69
71
|
});
|
|
70
|
-
assert.equal(
|
|
72
|
+
assert.equal(deliverySettlementForOutcome({
|
|
71
73
|
exitCode: null,
|
|
72
74
|
signal: null,
|
|
73
75
|
timedOut: false,
|
|
@@ -33,7 +33,7 @@ import { tmpdir } from 'node:os';
|
|
|
33
33
|
import { join } from 'node:path';
|
|
34
34
|
import { spawn, spawnSync } from 'node:child_process';
|
|
35
35
|
import { createNode, deleteNode, getNode, subscribe, updateNode } from '../../canvas/canvas.js';
|
|
36
|
-
import { armCron,
|
|
36
|
+
import { armCron, getCron } from '../../canvas/crons.js';
|
|
37
37
|
import { readInboxSince } from '../../feed/inbox.js';
|
|
38
38
|
import { closeDb } from '../../canvas/db.js';
|
|
39
39
|
import { apiSocketPath } from '../../canvas/paths.js';
|
|
@@ -304,11 +304,11 @@ test('a recovery relaunch keeps the armed deadline; a wake revive consumes it',
|
|
|
304
304
|
const recovered = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-1' });
|
|
305
305
|
armDeadline(recovered, 'cron-recovery');
|
|
306
306
|
await reviveNode(recovered, { resume: true, recovery: true, capacity: 'freeze' });
|
|
307
|
-
assert.equal(
|
|
307
|
+
assert.equal(getCron('cron-recovery') !== null, true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
|
|
308
308
|
const woken = h.fabricateBrokerNode({ status: 'active', intent: null, pi_pid: deadPid(), pi_session_id: 'uuid-2' });
|
|
309
309
|
armDeadline(woken, 'cron-wake');
|
|
310
310
|
await reviveNode(woken, { resume: true, capacity: 'freeze' });
|
|
311
|
-
assert.equal(
|
|
311
|
+
assert.equal(getCron('cron-wake'), null, 'a wake revive won the race and cancels the deadline');
|
|
312
312
|
}
|
|
313
313
|
finally {
|
|
314
314
|
await h.dispose();
|
|
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { closeDb, MIGRATIONS } from '../canvas/db.js';
|
|
8
8
|
import { createNode, getRow } from '../canvas/canvas.js';
|
|
9
|
-
import { armOutcomeDelivery, claimOutcomeDelivery,
|
|
9
|
+
import { armOutcomeDelivery, claimOutcomeDelivery, readOutcomeDelivery, recoverRunningOutcomeDeliveries, settleOutcomeDelivery, } from '../canvas/node-outcome-deliveries.js';
|
|
10
10
|
import { transition } from '../runtime/lifecycle.js';
|
|
11
11
|
import { composeNodeOutcomeDocument } from '../runtime/outcome-document.js';
|
|
12
12
|
import { pushFinal } from '../feed/feed.js';
|
|
@@ -153,11 +153,6 @@ test('outcome delivery state is fenced, recoverable, and follows the durable bac
|
|
|
153
153
|
nextAttemptAt: readOutcomeDelivery('recover').nextAttemptAt,
|
|
154
154
|
claimOwner: readOutcomeDelivery('recover').claimOwner,
|
|
155
155
|
}, { state: 'pending', nextAttemptAt: recoveredAt, claimOwner: null });
|
|
156
|
-
assert.equal(outcomeDeliveryBackoffMs(1), 5_000);
|
|
157
|
-
assert.equal(outcomeDeliveryBackoffMs(2), 10_000);
|
|
158
|
-
assert.equal(outcomeDeliveryBackoffMs(3), 20_000);
|
|
159
|
-
assert.equal(outcomeDeliveryBackoffMs(11), 3_600_000);
|
|
160
|
-
assert.equal(outcomeDeliveryBackoffMs(100), 3_600_000);
|
|
161
156
|
});
|
|
162
157
|
test('v39 migrates a v38 database and backfills finalized and terminal rows', () => {
|
|
163
158
|
const dbPath = join(home, 'v38.db');
|
|
@@ -22,6 +22,7 @@ import assert from 'node:assert/strict';
|
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
|
|
25
|
+
import { acquireCronRunLease, armCron, setCronState } from '../../canvas/crons.js';
|
|
25
26
|
import { registerWarmSpare, subscribe, subscriptionsOf, updateNode } from '../../canvas/index.js';
|
|
26
27
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
27
28
|
import { reviveNode } from '../../runtime/revive.js';
|
|
@@ -45,6 +46,47 @@ async function awaitReleased(nodeId) {
|
|
|
45
46
|
return node?.status === 'idle' && node.intent === 'idle-release' && typeof node.pi_pid === 'number' && !isPidAlive(node.pi_pid);
|
|
46
47
|
}, { label: `idle-release broker exit for ${nodeId}` });
|
|
47
48
|
}
|
|
49
|
+
async function assertCronWakeReleases(nodeId, running) {
|
|
50
|
+
const cronId = `${nodeId}-cron`;
|
|
51
|
+
const target = h.fabricateBrokerNode({ id: nodeId, parent: root, status: 'active' });
|
|
52
|
+
const now = new Date();
|
|
53
|
+
armCron({
|
|
54
|
+
cron_id: cronId,
|
|
55
|
+
name: cronId,
|
|
56
|
+
created_by: null,
|
|
57
|
+
command: 'printf wake',
|
|
58
|
+
fire_at: new Date(now.getTime() + 3_600_000).toISOString(),
|
|
59
|
+
recur: null,
|
|
60
|
+
tz: null,
|
|
61
|
+
expires_at: null,
|
|
62
|
+
anchor_node: nodeId,
|
|
63
|
+
cancel_on_wake: false,
|
|
64
|
+
cwd: h.home,
|
|
65
|
+
env_json: null,
|
|
66
|
+
profile: null,
|
|
67
|
+
scope: 'profile',
|
|
68
|
+
run_timeout_s: 30,
|
|
69
|
+
overlap: 'skip',
|
|
70
|
+
on_output: 'always',
|
|
71
|
+
sink: JSON.stringify({ node: nodeId }),
|
|
72
|
+
tier: 'normal',
|
|
73
|
+
});
|
|
74
|
+
if (running) {
|
|
75
|
+
assert.equal(acquireCronRunLease(cronId, 'seam-fixture', process.pid, now.toISOString(), false), true);
|
|
76
|
+
setCronState(cronId, 'paused');
|
|
77
|
+
}
|
|
78
|
+
updateNode(target, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
|
|
79
|
+
await reviveNode(target, { resume: true, capacity: 'freeze' });
|
|
80
|
+
await h.awaitBoot(target);
|
|
81
|
+
await h.stop(target);
|
|
82
|
+
await awaitReleased(target);
|
|
83
|
+
await h.awaitFleetExit(target);
|
|
84
|
+
assert.equal(h.node(target)?.status, 'idle', `${running ? 'running' : 'scheduled'} cron did not trigger a stop-guard reprompt`);
|
|
85
|
+
}
|
|
86
|
+
test('an anchored node-sink cron releases a terminal broker when scheduled or in flight', { timeout: 60_000 }, async () => {
|
|
87
|
+
await assertCronWakeReleases('scheduled-cron-wake', false);
|
|
88
|
+
await assertCronWakeReleases('running-cron-wake', true);
|
|
89
|
+
});
|
|
48
90
|
test('idle-release settles its real watcher before exit; daemon revives exactly once for a later row', { timeout: 30_000 }, async () => {
|
|
49
91
|
// Reproduce the observed #353/#362 edge: real pi reported
|
|
50
92
|
// ctx.hasPendingMessages() === true at the sole agent_settled even though the
|
|
@@ -9,7 +9,9 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { createNode, deliverAndSettleMessageWait, getNode, subscribe, setMessageWait, setStatus } from '../canvas/canvas.js';
|
|
12
|
+
import { armCron, cancelCron, setCronState } from '../canvas/crons.js';
|
|
12
13
|
import { closeDb, openDb } from '../canvas/db.js';
|
|
14
|
+
import { hasLiveObligation } from '../../daemon/reconcilers/live-obligation.js';
|
|
13
15
|
import { contextDir } from '../canvas/paths.js';
|
|
14
16
|
import { bashJobPaths } from '../bash-jobs.js';
|
|
15
17
|
import { evaluateStop } from '../runtime/stop-guard.js';
|
|
@@ -29,6 +31,29 @@ function node(id, over = {}) {
|
|
|
29
31
|
};
|
|
30
32
|
}
|
|
31
33
|
const noSignals = { pushedFinal: false, requestedHumanReply: false };
|
|
34
|
+
function arm(overrides) {
|
|
35
|
+
armCron({
|
|
36
|
+
name: overrides.cron_id,
|
|
37
|
+
created_by: null,
|
|
38
|
+
command: 'printf cron',
|
|
39
|
+
fire_at: new Date(Date.now() + 3_600_000).toISOString(),
|
|
40
|
+
recur: null,
|
|
41
|
+
tz: null,
|
|
42
|
+
expires_at: null,
|
|
43
|
+
anchor_node: null,
|
|
44
|
+
cancel_on_wake: false,
|
|
45
|
+
cwd: home,
|
|
46
|
+
env_json: null,
|
|
47
|
+
profile: null,
|
|
48
|
+
scope: 'profile',
|
|
49
|
+
run_timeout_s: 30,
|
|
50
|
+
overlap: 'skip',
|
|
51
|
+
on_output: 'always',
|
|
52
|
+
sink: '{}',
|
|
53
|
+
tier: 'normal',
|
|
54
|
+
...overrides,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
32
57
|
before(() => {
|
|
33
58
|
home = mkdtempSync(join(tmpdir(), 'crtr-stopguard-'));
|
|
34
59
|
process.env['CRTR_HOME'] = home;
|
|
@@ -82,6 +107,45 @@ test('a TERMINAL node awaiting a background bash job may dorm until its completi
|
|
|
82
107
|
assert.equal(completed.action, 'reprompt');
|
|
83
108
|
assert.equal(completed.reason, 'stalled');
|
|
84
109
|
});
|
|
110
|
+
test('a pending node-sink cron wake releases terminal nodes and holds unattended nodes', () => {
|
|
111
|
+
createNode(node('target'));
|
|
112
|
+
const assertsWake = (id) => {
|
|
113
|
+
assert.deepEqual(evaluateStop('target', noSignals), { action: 'allow', reason: 'scheduled' }, id);
|
|
114
|
+
assert.equal(hasLiveObligation('target'), true, `${id} holds the unattended node`);
|
|
115
|
+
};
|
|
116
|
+
const assertsNoWake = (id) => {
|
|
117
|
+
assert.equal(evaluateStop('target', noSignals).action, 'reprompt', id);
|
|
118
|
+
assert.equal(hasLiveObligation('target'), false, `${id} does not hold the unattended node`);
|
|
119
|
+
};
|
|
120
|
+
arm({ cron_id: 'scheduled', anchor_node: 'target', sink: JSON.stringify({ node: 'target' }) });
|
|
121
|
+
assertsWake('an anchored scheduled always sink');
|
|
122
|
+
cancelCron('scheduled');
|
|
123
|
+
arm({ cron_id: 'running', anchor_node: 'target', sink: JSON.stringify({ node: 'target' }) });
|
|
124
|
+
openDb().prepare("UPDATE crons SET run_state = 'running' WHERE cron_id = ?").run('running');
|
|
125
|
+
setCronState('running', 'paused');
|
|
126
|
+
assertsWake('a paused in-flight sink');
|
|
127
|
+
cancelCron('running');
|
|
128
|
+
arm({ cron_id: 'unanchored-change', on_output: 'on-change', sink: JSON.stringify({ node: 'target' }) });
|
|
129
|
+
assertsWake('an unanchored on-change sink');
|
|
130
|
+
cancelCron('unanchored-change');
|
|
131
|
+
arm({ cron_id: 'other-target', sink: JSON.stringify({ node: 'other' }) });
|
|
132
|
+
assertsNoWake('a sink for another node');
|
|
133
|
+
cancelCron('other-target');
|
|
134
|
+
arm({ cron_id: 'silent', on_output: 'silent', sink: JSON.stringify({ node: 'target' }) });
|
|
135
|
+
assertsNoWake('a silent cron');
|
|
136
|
+
cancelCron('silent');
|
|
137
|
+
arm({ cron_id: 'on-failure', on_output: 'on-failure', sink: JSON.stringify({ node: 'target' }) });
|
|
138
|
+
assertsNoWake('an on-failure cron');
|
|
139
|
+
cancelCron('on-failure');
|
|
140
|
+
arm({ cron_id: 'expired', expires_at: new Date(Date.now() - 1_000).toISOString(), sink: JSON.stringify({ node: 'target' }) });
|
|
141
|
+
assertsNoWake('an expired idle cron');
|
|
142
|
+
cancelCron('expired');
|
|
143
|
+
arm({ cron_id: 'canceled', sink: JSON.stringify({ node: 'target' }) });
|
|
144
|
+
cancelCron('canceled');
|
|
145
|
+
assertsNoWake('a canceled cron');
|
|
146
|
+
arm({ cron_id: 'deadline', anchor_node: 'target', cancel_on_wake: true, on_output: 'on-failure' });
|
|
147
|
+
assertsWake('an active deadline');
|
|
148
|
+
});
|
|
85
149
|
test('only an explicit live controller wait permits dormancy; ancestry alone does not', () => {
|
|
86
150
|
createNode(node('parent', { lifecycle: 'terminal', status: 'active' }));
|
|
87
151
|
createNode(node('child', { parent: 'parent' }));
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -36,6 +36,28 @@ export declare function readJobPgid(contextDir: string, jobId: string): number |
|
|
|
36
36
|
/** The optional human-readable job label. Jobs created before labels existed
|
|
37
37
|
* have no file and deliberately project null. */
|
|
38
38
|
export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
|
|
39
|
+
/** Scheduler niceness every command a node runs is started with. Agent work —
|
|
40
|
+
* builds, test suites, typechecks — sits below the daemon, the brokers, the
|
|
41
|
+
* viewers, and the person's own shell, so a fan-out of release builds cannot
|
|
42
|
+
* starve them: the scheduler only honors this under contention and it costs
|
|
43
|
+
* nothing on an idle host. */
|
|
44
|
+
export declare const DEFAULT_BASH_NICENESS = 10;
|
|
45
|
+
/** The politest priority the scheduler accepts. A NEGATIVE niceness (more
|
|
46
|
+
* priority than the broker itself) needs root: `nice` prints a permission error
|
|
47
|
+
* into the command's own log and runs it at the inherited priority anyway, so
|
|
48
|
+
* the override range stops at 0. */
|
|
49
|
+
export declare const MAX_BASH_NICENESS = 19;
|
|
50
|
+
/** Per-node override of the bash niceness, written by `crtr node config --nice`
|
|
51
|
+
* and read by the valve when it starts each command. Node-local like the job
|
|
52
|
+
* dirs beside it, so a change applies to the node's very next command without
|
|
53
|
+
* waiting for a revive. */
|
|
54
|
+
export declare function bashNicenessPath(contextDir: string): string;
|
|
55
|
+
/** The niceness this node's commands run at. Any missing, malformed, or
|
|
56
|
+
* out-of-range file reads as the default rather than an odd priority. */
|
|
57
|
+
export declare function readBashNiceness(contextDir: string): number;
|
|
58
|
+
/** Set the node's bash niceness. The caller owns validation; the store keeps
|
|
59
|
+
* one integer so the valve's read stays a single file read per command. */
|
|
60
|
+
export declare function writeBashNiceness(contextDir: string, niceness: number): void;
|
|
39
61
|
/** The auto-background cancel deadline (epoch ms), or undefined for a job with
|
|
40
62
|
* no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
|
|
41
63
|
* malformed file reads as no deadline rather than an instant expiry. */
|
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, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { closeSync, existsSync, mkdirSync, 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
|
|
@@ -66,6 +66,43 @@ export function readJobPurpose(contextDir, jobId) {
|
|
|
66
66
|
return null;
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
+
/** Scheduler niceness every command a node runs is started with. Agent work —
|
|
70
|
+
* builds, test suites, typechecks — sits below the daemon, the brokers, the
|
|
71
|
+
* viewers, and the person's own shell, so a fan-out of release builds cannot
|
|
72
|
+
* starve them: the scheduler only honors this under contention and it costs
|
|
73
|
+
* nothing on an idle host. */
|
|
74
|
+
export const DEFAULT_BASH_NICENESS = 10;
|
|
75
|
+
/** The politest priority the scheduler accepts. A NEGATIVE niceness (more
|
|
76
|
+
* priority than the broker itself) needs root: `nice` prints a permission error
|
|
77
|
+
* into the command's own log and runs it at the inherited priority anyway, so
|
|
78
|
+
* the override range stops at 0. */
|
|
79
|
+
export const MAX_BASH_NICENESS = 19;
|
|
80
|
+
/** Per-node override of the bash niceness, written by `crtr node config --nice`
|
|
81
|
+
* and read by the valve when it starts each command. Node-local like the job
|
|
82
|
+
* dirs beside it, so a change applies to the node's very next command without
|
|
83
|
+
* waiting for a revive. */
|
|
84
|
+
export function bashNicenessPath(contextDir) {
|
|
85
|
+
return join(bashJobsDir(contextDir), 'niceness');
|
|
86
|
+
}
|
|
87
|
+
/** The niceness this node's commands run at. Any missing, malformed, or
|
|
88
|
+
* out-of-range file reads as the default rather than an odd priority. */
|
|
89
|
+
export function readBashNiceness(contextDir) {
|
|
90
|
+
try {
|
|
91
|
+
const n = Number(readFileSync(bashNicenessPath(contextDir), 'utf8').trim());
|
|
92
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_BASH_NICENESS)
|
|
93
|
+
return DEFAULT_BASH_NICENESS;
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return DEFAULT_BASH_NICENESS;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Set the node's bash niceness. The caller owns validation; the store keeps
|
|
101
|
+
* one integer so the valve's read stays a single file read per command. */
|
|
102
|
+
export function writeBashNiceness(contextDir, niceness) {
|
|
103
|
+
mkdirSync(bashJobsDir(contextDir), { recursive: true });
|
|
104
|
+
writeFileSync(bashNicenessPath(contextDir), `${Math.trunc(niceness)}\n`);
|
|
105
|
+
}
|
|
69
106
|
/** The auto-background cancel deadline (epoch ms), or undefined for a job with
|
|
70
107
|
* no deadline (a deliberate handoff, or a legacy job dir). A non-finite or
|
|
71
108
|
* malformed file reads as no deadline rather than an instant expiry. */
|