@north-light/crouter 0.3.301 → 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/viewer.js +394 -394
- 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/lifecycle.js +7 -6
- package/dist/commands/push.js +4 -4
- package/dist/core/__tests__/integration/revive.test.js +3 -3
- package/dist/core/__tests__/seam/dormancy-release.test.js +42 -0
- package/dist/core/__tests__/stop-guard.test.js +64 -0
- package/dist/core/canvas/crons.d.ts +20 -4
- package/dist/core/canvas/crons.js +54 -7
- 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/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/reconcilers/live-obligation.js +2 -2
- 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) {
|
|
@@ -71,11 +71,11 @@ const nodeRecycle = defineLeaf({
|
|
|
71
71
|
// node lifecycle close — close a node + cascade-cancel its exclusive subtree (Alt+C → x)
|
|
72
72
|
const nodeClose = defineLeaf({
|
|
73
73
|
name: 'close',
|
|
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.
|
|
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)',
|
|
76
76
|
help: {
|
|
77
77
|
name: 'node lifecycle close',
|
|
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
|
|
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`)',
|
|
79
79
|
params: [
|
|
80
80
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to close. Defaults to the node occupying --pane (or your current pane).' },
|
|
81
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.' },
|
|
@@ -88,9 +88,10 @@ const nodeClose = defineLeaf({
|
|
|
88
88
|
],
|
|
89
89
|
outputKind: 'object',
|
|
90
90
|
effects: [
|
|
91
|
-
'Marks
|
|
92
|
-
'
|
|
93
|
-
'
|
|
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.',
|
|
94
95
|
],
|
|
95
96
|
},
|
|
96
97
|
run: async (input) => {
|
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
|
});
|
|
@@ -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();
|
|
@@ -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' }));
|
|
@@ -3,6 +3,16 @@ export type CronOverlap = 'skip' | 'queue' | 'replace';
|
|
|
3
3
|
export type CronOnOutput = 'silent' | 'on-failure' | 'always' | 'on-change';
|
|
4
4
|
export type CronState = 'active' | 'paused';
|
|
5
5
|
export type CronRunState = 'idle' | 'running';
|
|
6
|
+
/** The decoded delivery target of a stored cron sink. */
|
|
7
|
+
export type ParsedCronSink = {
|
|
8
|
+
kind: 'node';
|
|
9
|
+
node: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'spawn';
|
|
12
|
+
spawn: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'human';
|
|
15
|
+
} | null;
|
|
6
16
|
/** A hydrated `crons` row. */
|
|
7
17
|
export interface Cron {
|
|
8
18
|
cron_id: string;
|
|
@@ -96,6 +106,10 @@ export interface CronRunRecord {
|
|
|
96
106
|
/** Ring bound for `cron_runs`: recordCronRun prunes each cron's history to
|
|
97
107
|
* its most recent this-many rows. */
|
|
98
108
|
export declare const CRON_RUN_RING = 20;
|
|
109
|
+
/** Decode the stored cron sink JSON. A malformed or unrecognised value has no sink. */
|
|
110
|
+
export declare function parseCronSink(sinkJson: string): ParsedCronSink;
|
|
111
|
+
/** Render a stored cron sink as the arm-time CLI spec, or null when it has no destination. */
|
|
112
|
+
export declare function cronSinkDisplay(sinkJson: string): string | null;
|
|
99
113
|
/** The ONLY sanctioned way to signal a cron run's process group from a stored
|
|
100
114
|
* row. Re-captures the CURRENT identity of `run_pid` and signals only when it
|
|
101
115
|
* MATCHES the fingerprint recorded at lease acquisition. Returns whether the
|
|
@@ -180,10 +194,12 @@ export declare function setCronState(cron_id: string, state: CronState): boolean
|
|
|
180
194
|
* previous run's stdout. Written after EVERY settled run of an on-change
|
|
181
195
|
* cron, delivered or not. */
|
|
182
196
|
export declare function setCronLastOutputHash(cron_id: string, hash: string): void;
|
|
183
|
-
/** True when
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
|
|
197
|
+
/** True when a cron can still wake this node. An active deadline is a wake
|
|
198
|
+
* source by its anchor; output delivery is a wake source by its decoded node
|
|
199
|
+
* sink. Scheduled rows must remain active and unexpired, while a leased run
|
|
200
|
+
* remains eligible through settlement even if it has since been paused or
|
|
201
|
+
* expired. */
|
|
202
|
+
export declare function hasPendingCronWake(nodeId: string): boolean;
|
|
187
203
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
188
204
|
* A real wake consumes the wait at its delivery boundary or, as a dormant
|
|
189
205
|
* fallback, in reviveNode; recovery replacement is not a wake. Terminal
|
|
@@ -60,6 +60,36 @@ function cronFrom(r) {
|
|
|
60
60
|
updated: r['updated'],
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
+
/** Decode the stored cron sink JSON. A malformed or unrecognised value has no sink. */
|
|
64
|
+
export function parseCronSink(sinkJson) {
|
|
65
|
+
try {
|
|
66
|
+
const sink = JSON.parse(sinkJson);
|
|
67
|
+
if (typeof sink['node'] === 'string')
|
|
68
|
+
return { kind: 'node', node: sink['node'] };
|
|
69
|
+
if (typeof sink['spawn'] === 'string')
|
|
70
|
+
return { kind: 'spawn', spawn: sink['spawn'] };
|
|
71
|
+
if (sink['human'] === true)
|
|
72
|
+
return { kind: 'human' };
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// A malformed stored value cannot deliver but remains listable and runnable.
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/** Render a stored cron sink as the arm-time CLI spec, or null when it has no destination. */
|
|
80
|
+
export function cronSinkDisplay(sinkJson) {
|
|
81
|
+
const sink = parseCronSink(sinkJson);
|
|
82
|
+
if (sink === null)
|
|
83
|
+
return null;
|
|
84
|
+
switch (sink.kind) {
|
|
85
|
+
case 'node':
|
|
86
|
+
return `node:${sink.node}`;
|
|
87
|
+
case 'spawn':
|
|
88
|
+
return `spawn:${sink.spawn}`;
|
|
89
|
+
case 'human':
|
|
90
|
+
return 'human';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
63
93
|
// Signal safety
|
|
64
94
|
/** The ONLY sanctioned way to signal a cron run's process group from a stored
|
|
65
95
|
* row. Re-captures the CURRENT identity of `run_pid` and signals only when it
|
|
@@ -261,13 +291,30 @@ export function setCronLastOutputHash(cron_id, hash) {
|
|
|
261
291
|
.prepare('UPDATE crons SET last_output_hash = ?, updated = ? WHERE cron_id = ?')
|
|
262
292
|
.run(hash, new Date().toISOString(), cron_id);
|
|
263
293
|
}
|
|
264
|
-
/** True when
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
294
|
+
/** True when a cron can still wake this node. An active deadline is a wake
|
|
295
|
+
* source by its anchor; output delivery is a wake source by its decoded node
|
|
296
|
+
* sink. Scheduled rows must remain active and unexpired, while a leased run
|
|
297
|
+
* remains eligible through settlement even if it has since been paused or
|
|
298
|
+
* expired. */
|
|
299
|
+
export function hasPendingCronWake(nodeId) {
|
|
300
|
+
const rows = openDb().prepare(`SELECT anchor_node, cancel_on_wake, sink
|
|
301
|
+
FROM crons
|
|
302
|
+
WHERE (anchor_node = ? AND cancel_on_wake = 1 AND state = 'active')
|
|
303
|
+
OR (
|
|
304
|
+
on_output IN ('always', 'on-change')
|
|
305
|
+
AND (
|
|
306
|
+
run_state = 'running'
|
|
307
|
+
OR (run_state = 'idle' AND state = 'active' AND (expires_at IS NULL OR expires_at > ?))
|
|
308
|
+
)
|
|
309
|
+
)`).all(nodeId, new Date().toISOString());
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
if (row['anchor_node'] === nodeId && Number(row['cancel_on_wake']) !== 0)
|
|
312
|
+
return true;
|
|
313
|
+
const sink = parseCronSink(row['sink']);
|
|
314
|
+
if (sink?.kind === 'node' && sink.node === nodeId)
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
271
318
|
}
|
|
272
319
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
273
320
|
* A real wake consumes the wait at its delivery boundary or, as a dormant
|
|
@@ -145,6 +145,8 @@ export interface MemoryResolutionOpts extends MemoryCandidateOpts {
|
|
|
145
145
|
* boot render, on-read resolvedDocs, the slash-command snapshot, and
|
|
146
146
|
* persona resolution, which stay on the flat ancestor+profile stack. */
|
|
147
147
|
includeDescendants?: boolean;
|
|
148
|
+
/** Report when bounded descendant discovery omits an owner. Defaults to the inverse of `quiet`, so callers can preserve quiet malformed-document handling while still making an incomplete corpus visible to their caller. */
|
|
149
|
+
reportDiscovery?: boolean;
|
|
148
150
|
}
|
|
149
151
|
/** A loaded corpus and the exact-identity queries over it. */
|
|
150
152
|
export interface MemoryView {
|
|
@@ -195,7 +197,7 @@ export declare function openProjectMemoryStore(ownerDir: string): MemoryStoreDes
|
|
|
195
197
|
/** Every store a target sees, in precedence order: node, each project store
|
|
196
198
|
* nearest-first (its enabled plugins after it), then profile, user, builtin.
|
|
197
199
|
* One real store reached twice contributes once, at its first position. */
|
|
198
|
-
export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean,
|
|
200
|
+
export declare function memoryStoresInPrecedence(target: MemoryTarget, scope?: MemoryScope, includeDescendants?: boolean, reportDiscovery?: boolean): MemoryStoreDescriptor[];
|
|
199
201
|
/** The native (non-plugin) MOUNTED store dirs in resolution precedence.
|
|
200
202
|
* Addresses a store directly when the document itself may be absent, which is
|
|
201
203
|
* how a DELETED doc's revision log is still found: history outlives the doc,
|
|
@@ -193,7 +193,7 @@ export function openProjectMemoryStore(ownerDir) {
|
|
|
193
193
|
/** Every store a target sees, in precedence order: node, each project store
|
|
194
194
|
* nearest-first (its enabled plugins after it), then profile, user, builtin.
|
|
195
195
|
* One real store reached twice contributes once, at its first position. */
|
|
196
|
-
export function memoryStoresInPrecedence(target, scope, includeDescendants = false,
|
|
196
|
+
export function memoryStoresInPrecedence(target, scope, includeDescendants = false, reportDiscovery = true) {
|
|
197
197
|
const out = [];
|
|
198
198
|
const projectStores = (crtrRoot, projectMemory) => {
|
|
199
199
|
out.push(projectStoreDescriptor(dirname(crtrRoot), join(crtrRoot, 'memory'), projectMemory));
|
|
@@ -211,7 +211,7 @@ export function memoryStoresInPrecedence(target, scope, includeDescendants = fal
|
|
|
211
211
|
// nested one. They carry the neutral ceiling — no profile entry names
|
|
212
212
|
// them, and descendant discovery is off for every automatic delivery path.
|
|
213
213
|
if (includeDescendants) {
|
|
214
|
-
for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r),
|
|
214
|
+
for (const root of descendantStoreRoots(annotated.map(({ root: r }) => r), reportDiscovery)) {
|
|
215
215
|
projectStores(root, NEUTRAL_PROJECT_MEMORY);
|
|
216
216
|
}
|
|
217
217
|
}
|
|
@@ -438,7 +438,7 @@ function makeView(stores, docs, scope) {
|
|
|
438
438
|
* with its migration remedy and contributes nothing. */
|
|
439
439
|
export function loadMemoryTargetView(target, opts = {}) {
|
|
440
440
|
const quiet = opts.quiet ?? false;
|
|
441
|
-
const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, quiet);
|
|
441
|
+
const stores = memoryStoresInPrecedence(target, opts.scope, opts.includeDescendants ?? false, opts.reportDiscovery ?? !quiet);
|
|
442
442
|
const docs = [];
|
|
443
443
|
for (const store of stores) {
|
|
444
444
|
if (store.mountStatus === 'absent')
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* excluding centrally gives the resolver and lint the identical call with no
|
|
10
10
|
* duplicated dedupe logic.
|
|
11
11
|
*/
|
|
12
|
-
export declare function descendantStoreRoots(ancestorRoots: string[],
|
|
12
|
+
export declare function descendantStoreRoots(ancestorRoots: string[], reportBudgetExceeded?: boolean): string[];
|
|
13
13
|
/** Complete descendant-store discovery for state migrations. Unlike the
|
|
14
14
|
* interactive addressing lane, this filesystem walk has no time or depth
|
|
15
15
|
* budget, crosses embedded repositories, admits plugin-only `.crouter` roots,
|
|
@@ -39,7 +39,7 @@ const cache = new Map();
|
|
|
39
39
|
* excluding centrally gives the resolver and lint the identical call with no
|
|
40
40
|
* duplicated dedupe logic.
|
|
41
41
|
*/
|
|
42
|
-
export function descendantStoreRoots(ancestorRoots,
|
|
42
|
+
export function descendantStoreRoots(ancestorRoots, reportBudgetExceeded = true) {
|
|
43
43
|
const key = [...ancestorRoots].sort().join('\n');
|
|
44
44
|
const cached = cache.get(key);
|
|
45
45
|
if (cached !== undefined)
|
|
@@ -56,8 +56,8 @@ export function descendantStoreRoots(ancestorRoots, quiet = false) {
|
|
|
56
56
|
owners.push(owner);
|
|
57
57
|
}
|
|
58
58
|
const tripBudget = (owner) => {
|
|
59
|
-
if (
|
|
60
|
-
warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — nested stores
|
|
59
|
+
if (reportBudgetExceeded) {
|
|
60
|
+
warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — this result may omit nested stores. To read a document from a known nested store, pass its owner as \`--dir <owner>\`; that exact-store read bypasses discovery.`);
|
|
61
61
|
}
|
|
62
62
|
};
|
|
63
63
|
const found = [];
|
|
@@ -237,5 +237,5 @@ export function buildIdentityAssertion(nodeId, kind, mode, forkFrom, sourceLabel
|
|
|
237
237
|
export function worktreeNote(worktree) {
|
|
238
238
|
if (worktree?.state !== 'open')
|
|
239
239
|
return '';
|
|
240
|
-
return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here.
|
|
240
|
+
return `Branch: \`${worktree.branch}\`\nWorktree: \`${worktree.path}\`\nBase: \`${worktree.base_ref}\` @ \`${worktree.base_sha}\`\nCommit work here. Before finishing, land it with \`crtr node worktree close\` or keep the checkout and branch when the worktree is clean and its exact tip exists on any branch on origin. Auto-drop applies only when the worktree is clean (no uncommitted changes) and nothing remains to land (zero commits ahead of the base, or its commits are already contained in the base); otherwise, unlanded commits or uncommitted changes block finalization.`;
|
|
241
241
|
}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Closing a node tears down the focused node and every descendant it
|
|
4
4
|
// EXCLUSIVELY owns, walking DOWN the subscribes_to spine (subscriptionsOf = a
|
|
5
|
-
// node's reports/children).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// node's reports/children). A node that never produced substantive assistant
|
|
6
|
+
// output is reaped and cannot be revived. Streaming nodes and nodes with an open
|
|
7
|
+
// managed worktree are retained; other closed nodes keep their pi session,
|
|
8
|
+
// canvas edges, and on-disk state for a later revive (`crtr node lifecycle
|
|
9
|
+
// revive` / focus → `pi --session <id>`).
|
|
8
10
|
//
|
|
9
11
|
// Per node, in this order — the order matters twice:
|
|
10
12
|
//
|
|
@@ -171,9 +173,9 @@ export function closeNode(rootId, opts = {}) {
|
|
|
171
173
|
// Surviving managers captured BEFORE any teardown — a reap (below) deletes
|
|
172
174
|
// this node's edges, so the step-4 fan-out must read them up front.
|
|
173
175
|
const survivors = subscribersOf(id).filter((s) => !closing.has(s.node_id));
|
|
174
|
-
// 0)
|
|
175
|
-
//
|
|
176
|
-
//
|
|
176
|
+
// 0) A node with no substantive assistant output is a useless shell — don't
|
|
177
|
+
// park it as a canceled husk; reap it outright (engine + viewer + row +
|
|
178
|
+
// dir). reapIfEmpty handles the teardown; when it fires we
|
|
177
179
|
// skip the cancel transition + resume notice (the node is gone) but still
|
|
178
180
|
// fan the "child gone" wake out to surviving managers below.
|
|
179
181
|
if (!reapIfEmpty(id)) {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// manual cleanup.
|
|
24
24
|
// • otherwise → a TERMINAL node with nothing live to wait for and no
|
|
25
25
|
// final pushed. Re-prompt it to finish or escalate.
|
|
26
|
-
import { hasActiveLiveSubscription, hasLiveMessageWait,
|
|
26
|
+
import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCronWake, getNode, contextDir } from '../canvas/index.js';
|
|
27
27
|
import { activeBackgroundBashJobs } from '../bash-jobs.js';
|
|
28
28
|
import { formatCard, formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
|
|
29
29
|
import { readOutputRequest } from './structured-output.js';
|
|
@@ -41,12 +41,12 @@ function formatInvalidStructuredOutputReprompt(error) {
|
|
|
41
41
|
`After resolving the file, retry with \`crtr push result\` (or \`crtr push result --decline "<reason>" --code <token>\` when the schema cannot be honestly satisfied). ` +
|
|
42
42
|
`If this is unexpected, escalate to the user using \`crtr human send\`.`);
|
|
43
43
|
}
|
|
44
|
-
/** The wake sources that legitimize a terminal node's dormancy: a pending
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
/** The wake sources that legitimize a terminal node's dormancy: a pending cron
|
|
45
|
+
* wake, an active subscription to a live publisher, an explicit live controller
|
|
46
|
+
* wait, or a live background bash job whose completion sends an inbox message.
|
|
47
|
+
* Parentage alone is deliberately absent. */
|
|
48
48
|
function wakeCapableWait(nodeId, backgroundJobsRunning) {
|
|
49
|
-
if (
|
|
49
|
+
if (hasPendingCronWake(nodeId))
|
|
50
50
|
return 'scheduled';
|
|
51
51
|
if (hasActiveLiveSubscription(nodeId)
|
|
52
52
|
|| hasLiveMessageWait(nodeId)
|