@north-light/crouter 0.3.248 → 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/clients/inbox/tui/clipboard.d.ts +0 -2
- package/dist/clients/inbox/tui/clipboard.js +0 -26
- package/dist/pi-extensions/truncate.d.ts +0 -5
- package/dist/pi-extensions/truncate.js +0 -14
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A page ticket that settles out-of-band (answered from another surface, or a
|
|
2
|
+
// row this inbox failed to drop) must not read as "being edited by another
|
|
3
|
+
// inbox" when the reader presses Enter: its response.json already exists, so the
|
|
4
|
+
// row is stale and this activation must report the settlement and drop it.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { ticketDir } from '#core/human/root';
|
|
10
|
+
import { submitPage, takeoverAndCompletePage } from '#core/human/tickets';
|
|
11
|
+
import { claimPath } from '#core/human/convention';
|
|
12
|
+
import { InboxController } from '../../controller.js';
|
|
13
|
+
const key = (part = {}) => ({ ctrl: false, meta: false, upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, wordLeft: false, wordRight: false, home: false, end: false, pageUp: false, pageDown: false, del: false, return: false, newline: false, escape: false, tab: false, backspace: false, ...part });
|
|
14
|
+
function fakeHost() {
|
|
15
|
+
const handle = { close() { }, invalidate() { }, onClose() { } };
|
|
16
|
+
return { capabilities: { remote: false, canFocusNode: false, selfPane: undefined }, mount: () => handle, notice() { }, suspendForChild: () => () => { }, focusNode: async () => false, pickReviewFile: async () => null };
|
|
17
|
+
}
|
|
18
|
+
const status = (c) => c.status;
|
|
19
|
+
const temp = mkdtempSync(join(tmpdir(), 'crtr-stale-row-'));
|
|
20
|
+
const sources = mkdtempSync(join(tmpdir(), 'crtr-stale-src-'));
|
|
21
|
+
process.env['CRTR_HOME'] = temp;
|
|
22
|
+
const sourceFile = join(sources, 'ask.tsx');
|
|
23
|
+
writeFileSync(sourceFile, 'export default () => (\n <Page title="Stale row page" subtitle="Pick one">\n <UserQuestion id="choice" label="Restore?" body="Pick one." mode="single" options={[{ id: "yes", label: "Yes" }, { id: "no", label: "No" }]} />\n </Page>\n);\n');
|
|
24
|
+
const inboxDelivery = { placement: 'inline', inbox: true, reply: true };
|
|
25
|
+
try {
|
|
26
|
+
const dir = ticketDir('stale-page');
|
|
27
|
+
submitPage({ dir, sourceFile, delivery: inboxDelivery });
|
|
28
|
+
// Seed the controller's in-memory list from disk while the ticket is pending.
|
|
29
|
+
const options = { cols: 100, rows: 24 };
|
|
30
|
+
const controller = new InboxController({ host: fakeHost(), onDirty() { }, onClosed() { }, ...options });
|
|
31
|
+
assert.equal(controller.snapshot().items.length, 1, 'pending page is listed');
|
|
32
|
+
// Settle it out of band, without letting the controller rescan: this is the
|
|
33
|
+
// stale-row state the bug leaves behind.
|
|
34
|
+
takeoverAndCompletePage(dir, { choice: { selectedOptionIds: ['yes'], comments: [] } }, { host: 'daemon', pid: 1 });
|
|
35
|
+
assert.equal(existsSync(join(dir, 'response.json')), true, 'response is published on disk');
|
|
36
|
+
assert.equal(existsSync(claimPath(dir)), false, 'completion leaves no claim behind');
|
|
37
|
+
assert.equal(controller.snapshot().items.length, 1, 'the in-memory row is still stale');
|
|
38
|
+
// Pressing Enter on the stale row must report the settlement and drop it,
|
|
39
|
+
// never claim it is edited by another inbox.
|
|
40
|
+
controller.handleKey('', key({ return: true }));
|
|
41
|
+
const reported = status(controller) ?? '';
|
|
42
|
+
assert.match(reported, /already resolved/, `stale-row activation reports the settlement, got: ${JSON.stringify(reported)}`);
|
|
43
|
+
assert.doesNotMatch(reported, /being edited/, 'a settled row is never "edited by another inbox"');
|
|
44
|
+
assert.equal(controller.snapshot().items.length, 0, 'the stale row drops on activation');
|
|
45
|
+
assert.equal(controller.snapshot().screen, 'list', 'a settled row never opens the detail pane');
|
|
46
|
+
controller.close();
|
|
47
|
+
console.log('stale-row regression passed');
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
rmSync(temp, { recursive: true, force: true });
|
|
51
|
+
rmSync(sources, { recursive: true, force: true });
|
|
52
|
+
delete process.env['CRTR_HOME'];
|
|
53
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { statSync, watch } from 'node:fs';
|
|
1
|
+
import { existsSync, statSync, watch } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { visibleWidth } from '@earendil-works/pi-tui';
|
|
2
4
|
import { renderMarkdown } from '../../core/termrender/termrender.js';
|
|
3
5
|
import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from './tui/ansi.js';
|
|
4
6
|
import { buildInboxLines, recommendedLabel } from './tui.js';
|
|
@@ -8,7 +10,8 @@ import { resolvePageComponents } from '../../core/config.js';
|
|
|
8
10
|
import { watchInboxActivity } from '../../core/human/root.js';
|
|
9
11
|
import { claimTicket, heartbeatClaim, releaseClaim } from '../../core/human/claim.js';
|
|
10
12
|
import { readPageTicket, readTicketResult } from '../../core/human/tickets.js';
|
|
11
|
-
import { clearProgress, pagePath } from '../../core/human/convention.js';
|
|
13
|
+
import { claimPath, clearProgress, isResolved, pagePath, responsePath } from '../../core/human/convention.js';
|
|
14
|
+
import { emitEvent } from '../../core/events/emit.js';
|
|
12
15
|
import { projectPageDisplayMarkdown } from '../../core/human/page-markdown.js';
|
|
13
16
|
import { projectHtmlDocumentMarkdown } from '../../core/human/page-html-markdown.js';
|
|
14
17
|
import { pageBodyLines } from './tui/page-body.js';
|
|
@@ -200,7 +203,13 @@ export class InboxController {
|
|
|
200
203
|
}
|
|
201
204
|
const claim = claimTicket(item.dir);
|
|
202
205
|
if (claim === null) {
|
|
203
|
-
|
|
206
|
+
// A null claim means either the ticket already settled (its response.json
|
|
207
|
+
// exists) or a live claim is held. Only the latter is "another inbox";
|
|
208
|
+
// the former is a stale row this scan should drop.
|
|
209
|
+
const resolved = isResolved(item.dir);
|
|
210
|
+
this.status = resolved ? safeCanonicalResultStatus(item.dir) : 'ticket is being edited by another inbox';
|
|
211
|
+
if (resolved)
|
|
212
|
+
this.rescan();
|
|
204
213
|
return;
|
|
205
214
|
}
|
|
206
215
|
const settled = item.fastAction.kind === 'acknowledge'
|
|
@@ -250,7 +259,10 @@ export class InboxController {
|
|
|
250
259
|
return;
|
|
251
260
|
const claim = claimTicket(item.dir);
|
|
252
261
|
if (claim === null) {
|
|
253
|
-
|
|
262
|
+
const resolved = isResolved(item.dir);
|
|
263
|
+
this.status = resolved ? safeCanonicalResultStatus(item.dir) : 'ticket is being edited by another inbox';
|
|
264
|
+
if (resolved)
|
|
265
|
+
this.rescan();
|
|
254
266
|
return;
|
|
255
267
|
}
|
|
256
268
|
this.claim = { dir: item.dir, token: claim.token };
|
|
@@ -361,6 +373,22 @@ export class InboxController {
|
|
|
361
373
|
this.submittingDir = undefined;
|
|
362
374
|
this.leaveDetail(false);
|
|
363
375
|
this.rescan();
|
|
376
|
+
// A settled page whose row survives its own post-resolve rescan is the
|
|
377
|
+
// "row won't leave" defect. This records the disk truth at the exact moment
|
|
378
|
+
// the row should have dropped, so a live recurrence is diagnosable without a
|
|
379
|
+
// reproduction: was the response published, does the scan still list it, and
|
|
380
|
+
// was the claim left behind.
|
|
381
|
+
const stillListed = this.items.some((item) => item.dir === claim.dir);
|
|
382
|
+
emitEvent({
|
|
383
|
+
level: stillListed ? 'warn' : 'info',
|
|
384
|
+
event: 'inbox.page.resolved',
|
|
385
|
+
fields: {
|
|
386
|
+
ticket_id: basename(claim.dir),
|
|
387
|
+
response_published: existsSync(responsePath(claim.dir)),
|
|
388
|
+
still_listed: stillListed,
|
|
389
|
+
claim_left_behind: existsSync(claimPath(claim.dir)),
|
|
390
|
+
},
|
|
391
|
+
});
|
|
364
392
|
this.markDirty();
|
|
365
393
|
}
|
|
366
394
|
/** Edit the active buffer while the host releases the terminal to $EDITOR. */
|
|
@@ -527,7 +555,6 @@ export class InboxController {
|
|
|
527
555
|
}
|
|
528
556
|
}
|
|
529
557
|
}
|
|
530
|
-
function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
|
|
531
558
|
function canonicalResultStatus(result) {
|
|
532
559
|
if (result === null)
|
|
533
560
|
return 'ticket was already resolved';
|
|
@@ -33,7 +33,9 @@ export declare function hardWrap(text: string, maxWidth: number): string[];
|
|
|
33
33
|
* them as cheap no-ops.
|
|
34
34
|
*/
|
|
35
35
|
export declare function centerHorizontal(lines: string[], cols: number, contentWidth: number): string[];
|
|
36
|
-
/** Visible width of a line in display cells
|
|
36
|
+
/** Visible width of a line in display cells. pi-tui's helper is the one
|
|
37
|
+
* terminal-cell contract: it accounts for graphemes and wide cells while
|
|
38
|
+
* ignoring SGR, OSC, and APC sequences. */
|
|
37
39
|
export declare function visibleWidth(line: string): number;
|
|
38
40
|
/**
|
|
39
41
|
* The one display-cell containment primitive: the visible cells
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import stringWidth from 'string-width';
|
|
2
|
+
import { visibleWidth as piVisibleWidth } from '@earendil-works/pi-tui';
|
|
2
3
|
// ── ANSI escape constants ─────────────────────────────────────────────────────
|
|
3
4
|
// The single home for these — both the page renderer (render.ts) and the inbox
|
|
4
5
|
// picker (inbox/tui.ts) import from here rather than keeping their own copies.
|
|
@@ -172,31 +173,34 @@ export function centerHorizontal(lines, cols, contentWidth) {
|
|
|
172
173
|
const pad = ' '.repeat(extraPad);
|
|
173
174
|
return lines.map((line) => (line === '' ? '' : pad + line));
|
|
174
175
|
}
|
|
175
|
-
/** Visible width of a line in display cells
|
|
176
|
+
/** Visible width of a line in display cells. pi-tui's helper is the one
|
|
177
|
+
* terminal-cell contract: it accounts for graphemes and wide cells while
|
|
178
|
+
* ignoring SGR, OSC, and APC sequences. */
|
|
176
179
|
export function visibleWidth(line) {
|
|
177
|
-
return
|
|
180
|
+
return piVisibleWidth(line);
|
|
178
181
|
}
|
|
179
182
|
const ANSI_TOKEN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)|\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-^]/g;
|
|
180
183
|
const OSC8_TOKEN = /\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(\x07|\x1b\\)/g;
|
|
181
184
|
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
182
185
|
/** ANSI escapes are tokens of their own; all printable text is segmented into
|
|
183
186
|
* extended grapheme clusters before widths or slice boundaries are calculated.
|
|
184
|
-
* `
|
|
187
|
+
* `visibleWidth` assigns display width to the complete cluster (not its
|
|
185
188
|
* constituent code points), which keeps ZWJ emoji, flags, skin tones and
|
|
186
|
-
* combining sequences indivisible while panning
|
|
189
|
+
* combining sequences indivisible while panning, and counts a tab as the same
|
|
190
|
+
* cells the rest of the rectangle measures it at. */
|
|
187
191
|
function displayTokens(line) {
|
|
188
192
|
const tokens = [];
|
|
189
193
|
let at = 0;
|
|
190
194
|
ANSI_TOKEN.lastIndex = 0;
|
|
191
195
|
for (let match = ANSI_TOKEN.exec(line); match !== null; match = ANSI_TOKEN.exec(line)) {
|
|
192
196
|
for (const segment of GRAPHEMES.segment(line.slice(at, match.index))) {
|
|
193
|
-
tokens.push({ ansi: false, text: segment.segment, width:
|
|
197
|
+
tokens.push({ ansi: false, text: segment.segment, width: visibleWidth(segment.segment) });
|
|
194
198
|
}
|
|
195
199
|
tokens.push({ ansi: true, text: match[0] });
|
|
196
200
|
at = match.index + match[0].length;
|
|
197
201
|
}
|
|
198
202
|
for (const segment of GRAPHEMES.segment(line.slice(at))) {
|
|
199
|
-
tokens.push({ ansi: false, text: segment.segment, width:
|
|
203
|
+
tokens.push({ ansi: false, text: segment.segment, width: visibleWidth(segment.segment) });
|
|
200
204
|
}
|
|
201
205
|
return tokens;
|
|
202
206
|
}
|
|
@@ -284,8 +288,8 @@ function closeActiveOsc8(line) {
|
|
|
284
288
|
export function clipLine(line, maxWidth) {
|
|
285
289
|
if (maxWidth < 1)
|
|
286
290
|
return '';
|
|
287
|
-
if (
|
|
288
|
-
return line; //
|
|
291
|
+
if (visibleWidth(line) <= maxWidth)
|
|
292
|
+
return line; // visibleWidth ignores ANSI
|
|
289
293
|
return sliceCells(line, 0, maxWidth);
|
|
290
294
|
}
|
|
291
295
|
/**
|
|
@@ -300,7 +304,7 @@ export function clipLine(line, maxWidth) {
|
|
|
300
304
|
export function panLine(line, offset, width) {
|
|
301
305
|
if (width < 1)
|
|
302
306
|
return '';
|
|
303
|
-
const w =
|
|
307
|
+
const w = visibleWidth(line);
|
|
304
308
|
if (w <= width)
|
|
305
309
|
return line;
|
|
306
310
|
const max = w - width;
|
|
@@ -313,5 +317,5 @@ export function panLine(line, offset, width) {
|
|
|
313
317
|
}
|
|
314
318
|
/** Cells of `line` that fall outside a `width`-wide rectangle. */
|
|
315
319
|
export function rowOverflow(line, width) {
|
|
316
|
-
return Math.max(0,
|
|
320
|
+
return Math.max(0, visibleWidth(line) - width);
|
|
317
321
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `node inspect list`/etc used to surface a bare `daemon_unavailable` on a
|
|
3
3
|
// cold-start timeout, discarding the real cause. `readColdStartDiagnostic`
|
|
4
4
|
// (`../api-client.ts`) is what `cliClient()` wires into `CrtrClient` as
|
|
5
|
-
// `coldStartDiagnostic` — this proves it reads a BOUNDED tail of crtrd.
|
|
5
|
+
// `coldStartDiagnostic` — this proves it reads a BOUNDED tail of crtrd.log
|
|
6
6
|
// (never the whole file) and degrades to `undefined` (not a throw) when the
|
|
7
7
|
// log is missing. The final test proves the READ itself is bounded (not just
|
|
8
8
|
// the returned string) — the prior implementation `readFileSync`'d the whole
|
|
@@ -30,21 +30,21 @@ after(() => {
|
|
|
30
30
|
process.env.CRTR_HOME = previousHome;
|
|
31
31
|
rmSync(home, { recursive: true, force: true });
|
|
32
32
|
});
|
|
33
|
-
test('returns undefined when crtrd.
|
|
33
|
+
test('returns undefined when crtrd.log does not exist', () => {
|
|
34
34
|
assert.equal(readColdStartDiagnostic(), undefined);
|
|
35
35
|
});
|
|
36
|
-
test('returns undefined for an empty/whitespace-only crtrd.
|
|
37
|
-
writeFileSync(join(home, 'crtrd.
|
|
36
|
+
test('returns undefined for an empty/whitespace-only crtrd.log', () => {
|
|
37
|
+
writeFileSync(join(home, 'crtrd.log'), ' \n\n \n');
|
|
38
38
|
assert.equal(readColdStartDiagnostic(), undefined);
|
|
39
39
|
});
|
|
40
40
|
test('surfaces the tail of a real startup failure', () => {
|
|
41
|
-
writeFileSync(join(home, 'crtrd.
|
|
41
|
+
writeFileSync(join(home, 'crtrd.log'), 'Error: listen EADDRINUSE: address already in use :::0\n');
|
|
42
42
|
const diagnostic = readColdStartDiagnostic();
|
|
43
43
|
assert.match(diagnostic ?? '', /EADDRINUSE/);
|
|
44
44
|
});
|
|
45
45
|
test('bounds the tail instead of leaking the whole log', () => {
|
|
46
46
|
const lines = Array.from({ length: 5_000 }, (_, i) => `line ${i}: ${'x'.repeat(80)}`);
|
|
47
|
-
writeFileSync(join(home, 'crtrd.
|
|
47
|
+
writeFileSync(join(home, 'crtrd.log'), lines.join('\n'));
|
|
48
48
|
const diagnostic = readColdStartDiagnostic();
|
|
49
49
|
assert.ok(diagnostic !== undefined);
|
|
50
50
|
// Bounded on both axes: never the full 5,000-line log, and never unbounded bytes.
|
|
@@ -62,7 +62,7 @@ test('bounds the READ itself — the tail window starts at an exact byte offset,
|
|
|
62
62
|
const afterSentinel = 'AFTER-WINDOW-SENTINEL';
|
|
63
63
|
const prefixFiller = 'p'.repeat(50_000 - beforeSentinel.length);
|
|
64
64
|
const tailFiller = 'q'.repeat(COLD_START_DIAGNOSTIC_TAIL_BYTES - afterSentinel.length);
|
|
65
|
-
writeFileSync(join(home, 'crtrd.
|
|
65
|
+
writeFileSync(join(home, 'crtrd.log'), prefixFiller + beforeSentinel + afterSentinel + tailFiller);
|
|
66
66
|
const diagnostic = readColdStartDiagnostic() ?? '';
|
|
67
67
|
assert.match(diagnostic, /AFTER-WINDOW-SENTINEL/);
|
|
68
68
|
assert.doesNotMatch(diagnostic, /BEFORE-WINDOW-SENTINEL/);
|
|
@@ -20,12 +20,12 @@ export declare function rethrowAsCliError(err: unknown, next?: string): never;
|
|
|
20
20
|
* 404-only→null mapping as `ApiCanvasSource.orNull`, but returning the raw DTO
|
|
21
21
|
* and rethrowing non-404s as CLI errors rather than swallowing them. */
|
|
22
22
|
export declare function getNodeOrNull(nodeId: string): Promise<NodeDetailDTO | null>;
|
|
23
|
-
/** Bounded tail of crtrd.
|
|
23
|
+
/** Bounded tail of crtrd.log surfaced on a cold-start `/healthz` timeout.
|
|
24
24
|
* Large enough to catch a real startup failure/stack trace,
|
|
25
25
|
* small enough that a runaway daemon log can never leak unbounded into a CLI
|
|
26
26
|
* error message. */
|
|
27
27
|
export declare const COLD_START_DIAGNOSTIC_TAIL_BYTES = 4096;
|
|
28
|
-
/** Read a bounded tail of crtrd's
|
|
28
|
+
/** Read a bounded tail of crtrd's event log to enrich a `daemon_unavailable`
|
|
29
29
|
* cold-start timeout, so the actual startup failure is not discarded behind a
|
|
30
30
|
* bare message. Bounds the I/O itself — opens the file and reads only the
|
|
31
31
|
* final `COLD_START_DIAGNOSTIC_TAIL_BYTES` bytes via `fstatSync`+`readSync`,
|
|
@@ -56,13 +56,13 @@ export async function getNodeOrNull(nodeId) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
// cliClient — the one place the CLI constructs a daemon client
|
|
59
|
-
/** Bounded tail of crtrd.
|
|
59
|
+
/** Bounded tail of crtrd.log surfaced on a cold-start `/healthz` timeout.
|
|
60
60
|
* Large enough to catch a real startup failure/stack trace,
|
|
61
61
|
* small enough that a runaway daemon log can never leak unbounded into a CLI
|
|
62
62
|
* error message. */
|
|
63
63
|
export const COLD_START_DIAGNOSTIC_TAIL_BYTES = 4_096;
|
|
64
64
|
const COLD_START_DIAGNOSTIC_TAIL_LINES = 20;
|
|
65
|
-
/** Read a bounded tail of crtrd's
|
|
65
|
+
/** Read a bounded tail of crtrd's event log to enrich a `daemon_unavailable`
|
|
66
66
|
* cold-start timeout, so the actual startup failure is not discarded behind a
|
|
67
67
|
* bare message. Bounds the I/O itself — opens the file and reads only the
|
|
68
68
|
* final `COLD_START_DIAGNOSTIC_TAIL_BYTES` bytes via `fstatSync`+`readSync`,
|
|
@@ -73,7 +73,7 @@ const COLD_START_DIAGNOSTIC_TAIL_LINES = 20;
|
|
|
73
73
|
export function readColdStartDiagnostic() {
|
|
74
74
|
let fd;
|
|
75
75
|
try {
|
|
76
|
-
fd = openSync(join(crtrHome(), 'crtrd.
|
|
76
|
+
fd = openSync(join(crtrHome(), 'crtrd.log'), 'r');
|
|
77
77
|
const { size } = fstatSync(fd);
|
|
78
78
|
const start = Math.max(0, size - COLD_START_DIAGNOSTIC_TAIL_BYTES);
|
|
79
79
|
const length = size - start;
|
|
@@ -83,7 +83,7 @@ export function readColdStartDiagnostic() {
|
|
|
83
83
|
readSync(fd, buffer, 0, length, start);
|
|
84
84
|
const lines = buffer.toString('utf8').split('\n').filter((line) => line.trim() !== '');
|
|
85
85
|
const tail = lines.slice(-COLD_START_DIAGNOSTIC_TAIL_LINES).join('\n').trim();
|
|
86
|
-
return tail === '' ? undefined : `crtrd.
|
|
86
|
+
return tail === '' ? undefined : `crtrd.log (tail):\n${tail}`;
|
|
87
87
|
}
|
|
88
88
|
catch {
|
|
89
89
|
return undefined;
|
|
@@ -124,7 +124,7 @@ export function cliClient(opts) {
|
|
|
124
124
|
ensureDaemon();
|
|
125
125
|
},
|
|
126
126
|
// On a cold-start timeout, enrich the bare `daemon_unavailable` with the
|
|
127
|
-
// tail of crtrd's
|
|
127
|
+
// tail of crtrd's event log instead of discarding it.
|
|
128
128
|
coldStartDiagnostic: readColdStartDiagnostic,
|
|
129
129
|
coldStartPollWindowMs: DAEMON_STARTUP_WINDOW_MS,
|
|
130
130
|
});
|
|
@@ -64,7 +64,7 @@ export const browseLeaf = defineLeaf({
|
|
|
64
64
|
profileId,
|
|
65
65
|
source,
|
|
66
66
|
viewedNodeIds: async () => new Set((await cliClient().listFocuses()).map((focus) => focus.node_id)),
|
|
67
|
-
closeNode: (id) => cliClient().closeNode(id, { finish: true }).then((r) => ({ closed: r.closed })),
|
|
67
|
+
closeNode: (id) => cliClient().closeNode(id, { finish: true, caller_pid: process.ppid }).then((r) => ({ closed: r.closed })),
|
|
68
68
|
});
|
|
69
69
|
},
|
|
70
70
|
});
|
|
@@ -211,7 +211,8 @@ async function runNodeCreation(input) {
|
|
|
211
211
|
if (noKickoff && mode === 'orchestrator') {
|
|
212
212
|
throw new InputError({ error: 'no_kickoff_orchestrator_unsupported', message: '--no-kickoff cannot be combined with --mode orchestrator', field: 'no-kickoff', next: 'Drop --no-kickoff and provide a prompt, or spawn in base mode.' });
|
|
213
213
|
}
|
|
214
|
-
const
|
|
214
|
+
const creator = envNodeId();
|
|
215
|
+
const parentId = parent ?? creator;
|
|
215
216
|
mark('node_new.create_start', { root });
|
|
216
217
|
const detail = await cliClient().createNode({
|
|
217
218
|
...(kind !== undefined ? { kind } : {}),
|
|
@@ -224,6 +225,7 @@ async function runNodeCreation(input) {
|
|
|
224
225
|
...(name !== undefined ? { name } : {}),
|
|
225
226
|
prompt,
|
|
226
227
|
...(root ? { root: true } : { parent: parentId ?? null }),
|
|
228
|
+
...(creator !== undefined ? { creator } : {}),
|
|
227
229
|
...(resident ? { root_lifecycle: 'resident' } : {}),
|
|
228
230
|
...(worktreeBase !== undefined ? { worktree: worktreeBase } : {}),
|
|
229
231
|
...(forkFrom !== undefined ? { fork_from: forkFrom } : {}),
|
|
@@ -214,6 +214,7 @@ const nodeShow = defineLeaf({
|
|
|
214
214
|
];
|
|
215
215
|
if (node['parent'])
|
|
216
216
|
meta.push(`- parent: ${node['parent']}`);
|
|
217
|
+
meta.push(`- creator: ${node['creator'] ?? 'an external process'}`);
|
|
217
218
|
if (node['waiting_for'])
|
|
218
219
|
meta.push(`- waiting for message: ${node['waiting_for']}`);
|
|
219
220
|
parts.push(meta.join('\n'));
|
|
@@ -100,7 +100,7 @@ const nodeClose = defineLeaf({
|
|
|
100
100
|
throw new InputError({ error: 'no_node', message: 'no node found in this pane to close', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
|
|
101
101
|
}
|
|
102
102
|
const res = await cliClient()
|
|
103
|
-
.closeNode(id)
|
|
103
|
+
.closeNode(id, { caller_pid: process.ppid })
|
|
104
104
|
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
105
105
|
return { node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
|
|
106
106
|
},
|
|
@@ -46,7 +46,7 @@ function messageSendParams() {
|
|
|
46
46
|
{ kind: 'stdin', name: 'body', required: false, constraint: 'Visible message body. Required unless --situational-context is supplied.' },
|
|
47
47
|
{ kind: 'flag', name: 'to', type: 'string', required: false, constraint: 'Target an existing node by id. Exactly one of --to or --self is required.' },
|
|
48
48
|
{ kind: 'flag', name: 'self', type: 'bool', required: false, constraint: 'Target the calling node. Exactly one of --to or --self is required.' },
|
|
49
|
-
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, default: 'normal', constraint: 'Delivery urgency. Deferred never wakes an idle node.' },
|
|
49
|
+
{ kind: 'flag', name: 'tier', type: 'enum', choices: ['critical', 'urgent', 'normal', 'deferred'], required: false, default: 'normal', constraint: 'Delivery urgency. Deferred never wakes an idle node, except a terminal one, which has no later cycle to read it on and is raised to urgent.' },
|
|
50
50
|
{ kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Non-empty hidden ambient context delivered beside the message, or alone.' },
|
|
51
51
|
{ kind: 'flag', name: 'reopen', type: 'bool', required: false, constraint: 'Re-task the target: commits it resident and clears any finalization latch before delivery. Valid for live, parked, and finalized targets.' },
|
|
52
52
|
];
|
|
@@ -97,9 +97,11 @@ async function runMessageEngine(input) {
|
|
|
97
97
|
}).catch(rethrowAsCliError);
|
|
98
98
|
const guidance = res.revived
|
|
99
99
|
? 'Delivered — the dormant target was revived to receive it.'
|
|
100
|
-
: tier
|
|
101
|
-
?
|
|
102
|
-
:
|
|
100
|
+
: tier !== 'deferred'
|
|
101
|
+
? 'Delivered.'
|
|
102
|
+
: res.delivered_tier === undefined
|
|
103
|
+
? "Delivered — held for the target's next natural cycle; not revived."
|
|
104
|
+
: 'Delivered — the target is terminal and takes no later cycle, so it was raised to urgent rather than held.';
|
|
103
105
|
return { revived: res.revived, guidance };
|
|
104
106
|
}
|
|
105
107
|
const nodeMessageSend = defineLeaf({
|
|
@@ -46,7 +46,7 @@ function deadlineHeredoc(body, nodeId) {
|
|
|
46
46
|
const lines = new Set(body.split('\n'));
|
|
47
47
|
while (lines.has(delimiter))
|
|
48
48
|
delimiter = `CRTR_DEADLINE_${++suffix}`;
|
|
49
|
-
return `crtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
|
|
49
|
+
return `crtr cron cancel "$CRTR_CRON_ID" && crtr node message send --to ${shellQuote(nodeId)} --tier urgent <<'${delimiter}'\n${body}\n${delimiter}`;
|
|
50
50
|
}
|
|
51
51
|
const nodeWaitDeadline = defineLeaf({
|
|
52
52
|
name: 'deadline',
|
package/dist/commands/push.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
// `crtr push` — the one verb up the graph.
|
|
2
2
|
//
|
|
3
|
-
// crtr push update [body] —
|
|
4
|
-
// crtr push urgent [body] — force-wake subscribers
|
|
3
|
+
// crtr push update [body] — report progress to subscribers
|
|
5
4
|
// crtr push final [body] — finish: write result, mark node done, close window
|
|
6
5
|
//
|
|
7
|
-
// "push" is THE verb a node uses to talk to its managers
|
|
8
|
-
//
|
|
9
|
-
// runtime into every node process). Absent ⇒
|
|
10
|
-
// meaningful inside a live node.
|
|
6
|
+
// "push" is THE verb a node uses to talk to its managers. `update` reports
|
|
7
|
+
// progress; its --tier sets delivery urgency. The caller's node is resolved from
|
|
8
|
+
// CRTR_NODE_ID (injected by the runtime into every node process). Absent ⇒
|
|
9
|
+
// InputError — push is only meaningful inside a live node.
|
|
11
10
|
//
|
|
12
|
-
// STAGE B-5: this verb is a PURE crtrd API client. Every
|
|
11
|
+
// STAGE B-5: this verb is a PURE crtrd API client. Every report goes through
|
|
13
12
|
// `POST /nodes/{id}/reports` (`client.pushReport`); the report file write, the
|
|
14
13
|
// per-subscriber inbox fan, and (final only) the lifecycle transition all happen
|
|
15
14
|
// server-side inside feed `push()` (spec §7.4). This file reaches ZERO canvas
|
|
@@ -32,34 +31,32 @@ function requireCallerNode() {
|
|
|
32
31
|
}
|
|
33
32
|
return id.trim();
|
|
34
33
|
}
|
|
35
|
-
const
|
|
36
|
-
update: '
|
|
37
|
-
urgent: 'force-wake subscribers (inbox tier urgent)',
|
|
34
|
+
const PUSH_BLURB = {
|
|
35
|
+
update: 'report progress to subscribers',
|
|
38
36
|
final: 'finish: write the canonical result, mark the node done, close its window',
|
|
39
37
|
};
|
|
40
|
-
const
|
|
41
|
-
update: 'you have
|
|
42
|
-
|
|
43
|
-
final: 'the work this node was spawned to do is complete and you are ready to hand back the canonical result — this writes that result, marks the node done, and closes its window, so it is the LAST thing you do here, not a progress note. Any node finishes this way. Use push update or push urgent instead while work is still in flight, and do not reach for this on a node working directly with the user: it has no manager to report up to and would close mid-conversation (the guard blocks it unless you pass --force after the user confirms).',
|
|
38
|
+
const PUSH_WHEN_TO_USE = {
|
|
39
|
+
update: 'you have progress worth surfacing to your managers while work remains in flight. Use normal delivery unless it can wait for their next real wake (--tier deferred) or needs immediate attention (--tier urgent); use push final only when the work is actually done.',
|
|
40
|
+
final: 'the work this node was spawned to do is complete and you are ready to hand back the canonical result — this writes that result, marks the node done, and closes its window, so it is the LAST thing you do here, not a progress note. Use push update while work is still in flight, and do not reach for final on a node working directly with the user: it has no manager to report up to and would close mid-conversation (the guard blocks it unless you pass --force after the user confirms).',
|
|
44
41
|
};
|
|
45
|
-
function
|
|
42
|
+
function makePushLeaf(kind) {
|
|
46
43
|
return defineLeaf({
|
|
47
|
-
name:
|
|
48
|
-
description:
|
|
49
|
-
whenToUse:
|
|
44
|
+
name: kind,
|
|
45
|
+
description: PUSH_BLURB[kind],
|
|
46
|
+
whenToUse: PUSH_WHEN_TO_USE[kind],
|
|
50
47
|
help: {
|
|
51
|
-
name: `push ${
|
|
52
|
-
summary:
|
|
48
|
+
name: `push ${kind}`,
|
|
49
|
+
summary: PUSH_BLURB[kind],
|
|
53
50
|
params: [
|
|
54
51
|
{ kind: 'stdin', name: 'body', required: true, constraint: "Report body (markdown). Pipe it from a single-quoted heredoc (`<<'EOF'`) to preserve literal bytes." },
|
|
55
|
-
...(
|
|
56
|
-
? [{ kind: 'flag', name: '
|
|
57
|
-
: []),
|
|
52
|
+
...(kind === 'update'
|
|
53
|
+
? [{ kind: 'flag', name: 'tier', type: 'enum', choices: ['deferred', 'normal', 'urgent'], required: false, default: 'normal', constraint: 'Delivery urgency: deferred holds for the receiver’s next non-deferred wake; normal wakes normally; urgent steers a live receiver and wakes an idle receiver.' }]
|
|
54
|
+
: [{ kind: 'flag', name: 'force', type: 'bool', required: false, default: false, constraint: 'Override the guard that blocks `push final` on a node the user is attending (a resident with no one to report up to). Only pass this after the user explicitly confirms they want this node finished.' }]),
|
|
58
55
|
],
|
|
59
56
|
output: [
|
|
60
57
|
{ name: 'report_path', type: 'string', required: true, constraint: 'Absolute path of the written report.' },
|
|
61
58
|
{ name: 'notified', type: 'string[]', required: true, constraint: 'Subscriber node ids that received an inbox pointer.' },
|
|
62
|
-
...(
|
|
59
|
+
...(kind === 'final'
|
|
63
60
|
? [
|
|
64
61
|
{ name: 'transitioned', type: 'object', required: false, constraint: 'Present only for a final that flipped the node — { from, to } lifecycle-status transition applied server-side.' },
|
|
65
62
|
{ name: 'worktree_auto_dropped', type: 'boolean', required: false, constraint: 'Present only when this node owned a managed worktree with zero commits ahead of its base and no uncommitted changes — the server auto-dropped it instead of requiring `node worktree close` first (see worktree_auto_dropped_path).' },
|
|
@@ -69,9 +66,9 @@ function makeTierLeaf(tier) {
|
|
|
69
66
|
],
|
|
70
67
|
outputKind: 'object',
|
|
71
68
|
effects: [
|
|
72
|
-
'Writes nodes/<nodeId>/reports/<ts>-<
|
|
69
|
+
'Writes nodes/<nodeId>/reports/<ts>-<kind>.md (server-side).',
|
|
73
70
|
'Appends one inbox pointer per subscriber.',
|
|
74
|
-
...(
|
|
71
|
+
...(kind === 'final'
|
|
75
72
|
? [
|
|
76
73
|
'Marks the node done (status + intent) server-side; its engine shuts down on next stop.',
|
|
77
74
|
'When this node owns a managed worktree with zero commits ahead of its base and no uncommitted changes, the server auto-drops it instead of blocking on `node worktree close`; a worktree with real commits or real uncommitted changes still blocks with open_managed_worktree.',
|
|
@@ -96,7 +93,7 @@ function makeTierLeaf(tier) {
|
|
|
96
93
|
// subscribers was deliberately terminalized to owe a final — it
|
|
97
94
|
// self-completes server-side. This preflight reads the node through the
|
|
98
95
|
// API (NodeDetailDTO), never canvas.db — no state-store reach.
|
|
99
|
-
if (
|
|
96
|
+
if (kind === 'final' && input['force'] !== true) {
|
|
100
97
|
let detail = null;
|
|
101
98
|
try {
|
|
102
99
|
detail = await client.getNode(nodeId);
|
|
@@ -118,8 +115,13 @@ function makeTierLeaf(tier) {
|
|
|
118
115
|
}
|
|
119
116
|
}
|
|
120
117
|
try {
|
|
121
|
-
const
|
|
122
|
-
|
|
118
|
+
const deliveryTier = input['tier'];
|
|
119
|
+
const result = await client.pushReport(nodeId, {
|
|
120
|
+
tier: kind,
|
|
121
|
+
body,
|
|
122
|
+
...(deliveryTier === undefined ? {} : { delivery_tier: deliveryTier }),
|
|
123
|
+
});
|
|
124
|
+
if (kind === 'final')
|
|
123
125
|
markStopSignal(nodeId, 'pushedFinal');
|
|
124
126
|
return {
|
|
125
127
|
report_path: result.report_path,
|
|
@@ -136,30 +138,27 @@ function makeTierLeaf(tier) {
|
|
|
136
138
|
},
|
|
137
139
|
render: (r) => {
|
|
138
140
|
const n = Array.isArray(r['notified']) ? r['notified'].length : 0;
|
|
139
|
-
const line =
|
|
141
|
+
const line = kind === 'final'
|
|
140
142
|
? 'Result recorded — node finished; its engine shuts down on next stop. Nothing more to do here: STOP your turn immediately. Reply with just "Done." and nothing else.'
|
|
141
|
-
:
|
|
142
|
-
? `Urgent report fanned to ${n} subscriber(s) — they are force-woken.`
|
|
143
|
-
: `Progress report fanned to ${n} subscriber(s).`;
|
|
143
|
+
: `Progress report fanned to ${n} subscriber(s).`;
|
|
144
144
|
const dropNote = r['worktree_auto_dropped'] === true ? `\n\nAuto-dropped a clean, zero-commit managed worktree at ${String(r['worktree_auto_dropped_path'])} — nothing to land.` : '';
|
|
145
145
|
return `${line}\n\n- report: ${r['report_path']}${dropNote}`;
|
|
146
146
|
},
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
|
-
// Registration
|
|
150
149
|
export function registerPush() {
|
|
151
150
|
return defineBranch({
|
|
152
151
|
name: 'push',
|
|
153
152
|
rootEntry: {
|
|
154
153
|
concept: 'the one verb up the graph — a node reports to whoever subscribes to it',
|
|
155
|
-
desc: 'push
|
|
154
|
+
desc: 'push an update or final report to your subscribers',
|
|
156
155
|
useWhen: 'sharing progress, raising an alert, or finishing your work',
|
|
157
156
|
},
|
|
158
157
|
help: {
|
|
159
158
|
name: 'push',
|
|
160
159
|
summary: 'push a report to your subscribers',
|
|
161
|
-
model: "A push writes a markdown report to the node's reports/ history and fans a lightweight pointer to every subscriber's inbox. Delivery inlines reports under 1,000 characters with their absolute path; larger reports remain pointers to dereference. Nothing is pushed automatically — the feed contains only what a node pushes explicitly, so push whenever you want a manager to see something. Pipe markdown via a single-quoted heredoc
|
|
160
|
+
model: "A push writes a markdown report to the node's reports/ history and fans a lightweight pointer to every subscriber's inbox. Delivery inlines reports under 1,000 characters with their absolute path; larger reports remain pointers to dereference. Nothing is pushed automatically — the feed contains only what a node pushes explicitly, so push whenever you want a manager to see something. Pipe markdown via a single-quoted heredoc so it reaches the report literally. `push final` is how ANY node finishes: write the canonical result, mark done, close the window.",
|
|
162
161
|
},
|
|
163
|
-
children: [
|
|
162
|
+
children: [makePushLeaf('update'), makePushLeaf('final')],
|
|
164
163
|
});
|
|
165
164
|
}
|