@north-light/crouter 0.3.249 → 0.3.250
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 +5 -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 +11 -0
- 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/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 +28 -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/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-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
|
@@ -23,9 +23,10 @@
|
|
|
23
23
|
// model-over-driver. Raw driver access is confined to placement, its pure
|
|
24
24
|
// tmux half, the bindings owner, and tmux chrome.
|
|
25
25
|
import { envNodeId } from '../../shared/env.js';
|
|
26
|
-
import { getNode, fullName, deleteNode, listNodes, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, } from '../canvas/index.js';
|
|
26
|
+
import { getNode, fullName, deleteNode, listNodes, subscribersOf, openFocusRow, closeFocusRow, getFocusByNode, getFocusByPane, getFocusById, listFocuses as listFocusRows, } from '../canvas/index.js';
|
|
27
27
|
import { nodeHasAssistantMessage, isStreaming } from '../canvas/render-source.js';
|
|
28
28
|
import { headlessBrokerHost } from './host.js';
|
|
29
|
+
import { fanDoctrineWake } from './close.js';
|
|
29
30
|
import { paneExists, paneLocation, closePane, currentTmux, switchClient, selectWindow, selectLayout, selectPane, getPaneOption, setPaneOption, respawnPaneSync, windowOfPane, renameWindow, } from './tmux-driver.js';
|
|
30
31
|
// The PURE tmux + broker-socket half now lives in placement-tmux.ts. placement
|
|
31
32
|
// composes the viewer OPENER over openViewerPane (tmux) + registerViewerFocus
|
|
@@ -351,12 +352,12 @@ export function reapIfEmpty(nodeId, keepPane) {
|
|
|
351
352
|
}
|
|
352
353
|
/** Sweep the whole canvas for empty shells and reap them — the bulk cleanup
|
|
353
354
|
* behind `crtr canvas prune --empty`, for the husks that accumulated before
|
|
354
|
-
* close/detach learned to reap. Skips: the caller ($CRTR_NODE_ID),
|
|
355
|
-
* mid-first-turn, and any node with a LIVE broker (pi_pid alive) — a
|
|
356
|
-
* engine is the daemon's domain and may have output not yet persisted,
|
|
357
|
-
* GC never kills one (the per-action {@link reapIfEmpty} does, since
|
|
358
|
-
* explicitly closed/detached that node). `dryRun` reports candidates
|
|
359
|
-
* deleting. Returns the reaped (or, under dryRun, reapable) node ids. */
|
|
355
|
+
* close/detach learned to reap. Skips: the caller ($CRTR_NODE_ID), dead rows,
|
|
356
|
+
* nodes mid-first-turn, and any node with a LIVE broker (pi_pid alive) — a
|
|
357
|
+
* running engine is the daemon's domain and may have output not yet persisted,
|
|
358
|
+
* so bulk GC never kills one (the per-action {@link reapIfEmpty} does, since
|
|
359
|
+
* the user explicitly closed/detached that node). `dryRun` reports candidates
|
|
360
|
+
* without deleting. Returns the reaped (or, under dryRun, reapable) node ids. */
|
|
360
361
|
export function reapEmptyNodes(opts = {}) {
|
|
361
362
|
const dryRun = opts.dryRun ?? false;
|
|
362
363
|
const selfId = envNodeId() ?? '';
|
|
@@ -364,9 +365,15 @@ export function reapEmptyNodes(opts = {}) {
|
|
|
364
365
|
for (const row of listNodes()) {
|
|
365
366
|
const id = row.node_id;
|
|
366
367
|
const meta = getNode(id);
|
|
367
|
-
if (id === selfId
|
|
368
|
+
if (id === selfId
|
|
369
|
+
|| row.status === 'dead'
|
|
370
|
+
|| isPidAlive(row.pi_pid)
|
|
371
|
+
|| meta?.managed_worktree?.state === 'open'
|
|
372
|
+
|| !isEmptyNode(id))
|
|
368
373
|
continue;
|
|
374
|
+
const subscribers = subscribersOf(id);
|
|
369
375
|
if (!dryRun) {
|
|
376
|
+
fanDoctrineWake(id, subscribers, `Child closed — ${meta?.name ?? id} (${id}) was closed from the canvas and is no longer running.`, { reason: 'child-closed', child: id });
|
|
370
377
|
headlessBrokerHost.teardown(id);
|
|
371
378
|
tearDownNode(id);
|
|
372
379
|
if (!deleteNode(id).deleted)
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// Best-effort throughout: a tmux/fs failure on one node never aborts the reap.
|
|
23
23
|
import { getNode, updateNode, fullName, closeFocusRow, view, } from '../canvas/index.js';
|
|
24
24
|
import { transition } from './lifecycle.js';
|
|
25
|
-
import { headlessBrokerHost } from './host.js';
|
|
25
|
+
import { headlessBrokerHost, requestBrokerTeardown } from './host.js';
|
|
26
26
|
import { tearDownNode, focusOf, registerViewerFocus, respawnPaneSync, windowOfPane, renameWindow, } from './placement.js';
|
|
27
27
|
import { viewerSplitEnv, waitForBrokerViewSocket } from './placement-tmux.js';
|
|
28
28
|
import { buildLaunchSpecAsync, buildPiArgv } from './launch.js';
|
|
@@ -51,7 +51,7 @@ export function reapDescendants(rootId) {
|
|
|
51
51
|
// viewer pane + registry row (attach auto-reconnects, so the viewer must be
|
|
52
52
|
// closed here or it lingers ~30s in a misleading "reconnecting…" state).
|
|
53
53
|
transition(id, 'cancel', { reason: 'closed' });
|
|
54
|
-
|
|
54
|
+
requestBrokerTeardown(id);
|
|
55
55
|
tearDownNode(id);
|
|
56
56
|
reaped.push(id);
|
|
57
57
|
}
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
// and there is goal/roadmap ground state to re-enter on.
|
|
23
23
|
//
|
|
24
24
|
// reviveNode remains the ONLY sanctioned launcher of the node engine.
|
|
25
|
-
import { existsSync } from 'node:fs';
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
26
|
import { isAbsolute } from 'node:path';
|
|
27
|
-
import { findNodeBySessionFile, getNode, updateNode, clearPid, recordPid, setFrozen, clearFrozen, fullName, subscribersOf, } from '../canvas/index.js';
|
|
27
|
+
import { findNodeBySessionFile, getNode, updateNode, clearPid, recordLaunch, recordPid, setFrozen, clearFrozen, fullName, subscribersOf, } from '../canvas/index.js';
|
|
28
28
|
import { emitEvent } from '../events/emit.js';
|
|
29
29
|
import { cancelCronsOnWake } from '../canvas/crons.js';
|
|
30
30
|
import { isParked, transition } from './lifecycle.js';
|
|
@@ -41,6 +41,7 @@ import { clearContextExposureState, initializeForkContextExposure, } from '../su
|
|
|
41
41
|
import { rootOfSpine } from './nodes.js';
|
|
42
42
|
import { isReviewCompanionBound } from '../review/companion.js';
|
|
43
43
|
import { assertProfileActive } from '../profiles/manifest.js';
|
|
44
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
44
45
|
// resumeArgs — which session source a revive resumes from
|
|
45
46
|
/** Pick the `--session` source for a revive. Both resume=true (a true resume)
|
|
46
47
|
* and resume=false (a refresh-yield) resume by the absolute session-file path
|
|
@@ -64,6 +65,40 @@ export function resumeArgs(meta, resume) {
|
|
|
64
65
|
return {};
|
|
65
66
|
return resume ? { resumeSessionPath: path } : { resumeSessionPath: path, newCycle: true };
|
|
66
67
|
}
|
|
68
|
+
/** Return false only when a valid Pi session header positively names a cwd that
|
|
69
|
+
* has gone away. Missing/corrupt headers remain the engine's concern: old
|
|
70
|
+
* fixtures and partial transcripts do not prove a cwd mismatch. */
|
|
71
|
+
function sessionCwdExists(sessionFile) {
|
|
72
|
+
try {
|
|
73
|
+
for (const line of readFileSync(sessionFile, 'utf8').split('\n')) {
|
|
74
|
+
if (line.trim() === '')
|
|
75
|
+
continue;
|
|
76
|
+
const entry = JSON.parse(line);
|
|
77
|
+
if (entry.type !== 'session')
|
|
78
|
+
continue;
|
|
79
|
+
return typeof entry.cwd !== 'string' || existsSync(entry.cwd);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// The broker/engine owns malformed-transcript diagnostics; it is not proof
|
|
84
|
+
// that the stored cwd is absent.
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/** The session-file preflight has two ordered vetoes. A live owner wins before
|
|
89
|
+
* inspecting its transcript, so this node can never adopt or clear another
|
|
90
|
+
* broker's file. Only an unowned/dead-owner file then reaches the cwd check. */
|
|
91
|
+
function resumableSessionArgs(nodeId, meta, resume) {
|
|
92
|
+
const path = meta.pi_session_file;
|
|
93
|
+
if (path == null || path === '' || !existsSync(path))
|
|
94
|
+
return {};
|
|
95
|
+
const owner = findNodeBySessionFile(path);
|
|
96
|
+
if (owner !== null && owner.node_id !== nodeId && isPidAlive(owner.pi_pid))
|
|
97
|
+
return {};
|
|
98
|
+
if (!sessionCwdExists(path))
|
|
99
|
+
return {};
|
|
100
|
+
return resumeArgs(meta, resume);
|
|
101
|
+
}
|
|
67
102
|
// Return type
|
|
68
103
|
/** A relaunch that has NO transcript to resume or branch from, and no durable
|
|
69
104
|
* work state on disk (no roadmap, no yield note), is not a revive at all — the
|
|
@@ -262,7 +297,7 @@ function launchRevive(nodeId, meta, opts) {
|
|
|
262
297
|
const refreshPending = meta.intent === 'refresh' || meta.cycle_pending === true;
|
|
263
298
|
const parkedReentry = isParked(meta) && hasFreshGroundState(nodeId) && hasUnseenWakeMail(nodeId);
|
|
264
299
|
const effectiveResume = refreshPending || parkedReentry ? false : opts.resume;
|
|
265
|
-
const resume = forkBirthPending ? {} :
|
|
300
|
+
const resume = forkBirthPending ? {} : resumableSessionArgs(nodeId, meta, effectiveResume);
|
|
266
301
|
// hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
|
|
267
302
|
// as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
|
|
268
303
|
// the latter case). cycling and resuming are mutually exclusive; `resuming`
|
|
@@ -354,7 +389,7 @@ function launchRevive(nodeId, meta, opts) {
|
|
|
354
389
|
// this marker advertised. Snapshot the boot-fault attempt in the SAME
|
|
355
390
|
// breath (see beginBootFaultAttempt): its own verification, not this call's
|
|
356
391
|
// unchecked result, is what scopes a later diagnostic read to this attempt.
|
|
357
|
-
clearFault(nodeId);
|
|
392
|
+
clearFault(nodeId, { preserveProviderRetryEpisode: true });
|
|
358
393
|
const bootFault = beginBootFaultAttempt(nodeId);
|
|
359
394
|
// A cron armed --cancel-on-wake belongs to the dormancy being left, so a WAKE
|
|
360
395
|
// of its anchor deletes it — an inbox delivery, an attach, an explicit
|
|
@@ -394,6 +429,7 @@ function launchRevive(nodeId, meta, opts) {
|
|
|
394
429
|
clearPid(nodeId);
|
|
395
430
|
throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
|
|
396
431
|
}
|
|
432
|
+
recordLaunch(nodeId, new Date().toISOString());
|
|
397
433
|
// Record the real broker pid. The pid is METADATA now (design D-13) — the
|
|
398
434
|
// liveness authority is the fleet entry registered synchronously inside
|
|
399
435
|
// `launch` above — but session_start re-records it after extension bind,
|
|
@@ -11,6 +11,9 @@ export interface SpawnChildOpts {
|
|
|
11
11
|
prompt: string;
|
|
12
12
|
/** Override the parent (defaults to the calling node from env). */
|
|
13
13
|
parent?: string;
|
|
14
|
+
/** Actor that created this node. Unlike parent (manager) and spawned_by
|
|
15
|
+
* (provenance), it controls the parent's birth announcement. */
|
|
16
|
+
creator?: string | null;
|
|
14
17
|
/** Spawn an INDEPENDENT root instead of a managed child: parent=null, no
|
|
15
18
|
* subscription back to the spawner, spawned_by=spawner. Resident and brought
|
|
16
19
|
* forefront by default — see `rootLifecycle` for the unattended variant. */
|
|
@@ -275,6 +275,7 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
275
275
|
// records spawned_by=spawner when a node (not a human shell) spawned it.
|
|
276
276
|
// A child's parent IS its manager.
|
|
277
277
|
parent: root ? null : spawner,
|
|
278
|
+
creator: opts.creator,
|
|
278
279
|
spawnedBy: root ? (spawner ?? undefined) : undefined,
|
|
279
280
|
profile_id: profileId,
|
|
280
281
|
forkFrom: opts.forkFrom,
|
|
@@ -24,6 +24,10 @@ export interface WarmRequest {
|
|
|
24
24
|
model: string | null;
|
|
25
25
|
situationalContext: string | null;
|
|
26
26
|
name: string | null;
|
|
27
|
+
/** Node that asked for this create, or null for an external process. Claim-time
|
|
28
|
+
* identity like `name` — a spare is minted creator-less and adopts the
|
|
29
|
+
* claimant's, so provenance survives the warm path. */
|
|
30
|
+
creator: string | null;
|
|
27
31
|
}
|
|
28
32
|
/** Claim a spare for `request`, reshaped onto its persona — or null when the
|
|
29
33
|
* pool cannot serve it. The caller falls back to a cold spawn; a claim never
|
|
@@ -128,6 +128,7 @@ export async function claimWarmNode(request) {
|
|
|
128
128
|
kind: request.kind,
|
|
129
129
|
mode: request.mode,
|
|
130
130
|
name: request.name ?? request.kind,
|
|
131
|
+
creator: request.creator,
|
|
131
132
|
// The recipe a later revive replays — skip it and the node silently comes
|
|
132
133
|
// back as the spare's persona.
|
|
133
134
|
launch: recipe.launch,
|
|
@@ -354,6 +355,7 @@ export function prewarmRecentRecipes() {
|
|
|
354
355
|
model: null,
|
|
355
356
|
situationalContext: null,
|
|
356
357
|
name: null,
|
|
358
|
+
creator: null,
|
|
357
359
|
}).then(mint).catch((err) => console.error(`[warm-pool] prewarm failed for ${candidate.cwd}: ${err.message}`));
|
|
358
360
|
}
|
|
359
361
|
catch (err) {
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -57,9 +57,10 @@ interface CleanEmptyProof {
|
|
|
57
57
|
* be a git-ancestor of (or equal to) the CURRENT tip of its recorded base
|
|
58
58
|
* branch — i.e. landing it would be a pure no-op. Any ambiguity (missing
|
|
59
59
|
* path, mid-rebase, wrong branch, an unresolvable base, or a real ancestry
|
|
60
|
-
* divergence) returns null
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* divergence) returns null. A missing base is the one exception: equality
|
|
61
|
+
* between the live HEAD and recorded creation SHA proves no work was added.
|
|
62
|
+
* This function only ever proves a no-op land from live Git state, never on
|
|
63
|
+
* a heuristic guess, so real unlanded work always falls through to the explicit-close guard. The locked
|
|
63
64
|
* path re-checks this predicate before recording deferred cleanup; production
|
|
64
65
|
* code must always go through `autoDropCleanManagedWorktree`. */
|
|
65
66
|
export declare function proveCleanEmptyManagedWorktree(wt: ManagedWorktree): CleanEmptyProof | null;
|
package/dist/core/worktree.js
CHANGED
|
@@ -51,9 +51,17 @@ function resolveLandableBase(repoRoot, baseRef) {
|
|
|
51
51
|
// syntax, which could turn a SHA or a `^{commit}` suffix into a non-landable
|
|
52
52
|
// base even though no local branch by that exact name exists.
|
|
53
53
|
const exists = gitSync(['show-ref', '--verify', '--quiet', ref], repoRoot);
|
|
54
|
-
|
|
54
|
+
// `show-ref --verify --quiet` reserves exit 1 for an absent ref. gitSync
|
|
55
|
+
// preserves that numeric status for compatibility, but marks spawn failures,
|
|
56
|
+
// timeouts, and signal exits so they cannot masquerade as an absent branch.
|
|
57
|
+
if (exists.status === 1 && exists.executionFailed !== true) {
|
|
55
58
|
throw new WorktreeError('missing_base_branch', `managed worktree base '${baseRef}' does not name an existing local branch`, 'The base must be a local branch that landing can fast-forward — freshen your local branch from origin first (rebase-not-merge), then base on it.');
|
|
56
59
|
}
|
|
60
|
+
if (exists.status !== 0) {
|
|
61
|
+
const stderr = exists.stderr.trim() || '<empty>';
|
|
62
|
+
const execution = exists.processError === undefined ? '' : `\nprocess error: ${exists.processError}`;
|
|
63
|
+
throw new WorktreeError('base_branch_inspection_failed', `could not inspect local base branch '${baseRef}' (git show-ref exited with status ${exists.status})`, 'Inspect the repository and its Git metadata, then retry.', `status: ${exists.status}\nstderr: ${stderr}${execution}`);
|
|
64
|
+
}
|
|
57
65
|
return runGit(repoRoot, ['rev-parse', '--verify', ref], 'base_branch_resolve_failed', 'Inspect the local base branch, then retry.');
|
|
58
66
|
}
|
|
59
67
|
/** Path of the worktree where a local branch is currently checked out, or null
|
|
@@ -238,16 +246,23 @@ export function isRebaseInProgress(cwd) {
|
|
|
238
246
|
return existsSync(gitPath(cwd, 'rebase-merge')) || existsSync(gitPath(cwd, 'rebase-apply'));
|
|
239
247
|
}
|
|
240
248
|
/** The ONE authoritative closed-worktree state transition, shared by both
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
+
* closing paths (explicit `closeManagedWorktreeLocked` land, and the clean
|
|
250
|
+
* `autoDropCleanManagedWorktreeLocked` push-final drop): persist
|
|
251
|
+
* `managed_worktree.state: 'closed'`, name its cleanup state, and repoint the
|
|
252
|
+
* node's launch `cwd` to the durable base checkout. The caller broker stays
|
|
253
|
+
* alive and is Pi's sole transcript writer, so its session header cannot be
|
|
254
|
+
* safely rewritten here; clear the session coordinates and let the later
|
|
255
|
+
* revive launch fresh instead. Both closing paths MUST go through this helper
|
|
256
|
+
* instead of hand-rolling the `updateNode` call, so neither can drift out of
|
|
257
|
+
* sync. */
|
|
249
258
|
function persistClosedWorktreeState(nodeId, wt, cleanup = 'pending') {
|
|
250
|
-
|
|
259
|
+
const node = getNode(nodeId);
|
|
260
|
+
const sessionInvalidated = node?.pi_session_id != null || node?.pi_session_file != null;
|
|
261
|
+
updateNode(nodeId, {
|
|
262
|
+
cwd: wt.repo_root,
|
|
263
|
+
...(sessionInvalidated ? { pi_session_id: null, pi_session_file: null } : {}),
|
|
264
|
+
managed_worktree: { ...wt, state: 'closed', cleanup, closed: new Date().toISOString() },
|
|
265
|
+
});
|
|
251
266
|
}
|
|
252
267
|
function deferredCleanupResult(wt) {
|
|
253
268
|
const remove = `git -C ${shellQuote(wt.repo_root)} worktree remove ${shellQuote(wt.path)}`;
|
|
@@ -301,7 +316,29 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
301
316
|
const found = branch === 'HEAD' ? 'detached HEAD' : branch;
|
|
302
317
|
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch}, found ${found})`, `Check out ${wt.branch} in ${wt.path} before closing, or ask the user with \`crtr human send\` if you are unsure why it changed, then rerun \`crtr node worktree close\`.`);
|
|
303
318
|
}
|
|
304
|
-
|
|
319
|
+
let baseSha;
|
|
320
|
+
try {
|
|
321
|
+
// `repo_root` is the checkout that created this child, and it can vanish
|
|
322
|
+
// when that parent finishes first. The live managed checkout still names
|
|
323
|
+
// the shared repository and is the authority for every close operation.
|
|
324
|
+
baseSha = resolveLandableBase(wt.path, wt.base_ref);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
// A deleted parent base cannot receive a fast-forward. A clean child at
|
|
328
|
+
// its recorded creation SHA has nothing to land, however, so conclude its
|
|
329
|
+
// deferred cleanup without guessing where work should go.
|
|
330
|
+
const head = runGit(wt.path, ['rev-parse', 'HEAD'], 'landed_sha_failed', 'Inspect the worktree checkout manually, then rerun `crtr node worktree close`.');
|
|
331
|
+
if (!(error instanceof WorktreeError) || error.code !== 'missing_base_branch' || head !== wt.base_sha)
|
|
332
|
+
throw error;
|
|
333
|
+
persistClosedWorktreeState(nodeId, wt, 'pending');
|
|
334
|
+
return {
|
|
335
|
+
node_id: nodeId,
|
|
336
|
+
branch: wt.branch,
|
|
337
|
+
worktree_path: wt.path,
|
|
338
|
+
landed_sha: wt.base_sha,
|
|
339
|
+
...deferredCleanupResult(wt),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
305
342
|
const rebase = gitSync(['rebase', baseSha], wt.path);
|
|
306
343
|
if (rebase.status !== 0) {
|
|
307
344
|
const detail = (rebase.stderr.trim() || rebase.stdout.trim()).trim();
|
|
@@ -312,11 +349,11 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
312
349
|
// captured baseSha — landing is purely local, nothing is ever pushed to
|
|
313
350
|
// origin. Both landing paths require the base to still be exactly at that
|
|
314
351
|
// captured tip, so neither can overwrite a concurrent base change.
|
|
315
|
-
const fastForward = gitSync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.
|
|
352
|
+
const fastForward = gitSync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.path);
|
|
316
353
|
if (fastForward.status !== 0) {
|
|
317
354
|
throw new WorktreeError('land_base_failed', `rebased worktree is not a fast-forward of local base branch '${wt.base_ref}'`, 'Ask the user with `crtr human send` how to land this managed worktree.', (fastForward.stderr.trim() || fastForward.stdout.trim()).trim());
|
|
318
355
|
}
|
|
319
|
-
const baseCheckout = checkoutPathForBranch(wt.
|
|
356
|
+
const baseCheckout = checkoutPathForBranch(wt.path, wt.base_ref);
|
|
320
357
|
if (baseCheckout !== null) {
|
|
321
358
|
const checkoutHead = gitSync(['rev-parse', 'HEAD'], baseCheckout);
|
|
322
359
|
const actualHead = checkoutHead.stdout.trim();
|
|
@@ -329,7 +366,7 @@ function closeManagedWorktreeLocked(nodeId) {
|
|
|
329
366
|
}
|
|
330
367
|
const land = baseCheckout !== null
|
|
331
368
|
? gitSync(['merge', '--ff-only', landedSha], baseCheckout)
|
|
332
|
-
: gitSync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.
|
|
369
|
+
: gitSync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.path);
|
|
333
370
|
if (land.status !== 0) {
|
|
334
371
|
const detail = (land.stderr.trim() || land.stdout.trim()).trim();
|
|
335
372
|
throw new WorktreeError('land_base_failed', `could not fast-forward local base branch '${wt.base_ref}' to this worktree`, baseCheckout !== null
|
|
@@ -356,7 +393,10 @@ export function closeManagedWorktree(nodeId) {
|
|
|
356
393
|
// repository to lock; every state-mutating close path runs under the lock.
|
|
357
394
|
if (wt === null)
|
|
358
395
|
return closeManagedWorktreeLocked(nodeId);
|
|
359
|
-
|
|
396
|
+
// An absent checkout has no live Git directory; its separate recovery proof
|
|
397
|
+
// still relies on the recorded repository path. Every live close locks from
|
|
398
|
+
// the child checkout instead of the parent that created it.
|
|
399
|
+
return withRepositoryWorktreeLock(existsSync(wt.path) ? wt.path : wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
|
|
360
400
|
}
|
|
361
401
|
const ABANDON_AUTHOR_NAME = 'crtr-abandon';
|
|
362
402
|
const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
|
|
@@ -519,9 +559,10 @@ export function missingWorktreeRecoveryHint(wt) {
|
|
|
519
559
|
* be a git-ancestor of (or equal to) the CURRENT tip of its recorded base
|
|
520
560
|
* branch — i.e. landing it would be a pure no-op. Any ambiguity (missing
|
|
521
561
|
* path, mid-rebase, wrong branch, an unresolvable base, or a real ancestry
|
|
522
|
-
* divergence) returns null
|
|
523
|
-
*
|
|
524
|
-
*
|
|
562
|
+
* divergence) returns null. A missing base is the one exception: equality
|
|
563
|
+
* between the live HEAD and recorded creation SHA proves no work was added.
|
|
564
|
+
* This function only ever proves a no-op land from live Git state, never on
|
|
565
|
+
* a heuristic guess, so real unlanded work always falls through to the explicit-close guard. The locked
|
|
525
566
|
* path re-checks this predicate before recording deferred cleanup; production
|
|
526
567
|
* code must always go through `autoDropCleanManagedWorktree`. */
|
|
527
568
|
export function proveCleanEmptyManagedWorktree(wt) {
|
|
@@ -537,18 +578,23 @@ export function proveCleanEmptyManagedWorktree(wt) {
|
|
|
537
578
|
const status = gitSync(['status', '--porcelain'], wt.path);
|
|
538
579
|
if (status.status !== 0 || status.stdout.trim() !== '')
|
|
539
580
|
return null;
|
|
540
|
-
let baseSha;
|
|
541
|
-
try {
|
|
542
|
-
baseSha = resolveLandableBase(wt.repo_root, wt.base_ref);
|
|
543
|
-
}
|
|
544
|
-
catch {
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
547
581
|
const head = gitSync(['rev-parse', 'HEAD'], wt.path);
|
|
548
582
|
if (head.status !== 0)
|
|
549
583
|
return null;
|
|
550
584
|
const branchSha = head.stdout.trim();
|
|
551
|
-
|
|
585
|
+
let baseSha;
|
|
586
|
+
try {
|
|
587
|
+
baseSha = resolveLandableBase(wt.path, wt.base_ref);
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
// A parent-first close can remove the recorded base branch. Equality with
|
|
591
|
+
// the durable creation SHA proves this clean child never added work.
|
|
592
|
+
if (error instanceof WorktreeError && error.code === 'missing_base_branch' && branchSha === wt.base_sha) {
|
|
593
|
+
return { branchSha, baseSha: wt.base_sha };
|
|
594
|
+
}
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
const ancestor = gitSync(['merge-base', '--is-ancestor', branchSha, baseSha], wt.path);
|
|
552
598
|
if (ancestor.status !== 0)
|
|
553
599
|
return null;
|
|
554
600
|
return { branchSha, baseSha };
|
|
@@ -612,7 +658,7 @@ export function autoDropCleanManagedWorktree(nodeId) {
|
|
|
612
658
|
const pathAbsent = !existsSync(wt.path);
|
|
613
659
|
if (pathAbsent ? proveAbsentPathManagedWorktree(wt) === null : proveCleanEmptyManagedWorktree(wt) === null)
|
|
614
660
|
return null;
|
|
615
|
-
return withRepositoryWorktreeLock(wt.repo_root, () => {
|
|
661
|
+
return withRepositoryWorktreeLock(pathAbsent ? wt.repo_root : wt.path, () => {
|
|
616
662
|
// Re-fetch inside the lock: state may have changed since the caller's
|
|
617
663
|
// hasOpenManagedWorktree check ran outside it.
|
|
618
664
|
const locked = openManagedWorktreeForNode(nodeId);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Regression for NL-159 / #571: the daemon's pidfile/ownership claim must not
|
|
2
|
+
// survive a failed mandatory Unix API bind. A directory at the socket path
|
|
3
|
+
// deterministically makes `listen()` emit EADDRINUSE without touching crtrd's
|
|
4
|
+
// real canvas or runtime homes.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
import { closeDb } from '../../../core/canvas/db.js';
|
|
10
|
+
import { claimDaemonOwnership, releaseDaemonOwnership } from '../../../core/canvas/daemon-owner.js';
|
|
11
|
+
import { resetEventSourceForTests } from '../../../core/events/source.js';
|
|
12
|
+
import { resetFleetForTests } from '../../../core/runtime/fleet.js';
|
|
13
|
+
import { runDaemon } from '../../crtrd.js';
|
|
14
|
+
import { spawnDaemon } from '../../manage.js';
|
|
15
|
+
const isolatedEnvKeys = [
|
|
16
|
+
'HOME',
|
|
17
|
+
'CRTR_HOME',
|
|
18
|
+
'CRTR_PIDFILE',
|
|
19
|
+
'CRTR_RUNTIME_HOME',
|
|
20
|
+
'XDG_CACHE_HOME',
|
|
21
|
+
'XDG_CONFIG_HOME',
|
|
22
|
+
'XDG_DATA_HOME',
|
|
23
|
+
'PI_CODING_AGENT_DIR',
|
|
24
|
+
];
|
|
25
|
+
test('a Unix socket bind failure rejects readiness and releases daemon ownership', async () => {
|
|
26
|
+
const root = mkdtempSync('/tmp/crtr-api-bind-failure-');
|
|
27
|
+
const home = join(root, 'home');
|
|
28
|
+
const canvasHome = join(root, 'canvas');
|
|
29
|
+
const socketPath = join(canvasHome, 'crtrd.sock');
|
|
30
|
+
const previousCwd = process.cwd();
|
|
31
|
+
const previousEnv = new Map(isolatedEnvKeys.map((key) => [key, process.env[key]]));
|
|
32
|
+
mkdirSync(home, { recursive: true });
|
|
33
|
+
mkdirSync(socketPath, { recursive: true });
|
|
34
|
+
try {
|
|
35
|
+
process.chdir(home);
|
|
36
|
+
process.env['HOME'] = home;
|
|
37
|
+
process.env['CRTR_HOME'] = canvasHome;
|
|
38
|
+
process.env['CRTR_PIDFILE'] = join(canvasHome, 'crtrd.pid');
|
|
39
|
+
process.env['CRTR_RUNTIME_HOME'] = join(root, 'runtime');
|
|
40
|
+
process.env['XDG_CACHE_HOME'] = join(root, 'cache');
|
|
41
|
+
process.env['XDG_CONFIG_HOME'] = join(root, 'config');
|
|
42
|
+
process.env['XDG_DATA_HOME'] = join(root, 'data');
|
|
43
|
+
process.env['PI_CODING_AGENT_DIR'] = join(home, '.pi', 'agent');
|
|
44
|
+
closeDb();
|
|
45
|
+
resetEventSourceForTests();
|
|
46
|
+
resetFleetForTests();
|
|
47
|
+
await assert.rejects(() => runDaemon(), (error) => error.code === 'EADDRINUSE', 'the mandatory API socket bind must reject startup');
|
|
48
|
+
const pidfile = process.env['CRTR_PIDFILE'];
|
|
49
|
+
assert.equal(existsSync(pidfile), false, 'failed readiness removes the pidfile mirror');
|
|
50
|
+
assert.equal(existsSync(socketPath), true, 'the pre-existing directory was never mistaken for a serving socket');
|
|
51
|
+
assert.match(readFileSync(join(canvasHome, 'crtrd.log'), 'utf8'), /api\.server\.failed/, 'the bind failure is recorded for cold-start diagnostics');
|
|
52
|
+
writeFileSync(pidfile, String(process.pid));
|
|
53
|
+
assert.deepEqual(await spawnDaemon(), {
|
|
54
|
+
started: false,
|
|
55
|
+
existing_pid: process.pid,
|
|
56
|
+
running: true,
|
|
57
|
+
serving: false,
|
|
58
|
+
next: 'Run `crtr sys daemon stop` to release the non-serving daemon before starting it again.',
|
|
59
|
+
}, 'start distinguishes a live non-serving pidfile owner from a serving daemon');
|
|
60
|
+
rmSync(pidfile, { force: true });
|
|
61
|
+
const retryClaim = claimDaemonOwnership('post-api-bind-failure');
|
|
62
|
+
assert.equal(retryClaim.claimed, true, 'failed readiness releases the authoritative ownership claim');
|
|
63
|
+
if (retryClaim.claimed) {
|
|
64
|
+
assert.equal(releaseDaemonOwnership(retryClaim.claim).released, true, 'the test-only retry claim releases cleanly');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
closeDb();
|
|
69
|
+
resetEventSourceForTests();
|
|
70
|
+
resetFleetForTests();
|
|
71
|
+
process.chdir(previousCwd);
|
|
72
|
+
for (const key of isolatedEnvKeys) {
|
|
73
|
+
const previous = previousEnv.get(key);
|
|
74
|
+
if (previous === undefined)
|
|
75
|
+
delete process.env[key];
|
|
76
|
+
else
|
|
77
|
+
process.env[key] = previous;
|
|
78
|
+
}
|
|
79
|
+
rmSync(root, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
4
|
import { createServer } from 'node:net';
|
|
5
5
|
import { createNode, getNode, openDb } from '../../../core/canvas/index.js';
|
|
6
|
+
import { armCron, getCron } from '../../../core/canvas/crons.js';
|
|
6
7
|
import { closeDb } from '../../../core/canvas/db.js';
|
|
7
8
|
import { viewSocketPath } from '../../../core/canvas/paths.js';
|
|
8
9
|
import { readInboxSince } from '../../../core/feed/inbox.js';
|
|
@@ -98,6 +99,43 @@ function assertReopened(id) {
|
|
|
98
99
|
assert.equal(meta?.lifecycle, 'resident');
|
|
99
100
|
assert.equal(meta?.final_report, null);
|
|
100
101
|
}
|
|
102
|
+
function armDeadline(id) {
|
|
103
|
+
armCron({
|
|
104
|
+
cron_id: `${id}-deadline`,
|
|
105
|
+
name: `${id} deadline`,
|
|
106
|
+
command: 'echo stale deadline',
|
|
107
|
+
fire_at: new Date(Date.now() + 60_000).toISOString(),
|
|
108
|
+
recur: null,
|
|
109
|
+
tz: null,
|
|
110
|
+
expires_at: null,
|
|
111
|
+
anchor_node: id,
|
|
112
|
+
cancel_on_wake: true,
|
|
113
|
+
created_by: null,
|
|
114
|
+
cwd: home,
|
|
115
|
+
env_json: null,
|
|
116
|
+
profile: null,
|
|
117
|
+
scope: 'profile',
|
|
118
|
+
run_timeout_s: 30,
|
|
119
|
+
overlap: 'skip',
|
|
120
|
+
on_output: 'silent',
|
|
121
|
+
sink: '{}',
|
|
122
|
+
tier: 'normal',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
test('live interactive delivery clears the target deadline', async () => {
|
|
126
|
+
const id = 'live-deadline';
|
|
127
|
+
createNode(node(id, 'terminal', 'active', process.pid));
|
|
128
|
+
armDeadline(id);
|
|
129
|
+
const broker = await startLiveBroker(id);
|
|
130
|
+
try {
|
|
131
|
+
const result = await sendMessage(id, { body: 'wake before deadline', delivery: 'interactive' });
|
|
132
|
+
assert.equal(result.body.delivered_via, 'engine');
|
|
133
|
+
assert.equal(getCron(`${id}-deadline`), null, 'the accepted engine delivery won the deadline race');
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await broker.close();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
101
139
|
test('reopen makes live terminal, finalized terminal, and parked targets resident and unlatched before delivery', async () => {
|
|
102
140
|
const liveId = 'live-terminal';
|
|
103
141
|
createNode(node(liveId, 'terminal', 'active', process.pid));
|
|
@@ -117,6 +117,7 @@ test('A3: messages append inbox.jsonl; final report writes + notifies subscriber
|
|
|
117
117
|
assert.ok(res.notified.includes(A), `final push notifies the spawn-subscribed manager A (notified=${JSON.stringify(res.notified)})`);
|
|
118
118
|
assert.deepEqual(res.transitioned, { from: 'active', to: 'done' }, 'row transitioned active→done');
|
|
119
119
|
assert.equal(h.node(B)?.status, 'done', 'row status is done after final');
|
|
120
|
+
await assert.rejects(() => client.pushReport(B, { tier: 'final', delivery_tier: 'deferred', body: 'invalid tiered final' }), (error) => error instanceof ApiError && error.code === 'usage' && error.message === 'delivery_tier is only valid for update reports', 'the API rejects a delivery tier on final before it can alter final delivery');
|
|
120
121
|
});
|
|
121
122
|
// A4 — WS attach reconstructs the same settled broker snapshot as a direct
|
|
122
123
|
// ViewSocketClient; ?revive=0 on a dormant node → 409 without launching a broker
|
|
@@ -30,6 +30,7 @@ import { registerPush } from '../../../../commands/push.js';
|
|
|
30
30
|
import { registerNode } from '../../../../commands/node.js';
|
|
31
31
|
import { createNode, getNode } from '../../../../core/canvas/canvas.js';
|
|
32
32
|
import { createManagedWorktree } from '../../../../core/worktree.js';
|
|
33
|
+
import { readInboxSince } from '../../../../core/feed/inbox.js';
|
|
33
34
|
import { gitSync } from '../../../../core/git.js';
|
|
34
35
|
import { InputError } from '../../../../core/io.js';
|
|
35
36
|
// A terminal fabricated node (no broker needed) that OWNS a managed worktree —
|
|
@@ -72,11 +73,11 @@ function pushLeaf(tier) {
|
|
|
72
73
|
// Drive the leaf as the given caller node — sets CRTR_NODE_ID (client-side
|
|
73
74
|
// identity resolution stays in the CLI process, per the B-5 scope) around the
|
|
74
75
|
// run, restoring it after.
|
|
75
|
-
async function runPushAs(caller, tier, body) {
|
|
76
|
+
async function runPushAs(caller, tier, body, input = {}) {
|
|
76
77
|
const prev = process.env['CRTR_NODE_ID'];
|
|
77
78
|
process.env['CRTR_NODE_ID'] = caller;
|
|
78
79
|
try {
|
|
79
|
-
return (await pushLeaf(tier).run({ body }));
|
|
80
|
+
return (await pushLeaf(tier).run({ body, ...input }));
|
|
80
81
|
}
|
|
81
82
|
finally {
|
|
82
83
|
if (prev === undefined)
|
|
@@ -151,6 +152,16 @@ test('`crtr push final` from a child writes a report, notifies the subscribed pa
|
|
|
151
152
|
// — the re-homed guard only engages for a managed-worktree owner.
|
|
152
153
|
assert.equal('worktree_auto_dropped' in out, false, 'a normal node final is unaffected by the worktree guard');
|
|
153
154
|
});
|
|
155
|
+
test('`crtr push update --tier deferred` fans a deferred pointer while preserving an update report', async () => {
|
|
156
|
+
const parent = h.spawnRoot('leaf-api-parity deferred parent');
|
|
157
|
+
const child = (await client.createNode({ kind: 'developer', parent, prompt: 'deferred worker' })).node_id;
|
|
158
|
+
await h.awaitBoot(child);
|
|
159
|
+
const out = await runPushAs(child, 'update', 'deferred progress', { tier: 'deferred' });
|
|
160
|
+
assert.ok(out['notified'].includes(parent), 'the subscribed parent receives the report pointer');
|
|
161
|
+
const entry = readInboxSince(parent).find((candidate) => candidate.from === child && candidate.label === 'deferred progress');
|
|
162
|
+
assert.equal(entry?.kind, 'update', 'the stored report remains an update');
|
|
163
|
+
assert.equal(entry?.tier, 'deferred', 'the requested delivery tier reaches the parent');
|
|
164
|
+
});
|
|
154
165
|
// Re-homed worktree guard/auto-drop (ambiguity-2 follow-up): the pre-B-5 CLI
|
|
155
166
|
// `push final` worktree behavior now lives SERVER-side in handlers/reports.ts,
|
|
156
167
|
// exercised end-to-end through the real push.ts leaf.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { appendInbox } from '../../../core/feed/inbox.js';
|
|
5
5
|
import { contextDir, isSafeNodeId } from '../../../core/canvas/paths.js';
|
|
6
6
|
import { getNode } from '../../../core/canvas/canvas.js';
|
|
7
|
+
import { cancelCronsOnWake } from '../../../core/canvas/crons.js';
|
|
7
8
|
import { isBrokerLive } from '../../../core/runtime/model-swap.js';
|
|
8
9
|
import { assertNotFinalized } from '../../../core/runtime/reopen.js';
|
|
9
10
|
import { reviveNode } from '../../../core/runtime/revive.js';
|
|
@@ -74,6 +75,7 @@ async function handleStop(ctx) {
|
|
|
74
75
|
label: body.split('\n')[0].slice(0, 120),
|
|
75
76
|
data: { body },
|
|
76
77
|
});
|
|
78
|
+
cancelCronsOnWake(nodeId);
|
|
77
79
|
const node = getNode(nodeId);
|
|
78
80
|
if (node !== null && !isBrokerLive(node)) {
|
|
79
81
|
try {
|