@north-light/crouter 0.3.251 → 0.3.253
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/config.d.ts +2 -0
- package/dist/builtin-memory/02-turn-lifecycle/00-ending-a-turn.md +1 -5
- package/dist/builtin-memory/04-base-worker-exploring.md +13 -0
- package/dist/builtin-memory/04-base-worker.md +1 -4
- package/dist/builtin-memory/05-kinds/explore/00-base.md +2 -2
- package/dist/builtin-memory/05-kinds/explore/01-orchestrator.md +4 -2
- package/dist/builtin-memory/05-kinds/review/security-findings.md +1 -1
- package/dist/builtin-memory/explore/exploration-doc.md +27 -0
- package/dist/clients/attach/__tests__/completion-frecency.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/completion-frecency.test.js +35 -0
- package/dist/clients/attach/__tests__/ref-autocomplete.test.js +3 -1
- package/dist/clients/attach/__tests__/titled-editor-preview.test.js +2 -1
- package/dist/clients/attach/input/completion-frecency.d.ts +45 -0
- package/dist/clients/attach/input/completion-frecency.js +141 -0
- package/dist/clients/attach/input/controller.d.ts +17 -0
- package/dist/clients/attach/input/controller.js +40 -0
- package/dist/clients/attach/input/ref-autocomplete.d.ts +3 -1
- package/dist/clients/attach/input/ref-autocomplete.js +49 -8
- package/dist/clients/attach/input/titled-editor.d.ts +3 -0
- package/dist/clients/attach/input/titled-editor.js +5 -0
- package/dist/clients/attach/session/editor-inventory.d.ts +3 -0
- package/dist/clients/attach/session/editor-inventory.js +1 -1
- package/dist/clients/attach/session/input-wiring.d.ts +3 -0
- package/dist/clients/attach/session/input-wiring.js +2 -0
- package/dist/clients/attach/viewer.js +578 -578
- package/dist/commands/node/lifecycle.js +21 -6
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +4 -2
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +4 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +4 -1
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +3 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +7 -1
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +4 -1
- package/dist/core/__tests__/migration.test.js +57 -11
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +17 -2
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -0
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +9 -2
- package/dist/core/canvas/migrations.js +63 -24
- package/dist/core/runtime/broker/engine-drive.js +7 -1
- package/dist/core/runtime/broker/fault-retry.js +31 -13
- package/dist/core/runtime/broker/held-deferred-inbox.d.ts +4 -0
- package/dist/core/runtime/broker/held-deferred-inbox.js +7 -2
- package/dist/core/runtime/broker.js +19 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/fault.js +8 -5
- package/dist/core/runtime/recycle.js +63 -41
- package/dist/core/runtime/reset.d.ts +1 -1
- package/dist/core/runtime/reset.js +25 -2
- package/dist/core/runtime/revive.js +6 -1
- package/dist/daemon/__tests__/helpers/source-daemon.js +1 -0
- package/dist/daemon/api/handlers/messages.js +1 -1
- package/dist/daemon/api/handlers/nodes.js +124 -4
- package/dist/daemon/api/handlers/reports.js +1 -1
- package/dist/daemon/api/map.d.ts +1 -1
- package/dist/daemon/api/map.js +3 -2
- package/dist/daemon/cron/sinks.js +1 -1
- package/dist/daemon/messaging/node-message.js +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +5 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -109,15 +109,15 @@ const nodeClose = defineLeaf({
|
|
|
109
109
|
return `Closed ${r['node_id']} and its exclusive subtree — ${r['count']} node(s) closed${spared > 0 ? `, ${spared} spared (still managed from outside the subtree)` : ''}.`;
|
|
110
110
|
},
|
|
111
111
|
});
|
|
112
|
-
// node config — reconfigure a node's settings headlessly (model/lifecycle/kind/mode/name)
|
|
112
|
+
// node config — reconfigure a node's settings headlessly (model/lifecycle/kind/mode/name/cwd repair)
|
|
113
113
|
const NODE_CONFIG_MODEL_EFFECT = 'Model change first: a live broker switches immediately over its socket; a dormant node persists the durable model override and launch recipe for its next revive.';
|
|
114
114
|
export const nodeConfig = defineLeaf({
|
|
115
115
|
name: 'config',
|
|
116
|
-
description: 'reconfigure a node\'s settings headlessly — model, lifecycle (terminal/resident), kind, mode, name',
|
|
117
|
-
whenToUse: 'you want to reconfigure a node\'s settings headlessly after spawn — swap its model/provider, flip it terminal↔resident (interactable), respecialize its kind, switch base↔orchestrator mode, or rename it — without opening its viewer. The durable attribute setter; reuses the same recipes a revive reads. Use `node promote` for the opinionated self-promotion path, and `node lifecycle` for teardown actions (recycle/close/demote).',
|
|
116
|
+
description: 'reconfigure a node\'s settings headlessly — model, lifecycle (terminal/resident), kind, mode, name; repair a missing cwd',
|
|
117
|
+
whenToUse: 'you want to reconfigure a node\'s settings headlessly after spawn — swap its model/provider, flip it terminal↔resident (interactable), respecialize its kind, switch base↔orchestrator mode, or rename it — without opening its viewer. It also repairs a dormant node whose recorded cwd is gone, starting its next revive in a fresh conversation. The durable attribute setter; reuses the same recipes a revive reads. Use `node promote` for the opinionated self-promotion path, and `node lifecycle` for teardown actions (recycle/close/demote).',
|
|
118
118
|
help: {
|
|
119
119
|
name: 'node config',
|
|
120
|
-
summary: 'reconfigure a node\'s settings headlessly — model, lifecycle, kind, mode, name',
|
|
120
|
+
summary: 'reconfigure a node\'s settings headlessly — model, lifecycle, kind, mode, name; repair a missing cwd',
|
|
121
121
|
params: [
|
|
122
122
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Target node id. Defaults to the node in --pane, else CRTR_NODE_ID.' },
|
|
123
123
|
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to resolve the node from. Defaults to $TMUX_PANE.' },
|
|
@@ -140,20 +140,23 @@ export const nodeConfig = defineLeaf({
|
|
|
140
140
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Persona kind. The <kinds> list below names every top-level installable kind and when to use each; a registered sub-kind is valid too by exact path but not listed here.' },
|
|
141
141
|
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, constraint: 'Set persona mode headlessly. base is hands-on; orchestrator holds a roadmap and delegates. orchestrator seeds a roadmap scaffold if absent.' },
|
|
142
142
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
|
|
143
|
+
{ kind: 'flag', name: 'cwd', type: 'string', required: false, constraint: 'Repair-only replacement launch directory. Must be a non-empty absolute existing directory and used alone; the daemon accepts it only for a dormant, non-forked node with no open managed worktree whose recorded cwd is gone. Clears the saved Pi session, so the next revive starts fresh while durable node work remains.' },
|
|
143
144
|
],
|
|
144
145
|
output: [
|
|
145
|
-
{ name: 'node_id', type: 'string', required:
|
|
146
|
+
{ name: 'node_id', type: 'string', required: false, constraint: 'The reconfigured node, present for ordinary config changes.' },
|
|
146
147
|
{ name: 'model', type: 'string', required: false, constraint: 'The resolved model, present only when --model changed it.' },
|
|
147
148
|
{ name: 'lifecycle', type: 'string', required: false, constraint: 'The new lifecycle, present only when --lifecycle changed it.' },
|
|
148
149
|
{ name: 'kind', type: 'string', required: false, constraint: 'The new kind, present only when --kind changed it.' },
|
|
149
150
|
{ name: 'mode', type: 'string', required: false, constraint: 'The new mode, present only when --mode changed it.' },
|
|
150
151
|
{ name: 'name', type: 'string', required: false, constraint: 'The new name, present only when --name changed it.' },
|
|
152
|
+
{ name: 'cwd', type: 'string', required: false, constraint: 'The lexically resolved replacement directory, present only after a cwd repair.' },
|
|
151
153
|
],
|
|
152
154
|
outputKind: 'object',
|
|
153
155
|
effects: [
|
|
154
156
|
NODE_CONFIG_MODEL_EFFECT,
|
|
155
157
|
'Kind, mode, and lifecycle changes rebuild the launch spec from the fresh node meta; setting mode to orchestrator seeds a roadmap scaffold if absent.',
|
|
156
158
|
'Name changes update the row and, when the node already has a live window, rename that viewer window to the new full name.',
|
|
159
|
+
'A cwd repair writes only the lexically resolved cwd and clears saved Pi session pointers; the old transcript and durable node work remain untouched.',
|
|
157
160
|
],
|
|
158
161
|
dynamicState: () => kindsStateBlock(),
|
|
159
162
|
},
|
|
@@ -165,6 +168,10 @@ export const nodeConfig = defineLeaf({
|
|
|
165
168
|
// kind is validated against the caller's installed personas here for the
|
|
166
169
|
// clean listing error, and re-validated server-side against the TARGET
|
|
167
170
|
// node's scope as a backstop.
|
|
171
|
+
const cwdSpec = input['cwd'];
|
|
172
|
+
if (cwdSpec !== undefined && ['model', 'kind', 'lifecycle', 'mode', 'name'].some((flag) => input[flag] !== undefined)) {
|
|
173
|
+
throw new InputError({ error: 'cwd_not_exclusive', message: 'cwd repair cannot be combined with another config flag', field: 'cwd', next: 'Pass --cwd alone.' });
|
|
174
|
+
}
|
|
168
175
|
const modelSpec = input['model']?.trim();
|
|
169
176
|
if (modelSpec === '') {
|
|
170
177
|
throw new InputError({ error: 'empty_spec', message: 'a model spec is required', field: 'model', next: 'Run `crtr node config --model -h` and read the focused contract before retrying.' });
|
|
@@ -203,12 +210,18 @@ export const nodeConfig = defineLeaf({
|
|
|
203
210
|
patch.name = nameSpec;
|
|
204
211
|
applied.push('name');
|
|
205
212
|
}
|
|
213
|
+
if (cwdSpec !== undefined) {
|
|
214
|
+
patch.cwd = cwdSpec;
|
|
215
|
+
applied.push('cwd');
|
|
216
|
+
}
|
|
206
217
|
if (applied.length === 0) {
|
|
207
|
-
throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name.' });
|
|
218
|
+
throw new InputError({ error: 'no_change', message: 'no config changes requested', next: 'Pass at least one of --model, --lifecycle, --kind, --mode, --name, --cwd.' });
|
|
208
219
|
}
|
|
209
220
|
const detail = await cliClient()
|
|
210
221
|
.patchConfig(nodeId, patch)
|
|
211
222
|
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
223
|
+
if (cwdSpec !== undefined)
|
|
224
|
+
return { cwd: detail.cwd };
|
|
212
225
|
const result = { node_id: nodeId };
|
|
213
226
|
if (applied.includes('model'))
|
|
214
227
|
result.model = detail.model_override ?? modelSpec;
|
|
@@ -223,6 +236,8 @@ export const nodeConfig = defineLeaf({
|
|
|
223
236
|
return result;
|
|
224
237
|
},
|
|
225
238
|
render: (r) => {
|
|
239
|
+
if (r['cwd'] !== undefined)
|
|
240
|
+
return `Repaired cwd: ${r['cwd']}.`;
|
|
226
241
|
const bits = [];
|
|
227
242
|
if (r['model'] !== undefined)
|
|
228
243
|
bits.push(`model=${r['model']}`);
|
|
@@ -15,7 +15,7 @@ import { drainBearings } from '../runtime/kickoff.js';
|
|
|
15
15
|
import { appendSituationalContext } from '../runtime/situational-context.js';
|
|
16
16
|
import { createEngineDriver } from '../runtime/broker/engine-drive.js';
|
|
17
17
|
import { createTurnAdmissionGate } from '../runtime/broker/turn-admission.js';
|
|
18
|
-
import { heldDeferredInboxPromptJoin } from '../runtime/broker/held-deferred-inbox.js';
|
|
18
|
+
import { BROKER_IDLE_TURN_START, heldDeferredInboxPromptJoin, } from '../runtime/broker/held-deferred-inbox.js';
|
|
19
19
|
import { createNode, getNode, recordPid, setIntent } from '../canvas/canvas.js';
|
|
20
20
|
import { createProfile, pauseProfile, resumeProfile } from '../profiles/manifest.js';
|
|
21
21
|
import { closeDb } from '../canvas/db.js';
|
|
@@ -47,7 +47,7 @@ function makeFakePi() {
|
|
|
47
47
|
const handlers = {};
|
|
48
48
|
let resolveFirstInjection;
|
|
49
49
|
const firstInjection = new Promise((resolve) => { resolveFirstInjection = resolve; });
|
|
50
|
-
|
|
50
|
+
const pi = {
|
|
51
51
|
injected: [],
|
|
52
52
|
firstInjection,
|
|
53
53
|
sentMessages: [],
|
|
@@ -59,6 +59,8 @@ function makeFakePi() {
|
|
|
59
59
|
sendMessage(message) { this.sentMessages.push(message); },
|
|
60
60
|
async fire(e, ev, ctx) { await handlers[e]?.(ev, ctx); },
|
|
61
61
|
};
|
|
62
|
+
globalThis[BROKER_IDLE_TURN_START] = (content) => pi.sendUserMessage(content);
|
|
63
|
+
return pi;
|
|
62
64
|
}
|
|
63
65
|
// Fire agent_settled — the authoritative durable-commit boundary in the
|
|
64
66
|
// at-least-once model. `pending` maps to pi's `hasPendingMessages()` and is
|
|
@@ -30,6 +30,7 @@ import { createNode } from '../canvas/canvas.js';
|
|
|
30
30
|
import { closeDb } from '../canvas/db.js';
|
|
31
31
|
import { apiSocketPath } from '../canvas/paths.js';
|
|
32
32
|
import { appendInbox } from '../feed/inbox.js';
|
|
33
|
+
import { BROKER_IDLE_TURN_START } from '../runtime/broker/held-deferred-inbox.js';
|
|
33
34
|
const TICK_MS = 20;
|
|
34
35
|
const DEBOUNCE_MS = 25;
|
|
35
36
|
let home;
|
|
@@ -76,7 +77,7 @@ function getRequestCount(nodeId) {
|
|
|
76
77
|
}
|
|
77
78
|
function makeFakePi() {
|
|
78
79
|
const handlers = {};
|
|
79
|
-
|
|
80
|
+
const pi = {
|
|
80
81
|
injected: [],
|
|
81
82
|
sentMessages: [],
|
|
82
83
|
on(e, h) { handlers[e] = h; },
|
|
@@ -84,6 +85,8 @@ function makeFakePi() {
|
|
|
84
85
|
sendMessage(message) { this.sentMessages.push(message); },
|
|
85
86
|
fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
|
|
86
87
|
};
|
|
88
|
+
globalThis[BROKER_IDLE_TURN_START] = (content) => pi.sendUserMessage(content);
|
|
89
|
+
return pi;
|
|
87
90
|
}
|
|
88
91
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
89
92
|
async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
|
|
@@ -18,6 +18,7 @@ import { closeDb } from '../canvas/db.js';
|
|
|
18
18
|
import { appendInbox, coalesce, finalizeInboxEntry } from '../feed/inbox.js';
|
|
19
19
|
import { appendSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../runtime/situational-context.js';
|
|
20
20
|
import { parseCard } from '../../shared/generated-context.js';
|
|
21
|
+
import { BROKER_IDLE_TURN_START } from '../runtime/broker/held-deferred-inbox.js';
|
|
21
22
|
// Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
|
|
22
23
|
// CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the watcher delivers in tens of
|
|
23
24
|
// milliseconds, not seconds — exactly the resolve+seed tick, read tick, and
|
|
@@ -40,7 +41,7 @@ function freshNode(nodeId) {
|
|
|
40
41
|
}
|
|
41
42
|
function makeFakePi() {
|
|
42
43
|
const handlers = {};
|
|
43
|
-
|
|
44
|
+
const pi = {
|
|
44
45
|
injected: [],
|
|
45
46
|
sentMessages: [],
|
|
46
47
|
on(e, h) { handlers[e] = h; },
|
|
@@ -48,6 +49,8 @@ function makeFakePi() {
|
|
|
48
49
|
sendMessage(message) { this.sentMessages.push(message); },
|
|
49
50
|
fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
|
|
50
51
|
};
|
|
52
|
+
globalThis[BROKER_IDLE_TURN_START] = (content) => pi.sendUserMessage(content);
|
|
53
|
+
return pi;
|
|
51
54
|
}
|
|
52
55
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
53
56
|
/** The text of a batch that must coalesce into exactly one delivery. */
|
|
@@ -193,7 +193,9 @@ declare class FakeSession {
|
|
|
193
193
|
getSessionStats(): Record<string, unknown>;
|
|
194
194
|
private syncTreeSnapshot;
|
|
195
195
|
setSessionName(_name: string): void;
|
|
196
|
-
prompt(text: string,
|
|
196
|
+
prompt(text: string, options?: {
|
|
197
|
+
preflightResult?: (success: boolean) => void;
|
|
198
|
+
}): Promise<void>;
|
|
197
199
|
/** The unrun queues the welcome snapshot reports (`snapshot.queued`). Real
|
|
198
200
|
* pi holds a steer + followUp queue and drains it when the next turn runs;
|
|
199
201
|
* the fake records the same texts and clears them in emitTurn, so a client
|
|
@@ -448,12 +448,18 @@ class FakeSession {
|
|
|
448
448
|
/* no-op recording stub */
|
|
449
449
|
}
|
|
450
450
|
// controller-drive stubs (no controller connects in these tests)
|
|
451
|
-
prompt(text,
|
|
451
|
+
prompt(text, options) {
|
|
452
452
|
// Rejection seam: the engine refuses the call itself (pi's no-provider-
|
|
453
453
|
// credential shape) — the promise rejects and NO lifecycle event fires.
|
|
454
454
|
if (text.includes(REJECT_PROMPT_CALL)) {
|
|
455
455
|
return Promise.reject(new Error('No API key found for anthropic'));
|
|
456
456
|
}
|
|
457
|
+
options?.preflightResult?.(true);
|
|
458
|
+
// The inbox watcher now enters through the broker-owned prompt bridge, not
|
|
459
|
+
// the Pi extension runtime. Keep its existing observable wake record for
|
|
460
|
+
// the compiled dormancy seam while the prompt drives the real lifecycle.
|
|
461
|
+
if (text.startsWith('<runtime kind="inbox"'))
|
|
462
|
+
this.recordInjected({ content: text });
|
|
457
463
|
// The broker calls this for the controller-less retry path. Emit the turn
|
|
458
464
|
// on the broker's subscribe channel and also run the real extension hooks so
|
|
459
465
|
// the canvas stophook records the fault marker and the broker can schedule
|
|
@@ -50,6 +50,7 @@ import { canvasDbPath, nodeDir, apiSocketPath } from '../../canvas/paths.js';
|
|
|
50
50
|
import { closeDb } from '../../canvas/db.js';
|
|
51
51
|
import { createApiServer } from '../../../daemon/api/server.js';
|
|
52
52
|
import { CrtrClient } from '../../../api/index.js';
|
|
53
|
+
import { BROKER_IDLE_TURN_START } from '../../runtime/broker/held-deferred-inbox.js';
|
|
53
54
|
import { registerNode } from '../../../commands/node.js';
|
|
54
55
|
import { pushFinal } from '../../feed/feed.js';
|
|
55
56
|
import { InputError } from '../../io.js';
|
|
@@ -500,13 +501,15 @@ test('the lifecycle tick parks a resident holding deferred-only mail — mail th
|
|
|
500
501
|
});
|
|
501
502
|
function makeFakePi() {
|
|
502
503
|
const handlers = {};
|
|
503
|
-
|
|
504
|
+
const pi = {
|
|
504
505
|
injected: [],
|
|
505
506
|
on(e, h) { handlers[e] = h; },
|
|
506
507
|
sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
|
|
507
508
|
sendMessage() { },
|
|
508
509
|
async fire(e, ev, ctx) { await handlers[e]?.(ev, ctx); },
|
|
509
510
|
};
|
|
511
|
+
globalThis[BROKER_IDLE_TURN_START] = (content) => pi.sendUserMessage(content);
|
|
512
|
+
return pi;
|
|
510
513
|
}
|
|
511
514
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
512
515
|
test('the real inbox watcher holds a deferred entry alone, then delivers it TOGETHER with a later normal entry with no duplicate/loss, and the durable cursor settles past both (gh #341 review: genuine delivery, not just the daemon decision)', { timeout: 30_000 }, async () => {
|
|
@@ -215,36 +215,82 @@ test('a fresh db has the four additive runtime columns', () => {
|
|
|
215
215
|
assert.equal(rt['window'], null);
|
|
216
216
|
assert.equal(rt['tmux_session'], null);
|
|
217
217
|
});
|
|
218
|
-
test('session-file ownership migration
|
|
218
|
+
test('session-file ownership migration reconciles authoritative metas before clearing duplicate claimants', () => {
|
|
219
219
|
ensureHome();
|
|
220
220
|
const raw = new DatabaseSync(canvasDbPath());
|
|
221
221
|
migrateToVersion(raw, 35);
|
|
222
222
|
const sessionFile = '/tmp/preexisting-duplicate.jsonl';
|
|
223
223
|
const aliasedSessionFile = '/tmp/./preexisting-duplicate.jsonl';
|
|
224
|
-
|
|
224
|
+
const recoveredSessionFile = '/tmp/recovered-session.jsonl';
|
|
225
|
+
const metas = [
|
|
226
|
+
['first-owner', '2026-01-01T00:00:00.000Z', sessionFile],
|
|
227
|
+
['later-claimant', '2026-01-02T00:00:00.000Z', aliasedSessionFile],
|
|
228
|
+
// The durable meta write survived, but the derived row below is stale.
|
|
229
|
+
['recovered-claimant', '2026-01-03T00:00:00.000Z', recoveredSessionFile],
|
|
230
|
+
];
|
|
231
|
+
for (const [id, created, pi_session_file] of metas) {
|
|
225
232
|
const nodeDir = join(home, 'nodes', id);
|
|
226
233
|
mkdirSync(nodeDir, { recursive: true });
|
|
227
234
|
writeFileSync(join(nodeDir, 'meta.json'), JSON.stringify({
|
|
228
|
-
node_id: id, name: id, created:
|
|
229
|
-
cwd: '/tmp', kind: 'developer', mode: 'base', lifecycle: 'terminal', pi_session_id: `${id}-session`, pi_session_file: id === 'first-owner' ? sessionFile : aliasedSessionFile,
|
|
235
|
+
node_id: id, name: id, created, cwd: '/tmp', kind: 'developer', mode: 'base', lifecycle: 'terminal', pi_session_id: `${id}-session`, pi_session_file,
|
|
230
236
|
}));
|
|
231
237
|
}
|
|
232
|
-
raw.prepare('INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, pi_session_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
238
|
+
const insert = raw.prepare('INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, pi_session_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
239
|
+
insert.run('first-owner', 'first-owner', 'developer', 'base', 'terminal', 'active', '/tmp', '2026-01-01T00:00:00.000Z', sessionFile);
|
|
240
|
+
insert.run('later-claimant', 'later-claimant', 'developer', 'base', 'terminal', 'active', '/tmp', '2026-01-02T00:00:00.000Z', aliasedSessionFile);
|
|
241
|
+
insert.run('recovered-claimant', 'recovered-claimant', 'developer', 'base', 'terminal', 'active', '/tmp', '2026-01-03T00:00:00.000Z', sessionFile);
|
|
236
242
|
assert.doesNotThrow(() => migrate(raw), 'a real canvas with pre-existing duplicates must migrate');
|
|
237
243
|
const rows = raw.prepare('SELECT node_id, pi_session_file FROM nodes ORDER BY created, node_id').all();
|
|
238
244
|
assert.deepEqual(rows.map((row) => [row.node_id, row.pi_session_file]), [
|
|
239
245
|
['first-owner', sessionFile],
|
|
240
246
|
['later-claimant', null],
|
|
241
|
-
|
|
247
|
+
['recovered-claimant', recoveredSessionFile],
|
|
248
|
+
], 'the oldest duplicate is retained while a stale row is reconciled from its authoritative meta');
|
|
242
249
|
const loser = JSON.parse(readFileSync(join(home, 'nodes', 'later-claimant', 'meta.json'), 'utf8'));
|
|
243
|
-
assert.equal(loser.pi_session_id, null, 'the loser meta cannot reindex its stale session id');
|
|
244
|
-
assert.equal(loser.pi_session_file, null, 'the loser meta cannot restore the duplicate projection');
|
|
250
|
+
assert.equal(loser.pi_session_id, null, 'the duplicate loser meta cannot reindex its stale session id');
|
|
251
|
+
assert.equal(loser.pi_session_file, null, 'the duplicate loser meta cannot restore the duplicate projection');
|
|
252
|
+
const recovered = JSON.parse(readFileSync(join(home, 'nodes', 'recovered-claimant', 'meta.json'), 'utf8'));
|
|
253
|
+
assert.equal(recovered.pi_session_id, 'recovered-claimant-session', 'a stale row cannot clear the authoritative session id');
|
|
254
|
+
assert.equal(recovered.pi_session_file, recoveredSessionFile, 'a stale row cannot clear the authoritative session file');
|
|
245
255
|
assert.throws(() => raw.prepare('UPDATE nodes SET pi_session_file = ? WHERE node_id = ?').run(sessionFile, 'later-claimant'), /UNIQUE/, 'the schema enforces the repaired invariant');
|
|
246
256
|
raw.close();
|
|
247
257
|
});
|
|
258
|
+
test('forward session-file repair reprojects a surviving v36 meta coordinate before restoring its UNIQUE index', () => {
|
|
259
|
+
ensureHome();
|
|
260
|
+
const raw = new DatabaseSync(canvasDbPath());
|
|
261
|
+
migrateToVersion(raw, 35);
|
|
262
|
+
const recoveredSessionFile = '/tmp/surviving-meta-coordinate.jsonl';
|
|
263
|
+
const staleSessionFile = '/tmp/stale-v36-coordinate.jsonl';
|
|
264
|
+
for (const [id, pi_session_id, pi_session_file] of [
|
|
265
|
+
['surviving-owner', 'surviving-session', recoveredSessionFile],
|
|
266
|
+
['stale-row', null, null],
|
|
267
|
+
]) {
|
|
268
|
+
const nodeDir = join(home, 'nodes', id);
|
|
269
|
+
mkdirSync(nodeDir, { recursive: true });
|
|
270
|
+
writeFileSync(join(nodeDir, 'meta.json'), JSON.stringify({
|
|
271
|
+
node_id: id, name: id, created: id === 'surviving-owner' ? '2026-01-01T00:00:00.000Z' : '2026-01-02T00:00:00.000Z',
|
|
272
|
+
cwd: '/tmp', kind: 'developer', mode: 'base', lifecycle: 'terminal', pi_session_id, pi_session_file,
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
const insert = raw.prepare('INSERT INTO nodes (node_id, name, kind, mode, lifecycle, status, cwd, created, pi_session_file) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
|
276
|
+
// This is the v0.3.250 shape after v36: the owner’s surviving meta coordinate
|
|
277
|
+
// disagrees with its indexed row, while the row carrying it has no meta claim.
|
|
278
|
+
insert.run('surviving-owner', 'surviving-owner', 'developer', 'base', 'terminal', 'active', '/tmp', '2026-01-01T00:00:00.000Z', staleSessionFile);
|
|
279
|
+
insert.run('stale-row', 'stale-row', 'developer', 'base', 'terminal', 'active', '/tmp', '2026-01-02T00:00:00.000Z', recoveredSessionFile);
|
|
280
|
+
raw.exec('DROP INDEX IF EXISTS idx_nodes_pi_session_file; CREATE UNIQUE INDEX idx_nodes_pi_session_file ON nodes(pi_session_file); PRAGMA user_version = 36;');
|
|
281
|
+
assert.doesNotThrow(() => migrate(raw));
|
|
282
|
+
assert.equal(userVersion(raw), MIGRATIONS.length);
|
|
283
|
+
const rows = raw.prepare('SELECT node_id, pi_session_file FROM nodes ORDER BY created, node_id').all();
|
|
284
|
+
assert.deepEqual(rows.map((row) => [row.node_id, row.pi_session_file]), [
|
|
285
|
+
['surviving-owner', recoveredSessionFile],
|
|
286
|
+
['stale-row', null],
|
|
287
|
+
]);
|
|
288
|
+
const survivor = JSON.parse(readFileSync(join(home, 'nodes', 'surviving-owner', 'meta.json'), 'utf8'));
|
|
289
|
+
assert.equal(survivor.pi_session_id, 'surviving-session');
|
|
290
|
+
assert.equal(survivor.pi_session_file, recoveredSessionFile);
|
|
291
|
+
assert.throws(() => raw.prepare('UPDATE nodes SET pi_session_file = ? WHERE node_id = ?').run(recoveredSessionFile, 'stale-row'), /UNIQUE/);
|
|
292
|
+
raw.close();
|
|
293
|
+
});
|
|
248
294
|
test('node lifecycle substrate migration adds six columns and backfills terminal reasons', () => {
|
|
249
295
|
ensureHome();
|
|
250
296
|
const raw = new DatabaseSync(canvasDbPath());
|
|
@@ -148,6 +148,8 @@ test('a crashed broker re-drives only its pending provider episode, never admitt
|
|
|
148
148
|
seedPool('openai-codex', false);
|
|
149
149
|
const pendingNode = await h.spawnHeadlessChild(parent, 'pending provider episode', { model: OPENAI_STRONG.spec });
|
|
150
150
|
await h.waitFor(() => agentStartCount(pendingNode) >= 1, { label: 'pending episode fixture started' });
|
|
151
|
+
const pendingKickoffObserver = await attachUntil(pendingNode, 'observer', 'pending-provider-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'pending provider episode kickoff settled');
|
|
152
|
+
pendingKickoffObserver.close();
|
|
151
153
|
const pendingStarts = agentStartCount(pendingNode);
|
|
152
154
|
h.fakeCmd(pendingNode, {
|
|
153
155
|
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 2_000,
|
|
@@ -160,6 +162,8 @@ test('a crashed broker re-drives only its pending provider episode, never admitt
|
|
|
160
162
|
assert.equal(agentStartCount(pendingNode), pendingStarts + 1, 'a pending episode re-drives exactly once after broker death');
|
|
161
163
|
const admittedNode = await h.spawnHeadlessChild(parent, 'admitted provider episode', { model: OPENAI_STRONG.spec });
|
|
162
164
|
await h.waitFor(() => agentStartCount(admittedNode) >= 1, { label: 'admitted episode fixture started' });
|
|
165
|
+
const admittedKickoffObserver = await attachUntil(admittedNode, 'observer', 'admitted-provider-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'admitted provider episode kickoff settled');
|
|
166
|
+
admittedKickoffObserver.close();
|
|
163
167
|
const admittedStarts = agentStartCount(admittedNode);
|
|
164
168
|
h.fakeCmd(admittedNode, {
|
|
165
169
|
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
|
|
@@ -174,6 +178,8 @@ test('a crashed broker re-drives only its pending provider episode, never admitt
|
|
|
174
178
|
assert.equal(agentStartCount(admittedNode), admittedStarts, 'an admitted episode is never replayed by replacement startup');
|
|
175
179
|
const invalidatedNode = await h.spawnHeadlessChild(parent, 'invalidated provider episode', { model: OPENAI_STRONG.spec });
|
|
176
180
|
await h.waitFor(() => agentStartCount(invalidatedNode) >= 1, { label: 'invalidated episode fixture started' });
|
|
181
|
+
const invalidatedKickoffObserver = await attachUntil(invalidatedNode, 'observer', 'invalidated-provider-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'invalidated provider episode kickoff settled');
|
|
182
|
+
invalidatedKickoffObserver.close();
|
|
177
183
|
const invalidatedStarts = agentStartCount(invalidatedNode);
|
|
178
184
|
h.fakeCmd(invalidatedNode, {
|
|
179
185
|
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
|
|
@@ -190,10 +196,13 @@ test('a crashed broker re-drives only its pending provider episode, never admitt
|
|
|
190
196
|
assert.equal(agentStartCount(invalidatedNode), invalidatedStarts, 'an invalidated rebound-session episode is never replayed by replacement startup');
|
|
191
197
|
const cyclingNode = await h.spawnHeadlessChild(parent, 'fresh-cycle provider episode', { model: OPENAI_STRONG.spec });
|
|
192
198
|
await h.waitFor(() => agentStartCount(cyclingNode) >= 1, { label: 'fresh-cycle episode fixture started' });
|
|
199
|
+
const cyclingKickoffObserver = await attachUntil(cyclingNode, 'observer', 'fresh-cycle-provider-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'fresh-cycle provider episode kickoff settled');
|
|
200
|
+
cyclingKickoffObserver.close();
|
|
193
201
|
h.fakeCmd(cyclingNode, {
|
|
194
|
-
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() +
|
|
202
|
+
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 2_000,
|
|
195
203
|
});
|
|
196
204
|
await h.waitFor(() => readProviderRetryEpisode(cyclingNode)?.state === 'pending' ? true : null, { label: 'fresh-cycle episode pending record persisted' });
|
|
205
|
+
const cyclingStarts = agentStartCount(cyclingNode);
|
|
197
206
|
const cyclingBoots = h.bootCount(cyclingNode);
|
|
198
207
|
const yieldResult = h.cli(cyclingNode, ['node', 'yield', 'begin a fresh cycle']);
|
|
199
208
|
assert.equal(yieldResult.code, 0, `node yield should succeed\n--stderr--\n${yieldResult.stderr}`);
|
|
@@ -207,7 +216,13 @@ test('a crashed broker re-drives only its pending provider episode, never admitt
|
|
|
207
216
|
await h.tick(Date.parse(cyclingBackoff.respawn_not_before) + 1);
|
|
208
217
|
await h.awaitBoot(cyclingNode, { minCount: cyclingBoots + 1 });
|
|
209
218
|
await h.waitFor(() => existsSync(h.brokerSock(cyclingNode)), { label: 'fresh-cycle replacement broker listener bound' });
|
|
210
|
-
|
|
219
|
+
await h.waitFor(() => readProviderRetryEpisode(cyclingNode) === null, { label: 'fresh-cycle kickoff settled successfully' });
|
|
220
|
+
assert.equal(readProviderRetryEpisode(cyclingNode), null, 'the settled fresh-cycle kickoff clears the old provider episode');
|
|
221
|
+
assert.equal(agentStartCount(cyclingNode), cyclingStarts + 1, 'the fresh-cycle kickoff starts exactly once');
|
|
222
|
+
// A retained episode would re-arm immediately once its deadline becomes due.
|
|
223
|
+
// Wait past that poll interval to prove the completed kickoff cannot replay.
|
|
224
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
225
|
+
assert.equal(agentStartCount(cyclingNode), cyclingStarts + 1, 'the cleared fresh-cycle episode never replays');
|
|
211
226
|
});
|
|
212
227
|
test('reload_auth re-drives a parked fatal provider-auth turn exactly once', { timeout: 30_000 }, async () => {
|
|
213
228
|
const parent = h.spawnRoot('auth reload parent');
|
|
@@ -72,6 +72,7 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
72
72
|
tools: [],
|
|
73
73
|
systemPrompt: '',
|
|
74
74
|
env: {
|
|
75
|
+
CRTR_RUNTIME_HOME: process.env['CRTR_RUNTIME_HOME'],
|
|
75
76
|
CRTR_FAKE_ENGINE_PENDING_AT_SETTLE: '1',
|
|
76
77
|
CRTR_WATCHER_TICK_MS: '20',
|
|
77
78
|
CRTR_WATCHER_DEBOUNCE_MS: '25',
|
|
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { FaultRetry } from '../runtime/broker/fault-retry.js';
|
|
7
|
-
import { readFault,
|
|
7
|
+
import { admitProviderRetryEpisode, clearFault, readFault, readProviderRetryEpisode, recordPendingProviderRetryFault, } from '../runtime/fault.js';
|
|
8
8
|
let home;
|
|
9
9
|
before(() => {
|
|
10
10
|
home = mkdtempSync(join(tmpdir(), 'crtr-watchdog-abort-retry-'));
|
|
@@ -70,12 +70,18 @@ test('only a watchdog-stamped aborted settlement records an automatic connection
|
|
|
70
70
|
test('a failed retry settlement carries the episode forward instead of restarting it', () => {
|
|
71
71
|
// The episode marker a daemon retry records before re-prompting: original
|
|
72
72
|
// anchor, start time, and the attempt just consumed.
|
|
73
|
-
|
|
73
|
+
const sessionFile = 'retry-episode-session';
|
|
74
|
+
const admittedFault = recordPendingProviderRetryFault('retry-episode-node', sessionFile, {
|
|
74
75
|
link: 'pi→provider', op: 'generation turn', kind: 'connection',
|
|
75
76
|
retry: { disposition: 'auto', by: 'daemon', attempt: 2, max: 8 },
|
|
76
77
|
message: 'connection reset', since: '2026-01-01T00:00:00.000Z', anchorEntryId: 'original-anchor',
|
|
77
78
|
});
|
|
79
|
+
assert.ok(admittedFault, 'the pre-prompt retry fault persists');
|
|
80
|
+
assert.equal(admitProviderRetryEpisode('retry-episode-node', sessionFile, admittedFault), true, 'the retry is durably admitted before prompt');
|
|
81
|
+
clearFault('retry-episode-node', { link: 'pi→provider' }); // error turn_end clears its ordinary marker
|
|
82
|
+
assert.equal(readProviderRetryEpisode('retry-episode-node')?.state, 'admitted', 'error turn_end preserves the admitted retry for settlement');
|
|
78
83
|
const session = {
|
|
84
|
+
sessionFile,
|
|
79
85
|
sessionManager: { getLeafId: () => 'leaf-after-failed-retry' },
|
|
80
86
|
model: undefined,
|
|
81
87
|
};
|
|
@@ -100,4 +106,5 @@ test('a failed retry settlement carries the episode forward instead of restartin
|
|
|
100
106
|
assert.equal(fault?.anchorEntryId, 'original-anchor', 'each retry must rewind to the original fork point, not stack prompts');
|
|
101
107
|
assert.equal(fault?.since, '2026-01-01T00:00:00.000Z', 'the episode start survives a failed retry');
|
|
102
108
|
assert.equal(fault?.retry.attempt, 2, 'the attempt count accumulates toward exhaustion');
|
|
109
|
+
assert.equal(readProviderRetryEpisode('retry-episode-node')?.state, 'pending', 'the failed admitted retry becomes the next durable pending attempt');
|
|
103
110
|
});
|
|
@@ -26,14 +26,19 @@ import { basename, join, resolve } from 'node:path';
|
|
|
26
26
|
import { crtrHome, isSafeNodeId, nodesRoot, nodeMetaPath } from './paths.js';
|
|
27
27
|
import { STATE_MIGRATIONS } from '../../migrations/registry.js';
|
|
28
28
|
import { customExtensionPaths } from './extensions.js';
|
|
29
|
-
import { updateJsonFileDurably } from './meta-file.js';
|
|
29
|
+
import { readMetaObject, updateJsonFileDurably, withMetaLock, writeMetaObjectLocked } from './meta-file.js';
|
|
30
30
|
// Schema as a forward-only migration list
|
|
31
31
|
//
|
|
32
32
|
// The schema is the migration list: one place a schema change is expressed,
|
|
33
33
|
// one gate (`PRAGMA user_version`) that applies it. `migrate()` runs every
|
|
34
34
|
// pending step in order and bumps `user_version` after each, so a fresh db and
|
|
35
35
|
// the live fleet (all at `user_version 0`) converge on the same final shape.
|
|
36
|
-
// Migrations are append-only and forward-only — never edit a shipped step.
|
|
36
|
+
// Migrations are append-only and forward-only — never edit a shipped step. The
|
|
37
|
+
// v36 body below is the sole corrective exception: v0.3.250 shipped a destructive
|
|
38
|
+
// stale-row decision, so v36 is narrowed for databases still below its gate while
|
|
39
|
+
// appended v37 repairs databases that already crossed it. Do not repeat this
|
|
40
|
+
// exception; an appended migration is required unless it would run too late to
|
|
41
|
+
// prevent destruction at an existing version boundary.
|
|
37
42
|
/** v1 — the baseline tables + indexes. `IF NOT EXISTS` makes this a no-op on
|
|
38
43
|
* any existing db (the live fleet already has these tables). */
|
|
39
44
|
function baselineSchema(db) {
|
|
@@ -1215,32 +1220,43 @@ function addNodeSessionFileIndex(db) {
|
|
|
1215
1220
|
}
|
|
1216
1221
|
db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_pi_session_file ON nodes(pi_session_file);');
|
|
1217
1222
|
}
|
|
1218
|
-
/**
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
function
|
|
1223
|
+
/** Reproject the derived session-file index from authoritative node metadata.
|
|
1224
|
+
* v36 may resolve duplicate authoritative claimants because it runs before the
|
|
1225
|
+
* first UNIQUE index exists. v37 must retain every surviving meta claim: a
|
|
1226
|
+
* conflict at that later boundary is irreconcilable and fails instead. */
|
|
1227
|
+
function reconcileNodeSessionFileOwners(db, clearDuplicateMetaClaimants) {
|
|
1223
1228
|
const canonical = (sessionFile) => {
|
|
1224
1229
|
const absolute = resolve(sessionFile);
|
|
1225
1230
|
return existsSync(absolute) ? realpathSync(absolute) : absolute;
|
|
1226
1231
|
};
|
|
1227
|
-
const rows = db.prepare(`
|
|
1228
|
-
SELECT node_id, pi_session_file
|
|
1229
|
-
FROM nodes
|
|
1230
|
-
WHERE pi_session_file IS NOT NULL
|
|
1231
|
-
ORDER BY created ASC, node_id ASC
|
|
1232
|
-
`).all();
|
|
1233
1232
|
const setFile = db.prepare('UPDATE nodes SET pi_session_file = ? WHERE node_id = ?');
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1233
|
+
db.exec('DROP INDEX IF EXISTS idx_nodes_pi_session_file;');
|
|
1234
|
+
const rows = db.prepare('SELECT node_id FROM nodes ORDER BY created ASC, node_id ASC').all();
|
|
1235
|
+
for (const { node_id } of rows) {
|
|
1236
|
+
const metaPath = nodeMetaPath(node_id);
|
|
1237
|
+
// Missing or unreadable metadata has no authoritative coordinate. Avoid
|
|
1238
|
+
// taking a file lock there: old rows can outlive a removed node directory.
|
|
1239
|
+
if (readMetaObject(metaPath) === null) {
|
|
1240
|
+
setFile.run(null, node_id);
|
|
1237
1241
|
continue;
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
meta
|
|
1241
|
-
|
|
1242
|
+
}
|
|
1243
|
+
const sessionFile = withMetaLock(metaPath, () => {
|
|
1244
|
+
const meta = readMetaObject(metaPath);
|
|
1245
|
+
if (meta === null)
|
|
1246
|
+
throw new Error(`node ${node_id} session metadata changed during ownership migration`);
|
|
1247
|
+
if (typeof meta['pi_session_file'] !== 'string')
|
|
1248
|
+
return null;
|
|
1249
|
+
const storedSessionFile = meta['pi_session_file'];
|
|
1250
|
+
const normalizedSessionFile = canonical(storedSessionFile);
|
|
1251
|
+
if (normalizedSessionFile !== storedSessionFile) {
|
|
1252
|
+
meta['pi_session_file'] = normalizedSessionFile;
|
|
1253
|
+
writeMetaObjectLocked(metaPath, meta);
|
|
1254
|
+
}
|
|
1255
|
+
return normalizedSessionFile;
|
|
1242
1256
|
});
|
|
1243
|
-
|
|
1257
|
+
// meta.json is authoritative: a crash after its write can leave this row
|
|
1258
|
+
// claiming another node's old session coordinate.
|
|
1259
|
+
setFile.run(sessionFile, node_id);
|
|
1244
1260
|
}
|
|
1245
1261
|
const duplicates = db.prepare(`
|
|
1246
1262
|
SELECT pi_session_file
|
|
@@ -1249,6 +1265,10 @@ WHERE pi_session_file IS NOT NULL
|
|
|
1249
1265
|
GROUP BY pi_session_file
|
|
1250
1266
|
HAVING COUNT(*) > 1
|
|
1251
1267
|
`).all();
|
|
1268
|
+
if (!clearDuplicateMetaClaimants && duplicates.length > 0) {
|
|
1269
|
+
const coordinates = duplicates.map(({ pi_session_file }) => pi_session_file).join(', ');
|
|
1270
|
+
throw new Error(`cannot restore unique Pi session ownership: authoritative metadata has concurrent claimants for ${coordinates}`);
|
|
1271
|
+
}
|
|
1252
1272
|
const clearRow = db.prepare('UPDATE nodes SET pi_session_file = NULL WHERE node_id = ?');
|
|
1253
1273
|
for (const { pi_session_file } of duplicates) {
|
|
1254
1274
|
const owners = db.prepare(`
|
|
@@ -1259,18 +1279,36 @@ ORDER BY created ASC, node_id ASC
|
|
|
1259
1279
|
`).all(pi_session_file);
|
|
1260
1280
|
for (const { node_id } of owners.slice(1)) {
|
|
1261
1281
|
// Clear both durable coordinates so a later reindex cannot restore the
|
|
1262
|
-
// duplicate projection.
|
|
1263
|
-
|
|
1282
|
+
// duplicate projection. The checked write fails rather than erasing a
|
|
1283
|
+
// concurrent metadata change.
|
|
1284
|
+
const cleared = updateJsonFileDurably(nodeMetaPath(node_id), (meta) => {
|
|
1285
|
+
const currentSessionFile = meta['pi_session_file'];
|
|
1286
|
+
if (typeof currentSessionFile !== 'string' || canonical(currentSessionFile) !== pi_session_file) {
|
|
1287
|
+
throw new Error(`node ${node_id} session metadata changed during ownership migration`);
|
|
1288
|
+
}
|
|
1264
1289
|
meta['pi_session_id'] = null;
|
|
1265
1290
|
meta['pi_session_file'] = null;
|
|
1266
1291
|
return true;
|
|
1267
1292
|
});
|
|
1293
|
+
if (!cleared)
|
|
1294
|
+
throw new Error(`node ${node_id} session metadata changed during ownership migration`);
|
|
1268
1295
|
clearRow.run(node_id);
|
|
1269
1296
|
}
|
|
1270
1297
|
}
|
|
1271
|
-
db.exec('DROP INDEX IF EXISTS idx_nodes_pi_session_file;');
|
|
1272
1298
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_pi_session_file ON nodes(pi_session_file);');
|
|
1273
1299
|
}
|
|
1300
|
+
/** v36 — one Pi transcript may have exactly one node owner. This is the narrow
|
|
1301
|
+
* corrective body for clients below v36; it reprojects metadata before choosing
|
|
1302
|
+
* the earliest created duplicate claimant (node_id breaks equal timestamps). */
|
|
1303
|
+
function makeNodeSessionFileUnique(db) {
|
|
1304
|
+
reconcileNodeSessionFileOwners(db, true);
|
|
1305
|
+
}
|
|
1306
|
+
/** v37 — repair v0.3.250's already-applied v36 projection without inventing
|
|
1307
|
+
* coordinates v36 already cleared. Surviving authoritative metadata wins over
|
|
1308
|
+
* stale rows; concurrent surviving claims fail loudly rather than clearing one. */
|
|
1309
|
+
function repairNodeSessionFileUniqueIndex(db) {
|
|
1310
|
+
reconcileNodeSessionFileOwners(db, false);
|
|
1311
|
+
}
|
|
1274
1312
|
/** v35 — durable node lifecycle execution state and terminal cause. */
|
|
1275
1313
|
function addNodeLifecycleColumns(db) {
|
|
1276
1314
|
const columns = nodeColumns(db);
|
|
@@ -1340,6 +1378,7 @@ export const MIGRATIONS = [
|
|
|
1340
1378
|
/* v34 */ addNodeSessionFileIndex,
|
|
1341
1379
|
/* v35 */ addNodeLifecycleColumns,
|
|
1342
1380
|
/* v36 */ makeNodeSessionFileUnique,
|
|
1381
|
+
/* v37 */ repairNodeSessionFileUniqueIndex,
|
|
1343
1382
|
...JOURNALED_STATE_MIGRATIONS,
|
|
1344
1383
|
];
|
|
1345
1384
|
/** Migration indexes that manage their OWN transaction and therefore must not
|
|
@@ -52,7 +52,13 @@ export function createEngineDriver(deps) {
|
|
|
52
52
|
// claims held deferred mail before the prompt body, but its cursor stays
|
|
53
53
|
// uncommitted until the resulting turn's agent_settled event.
|
|
54
54
|
if (route.call === 'prompt' && !session().isStreaming) {
|
|
55
|
-
|
|
55
|
+
try {
|
|
56
|
+
await heldDeferredInboxPromptJoin()?.(session());
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// The watcher emitted the failed durable delivery and retains it for
|
|
60
|
+
// replay. A person's live prompt is not replayable, so it must run.
|
|
61
|
+
}
|
|
56
62
|
}
|
|
57
63
|
await session().prompt(frame.text, { ...promptOptions, preflightResult: release });
|
|
58
64
|
};
|