@crouton-kit/crouter 0.3.82 → 0.3.83
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/builtin-pi-packages/pi-mode-switch/README.md +3 -3
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -3
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +16 -44
- package/dist/clients/attach/attach-cmd.d.ts +1 -2
- package/dist/clients/attach/attach-cmd.js +685 -685
- package/dist/clients/attach/attach-def.d.ts +2 -0
- package/dist/clients/attach/attach-def.js +93 -0
- package/dist/clients/attach/canvas-panels.d.ts +10 -3
- package/dist/clients/attach/canvas-panels.js +42 -16
- package/dist/clients/attach/chat-view.d.ts +20 -1
- package/dist/clients/attach/chat-view.js +85 -2
- package/dist/commands/__tests__/human.test.js +7 -22
- package/dist/commands/attention.js +31 -30
- package/dist/commands/human/prompts.js +51 -38
- package/dist/commands/human/queue.d.ts +1 -28
- package/dist/commands/human/queue.js +156 -520
- package/dist/commands/human/shared.d.ts +28 -68
- package/dist/commands/human/shared.js +37 -120
- package/dist/commands/human.js +13 -17
- package/dist/commands/node.js +4 -2
- package/dist/commands/surface.js +1 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +98 -0
- package/dist/commands/sys/setup-core.d.ts +22 -0
- package/dist/commands/sys/setup-core.js +62 -0
- package/dist/commands/sys/setup.js +8 -1
- package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
- package/dist/core/__tests__/human-deliver.test.js +332 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
- package/dist/core/__tests__/pid-identity-match.test.js +83 -0
- package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/attention.test.js +126 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
- package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
- package/dist/core/canvas/attention.d.ts +16 -14
- package/dist/core/canvas/attention.js +34 -29
- package/dist/core/canvas/boot.d.ts +1 -0
- package/dist/core/canvas/boot.js +1 -1
- package/dist/core/canvas/browse/app.js +3 -3
- package/dist/core/canvas/pid.d.ts +43 -17
- package/dist/core/canvas/pid.js +104 -35
- package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
- package/dist/core/canvas/remote-canvas-source.js +4 -2
- package/dist/core/canvas/render.d.ts +5 -5
- package/dist/core/canvas/render.js +14 -14
- package/dist/core/canvas/source.d.ts +2 -2
- package/dist/core/canvas/source.js +3 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +3 -2
- package/dist/core/preview-registry.js +22 -29
- package/dist/core/profiles/select.js +4 -4
- package/dist/core/runtime/pi-vendored.js +15 -1
- package/dist/core/runtime/placement.d.ts +1 -10
- package/dist/core/runtime/placement.js +43 -26
- package/dist/core/runtime/session-list-cache.d.ts +22 -2
- package/dist/core/runtime/session-list-cache.js +92 -24
- package/dist/core/runtime/spawn.js +9 -0
- package/dist/core/spawn.d.ts +0 -59
- package/dist/core/spawn.js +5 -163
- package/dist/daemon/crtrd.js +7 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +2 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
- package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
- package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
- package/dist/core/__tests__/human-surface-target.test.js +0 -98
- package/dist/web-client/assets/index--md2ylfi.js +0 -78
- /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
- /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/pid-identity-match.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Regression cover for the uncapped-revive-loop bug on Blaxel unikraft guests
|
|
4
|
+
// (crouter-cloud "frozen on reconnecting", 2026-07-11): process identity is
|
|
5
|
+
// `lstart#ticks`, but `lstart` is derived from the wall-clock boot epoch
|
|
6
|
+
// (`/proc/stat` btime + starttime jiffies) and btime is UNSTABLE in the guest
|
|
7
|
+
// (clock correction / suspend-resume). A SINGLE live broker was observed with
|
|
8
|
+
// identical pid+ticks but `lstart` drifting +81s between two probes. The old
|
|
9
|
+
// `identitiesMatch` compared the `lstart` base FIRST and returned false on any
|
|
10
|
+
// base mismatch — so it read that still-live broker as reused/dead every
|
|
11
|
+
// cycle, wedging the daemon into an uncapped revive loop that rebound the
|
|
12
|
+
// node's view.sock and dropped the browser chat WS forever.
|
|
13
|
+
//
|
|
14
|
+
// The fix: when BOTH identities carry `#ticks`, ticks decide it ALONE (they
|
|
15
|
+
// are per-boot unique, strictly finer than lstart, and — unlike lstart — do
|
|
16
|
+
// not move when the wall clock does). The base is consulted only when a side
|
|
17
|
+
// lacks ticks (macOS / no procfs).
|
|
18
|
+
//
|
|
19
|
+
// Follow-up (cross-boot false match): ticks-alone is only safe WITHIN one
|
|
20
|
+
// boot — jiffies-since-boot reset and can repeat across boots. So the identity
|
|
21
|
+
// base is now BOOT-SCOPED on Linux-with-procfs: it is the kernel `boot_id`
|
|
22
|
+
// UUID (`<bootId>#<ticks>`), and a new-format match requires base AND ticks.
|
|
23
|
+
// A legacy `lstart#ticks` row (recorded before this) still migrates cleanly:
|
|
24
|
+
// when a base is NOT a boot_id UUID on either side, ticks decide alone.
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { identitiesMatch } from '../canvas/pid.js';
|
|
28
|
+
test('equal ticks with DIFFERENT lstart bases compare EQUAL (the btime-drift bug)', () => {
|
|
29
|
+
// Same live process, same jiffies-since-boot; only the wall-clock-anchored
|
|
30
|
+
// lstart drifted because btime shifted. Must NOT read as reuse.
|
|
31
|
+
const a = 'Sat Jul 11 17:50:24 2026#53459';
|
|
32
|
+
const b = 'Sat Jul 11 17:51:45 2026#53459';
|
|
33
|
+
assert.equal(identitiesMatch(a, b), true, 'equal ticks must win over a drifting lstart base');
|
|
34
|
+
});
|
|
35
|
+
test('different ticks compare UNEQUAL even when lstart bases are identical (same-second reuse)', () => {
|
|
36
|
+
const a = 'Sat Jul 11 17:50:24 2026#53459';
|
|
37
|
+
const b = 'Sat Jul 11 17:50:24 2026#60536';
|
|
38
|
+
assert.equal(identitiesMatch(a, b), false, 'different ticks are proof of a reused pid');
|
|
39
|
+
});
|
|
40
|
+
test('identities WITHOUT ticks fall back to comparing the lstart base', () => {
|
|
41
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:50:24 2026'), true, 'no ticks, equal base → match');
|
|
42
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:51:45 2026'), false, 'no ticks, different base → reuse');
|
|
43
|
+
});
|
|
44
|
+
test('one side lacking ticks fails open to a base compare (transient /proc miss)', () => {
|
|
45
|
+
// A transient `/proc` read failure can record the SAME live process plain on
|
|
46
|
+
// one capture and with ticks on another. Bases agree → fail-open to match.
|
|
47
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:50:24 2026#53459'), true, 'coarse-vs-fine with matching base → match');
|
|
48
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:51:45 2026#53459'), false, 'coarse-vs-fine with different base → reuse');
|
|
49
|
+
});
|
|
50
|
+
// --- boot-scoped (new-format) identities ---
|
|
51
|
+
const BOOT_A = '4b0c1d2e-3f40-4152-8637-a1b2c3d4e5f6';
|
|
52
|
+
const BOOT_B = '9e8d7c6b-5a40-4938-9271-fedcba098765';
|
|
53
|
+
test('new-format: equal boot_id + equal ticks → match', () => {
|
|
54
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_A}#53459`), true, 'same boot, same process');
|
|
55
|
+
});
|
|
56
|
+
test('new-format: equal ticks + DIFFERENT boot_id → NO match (cross-boot collision)', () => {
|
|
57
|
+
// Ticks reset per boot and can repeat; a different boot_id with the same
|
|
58
|
+
// ticks is a DIFFERENT process on a DIFFERENT boot. This is the exact false
|
|
59
|
+
// match the boot-scoped base closes.
|
|
60
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_B}#53459`), false, 'equal ticks must NOT win across boots');
|
|
61
|
+
});
|
|
62
|
+
test('new-format: same boot_id + DIFFERENT ticks → NO match (in-boot reuse)', () => {
|
|
63
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_A}#60536`), false, 'reused pid within one boot');
|
|
64
|
+
});
|
|
65
|
+
test('migration: legacy lstart#ticks vs new bootId#ticks with equal ticks → match', () => {
|
|
66
|
+
// An in-place upgrade compares an already-recorded `lstart#ticks` row
|
|
67
|
+
// against a freshly-read `bootId#ticks`. A blanket base-mismatch verdict
|
|
68
|
+
// would false-kill the live broker; the legacy base (not a UUID) routes to
|
|
69
|
+
// ticks-alone so the live broker survives the upgrade.
|
|
70
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#53459', `${BOOT_A}#53459`), true, 'legacy → new migration');
|
|
71
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, 'Sat Jul 11 17:51:45 2026#53459'), true, 'order-independent');
|
|
72
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#60536', `${BOOT_A}#53459`), false, 'legacy migration still discriminates on ticks');
|
|
73
|
+
});
|
|
74
|
+
test('malformed/empty ticks suffix is treated as ABSENT (base compare applies)', () => {
|
|
75
|
+
// A garbage suffix must never be trusted as a fine discriminator; it falls
|
|
76
|
+
// back to the base compare.
|
|
77
|
+
assert.equal(identitiesMatch('A#', 'B#'), false, 'empty suffix → absent ticks → bases differ');
|
|
78
|
+
assert.equal(identitiesMatch('A#', 'A#'), true, 'empty suffix → absent ticks → bases agree');
|
|
79
|
+
assert.equal(identitiesMatch('A#bad', 'B#bad'), false, 'non-numeric suffix → absent ticks → bases differ');
|
|
80
|
+
assert.equal(identitiesMatch('A#bad', 'A#other'), true, 'non-numeric suffix → absent ticks → bases agree');
|
|
81
|
+
assert.equal(identitiesMatch(`${BOOT_A}#12#34`, `${BOOT_A}#12#34`), true, 'multiple-# suffix is non-numeric → absent ticks → boot_id bases agree');
|
|
82
|
+
assert.equal(identitiesMatch(`${BOOT_A}#12#34`, `${BOOT_B}#12#34`), false, 'multiple-# suffix → absent ticks → boot_id bases differ');
|
|
83
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// attention.test.ts — the C2 ticket-attribution contract for `attention.ts`.
|
|
2
|
+
//
|
|
3
|
+
// The humanloop cutover generalized crouter's per-node attention counter from
|
|
4
|
+
// "pending asks" to "all pending human tickets": a count now folds in decks
|
|
5
|
+
// (asks), notify decks, AND anchored reviews, all bucketed by the
|
|
6
|
+
// `source.nodeId` stamp humanloop puts on every crouter-originated submission.
|
|
7
|
+
// This exercises the nuanced part that regressions hide in:
|
|
8
|
+
// - a cwd shared by several nodes is scanned ONCE and its tickets split by
|
|
9
|
+
// stamp, never double-counted;
|
|
10
|
+
// - decks, notify decks, and reviews all count;
|
|
11
|
+
// - a bare-shell ticket (no nodeId stamp) belongs to no node and is excluded
|
|
12
|
+
// from the per-node map;
|
|
13
|
+
// - every requested id is present (0 when it has none).
|
|
14
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
|
|
20
|
+
import { createNode } from '../canvas.js';
|
|
21
|
+
import { closeDb } from '../db.js';
|
|
22
|
+
import { interactionsRoot } from '../../artifact.js';
|
|
23
|
+
import { countTickets, ticketCountsForNodes, pendingTicketsForView } from '../attention.js';
|
|
24
|
+
function meta(id, cwd, extra) {
|
|
25
|
+
return {
|
|
26
|
+
node_id: id,
|
|
27
|
+
name: id,
|
|
28
|
+
created: new Date().toISOString(),
|
|
29
|
+
cwd,
|
|
30
|
+
kind: 'general',
|
|
31
|
+
mode: 'base',
|
|
32
|
+
lifecycle: 'terminal',
|
|
33
|
+
status: 'active',
|
|
34
|
+
...extra,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Seed a deck ticket (notify is just a durable deck) under `cwd`'s
|
|
38
|
+
* interactions root, stamped with `nodeId` when given (omit for a bare-shell
|
|
39
|
+
* ticket with no canvas owner). */
|
|
40
|
+
function seedDeck(cwd, id, nodeId) {
|
|
41
|
+
const dir = join(interactionsRoot(cwd), id);
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
atomicWriteJson(deckPath(dir), {
|
|
44
|
+
source: nodeId === undefined ? {} : { nodeId },
|
|
45
|
+
interactions: [{ id: 'q', title: id, options: [] }],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Seed an anchored review ticket (review.json) under `cwd`, stamped `nodeId`. */
|
|
49
|
+
function seedReview(cwd, id, nodeId) {
|
|
50
|
+
const dir = join(interactionsRoot(cwd), id);
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
atomicWriteJson(join(dir, 'review.json'), {
|
|
53
|
+
schema: 'humanloop.review/v1',
|
|
54
|
+
file: join(cwd, 'src.md'),
|
|
55
|
+
output: join(dir, 'feedback.json'),
|
|
56
|
+
title: id,
|
|
57
|
+
source: { nodeId },
|
|
58
|
+
blockedSince: new Date().toISOString(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
let home;
|
|
62
|
+
let sharedCwd;
|
|
63
|
+
let soloCwd;
|
|
64
|
+
before(() => {
|
|
65
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-attention-'));
|
|
66
|
+
sharedCwd = mkdtempSync(join(tmpdir(), 'crtr-attention-shared-'));
|
|
67
|
+
soloCwd = mkdtempSync(join(tmpdir(), 'crtr-attention-solo-'));
|
|
68
|
+
process.env['CRTR_HOME'] = home;
|
|
69
|
+
});
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
closeDb();
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
rmSync(interactionsRoot(sharedCwd), { recursive: true, force: true });
|
|
74
|
+
rmSync(interactionsRoot(soloCwd), { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
after(() => {
|
|
77
|
+
closeDb();
|
|
78
|
+
for (const d of [home, sharedCwd, soloCwd])
|
|
79
|
+
rmSync(d, { recursive: true, force: true });
|
|
80
|
+
delete process.env['CRTR_HOME'];
|
|
81
|
+
});
|
|
82
|
+
test('ticketCountsForNodes buckets deck/review/notify by node across a shared cwd and drops bare-shell tickets', () => {
|
|
83
|
+
// Nodes a and b share one cwd; c has its own.
|
|
84
|
+
createNode(meta('a', sharedCwd));
|
|
85
|
+
createNode(meta('b', sharedCwd));
|
|
86
|
+
createNode(meta('c', soloCwd));
|
|
87
|
+
// Shared cwd: two tickets for a (an ask + a notify deck), one review for b,
|
|
88
|
+
// and one unattributed bare-shell deck that belongs to no node.
|
|
89
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
90
|
+
seedDeck(sharedCwd, 'a-notify', 'a');
|
|
91
|
+
seedReview(sharedCwd, 'b-review', 'b');
|
|
92
|
+
seedDeck(sharedCwd, 'bare', undefined);
|
|
93
|
+
// Solo cwd: one ask for c.
|
|
94
|
+
seedDeck(soloCwd, 'c-ask', 'c');
|
|
95
|
+
const counts = ticketCountsForNodes(['a', 'b', 'c']);
|
|
96
|
+
// a: ask + notify = 2; b: review = 1; c: ask = 1. The bare-shell ticket is
|
|
97
|
+
// excluded from every per-node bucket even though it lives in a's cwd.
|
|
98
|
+
assert.deepEqual(counts, { a: 2, b: 1, c: 1 });
|
|
99
|
+
});
|
|
100
|
+
test('countTickets counts only the requesting node in a shared cwd', () => {
|
|
101
|
+
createNode(meta('a', sharedCwd));
|
|
102
|
+
createNode(meta('b', sharedCwd));
|
|
103
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
104
|
+
seedReview(sharedCwd, 'b-review', 'b');
|
|
105
|
+
seedDeck(sharedCwd, 'bare', undefined);
|
|
106
|
+
assert.equal(countTickets('a'), 1);
|
|
107
|
+
assert.equal(countTickets('b'), 1);
|
|
108
|
+
});
|
|
109
|
+
test('every requested id is present with 0 when it has no tickets', () => {
|
|
110
|
+
createNode(meta('a', sharedCwd));
|
|
111
|
+
createNode(meta('c', soloCwd));
|
|
112
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
113
|
+
assert.deepEqual(ticketCountsForNodes(['a', 'c']), { a: 1, c: 0 });
|
|
114
|
+
});
|
|
115
|
+
test('pendingTicketsForView de-dups a shared cwd to one entry summing all its tickets', () => {
|
|
116
|
+
// a is the view root; b shares a's cwd.
|
|
117
|
+
createNode(meta('a', sharedCwd));
|
|
118
|
+
createNode(meta('b', sharedCwd));
|
|
119
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
120
|
+
seedReview(sharedCwd, 'b-review', 'b');
|
|
121
|
+
const items = pendingTicketsForView('a');
|
|
122
|
+
// One cwd → one entry; its count is every pending ticket in that cwd (2),
|
|
123
|
+
// not a per-node split, so the shared dir is never counted twice.
|
|
124
|
+
assert.equal(items.length, 1);
|
|
125
|
+
assert.equal(items[0].count, 2);
|
|
126
|
+
});
|
|
@@ -207,8 +207,8 @@ test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade grace
|
|
|
207
207
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
208
208
|
assert.equal(await source.getNode('http500'), null);
|
|
209
209
|
assert.deepEqual(await source.listNodes(), []);
|
|
210
|
-
assert.deepEqual(await source.
|
|
211
|
-
assert.deepEqual(await source.
|
|
210
|
+
assert.deepEqual(await source.ticketCountsForView('badjson'), {});
|
|
211
|
+
assert.deepEqual(await source.ticketCountsForView('nonzero-exit'), {});
|
|
212
212
|
});
|
|
213
213
|
test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of returning a shape a consumer would crash on', async (t) => {
|
|
214
214
|
const server = await startMockCrtrServer((args) => {
|
|
@@ -247,8 +247,8 @@ test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of r
|
|
|
247
247
|
// `listNodes()` with a non-array `nodes` field must degrade to [], never
|
|
248
248
|
// throw or return the malformed value.
|
|
249
249
|
assert.deepEqual(await source.listNodes(), []);
|
|
250
|
-
// `
|
|
251
|
-
assert.deepEqual(await source.
|
|
250
|
+
// `ticketCountsForView()` with a non-object `counts` field must degrade to {}.
|
|
251
|
+
assert.deepEqual(await source.ticketCountsForView('root'), {});
|
|
252
252
|
});
|
|
253
253
|
function fullNodeRow(id, over = {}) {
|
|
254
254
|
return {
|
|
@@ -110,7 +110,7 @@ after(() => {
|
|
|
110
110
|
* poisoned session-file path so the mocked `node inspect show` response can
|
|
111
111
|
* point `pi_session_file` at it (the vector a regression would follow). */
|
|
112
112
|
function seedPoisonedLocalState() {
|
|
113
|
-
// A local node row + a pending
|
|
113
|
+
// A local node row + a pending ticket under its cwd — poisons `ticketCountsForNodes`.
|
|
114
114
|
createNode(meta('a', { cwd: localCwd }));
|
|
115
115
|
const askDir = join(interactionsRoot(localCwd), 'poison-ask');
|
|
116
116
|
mkdirSync(askDir, { recursive: true });
|
|
@@ -1,34 +1,36 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface TicketEntry {
|
|
2
2
|
node_id: string;
|
|
3
3
|
name: string;
|
|
4
4
|
cwd: string;
|
|
5
5
|
count: number;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
* Count pending
|
|
8
|
+
* Count pending tickets for the cwd of a single node.
|
|
9
9
|
* Returns 0 when the node is unknown or humanloop is unavailable.
|
|
10
10
|
*/
|
|
11
|
-
export declare function
|
|
11
|
+
export declare function countTickets(nodeId: string): number;
|
|
12
12
|
/**
|
|
13
|
-
* Pending
|
|
13
|
+
* Pending tickets for all nodes reachable in the subscription sub-DAG from
|
|
14
14
|
* `rootId` (including root itself). De-duped by cwd: when multiple nodes
|
|
15
15
|
* share a cwd the first one encountered claims the entry.
|
|
16
16
|
*
|
|
17
17
|
* Returns only entries with count > 0.
|
|
18
18
|
*/
|
|
19
|
-
export declare function
|
|
19
|
+
export declare function pendingTicketsForView(rootId: string): TicketEntry[];
|
|
20
20
|
/**
|
|
21
|
-
* Per-node pending
|
|
22
|
-
* counterpart to `
|
|
21
|
+
* Per-node pending ticket counts for an explicit set of node ids — the batched
|
|
22
|
+
* counterpart to `countTickets`, used by the nav chrome to label every visible
|
|
23
23
|
* node in ONE pass. Groups ids by their cwd so each distinct interactions dir
|
|
24
|
-
* is scanned exactly once,
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* is scanned exactly once, de-duplicates by canonical ticket dir (scanInbox
|
|
25
|
+
* already returns one summary per dir), then buckets the tickets by the
|
|
26
|
+
* `source.nodeId` stamp (same attribution as `countForCwd(cwd, nodeId)`).
|
|
27
|
+
* Tickets with no node stamp (bare-shell / unattributed) belong to no node and
|
|
28
|
+
* are excluded from the map. Every requested id appears in the result (0 when
|
|
29
|
+
* it has none). Never throws.
|
|
28
30
|
*/
|
|
29
|
-
export declare function
|
|
31
|
+
export declare function ticketCountsForNodes(ids: string[]): Record<string, number>;
|
|
30
32
|
/**
|
|
31
|
-
* Pending
|
|
33
|
+
* Pending tickets across the entire canvas — every distinct cwd among all known
|
|
32
34
|
* nodes. Returns only entries with count > 0.
|
|
33
35
|
*/
|
|
34
|
-
export declare function
|
|
36
|
+
export declare function ticketsAcrossCanvas(): TicketEntry[];
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// attention.ts — pending human-
|
|
1
|
+
// attention.ts — pending human-ticket counters across the canvas.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// A "ticket" is any pending humanloop interaction: a deck (ask), a notify deck,
|
|
4
|
+
// or an anchored review. `scanInbox` returns the sealed deck/review summary
|
|
5
|
+
// union (notify is a durable deck), so every count here is "all pending human
|
|
6
|
+
// tickets", not just asks. Tickets are stored per-cwd, not per-node
|
|
7
|
+
// (interactionsRoot is keyed by the cwd the agent ran in), so a cwd shared by
|
|
8
|
+
// multiple nodes is de-duped before summing to avoid counting the same pending
|
|
9
|
+
// ticket N times.
|
|
7
10
|
//
|
|
8
11
|
// All public functions are best-effort: scanInbox failures return 0 / empty.
|
|
9
|
-
// Callers are display code (dashboard, attention queue) that must
|
|
10
|
-
// on a cold canvas or missing humanloop state.
|
|
12
|
+
// Callers are display code (dashboard, attention queue, attach chrome) that must
|
|
13
|
+
// not blow up on a cold canvas or missing humanloop state.
|
|
11
14
|
import { scanInbox } from '@crouton-kit/humanloop';
|
|
12
15
|
import { interactionsRoot } from '../artifact.js';
|
|
13
16
|
import { getNode, listNodes, view } from './canvas.js';
|
|
@@ -15,21 +18,21 @@ import { getNode, listNodes, view } from './canvas.js';
|
|
|
15
18
|
// Helpers
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
17
20
|
/**
|
|
18
|
-
* Count pending
|
|
21
|
+
* Count pending tickets for a single cwd root. Never throws.
|
|
19
22
|
*
|
|
20
|
-
* When `nodeId` is given, count only
|
|
21
|
-
* stamps `
|
|
22
|
-
* sharing a cwd no longer pollute each
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* When `nodeId` is given, count only tickets raised by THAT node — humanloop
|
|
24
|
+
* stamps `source.nodeId` (deck AND review submissions alike) with the
|
|
25
|
+
* originating CRTR_NODE_ID, so two nodes sharing a cwd no longer pollute each
|
|
26
|
+
* other's count. Tickets with no stamp (raised from a bare shell, or outside a
|
|
27
|
+
* canvas node) are not attributable to any node and are excluded from the
|
|
28
|
+
* per-node count.
|
|
26
29
|
*/
|
|
27
30
|
function countForCwd(cwd, nodeId) {
|
|
28
31
|
try {
|
|
29
32
|
const items = scanInbox([interactionsRoot(cwd)]);
|
|
30
33
|
if (nodeId === undefined)
|
|
31
34
|
return items.length;
|
|
32
|
-
return items.filter((i) => i.source
|
|
35
|
+
return items.filter((i) => i.source.nodeId === nodeId).length;
|
|
33
36
|
}
|
|
34
37
|
catch {
|
|
35
38
|
// humanloop not installed, or interactions dir doesn't exist — both fine.
|
|
@@ -40,23 +43,23 @@ function countForCwd(cwd, nodeId) {
|
|
|
40
43
|
// Public API
|
|
41
44
|
// ---------------------------------------------------------------------------
|
|
42
45
|
/**
|
|
43
|
-
* Count pending
|
|
46
|
+
* Count pending tickets for the cwd of a single node.
|
|
44
47
|
* Returns 0 when the node is unknown or humanloop is unavailable.
|
|
45
48
|
*/
|
|
46
|
-
export function
|
|
49
|
+
export function countTickets(nodeId) {
|
|
47
50
|
const node = getNode(nodeId);
|
|
48
51
|
if (node === null)
|
|
49
52
|
return 0;
|
|
50
53
|
return countForCwd(node.cwd, nodeId);
|
|
51
54
|
}
|
|
52
55
|
/**
|
|
53
|
-
* Pending
|
|
56
|
+
* Pending tickets for all nodes reachable in the subscription sub-DAG from
|
|
54
57
|
* `rootId` (including root itself). De-duped by cwd: when multiple nodes
|
|
55
58
|
* share a cwd the first one encountered claims the entry.
|
|
56
59
|
*
|
|
57
60
|
* Returns only entries with count > 0.
|
|
58
61
|
*/
|
|
59
|
-
export function
|
|
62
|
+
export function pendingTicketsForView(rootId) {
|
|
60
63
|
// view() returns children only (excludes root), so prepend root.
|
|
61
64
|
const ids = [rootId, ...view(rootId)];
|
|
62
65
|
const seen = new Map(); // cwd → entry
|
|
@@ -78,15 +81,17 @@ export function pendingAsksForView(rootId) {
|
|
|
78
81
|
return Array.from(seen.values()).filter((e) => e.count > 0);
|
|
79
82
|
}
|
|
80
83
|
/**
|
|
81
|
-
* Per-node pending
|
|
82
|
-
* counterpart to `
|
|
84
|
+
* Per-node pending ticket counts for an explicit set of node ids — the batched
|
|
85
|
+
* counterpart to `countTickets`, used by the nav chrome to label every visible
|
|
83
86
|
* node in ONE pass. Groups ids by their cwd so each distinct interactions dir
|
|
84
|
-
* is scanned exactly once,
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
87
|
+
* is scanned exactly once, de-duplicates by canonical ticket dir (scanInbox
|
|
88
|
+
* already returns one summary per dir), then buckets the tickets by the
|
|
89
|
+
* `source.nodeId` stamp (same attribution as `countForCwd(cwd, nodeId)`).
|
|
90
|
+
* Tickets with no node stamp (bare-shell / unattributed) belong to no node and
|
|
91
|
+
* are excluded from the map. Every requested id appears in the result (0 when
|
|
92
|
+
* it has none). Never throws.
|
|
88
93
|
*/
|
|
89
|
-
export function
|
|
94
|
+
export function ticketCountsForNodes(ids) {
|
|
90
95
|
const counts = {};
|
|
91
96
|
for (const id of ids)
|
|
92
97
|
counts[id] = 0;
|
|
@@ -110,7 +115,7 @@ export function asksForNodes(ids) {
|
|
|
110
115
|
}
|
|
111
116
|
const want = new Set(cwdIds);
|
|
112
117
|
for (const i of items) {
|
|
113
|
-
const nid = i.source
|
|
118
|
+
const nid = i.source.nodeId;
|
|
114
119
|
if (nid !== undefined && want.has(nid))
|
|
115
120
|
counts[nid] = (counts[nid] ?? 0) + 1;
|
|
116
121
|
}
|
|
@@ -118,10 +123,10 @@ export function asksForNodes(ids) {
|
|
|
118
123
|
return counts;
|
|
119
124
|
}
|
|
120
125
|
/**
|
|
121
|
-
* Pending
|
|
126
|
+
* Pending tickets across the entire canvas — every distinct cwd among all known
|
|
122
127
|
* nodes. Returns only entries with count > 0.
|
|
123
128
|
*/
|
|
124
|
-
export function
|
|
129
|
+
export function ticketsAcrossCanvas() {
|
|
125
130
|
const rows = listNodes();
|
|
126
131
|
const seen = new Map(); // cwd → entry
|
|
127
132
|
for (const row of rows) {
|
|
@@ -7,6 +7,7 @@ export interface BootIdentity {
|
|
|
7
7
|
* discriminator only, used when `bootId` is null on either side. */
|
|
8
8
|
bootEpochMs: number;
|
|
9
9
|
}
|
|
10
|
+
export declare function readKernelBootId(): string | null;
|
|
10
11
|
/** Identity of the CURRENT OS boot. `now` is injectable for tests. */
|
|
11
12
|
export declare function currentBootIdentity(now?: number): BootIdentity;
|
|
12
13
|
/** Pure decision core: does `current` belong to a DIFFERENT boot than the
|
package/dist/core/canvas/boot.js
CHANGED
|
@@ -47,7 +47,7 @@ import { openDb } from './db.js';
|
|
|
47
47
|
* dangerous direction. The production target (Linux microVM guests) never
|
|
48
48
|
* reaches this branch; there `boot_id` is exact. */
|
|
49
49
|
const BOOT_EPOCH_TOLERANCE_MS = 60_000;
|
|
50
|
-
function readKernelBootId() {
|
|
50
|
+
export function readKernelBootId() {
|
|
51
51
|
try {
|
|
52
52
|
const id = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
53
53
|
return id.length > 0 ? id : null;
|
|
@@ -84,8 +84,8 @@ export async function runBrowse(opts = {}) {
|
|
|
84
84
|
let childIds;
|
|
85
85
|
let tree;
|
|
86
86
|
let totalNodes;
|
|
87
|
-
// Remote-only: pending-
|
|
88
|
-
// `
|
|
87
|
+
// Remote-only: pending human-ticket counts per node, merged from one
|
|
88
|
+
// `ticketCountsForView` call per forest root (RemoteCanvasSource.ticketCountsForView
|
|
89
89
|
// already shells a single `canvas attention map --view <root>` per root, same
|
|
90
90
|
// cost class as the per-node `subscriptionsOf` fan-out this snapshot already
|
|
91
91
|
// does below) — fetched once per snapshot rebuild, not per flush/keystroke, so
|
|
@@ -105,7 +105,7 @@ export async function runBrowse(opts = {}) {
|
|
|
105
105
|
tree = buildTree(rows, rootIds, (id) => childIds.get(id) ?? []);
|
|
106
106
|
totalNodes = tree.nodes.size;
|
|
107
107
|
if (isRemote) {
|
|
108
|
-
const maps = await Promise.all(rootIds.map((root) => source.
|
|
108
|
+
const maps = await Promise.all(rootIds.map((root) => source.ticketCountsForView(root).catch(() => ({}))));
|
|
109
109
|
remoteAskCounts = Object.assign({}, ...maps);
|
|
110
110
|
}
|
|
111
111
|
};
|
|
@@ -20,24 +20,50 @@ export declare function isPidAlive(pid: number | null | undefined): boolean;
|
|
|
20
20
|
* drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness
|
|
21
21
|
* fix).
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* one capture and `lstart#ticks` on another (a transient `/proc` read
|
|
27
|
-
* failure, or capturing on a platform/container without procfs). A naive
|
|
28
|
-
* `===` then reads that as a reuse (different string) and reports `dead` for
|
|
29
|
-
* a broker that never actually died — the false-DEAD direction, which can
|
|
30
|
-
* make `reviveNode`'s double-launch guard wrongly relaunch a SECOND broker
|
|
31
|
-
* onto a still-live session.
|
|
23
|
+
* Two hazards this closes, both in the false-DEAD direction (the dangerous
|
|
24
|
+
* one — a false DEAD lets `reviveNode`'s double-launch guard relaunch a
|
|
25
|
+
* SECOND broker onto a still-live session):
|
|
32
26
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
27
|
+
* 1. `composeIdentity` appends the `#<ticks>` Linux discriminator only when
|
|
28
|
+
* `/proc/<pid>/stat` happens to be readable at capture time — so the SAME
|
|
29
|
+
* live process can be recorded plain `lstart` on one capture and
|
|
30
|
+
* `lstart#ticks` on another (a transient `/proc` read failure, or
|
|
31
|
+
* capturing on a platform/container without procfs). A naive `===` reads
|
|
32
|
+
* that as a reuse (different string) and reports `dead` for a broker that
|
|
33
|
+
* never died.
|
|
34
|
+
* 2. `lstart` is NOT stable for a live process on every host. It is derived
|
|
35
|
+
* from `/proc/stat` btime (wall-clock boot epoch) + starttime jiffies, so
|
|
36
|
+
* a clock correction / suspend-resume shifts btime and re-anchors the
|
|
37
|
+
* SAME live process's `lstart` by seconds between two captures. Observed
|
|
38
|
+
* on Blaxel unikraft guests: pid+ticks identical across probes, `lstart`
|
|
39
|
+
* drifting +81s — a base-first compare then reads the live home-node
|
|
40
|
+
* broker as reused/dead every cycle, driving an uncapped revive loop.
|
|
41
|
+
*
|
|
42
|
+
* A third hazard the base itself once carried: `ticks` (jiffies-since-boot)
|
|
43
|
+
* RESET on every boot and can repeat across boots, so ticks-alone is only
|
|
44
|
+
* safe WITHIN one boot. `composeIdentity` therefore boot-SCOPES the base on
|
|
45
|
+
* Linux-with-procfs — the base IS the kernel `boot_id` UUID — so a
|
|
46
|
+
* new-format identity is `<bootId>#<ticks>` and equality means same boot AND
|
|
47
|
+
* same process. This closes the cross-boot false match at the root, without
|
|
48
|
+
* plumbing boot reconciliation ahead of every caller.
|
|
49
|
+
*
|
|
50
|
+
* Rule: split both on `#`.
|
|
51
|
+
* - BOTH sides carry `#ticks` AND both bases are `boot_id` UUIDs
|
|
52
|
+
* (new-format vs new-format): match iff base AND ticks are equal. A
|
|
53
|
+
* different boot_id with equal ticks is NOT a match (the cross-boot
|
|
54
|
+
* collision).
|
|
55
|
+
* - BOTH sides carry `#ticks` but a base is NOT a boot_id UUID on EITHER
|
|
56
|
+
* side — a LEGACY `lstart#ticks` row recorded before this repo boot-scoped
|
|
57
|
+
* the base, or a Linux host that has procfs ticks but no readable boot_id:
|
|
58
|
+
* ticks DECIDE it alone (the prior landed semantics). This is a NARROW
|
|
59
|
+
* format-migration path so an in-place upgrade never false-kills a live
|
|
60
|
+
* broker whose row still holds `lstart#ticks`; it is NOT a general
|
|
61
|
+
* fallback — new-format vs new-format always demands base+ticks above.
|
|
62
|
+
* - EITHER side lacks a valid `#ticks` suffix (macOS / no procfs / transient
|
|
63
|
+
* `/proc` miss / malformed suffix): fall back to the coarse base —
|
|
64
|
+
* matching iff the bases agree (fail-open to same-process, matching this
|
|
65
|
+
* module's universal discipline: only a POSITIVE mismatch counts as reuse,
|
|
66
|
+
* never an absence of evidence). */
|
|
41
67
|
export declare function identitiesMatch(a: string, b: string): boolean;
|
|
42
68
|
/** Three-valued liveness verdict for a RECORDED node pid, identity-guarded
|
|
43
69
|
* against PID REUSE using the launch-time baseline (`pi_pid_identity`,
|