@north-light/crouter 0.3.213 → 0.3.214

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.
@@ -149,7 +149,7 @@ test('a human message coalesces verbatim — a conversation turn, not a digest',
149
149
  finalizeInboxEntry({ from: 'mqifzplr-0753bde3', tier: 'normal', kind: 'message', label: 'go', data: { body: 'go' } }),
150
150
  ]));
151
151
  assert.equal(agent.startsWith('<runtime kind="inbox"'), true);
152
- assert.match(agent, /<from id="mqifzplr-0753bde3" updates="1">/);
152
+ assert.match(agent, /<from id="mqifzplr-0753bde3">/);
153
153
  });
154
154
  test('a mixed batch splits: human words verbatim, node reports in one card, order preserved', () => {
155
155
  // Concatenating the two into one message left `parseCard` unable to see the
@@ -16,6 +16,7 @@ function row(over = {}) {
16
16
  pi_session_id: null,
17
17
  cycle_pending: null,
18
18
  fork_from: null,
19
+ lifecycle: 'terminal',
19
20
  ...over,
20
21
  };
21
22
  }
@@ -40,6 +41,18 @@ test('row 4: intent=idle-release (chosen dormancy) takes no action — pass-2 in
40
41
  assert.equal(classifyDeadNode(row({ intent: 'idle-release' }), false), 'dormant');
41
42
  assert.equal(classifyDeadNode(row({ intent: 'idle-release', pi_session_id: 'sess' }), false), 'dormant', 'a saved session does not turn chosen dormancy into a crash');
42
43
  });
44
+ // Row 4b is why a canvas of parked conversations does not stampede its host at
45
+ // boot: a daemon restart classifies every one of them at once, and resuming an
46
+ // engine that has nothing to come back to is pure cost.
47
+ test('row 4b: a resident that died between turns with nothing to wake it is released, not resumed', () => {
48
+ const resident = row({ lifecycle: 'resident', pi_session_id: 'sess' });
49
+ assert.equal(classifyDeadNode(resident, false, false), 'release-idle');
50
+ assert.equal(classifyDeadNode(resident, false), 'respawn-resume', 'an uninformed caller keeps the conservative respawn');
51
+ assert.equal(classifyDeadNode(resident, false, true), 'respawn-resume', 'unseen mail, a live obligation, or an attached human brings it back');
52
+ assert.equal(classifyDeadNode(resident, true, false), 'respawn-resume', 'an interrupted turn outranks it — the continuation still owes that turn');
53
+ assert.equal(classifyDeadNode(row({ lifecycle: 'terminal', pi_session_id: 'sess' }), false, false), 'respawn-resume', 'a terminal node with nothing live has STALLED; coming back to be reprompted is the point');
54
+ assert.equal(classifyDeadNode(row({ lifecycle: 'resident' }), false, false), 'boot-failure', 'no saved session means it never booted — releasing would hide a boot failure');
55
+ });
43
56
  test('row 5: every saved session resumes strictly unless its own pending cycle must be retried', () => {
44
57
  assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), false), 'respawn-resume');
45
58
  assert.equal(classifyDeadNode(row({ pi_session_id: 'sess' }), true), 'respawn-resume', 'a dirty interrupted turn resumes in place; the respawn continuation re-drives it');
@@ -356,10 +356,6 @@ function cardEntry(e) {
356
356
  ...(e.disposition === undefined ? {} : { disposition: e.disposition }),
357
357
  };
358
358
  }
359
- /** The sender's display name as its last entry snapshotted it. */
360
- function sectionName(items) {
361
- return items.map((item) => item.from_name).filter((name) => name !== undefined && name !== '').at(-1);
362
- }
363
359
  /** Split unread inbox pointers into ordered deliveries: each human entry
364
360
  * verbatim, and every other sender in one `<runtime kind="inbox">` card. */
365
361
  export function coalesce(entries) {
@@ -395,8 +391,7 @@ export function coalesce(entries) {
395
391
  cardSlot = deliveries.length;
396
392
  deliveries.push({ kind: 'card', text: '', entries: [] });
397
393
  }
398
- const name = sectionName(items);
399
- sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map(cardEntry) });
394
+ sections.push({ id: sender, entries: items.map(cardEntry) });
400
395
  }
401
396
  if (cardSlot >= 0) {
402
397
  // Card sections group by sender, so its entry list comes from the input
@@ -76,7 +76,8 @@ ${SLOT_RULES}
76
76
 
77
77
  Props
78
78
  - \`id\` — required component id.
79
- - \`options\` — required nonempty array of \`{id:string (nonempty, unique), label:string (nonempty), description?:string}\`.
79
+ - \`options\` — required nonempty array of \`{id:string (nonempty, unique), label:string (nonempty), description?:string, recommended?:boolean}\`.
80
+ - \`recommended\` on one option marks it the suggested answer. At most one option per question may set it. The inbox shows it on the row, and when this question is the page's only response-bearing component the user can accept it from the list with one keystroke, without opening the page. Recommend when you have a view worth acting on; leave it off when the choice is genuinely theirs.
80
81
  - \`mode\` — required: \`single\` or \`multi\`.
81
82
  - \`label\` — required nonempty string: the question itself, drawn as the bold first line.
82
83
  - \`body\` — required nonempty string: markdown rendered above the choices — the context under the question a label cannot carry. Display-only; contributes nothing to the response.
@@ -92,7 +93,7 @@ Example
92
93
  <UserQuestion id="lane" label="Release lane" body="The audit closes tomorrow." mode="single" allowFreetext
93
94
  freetextLabel="Something else"
94
95
  options={[
95
- { id: 'now', label: 'Ship now', description: 'Both gates are green.' },
96
+ { id: 'now', label: 'Ship now', description: 'Both gates are green.', recommended: true },
96
97
  { id: 'hold', label: 'Hold for the audit' },
97
98
  ]} />
98
99
  \`\`\``,
@@ -26,6 +26,7 @@ export declare const optionSchema: z.ZodObject<{
26
26
  id: z.ZodString;
27
27
  label: z.ZodString;
28
28
  description: z.ZodOptional<z.ZodString>;
29
+ recommended: z.ZodOptional<z.ZodBoolean>;
29
30
  }, z.core.$strict>;
30
31
  export declare const optionsConfigSchema: z.ZodObject<{
31
32
  label: z.ZodOptional<z.ZodString>;
@@ -34,6 +35,7 @@ export declare const optionsConfigSchema: z.ZodObject<{
34
35
  id: z.ZodString;
35
36
  label: z.ZodString;
36
37
  description: z.ZodOptional<z.ZodString>;
38
+ recommended: z.ZodOptional<z.ZodBoolean>;
37
39
  }, z.core.$strict>>;
38
40
  mode: z.ZodEnum<{
39
41
  single: "single";
@@ -229,6 +231,32 @@ export interface PageManifest {
229
231
  export declare const BUILTIN_PAGE_CONFIG_SCHEMAS: Record<(typeof BUILTIN_PAGE_KINDS)[number], z.ZodType>;
230
232
  export declare const BUILTIN_PAGE_RESPONSE_SCHEMAS: Partial<Record<(typeof BUILTIN_PAGE_KINDS)[number], z.ZodType>>;
231
233
  export declare function issueText(error: z.ZodError): string;
234
+ /** One question's recommended option, named well enough for a list row to draw it without reading the page. */
235
+ export interface RecommendedOption {
236
+ slotId: string;
237
+ optionId: string;
238
+ label: string;
239
+ }
240
+ /**
241
+ * What one keypress on an inbox row publishes. `responses` is a complete, already-valid
242
+ * final response map, so the surface that sends it never assembles or interprets one.
243
+ * `kind` carries the semantic only — the words shown to a person belong to the surface.
244
+ */
245
+ export interface PageFastAction {
246
+ kind: 'answer' | 'acknowledge';
247
+ responses: PageResponses;
248
+ }
249
+ /**
250
+ * The two independent overview facts: what a row displays, and what it can publish.
251
+ * A page can recommend without being fast-settleable — a recommendation beside a sibling
252
+ * input is still worth showing, but only the page itself can answer the rest of it.
253
+ */
254
+ export interface PageOverviewActions {
255
+ recommendedOptions: RecommendedOption[];
256
+ fastAction?: PageFastAction;
257
+ }
258
+ /** Derive both overview facts from a manifest alone. Pure: no ticket state, no second read. */
259
+ export declare function pageOverviewActions(manifest: PageManifest): PageOverviewActions;
232
260
  /** The canonical response-bearing predicate used by every page consumer. */
233
261
  export declare function isResponseBearingSlot(slot: PageSlot): boolean;
234
262
  /** Validate a persisted manifest. Publish-time validation made its slots authoritative, so reads do not resolve a component catalog. */
@@ -22,7 +22,7 @@ export const commentAnchorSchema = z.discriminatedUnion('kind', [
22
22
  z.object({ kind: z.literal('row'), rowId: itemIdSchema }).strict(),
23
23
  ]);
24
24
  export const commentSchema = z.object({ id: z.string().min(1), anchor: commentAnchorSchema, text: z.string() }).strict();
25
- export const optionSchema = z.object({ id: itemIdSchema, label: z.string().min(1), description: z.string().optional() }).strict();
25
+ export const optionSchema = z.object({ id: itemIdSchema, label: z.string().min(1), description: z.string().optional(), recommended: z.boolean().optional() }).strict();
26
26
  export const optionsConfigSchema = z.object({
27
27
  label: z.string().optional(),
28
28
  body: z.string().optional(),
@@ -31,7 +31,11 @@ export const optionsConfigSchema = z.object({
31
31
  allowFreetext: z.boolean().optional(),
32
32
  freetextLabel: z.string().optional(),
33
33
  freetextPlaceholder: z.string().optional(),
34
- }).strict();
34
+ }).strict().superRefine((config, ctx) => {
35
+ const recommended = config.options.filter((option) => option.recommended === true);
36
+ if (recommended.length > 1)
37
+ ctx.addIssue({ code: 'custom', path: ['options'], message: `at most one option may be recommended, but ${recommended.length} are` });
38
+ });
35
39
  export const optionsResponseSchema = z.object({ selectedOptionIds: z.array(itemIdSchema), comments: z.array(commentSchema), freetext: z.string().optional() }).strict();
36
40
  // Text is a writing surface, always: its whole point is the string the user hands back, so
37
41
  // there is no read-only mode. `singleLine` only swaps the multi-line surface for one compact
@@ -111,6 +115,35 @@ export const BUILTIN_PAGE_RESPONSE_SCHEMAS = {
111
115
  export function issueText(error) {
112
116
  return error.issues.map((issue) => `${issue.path.length === 0 ? 'value' : issue.path.join('.')}: ${issue.message}`).join('; ');
113
117
  }
118
+ /** Derive both overview facts from a manifest alone. Pure: no ticket state, no second read. */
119
+ export function pageOverviewActions(manifest) {
120
+ const recommendedOptions = [];
121
+ for (const slot of manifest.slots) {
122
+ if (slot.kind !== 'options' || slot.id === undefined)
123
+ continue;
124
+ // Persisted manifests predate the one-recommendation rule, so take the first rather than assuming.
125
+ const option = slot.config.options.find((candidate) => candidate.recommended === true);
126
+ if (option !== undefined)
127
+ recommendedOptions.push({ slotId: slot.id, optionId: option.id, label: option.label });
128
+ }
129
+ // An inline page lives in the transcript, where there is no row to press.
130
+ if (manifest.delivery.placement !== 'panel')
131
+ return { recommendedOptions };
132
+ const answerable = manifest.slots.filter(isResponseBearingSlot);
133
+ // A notice settles on the empty map, which is exactly what Acknowledge publishes from the page.
134
+ if (answerable.length === 0)
135
+ return { recommendedOptions, fastAction: { kind: 'acknowledge', responses: {} } };
136
+ if (answerable.length > 1)
137
+ return { recommendedOptions };
138
+ const slot = answerable[0];
139
+ const recommendation = recommendedOptions.find((candidate) => candidate.slotId === slot.id);
140
+ if (slot.kind !== 'options' || recommendation === undefined)
141
+ return { recommendedOptions };
142
+ return {
143
+ recommendedOptions,
144
+ fastAction: { kind: 'answer', responses: { [slot.id]: { selectedOptionIds: [recommendation.optionId], comments: [] } } },
145
+ };
146
+ }
114
147
  /** The canonical response-bearing predicate used by every page consumer. */
115
148
  export function isResponseBearingSlot(slot) {
116
149
  if (slot.display === true)
@@ -3,6 +3,7 @@ import { basename, join } from 'node:path';
3
3
  import { claimPath, isResolved, pageManifestPath, reviewPath } from './convention.js';
4
4
  import { readJsonOrNull } from '../fs-utils.js';
5
5
  import { validateReviewDescriptor } from './review-schema.js';
6
+ import { pageOverviewActions } from './page-schema.js';
6
7
  import { parsePage } from './page.js';
7
8
  import { pageTicketState, readTicketResult } from './tickets.js';
8
9
  import { ticketsRoot } from './root.js';
@@ -42,6 +43,8 @@ function pageSummary(dir, id) {
42
43
  return null;
43
44
  }
44
45
  }
46
+ const state = pageTicketState(dir);
47
+ const { recommendedOptions, fastAction } = pageOverviewActions(manifest);
45
48
  return {
46
49
  dir,
47
50
  id,
@@ -56,7 +59,10 @@ function pageSummary(dir, id) {
56
59
  awaitsResponse: manifest.delivery.reply,
57
60
  source: manifest.source ?? {},
58
61
  emittedAt,
59
- state: pageTicketState(dir),
62
+ state,
63
+ recommendedOptions,
64
+ // A settled ticket has nothing left to publish, so only a pending one offers the keypress.
65
+ ...(fastAction !== undefined && state === 'pending' ? { fastAction } : {}),
60
66
  claim: claimSummary(dir),
61
67
  };
62
68
  }
@@ -65,6 +65,10 @@ export interface PageTicketSummary {
65
65
  steps: number;
66
66
  slotKinds: string[];
67
67
  awaitsResponse: boolean;
68
+ /** Every question's recommended option, for a row that says what is suggested without opening the page. */
69
+ recommendedOptions: import('./page-schema.js').RecommendedOption[];
70
+ /** Present when this ticket can be settled straight from a list row; absent when only the page can answer it. */
71
+ fastAction?: import('./page-schema.js').PageFastAction;
68
72
  state: 'pending' | 'resolved' | 'canceled' | 'passive';
69
73
  }
70
74
  /** The only pending-ticket shape scanners expose. */
@@ -198,11 +198,7 @@ export function coalesceBrokerInbox(entries, reportNodes) {
198
198
  cardSlot = deliveries.length;
199
199
  deliveries.push({ kind: 'card', text: '', cards: [], entries: [] });
200
200
  }
201
- // The fallback to a CURRENT projected name is correct only for legacy
202
- // entries, which predate the producer's own `from_name` snapshot.
203
- const name = items.map((item) => item.from_name).filter((value) => value !== undefined && value !== '').at(-1)
204
- ?? reportNodes.get(sender)?.name;
205
- sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map((entry) => cardEntry(entry, reportNodes)) });
201
+ sections.push({ id: sender, entries: items.map((entry) => cardEntry(entry, reportNodes)) });
206
202
  }
207
203
  if (cardSlot >= 0) {
208
204
  // Card sections group by sender, so its entry list comes from the input
@@ -26,6 +26,7 @@
26
26
  // so a doc delivered by one never re-delivers at the same or lower rung
27
27
  // through another. Each candidate renders at its matched rung — the highest
28
28
  // `at` over its matching entries for the event.
29
+ import { createHash } from 'node:crypto';
29
30
  import { homedir } from 'node:os';
30
31
  import { dirname, parse, relative, sep } from 'node:path';
31
32
  import { CRTR_DIR_NAME } from '../../types.js';
@@ -172,6 +173,14 @@ function readFileFrontmatter(absReadFile) {
172
173
  return {};
173
174
  }
174
175
  }
176
+ /** Content-identity dedup key, or null for an empty body (name-only docs
177
+ * must not collide with each other). */
178
+ function docContentKey(doc) {
179
+ const body = doc.body.trim();
180
+ if (body === '')
181
+ return null;
182
+ return `content-sha256:${createHash('sha256').update(body).digest('hex')}`;
183
+ }
175
184
  function renderDocEnvelope(doc, rung) {
176
185
  if (rung === 'none')
177
186
  return null;
@@ -191,8 +200,13 @@ export function renderCandidateBlocks(subject, candidates, seen) {
191
200
  const rendered = [];
192
201
  for (const { doc, rung } of candidates) {
193
202
  const real = realpathOrSelf(doc.path);
203
+ // Byte-identical bodies at different paths (a cloned repo's store) inform
204
+ // once; the content key shares the realpath map and its rung semantics.
205
+ const contentKey = docContentKey(doc);
194
206
  if (deliveredAtOrAbove(seen, real, rung))
195
207
  continue;
208
+ if (contentKey !== null && deliveredAtOrAbove(seen, contentKey, rung))
209
+ continue;
196
210
  try {
197
211
  if (subject === null ? doc.gate !== undefined : !gatePasses(doc, subject))
198
212
  continue;
@@ -200,6 +214,8 @@ export function renderCandidateBlocks(subject, candidates, seen) {
200
214
  if (block === null)
201
215
  continue;
202
216
  recordDelivery(seen, real, rung);
217
+ if (contentKey !== null)
218
+ recordDelivery(seen, contentKey, rung);
203
219
  rendered.push(block);
204
220
  }
205
221
  catch {
@@ -169,33 +169,44 @@ function isSlotValidationError(err) {
169
169
  // ===========================================================================
170
170
  // GET /v1/human/inbox — enumerate
171
171
  // ===========================================================================
172
+ /** The one page-summary projection: the pending list and the per-node history differ only in which tickets they walk. */
173
+ function pageSummaryToDTO(item, canonicalRoot) {
174
+ const dto = {
175
+ ticket_id: createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex'),
176
+ kind: 'page',
177
+ title: item.title,
178
+ placement: item.placement,
179
+ dialect: item.dialect,
180
+ steps: item.steps,
181
+ emitted_at: item.emittedAt,
182
+ source: item.source,
183
+ slot_kinds: item.slotKinds,
184
+ inbox: item.inbox,
185
+ awaits_response: item.awaitsResponse,
186
+ state: item.state,
187
+ };
188
+ if (item.subtitle !== '')
189
+ dto.subtitle = item.subtitle;
190
+ if (item.answerDigest !== undefined)
191
+ dto.answer_digest = item.answerDigest;
192
+ if (item.recommendedOptions.length > 0) {
193
+ dto.recommended_options = item.recommendedOptions.map((option) => ({ slot_id: option.slotId, option_id: option.optionId, label: option.label }));
194
+ }
195
+ if (item.fastAction !== undefined)
196
+ dto.fast_action = { kind: item.fastAction.kind, responses: item.fastAction.responses };
197
+ return dto;
198
+ }
172
199
  function handleList() {
173
200
  const allItems = scanInbox();
174
201
  const filtered = filterTerminalReviewTickets(allItems);
202
+ const canonicalRoot = realpathSync(ticketsRoot());
175
203
  const tickets = filtered.map((item) => {
176
204
  if (item.kind === 'page') {
177
- const ticketId = createHash('sha256').update(`${realpathSync(ticketsRoot())}\0${item.id}`, 'utf8').digest('hex');
178
- const dto = {
179
- ticket_id: ticketId,
180
- kind: 'page',
181
- title: item.title,
182
- placement: item.placement,
183
- dialect: item.dialect,
184
- steps: item.steps,
185
- emitted_at: item.emittedAt,
186
- source: item.source,
187
- slot_kinds: item.slotKinds,
188
- inbox: item.inbox,
189
- awaits_response: item.awaitsResponse,
190
- state: item.state,
191
- };
192
- if (item.subtitle !== '')
193
- dto.subtitle = item.subtitle;
194
- return dto;
205
+ return pageSummaryToDTO(item, canonicalRoot);
195
206
  }
196
207
  else if (item.kind === 'review') {
197
208
  return {
198
- ticket_id: createHash('sha256').update(`${realpathSync(ticketsRoot())}\0${item.id}`, 'utf8').digest('hex'),
209
+ ticket_id: createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex'),
199
210
  kind: 'review',
200
211
  title: item.title,
201
212
  subtitle: item.subtitle,
@@ -221,28 +232,7 @@ function handleHistory(ctx) {
221
232
  }
222
233
  const history = scanPageHistory(nodeId);
223
234
  const canonicalRoot = realpathSync(ticketsRoot());
224
- const tickets = history.map((item) => {
225
- const ticketId = createHash('sha256').update(`${canonicalRoot}\0${item.id}`, 'utf8').digest('hex');
226
- const dto = {
227
- ticket_id: ticketId,
228
- kind: 'page',
229
- title: item.title,
230
- placement: item.placement,
231
- dialect: item.dialect,
232
- steps: item.steps,
233
- emitted_at: item.emittedAt,
234
- source: item.source,
235
- slot_kinds: item.slotKinds,
236
- inbox: item.inbox,
237
- awaits_response: item.awaitsResponse,
238
- state: item.state,
239
- };
240
- if (item.subtitle !== '')
241
- dto.subtitle = item.subtitle;
242
- if (item.answerDigest !== undefined)
243
- dto.answer_digest = item.answerDigest;
244
- return dto;
245
- });
235
+ const tickets = history.map((item) => pageSummaryToDTO(item, canonicalRoot));
246
236
  return { status: 200, body: { tickets } };
247
237
  }
248
238
  // ===========================================================================
@@ -20,10 +20,10 @@ export declare function surfaceBootFailure(meta: NodeMeta): Promise<void>;
20
20
  * fix for that is the continuation prompt recovery delivers (see `#respawn`),
21
21
  * not throwing the conversation away. */
22
22
  export declare function retryResumeMode(meta: Pick<NodeMeta, 'cycle_pending'> | null): boolean;
23
- export type DeadNodeAction = 'forget' | 'respawn-fresh' | 'dormant' | 'respawn-cycle' | 'respawn-resume' | 'boot-failure';
23
+ export type DeadNodeAction = 'forget' | 'respawn-fresh' | 'dormant' | 'release-idle' | 'respawn-cycle' | 'respawn-resume' | 'boot-failure';
24
24
  /** The narrow durable state the policy table reads. Structural (a Pick, not
25
25
  * NodeMeta itself) so tests fabricate table rows directly. */
26
- export type DeadNodeRow = Pick<NodeMeta, 'status' | 'intent' | 'pi_session_id' | 'cycle_pending' | 'fork_from'>;
26
+ export type DeadNodeRow = Pick<NodeMeta, 'status' | 'intent' | 'pi_session_id' | 'cycle_pending' | 'fork_from' | 'lifecycle'>;
27
27
  /** One pure function producing the action for a node known-dead. Used at
28
28
  * exactly two call sites — an exit-policy job and the startup recovery sweep
29
29
  * (design D-12) — so exit recovery and startup recovery can never diverge.
@@ -37,8 +37,12 @@ export type DeadNodeRow = Pick<NodeMeta, 'status' | 'intent' | 'pi_session_id' |
37
37
  * needs the turn re-driven, which is a continuation prompt on top of the
38
38
  * resume (see `#respawn`), not a fresh branch. `busy` therefore survives here
39
39
  * only to route a node that died before session_start ever recorded a session
40
- * to a fresh relaunch instead of terminalizing it as a boot failure. */
41
- export declare function classifyDeadNode(row: DeadNodeRow | null, busy: boolean): DeadNodeAction;
40
+ * to a fresh relaunch instead of terminalizing it as a boot failure.
41
+ *
42
+ * `pendingWake` is the one non-row input (`hasPendingWake`): whether anything
43
+ * would drive a turn if the engine came back. It selects row 4b and defaults
44
+ * to `true`, so an uninformed caller keeps the conservative respawn. */
45
+ export declare function classifyDeadNode(row: DeadNodeRow | null, busy: boolean, pendingWake?: boolean): DeadNodeAction;
42
46
  /** An exit with uptime at or above this resets the consecutive-failure
43
47
  * counter before classification; a shorter-lived exit increments it. */
44
48
  export declare const HEALTHY_UPTIME_MS = 60000;
@@ -19,6 +19,7 @@ import { isSafeNodeId } from '../core/canvas/paths.js';
19
19
  import { transition } from '../core/runtime/lifecycle.js';
20
20
  import { fanDoctrineWake } from '../core/runtime/close.js';
21
21
  import { hasCleanAbort, isBusy } from '../core/runtime/busy.js';
22
+ import { hasPendingWake } from './reconcilers/live-obligation.js';
22
23
  import { reviveNode } from '../core/runtime/revive.js';
23
24
  import { pushUrgent } from '../core/feed/feed.js';
24
25
  import { emitEvent } from '../core/events/emit.js';
@@ -76,8 +77,12 @@ export function retryResumeMode(meta) {
76
77
  * needs the turn re-driven, which is a continuation prompt on top of the
77
78
  * resume (see `#respawn`), not a fresh branch. `busy` therefore survives here
78
79
  * only to route a node that died before session_start ever recorded a session
79
- * to a fresh relaunch instead of terminalizing it as a boot failure. */
80
- export function classifyDeadNode(row, busy) {
80
+ * to a fresh relaunch instead of terminalizing it as a boot failure.
81
+ *
82
+ * `pendingWake` is the one non-row input (`hasPendingWake`): whether anything
83
+ * would drive a turn if the engine came back. It selects row 4b and defaults
84
+ * to `true`, so an uninformed caller keeps the conservative respawn. */
85
+ export function classifyDeadNode(row, busy, pendingWake = true) {
81
86
  if (row === null)
82
87
  return 'forget'; // row 1: row deleted
83
88
  if (row.status === 'done' || row.status === 'canceled' || row.status === 'dead')
@@ -86,6 +91,18 @@ export function classifyDeadNode(row, busy) {
86
91
  return 'respawn-fresh'; // row 3
87
92
  if (row.intent === 'idle-release')
88
93
  return 'dormant'; // row 4
94
+ // Row 4b — a RESIDENT node is interactable and is never forced to finish
95
+ // anything (stop-guard's `dormant`), so one that died between turns with
96
+ // nothing waiting for it has no turn to come back to. Resuming it would boot
97
+ // an engine that sits at an idle prompt until the park clock releases it 15
98
+ // minutes later, and a daemon restart classifies EVERY such node at once —
99
+ // which is how a canvas with a hundred parked conversations stampedes its
100
+ // host to death at boot. Release instead: the row stays revivable and its
101
+ // next message wakes it with the same session resume.
102
+ // A terminal node is deliberately excluded: with nothing live and no final
103
+ // pushed it has STALLED, and coming back to be reprompted is the point.
104
+ if (row.lifecycle === 'resident' && !busy && !pendingWake && row.pi_session_id != null)
105
+ return 'release-idle';
89
106
  if (row.pi_session_id != null)
90
107
  return retryResumeMode(row) ? 'respawn-resume' : 'respawn-cycle'; // row 5: any saved session
91
108
  if (busy)
@@ -263,7 +280,7 @@ export class DaemonFleet {
263
280
  // as the run settles, then writes the clean-abort certificate. Both need the
264
281
  // same recovery: resume the saved transcript and re-drive the stopped turn.
265
282
  const interruptedTurn = isBusy(nodeId) || hasCleanAbort(nodeId);
266
- const action = classifyDeadNode(meta, interruptedTurn);
283
+ const action = classifyDeadNode(meta, interruptedTurn, hasPendingWake(nodeId));
267
284
  switch (action) {
268
285
  case 'forget':
269
286
  // Fanouts for terminal exits already happened at the transition site.
@@ -275,6 +292,22 @@ export class DaemonFleet {
275
292
  // tick's pass-2 cursor-keyed retry cap (D-11), not this throttle.
276
293
  this.#clearThrottle(nodeId);
277
294
  break;
295
+ case 'release-idle':
296
+ // Dormancy the daemon chose ON the node's behalf, and the same
297
+ // non-failure: the row lands exactly where a stophook idle-release
298
+ // would have left it, so pass-2 inbox wakes own its next revive.
299
+ this.#clearThrottle(nodeId);
300
+ transition(nodeId, 'release');
301
+ emitEvent({
302
+ level: 'info',
303
+ event: 'broker.recovery.released',
304
+ ...(isSafeNodeId(nodeId) ? { node_id: nodeId } : {}),
305
+ fields: {
306
+ ...(isSafeNodeId(nodeId) ? {} : { affected_node_id: nodeId }),
307
+ origin,
308
+ },
309
+ });
310
+ break;
278
311
  case 'boot-failure':
279
312
  await this.#terminalize(nodeId, meta, {
280
313
  event: 'broker.boot.failed',
@@ -1,6 +1,15 @@
1
1
  import { type Lifecycle } from '../../core/canvas/index.js';
2
2
  /** Durable work that requires a node to remain automatically wakeable. */
3
3
  export declare function hasLiveObligation(nodeId: string): boolean;
4
+ /** Whether anything would drive a turn if this node's engine came back right
5
+ * now: durable work that must wake it, mail it has not seen, or a human
6
+ * already looking at it. The negative is what makes re-launching a dead
7
+ * node's engine pure cost — it would boot, load its context, sit at an idle
8
+ * prompt, and wait out the park clock.
9
+ *
10
+ * Deferred mail deliberately does not count: it rides the node's next natural
11
+ * cycle rather than causing one. */
12
+ export declare function hasPendingWake(nodeId: string): boolean;
4
13
  /** What parking an unattended node actually amounts to — the one rule both the
5
14
  * live-broker park clock (broker-supervision) and the already-parked
6
15
  * reconciliation (dormant-inbox) apply.
@@ -2,6 +2,8 @@ import { hasActiveLiveSubscription, hasLiveMessageWait, hasLiveSubscriber, hasPe
2
2
  import { countTickets } from '../../core/canvas/attention.js';
3
3
  import { readCursor, readInboxSince } from '../../core/feed/inbox.js';
4
4
  import { readOutputRequest } from '../../core/runtime/structured-output.js';
5
+ import { focusOf } from '../../core/runtime/placement.js';
6
+ import { hasAttachedBrokerViewers } from '../../core/runtime/broker/client-registry.js';
5
7
  /** Durable work that requires a node to remain automatically wakeable. */
6
8
  export function hasLiveObligation(nodeId) {
7
9
  return hasActiveLiveSubscription(nodeId)
@@ -10,6 +12,21 @@ export function hasLiveObligation(nodeId) {
10
12
  || countTickets(nodeId) > 0
11
13
  || readOutputRequest(nodeId).state !== 'absent';
12
14
  }
15
+ /** Whether anything would drive a turn if this node's engine came back right
16
+ * now: durable work that must wake it, mail it has not seen, or a human
17
+ * already looking at it. The negative is what makes re-launching a dead
18
+ * node's engine pure cost — it would boot, load its context, sit at an idle
19
+ * prompt, and wait out the park clock.
20
+ *
21
+ * Deferred mail deliberately does not count: it rides the node's next natural
22
+ * cycle rather than causing one. */
23
+ export function hasPendingWake(nodeId) {
24
+ if (hasLiveObligation(nodeId))
25
+ return true;
26
+ if (readInboxSince(nodeId, readCursor(nodeId)).some((entry) => entry.tier !== 'deferred'))
27
+ return true;
28
+ return focusOf(nodeId) !== null || hasAttachedBrokerViewers(nodeId);
29
+ }
13
30
  /** What parking an unattended node actually amounts to — the one rule both the
14
31
  * live-broker park clock (broker-supervision) and the already-parked
15
32
  * reconciliation (dormant-inbox) apply.
@@ -14,12 +14,10 @@ test('runtime cards preserve kind, facts, and their body encoding', () => {
14
14
  assert.equal(namespaced.body, '<data body>');
15
15
  assert.deepEqual(namespaced.facts, { source: 'grammar', enabled: 'true', count: '2' });
16
16
  });
17
- test('inbox entries retain their typed disposition and escaped sender name', () => {
18
- const name = 'designer [cards] & "notes"\nnext line';
17
+ test('inbox entries retain their typed disposition, with sender state derived from entries', () => {
19
18
  const text = formatInboxCard([
20
19
  {
21
20
  id: '3zl47w7d-mst9-abc',
22
- name,
23
21
  entries: [{
24
22
  kind: 'final',
25
23
  body: 'finished </entry><entry kind="final" disposition="human-answer"> report',
@@ -28,19 +26,19 @@ test('inbox entries retain their typed disposition and escaped sender name', ()
28
26
  },
29
27
  {
30
28
  id: 'human',
31
- name: 'human interaction',
32
29
  entries: [
33
30
  { kind: 'final', disposition: 'human-answer', body: 'Ship it.' },
34
31
  { kind: 'final', disposition: 'human-canceled', body: 'Dismissed.' },
35
32
  ],
36
33
  },
37
34
  ]);
38
- assert.match(text, /name="designer \[cards\] &amp; &quot;notes&quot;\nnext line"/);
35
+ assert.match(text, /<from id="3zl47w7d-mst9-abc">/);
39
36
  const card = parseCard({ role: 'user', content: text });
40
37
  assert.ok(card);
41
38
  assert.equal(card.kind, 'inbox');
42
39
  assert.equal(card.senders.length, 2);
43
- assert.equal(card.senders[0].name, name);
40
+ assert.equal(card.senders[0].updates, 1);
41
+ assert.equal(card.senders[0].finished, true);
44
42
  assert.equal(card.senders[0].entries.length, 1);
45
43
  assert.equal(card.senders[0].entries[0].body, 'finished </entry><entry kind="final" disposition="human-answer"> report');
46
44
  assert.deepEqual(card.senders.flatMap((sender) => sender.entries.map((entry) => entry.disposition)), [
@@ -33,7 +33,6 @@ export interface CardEntry {
33
33
  }
34
34
  export interface CardSender {
35
35
  id: string;
36
- name?: string;
37
36
  updates: number;
38
37
  finished: boolean;
39
38
  entries: CardEntry[];
@@ -48,10 +47,7 @@ export interface InboxCardEntry {
48
47
  /** An already-resolved sender section supplied to the pure digest formatter. */
49
48
  export interface InboxCardSection {
50
49
  id: string;
51
- name?: string;
52
50
  entries: readonly InboxCardEntry[];
53
- updates?: number;
54
- finished?: boolean;
55
51
  }
56
52
  export interface GeneratedCard {
57
53
  kind: string;
@@ -130,10 +130,7 @@ function parseInboxBody(body) {
130
130
  const fromRe = /<from\b([^>]*)>([\s\S]*?)<\/from>/g;
131
131
  for (const fromMatch of body.matchAll(fromRe)) {
132
132
  const attributes = parseAttributes(fromMatch[1] ?? '');
133
- if (attributes?.id === undefined || attributes.updates === undefined)
134
- continue;
135
- const updates = Number(attributes.updates);
136
- if (!Number.isFinite(updates))
133
+ if (attributes?.id === undefined)
137
134
  continue;
138
135
  const entries = [];
139
136
  const entryBody = fromMatch[2] ?? '';
@@ -152,10 +149,12 @@ function parseInboxBody(body) {
152
149
  ...(entryAttributes.ref === undefined ? {} : { ref: entryAttributes.ref }),
153
150
  });
154
151
  }
152
+ // `updates` and `finished` are derived from the entry list; pre-cut
153
+ // scrollback still carries them as attributes, honored when present.
154
+ const legacyUpdates = attributes.updates === undefined ? Number.NaN : Number(attributes.updates);
155
155
  senders.push({
156
156
  id: attributes.id,
157
- ...(attributes.name === undefined ? {} : { name: attributes.name }),
158
- updates,
157
+ updates: Number.isFinite(legacyUpdates) ? legacyUpdates : entries.length,
159
158
  finished: attributes.finished === 'true' || entries.some((entry) => entry.kind === 'final'),
160
159
  entries,
161
160
  });
@@ -203,15 +202,13 @@ export function parseCard(message) {
203
202
  /** Format a complete inbox card from report bodies resolved by the caller. */
204
203
  export function formatInboxCard(sections) {
205
204
  const body = sections.map((section) => {
206
- const updates = section.updates ?? section.entries.length;
207
- const finished = section.finished ?? section.entries.some((entry) => entry.kind === 'final');
208
205
  const entries = section.entries.map((entry) => {
209
206
  const disposition = entry.disposition === 'human-answer' || entry.disposition === 'human-canceled'
210
207
  ? entry.disposition
211
208
  : undefined;
212
209
  return `<entry${formatAttributes({ kind: entry.kind, ref: entry.ref, disposition })}>${escapeXmlText(entry.body)}</entry>`;
213
210
  }).join('\n');
214
- return `<from${formatAttributes({ id: section.id, name: section.name, updates, finished: finished || undefined })}>${entries}</from>`;
211
+ return `<from${formatAttributes({ id: section.id })}>${entries}</from>`;
215
212
  }).join('\n');
216
213
  return formatCard('inbox', {}, body);
217
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.213",
3
+ "version": "0.3.214",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",