@north-light/crouter 0.3.236 → 0.3.237
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/client.d.ts +2 -1
- package/dist/api/client.js +3 -0
- package/dist/api/dto/broker-ops.d.ts +1 -1
- package/dist/api/dto/common.d.ts +2 -0
- package/dist/api/dto/lifecycle.d.ts +9 -2
- package/dist/api/dto/messages.d.ts +5 -0
- package/dist/api/dto/nodes.d.ts +10 -2
- package/dist/api/dto/worktree.d.ts +10 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
- package/dist/builtin-memory/internal/agent-shaping.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
- package/dist/clients/attach/render/tool-calls.js +18 -3
- package/dist/clients/attach/viewer.js +426 -426
- package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
- package/dist/clients/inbox/review/roundtrip.js +1 -1
- package/dist/commands/memory/lint.js +22 -13
- package/dist/commands/memory/shared.js +1 -1
- package/dist/commands/node/create.d.ts +1 -1
- package/dist/commands/node/create.js +1 -1
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/commands/node-worktree.js +39 -8
- package/dist/commands/revive.js +11 -2
- package/dist/commands/sys/context/admin/actions.d.ts +48 -0
- package/dist/commands/sys/context/admin/actions.js +246 -0
- package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
- package/dist/commands/sys/context/admin/axes-panel.js +117 -0
- package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
- package/dist/commands/sys/context/admin/detail-panel.js +178 -0
- package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
- package/dist/commands/sys/context/admin/docs-panel.js +186 -0
- package/dist/commands/sys/context/admin/model.d.ts +75 -0
- package/dist/commands/sys/context/admin/model.js +209 -0
- package/dist/commands/sys/context/admin/shell.d.ts +82 -0
- package/dist/commands/sys/context/admin/shell.js +669 -0
- package/dist/commands/sys/context/admin.d.ts +1 -0
- package/dist/commands/sys/context/admin.js +124 -0
- package/dist/commands/sys/context/doc.d.ts +1 -0
- package/dist/commands/sys/context/doc.js +196 -0
- package/dist/commands/sys/context/prompt-review.d.ts +1 -0
- package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
- package/dist/commands/sys/context/resolve.d.ts +55 -0
- package/dist/commands/sys/context/resolve.js +428 -0
- package/dist/commands/sys/context/sysprompt.d.ts +1 -0
- package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
- package/dist/commands/sys/context.d.ts +2 -0
- package/dist/commands/sys/context.js +20 -0
- package/dist/commands/sys.js +3 -4
- package/dist/core/__tests__/child-death-wake.test.js +38 -22
- package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
- package/dist/core/__tests__/daemon-boot.test.js +40 -28
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
- package/dist/core/__tests__/integration/revive.test.js +12 -12
- package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
- package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
- package/dist/core/__tests__/migration.test.js +38 -1
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
- package/dist/core/__tests__/revive-capacity.test.js +127 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
- package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
- package/dist/core/canvas/browse/render.js +11 -3
- package/dist/core/canvas/canvas.d.ts +15 -3
- package/dist/core/canvas/canvas.js +52 -16
- package/dist/core/canvas/migrations.js +21 -0
- package/dist/core/canvas/nav-render.js +3 -1
- package/dist/core/canvas/remote-canvas-source.js +2 -0
- package/dist/core/canvas/render-source.d.ts +3 -1
- package/dist/core/canvas/render-source.js +7 -3
- package/dist/core/canvas/status-glyph.d.ts +10 -1
- package/dist/core/canvas/status-glyph.js +19 -3
- package/dist/core/canvas/types.d.ts +20 -1
- package/dist/core/feed/feed.js +1 -1
- package/dist/core/human/feedback-companion.js +5 -2
- package/dist/core/memory/lint.d.ts +19 -0
- package/dist/core/memory/lint.js +97 -0
- package/dist/core/memory-resolver.d.ts +4 -0
- package/dist/core/memory-resolver.js +7 -7
- package/dist/core/preview-registry.js +4 -4
- package/dist/core/review/realize.js +4 -2
- package/dist/core/runtime/close.js +1 -1
- package/dist/core/runtime/fleet.d.ts +29 -1
- package/dist/core/runtime/fleet.js +23 -0
- package/dist/core/runtime/host.js +32 -11
- package/dist/core/runtime/launch-prompt.d.ts +1 -1
- package/dist/core/runtime/launch-prompt.js +2 -2
- package/dist/core/runtime/lifecycle.d.ts +4 -2
- package/dist/core/runtime/lifecycle.js +12 -1
- package/dist/core/runtime/memory.js +1 -1
- package/dist/core/runtime/reopen.js +10 -1
- package/dist/core/runtime/reset.js +1 -1
- package/dist/core/runtime/revive-all.d.ts +3 -0
- package/dist/core/runtime/revive-all.js +10 -4
- package/dist/core/runtime/revive.d.ts +12 -0
- package/dist/core/runtime/revive.js +62 -3
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +36 -7
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/core/substrate/index.d.ts +6 -3
- package/dist/core/substrate/index.js +4 -3
- package/dist/core/substrate/on-read.d.ts +0 -8
- package/dist/core/substrate/on-read.js +45 -124
- package/dist/core/substrate/plan.d.ts +94 -0
- package/dist/core/substrate/plan.js +266 -0
- package/dist/core/substrate/render.d.ts +5 -6
- package/dist/core/substrate/render.js +76 -129
- package/dist/core/substrate/schema.d.ts +1 -6
- package/dist/core/substrate/schema.js +1 -15
- package/dist/core/substrate/session-cache.d.ts +2 -10
- package/dist/core/substrate/session-cache.js +9 -47
- package/dist/core/substrate/subject-fields.d.ts +1 -4
- package/dist/core/substrate/subject-fields.js +2 -5
- package/dist/core/substrate/surface-match.d.ts +37 -17
- package/dist/core/substrate/surface-match.js +44 -53
- package/dist/core/tui/page-host.d.ts +6 -0
- package/dist/core/tui/page-host.js +12 -3
- package/dist/core/worktree.d.ts +8 -0
- package/dist/core/worktree.js +65 -0
- package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
- package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
- package/dist/daemon/__tests__/thaw-order.test.js +55 -0
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
- package/dist/daemon/api/bridge.js +5 -1
- package/dist/daemon/api/handlers/attach.js +5 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +10 -2
- package/dist/daemon/api/handlers/nodes.js +8 -1
- package/dist/daemon/api/handlers/worktree.js +10 -1
- package/dist/daemon/api/map.js +7 -0
- package/dist/daemon/companion-retire.js +1 -1
- package/dist/daemon/cron/passes.d.ts +0 -5
- package/dist/daemon/cron/passes.js +0 -20
- package/dist/daemon/cron/sinks.js +1 -1
- package/dist/daemon/cron-run.d.ts +5 -6
- package/dist/daemon/cron-run.js +7 -16
- package/dist/daemon/crtrd.d.ts +0 -10
- package/dist/daemon/crtrd.js +23 -61
- package/dist/daemon/fleet.d.ts +10 -7
- package/dist/daemon/fleet.js +71 -12
- package/dist/daemon/messaging/node-message.js +3 -1
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
- package/dist/daemon/reconcilers/broker-supervision.js +39 -52
- package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
- package/dist/daemon/reconcilers/controller-death.js +26 -0
- package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
- package/dist/daemon/reconcilers/cron-lane.js +0 -3
- package/dist/daemon/reconcilers/live-obligation.js +3 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/commands/sys/prompt-review.d.ts +0 -1
- package/dist/commands/sys/sysprompt.d.ts +0 -1
- package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
- package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
- package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
- /package/dist/core/__tests__/{cron-broker-capacity.test.d.ts → cron-self-cancel-delivery.test.d.ts} +0 -0
|
@@ -323,69 +323,60 @@ export function matchesPreCommandEntry(entry, subject, command) {
|
|
|
323
323
|
const stripped = stripHeredocs(command);
|
|
324
324
|
return entry.match.some((g) => commandGlobMatches(g, stripped));
|
|
325
325
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
326
|
+
/** Does ONE entry of `event` fit this occurrence — constraints and the entry's
|
|
327
|
+
* own gate together? */
|
|
328
|
+
export function surfaceEntryParticipates(entry, doc, subject, event, payload) {
|
|
329
|
+
if (entry.on !== event)
|
|
330
|
+
return false;
|
|
331
|
+
switch (event) {
|
|
332
|
+
// Presence of the entry is the match; callers scope the corpus.
|
|
333
|
+
case 'boot':
|
|
334
|
+
case 'workspace-open':
|
|
335
|
+
return surfaceEntryGatePasses(entry, subject);
|
|
336
|
+
case 'read':
|
|
337
|
+
if (payload.absoluteFile === undefined || doc.path === undefined || doc.scope === undefined)
|
|
338
|
+
return false;
|
|
339
|
+
return matchesReadEntry(entry, { path: doc.path, scope: doc.scope }, subject, payload.absoluteFile, payload.readFrontmatter ?? {});
|
|
340
|
+
case 'memory-read':
|
|
341
|
+
if (payload.resolvedCanonicalName === undefined)
|
|
342
|
+
return false;
|
|
343
|
+
return matchesMemoryReadEntry(entry, payload.routingAnchor ?? '', subject, payload.resolvedCanonicalName);
|
|
344
|
+
case 'command':
|
|
345
|
+
return payload.command !== undefined && matchesCommandEntry(entry, subject, payload.command);
|
|
346
|
+
case 'pre-command':
|
|
347
|
+
return payload.command !== undefined && matchesPreCommandEntry(entry, subject, payload.command);
|
|
338
348
|
}
|
|
339
|
-
return r;
|
|
340
349
|
}
|
|
341
|
-
/** The doc's
|
|
342
|
-
*
|
|
343
|
-
export function
|
|
344
|
-
let
|
|
345
|
-
for (const
|
|
346
|
-
if (
|
|
350
|
+
/** The entry that decides this doc's delivery for one event occurrence: the
|
|
351
|
+
* highest-rung participating entry, or null when none participates. */
|
|
352
|
+
export function matchingSurfaceEntry(doc, subject, event, payload) {
|
|
353
|
+
let best = null;
|
|
354
|
+
for (const entry of doc.surfaces) {
|
|
355
|
+
if (!surfaceEntryParticipates(entry, doc, subject, event, payload))
|
|
347
356
|
continue;
|
|
348
|
-
if (rungRank(
|
|
349
|
-
|
|
357
|
+
if (best === null || rungRank(entry.at) > rungRank(best.at))
|
|
358
|
+
best = entry;
|
|
350
359
|
}
|
|
351
|
-
return
|
|
360
|
+
return best;
|
|
361
|
+
}
|
|
362
|
+
/** The doc's delivery rung for a read of `absReadFile` (already realpathed). */
|
|
363
|
+
export function readDeliveryRung(doc, subject, absReadFile, readFrontmatter) {
|
|
364
|
+
return matchingSurfaceEntry(doc, subject, 'read', { absoluteFile: absReadFile, readFrontmatter })?.at ?? 'none';
|
|
365
|
+
}
|
|
366
|
+
/** The doc's workspace-open rung. */
|
|
367
|
+
export function workspaceOpenRung(doc, subject) {
|
|
368
|
+
return matchingSurfaceEntry(doc, subject, 'workspace-open', {})?.at ?? 'none';
|
|
352
369
|
}
|
|
353
370
|
/** The doc's delivery rung for a `crtr memory read` that resolved a doc known
|
|
354
|
-
* by its exact canonical `name` — no mount-stripped or other alias.
|
|
355
|
-
* `routingAnchor` comes from the doc's resolver descriptor — it is not
|
|
356
|
-
* derivable from the canonical name, which cannot say whether the doc fronts
|
|
357
|
-
* its own directory. */
|
|
371
|
+
* by its exact canonical `name` — no mount-stripped or other alias. */
|
|
358
372
|
export function memoryReadDeliveryRung(doc, routingAnchor, subject, name) {
|
|
359
|
-
|
|
360
|
-
for (const e of doc.surfaces) {
|
|
361
|
-
if (!matchesMemoryReadEntry(e, routingAnchor, subject, name))
|
|
362
|
-
continue;
|
|
363
|
-
if (rungRank(e.at) > rungRank(r))
|
|
364
|
-
r = e.at;
|
|
365
|
-
}
|
|
366
|
-
return r;
|
|
373
|
+
return matchingSurfaceEntry(doc, subject, 'memory-read', { routingAnchor, resolvedCanonicalName: name })?.at ?? 'none';
|
|
367
374
|
}
|
|
368
375
|
/** The doc's delivery rung for an executed command string. */
|
|
369
376
|
export function commandDeliveryRung(doc, subject, command) {
|
|
370
|
-
|
|
371
|
-
for (const e of doc.surfaces) {
|
|
372
|
-
if (!matchesCommandEntry(e, subject, command))
|
|
373
|
-
continue;
|
|
374
|
-
if (rungRank(e.at) > rungRank(r))
|
|
375
|
-
r = e.at;
|
|
376
|
-
}
|
|
377
|
-
return r;
|
|
377
|
+
return matchingSurfaceEntry(doc, subject, 'command', { command })?.at ?? 'none';
|
|
378
378
|
}
|
|
379
|
-
/** The doc's delivery rung for a command about to run
|
|
380
|
-
* over the doc's matching `pre-command` entries, folded the same way as every
|
|
381
|
-
* other event. */
|
|
379
|
+
/** The doc's delivery rung for a command about to run. */
|
|
382
380
|
export function preCommandDeliveryRung(doc, subject, command) {
|
|
383
|
-
|
|
384
|
-
for (const e of doc.surfaces) {
|
|
385
|
-
if (!matchesPreCommandEntry(e, subject, command))
|
|
386
|
-
continue;
|
|
387
|
-
if (rungRank(e.at) > rungRank(r))
|
|
388
|
-
r = e.at;
|
|
389
|
-
}
|
|
390
|
-
return r;
|
|
381
|
+
return matchingSurfaceEntry(doc, subject, 'pre-command', { command })?.at ?? 'none';
|
|
391
382
|
}
|
|
@@ -10,6 +10,12 @@ export interface TuiPageContext {
|
|
|
10
10
|
terminal: ProcessTerminal;
|
|
11
11
|
bindings: BindingResolution<BindingId>;
|
|
12
12
|
rawUserConfig: ReturnType<typeof readRawScopeConfig>;
|
|
13
|
+
/**
|
|
14
|
+
* Hand the terminal to a child process ($EDITOR) and return the closure that
|
|
15
|
+
* takes it back with a full repaint. Callers must invoke the closure in a
|
|
16
|
+
* `finally` block. Same contract as the attach viewer's `SurfaceHost`.
|
|
17
|
+
*/
|
|
18
|
+
suspendForChild(): () => void;
|
|
13
19
|
}
|
|
14
20
|
export interface TuiPageControl<Result> {
|
|
15
21
|
/** Close the page with a result. */
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// `crtr sys settings`, the setup wizard, and `crtr pkg browse`.
|
|
3
3
|
//
|
|
4
4
|
// It owns exactly the concerns every page shares: theme, resolved keybindings,
|
|
5
|
-
// the raw user config those pages edit, the ProcessTerminal/TUI pair,
|
|
6
|
-
//
|
|
5
|
+
// the raw user config those pages edit, the ProcessTerminal/TUI pair, the
|
|
6
|
+
// release and reclaim of that terminal for a child process, and a restore path
|
|
7
|
+
// that survives an exception, a signal, and process exit. Anything
|
|
7
8
|
// a single page needs beyond that — pi's model runtime, a canvas client — the
|
|
8
9
|
// page's own runner builds and closes over before calling in here.
|
|
9
10
|
import { ProcessTerminal, TuiMainScreen, } from '@earendil-works/pi-tui';
|
|
@@ -40,8 +41,16 @@ export async function runTuiPage(createSurface, interceptCancel) {
|
|
|
40
41
|
restore();
|
|
41
42
|
resolve(result);
|
|
42
43
|
};
|
|
44
|
+
const suspendForChild = () => {
|
|
45
|
+
tui.stop();
|
|
46
|
+
return () => {
|
|
47
|
+
tui.start();
|
|
48
|
+
tui.invalidate();
|
|
49
|
+
tui.requestRender(true);
|
|
50
|
+
};
|
|
51
|
+
};
|
|
43
52
|
try {
|
|
44
|
-
surface = createSurface({ tui, terminal, bindings, rawUserConfig }, { submit: finish, cancel: () => finish(undefined) });
|
|
53
|
+
surface = createSurface({ tui, terminal, bindings, rawUserConfig, suspendForChild }, { submit: finish, cancel: () => finish(undefined) });
|
|
45
54
|
tui.addChild(surface);
|
|
46
55
|
tui.setFocus(surface);
|
|
47
56
|
process.once("exit", restore);
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export interface CloseManagedWorktreeResult {
|
|
|
31
31
|
branch_delete_error?: string;
|
|
32
32
|
}
|
|
33
33
|
export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktreeResult;
|
|
34
|
+
export interface AbandonManagedWorktreeResult {
|
|
35
|
+
node_id: string;
|
|
36
|
+
branch: string;
|
|
37
|
+
tip_sha: string;
|
|
38
|
+
worktree_path: string;
|
|
39
|
+
branch_deleted: boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function abandonManagedWorktree(nodeId: string, by: string): AbandonManagedWorktreeResult;
|
|
34
42
|
/** Recovery guidance for a vanished checkout whose refs do not prove that its
|
|
35
43
|
* work is delivered. This deliberately never recommends `worktree close`:
|
|
36
44
|
* that command cannot operate on the absent checkout. */
|
package/dist/core/worktree.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { gitSync } from './git.js';
|
|
5
5
|
import { crtrHome } from './canvas/paths.js';
|
|
6
|
+
import { isPidAlive } from './canvas/pid.js';
|
|
6
7
|
import { getNode, updateNode } from './canvas/index.js';
|
|
7
8
|
import { CONFIG_FILE, CRTR_DIR_NAME, STATE_FILE } from '../types.js';
|
|
8
9
|
import { withExclusiveDirectoryLock } from './exclusive-lock.js';
|
|
@@ -357,6 +358,70 @@ export function closeManagedWorktree(nodeId) {
|
|
|
357
358
|
return closeManagedWorktreeLocked(nodeId);
|
|
358
359
|
return withRepositoryWorktreeLock(wt.repo_root, () => closeManagedWorktreeLocked(nodeId));
|
|
359
360
|
}
|
|
361
|
+
const ABANDON_AUTHOR_NAME = 'crtr-abandon';
|
|
362
|
+
const ABANDON_AUTHOR_EMAIL = 'crtr-abandon@localhost';
|
|
363
|
+
const ABANDON_SUBJECT = 'crtr-abandon';
|
|
364
|
+
function persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by) {
|
|
365
|
+
updateNode(nodeId, {
|
|
366
|
+
cwd: wt.repo_root,
|
|
367
|
+
managed_worktree: {
|
|
368
|
+
...wt,
|
|
369
|
+
state: 'abandoned',
|
|
370
|
+
abandoned: { branch: wt.branch, tip_sha: tipSha, at, by },
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
function createAbandonMarker(wt, parentSha, nodeId, at) {
|
|
375
|
+
const treeSha = runGit(wt.repo_root, ['rev-parse', `${parentSha}^{tree}`], 'abandon_tree_failed', 'Inspect the branch manually, then retry.');
|
|
376
|
+
const markerSha = runGit(wt.repo_root, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit-tree', treeSha, '-p', parentSha, '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the branch manually, then retry.');
|
|
377
|
+
runGit(wt.repo_root, ['update-ref', `refs/heads/${wt.branch}`, markerSha, parentSha], 'abandon_marker_failed', 'The branch changed while abandon was starting; inspect the worktree, then retry.');
|
|
378
|
+
return markerSha;
|
|
379
|
+
}
|
|
380
|
+
function abandonManagedWorktreeLocked(nodeId, by) {
|
|
381
|
+
const node = getNode(nodeId);
|
|
382
|
+
if (node === null) {
|
|
383
|
+
throw new WorktreeError('unknown_node', `unknown node: ${nodeId}`, 'Pass a real node id.');
|
|
384
|
+
}
|
|
385
|
+
const wt = openManagedWorktreeForNode(nodeId);
|
|
386
|
+
if (wt === null) {
|
|
387
|
+
throw new WorktreeError('no_managed_worktree', `node ${nodeId} has no open managed worktree`, 'Nothing to abandon for this node.');
|
|
388
|
+
}
|
|
389
|
+
if (node.status === 'active' || node.status === 'idle' || isPidAlive(node.pi_pid)) {
|
|
390
|
+
throw new WorktreeError('node_running', `node ${nodeId} is still running`, 'Stop the node before abandoning its managed worktree.');
|
|
391
|
+
}
|
|
392
|
+
if (!existsSync(wt.path)) {
|
|
393
|
+
throw new WorktreeError('worktree_missing', `managed worktree path is missing: ${wt.path}`, missingWorktreeRecoveryHint(wt));
|
|
394
|
+
}
|
|
395
|
+
if (currentBranch(wt.path) !== wt.branch) {
|
|
396
|
+
throw new WorktreeError('wrong_branch', `managed worktree is not on its recorded branch (expected ${wt.branch})`, `Check out ${wt.branch} in ${wt.path}, then rerun \`crtr node worktree abandon\`.`);
|
|
397
|
+
}
|
|
398
|
+
const at = new Date().toISOString();
|
|
399
|
+
const clean = proveCleanEmptyManagedWorktree(wt);
|
|
400
|
+
if (clean !== null && clean.branchSha === wt.base_sha) {
|
|
401
|
+
const markerSha = createAbandonMarker(wt, clean.branchSha, nodeId, at);
|
|
402
|
+
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
403
|
+
const deleted = bestEffortDeleteBranchWithContainmentProof(wt, markerSha, `refs/heads/${wt.base_ref}`, clean.baseSha);
|
|
404
|
+
if (deleted.branchDeleted) {
|
|
405
|
+
persistClosedWorktreeState(nodeId, wt, 'complete');
|
|
406
|
+
return { node_id: nodeId, branch: wt.branch, tip_sha: markerSha, worktree_path: wt.path, branch_deleted: true };
|
|
407
|
+
}
|
|
408
|
+
const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
409
|
+
persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
|
|
410
|
+
return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
|
|
411
|
+
}
|
|
412
|
+
runGit(wt.path, ['add', '--all'], 'abandon_stage_failed', 'Inspect the worktree manually, then retry.');
|
|
413
|
+
runGit(wt.path, ['-c', `user.name=${ABANDON_AUTHOR_NAME}`, '-c', `user.email=${ABANDON_AUTHOR_EMAIL}`, 'commit', '--allow-empty', '-m', ABANDON_SUBJECT, '-m', `Crtr-Abandon: ${nodeId} ${at}`], 'abandon_commit_failed', 'Inspect the worktree manually, then retry.');
|
|
414
|
+
runGit(wt.repo_root, ['worktree', 'remove', wt.path], 'worktree_remove_failed', 'Inspect the worktree manually, then retry.');
|
|
415
|
+
const tipSha = runGit(wt.repo_root, ['rev-parse', `refs/heads/${wt.branch}`], 'abandon_tip_failed', 'Inspect the branch manually, then retry.');
|
|
416
|
+
persistAbandonedWorktreeState(nodeId, wt, tipSha, at, by);
|
|
417
|
+
return { node_id: nodeId, branch: wt.branch, tip_sha: tipSha, worktree_path: wt.path, branch_deleted: false };
|
|
418
|
+
}
|
|
419
|
+
export function abandonManagedWorktree(nodeId, by) {
|
|
420
|
+
const wt = openManagedWorktreeForNode(nodeId);
|
|
421
|
+
if (wt === null)
|
|
422
|
+
return abandonManagedWorktreeLocked(nodeId, by);
|
|
423
|
+
return withRepositoryWorktreeLock(wt.repo_root, () => abandonManagedWorktreeLocked(nodeId, by));
|
|
424
|
+
}
|
|
360
425
|
// Push-final auto-drop of a clean, zero-commit managed worktree
|
|
361
426
|
/** Delete a source branch only while the ref that proved it already delivered
|
|
362
427
|
* is still at the exact inspected SHA. `containingRef` can be the recorded
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
import { test } from 'node:test';
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { BrokerSupervisionReconciler } from '../reconcilers/broker-supervision.js';
|
|
12
|
-
import { ControllerDeathReconciler
|
|
12
|
+
import { ControllerDeathReconciler } from '../reconcilers/controller-death.js';
|
|
13
|
+
import { NodeLifecycleReconciler } from '../reconcilers/node-lifecycle/tick.js';
|
|
13
14
|
import { CronLaneReconciler } from '../reconcilers/cron-lane.js';
|
|
14
15
|
import { HumanDeliveryLaneReconciler } from '../reconcilers/human-delivery-lane.js';
|
|
15
16
|
import { PendingReviewSubmitReconciler } from '../reconcilers/pending-review-submit.js';
|
|
@@ -18,7 +19,7 @@ import { StorageMaintenanceReconciler } from '../reconcilers/storage-maintenance
|
|
|
18
19
|
const RECONCILERS = [
|
|
19
20
|
['BrokerSupervisionReconciler', BrokerSupervisionReconciler],
|
|
20
21
|
['ControllerDeathReconciler', ControllerDeathReconciler],
|
|
21
|
-
['
|
|
22
|
+
['NodeLifecycleReconciler', NodeLifecycleReconciler],
|
|
22
23
|
['CronLaneReconciler', CronLaneReconciler],
|
|
23
24
|
['HumanDeliveryLaneReconciler', HumanDeliveryLaneReconciler],
|
|
24
25
|
['PendingReviewSubmitReconciler', PendingReviewSubmitReconciler],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/daemon/__tests__/thaw-order.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Thaw ordering IS the freeze lane's allocation policy: the lane enacts
|
|
4
|
+
// sequentially on the daemon's serial lane, so whichever candidate it reaches
|
|
5
|
+
// first takes the free slot. `thawTier` + `compareThaw` are that whole policy,
|
|
6
|
+
// and both are pure — this file pins them directly so a re-stalling node cannot
|
|
7
|
+
// silently starve the mail queued behind it.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { compareThaw, thawTier } from '../reconcilers/node-lifecycle/freeze-lane.js';
|
|
11
|
+
function row(over = {}) {
|
|
12
|
+
return {
|
|
13
|
+
node_id: over.node_id ?? 'N',
|
|
14
|
+
name: 'n',
|
|
15
|
+
kind: 'developer',
|
|
16
|
+
mode: 'base',
|
|
17
|
+
lifecycle: 'terminal',
|
|
18
|
+
status: 'active',
|
|
19
|
+
cwd: '/tmp',
|
|
20
|
+
host_kind: 'broker',
|
|
21
|
+
profile_id: null,
|
|
22
|
+
parent: null,
|
|
23
|
+
created: '2026-01-01T00:00:00.000Z',
|
|
24
|
+
intent: null,
|
|
25
|
+
waiting_for: null,
|
|
26
|
+
pi_pid: null,
|
|
27
|
+
pi_pid_identity: null,
|
|
28
|
+
window: null,
|
|
29
|
+
tmux_session: null,
|
|
30
|
+
pane: null,
|
|
31
|
+
...over,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
test('a node that spent a slot and settled nothing drops behind the mail it stalled', () => {
|
|
35
|
+
assert.equal(thawTier(row(), false), 1, 'a never-launched row has spent nothing');
|
|
36
|
+
assert.equal(thawTier(row({ launched_at: '2026-01-01T00:00:10.000Z', last_settled_at: '2026-01-01T00:00:20.000Z' }), false), 1, 'a launch that settled a turn earned its place at the front');
|
|
37
|
+
assert.equal(thawTier(row({ launched_at: '2026-01-01T00:00:10.000Z' }), false), 2, 're-stall demotion: a slot was spent and nothing was settled with it');
|
|
38
|
+
assert.equal(thawTier(row({ launched_at: '2026-01-01T00:00:20.000Z', last_settled_at: '2026-01-01T00:00:10.000Z' }), false), 2, 'a settlement from BEFORE the launch does not count for that launch');
|
|
39
|
+
assert.equal(thawTier(row(), true), 2, 'a row waiting on mail competes in tier 2 regardless of its history');
|
|
40
|
+
});
|
|
41
|
+
test('thaw order is tier first, then longest-frozen first, with the unfrozen last', () => {
|
|
42
|
+
const entry = (node_id, tier, frozen_at) => ({
|
|
43
|
+
row: row({ node_id, ...(frozen_at !== undefined ? { frozen_at } : {}) }),
|
|
44
|
+
enact: 'policy',
|
|
45
|
+
tier,
|
|
46
|
+
});
|
|
47
|
+
const entries = [
|
|
48
|
+
entry('never-frozen-stalled', 1),
|
|
49
|
+
entry('mail-old', 2, '2026-01-01T00:00:01.000Z'),
|
|
50
|
+
entry('stalled-new', 1, '2026-01-01T00:00:09.000Z'),
|
|
51
|
+
entry('stalled-old', 1, '2026-01-01T00:00:02.000Z'),
|
|
52
|
+
];
|
|
53
|
+
entries.sort(compareThaw);
|
|
54
|
+
assert.deepEqual(entries.map((e) => e.row.node_id), ['stalled-old', 'stalled-new', 'never-frozen-stalled', 'mail-old'], 'tier 1 takes every free slot before tier 1 exhausts, oldest wait first; a row that has not waited sorts last in its tier');
|
|
55
|
+
});
|
|
@@ -8,6 +8,8 @@ import { createNode, getNode } from '../../../core/canvas/canvas.js';
|
|
|
8
8
|
import { contextDir, jobDir, viewSocketPath } from '../../../core/canvas/paths.js';
|
|
9
9
|
import { recordFault } from '../../../core/runtime/fault.js';
|
|
10
10
|
import { headlessBrokerHost } from '../../../core/runtime/host.js';
|
|
11
|
+
import { resetFleetForTests } from '../../../core/runtime/fleet.js';
|
|
12
|
+
import { bindTestFleet } from '../../../core/__tests__/helpers/fleet.js';
|
|
11
13
|
import { handleCreate } from '../handlers/nodes.js';
|
|
12
14
|
import { canvasRoutes } from '../handlers/canvas.js';
|
|
13
15
|
let home = '';
|
|
@@ -18,6 +20,9 @@ let originalLaunch;
|
|
|
18
20
|
before(() => {
|
|
19
21
|
home = mkdtempSync('/tmp/crtr-snapshot-fields-');
|
|
20
22
|
process.env['CRTR_HOME'] = home;
|
|
23
|
+
// Every launcher claims a slot from the ledger before it reaches the host, so
|
|
24
|
+
// a spawn in-process needs a bound fleet (D-2).
|
|
25
|
+
bindTestFleet();
|
|
21
26
|
originalLaunch = headlessBrokerHost.launch;
|
|
22
27
|
headlessBrokerHost.launch = (nodeId) => {
|
|
23
28
|
const broker = createServer();
|
|
@@ -29,6 +34,7 @@ before(() => {
|
|
|
29
34
|
});
|
|
30
35
|
after(async () => {
|
|
31
36
|
headlessBrokerHost.launch = originalLaunch;
|
|
37
|
+
resetFleetForTests();
|
|
32
38
|
await Promise.all(brokers.map((broker) => new Promise((resolve) => {
|
|
33
39
|
if (broker.listening)
|
|
34
40
|
broker.close(() => resolve());
|
|
@@ -7,6 +7,8 @@ import { closeDb } from '../../../core/canvas/db.js';
|
|
|
7
7
|
import { getNode } from '../../../core/canvas/canvas.js';
|
|
8
8
|
import { nodeDir } from '../../../core/canvas/paths.js';
|
|
9
9
|
import { headlessBrokerHost } from '../../../core/runtime/host.js';
|
|
10
|
+
import { resetFleetForTests } from '../../../core/runtime/fleet.js';
|
|
11
|
+
import { bindTestFleet } from '../../../core/__tests__/helpers/fleet.js';
|
|
10
12
|
import { handleCreate, nodeRoutes } from '../handlers/nodes.js';
|
|
11
13
|
import { brokerOperationRoutes } from '../handlers/broker-ops.js';
|
|
12
14
|
let home = '';
|
|
@@ -18,6 +20,9 @@ before(() => {
|
|
|
18
20
|
// macOS Unix-domain sockets have a short path limit; keep the canvas home short.
|
|
19
21
|
home = mkdtempSync('/tmp/crtr-node-description-');
|
|
20
22
|
process.env['CRTR_HOME'] = home;
|
|
23
|
+
// Every launcher claims a slot from the ledger before it reaches the host, so
|
|
24
|
+
// a spawn in-process needs a bound fleet (D-2).
|
|
25
|
+
bindTestFleet();
|
|
21
26
|
originalLaunch = headlessBrokerHost.launch;
|
|
22
27
|
headlessBrokerHost.launch = (nodeId) => {
|
|
23
28
|
const broker = createServer();
|
|
@@ -28,6 +33,7 @@ before(() => {
|
|
|
28
33
|
});
|
|
29
34
|
after(async () => {
|
|
30
35
|
headlessBrokerHost.launch = originalLaunch;
|
|
36
|
+
resetFleetForTests();
|
|
31
37
|
await Promise.all(brokers.map((broker) => new Promise((resolve) => {
|
|
32
38
|
if (broker.listening)
|
|
33
39
|
broker.close(() => resolve());
|
|
@@ -78,7 +78,7 @@ test('revive refuses a paused profile before launching a broker', () => {
|
|
|
78
78
|
parent: null,
|
|
79
79
|
profile_id: profile.profileId,
|
|
80
80
|
});
|
|
81
|
-
assert.throws(() => reviveNode(nodeId, { resume: true }), (error) => {
|
|
81
|
+
assert.throws(() => reviveNode(nodeId, { resume: true, capacity: 'freeze' }), (error) => {
|
|
82
82
|
assert.ok(error instanceof CrtrError);
|
|
83
83
|
assert.equal(error.code, 'usage');
|
|
84
84
|
assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
|
|
@@ -359,7 +359,11 @@ export async function handleAttachUpgrade(req, socket, head, nodeId) {
|
|
|
359
359
|
// pass only `{ resume }`.
|
|
360
360
|
let launchExited;
|
|
361
361
|
try {
|
|
362
|
-
|
|
362
|
+
// The bridge upgrade waits on this node's view socket, which only a
|
|
363
|
+
// live broker publishes: at the cap, refuse the upgrade with the real
|
|
364
|
+
// reason instead of freezing and then timing out on a socket that was
|
|
365
|
+
// never going to appear.
|
|
366
|
+
const result = reviveNode(nodeId, { resume: true, capacity: 'refuse' });
|
|
363
367
|
launchExited = result.launch?.exited;
|
|
364
368
|
}
|
|
365
369
|
catch (err) {
|
|
@@ -36,7 +36,11 @@ async function handleAttach(ctx) {
|
|
|
36
36
|
// reviveNode is the ONLY sanctioned broker launcher; a double-revive of a
|
|
37
37
|
// live node is an idempotent no-op (no `launch` handle) — `revived` reflects
|
|
38
38
|
// whether a fresh engine actually started.
|
|
39
|
-
|
|
39
|
+
// An attach has a viewer waiting on a socket that only a live broker
|
|
40
|
+
// publishes, so there is nothing to wait for it with: refuse at the cap and
|
|
41
|
+
// let the caller see why, rather than freezing and returning a `revived`
|
|
42
|
+
// that never becomes true.
|
|
43
|
+
const result = reviveNode(id, { resume, capacity: 'refuse' });
|
|
40
44
|
revived = result.launch !== undefined;
|
|
41
45
|
resumed = result.resumed;
|
|
42
46
|
// If we launched, let the broker publish its view socket so the local
|
|
@@ -77,7 +77,7 @@ async function handleStop(ctx) {
|
|
|
77
77
|
const node = getNode(nodeId);
|
|
78
78
|
if (node !== null && !isBrokerLive(node)) {
|
|
79
79
|
try {
|
|
80
|
-
reviveNode(nodeId, { resume: true });
|
|
80
|
+
reviveNode(nodeId, { resume: true, capacity: 'freeze' });
|
|
81
81
|
}
|
|
82
82
|
catch { /* the notice remains durable */ }
|
|
83
83
|
}
|
|
@@ -221,7 +221,9 @@ async function handleMessage(ctx) {
|
|
|
221
221
|
appendSituationalContext(id, sidecar);
|
|
222
222
|
if (req.reopen)
|
|
223
223
|
commitReopen(id, expectedFinalReport);
|
|
224
|
-
|
|
224
|
+
// A fresh revive appends no inbox entry, so nothing survives a freeze for
|
|
225
|
+
// the daemon to act on later: at the cap the caller is told, not ignored.
|
|
226
|
+
const result = reviveNode(id, { resume: false, capacity: 'refuse' });
|
|
225
227
|
const body = {
|
|
226
228
|
node_id: id,
|
|
227
229
|
delivered: false,
|
|
@@ -324,10 +326,15 @@ async function handleMessage(ctx) {
|
|
|
324
326
|
// A wake-capable tier revives a dormant target so its inbox-watcher delivers
|
|
325
327
|
// this; deferred never wakes — it rides the target's next natural cycle.
|
|
326
328
|
let revived = false;
|
|
329
|
+
let frozen = false;
|
|
327
330
|
if (tier !== 'deferred' && !paused && !isBrokerLive(meta)) {
|
|
328
331
|
try {
|
|
329
|
-
|
|
332
|
+
// The entry is already durable, so a wake denied for capacity is only
|
|
333
|
+
// delayed: the row freezes and the tick relaunches it — at which point
|
|
334
|
+
// the inbox watcher delivers this entry as it always would.
|
|
335
|
+
const result = reviveNode(id, { resume: true, capacity: 'freeze' });
|
|
330
336
|
revived = result.launch !== undefined;
|
|
337
|
+
frozen = result.outcome === 'frozen';
|
|
331
338
|
}
|
|
332
339
|
catch {
|
|
333
340
|
/* best-effort wake — the entry is durably appended regardless */
|
|
@@ -337,6 +344,7 @@ async function handleMessage(ctx) {
|
|
|
337
344
|
node_id: id,
|
|
338
345
|
delivered: true,
|
|
339
346
|
revived,
|
|
347
|
+
...(frozen ? { not_revived_reason: 'capacity_frozen' } : {}),
|
|
340
348
|
delivered_at: entry.ts,
|
|
341
349
|
...(req.delivery === 'interactive' ? { delivered_via: 'inbox' } : {}),
|
|
342
350
|
};
|
|
@@ -487,6 +487,9 @@ function handleRevive(ctx) {
|
|
|
487
487
|
const cron = !resume && body?.cron_id !== undefined ? getCron(body.cron_id) : null;
|
|
488
488
|
const result = reviveNode(id, {
|
|
489
489
|
resume,
|
|
490
|
+
// An explicit revive is a caller asking for this node NOW; at the cap it
|
|
491
|
+
// gets the refusal naming it rather than a silent nothing-happened.
|
|
492
|
+
capacity: 'refuse',
|
|
490
493
|
...(cron !== null ? { wakeReason: cronWakeOrigin(cron, 'schedule_revive') } : {}),
|
|
491
494
|
});
|
|
492
495
|
const status = (getNode(id)?.status ?? 'active');
|
|
@@ -600,7 +603,11 @@ function handleWait(ctx) {
|
|
|
600
603
|
}
|
|
601
604
|
function handleReviveAll() {
|
|
602
605
|
const result = reviveAll();
|
|
603
|
-
const body = {
|
|
606
|
+
const body = {
|
|
607
|
+
revived: [...result.revived],
|
|
608
|
+
failed: [...result.failed],
|
|
609
|
+
frozen: [...result.frozen],
|
|
610
|
+
};
|
|
604
611
|
return { status: 200, body };
|
|
605
612
|
}
|
|
606
613
|
// PATCH /v1/nodes/{id}/config — model / situational-context (spec §6.2)
|
|
@@ -12,13 +12,22 @@
|
|
|
12
12
|
// all of that onto `CrtrError` for every route that can raise it — so the CLI
|
|
13
13
|
// leaf can reconstruct the original agent-facing contract instead of collapsing
|
|
14
14
|
// to a generic status code.
|
|
15
|
-
import { closeManagedWorktree } from '../../../core/worktree.js';
|
|
15
|
+
import { abandonManagedWorktree, closeManagedWorktree } from '../../../core/worktree.js';
|
|
16
|
+
import { usage } from '../../../core/errors.js';
|
|
16
17
|
function handleWorktreeClose(ctx) {
|
|
17
18
|
const id = ctx.params['id'];
|
|
18
19
|
// No requireMeta: closeManagedWorktree already throws the precise
|
|
19
20
|
// `unknown_node` / `no_managed_worktree` WorktreeError for a bad id.
|
|
20
21
|
return { status: 200, body: closeManagedWorktree(id) };
|
|
21
22
|
}
|
|
23
|
+
function handleWorktreeAbandon(ctx) {
|
|
24
|
+
const rawBy = ctx.body?.['by'];
|
|
25
|
+
const by = typeof rawBy === 'string' ? rawBy.trim() : '';
|
|
26
|
+
if (by === '')
|
|
27
|
+
throw usage('abandon requires a non-empty invoking identity', { received: ctx.body, next: 'Pass the invoking node id or human identity.' });
|
|
28
|
+
return { status: 200, body: abandonManagedWorktree(ctx.params['id'], by) };
|
|
29
|
+
}
|
|
22
30
|
export const worktreeRoutes = [
|
|
23
31
|
{ method: 'POST', pattern: '/v1/nodes/:id/worktree/close', handler: handleWorktreeClose },
|
|
32
|
+
{ method: 'POST', pattern: '/v1/nodes/:id/worktree/abandon', handler: handleWorktreeAbandon },
|
|
24
33
|
];
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -38,6 +38,8 @@ export function toNodeSummaryDTO(row) {
|
|
|
38
38
|
created: row.created,
|
|
39
39
|
intent: row.intent,
|
|
40
40
|
waiting_for: row.waiting_for,
|
|
41
|
+
frozen_at: row.frozen_at ?? null,
|
|
42
|
+
terminal_reason: row.terminal_reason ?? null,
|
|
41
43
|
pi_pid: row.pi_pid,
|
|
42
44
|
pi_pid_identity: row.pi_pid_identity,
|
|
43
45
|
window: row.window,
|
|
@@ -85,6 +87,8 @@ export function toNodeWorktreeDTO(w) {
|
|
|
85
87
|
dto.cleanup = w.cleanup;
|
|
86
88
|
if (w.closed !== undefined)
|
|
87
89
|
dto.closed = w.closed;
|
|
90
|
+
if (w.abandoned !== undefined)
|
|
91
|
+
dto.abandoned = { ...w.abandoned };
|
|
88
92
|
return dto;
|
|
89
93
|
}
|
|
90
94
|
/** Project the hydrated node view (`getNode` → `NodeMeta`) into a detail DTO.
|
|
@@ -106,6 +110,8 @@ export function toNodeDetailDTO(meta, edges) {
|
|
|
106
110
|
created: meta.created,
|
|
107
111
|
intent: (meta.intent ?? null),
|
|
108
112
|
waiting_for: meta.waiting_for ?? null,
|
|
113
|
+
frozen_at: meta.frozen_at ?? null,
|
|
114
|
+
terminal_reason: meta.terminal_reason ?? null,
|
|
109
115
|
pi_pid: meta.pi_pid ?? null,
|
|
110
116
|
pi_pid_identity: meta.pi_pid_identity ?? null,
|
|
111
117
|
window: meta.window ?? null,
|
|
@@ -154,6 +160,7 @@ export function toReviveResultDTO(nodeId, result, status) {
|
|
|
154
160
|
revived: result.launch !== undefined,
|
|
155
161
|
resumed: result.resumed,
|
|
156
162
|
status,
|
|
163
|
+
outcome: result.outcome,
|
|
157
164
|
};
|
|
158
165
|
}
|
|
159
166
|
/** Project a `closeNode` cascade result. Field names align 1:1. */
|
|
@@ -29,7 +29,7 @@ export async function retireCompanion(nodeId, outcome) {
|
|
|
29
29
|
catch { /* the canonical final is authoritative */ }
|
|
30
30
|
return result;
|
|
31
31
|
}
|
|
32
|
-
transition(nodeId, 'cancel');
|
|
32
|
+
transition(nodeId, 'cancel', { reason: 'retired' });
|
|
33
33
|
if (node.status === 'active' || node.status === 'idle') {
|
|
34
34
|
try {
|
|
35
35
|
headlessBrokerHost.teardown(nodeId);
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import { type Cron } from '../../core/canvas/crons.js';
|
|
2
1
|
export interface RunDueCronOptions {
|
|
3
2
|
/** Registers settlement work for graceful-teardown draining. */
|
|
4
3
|
register?: (work: Promise<void>) => void;
|
|
5
|
-
/** Node-launching runs need capacity; absent means all runs are admitted. */
|
|
6
|
-
hasBrokerCapacity?: () => boolean;
|
|
7
4
|
}
|
|
8
|
-
export declare function cronLaunchesNode(c: Cron): boolean;
|
|
9
5
|
export declare function expireDueCrons(nowIso: string): void;
|
|
10
6
|
export declare function applyOverlapPolicies(now: number, nowIso: string): void;
|
|
11
|
-
export declare function emitCapacityDeferral(now: number, deferred: number): void;
|
|
@@ -3,17 +3,9 @@ import { cronDaemonInstanceId, liveRunFor } from './live-runs.js';
|
|
|
3
3
|
import { advanceCron, cancelCron, dueOverlapCrons, expiredIdleCrons, listCronRuns, recordCronRun, } from '../../core/canvas/crons.js';
|
|
4
4
|
import { emitEvent } from '../../core/events/emit.js';
|
|
5
5
|
import { nextSlotAfter } from '../../core/wake.js';
|
|
6
|
-
import { parseSink } from '../cron-sink.js';
|
|
7
6
|
/** Lead of a `cron_runs.delivered` string written by the overlap=skip pass.
|
|
8
7
|
* Matched (not just written) — see `latestRunIsSkip`. */
|
|
9
8
|
const SKIP_MARKER = 'skipped (overlap=skip)';
|
|
10
|
-
// A false negative is an ungated run; a false positive delays one poll at capacity.
|
|
11
|
-
const NODE_LAUNCHING_COMMAND = /\bcrtr\b[^\n]*?\bnode\s+lifecycle\s+revive\b|\bcrtr\b[^\n]*?\bnode\s+new\b|\bcrtr\b[^\n]*?\bcanvas\s+revive\b|\bcrtr\b[^\n]*?\bnode\s+message\s+send\b[^\n]*?\s--fresh\b/;
|
|
12
|
-
export function cronLaunchesNode(c) {
|
|
13
|
-
if (parseSink(c.sink)?.kind === 'spawn')
|
|
14
|
-
return true;
|
|
15
|
-
return NODE_LAUNCHING_COMMAND.test(c.command);
|
|
16
|
-
}
|
|
17
9
|
export function expireDueCrons(nowIso) {
|
|
18
10
|
for (const c of expiredIdleCrons(nowIso)) {
|
|
19
11
|
cancelCron(c.cron_id);
|
|
@@ -64,15 +56,3 @@ export function applyOverlapPolicies(now, nowIso) {
|
|
|
64
56
|
}
|
|
65
57
|
}
|
|
66
58
|
}
|
|
67
|
-
const CAPACITY_DEFER_LOG_THROTTLE_MS = 30_000;
|
|
68
|
-
let lastCapacityDeferLogAt = Number.NEGATIVE_INFINITY;
|
|
69
|
-
export function emitCapacityDeferral(now, deferred) {
|
|
70
|
-
if (now - lastCapacityDeferLogAt < CAPACITY_DEFER_LOG_THROTTLE_MS)
|
|
71
|
-
return;
|
|
72
|
-
lastCapacityDeferLogAt = now;
|
|
73
|
-
emitEvent({
|
|
74
|
-
level: 'warn',
|
|
75
|
-
event: 'cron.capacity.run_deferred',
|
|
76
|
-
fields: { deferred_runs: deferred },
|
|
77
|
-
});
|
|
78
|
-
}
|
|
@@ -33,7 +33,7 @@ function deliverNodeSink(c, target, stdout) {
|
|
|
33
33
|
// The durable append succeeds even if its best-effort wake fails.
|
|
34
34
|
if (c.tier !== 'deferred' && !isBrokerLive(meta)) {
|
|
35
35
|
try {
|
|
36
|
-
reviveNode(target, { resume: true });
|
|
36
|
+
reviveNode(target, { resume: true, capacity: 'freeze' });
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
39
|
/* best-effort wake — the entry is durably appended regardless */
|