@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.
@@ -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, readMemoryDocContent, resolveMemoryCandidates, resolveMemoryDocs, } from '../../core/memory-resolver.js';
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 must not turn malformed stores elsewhere in the corpus
125
- // into warnings about the requested document. `memory lint` owns that
126
- // corpus health report; resolution still includes every candidate.
127
- listingByName = linkByName = docsByName(listAllMemoryDocs(selector.scope, true, true));
128
- try {
129
- set = resolveMemoryCandidates(nameRaw, resolveOpts);
130
- }
131
- catch (e) {
132
- if (!(e instanceof CrtrError && e.code === 'not_found'))
133
- throw e;
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 (revivable)',
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. Windows die but nothing is deleted — each closed node keeps its 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
+ 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 them all canceled, and leave each a notice it reads on resume. A descendant still subscribed to by a manager outside the subtree is left running. Nothing is deleted: every closed node keeps its pi session and can be revived later (`crtr node lifecycle revive`)',
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 the node and its exclusive descendants `canceled` and clears intent (the daemon never revives a canceled node).',
92
- 'Kills each closed node\'s tmux window; their pi sessions and canvas edges persist for a later revive.',
93
- 'Appends a cancellation notice to each closed node\'s inbox, surfaced on its next resume.',
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) => {
@@ -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 owned a managed worktree with zero commits ahead of its base and no uncommitted changes — the server auto-dropped it instead of requiring `node worktree close` first (see worktree_auto_dropped_path).' },
164
- { name: 'worktree_auto_dropped_path', type: 'string', required: false, constraint: 'Present only alongside worktree_auto_dropped: the checkout path that was dropped.' },
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 auto-drops it 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.',
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\nAuto-dropped a clean, zero-commit managed worktree at ${String(r['worktree_auto_dropped_path'])} — nothing to land.` : '';
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, hasPendingCancelOnWakeCron } from '../../canvas/crons.js';
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(hasPendingCancelOnWakeCron(recovered), true, 'the daemon replacing a dead broker is not a wake — the deadline survives so the wait can still settle');
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(hasPendingCancelOnWakeCron(woken), false, 'a wake revive won the race and cancels the deadline');
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 this node has a pending cancel-on-wake cron. The stop guard uses
184
- * this focused deadline check to distinguish legitimate clock dormancy from a
185
- * terminal node that stopped without finishing. */
186
- export declare function hasPendingCancelOnWakeCron(anchor_node: string): boolean;
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 this node has a pending cancel-on-wake cron. The stop guard uses
265
- * this focused deadline check to distinguish legitimate clock dormancy from a
266
- * terminal node that stopped without finishing. */
267
- export function hasPendingCancelOnWakeCron(anchor_node) {
268
- return (openDb()
269
- .prepare("SELECT 1 FROM crons WHERE anchor_node = ? AND cancel_on_wake = 1 AND state = 'active' LIMIT 1")
270
- .get(anchor_node) !== undefined);
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, quiet?: boolean): MemoryStoreDescriptor[];
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, quiet = 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), quiet)) {
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[], quiet?: boolean): 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, quiet = false) {
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 (!quiet) {
60
- warn(`memory: nested-store discovery under ${owner} exceeded its ${TIME_BUDGET_MS}ms budget — nested stores may be missing from this listing; register deep stores explicitly (add their dirs to the selected profile's projects)`);
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. Land it with \`crtr node worktree close\` before finishing. 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); unlanded commits (commits not already contained in the base) or uncommitted changes require an explicit close/landing decision.`;
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). Nothing is deleted: pi_session_id, the canvas
6
- // edges, and all on-disk state persist, so any closed node can later be revived
7
- // (`crtr node lifecycle revive` / focus → `pi --session <id>`). A close is a pause, not a reap.
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) An EMPTY node (engine never produced an assistant message) is a useless
175
- // shell — don't park it as a canceled husk, reap it outright (engine +
176
- // viewer + row + dir). reapIfEmpty handles the teardown; when it fires we
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, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
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
- * cancel-on-wake deadline, an active subscription to a live publisher, an
46
- * explicit live controller wait, or a live background bash job whose completion
47
- * sends an inbox message. Parentage alone is deliberately absent. */
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 (hasPendingCancelOnWakeCron(nodeId))
49
+ if (hasPendingCronWake(nodeId))
50
50
  return 'scheduled';
51
51
  if (hasActiveLiveSubscription(nodeId)
52
52
  || hasLiveMessageWait(nodeId)