@north-light/crouter 0.3.192 → 0.3.194
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 +31 -10
- package/dist/api/client.js +83 -24
- package/dist/api/dto/bash-jobs.d.ts +24 -0
- package/dist/api/dto/bash-jobs.js +9 -0
- package/dist/api/dto/human.d.ts +3 -4
- package/dist/api/dto/inbox.d.ts +106 -65
- package/dist/api/dto/inbox.js +2 -8
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +7 -0
- package/dist/api/routes.js +7 -0
- package/dist/builtin-memory/02-lifecycle/01-resident.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +1 -1
- package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
- package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
- package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
- package/dist/clients/attach/chrome/canvas-panels.js +1 -1
- package/dist/clients/attach/input/overlay-owner.js +1 -1
- package/dist/clients/attach/overlays/auth.js +34 -8
- package/dist/clients/attach/overlays/pickers.d.ts +5 -8
- package/dist/clients/attach/overlays/pickers.js +37 -43
- package/dist/clients/attach/viewer.js +643 -620
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
- package/dist/clients/inbox/controller.d.ts +2 -3
- package/dist/clients/inbox/controller.js +41 -42
- package/dist/clients/inbox/page-adapter.d.ts +33 -0
- package/dist/clients/inbox/page-adapter.js +54 -0
- package/dist/clients/inbox/resolve.d.ts +2 -2
- package/dist/clients/inbox/resolve.js +1 -1
- package/dist/clients/inbox/review-visibility.d.ts +1 -1
- package/dist/clients/inbox/review-visibility.js +1 -1
- package/dist/clients/inbox/tui/ansi.d.ts +1 -1
- package/dist/clients/inbox/tui/ansi.js +2 -2
- package/dist/clients/inbox/tui/input.d.ts +1 -3
- package/dist/clients/inbox/tui/input.js +242 -553
- package/dist/clients/inbox/tui/panel.d.ts +3 -1
- package/dist/clients/inbox/tui/panel.js +59 -187
- package/dist/clients/inbox/tui/render.d.ts +0 -25
- package/dist/clients/inbox/tui/render.js +165 -416
- package/dist/clients/inbox/tui/slots.d.ts +32 -0
- package/dist/clients/inbox/tui/slots.js +156 -0
- package/dist/clients/inbox/tui/types.d.ts +25 -13
- package/dist/clients/inbox/tui.js +5 -3
- package/dist/commands/attention.js +2 -2
- package/dist/commands/human/doc.js +66 -20
- package/dist/commands/human/prompts.d.ts +3 -3
- package/dist/commands/human/prompts.js +158 -112
- package/dist/commands/human/queue.d.ts +0 -1
- package/dist/commands/human/queue.js +34 -94
- package/dist/commands/human/shared.d.ts +10 -4
- package/dist/commands/human/shared.js +62 -13
- package/dist/commands/human.js +8 -4
- package/dist/commands/memory/read.js +13 -5
- package/dist/commands/node/bash.js +18 -30
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/commands/surface/node/focus.js +1 -1
- package/dist/core/__tests__/human-deliver.test.js +40 -46
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
- package/dist/core/__tests__/seam/dormancy-release.test.js +64 -10
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
- package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
- package/dist/core/auth-interaction.d.ts +2 -2
- package/dist/core/bash-jobs.d.ts +43 -0
- package/dist/core/bash-jobs.js +96 -2
- package/dist/core/canvas/__tests__/attention.test.js +16 -10
- package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
- package/dist/core/canvas/attention.js +1 -1
- package/dist/core/canvas/browse/app.js +1 -1
- package/dist/core/canvas/nav-model.d.ts +1 -1
- package/dist/core/canvas/nav-model.js +1 -1
- package/dist/core/canvas/remote-canvas-source.js +1 -1
- package/dist/core/canvas/types.d.ts +2 -1
- package/dist/core/config.js +4 -1
- package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-render.test.js +49 -0
- package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-tickets.test.js +112 -0
- package/dist/core/human/__tests__/page.test.d.ts +1 -0
- package/dist/core/human/__tests__/page.test.js +139 -0
- package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
- package/dist/core/human/claim.js +2 -2
- package/dist/core/human/convention.d.ts +4 -4
- package/dist/core/human/convention.js +6 -13
- package/dist/core/human/markdown-html.d.ts +8 -0
- package/dist/core/human/markdown-html.js +581 -0
- package/dist/core/human/page-catalog.d.ts +6 -0
- package/dist/core/human/page-catalog.js +43 -0
- package/dist/core/human/page-render.d.ts +3 -0
- package/dist/core/human/page-render.js +131 -0
- package/dist/core/human/page-schema.d.ts +312 -0
- package/dist/core/human/page-schema.js +291 -0
- package/dist/core/human/page-synth.d.ts +9 -0
- package/dist/core/human/page-synth.js +17 -0
- package/dist/core/human/page.d.ts +22 -0
- package/dist/core/human/page.js +427 -0
- package/dist/core/human/review-schema.d.ts +19 -0
- package/dist/core/human/review-schema.js +34 -0
- package/dist/core/human/scan.d.ts +12 -3
- package/dist/core/human/scan.js +69 -26
- package/dist/core/human/summary.d.ts +5 -6
- package/dist/core/human/summary.js +54 -37
- package/dist/core/human/tickets.d.ts +61 -11
- package/dist/core/human/tickets.js +100 -57
- package/dist/core/human/types.d.ts +21 -66
- package/dist/core/human/types.js +1 -5
- package/dist/core/inspector/model.d.ts +5 -0
- package/dist/core/inspector/model.js +5 -0
- package/dist/core/inspector/text.js +2 -2
- package/dist/core/inspector/tui.js +10 -3
- package/dist/core/keybindings/catalog.d.ts +1 -1
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/preview-registry.js +0 -1
- package/dist/core/review/ticket-filter.d.ts +1 -1
- package/dist/core/review/ticket-filter.js +2 -8
- package/dist/core/runtime/broker/read-ops.js +3 -0
- package/dist/core/runtime/broker.js +19 -10
- package/dist/core/runtime/package-health.js +1 -1
- package/dist/core/runtime/persona.js +2 -1
- package/dist/core/runtime/tmux-bindings.js +8 -1
- package/dist/core/termrender/termrender.d.ts +1 -1
- package/dist/core/termrender/termrender.js +2 -2
- package/dist/core/termrender/version.d.ts +1 -1
- package/dist/core/termrender/version.js +1 -1
- package/dist/core/tui/page-host.d.ts +1 -1
- package/dist/core/tui/page-host.js +2 -2
- package/dist/core/user-settings.d.ts +19 -0
- package/dist/core/user-settings.js +22 -0
- package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
- package/dist/daemon/api/handlers/bash-jobs.js +90 -0
- package/dist/daemon/api/handlers/human.js +16 -14
- package/dist/daemon/api/handlers/inbox.js +311 -227
- package/dist/daemon/api/router.d.ts +5 -1
- package/dist/daemon/api/router.js +5 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +23 -10
- package/dist/daemon/human/finish.d.ts +8 -3
- package/dist/daemon/human/finish.js +35 -15
- package/dist/daemon/reconcilers/broker-supervision.js +7 -1
- package/dist/daemon/reconcilers/dormant-inbox.js +8 -0
- package/dist/daemon/reconcilers/live-obligation.d.ts +2 -0
- package/dist/daemon/reconcilers/live-obligation.js +11 -0
- package/dist/pages/bundle.css +1 -0
- package/dist/pages/bundle.js +1347 -0
- package/dist/pages/comments.d.ts +34 -0
- package/dist/pages/comments.js +72 -0
- package/dist/pages/elements/cards.d.ts +17 -0
- package/dist/pages/elements/cards.js +785 -0
- package/dist/pages/elements/chart.d.ts +1 -0
- package/dist/pages/elements/chart.js +648 -0
- package/dist/pages/elements/options.d.ts +21 -0
- package/dist/pages/elements/options.js +604 -0
- package/dist/pages/elements/pages.d.ts +24 -0
- package/dist/pages/elements/pages.js +355 -0
- package/dist/pages/elements/slot.d.ts +21 -0
- package/dist/pages/elements/slot.js +105 -0
- package/dist/pages/elements/table.d.ts +19 -0
- package/dist/pages/elements/table.js +906 -0
- package/dist/pages/elements/text.d.ts +25 -0
- package/dist/pages/elements/text.js +951 -0
- package/dist/pages/entry.d.ts +8 -0
- package/dist/pages/entry.js +8 -0
- package/dist/pages/host.d.ts +132 -0
- package/dist/pages/host.js +149 -0
- package/dist/pages/register.d.ts +2 -0
- package/dist/pages/register.js +3 -0
- package/dist/pages/slot-config.d.ts +36 -0
- package/dist/pages/slot-config.js +50 -0
- package/dist/pages/types.d.ts +98 -0
- package/dist/pages/types.js +23 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +4 -2
- package/dist/pi-extensions/canvas-bash-valve.js +25 -7
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/package.json +8 -8
- package/runtime.lock.json +156 -101
- package/scripts/install-runtime.mjs +172 -5
- package/dist/clients/inbox/deck-adapter.d.ts +0 -29
- package/dist/clients/inbox/deck-adapter.js +0 -63
- package/dist/core/human/deck-factories.d.ts +0 -8
- package/dist/core/human/deck-factories.js +0 -15
- package/dist/core/human/deck-schema.d.ts +0 -70
- package/dist/core/human/deck-schema.js +0 -92
|
@@ -1,17 +1,66 @@
|
|
|
1
1
|
import { readConfig } from '../../core/config.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
2
|
+
function markdownAuthoring(productKinds) {
|
|
3
|
+
const productSlots = productKinds.length === 0
|
|
4
|
+
? ''
|
|
5
|
+
: `\nProduct slots registered in this home: ${productKinds.map((kind) => `\`${kind}\``).join(', ')}. Use \`slot:<kind>\`; each is response-bearing.`;
|
|
6
|
+
return [
|
|
7
|
+
'Markdown page (`.md`; one step only)',
|
|
8
|
+
'Front matter starts at byte 0 and recognizes exactly `title` (required), `subtitle` (optional), and `placement` (`inline|panel`, optional, default `panel`); every other key is invalid. Outside front matter, write directive-flavored Markdown. A top-level `slot:<kind>` JSON fence contains one object: reserved top-level `id` identifies a response-bearing slot; every other key is that kind\'s config.',
|
|
9
|
+
'Built-ins: `options` asks for one or more choices; `text` asks for text; `table` asks for row and/or column selections when enabled, otherwise displays data; `cards` asks for card selections; `chart` displays a chart and asks nothing.' + productSlots,
|
|
10
|
+
'Format specimen:',
|
|
11
|
+
'````markdown',
|
|
12
|
+
'---',
|
|
13
|
+
'title: Rollout scope',
|
|
14
|
+
'subtitle: Choose tonight\'s rollout.',
|
|
15
|
+
'placement: inline',
|
|
16
|
+
'---',
|
|
17
|
+
'',
|
|
18
|
+
'Which rollout should ship?',
|
|
19
|
+
'',
|
|
20
|
+
'```slot:options',
|
|
21
|
+
'{"id":"rollout","mode":"single","options":[{"id":"canary","label":"Canary first"}]}',
|
|
22
|
+
'```',
|
|
23
|
+
'````',
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
function htmlAuthoring(productKinds) {
|
|
27
|
+
const productSlot = productKinds.length === 0
|
|
28
|
+
? ''
|
|
29
|
+
: ' Product kinds use `<crtr-slot kind="<kind>" id="…">` with the same JSON-script child.';
|
|
30
|
+
return [
|
|
31
|
+
'HTML page (`.html` or `.htm`)',
|
|
32
|
+
'Put required title and optional subtitle in `<head>` metas named `crtr-page-title` and `crtr-page-subtitle`. Placement is declared only by `<meta name="crtr-page-placement" content="inline|panel">` in `<head>`; it defaults to `panel`.',
|
|
33
|
+
'Built-ins are `<crtr-options>`, `<crtr-text>`, `<crtr-table>`, `<crtr-cards>`, and `<crtr-chart>`; `id` is an attribute and config is the first direct `<script type="application/json">` child.' + productSlot,
|
|
34
|
+
'An interactive page (any response-bearing slot), or any multi-step page, requires one `<crtr-pages>` wrapper containing one or more `<crtr-page>` steps, with every response-bearing element inside a step. A pure display page is correctly bare HTML with no `crtr-*` markup at all.',
|
|
35
|
+
'Format specimen:',
|
|
36
|
+
'```html',
|
|
37
|
+
'<!doctype html>',
|
|
38
|
+
'<html><head>',
|
|
39
|
+
' <meta name="crtr-page-title" content="Rollout scope">',
|
|
40
|
+
' <meta name="crtr-page-placement" content="inline">',
|
|
41
|
+
'</head><body>',
|
|
42
|
+
' <crtr-pages><crtr-page>',
|
|
43
|
+
' <crtr-options id="rollout"><script type="application/json">{"mode":"single","options":[{"id":"canary","label":"Canary first"}]}</script></crtr-options>',
|
|
44
|
+
' </crtr-page></crtr-pages>',
|
|
45
|
+
'</body></html>',
|
|
46
|
+
'```',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
/** Invocation-local page capability and authoring contract for `human -h`.
|
|
50
|
+
* The CLI process rebuilds command help each time, so config changes appear on
|
|
51
|
+
* the next invocation without a parallel documentation surface. */
|
|
52
|
+
export function pageHelpText() {
|
|
53
|
+
const config = readConfig('user');
|
|
54
|
+
const hardRule = '**Hard rule:** write the page to a local file and submit it with `--page PATH`; NEVER paste page markup into a message.';
|
|
55
|
+
const shorthand = 'Zero ceremony: `ask` stdin plus `--title` and `--subtitle` synthesizes a one-text-slot page; `notify` title plus `--subtitle` and optional `--body` synthesizes a display page.';
|
|
56
|
+
const dialects = config.html_pages
|
|
57
|
+
? [markdownAuthoring(config.page_components), htmlAuthoring(config.page_components)]
|
|
58
|
+
: [markdownAuthoring(config.page_components)];
|
|
59
|
+
const pageHint = config.html_pages
|
|
60
|
+
? 'Authored page file. This home accepts .md, .html, and .htm; the extension selects the dialect.'
|
|
61
|
+
: 'Authored Markdown page file. This home has html_pages off, so only .md is accepted.';
|
|
62
|
+
return { authoring: [hardRule, shorthand, ...dialects].join('\n\n'), pageHint };
|
|
63
|
+
}
|
|
15
64
|
export function resolveMaxPanes() {
|
|
16
65
|
return readConfig('user').max_panes_per_window;
|
|
17
66
|
}
|
package/dist/commands/human.js
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Ask creates a bridge node and submits a ticket to the store. Review creation
|
|
4
4
|
// asks crtrd to create a ticket, daemon-owned review, and companion. notify
|
|
5
|
-
// enqueues a durable
|
|
5
|
+
// enqueues a durable page with no node; show is passive display.
|
|
6
6
|
import { defineBranch } from '../core/command.js';
|
|
7
7
|
import { humanAsk, humanNotify, humanShow } from './human/prompts.js';
|
|
8
|
+
import { humanList, humanResolve, humanCancel } from './human/queue.js';
|
|
8
9
|
import { humanReviewBranch } from './human/review.js';
|
|
9
|
-
import { humanList, humanDeck, humanResolve, humanCancel } from './human/queue.js';
|
|
10
10
|
import { humanInbox } from './human/inbox.js';
|
|
11
11
|
import { humanDoc } from './human/doc.js';
|
|
12
|
+
import { pageHelpText } from './human/shared.js';
|
|
12
13
|
export function registerHuman() {
|
|
14
|
+
const pageHelp = pageHelpText();
|
|
13
15
|
return defineBranch({
|
|
14
16
|
name: 'human',
|
|
15
17
|
rootEntry: {
|
|
@@ -20,7 +22,10 @@ export function registerHuman() {
|
|
|
20
22
|
help: {
|
|
21
23
|
name: 'human',
|
|
22
24
|
summary: 'human-in-the-loop decisions, document review, and live display',
|
|
23
|
-
model:
|
|
25
|
+
model: [
|
|
26
|
+
"Tickets queue in the human inbox; nothing opens on screen. `ask` returns immediately and its answer is pushed to the asking node, while `notify` expects no reply. `review` is for live document review with anchored comments; `show` is a passive tmux live-watch rather than a ticket. Validate authored pages with `crtr human doc check`.",
|
|
27
|
+
pageHelp.authoring,
|
|
28
|
+
].join('\n\n'),
|
|
24
29
|
},
|
|
25
30
|
children: [
|
|
26
31
|
humanAsk,
|
|
@@ -29,7 +34,6 @@ export function registerHuman() {
|
|
|
29
34
|
humanShow,
|
|
30
35
|
humanCancel,
|
|
31
36
|
humanList,
|
|
32
|
-
humanDeck,
|
|
33
37
|
humanResolve,
|
|
34
38
|
humanInbox,
|
|
35
39
|
humanDoc,
|
|
@@ -55,6 +55,12 @@ function indexRoutes(doc) {
|
|
|
55
55
|
if (!byName.has(d.name))
|
|
56
56
|
byName.set(d.name, d);
|
|
57
57
|
const prefix = dir + '/';
|
|
58
|
+
// Keyed by RENDERED identity (`displayName`), not `.name`: one physical
|
|
59
|
+
// directory INDEX answers to two names — `listAllMemoryDocs` emits the
|
|
60
|
+
// path-derived `dir/INDEX`, while `resolveMemoryDoc('dir')` returns it
|
|
61
|
+
// carrying the bare dir as its identity. Keying on `.name` let a body link
|
|
62
|
+
// to a sub-INDEX (written the normal way, by bare dir name) miss the entry
|
|
63
|
+
// the directory scan already added, and the doc rendered twice.
|
|
58
64
|
const members = new Map();
|
|
59
65
|
for (const [name, d] of byName) {
|
|
60
66
|
if (!name.startsWith(prefix) || name === canonicalName)
|
|
@@ -70,8 +76,8 @@ function indexRoutes(doc) {
|
|
|
70
76
|
break;
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
|
-
if (!members.has(member.name))
|
|
74
|
-
members.set(member.name, member);
|
|
79
|
+
if (!members.has(displayName(member.name)))
|
|
80
|
+
members.set(displayName(member.name), member);
|
|
75
81
|
}
|
|
76
82
|
// No members means this INDEX gates no cluster in the corpus namespace — a
|
|
77
83
|
// store-root front door whose explicit frontmatter name dodges the root
|
|
@@ -88,10 +94,12 @@ function indexRoutes(doc) {
|
|
|
88
94
|
catch {
|
|
89
95
|
continue; // dangling link — lint's finding, not read's
|
|
90
96
|
}
|
|
91
|
-
if (displayName(linked.name) !== linkName
|
|
97
|
+
if (displayName(linked.name) !== linkName)
|
|
92
98
|
continue;
|
|
93
|
-
if (
|
|
94
|
-
|
|
99
|
+
if (linkName === displayName(canonicalName) || linkName === displayName(doc.name))
|
|
100
|
+
continue;
|
|
101
|
+
if (!members.has(linkName))
|
|
102
|
+
members.set(linkName, linked);
|
|
95
103
|
}
|
|
96
104
|
return [...members.values()]
|
|
97
105
|
.map((m) => {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
// `crtr node bash` — file-backed background job controls.
|
|
2
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
3
2
|
import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
4
3
|
import { InputError } from '../../core/io.js';
|
|
5
4
|
import { displayMessage } from '../../core/runtime/tmux-chrome.js';
|
|
6
5
|
import { contextDir } from '../../core/canvas/paths.js';
|
|
7
|
-
import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, jobStartedAtMs, readJobPgid, tailJobLog } from '../../core/bash-jobs.js';
|
|
6
|
+
import { activeBackgroundBashJobs, backgroundRunningBashJobs, bashJobPaths, formatBashElapsed, isSafeBashJobId, jobStartedAtMs, readJobPgid, stopBackgroundBashJob, tailJobLog } from '../../core/bash-jobs.js';
|
|
8
7
|
import { cliClient, getNodeOrNull } from '../api-client.js';
|
|
9
8
|
import { nodeInPane } from '../surface/node/placement.js';
|
|
10
9
|
function resolveBashTarget(input, missingMessage) {
|
|
@@ -79,7 +78,7 @@ const nodeBashList = defineLeaf({
|
|
|
79
78
|
],
|
|
80
79
|
output: [
|
|
81
80
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The target node.' },
|
|
82
|
-
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job.' },
|
|
81
|
+
{ name: 'jobs', type: 'object[]', required: true, constraint: 'Oldest-first {job_id, command, purpose, started_at, elapsed_ms, log_path, pgid, log_tail} for every live background job; purpose is a human-readable label or null.' },
|
|
83
82
|
],
|
|
84
83
|
outputKind: 'object',
|
|
85
84
|
effects: ['Reads the node context jobs directory. Changes nothing.'],
|
|
@@ -96,6 +95,7 @@ const nodeBashList = defineLeaf({
|
|
|
96
95
|
const jobs = activeBackgroundBashJobs(dir).map((job) => ({
|
|
97
96
|
job_id: job.jobId,
|
|
98
97
|
command: job.command,
|
|
98
|
+
purpose: job.purpose,
|
|
99
99
|
started_at: new Date(job.startedAtMs).toISOString(),
|
|
100
100
|
elapsed_ms: now - job.startedAtMs,
|
|
101
101
|
log_path: job.logPath,
|
|
@@ -108,20 +108,9 @@ const nodeBashList = defineLeaf({
|
|
|
108
108
|
const jobs = result['jobs'];
|
|
109
109
|
if (jobs.length === 0)
|
|
110
110
|
return `No background bash jobs for ${result['node_id']}.`;
|
|
111
|
-
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}\n log: ${job.log_path}`).join('\n');
|
|
111
|
+
return jobs.map((job) => `${job.job_id} · ${formatBashElapsed(job.elapsed_ms)} · ${job.command.split('\n')[0]}${job.purpose === null ? '' : `\n purpose: ${job.purpose}`}\n log: ${job.log_path}`).join('\n');
|
|
112
112
|
},
|
|
113
113
|
});
|
|
114
|
-
/** SIGTERM the job's process group, give it a moment, then SIGKILL. Returns
|
|
115
|
-
* false only when the group was already gone at the first signal. */
|
|
116
|
-
function stopJobGroup(pgid) {
|
|
117
|
-
try {
|
|
118
|
-
process.kill(-pgid, 'SIGTERM');
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
return true;
|
|
124
|
-
}
|
|
125
114
|
const nodeBashKill = defineLeaf({
|
|
126
115
|
name: 'kill',
|
|
127
116
|
description: 'stop one of a node’s background bash jobs',
|
|
@@ -153,35 +142,34 @@ const nodeBashKill = defineLeaf({
|
|
|
153
142
|
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
154
143
|
}
|
|
155
144
|
const jobId = input['job'];
|
|
145
|
+
if (!isSafeBashJobId(jobId)) {
|
|
146
|
+
throw new InputError({ error: 'invalid_job_id', message: `invalid background job id: ${jobId}`, next: 'Use a job id reported by `crtr node bash list`.' });
|
|
147
|
+
}
|
|
156
148
|
const dir = contextDir(node.node_id);
|
|
157
149
|
const paths = bashJobPaths(dir, jobId);
|
|
158
|
-
|
|
150
|
+
const stopped = await stopBackgroundBashJob(dir, jobId);
|
|
151
|
+
if (stopped.kind === 'missing') {
|
|
159
152
|
throw new InputError({ error: 'not_found', message: `no background job ${jobId} for ${node.node_id}`, next: 'List live jobs with `crtr node bash list`.' });
|
|
160
153
|
}
|
|
161
|
-
|
|
162
|
-
|
|
154
|
+
if (stopped.kind === 'retired') {
|
|
155
|
+
throw new InputError({
|
|
156
|
+
error: 'not_found',
|
|
157
|
+
message: `background job ${jobId} for ${node.node_id} is no longer running`,
|
|
158
|
+
next: `It finished or was already stopped, so nothing was signaled. Log: ${stopped.logPath}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (stopped.kind === 'not-stoppable') {
|
|
163
162
|
throw new InputError({
|
|
164
163
|
error: 'not_stoppable',
|
|
165
164
|
message: `job ${jobId} recorded no process group`,
|
|
166
165
|
next: `It started before crtr persisted one. Stop it by hand, then it will disappear from the list. Log: ${paths.jobLog}`,
|
|
167
166
|
});
|
|
168
167
|
}
|
|
169
|
-
const signaled = stopJobGroup(pgid);
|
|
170
|
-
// The supervisor shares the group it leads, so it usually dies with the
|
|
171
|
-
// command and never writes job.exit. Record the cancellation ourselves —
|
|
172
|
-
// that sentinel is what retires the job from every live-jobs read.
|
|
173
|
-
await new Promise((r) => setTimeout(r, 400));
|
|
174
|
-
try {
|
|
175
|
-
process.kill(-pgid, 'SIGKILL');
|
|
176
|
-
}
|
|
177
|
-
catch { /* already gone */ }
|
|
178
|
-
if (!existsSync(paths.jobExit))
|
|
179
|
-
writeFileSync(paths.jobExit, '143\n');
|
|
180
168
|
await cliClient().sendMessage(node.node_id, {
|
|
181
169
|
body: `Background bash job ${jobId} was stopped by the human before it finished. Log: ${paths.jobLog} Command: ${paths.cmdSh}`,
|
|
182
170
|
tier: 'urgent',
|
|
183
171
|
});
|
|
184
|
-
return { node_id: node.node_id, job_id: jobId, signaled };
|
|
172
|
+
return { node_id: node.node_id, job_id: jobId, signaled: stopped.signaled };
|
|
185
173
|
},
|
|
186
174
|
render: (result) => `Canceled background bash job ${result['job_id']} for ${result['node_id']}${result['signaled'] === true ? '' : ' (its process group was already gone)'}.`,
|
|
187
175
|
});
|
|
@@ -130,7 +130,7 @@ export const nodeConfig = defineLeaf({
|
|
|
130
130
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Target node id. Defaults to the node in --pane, else CRTR_NODE_ID.' },
|
|
131
131
|
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to resolve the node from. Defaults to $TMUX_PANE.' },
|
|
132
132
|
{ kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Model spec: exact provider/id, capability tier (ultra|strong|medium|light), family alias (opus|sonnet|haiku), or a free-text substring when the broker is live. Dormant nodes require a provider/id, tier, or alias.' },
|
|
133
|
-
{ kind: 'flag', name: 'lifecycle', type: 'enum', choices: ['terminal', 'resident'], required: false, constraint: 'Set lifecycle headlessly without closing the viewer. terminal owes a final and reaps when done; resident stays interactable
|
|
133
|
+
{ kind: 'flag', name: 'lifecycle', type: 'enum', choices: ['terminal', 'resident'], required: false, constraint: 'Set lifecycle headlessly without closing the viewer. terminal owes a final and reaps when done; resident stays interactable while attached or awaiting something live.' },
|
|
134
134
|
{ 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 sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here.' },
|
|
135
135
|
{ 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.' },
|
|
136
136
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Rename the node; if the node has a live window, also rename that viewer window.' },
|
|
@@ -242,7 +242,7 @@ export const nodePromote = defineLeaf({
|
|
|
242
242
|
summary: 'promote yourself to an orchestrator when worthwhile parallel delegation should make coordination your primary job; task duration, sequential phases, or one helper do not qualify. Mode only — lifecycle stays as-is unless you pass --resident',
|
|
243
243
|
params: [
|
|
244
244
|
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every top-level installable kind and when to use each; a sub-kind (e.g. plan/reviewers/security) is valid too by exact path but not listed here. For an exact composed persona list, use `crtr sys prompt-review --list --kind <kind>` with the target kind. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
|
|
245
|
-
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it
|
|
245
|
+
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it is never forced to submit a final, stays wakeable while attached or awaiting something live, and completes after the unattended grace otherwise. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
|
|
246
246
|
{ kind: 'flag', name: 'model', type: 'enum', choices: ['ultra', 'strong'], required: false, constraint: 'Change the model you run on, by capability tier: `ultra` (frontier — reserve for high-taste judgment or enormous work: speccing, planning something large, e2e-testing something hard to test) or `strong` (opus — regular dev work). Omit to keep your current model. An orchestrator steering work is never weaker than opus, so these are the only two choices; the change is durable across future revives.' },
|
|
247
247
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
|
|
248
248
|
],
|
|
@@ -30,7 +30,7 @@ export const surfaceNodeFocusLeaf = defineLeaf({
|
|
|
30
30
|
const node = await cliClient()
|
|
31
31
|
.getNode(id)
|
|
32
32
|
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
33
|
-
// A kind:'human' node is a control-plane ASK (a
|
|
33
|
+
// A kind:'human' node is a control-plane ASK (a page on the human's
|
|
34
34
|
// screen), NOT a pi conversation — it has no session. Reviving one boots a
|
|
35
35
|
// confused blank "you have been revived" pi, so refuse rather than focus it.
|
|
36
36
|
// (The nav/resume UIs already hide human nodes; this guards a hand-typed id.)
|
|
@@ -5,21 +5,18 @@ import { test, describe, before, after, beforeEach, afterEach } from 'node:test'
|
|
|
5
5
|
import assert from 'node:assert/strict';
|
|
6
6
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { join
|
|
8
|
+
import { join } from 'node:path';
|
|
9
9
|
import { Readable } from 'node:stream';
|
|
10
10
|
import { createApiServer } from '../../daemon/api/server.js';
|
|
11
|
-
import { cancelHumanTicket, deliverTerminalResult,
|
|
11
|
+
import { cancelHumanTicket, deliverTerminalResult, resolvePageTicket } from '../../daemon/human/finish.js';
|
|
12
12
|
import { humanAsk, humanNotify } from '../../commands/human/prompts.js';
|
|
13
|
-
import { runCli } from '../command.js';
|
|
14
13
|
import { getNode, listNodes, closeDb } from '../canvas/index.js';
|
|
15
14
|
import { readInboxSince } from '../feed/inbox.js';
|
|
16
15
|
import { resolveRef } from '../canvas/history.js';
|
|
17
|
-
import { apiSocketPath
|
|
16
|
+
import { apiSocketPath } from '../canvas/paths.js';
|
|
18
17
|
import { spawnNode } from '../runtime/nodes.js';
|
|
19
|
-
import { resolveRoot } from '../../build-root.js';
|
|
20
|
-
import { validateDeck } from '../human/deck-schema.js';
|
|
21
18
|
import { ticketDir } from '../human/root.js';
|
|
22
|
-
import { readTicketResult,
|
|
19
|
+
import { readTicketResult, submitPage } from '../human/tickets.js';
|
|
23
20
|
let home;
|
|
24
21
|
let priorHome;
|
|
25
22
|
let server;
|
|
@@ -58,15 +55,27 @@ function bridgeUnder() {
|
|
|
58
55
|
const bridge = spawnNode({ kind: 'human', parent, cwd: home, name: 'human-ask', lifecycle: 'terminal' }).node_id;
|
|
59
56
|
return { parent, bridge };
|
|
60
57
|
}
|
|
61
|
-
function
|
|
58
|
+
function submitBridgePage(bridge) {
|
|
62
59
|
const dir = ticketDir(bridge);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
60
|
+
const sourceDir = mkdtempSync(join(tmpdir(), 'crtr-deliver-page-'));
|
|
61
|
+
const sourceFile = join(sourceDir, 'page.md');
|
|
62
|
+
writeFileSync(sourceFile, [
|
|
63
|
+
'---',
|
|
64
|
+
'title: Ship it?',
|
|
65
|
+
'subtitle: Decide whether to ship now so the release can proceed.',
|
|
66
|
+
'---',
|
|
67
|
+
'',
|
|
68
|
+
'```slot:text',
|
|
69
|
+
'{ "id": "question", "initialText": "", "editable": true }',
|
|
70
|
+
'```',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n'));
|
|
73
|
+
try {
|
|
74
|
+
submitPage({ dir, sourceFile, dialect: 'md' });
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
70
79
|
return dir;
|
|
71
80
|
}
|
|
72
81
|
function reportBody(ref) {
|
|
@@ -110,47 +119,32 @@ describe('enqueue is producer-only', () => {
|
|
|
110
119
|
assert.equal(typeof result['dir'], 'string');
|
|
111
120
|
for (const forbidden of ['pane_id', 'render_pane_id', 'pid', 'worker_pid'])
|
|
112
121
|
assert.ok(!(forbidden in result), `ask result must not expose ${forbidden}`);
|
|
113
|
-
const
|
|
114
|
-
assert.equal(
|
|
115
|
-
assert.equal(
|
|
116
|
-
|
|
117
|
-
assert.
|
|
118
|
-
assert.equal(
|
|
119
|
-
const deckDirectory = mkdtempSync(join(tmpdir(), 'crtr-context-file-deck-'));
|
|
120
|
-
assert.notEqual(resolve(process.cwd()), deckDirectory, 'the CLI caller cwd must differ from the deck directory');
|
|
121
|
-
const deckPath = join(deckDirectory, 'deck.json');
|
|
122
|
-
writeFileSync(join(deckDirectory, 'body.md'), 'The body came from beside the deck JSON.');
|
|
123
|
-
writeFileSync(deckPath, JSON.stringify({ title: 'Context file body', interactions: [{ id: 'decision', title: 'Use the deck', subtitle: 'Use the deck body so the person can decide with its context.', bodyPath: 'body.md', options: [{ id: 'yes', label: 'Yes' }] }] }));
|
|
124
|
-
const nodesBefore = new Set(listNodes().map((node) => node.node_id));
|
|
125
|
-
await withMutedStdout(() => withPipedStdin('', async () => {
|
|
126
|
-
await runCli(await resolveRoot('human'), ['node', 'crtr', 'human', 'ask', '--context-file', deckPath]);
|
|
127
|
-
}));
|
|
128
|
-
const bridge = listNodes().find((node) => !nodesBefore.has(node.node_id));
|
|
129
|
-
assert.ok(bridge, 'the real CLI invocation created a human bridge');
|
|
130
|
-
const contextDeck = JSON.parse(readFileSync(join(nodeDir(bridge.node_id), 'deck.json'), 'utf8'));
|
|
131
|
-
assert.equal(contextDeck.interactions[0]?.body, 'The body came from beside the deck JSON.');
|
|
132
|
-
assert.equal(contextDeck.interactions[0]?.bodyPath, undefined);
|
|
122
|
+
const manifest = JSON.parse(readFileSync(join(result['dir'], 'page.json'), 'utf8'));
|
|
123
|
+
assert.equal(manifest.title, 'Release decision');
|
|
124
|
+
assert.equal(manifest.subtitle, 'Decide whether to release now so the launch can proceed.');
|
|
125
|
+
const document = readFileSync(join(result['dir'], 'page.md'), 'utf8');
|
|
126
|
+
assert.ok(document.includes('This first line must never become the title.'));
|
|
127
|
+
assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1, 'the synthesized question page carries one response slot');
|
|
133
128
|
});
|
|
134
129
|
});
|
|
135
130
|
describe('terminal ticket delivery', () => {
|
|
136
|
-
test('
|
|
131
|
+
test('resolvePageTicket pushFinals the readable answer once and wakes the subscribed parent', async () => {
|
|
137
132
|
const { parent, bridge } = bridgeUnder();
|
|
138
|
-
const dir =
|
|
139
|
-
const result = await
|
|
140
|
-
assert.equal(result.kind, '
|
|
133
|
+
const dir = submitBridgePage(bridge);
|
|
134
|
+
const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true, comments: [] } });
|
|
135
|
+
assert.equal(result.kind, 'page');
|
|
141
136
|
closeDb();
|
|
142
137
|
assert.equal(getNode(bridge)?.status, 'done');
|
|
143
138
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
144
139
|
assert.equal(finals.length, 1, 'exactly one pushFinal reached the parent');
|
|
145
140
|
const body = reportBody(finals[0].ref);
|
|
146
|
-
assert.
|
|
141
|
+
assert.ok(body.includes('"text":"yes, ship"'), 'the slot answer is inlined in the report');
|
|
147
142
|
assert.ok(body.includes(join(dir, 'response.json')));
|
|
148
|
-
assert.ok(!body.includes('humanloop.response/v2'));
|
|
149
143
|
});
|
|
150
144
|
test('a replay against a terminal bridge is a no-op', async () => {
|
|
151
145
|
const { parent, bridge } = bridgeUnder();
|
|
152
|
-
|
|
153
|
-
await
|
|
146
|
+
submitBridgePage(bridge);
|
|
147
|
+
await resolvePageTicket(bridge, { question: { text: 'yes', edited: true, comments: [] } });
|
|
154
148
|
assert.equal(await deliverTerminalResult(bridge), true);
|
|
155
149
|
closeDb();
|
|
156
150
|
const finals = readInboxSince(parent).filter((entry) => entry.from === bridge && entry.kind === 'final');
|
|
@@ -162,7 +156,7 @@ describe('terminal ticket delivery', () => {
|
|
|
162
156
|
const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
|
|
163
157
|
const { subscribe } = await import('../canvas/canvas.js');
|
|
164
158
|
subscribe(other, bridge, true);
|
|
165
|
-
|
|
159
|
+
submitBridgePage(bridge);
|
|
166
160
|
await cancelHumanTicket(bridge, { reason: 'stale', actor });
|
|
167
161
|
closeDb();
|
|
168
162
|
assert.equal(getNode(bridge)?.status, 'done');
|
|
@@ -173,12 +167,12 @@ describe('terminal ticket delivery', () => {
|
|
|
173
167
|
assert.match(notes[0].label, /canceled/);
|
|
174
168
|
});
|
|
175
169
|
});
|
|
176
|
-
test('notify enqueues a durable
|
|
170
|
+
test('notify enqueues a durable page and creates no canvas node', async () => {
|
|
177
171
|
const before = listNodes().length;
|
|
178
172
|
const result = (await humanNotify.run({ title: 'Build done', subtitle: 'The build passed, so the change is ready to review.' }));
|
|
179
173
|
assert.equal(result['queued'], true);
|
|
180
174
|
assert.equal(typeof result['dir'], 'string');
|
|
181
|
-
assert.ok(existsSync(join(result['dir'], '
|
|
175
|
+
assert.ok(existsSync(join(result['dir'], 'page.json')));
|
|
182
176
|
assert.ok(readTicketResult(result['dir']) === null);
|
|
183
177
|
closeDb();
|
|
184
178
|
assert.equal(listNodes().length, before);
|
|
@@ -75,7 +75,7 @@ test('G1 — controller prompt runs the engine and the streamed AgentSessionEven
|
|
|
75
75
|
// ran AND its terminal frame reached the client; the intermediate types prove
|
|
76
76
|
// the stream (not just a final blob) was fanned out.
|
|
77
77
|
await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'G1 agent_end carrying the prompt token');
|
|
78
|
-
for (const t of ['message_start', 'message_update', 'tool_execution_start', 'tool_execution_end', 'message_end', 'turn_end', 'agent_start', 'turn_start']) {
|
|
78
|
+
for (const t of ['message_start', 'message_update', 'tool_execution_start', 'tool_execution_update', 'tool_execution_end', 'message_end', 'turn_end', 'agent_start', 'turn_start']) {
|
|
79
79
|
assert.ok(c.frames.some((f) => f.type === t), `G1: client received a relayed ${t} frame`);
|
|
80
80
|
}
|
|
81
81
|
assert.ok(c.frames.some((f) => f.type === 'message_start' && frameHas(f, token)), 'G1: a relayed message carries the prompt token');
|
|
@@ -101,6 +101,12 @@ test('G1b — coalesced message_update never arrives after message_end; updates
|
|
|
101
101
|
const msgEnd = types.indexOf('message_end');
|
|
102
102
|
assert.ok(lastUpdate >= 0, 'G1b: at least one message_update relayed (coalescing must not starve updates)');
|
|
103
103
|
assert.ok(msgEnd >= 0, 'G1b: message_end relayed');
|
|
104
|
+
const updates = c.frames.filter((frame) => frame.type === 'message_update');
|
|
105
|
+
assert.ok(updates.length < 12, `G1b: ${updates.length} relayed updates proves the 12-update burst was coalesced`);
|
|
106
|
+
for (const update of updates) {
|
|
107
|
+
assert.equal(update.message.role, 'assistant', 'G1b: crouter relays the in-process cumulative message_update contract');
|
|
108
|
+
assert.ok('partial' in update.assistantMessageEvent, 'G1b: crouter does not route pi JSON/RPC delta-only events through view.sock');
|
|
109
|
+
}
|
|
104
110
|
assert.ok(lastUpdate < msgEnd, `G1b: a message_update arrived AFTER message_end (update@${lastUpdate} vs end@${msgEnd}) — the coalescer flushed out of order`);
|
|
105
111
|
// tool/turn/agent frames must also never precede a stale held update.
|
|
106
112
|
for (const t of ['tool_execution_start', 'turn_end', 'agent_end']) {
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// (1) Issue #222/#353 — a real idle-release cycle must settle the inbox watcher
|
|
7
7
|
// before the broker exits, and the daemon may only revive for a row strictly
|
|
8
8
|
// after the committed cursor.
|
|
9
|
-
// (2) The observed 57-broker / ~5.4GB leak —
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// (2) The observed 57-broker / ~5.4GB leak — DAEMON SUPERVISION, not an agent
|
|
10
|
+
// event, owns the unattended clock. A resident with no live obligation
|
|
11
|
+
// completes into prunable history; one awaiting a child remains wakeable.
|
|
12
|
+
// Both release their broker and fleet slot.
|
|
13
13
|
//
|
|
14
14
|
// Both drive a real broker process through the real watcher/supervision paths, so
|
|
15
15
|
// they belong to the compiled seam tier; neither needs tmux.
|
|
@@ -110,7 +110,20 @@ test('idle-release settles its real watcher before exit; daemon revives exactly
|
|
|
110
110
|
await h.tick();
|
|
111
111
|
assert.equal(h.node(manager).cycles ?? 0, cyclesAfterRelease + 1, 'the settled later row does not cause a duplicate revival');
|
|
112
112
|
});
|
|
113
|
-
test('
|
|
113
|
+
test('an already idle resident with no live obligation reconciles into completed history', async () => {
|
|
114
|
+
const nodeId = h.fabricateBrokerNode({
|
|
115
|
+
id: 'idle-resident-without-obligation',
|
|
116
|
+
parent: root,
|
|
117
|
+
lifecycle: 'resident',
|
|
118
|
+
status: 'idle',
|
|
119
|
+
intent: 'idle-release',
|
|
120
|
+
pi_session_id: 'parked-session',
|
|
121
|
+
});
|
|
122
|
+
await h.tick();
|
|
123
|
+
assert.equal(h.node(nodeId)?.status, 'done', 'an older idle row becomes pruning-eligible without needing a broker revive');
|
|
124
|
+
assert.equal(h.node(nodeId)?.intent, 'done', 'the reconciled row records clean completion');
|
|
125
|
+
});
|
|
126
|
+
test('an unattended resident with no live obligation completes on the daemon clock', { timeout: 30_000 }, async () => {
|
|
114
127
|
// The park clock is read per supervision pass (unattendedParkMsForDaemon), so
|
|
115
128
|
// it is armed for THIS test only — a file-global 1500ms park clock would also
|
|
116
129
|
// apply to the idle-release manager above and race its own release path.
|
|
@@ -120,6 +133,7 @@ test('a revived settled node with no work releases on the daemon park clock and
|
|
|
120
133
|
const nodeId = h.fabricateBrokerNode({
|
|
121
134
|
id: 'settled-no-work',
|
|
122
135
|
parent: root,
|
|
136
|
+
lifecycle: 'resident',
|
|
123
137
|
status: 'idle',
|
|
124
138
|
intent: 'idle-release',
|
|
125
139
|
});
|
|
@@ -141,15 +155,55 @@ test('a revived settled node with no work releases on the daemon park clock and
|
|
|
141
155
|
await h.tick(seenAt + 1500);
|
|
142
156
|
await h.waitFor(() => {
|
|
143
157
|
const node = h.node(nodeId);
|
|
144
|
-
return node?.status === '
|
|
145
|
-
}, { timeoutMs: 10_000, intervalMs: 20, label: '
|
|
158
|
+
return node?.status === 'done' && node.intent === 'done' && !isPidAlive(boot.pid);
|
|
159
|
+
}, { timeoutMs: 10_000, intervalMs: 20, label: 'unattended completion and broker exit' });
|
|
146
160
|
// The pid vanishing is not the fleet learning about it: wait for the child
|
|
147
161
|
// 'exit' delivery + its policy job, which is what actually frees the slot.
|
|
148
162
|
await h.awaitFleetExit(nodeId);
|
|
149
|
-
assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the
|
|
150
|
-
assert.equal(h.node(nodeId)?.
|
|
163
|
+
assert.equal(h.fleet.has(nodeId), false, 'exit policy forgets the completed broker');
|
|
164
|
+
assert.equal(h.node(nodeId)?.status, 'done', 'the row is terminal and eligible for history pruning');
|
|
151
165
|
await h.tick();
|
|
152
|
-
assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive
|
|
166
|
+
assert.equal(h.fleet.has(nodeId), false, 'a supervision tick does not revive completed history');
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (priorParkMs === undefined)
|
|
170
|
+
delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
171
|
+
else
|
|
172
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
test('an unattended resident awaiting a live child remains wakeable', { timeout: 30_000 }, async () => {
|
|
176
|
+
const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
|
|
177
|
+
process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
|
|
178
|
+
try {
|
|
179
|
+
const nodeId = h.fabricateBrokerNode({
|
|
180
|
+
id: 'settled-awaiting-child',
|
|
181
|
+
parent: root,
|
|
182
|
+
lifecycle: 'resident',
|
|
183
|
+
status: 'idle',
|
|
184
|
+
intent: 'idle-release',
|
|
185
|
+
});
|
|
186
|
+
const child = h.fabricateBrokerNode({ parent: nodeId, status: 'idle', intent: 'idle-release' });
|
|
187
|
+
subscribe(nodeId, child, true);
|
|
188
|
+
updateNode(nodeId, {
|
|
189
|
+
launch: {
|
|
190
|
+
extensions: [],
|
|
191
|
+
tools: [],
|
|
192
|
+
systemPrompt: '',
|
|
193
|
+
env: {},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
reviveNode(nodeId, { resume: true });
|
|
197
|
+
const boot = await h.awaitBoot(nodeId);
|
|
198
|
+
const seenAt = 20_000;
|
|
199
|
+
await h.tick(seenAt);
|
|
200
|
+
await h.tick(seenAt + 1500);
|
|
201
|
+
await h.waitFor(() => {
|
|
202
|
+
const node = h.node(nodeId);
|
|
203
|
+
return node?.status === 'idle' && node.intent === 'idle-release' && !isPidAlive(boot.pid);
|
|
204
|
+
}, { timeoutMs: 10_000, intervalMs: 20, label: 'awaiting resident release and broker exit' });
|
|
205
|
+
await h.awaitFleetExit(nodeId);
|
|
206
|
+
assert.equal(h.node(nodeId)?.status, 'idle', 'a live child keeps the resident eligible for an automatic wake');
|
|
153
207
|
}
|
|
154
208
|
finally {
|
|
155
209
|
if (priorParkMs === undefined)
|