@north-light/crouter 0.3.308 → 0.3.310

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.
Files changed (49) hide show
  1. package/dist/clients/attach/viewer.js +394 -394
  2. package/dist/commands/__tests__/node-message.test.js +13 -0
  3. package/dist/commands/canvas-history/grep.js +1 -1
  4. package/dist/commands/canvas-history/read.js +1 -1
  5. package/dist/commands/canvas-history/search.js +1 -1
  6. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +20 -0
  7. package/dist/commands/memory/edit.js +8 -11
  8. package/dist/commands/node-inspect-artifacts.js +2 -2
  9. package/dist/commands/push.js +1 -1
  10. package/dist/core/__tests__/canvas-inbox-watcher.test.js +3 -3
  11. package/dist/core/__tests__/human-deliver.test.js +1 -1
  12. package/dist/core/__tests__/integration/broker-wedge-supervision.test.d.ts +1 -0
  13. package/dist/core/__tests__/integration/broker-wedge-supervision.test.js +102 -0
  14. package/dist/core/__tests__/integration/refresh-stall-recycle.test.js +68 -2
  15. package/dist/core/__tests__/integration/worktree-reap.test.js +31 -0
  16. package/dist/core/__tests__/seam/inbox-reference-guidance.test.d.ts +1 -0
  17. package/dist/core/__tests__/seam/inbox-reference-guidance.test.js +70 -0
  18. package/dist/core/canvas/__tests__/session-window.test.d.ts +1 -0
  19. package/dist/core/canvas/__tests__/session-window.test.js +70 -0
  20. package/dist/core/canvas/browse/app.js +52 -27
  21. package/dist/core/canvas/browse/render.js +11 -2
  22. package/dist/core/canvas/render-source.js +63 -25
  23. package/dist/core/command.js +15 -0
  24. package/dist/core/feed/inbox.js +6 -4
  25. package/dist/core/runtime/bearings-render.js +3 -3
  26. package/dist/core/runtime/broker/inbox.js +6 -4
  27. package/dist/core/runtime/fleet.js +1 -1
  28. package/dist/core/runtime/placement.d.ts +1 -1
  29. package/dist/core/runtime/placement.js +1 -1
  30. package/dist/core/runtime/tmux-driver.d.ts +7 -0
  31. package/dist/core/runtime/tmux-driver.js +20 -0
  32. package/dist/core/substrate/on-read.d.ts +6 -4
  33. package/dist/core/substrate/on-read.js +14 -6
  34. package/dist/core/substrate/surface-match.d.ts +12 -4
  35. package/dist/core/substrate/surface-match.js +19 -10
  36. package/dist/core/worktree-mutation-async.js +1 -2
  37. package/dist/core/worktree.d.ts +0 -1
  38. package/dist/core/worktree.js +0 -113
  39. package/dist/daemon/api/handlers/canvas.js +3 -3
  40. package/dist/daemon/reconcilers/broker-supervision.d.ts +14 -2
  41. package/dist/daemon/reconcilers/broker-supervision.js +109 -57
  42. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  43. package/dist/daemon/reconcilers/storage-maintenance.js +31 -8
  44. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +10 -10
  45. package/dist/pi-extensions/canvas-doc-substrate.js +11 -5
  46. package/dist/shared/generated-context.d.ts +2 -0
  47. package/dist/shared/generated-context.js +6 -0
  48. package/package.json +1 -1
  49. package/runtime.lock.json +5 -5
@@ -58,6 +58,19 @@ test('node message send explains a positional node id without a target flag', as
58
58
  assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.');
59
59
  return true;
60
60
  }));
61
+ await withFakeStdin(pipedStdin(''), () => assert.rejects(() => parseArgv(params, [nodeId, 'message body']), (error) => {
62
+ assert.equal(error.code, 'bad_invocation');
63
+ assert.equal(error.message, 'no target given — the positional argument is the message body, not a node id');
64
+ assert.equal(error.details?.field, 'to');
65
+ assert.equal(error.details?.next, 'Pass the node id with --to <node-id> and pipe the message body on stdin, or use --self.');
66
+ return true;
67
+ }));
68
+ await withFakeStdin(pipedStdin(''), () => assert.rejects(() => parseArgv(params, [nodeId, 'message body', '--to', 'target-node']), (error) => {
69
+ assert.equal(error.code, 'bad_invocation');
70
+ assert.equal(error.message, 'unexpected extra positional argument (value withheld: this leaf reads "body" from stdin, never argv)');
71
+ assert.equal(error.details?.next, 'Pipe it on stdin instead of passing it as a positional argument.');
72
+ return true;
73
+ }));
61
74
  });
62
75
  test('node message request gives the same no-target guidance', async () => {
63
76
  const request = messageLeaf('request');
@@ -52,7 +52,7 @@ export const grepLeaf = defineLeaf({
52
52
  hits: res.hits,
53
53
  next_cursor: res.next_cursor,
54
54
  total: res.total,
55
- follow_up: 'Read a hit with `canvas history read <ref>`; narrow with `canvas history grep -h`.',
55
+ follow_up: 'Read a hit with `crtr canvas history read <ref>`; narrow with `crtr canvas history grep -h`.',
56
56
  };
57
57
  }
58
58
  catch (err) {
@@ -32,7 +32,7 @@ export const readLeaf = defineLeaf({
32
32
  if (!ref.includes(':')) {
33
33
  throw usage(`ref must be <node-id>:<relpath>; received: ${ref}`, {
34
34
  received: ref,
35
- next: 'Get a valid ref from `canvas history search` or `node inspect artifacts <node-id>`.',
35
+ next: 'Get a valid ref from `crtr canvas history search` or `crtr node inspect artifacts <node-id>`.',
36
36
  });
37
37
  }
38
38
  try {
@@ -58,7 +58,7 @@ export const searchLeaf = defineLeaf({
58
58
  hits: res.hits,
59
59
  next_cursor: res.next_cursor,
60
60
  total: res.total,
61
- follow_up: 'Read a hit with `canvas history read <ref>`; narrow with `canvas history search -h`.',
61
+ follow_up: 'Read a hit with `crtr canvas history read <ref>`; narrow with `crtr canvas history search -h`.',
62
62
  };
63
63
  }
64
64
  catch (err) {
@@ -189,6 +189,26 @@ test('editing the repository-root guide preserves its namespace declaration', as
189
189
  });
190
190
  assert.match(readFileSync(join(storeDir(root), 'INDEX.md'), 'utf8'), /^namespace: acme$/m);
191
191
  });
192
+ test('memory edit preserves the supplied body bytes and accepts leading-newline removal', async () => {
193
+ const target = join(storeDir(root), 'foo.md');
194
+ const withLeadingNewline = '\nBODY WITH CALLER BLANK';
195
+ await editLeaf.run({
196
+ name: 'acme/foo',
197
+ dir: root,
198
+ rationale: 'regression: a leading blank line must remain exactly as supplied',
199
+ body: withLeadingNewline,
200
+ });
201
+ assert.ok(readFileSync(target, 'utf8').endsWith(`\n---\n${withLeadingNewline}`));
202
+ const withoutLeadingNewline = 'BODY WITH CALLER BLANK';
203
+ const result = (await editLeaf.run({
204
+ name: 'acme/foo',
205
+ dir: root,
206
+ rationale: 'regression: removing a supplied leading blank line must be an edit',
207
+ body: withoutLeadingNewline,
208
+ }));
209
+ assert.deepEqual(result.changed, ['body']);
210
+ assert.ok(readFileSync(target, 'utf8').endsWith(`\n---\n${withoutLeadingNewline}`));
211
+ });
192
212
  test('move and delete recover same-store collisions by stable physical path, while edit and origin refuse', async () => {
193
213
  const collisionLeaf = join(storeDir(root), 'collision.md');
194
214
  const collisionIndex = join(storeDir(root), 'collision', 'INDEX.md');
@@ -184,16 +184,13 @@ export const editLeaf = defineLeaf({
184
184
  surfacesVal.some((e) => e !== null && typeof e === 'object' && e['on'] === 'workspace-open')) {
185
185
  throw usage('a workspace-open surfaces entry is a project-store mount route; user, profile, and node memory cannot carry one — drop the entry');
186
186
  }
187
- // LITERAL SAVE: the piped bytes reach disk untransformed, one structural
188
- // blank line after the fence. Omitting stdin re-emits the existing body
189
- // sliced from the raw file — never the parser's body, which drops leading
190
- // blank lines and indentation.
187
+ // LITERAL SAVE: a supplied body reaches disk unchanged; omitting stdin
188
+ // preserves the raw body after the fence, including leading whitespace.
191
189
  const existingBody = literalBodySegment(before);
192
- const bodySegment = stdinBody === '' ? existingBody : `\n${stdinBody}`;
193
- // The no-op check compares against the document as it would serialize
194
- // WITHOUT a fresh timestamp — `last-updated` changes on every edit, so
195
- // stamping first would make every no-op look like a change.
196
- const candidate = serializeMemoryDocLiteral(frontmatter, bodySegment);
190
+ const body = stdinBody === '' ? existingBody : stdinBody;
191
+ // The no-op check compares the exact bytes that would be written, without
192
+ // a fresh timestamp — `last-updated` changes on every edit.
193
+ const candidate = serializeMemoryDocLiteral(frontmatter, body);
197
194
  if (candidate === before) {
198
195
  throw usage(`edit changed nothing \u2014 ${doc.name} already says exactly this, so there is nothing to record`, {
199
196
  memory: doc.name,
@@ -201,7 +198,7 @@ export const editLeaf = defineLeaf({
201
198
  });
202
199
  }
203
200
  frontmatter['last-updated'] = new Date().toISOString();
204
- const after = serializeMemoryDocLiteral(frontmatter, bodySegment);
201
+ const after = serializeMemoryDocLiteral(frontmatter, body);
205
202
  writeText(doc.path, after);
206
203
  const logPath = historyLogPathFor(doc.root, doc.path);
207
204
  appendHistoryRecord(logPath, buildHistoryRecord({ op: 'edit', rationale, ...(verbatim ? { verbatim: true } : {}), before, after }));
@@ -209,7 +206,7 @@ export const editLeaf = defineLeaf({
209
206
  .filter((key) => key !== 'last-updated')
210
207
  .filter((key) => JSON.stringify(previous[key]) !== JSON.stringify(frontmatter[key]))
211
208
  .sort((a, b) => a.localeCompare(b));
212
- const changed = [...(bodySegment !== existingBody ? ['body'] : []), ...changedFields];
209
+ const changed = [...(body !== existingBody ? ['body'] : []), ...changedFields];
213
210
  return {
214
211
  name: doc.name,
215
212
  kind: String(frontmatter['kind'] ?? ''),
@@ -69,7 +69,7 @@ export const nodeArtifactsLeaf = defineLeaf({
69
69
  arts = list.artifacts;
70
70
  }
71
71
  catch (err) {
72
- rethrowAsCliError(err, 'List nodes with `crtr node inspect list`, or find one with `canvas history search`.');
72
+ rethrowAsCliError(err, 'List nodes with `crtr node inspect list`, or find one with `crtr canvas history search`.');
73
73
  }
74
74
  const ms = (ts) => Date.parse(ts) || 0;
75
75
  arts.sort((a, b) => (sort === 'oldest' ? ms(a.ts) - ms(b.ts) : ms(b.ts) - ms(a.ts)) || a.ref.localeCompare(b.ref));
@@ -85,7 +85,7 @@ export const nodeArtifactsLeaf = defineLeaf({
85
85
  return {
86
86
  node: `${name} (${nodeId})`,
87
87
  artifacts,
88
- follow_up: 'Read one with `canvas history read <ref>`; reopen the node with `node lifecycle revive ' + nodeId + '`.',
88
+ follow_up: 'Read one with `crtr canvas history read <ref>`; reopen the node with `crtr node lifecycle revive ' + nodeId + '`.',
89
89
  };
90
90
  },
91
91
  render: (r) => {
@@ -258,7 +258,7 @@ export function registerPush() {
258
258
  help: {
259
259
  name: 'push',
260
260
  summary: 'push a report to your subscribers',
261
- 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.",
261
+ 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; each report ref is read with `crtr canvas history read <ref>`. 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.",
262
262
  },
263
263
  children: [makePushLeaf('update'), makePushLeaf('final'), makeResultLeaf()],
264
264
  });
@@ -114,7 +114,7 @@ test('coalesce renders one inbox card whose entries inline short report bodies a
114
114
  assert.equal(card.senders[0].id, 'child');
115
115
  // The label is NOT reprinted above the body it duplicates.
116
116
  assert.deepEqual(card.senders[0].entries, [
117
- { kind: 'update', disposition: 'report', body: 'short report body', ref: 'child:reports/short-update.md' },
117
+ { kind: 'update', disposition: 'report', body: 'short report body\n\nRead this report with `crtr canvas history read child:reports/short-update.md`.', ref: 'child:reports/short-update.md' },
118
118
  ]);
119
119
  assert.equal(reportPath.endsWith('short-update.md'), true);
120
120
  // A report too large to inline (here: absent) keeps its label and its ref.
@@ -125,7 +125,7 @@ test('coalesce renders one inbox card whose entries inline short report bodies a
125
125
  ])),
126
126
  });
127
127
  assert.deepEqual(canonical.senders[0].entries, [
128
- { kind: 'final', disposition: 'report', body: 'done', ref: 'child:reports/final.md' },
128
+ { kind: 'final', disposition: 'report', body: 'done\n\nRead this report with `crtr canvas history read child:reports/final.md`.', ref: 'child:reports/final.md' },
129
129
  ]);
130
130
  assert.equal(canonical.senders[0].finished, true);
131
131
  const spilled = parseCard({
@@ -135,7 +135,7 @@ test('coalesce renders one inbox card whose entries inline short report bodies a
135
135
  ])),
136
136
  });
137
137
  assert.deepEqual(spilled.senders[0].entries, [
138
- { kind: 'message', disposition: 'report', body: 'long body', ref: '/tmp/message.md' },
138
+ { kind: 'message', disposition: 'report', body: 'long body\n\nPreview only. Read the full body from file `/tmp/message.md`.', ref: '/tmp/message.md' },
139
139
  ]);
140
140
  closeDb();
141
141
  });
@@ -164,7 +164,7 @@ describe('terminal ticket delivery', () => {
164
164
  const card = parseCard({ role: 'user', content: deliveries[0].text });
165
165
  assert.ok(card);
166
166
  const answer = card.senders.flatMap((sender) => sender.entries).find((entry) => entry.disposition === 'human-answer');
167
- assert.deepEqual(answer, { kind: 'final', disposition: 'human-answer', body: 'yes, ship', ref: final.ref });
167
+ assert.deepEqual(answer, { kind: 'final', disposition: 'human-answer', body: `yes, ship\n\nRead this report with \`crtr canvas history read ${final.ref}\`.`, ref: final.ref });
168
168
  });
169
169
  test('a replay against a terminal bridge is a no-op', async () => {
170
170
  const { parent, bridge } = bridgeUnder();
@@ -0,0 +1,102 @@
1
+ // Run with: npm run test:integration
2
+ //
3
+ // Regression coverage for #938/#1081. The busy marker survives an interrupted
4
+ // turn for recovery, but supervision must only treat a marker written after the
5
+ // current broker launch as that broker's progress clock. The daemon also must
6
+ // revalidate that clock after asynchronous ps sampling and report a failed
7
+ // SIGTERM honestly.
8
+ import { test } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { readFileSync, utimesSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { createHeadlessHarness } from '../helpers/harness.js';
13
+ import { getRow, recordLaunch, recordPid, subscribe } from '../../canvas/canvas.js';
14
+ import { jobDir } from '../../canvas/paths.js';
15
+ import { markBusy } from '../../runtime/busy.js';
16
+ import { BrokerSupervisionReconciler, WEDGE_QUIET_MS } from '../../../daemon/reconcilers/broker-supervision.js';
17
+ const T0 = 20_000_000;
18
+ function setBusyAt(id, at) {
19
+ markBusy(id);
20
+ utimesSync(join(jobDir(id), 'busy'), at / 1_000, at / 1_000);
21
+ }
22
+ function context(h, id) {
23
+ return {
24
+ rows: [getRow(id)],
25
+ fleet: h.fleet,
26
+ lifecycle: { acceptsDetachedWork: () => false, registerDetached: () => { } },
27
+ };
28
+ }
29
+ test('wedge supervision discards a busy marker refreshed while ps sampling', async () => {
30
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-wedge-sample' });
31
+ try {
32
+ const id = h.fabricateBrokerNode({ status: 'active', pi_session_id: 'wedge-sample' });
33
+ recordLaunch(id, new Date(T0 - WEDGE_QUIET_MS - 1).toISOString());
34
+ recordPid(id, process.pid);
35
+ h.fleet.register(id, { pid: process.pid, exited: new Promise(() => { }) });
36
+ setBusyAt(id, T0 - WEDGE_QUIET_MS);
37
+ let finishSample;
38
+ const sampled = new Promise((resolve) => { finishSample = resolve; });
39
+ const signals = [];
40
+ const supervisor = new BrokerSupervisionReconciler({
41
+ readTreeSample: async () => sampled,
42
+ signal: (_pid, signal) => { signals.push(signal); },
43
+ });
44
+ const running = supervisor.run(T0, context(h, id));
45
+ setBusyAt(id, T0 - 1);
46
+ finishSample({ cpuPercent: 0, hasDescendants: false });
47
+ await running;
48
+ assert.deepEqual(signals, [], 'a marker updated while ps ran cannot produce a wedge SIGTERM');
49
+ }
50
+ finally {
51
+ await h.dispose();
52
+ }
53
+ });
54
+ test('wedge supervision does not attribute a predecessor busy marker to a new broker', async () => {
55
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-wedge-predecessor' });
56
+ try {
57
+ const id = h.fabricateBrokerNode({ status: 'active', pi_session_id: 'wedge-predecessor' });
58
+ setBusyAt(id, T0 - WEDGE_QUIET_MS - 1);
59
+ recordLaunch(id, new Date(T0).toISOString());
60
+ recordPid(id, process.pid);
61
+ h.fleet.register(id, { pid: process.pid, exited: new Promise(() => { }) });
62
+ let samples = 0;
63
+ const supervisor = new BrokerSupervisionReconciler({
64
+ readTreeSample: async () => {
65
+ samples += 1;
66
+ return { cpuPercent: 0, hasDescendants: false };
67
+ },
68
+ signal: () => { throw new Error('a predecessor marker must not reach the signal path'); },
69
+ });
70
+ await supervisor.run(T0, context(h, id));
71
+ assert.equal(samples, 0, 'the old marker is not a progress observation for the new broker');
72
+ }
73
+ finally {
74
+ await h.dispose();
75
+ }
76
+ });
77
+ test('wedge supervision reports a throwing SIGTERM as an attempted, failed kick', async () => {
78
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-wedge-signal' });
79
+ try {
80
+ const parent = h.fabricateBrokerNode({ status: 'active' });
81
+ const id = h.fabricateBrokerNode({ status: 'active', pi_session_id: 'wedge-signal' });
82
+ subscribe(parent, id);
83
+ recordLaunch(id, new Date(T0 - WEDGE_QUIET_MS - 1).toISOString());
84
+ recordPid(id, process.pid);
85
+ h.fleet.register(id, { pid: process.pid, exited: new Promise(() => { }) });
86
+ setBusyAt(id, T0 - WEDGE_QUIET_MS);
87
+ const supervisor = new BrokerSupervisionReconciler({
88
+ readTreeSample: async () => ({ cpuPercent: 0, hasDescendants: false }),
89
+ signal: () => { throw new Error('EPERM'); },
90
+ });
91
+ await supervisor.run(T0, context(h, id));
92
+ const notice = h.inbox(parent).at(-1);
93
+ assert.match(notice.label, /attempted SIGTERM to the broker, but the signal call failed/);
94
+ assert.equal(notice.data?.['kick_attempted'], true);
95
+ assert.equal(notice.data?.['kicked'], false);
96
+ const events = readFileSync(join(h.home, 'crtrd.log'), 'utf8');
97
+ assert.match(events, /broker\.wedge\.signal_failed/);
98
+ }
99
+ finally {
100
+ await h.dispose();
101
+ }
102
+ });
@@ -21,10 +21,14 @@
21
21
  import { test } from 'node:test';
22
22
  import assert from 'node:assert/strict';
23
23
  import { spawn } from 'node:child_process';
24
+ import { utimesSync } from 'node:fs';
25
+ import { join } from 'node:path';
24
26
  import { createHeadlessHarness } from '../helpers/harness.js';
25
- import { recordPid } from '../../canvas/canvas.js';
27
+ import { getRow, recordLaunch, recordPid } from '../../canvas/canvas.js';
28
+ import { jobDir } from '../../canvas/paths.js';
26
29
  import { isPidAlive } from '../../canvas/pid.js';
27
- import { yieldStallVerdict, YIELD_STALL_GRACE_MS } from '../../../daemon/reconcilers/broker-supervision.js';
30
+ import { markBusy } from '../../runtime/busy.js';
31
+ import { BrokerSupervisionReconciler, yieldStallVerdict, YIELD_STALL_GRACE_MS } from '../../../daemon/reconcilers/broker-supervision.js';
28
32
  // D4a — the pure refresh-stall truth table.
29
33
  test('D4a yieldStallVerdict: kill ONLY a live, not-busy, refresh engine past the grace', () => {
30
34
  const G = YIELD_STALL_GRACE_MS;
@@ -36,6 +40,7 @@ test('D4a yieldStallVerdict: kill ONLY a live, not-busy, refresh engine past the
36
40
  // D4b — daemon integration: a stuck refresh on a LIVE engine is force-recycled.
37
41
  const T0 = 7_000_000;
38
42
  const T_KILL = T0 + YIELD_STALL_GRACE_MS + 1; // one ms past the grace from t0
43
+ const KILL_ESCALATE_MS = 20_000;
39
44
  test('D4b daemon force-recycles a stuck refresh: a live, not-busy refresh engine past the grace is SIGTERM\'d', { timeout: 30_000 }, async () => {
40
45
  const h = await createHeadlessHarness({ sessionPrefix: 'crtr-d4b' });
41
46
  // A real live subprocess standing in for the hung/stale-hook engine. `sleep`
@@ -86,3 +91,64 @@ test('D4b daemon force-recycles a stuck refresh: a live, not-busy refresh engine
86
91
  await h.dispose();
87
92
  }
88
93
  });
94
+ test('D4c yield escalation is scoped to the broker that received SIGTERM', { timeout: 30_000 }, async () => {
95
+ const h = await createHeadlessHarness({ sessionPrefix: 'crtr-d4c' });
96
+ const original = spawn('sleep', ['300'], { stdio: 'ignore' });
97
+ let replacement;
98
+ try {
99
+ const id = h.fabricateBrokerNode({
100
+ kind: 'developer',
101
+ status: 'active',
102
+ intent: 'refresh',
103
+ pi_pid: original.pid,
104
+ pi_session_id: 'sess-d4c',
105
+ });
106
+ recordLaunch(id, new Date(T0).toISOString());
107
+ recordPid(id, original.pid);
108
+ h.fleet.register(id, { pid: original.pid, exited: new Promise(() => { }) });
109
+ const supervisor = new BrokerSupervisionReconciler();
110
+ const context = () => ({
111
+ rows: [getRow(id)],
112
+ fleet: h.fleet,
113
+ lifecycle: { acceptsDetachedWork: () => false, registerDetached: () => { } },
114
+ });
115
+ await supervisor.run(T0, context());
116
+ await supervisor.run(T_KILL, context());
117
+ await h.waitFor(() => (isPidAlive(original.pid) ? null : true), {
118
+ label: 'original refresh broker received SIGTERM',
119
+ timeoutMs: 10_000,
120
+ });
121
+ h.fleet.forget(id);
122
+ markBusy(id); // interrupted predecessor witness must not block the replacement's refresh timer
123
+ utimesSync(join(jobDir(id), 'busy'), T_KILL / 1_000, T_KILL / 1_000);
124
+ replacement = spawn(process.execPath, ['-e', 'process.on("SIGTERM", () => {}); console.log("ready"); setInterval(() => {}, 1000)'], { stdio: ['ignore', 'pipe', 'ignore'] });
125
+ await new Promise((resolve, reject) => {
126
+ replacement.stdout.once('data', () => resolve());
127
+ replacement.once('error', reject);
128
+ });
129
+ recordLaunch(id, new Date(T_KILL + 1).toISOString());
130
+ recordPid(id, replacement.pid);
131
+ h.fleet.register(id, { pid: replacement.pid, exited: new Promise(() => { }) });
132
+ const replacementStarted = T_KILL + KILL_ESCALATE_MS + 1;
133
+ await supervisor.run(replacementStarted, context());
134
+ assert.equal(isPidAlive(replacement.pid), true, 'the old SIGKILL deadline does not target the replacement');
135
+ await supervisor.run(replacementStarted + YIELD_STALL_GRACE_MS + 1, context());
136
+ assert.equal(isPidAlive(replacement.pid), true, 'the replacement survives its own SIGTERM when still stalled');
137
+ await supervisor.run(replacementStarted + YIELD_STALL_GRACE_MS + KILL_ESCALATE_MS + 1, context());
138
+ await h.waitFor(() => (isPidAlive(replacement.pid) ? null : true), {
139
+ label: 'replacement refresh broker received its own SIGKILL',
140
+ timeoutMs: 10_000,
141
+ });
142
+ }
143
+ finally {
144
+ for (const child of [original, replacement]) {
145
+ if (child?.pid === undefined)
146
+ continue;
147
+ try {
148
+ child.kill('SIGKILL');
149
+ }
150
+ catch { /* already gone */ }
151
+ }
152
+ await h.dispose();
153
+ }
154
+ });
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import { gitSync } from '../../git.js';
12
12
  import { withExclusiveDirectoryLockAsync } from '../../exclusive-lock.js';
13
13
  import { createManagedWorktree, closeManagedWorktree, worktreeLockPathForCommonDir } from '../../worktree.js';
14
+ import { abandonManagedWorktreeAsync } from '../../worktree-mutation-async.js';
14
15
  import { reconcileManagedWorktree } from '../../worktree-sweep.js';
15
16
  import { createApiServer } from '../../../daemon/api/server.js';
16
17
  import { ManagedWorktreeSweepReconciler } from '../../../daemon/reconcilers/managed-worktree-sweep.js';
@@ -476,6 +477,36 @@ test('async close yields while an async repository holder runs, keeping API requ
476
477
  await server.close();
477
478
  }
478
479
  });
480
+ test('async abandon retains clean committed work after its base branch is deleted without a remote (#929)', async () => {
481
+ const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-929-'));
482
+ try {
483
+ run(['init', '-b', 'main'], repo);
484
+ run(['config', 'user.email', 'test@example.com'], repo);
485
+ run(['config', 'user.name', 'Test'], repo);
486
+ writeFileSync(join(repo, 'README.md'), 'hello\n');
487
+ run(['add', '.'], repo);
488
+ run(['commit', '-m', 'init'], repo);
489
+ const nodeId = 'wt-abandon-missing-base';
490
+ const wt = createManagedWorktree(repo, nodeId);
491
+ createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
492
+ writeFileSync(join(wt.path, 'committed.txt'), 'committed work\n');
493
+ run(['add', 'committed.txt'], wt.path);
494
+ run(['commit', '-m', 'real work'], wt.path);
495
+ const tipSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
496
+ assert.equal(gitSync(['remote'], repo).stdout.trim(), '', 'the repository has no remote that could prove delivery');
497
+ run(['checkout', '--detach'], repo);
498
+ run(['branch', '-D', 'main'], repo);
499
+ const result = await abandonManagedWorktreeAsync(nodeId, 'test-invoker');
500
+ assert.equal(result.branch_deleted, false, 'without containment proof the committed branch must be retained');
501
+ assert.equal(result.tip_sha, tipSha, 'abandon must retain the exact committed tip');
502
+ assert.equal(existsSync(wt.path), false, 'abandon removes only the checkout');
503
+ assert.equal(gitSync(['rev-parse', wt.branch], repo).stdout.trim(), tipSha, 'the retained branch still reaches the committed work');
504
+ assert.equal(getNode(nodeId)?.managed_worktree?.state, 'abandoned', 'the node records the abandoned disposition');
505
+ }
506
+ finally {
507
+ rmSync(repo, { recursive: true, force: true });
508
+ }
509
+ });
479
510
  test('abandon preserves committed and dirty work, records its retained tip, and releases finalization', async () => {
480
511
  const nodeId = 'wt-abandon';
481
512
  const wt = createManagedWorktree(sourceDir, nodeId);
@@ -0,0 +1,70 @@
1
+ // Run with: npm run build && node --test --test-timeout=180000 dist/core/__tests__/seam/inbox-reference-guidance.test.js
2
+ //
3
+ // Pins the delivered inbox text from the compiled runtime: report handles name
4
+ // the history reader, spilled messages name their exact recipient-side file,
5
+ // and bearings reserve context for agent-authored shared documents.
6
+ import { after, before, test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { readFileSync } from 'node:fs';
9
+ import { subscribe } from '../../canvas/index.js';
10
+ import { resolveRef } from '../../canvas/history.js';
11
+ import { appendInbox, coalesce, readInboxSince } from '../../feed/inbox.js';
12
+ import { pushUpdate } from '../../feed/feed.js';
13
+ import { contextDirNote } from '../../runtime/bearings-render.js';
14
+ import { coalesceBrokerInbox } from '../../runtime/broker/inbox.js';
15
+ import { createHeadlessHarness } from '../helpers/harness.js';
16
+ let h;
17
+ let root;
18
+ let recipient;
19
+ before(async () => {
20
+ h = await createHeadlessHarness({ sessionPrefix: 'crtr-inbox-reference-guidance' });
21
+ root = h.spawnRoot('inbox reference guidance seam root');
22
+ recipient = h.fabricateBrokerNode({ id: 'inbox-reference-recipient', parent: root, status: 'idle' });
23
+ });
24
+ after(async () => {
25
+ if (h !== undefined)
26
+ await h.dispose();
27
+ });
28
+ function onlyCard(entries) {
29
+ const deliveries = coalesce(entries);
30
+ assert.equal(deliveries.length, 1, 'one non-human inbox entry produces one card');
31
+ assert.equal(deliveries[0].kind, 'card');
32
+ return deliveries[0].text;
33
+ }
34
+ test('delivered report and message refs name the exact way to read their bodies', async () => {
35
+ const publisher = h.fabricateBrokerNode({ id: 'inbox-reference-publisher', parent: root, status: 'idle' });
36
+ subscribe(recipient, publisher, true);
37
+ const reportBody = 'published report body';
38
+ await pushUpdate(publisher, reportBody);
39
+ const reportEntry = readInboxSince(recipient).at(-1);
40
+ assert.equal(reportEntry.ref?.startsWith(`${publisher}:reports/`), true, 'delivery carries the canonical report handle');
41
+ const reportRef = reportEntry.ref;
42
+ const reportCard = onlyCard([reportEntry]);
43
+ const brokerReportCard = coalesceBrokerInbox([reportEntry], new Map())[0].text;
44
+ for (const card of [reportCard, brokerReportCard]) {
45
+ assert.ok(card.includes(`crtr canvas history read ${reportRef}`), 'delivery names the exact command for its handle');
46
+ }
47
+ assert.equal(resolveRef(reportRef)?.body, reportBody, 'the exact delivered handle resolves to the freshly published report');
48
+ for (const body of [
49
+ 'a'.repeat(1001),
50
+ Array.from({ length: 13 }, (_, i) => `line ${i + 1}`).join('\n'),
51
+ ]) {
52
+ const message = appendInbox(recipient, {
53
+ from: publisher,
54
+ tier: 'normal',
55
+ kind: 'message',
56
+ label: 'long message',
57
+ data: { body },
58
+ });
59
+ assert.ok(message.ref?.startsWith('/'), 'a clipped message stores its full body at an absolute recipient path');
60
+ const messageCard = onlyCard([message]);
61
+ const brokerMessageCard = coalesceBrokerInbox([message], new Map())[0].text;
62
+ for (const card of [messageCard, brokerMessageCard]) {
63
+ assert.ok(card.includes(`Preview only. Read the full body from file \`${message.ref}\`.`), 'the preview names the exact full-body path');
64
+ }
65
+ assert.equal(readFileSync(message.ref, 'utf8'), body, 'the exact delivered path holds the complete message');
66
+ }
67
+ const bearings = contextDirNote(recipient);
68
+ assert.match(bearings, /where you write documents to share with other nodes on the canvas/);
69
+ assert.doesNotMatch(bearings, /one place the other nodes on the canvas can read from/);
70
+ });
@@ -0,0 +1,70 @@
1
+ // session-window.test.ts — the preview readers see the ends of a huge session
2
+ // without slurping the middle.
3
+ //
4
+ // A pi session file grows without bound (67 MB on a real canvas), and reading one
5
+ // whole to show a few turns is what stalled the browse preview panel. The readers
6
+ // now take a head and a tail window, which introduces the one thing worth pinning:
7
+ // each cut lands in the MIDDLE of a jsonl record, so the fragment on the inside
8
+ // edge must be dropped rather than parsed. Get that wrong and the newest turn
9
+ // silently disappears — a rendering bug that looks like a node with no history.
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { readSessionParts, readTranscript } from '../render-source.js';
16
+ /** One pi-shaped jsonl message record. */
17
+ function rec(role, text) {
18
+ return JSON.stringify({ type: 'message', message: { role, content: [{ type: 'text', text }] } });
19
+ }
20
+ /** A session whose bulk sits between the first and last turns, big enough that the
21
+ * 4 MiB transcript tail and 1 MiB parts head cannot both reach the same records. */
22
+ function hugeSession(dir) {
23
+ const filler = rec('assistant', 'x'.repeat(64 * 1024));
24
+ const lines = [rec('user', 'FIRST-PROMPT')];
25
+ for (let i = 0; i < 120; i++)
26
+ lines.push(filler);
27
+ lines.push(rec('user', 'LAST-PROMPT'), rec('assistant', 'LAST-REPLY'));
28
+ const file = join(dir, 'huge.jsonl');
29
+ writeFileSync(file, `${lines.join('\n')}\n`);
30
+ return file;
31
+ }
32
+ test('a small session reads exactly as it did before windowing', () => {
33
+ const dir = mkdtempSync(join(tmpdir(), 'session-window-'));
34
+ try {
35
+ const file = join(dir, 'small.jsonl');
36
+ writeFileSync(file, `${[rec('user', 'hello'), rec('assistant', 'hi there')].join('\n')}\n`);
37
+ const parts = readSessionParts(file);
38
+ assert.equal(parts.prompts, 'hello');
39
+ assert.equal(parts.lastAssistant, 'hi there');
40
+ assert.deepEqual(readTranscript(file).map((t) => t.text), ['hello', 'hi there']);
41
+ }
42
+ finally {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+ test('a huge session still yields its newest turns', () => {
47
+ const dir = mkdtempSync(join(tmpdir(), 'session-window-'));
48
+ try {
49
+ const file = hugeSession(dir);
50
+ // The tail window lands mid-record; the newest turns must survive it intact.
51
+ const turns = readTranscript(file);
52
+ assert.equal(turns.at(-1)?.text, 'LAST-REPLY');
53
+ assert.equal(turns.at(-2)?.text, 'LAST-PROMPT');
54
+ // Nothing partial got through: every turn is a whole record's text.
55
+ for (const t of turns)
56
+ assert.ok(t.text.length > 0);
57
+ const parts = readSessionParts(file);
58
+ assert.ok(parts.prompts?.includes('FIRST-PROMPT'), 'head window keeps the opening prompt');
59
+ assert.equal(parts.lastAssistant, 'LAST-REPLY');
60
+ }
61
+ finally {
62
+ rmSync(dir, { recursive: true, force: true });
63
+ }
64
+ });
65
+ test('a missing session file is empty, not a throw', () => {
66
+ assert.deepEqual(readSessionParts(join(tmpdir(), 'nope-does-not-exist.jsonl')), {});
67
+ assert.deepEqual(readTranscript(join(tmpdir(), 'nope-does-not-exist.jsonl')), []);
68
+ assert.deepEqual(readSessionParts(null), {});
69
+ assert.deepEqual(readTranscript(undefined), []);
70
+ });