@north-light/crouter 0.3.249 → 0.3.251
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/README.md +1 -1
- package/dist/api/__tests__/integration/client.test.js +28 -7
- package/dist/api/client.d.ts +1 -1
- package/dist/api/client.js +2 -2
- package/dist/api/dto/lifecycle.d.ts +3 -0
- package/dist/api/dto/messages.d.ts +4 -0
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/api/dto/reports.d.ts +7 -2
- package/dist/builtin-memory/00-runtime-base/01-escalation.md +1 -1
- package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +3 -0
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +4 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +4 -3
- package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
- package/dist/clients/attach/session/chrome-refresh.js +1 -0
- package/dist/clients/attach/viewer.js +532 -532
- package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.d.ts +1 -0
- package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +53 -0
- package/dist/clients/inbox/controller.js +32 -5
- package/dist/clients/inbox/tui/ansi.d.ts +3 -1
- package/dist/clients/inbox/tui/ansi.js +14 -10
- package/dist/commands/__tests__/api-client-cold-start-diagnostic.test.js +7 -7
- package/dist/commands/api-client.d.ts +2 -2
- package/dist/commands/api-client.js +5 -5
- package/dist/commands/canvas-browse.js +1 -1
- package/dist/commands/node/create.js +3 -1
- package/dist/commands/node/inspect.js +1 -0
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/commands/node/message.js +6 -4
- package/dist/commands/node/wait.js +1 -1
- package/dist/commands/push.js +36 -37
- package/dist/commands/sys/daemon.js +21 -8
- package/dist/commands/sys/setup-core.js +2 -2
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.d.ts +1 -0
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +84 -0
- package/dist/core/__tests__/broker-turn-admission.test.d.ts +1 -0
- package/dist/core/__tests__/broker-turn-admission.test.js +44 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +100 -1
- package/dist/core/__tests__/child-death-wake.test.js +11 -7
- package/dist/core/__tests__/collectible.test.d.ts +1 -0
- package/dist/core/__tests__/collectible.test.js +88 -0
- package/dist/core/__tests__/context-intro.test.js +4 -41
- package/dist/core/__tests__/daemon-boot.test.js +30 -14
- package/dist/core/__tests__/daemon-wedge.test.js +25 -19
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -2
- package/dist/core/__tests__/human-deliver.test.js +5 -5
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +65 -4
- package/dist/core/__tests__/integration/human-deliver-e2e.test.js +3 -3
- package/dist/core/__tests__/integration/revive.test.js +48 -0
- package/dist/core/__tests__/integration/spawn-root.test.js +36 -1
- package/dist/core/__tests__/integration/worktree-land.test.js +82 -3
- package/dist/core/__tests__/integration/worktree-reap.test.js +44 -6
- package/dist/core/__tests__/migration.test.js +30 -0
- package/dist/core/__tests__/passive-subscription.test.js +39 -1
- package/dist/core/__tests__/prune.test.js +12 -3
- package/dist/core/__tests__/relaunch-root.test.js +1 -1
- package/dist/core/__tests__/respawn-throttle.test.js +16 -146
- package/dist/core/__tests__/seam/broker-crash-teardown.test.js +23 -11
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +81 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -1
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +63 -0
- package/dist/core/__tests__/seam/self-close-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/seam/self-close-teardown.test.js +49 -0
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +18 -10
- package/dist/core/canvas/canvas.d.ts +9 -15
- package/dist/core/canvas/canvas.js +41 -47
- package/dist/core/canvas/crons.d.ts +5 -8
- package/dist/core/canvas/crons.js +5 -8
- package/dist/core/canvas/history.d.ts +5 -0
- package/dist/core/canvas/history.js +12 -1
- package/dist/core/canvas/migrations.js +58 -1
- package/dist/core/canvas/pid.d.ts +1 -1
- package/dist/core/canvas/pid.js +5 -2
- package/dist/core/canvas/types.d.ts +4 -0
- package/dist/core/feed/feed.d.ts +4 -0
- package/dist/core/feed/feed.js +13 -8
- package/dist/core/feed/inbox.d.ts +2 -1
- package/dist/core/feed/inbox.js +22 -3
- package/dist/core/git.d.ts +3 -0
- package/dist/core/git.js +3 -1
- package/dist/core/human/feedback-companion.js +1 -0
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/companion.js +1 -0
- package/dist/core/review/realize.js +1 -0
- package/dist/core/runtime/broker/auth-reload.d.ts +2 -0
- package/dist/core/runtime/broker/auth-reload.js +9 -3
- package/dist/core/runtime/broker/engine-drive.d.ts +2 -0
- package/dist/core/runtime/broker/engine-drive.js +39 -23
- package/dist/core/runtime/broker/event-projection.d.ts +4 -0
- package/dist/core/runtime/broker/event-projection.js +43 -23
- package/dist/core/runtime/broker/fault-retry.d.ts +9 -0
- package/dist/core/runtime/broker/fault-retry.js +94 -8
- package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
- package/dist/core/runtime/broker/frame-dispatch.js +4 -1
- package/dist/core/runtime/broker/held-deferred-inbox.d.ts +9 -0
- package/dist/core/runtime/broker/held-deferred-inbox.js +8 -0
- package/dist/core/runtime/broker/inbox.js +4 -0
- package/dist/core/runtime/broker/rebind.js +18 -0
- package/dist/core/runtime/broker/turn-admission.d.ts +11 -0
- package/dist/core/runtime/broker/turn-admission.js +31 -0
- package/dist/core/runtime/broker/turn-ignition.d.ts +2 -0
- package/dist/core/runtime/broker/turn-ignition.js +25 -15
- package/dist/core/runtime/broker.js +21 -2
- package/dist/core/runtime/close.d.ts +11 -9
- package/dist/core/runtime/close.js +23 -19
- package/dist/core/runtime/fault.d.ts +21 -0
- package/dist/core/runtime/fault.js +129 -19
- package/dist/core/runtime/fleet.d.ts +0 -6
- package/dist/core/runtime/host.d.ts +7 -1
- package/dist/core/runtime/host.js +20 -2
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +24 -1
- package/dist/core/runtime/placement.d.ts +6 -6
- package/dist/core/runtime/placement.js +15 -8
- package/dist/core/runtime/reset.js +2 -2
- package/dist/core/runtime/revive.js +40 -4
- package/dist/core/runtime/spawn.d.ts +3 -0
- package/dist/core/runtime/spawn.js +1 -0
- package/dist/core/runtime/warm-pool.d.ts +4 -0
- package/dist/core/runtime/warm-pool.js +2 -0
- package/dist/core/worktree.d.ts +4 -3
- package/dist/core/worktree.js +73 -27
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.d.ts +1 -0
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +81 -0
- package/dist/daemon/api/__tests__/reopen-delivery.test.js +38 -0
- package/dist/daemon/api/__tests__/seam/api-server.test.js +1 -0
- package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +13 -2
- package/dist/daemon/api/handlers/bash-jobs.js +2 -0
- package/dist/daemon/api/handlers/broker-ops.js +23 -2
- package/dist/daemon/api/handlers/human.js +1 -1
- package/dist/daemon/api/handlers/messages.js +11 -2
- package/dist/daemon/api/handlers/nodes.js +13 -1
- package/dist/daemon/api/handlers/reports.js +14 -6
- package/dist/daemon/api/map.js +1 -0
- package/dist/daemon/api/server.d.ts +9 -12
- package/dist/daemon/api/server.js +30 -19
- package/dist/daemon/companion-retire.js +3 -9
- package/dist/daemon/cron/sinks.js +7 -2
- package/dist/daemon/crtrd.js +4 -1
- package/dist/daemon/fleet.d.ts +2 -16
- package/dist/daemon/fleet.js +63 -150
- package/dist/daemon/human/finish.js +2 -0
- package/dist/daemon/manage.d.ts +11 -0
- package/dist/daemon/manage.js +32 -3
- package/dist/daemon/messaging/node-message.js +7 -1
- package/dist/daemon/reconcilers/bash-deadline.js +2 -0
- package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -0
- package/dist/daemon/reconcilers/broker-supervision.js +22 -9
- package/dist/daemon/reconcilers/live-obligation.d.ts +16 -6
- package/dist/daemon/reconcilers/live-obligation.js +18 -6
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +15 -4
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +34 -3
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -1
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +11 -2
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +14 -7
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +3 -3
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
- package/dist/pi-extensions/canvas-context-intro.d.ts +3 -34
- package/dist/pi-extensions/canvas-context-intro.js +4 -76
- package/dist/pi-extensions/canvas-inbox-watcher.js +105 -4
- package/dist/pi-extensions/canvas-review-boundary.d.ts +3 -24
- package/dist/pi-extensions/canvas-review-boundary.js +4 -29
- package/dist/pi-extensions/canvas-stophook.js +1 -6
- package/dist/shared/birth-announcement.d.ts +12 -0
- package/dist/shared/birth-announcement.js +25 -0
- package/dist/shared/generated-context.d.ts +8 -4
- package/dist/shared/generated-context.js +15 -9
- package/package.json +4 -4
- package/runtime.lock.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +0 -254
- package/dist/pi-extensions/truncate.d.ts +0 -5
- package/dist/pi-extensions/truncate.js +0 -14
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
// its helpers in isolation.
|
|
8
8
|
import { test, before, after, afterEach } from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
13
13
|
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
14
14
|
import { defaultModelLaddersConfig } from '../../../types.js';
|
|
15
|
-
import { readFault } from '../../runtime/fault.js';
|
|
15
|
+
import { admitProviderRetryEpisode, readFault, readProviderRetryEpisode } from '../../runtime/fault.js';
|
|
16
16
|
import { buildPiArgv, normalizeModel } from '../../runtime/launch.js';
|
|
17
17
|
import { encodeFrame } from '../../runtime/broker-protocol.js';
|
|
18
18
|
const MODEL_LADDERS = defaultModelLaddersConfig();
|
|
@@ -79,6 +79,18 @@ function agentStartCount(id) {
|
|
|
79
79
|
return 0;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
async function crashAndRespawn(id, boots) {
|
|
83
|
+
const pid = h.node(id)?.pi_pid;
|
|
84
|
+
assert.ok(pid != null, 'the broker has a live pid before its physical death');
|
|
85
|
+
process.kill(pid, 'SIGKILL');
|
|
86
|
+
await h.awaitFleetExit(id);
|
|
87
|
+
await h.tick();
|
|
88
|
+
const deferred = h.node(id);
|
|
89
|
+
assert.ok(deferred.respawn_not_before != null, 'the daemon records respawn backoff after the broker exit');
|
|
90
|
+
await h.tick(Date.parse(deferred.respawn_not_before) + 1);
|
|
91
|
+
await h.awaitBoot(id, { minCount: boots + 1 });
|
|
92
|
+
await h.waitFor(() => existsSync(h.brokerSock(id)), { label: 'replacement broker listener bound' });
|
|
93
|
+
}
|
|
82
94
|
before(async () => {
|
|
83
95
|
h = await createHeadlessHarness({ sessionPrefix: 'crtr-provider-retry' });
|
|
84
96
|
});
|
|
@@ -130,6 +142,73 @@ test('a managed-cooling terminal authors a rate-limit auto fault whose deadline
|
|
|
130
142
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
131
143
|
assert.equal(agentStartCount(child), baselineStarts + 1, 'the fault-retry timer re-drives the turn exactly once');
|
|
132
144
|
});
|
|
145
|
+
test('a crashed broker re-drives only its pending provider episode, never admitted or invalidated episodes', { timeout: 90_000 }, async () => {
|
|
146
|
+
const parent = h.spawnRoot('durable provider episode parent');
|
|
147
|
+
seedPool('anthropic', false);
|
|
148
|
+
seedPool('openai-codex', false);
|
|
149
|
+
const pendingNode = await h.spawnHeadlessChild(parent, 'pending provider episode', { model: OPENAI_STRONG.spec });
|
|
150
|
+
await h.waitFor(() => agentStartCount(pendingNode) >= 1, { label: 'pending episode fixture started' });
|
|
151
|
+
const pendingStarts = agentStartCount(pendingNode);
|
|
152
|
+
h.fakeCmd(pendingNode, {
|
|
153
|
+
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 2_000,
|
|
154
|
+
});
|
|
155
|
+
const pending = await h.waitFor(() => readProviderRetryEpisode(pendingNode)?.state === 'pending' ? readProviderRetryEpisode(pendingNode) : null, { label: 'pending provider episode persisted before crash' });
|
|
156
|
+
assert.equal(pending.sessionFile, realpathSync(h.node(pendingNode).pi_session_file), 'the pending episode is tied to its transcript path');
|
|
157
|
+
await crashAndRespawn(pendingNode, h.bootCount(pendingNode));
|
|
158
|
+
await h.waitFor(() => agentStartCount(pendingNode) >= pendingStarts + 1, { label: 'replacement re-drove the pending episode' });
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
160
|
+
assert.equal(agentStartCount(pendingNode), pendingStarts + 1, 'a pending episode re-drives exactly once after broker death');
|
|
161
|
+
const admittedNode = await h.spawnHeadlessChild(parent, 'admitted provider episode', { model: OPENAI_STRONG.spec });
|
|
162
|
+
await h.waitFor(() => agentStartCount(admittedNode) >= 1, { label: 'admitted episode fixture started' });
|
|
163
|
+
const admittedStarts = agentStartCount(admittedNode);
|
|
164
|
+
h.fakeCmd(admittedNode, {
|
|
165
|
+
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
|
|
166
|
+
});
|
|
167
|
+
const admittedPending = await h.waitFor(() => readProviderRetryEpisode(admittedNode)?.state === 'pending' ? readProviderRetryEpisode(admittedNode) : null, { label: 'admitted episode pending record persisted' });
|
|
168
|
+
const admittedFault = readFault(admittedNode);
|
|
169
|
+
assert.ok(admittedFault !== null, 'the pending episode retains its retry fault before admission');
|
|
170
|
+
assert.equal(admitProviderRetryEpisode(admittedNode, admittedPending.sessionFile, admittedFault), true, 'the dispatch boundary durably admits the matching episode');
|
|
171
|
+
assert.equal(readProviderRetryEpisode(admittedNode)?.state, 'admitted');
|
|
172
|
+
await crashAndRespawn(admittedNode, h.bootCount(admittedNode));
|
|
173
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
174
|
+
assert.equal(agentStartCount(admittedNode), admittedStarts, 'an admitted episode is never replayed by replacement startup');
|
|
175
|
+
const invalidatedNode = await h.spawnHeadlessChild(parent, 'invalidated provider episode', { model: OPENAI_STRONG.spec });
|
|
176
|
+
await h.waitFor(() => agentStartCount(invalidatedNode) >= 1, { label: 'invalidated episode fixture started' });
|
|
177
|
+
const invalidatedStarts = agentStartCount(invalidatedNode);
|
|
178
|
+
h.fakeCmd(invalidatedNode, {
|
|
179
|
+
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
|
|
180
|
+
});
|
|
181
|
+
await h.waitFor(() => readProviderRetryEpisode(invalidatedNode)?.state === 'pending' ? true : null, { label: 'invalidated episode pending record persisted' });
|
|
182
|
+
const controller = await kit.attach(invalidatedNode, 'controller', 'invalidated-provider-episode');
|
|
183
|
+
controller.send({ type: 'new_session' });
|
|
184
|
+
await controller.waitFrame((frame) => frame.type === 'ack' && frame.for === 'new_session', 'replacement session acknowledged');
|
|
185
|
+
assert.equal(readFault(invalidatedNode), null, 'replacement discards the old daemon-owned auto fault instead of inheriting its anchor');
|
|
186
|
+
await h.waitFor(() => readProviderRetryEpisode(invalidatedNode)?.state === 'invalidated' ? true : null, { label: 'rebind durably invalidated the old episode' });
|
|
187
|
+
controller.close();
|
|
188
|
+
await crashAndRespawn(invalidatedNode, h.bootCount(invalidatedNode));
|
|
189
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
190
|
+
assert.equal(agentStartCount(invalidatedNode), invalidatedStarts, 'an invalidated rebound-session episode is never replayed by replacement startup');
|
|
191
|
+
const cyclingNode = await h.spawnHeadlessChild(parent, 'fresh-cycle provider episode', { model: OPENAI_STRONG.spec });
|
|
192
|
+
await h.waitFor(() => agentStartCount(cyclingNode) >= 1, { label: 'fresh-cycle episode fixture started' });
|
|
193
|
+
h.fakeCmd(cyclingNode, {
|
|
194
|
+
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
|
|
195
|
+
});
|
|
196
|
+
await h.waitFor(() => readProviderRetryEpisode(cyclingNode)?.state === 'pending' ? true : null, { label: 'fresh-cycle episode pending record persisted' });
|
|
197
|
+
const cyclingBoots = h.bootCount(cyclingNode);
|
|
198
|
+
const yieldResult = h.cli(cyclingNode, ['node', 'yield', 'begin a fresh cycle']);
|
|
199
|
+
assert.equal(yieldResult.code, 0, `node yield should succeed\n--stderr--\n${yieldResult.stderr}`);
|
|
200
|
+
const cyclingPid = h.node(cyclingNode)?.pi_pid;
|
|
201
|
+
assert.ok(cyclingPid != null, 'the refresh-pending broker remains live until its physical death');
|
|
202
|
+
process.kill(cyclingPid, 'SIGKILL');
|
|
203
|
+
await h.awaitFleetExit(cyclingNode);
|
|
204
|
+
await h.tick();
|
|
205
|
+
const cyclingBackoff = h.node(cyclingNode);
|
|
206
|
+
assert.ok(cyclingBackoff.respawn_not_before != null, 'the fresh-cycle relaunch receives daemon backoff');
|
|
207
|
+
await h.tick(Date.parse(cyclingBackoff.respawn_not_before) + 1);
|
|
208
|
+
await h.awaitBoot(cyclingNode, { minCount: cyclingBoots + 1 });
|
|
209
|
+
await h.waitFor(() => existsSync(h.brokerSock(cyclingNode)), { label: 'fresh-cycle replacement broker listener bound' });
|
|
210
|
+
assert.equal(readProviderRetryEpisode(cyclingNode)?.state, 'invalidated', 'new-cycle startup invalidates the old transcript episode');
|
|
211
|
+
});
|
|
133
212
|
test('reload_auth re-drives a parked fatal provider-auth turn exactly once', { timeout: 30_000 }, async () => {
|
|
134
213
|
const parent = h.spawnRoot('auth reload parent');
|
|
135
214
|
seedPool('anthropic', false);
|
|
@@ -194,7 +194,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
194
194
|
assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
|
|
195
195
|
const wake = readInboxSince(root).find((entry) => entry.from === nodeId && entry.data?.['reason'] === 'child-auto-done');
|
|
196
196
|
assert.ok(wake, 'the subscribing root is woken when the unattended clock completes the resident');
|
|
197
|
-
assert.match(wake.label, /
|
|
197
|
+
assert.match(wake.label, /submitted no final report/, 'the wake distinguishes automatic completion from a final report');
|
|
198
198
|
await h.tick();
|
|
199
199
|
assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive completed history');
|
|
200
200
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/held-deferred-human-prompt.test.js
|
|
2
|
+
//
|
|
3
|
+
// Regression: Pi loads canvas extensions through Jiti with moduleCache:false,
|
|
4
|
+
// while broker frame dispatch is native ESM. Deferred mail held by the Jiti
|
|
5
|
+
// watcher must still join the next human prompt driven by native broker code.
|
|
6
|
+
import { after, before, test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { appendInbox, readCursor } from '../../feed/inbox.js';
|
|
11
|
+
import { updateNode } from '../../canvas/index.js';
|
|
12
|
+
import { reviveNode } from '../../runtime/revive.js';
|
|
13
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
14
|
+
import { createAttachKit, delay, frameHas, tok } from '../helpers/broker-clients.js';
|
|
15
|
+
let h;
|
|
16
|
+
let root;
|
|
17
|
+
const kit = createAttachKit(() => h);
|
|
18
|
+
before(async () => {
|
|
19
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-held-deferred-prompt' });
|
|
20
|
+
root = h.spawnRoot('held deferred prompt seam root');
|
|
21
|
+
});
|
|
22
|
+
after(async () => {
|
|
23
|
+
kit.closeAll();
|
|
24
|
+
if (h !== undefined)
|
|
25
|
+
await h.dispose();
|
|
26
|
+
});
|
|
27
|
+
test('a Jiti-loaded watcher prepends its held deferred batch to a native broker human prompt', { timeout: 30_000 }, async () => {
|
|
28
|
+
const nodeId = h.fabricateBrokerNode({
|
|
29
|
+
id: 'held-deferred-human-prompt',
|
|
30
|
+
parent: root,
|
|
31
|
+
lifecycle: 'resident',
|
|
32
|
+
status: 'idle',
|
|
33
|
+
});
|
|
34
|
+
updateNode(nodeId, {
|
|
35
|
+
launch: {
|
|
36
|
+
extensions: [],
|
|
37
|
+
tools: [],
|
|
38
|
+
systemPrompt: '',
|
|
39
|
+
env: {
|
|
40
|
+
CRTR_WATCHER_TICK_MS: '20',
|
|
41
|
+
CRTR_WATCHER_DEBOUNCE_MS: '25',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
46
|
+
await h.awaitBoot(nodeId);
|
|
47
|
+
const entry = appendInbox(nodeId, {
|
|
48
|
+
from: 'child',
|
|
49
|
+
tier: 'deferred',
|
|
50
|
+
kind: 'update',
|
|
51
|
+
label: 'held Jiti deferred update',
|
|
52
|
+
});
|
|
53
|
+
await delay(150);
|
|
54
|
+
const customPath = join(h.home, 'nodes', nodeId, 'fake-pi.custom-messages.jsonl');
|
|
55
|
+
assert.equal(existsSync(customPath), false, 'deferred-only mail stays held and does not wake the broker');
|
|
56
|
+
const controller = await kit.attach(nodeId, 'controller', 'held-deferred-prompt-controller');
|
|
57
|
+
const prompt = tok('HELD-DEFERRED-HUMAN-PROMPT');
|
|
58
|
+
controller.send({ type: 'prompt', text: prompt });
|
|
59
|
+
await controller.waitFrame((frame) => frame.type === 'agent_end' && frameHas(frame, prompt), 'the human prompt turn');
|
|
60
|
+
const customMessages = readFileSync(customPath, 'utf8');
|
|
61
|
+
assert.match(customMessages, /held Jiti deferred update/, 'the Jiti extension instance joined its held batch before the native broker prompt');
|
|
62
|
+
assert.equal(readCursor(nodeId), entry.entry_id, 'agent_settled commits the joined handoff');
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Run with: npm run build && node --test dist/core/__tests__/seam/self-close-teardown.test.js
|
|
2
|
+
import { after, before, test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { isPidAlive } from '../../canvas/pid.js';
|
|
7
|
+
import { nodeDir } from '../../canvas/paths.js';
|
|
8
|
+
import { createHarness } from '../helpers/harness.js';
|
|
9
|
+
let h;
|
|
10
|
+
let root;
|
|
11
|
+
before(async () => {
|
|
12
|
+
h = await createHarness({ headless: true, sessionPrefix: 'crtr-self-close' });
|
|
13
|
+
root = h.spawnRoot('self-close teardown root');
|
|
14
|
+
});
|
|
15
|
+
after(async () => {
|
|
16
|
+
if (h !== undefined)
|
|
17
|
+
await h.dispose();
|
|
18
|
+
});
|
|
19
|
+
test('a node closes itself without killing its long-lived calling shell', { timeout: 60_000 }, async () => {
|
|
20
|
+
const id = await h.spawnHeadlessChild(root, 'self-close worker');
|
|
21
|
+
const brokerPid = h.node(id)?.pi_pid;
|
|
22
|
+
assert.ok(brokerPid != null && isPidAlive(brokerPid), 'precondition: worker broker is alive');
|
|
23
|
+
const session = h.node(id)?.pi_session_file;
|
|
24
|
+
assert.ok(session != null, 'worker has a session file');
|
|
25
|
+
writeFileSync(session, JSON.stringify({ type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'work' }] } }) + '\n');
|
|
26
|
+
const dir = nodeDir(id);
|
|
27
|
+
const resultPath = join(dir, 'self-close-result');
|
|
28
|
+
const shellPidPath = join(dir, 'self-close-shell.pid');
|
|
29
|
+
let shellPid;
|
|
30
|
+
try {
|
|
31
|
+
h.fakeCmd(id, { cmd: 'self_close' });
|
|
32
|
+
await h.waitFor(() => existsSync(resultPath), { label: 'self-close CLI result' });
|
|
33
|
+
assert.equal(readFileSync(resultPath, 'utf8').trim(), '0', 'the shell received a successful close result');
|
|
34
|
+
shellPid = Number(readFileSync(shellPidPath, 'utf8').trim());
|
|
35
|
+
assert.ok(Number.isInteger(shellPid) && shellPid > 0, 'fixture recorded the long-lived calling shell pid');
|
|
36
|
+
assert.equal(isPidAlive(shellPid), true, 'the calling shell survives the daemon teardown');
|
|
37
|
+
await h.waitFor(() => !isPidAlive(brokerPid), { label: 'self-closing broker exits' });
|
|
38
|
+
assert.equal(isPidAlive(shellPid), true, 'the calling shell remains alive after the broker is gone');
|
|
39
|
+
assert.equal(h.status(id), 'canceled', 'close writes the terminal transition before teardown');
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (shellPid !== undefined) {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(shellPid, 'SIGKILL');
|
|
45
|
+
}
|
|
46
|
+
catch { /* shell already exited */ }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
@@ -24,8 +24,8 @@ after(async () => {
|
|
|
24
24
|
if (h !== undefined)
|
|
25
25
|
await h.dispose();
|
|
26
26
|
});
|
|
27
|
-
// 30s (approved compiled-seam cap): three real broker boots plus the
|
|
28
|
-
// respawn
|
|
27
|
+
// 30s (approved compiled-seam cap): three real broker boots plus the durable
|
|
28
|
+
// respawn backoff's first ~5s delay before the successful retry.
|
|
29
29
|
test('yield-message survives one pre-session_start retry and clears on session_start', { timeout: 30_000 }, async () => {
|
|
30
30
|
const note = `Issue #146 pre-session_start replay ${FAIL_BEFORE_SESSION_START_ONCE} keeps the wake note`;
|
|
31
31
|
const id = await h.spawnHeadlessChild(root, 'headless worker — yield message transactional retry');
|
|
@@ -35,29 +35,37 @@ test('yield-message survives one pre-session_start retry and clears on session_s
|
|
|
35
35
|
const rc = h.cli(id, ['node', 'yield', note]);
|
|
36
36
|
assert.equal(rc.code, 0, `node yield should succeed\n--stderr--\n${rc.stderr}`);
|
|
37
37
|
// `node yield` arms intent=refresh; the current turn ending is what lets the
|
|
38
|
-
// stophook release the old broker.
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// stophook release the old broker. The supervision tick classifies that real
|
|
39
|
+
// ChildProcess exit, persists its backoff, and launches the fresh cycle once
|
|
40
|
+
// the backoff matures.
|
|
41
41
|
await h.stop(id);
|
|
42
42
|
await h.waitFor(() => !isPidAlive(oldPid), { label: 'yielded broker exited before session_start' });
|
|
43
|
+
await h.awaitFleetExit(id);
|
|
44
|
+
await h.tick();
|
|
45
|
+
const initialRefreshBackoff = h.node(id);
|
|
46
|
+
assert.equal(initialRefreshBackoff.respawn_failures, 1, 'the old broker death is durably counted');
|
|
47
|
+
assert.ok(initialRefreshBackoff.respawn_not_before != null, 'the initial refresh retry is durably deferred');
|
|
48
|
+
assert.equal(initialRefreshBackoff.pi_pid, null, 'classification clears the old broker pid');
|
|
49
|
+
await h.tick(Date.parse(initialRefreshBackoff.respawn_not_before) + 1);
|
|
43
50
|
// The initial refresh attempt launches and fails before session_start,
|
|
44
51
|
// leaving the note still present in node context.
|
|
45
52
|
const failedCyclePid = await h.waitFor(() => {
|
|
46
53
|
const pid = h.node(id)?.pi_pid;
|
|
47
54
|
return pid != null && pid !== oldPid ? pid : null;
|
|
48
|
-
}, { label: '
|
|
55
|
+
}, { label: 'tick-driven refresh attempt recorded its broker pid', timeoutMs: 30_000 });
|
|
49
56
|
await h.waitFor(() => !isPidAlive(failedCyclePid), { label: 'one-shot cycling broker died before session_start' });
|
|
50
57
|
// PID death precedes Node's ChildProcess `exit` delivery. Wait through the
|
|
51
|
-
// authoritative fleet barrier
|
|
52
|
-
// below reaches refresh arbitration
|
|
53
|
-
// The policy has only armed its backoff timer at this point; no recovery broker
|
|
54
|
-
// owns the slot yet.
|
|
58
|
+
// authoritative fleet barrier, then classify the failure once so the contender
|
|
59
|
+
// below reaches refresh arbitration while durable backoff is pending.
|
|
55
60
|
await h.awaitFleetExit(id);
|
|
61
|
+
await h.tick();
|
|
56
62
|
const afterFirstExit = h.node(id);
|
|
57
63
|
assert.equal(h.fleet.has(id), false, 'the failed broker has left the authoritative fleet before focus competes');
|
|
58
64
|
assert.equal(afterFirstExit?.cycles, baseCycles + 1, 'the first refresh attempt advances cycles');
|
|
59
65
|
assert.equal(afterFirstExit?.intent, 'refresh', 'the durable refresh intent still confronts the strict-resume contender');
|
|
60
66
|
assert.equal(afterFirstExit?.cycle_pending, true, 'cycle_pending remains true until session_start confirms');
|
|
67
|
+
assert.equal(afterFirstExit?.respawn_failures, 2, 'the failed refresh attempt is durably counted');
|
|
68
|
+
assert.ok(afterFirstExit?.respawn_not_before != null, 'focus competes while the durable refresh backoff is pending');
|
|
61
69
|
assert.equal(readYieldMessage(id), note, 'yield note stays present after pre-session_start failure');
|
|
62
70
|
assert.equal(existsSync(yieldMessagePath(id)), true, 'yield file survives the failed cycle');
|
|
63
71
|
// A real `surface node focus` on the node right now asks the revive arbiter for
|
|
@@ -334,6 +334,10 @@ export interface PruneResult {
|
|
|
334
334
|
pruned: PrunedNode[];
|
|
335
335
|
dryRun: boolean;
|
|
336
336
|
}
|
|
337
|
+
/** Whether a row may be removed by an automatic collection sweep. */
|
|
338
|
+
export declare function isCollectible(row: NodeRow, opts?: {
|
|
339
|
+
pastTtl?: boolean;
|
|
340
|
+
}): boolean;
|
|
337
341
|
/** {@link deleteNode}'s result: whether it actually deleted the node. */
|
|
338
342
|
export interface DeleteNodeResult {
|
|
339
343
|
deleted: boolean;
|
|
@@ -396,24 +400,14 @@ export declare function pruneNodes(opts: {
|
|
|
396
400
|
* store rather than per-node loss, and reaps nothing. Returns the reaped ids. */
|
|
397
401
|
export declare function reapGhostRows(): string[];
|
|
398
402
|
/** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE
|
|
399
|
-
* nodes plus every
|
|
400
|
-
* `max(maxNodes,
|
|
401
|
-
* TTL sweep can't (a canvas that never crosses the TTL still grows without
|
|
403
|
+
* nodes plus every row the collection authority retains, so the real retained
|
|
404
|
+
* floor is `max(maxNodes, retainedCount)`. Bounds the otherwise-unbounded growth
|
|
405
|
+
* the TTL sweep can't (a canvas that never crosses the TTL still grows without
|
|
402
406
|
* limit).
|
|
403
407
|
*
|
|
404
|
-
* Protected (never a prune candidate, regardless of recency rank): any
|
|
405
|
-
* non-terminal node — status active|idle — REGARDLESS of `pi_pid` liveness,
|
|
406
|
-
* or a node with an open managed worktree. A crashed-but-non-terminal row
|
|
407
|
-
* (dead pid, status still active/idle) must survive for daemon crash-recovery
|
|
408
|
-
* to resume, and an open worktree must retain its node ownership until
|
|
409
|
-
* `node worktree close`; neither is eligible for count-based pruning. The caller
|
|
410
|
-
* ($CRTR_NODE_ID) is also always protected.
|
|
411
|
-
*
|
|
412
408
|
* Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back
|
|
413
|
-
* to `created`); `target = max(maxNodes,
|
|
414
|
-
* ranked beyond `target` (0-indexed rank >= target) that is
|
|
415
|
-
* NOT the caller. Retained = all protected + the newest terminal nodes up to
|
|
416
|
-
* `target`.
|
|
409
|
+
* to `created`); `target = max(maxNodes, retainedCount)`; delete every node
|
|
410
|
+
* ranked beyond `target` (0-indexed rank >= target) that is collectible.
|
|
417
411
|
*
|
|
418
412
|
* Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`:
|
|
419
413
|
* transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun`
|
|
@@ -20,7 +20,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
|
|
|
20
20
|
* can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
|
|
21
21
|
const IDENTITY_KEYS = [
|
|
22
22
|
'node_id', 'name', 'description', 'title', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
|
|
23
|
-
'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
|
|
23
|
+
'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'creator', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
|
|
24
24
|
'pi_session_file', 'cycle_pending', 'model_override',
|
|
25
25
|
'launch', 'managed_worktree',
|
|
26
26
|
];
|
|
@@ -208,12 +208,13 @@ export function canonicalSessionFile(sessionFile) {
|
|
|
208
208
|
/** Resolve the crouter node that owns one exact Pi session file. */
|
|
209
209
|
export function findNodeBySessionFile(sessionFile) {
|
|
210
210
|
const canonical = canonicalSessionFile(sessionFile);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
// v36 makes this unique. LIMIT 1 also keeps the lookup total for a canvas
|
|
212
|
+
// opened during repair of an older duplicate projection: the deterministic
|
|
213
|
+
// oldest row remains the owner instead of turning a lookup into a crash.
|
|
214
|
+
const match = openDb()
|
|
215
|
+
.prepare('SELECT node_id FROM nodes WHERE pi_session_file = ? ORDER BY created, node_id LIMIT 1')
|
|
216
|
+
.get(canonical);
|
|
217
|
+
return match === undefined ? null : getNode(match.node_id);
|
|
217
218
|
}
|
|
218
219
|
/** The indexed row (from the db) — cheap for queries that don't need full meta. */
|
|
219
220
|
export function getRow(nodeId) {
|
|
@@ -839,6 +840,16 @@ export function rebuildIndex() {
|
|
|
839
840
|
function hasOpenManagedWorktree(nodeId) {
|
|
840
841
|
return getNode(nodeId)?.managed_worktree?.state === 'open';
|
|
841
842
|
}
|
|
843
|
+
/** Whether a row may be removed by an automatic collection sweep. */
|
|
844
|
+
export function isCollectible(row, opts = {}) {
|
|
845
|
+
if (!['done', 'dead', 'canceled'].includes(row.status)
|
|
846
|
+
|| row.pi_pid !== null
|
|
847
|
+
|| row.frozen_at !== null
|
|
848
|
+
|| hasOpenManagedWorktree(row.node_id)
|
|
849
|
+
|| row.node_id === envNodeId())
|
|
850
|
+
return false;
|
|
851
|
+
return opts.pastTtl === true || !['crashed', 'stranded', null].includes(row.terminal_reason ?? null);
|
|
852
|
+
}
|
|
842
853
|
/** Hard-delete ONE node: drop its row (the edges→nodes FK, ON DELETE CASCADE,
|
|
843
854
|
* GCs its edges) and remove its on-disk `nodes/<id>/` dir. The single-node
|
|
844
855
|
* analogue of {@link pruneNodes}, for IMMEDIATE reaping — e.g. an empty node the
|
|
@@ -905,14 +916,13 @@ export function pruneNodes(opts) {
|
|
|
905
916
|
const selfId = envNodeId() ?? '';
|
|
906
917
|
const db = openDb();
|
|
907
918
|
const terminal = db
|
|
908
|
-
.prepare(`SELECT
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
.all(cutoff)
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
created:
|
|
915
|
-
}));
|
|
919
|
+
.prepare(`SELECT * FROM nodes
|
|
920
|
+
WHERE status IN ('dead', 'done', 'canceled') AND created < ?
|
|
921
|
+
ORDER BY created`)
|
|
922
|
+
.all(cutoff)
|
|
923
|
+
.map(rowFrom)
|
|
924
|
+
.filter((row) => isCollectible(row, { pastTtl: true }))
|
|
925
|
+
.map((row) => ({ node_id: row.node_id, status: row.status, created: row.created }));
|
|
916
926
|
// Stale non-terminal sweep (opt-in): active | idle past the TTL whose process
|
|
917
927
|
// is provably gone (pi_pid NULL or not alive). Never the caller itself.
|
|
918
928
|
const stale = !includeStale ? [] : db
|
|
@@ -930,8 +940,9 @@ export function pruneNodes(opts) {
|
|
|
930
940
|
node_id: r['node_id'],
|
|
931
941
|
status: r['status'],
|
|
932
942
|
created: r['created'],
|
|
933
|
-
}))
|
|
934
|
-
|
|
943
|
+
}))
|
|
944
|
+
.filter((node) => !hasOpenManagedWorktree(node.node_id));
|
|
945
|
+
const candidates = [...terminal, ...stale];
|
|
935
946
|
if (dryRun || candidates.length === 0)
|
|
936
947
|
return { pruned: candidates, dryRun };
|
|
937
948
|
deletePrunedNodes(candidates);
|
|
@@ -1015,50 +1026,33 @@ function recencyMs(nodeId, created) {
|
|
|
1015
1026
|
return Number.isNaN(t) ? 0 : t;
|
|
1016
1027
|
}
|
|
1017
1028
|
/** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE
|
|
1018
|
-
* nodes plus every
|
|
1019
|
-
* `max(maxNodes,
|
|
1020
|
-
* TTL sweep can't (a canvas that never crosses the TTL still grows without
|
|
1029
|
+
* nodes plus every row the collection authority retains, so the real retained
|
|
1030
|
+
* floor is `max(maxNodes, retainedCount)`. Bounds the otherwise-unbounded growth
|
|
1031
|
+
* the TTL sweep can't (a canvas that never crosses the TTL still grows without
|
|
1021
1032
|
* limit).
|
|
1022
1033
|
*
|
|
1023
|
-
* Protected (never a prune candidate, regardless of recency rank): any
|
|
1024
|
-
* non-terminal node — status active|idle — REGARDLESS of `pi_pid` liveness,
|
|
1025
|
-
* or a node with an open managed worktree. A crashed-but-non-terminal row
|
|
1026
|
-
* (dead pid, status still active/idle) must survive for daemon crash-recovery
|
|
1027
|
-
* to resume, and an open worktree must retain its node ownership until
|
|
1028
|
-
* `node worktree close`; neither is eligible for count-based pruning. The caller
|
|
1029
|
-
* ($CRTR_NODE_ID) is also always protected.
|
|
1030
|
-
*
|
|
1031
1034
|
* Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back
|
|
1032
|
-
* to `created`); `target = max(maxNodes,
|
|
1033
|
-
* ranked beyond `target` (0-indexed rank >= target) that is
|
|
1034
|
-
* NOT the caller. Retained = all protected + the newest terminal nodes up to
|
|
1035
|
-
* `target`.
|
|
1035
|
+
* to `created`); `target = max(maxNodes, retainedCount)`; delete every node
|
|
1036
|
+
* ranked beyond `target` (0-indexed rank >= target) that is collectible.
|
|
1036
1037
|
*
|
|
1037
1038
|
* Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`:
|
|
1038
1039
|
* transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun`
|
|
1039
1040
|
* reports the candidate set and deletes NOTHING. */
|
|
1040
1041
|
export function pruneToLimit(opts) {
|
|
1041
1042
|
const dryRun = opts.dryRun ?? false;
|
|
1042
|
-
const selfId = envNodeId() ?? '';
|
|
1043
1043
|
const rows = listNodes();
|
|
1044
|
-
// Rank ALL nodes by recency DESC, tagging protection as we go. Protection is
|
|
1045
|
-
// status-only (active|idle) — NOT gated on pi_pid liveness — so a node whose
|
|
1046
|
-
// broker crashed but whose row is still non-terminal stays protected for
|
|
1047
|
-
// crash-recovery instead of being hard-deleted here.
|
|
1048
1044
|
const ranked = rows
|
|
1049
|
-
.map((
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
recency: recencyMs(r.node_id, r.created),
|
|
1054
|
-
protected: r.status === 'active' || r.status === 'idle' || hasOpenManagedWorktree(r.node_id),
|
|
1045
|
+
.map((row) => ({
|
|
1046
|
+
row,
|
|
1047
|
+
recency: recencyMs(row.node_id, row.created),
|
|
1048
|
+
retained: !isCollectible(row),
|
|
1055
1049
|
}))
|
|
1056
1050
|
.sort((a, b) => b.recency - a.recency);
|
|
1057
|
-
const
|
|
1058
|
-
const target = Math.max(opts.maxNodes,
|
|
1051
|
+
const retainedCount = ranked.filter((r) => r.retained).length;
|
|
1052
|
+
const target = Math.max(opts.maxNodes, retainedCount);
|
|
1059
1053
|
const candidates = ranked
|
|
1060
|
-
.filter((r, rank) => rank >= target && !r.
|
|
1061
|
-
.map((
|
|
1054
|
+
.filter((r, rank) => rank >= target && !r.retained)
|
|
1055
|
+
.map(({ row }) => ({ node_id: row.node_id, status: row.status, created: row.created }));
|
|
1062
1056
|
if (dryRun || candidates.length === 0)
|
|
1063
1057
|
return { pruned: candidates, dryRun };
|
|
1064
1058
|
deletePrunedNodes(candidates);
|
|
@@ -185,14 +185,11 @@ export declare function setCronLastOutputHash(cron_id: string, hash: string): vo
|
|
|
185
185
|
* terminal node that stopped without finishing. */
|
|
186
186
|
export declare function hasPendingCancelOnWakeCron(anchor_node: string): boolean;
|
|
187
187
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* Kills any in-flight
|
|
194
|
-
* run process group first (the cancelCron discipline), under one canvas write
|
|
195
|
-
* boundary. Returns the removed cron ids. */
|
|
188
|
+
* A real wake consumes the wait at its delivery boundary or, as a dormant
|
|
189
|
+
* fallback, in reviveNode; recovery replacement is not a wake. Terminal
|
|
190
|
+
* lifecycle transitions also clear the wait. Standing declarative crons are
|
|
191
|
+
* untouched. Kills an in-flight run process group first (the cancelCron
|
|
192
|
+
* discipline), under one canvas write boundary. Returns removed cron ids. */
|
|
196
193
|
export declare function cancelCronsOnWake(anchor_node: string): string[];
|
|
197
194
|
/** UPDATE the next fire time — recurrence advancement. The daemon settles a
|
|
198
195
|
* recurring row to its next slot BEFORE enacting the run (crash-safe). */
|
|
@@ -269,14 +269,11 @@ export function hasPendingCancelOnWakeCron(anchor_node) {
|
|
|
269
269
|
.get(anchor_node) !== undefined);
|
|
270
270
|
}
|
|
271
271
|
/** DELETE every cancel-on-wake cron anchored to this node — the deadline rule.
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* Kills any in-flight
|
|
278
|
-
* run process group first (the cancelCron discipline), under one canvas write
|
|
279
|
-
* boundary. Returns the removed cron ids. */
|
|
272
|
+
* A real wake consumes the wait at its delivery boundary or, as a dormant
|
|
273
|
+
* fallback, in reviveNode; recovery replacement is not a wake. Terminal
|
|
274
|
+
* lifecycle transitions also clear the wait. Standing declarative crons are
|
|
275
|
+
* untouched. Kills an in-flight run process group first (the cancelCron
|
|
276
|
+
* discipline), under one canvas write boundary. Returns removed cron ids. */
|
|
280
277
|
export function cancelCronsOnWake(anchor_node) {
|
|
281
278
|
return withCanvasWrite((db) => {
|
|
282
279
|
const rows = db
|
|
@@ -53,6 +53,11 @@ export interface CorpusFilter {
|
|
|
53
53
|
* CRTR_NODE_ID via the canvas data layer), falling back to the process cwd
|
|
54
54
|
* when not run as a node. */
|
|
55
55
|
export declare function callerCwd(): string;
|
|
56
|
+
/** The history ref of a node's newest report, or null when it left none.
|
|
57
|
+
* Report filenames lead with a compact timestamp, so the lexicographic maximum
|
|
58
|
+
* is the latest one. Callers use this to hand a reader something they can
|
|
59
|
+
* actually open (`crtr canvas history read <ref>`) instead of a bare node id. */
|
|
60
|
+
export declare function newestReportRef(nodeId: string): string | null;
|
|
56
61
|
/** Build the searchable artifact set for a scope + corpus filter. Node facets
|
|
57
62
|
* are applied against the db rows first (cheap); only surviving nodes' files
|
|
58
63
|
* are read. */
|
|
@@ -220,7 +220,7 @@ function metaArtifact(row, desc) {
|
|
|
220
220
|
loadBody: () => body,
|
|
221
221
|
};
|
|
222
222
|
}
|
|
223
|
-
/** List the markdown reports a node left,
|
|
223
|
+
/** List the markdown reports a node left, in directory order. */
|
|
224
224
|
function listReportFiles(nodeId) {
|
|
225
225
|
const dir = reportsDir(nodeId);
|
|
226
226
|
if (!pathExists(dir))
|
|
@@ -232,6 +232,17 @@ function listReportFiles(nodeId) {
|
|
|
232
232
|
return [];
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
|
+
/** The history ref of a node's newest report, or null when it left none.
|
|
236
|
+
* Report filenames lead with a compact timestamp, so the lexicographic maximum
|
|
237
|
+
* is the latest one. Callers use this to hand a reader something they can
|
|
238
|
+
* actually open (`crtr canvas history read <ref>`) instead of a bare node id. */
|
|
239
|
+
export function newestReportRef(nodeId) {
|
|
240
|
+
let newest = null;
|
|
241
|
+
for (const f of listReportFiles(nodeId))
|
|
242
|
+
if (newest === null || f > newest)
|
|
243
|
+
newest = f;
|
|
244
|
+
return newest === null ? null : `${nodeId}:reports/${newest}`;
|
|
245
|
+
}
|
|
235
246
|
/** Enumerate every context markdown doc a node left (recursive). */
|
|
236
247
|
function listDocFiles(nodeId) {
|
|
237
248
|
const dir = contextDir(nodeId);
|