@north-light/crouter 0.3.269 → 0.3.271

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 (78) hide show
  1. package/dist/api/dto/broker.d.ts +11 -2
  2. package/dist/api/dto/broker.js +2 -2
  3. package/dist/api/dto/nodes.d.ts +4 -0
  4. package/dist/commands/pkg/plugin-inspect.js +2 -2
  5. package/dist/commands/pkg/plugin-manage.js +3 -3
  6. package/dist/commands/sys/doctor.js +4 -3
  7. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +2 -2
  8. package/dist/core/__tests__/helpers/harness.js +1 -0
  9. package/dist/core/__tests__/human-deliver.test.js +1 -1
  10. package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
  11. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
  12. package/dist/core/__tests__/integration/revive.test.js +15 -15
  13. package/dist/core/__tests__/push-final-guard.test.js +1 -1
  14. package/dist/core/__tests__/revive-capacity.test.js +20 -20
  15. package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
  16. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
  17. package/dist/core/__tests__/seam/broker-provider-retry.test.js +2 -0
  18. package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
  19. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
  20. package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
  21. package/dist/core/command-hooks/discovery.d.ts +27 -1
  22. package/dist/core/command-hooks/discovery.js +52 -0
  23. package/dist/core/command-hooks/index.d.ts +4 -3
  24. package/dist/core/command-hooks/index.js +2 -1
  25. package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
  26. package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
  27. package/dist/core/command-hooks/report.d.ts +11 -2
  28. package/dist/core/command-hooks/report.js +10 -1
  29. package/dist/core/command-hooks/schema.d.ts +16 -1
  30. package/dist/core/command-hooks/schema.js +128 -40
  31. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
  32. package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
  33. package/dist/core/human/feedback-companion.js +1 -1
  34. package/dist/core/review/realize.js +1 -1
  35. package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
  36. package/dist/core/runtime/broker/engine-drive.js +29 -1
  37. package/dist/core/runtime/broker/frame-client.js +3 -4
  38. package/dist/core/runtime/broker/rebind.js +7 -0
  39. package/dist/core/runtime/broker-protocol.d.ts +11 -2
  40. package/dist/core/runtime/broker.d.ts +1 -1
  41. package/dist/core/runtime/broker.js +13 -3
  42. package/dist/core/runtime/fleet.d.ts +5 -6
  43. package/dist/core/runtime/node-read.d.ts +2 -0
  44. package/dist/core/runtime/node-read.js +18 -9
  45. package/dist/core/runtime/nodes.js +6 -1
  46. package/dist/core/runtime/revive-all.d.ts +1 -1
  47. package/dist/core/runtime/revive-all.js +2 -2
  48. package/dist/core/runtime/revive.d.ts +2 -2
  49. package/dist/core/runtime/revive.js +49 -13
  50. package/dist/core/runtime/session-visibility.d.ts +10 -14
  51. package/dist/core/runtime/session-visibility.js +43 -30
  52. package/dist/core/runtime/stamp/channel.d.ts +10 -6
  53. package/dist/core/runtime/stamp/channel.js +16 -7
  54. package/dist/core/runtime/stamp/protocol.d.ts +4 -0
  55. package/dist/core/runtime/turn-visibility.d.ts +10 -0
  56. package/dist/core/runtime/turn-visibility.js +31 -0
  57. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  58. package/dist/daemon/api/bridge.js +1 -1
  59. package/dist/daemon/api/handlers/attach.js +4 -4
  60. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  61. package/dist/daemon/api/handlers/messages.js +2 -2
  62. package/dist/daemon/api/handlers/nodes.js +7 -4
  63. package/dist/daemon/cron/sinks.js +3 -3
  64. package/dist/daemon/fleet.js +10 -22
  65. package/dist/daemon/messaging/node-message.js +1 -1
  66. package/dist/daemon/profile-delete.js +1 -1
  67. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
  68. package/dist/hook-authoring.d.ts +31 -1
  69. package/dist/hook-authoring.js +42 -5
  70. package/dist/index.d.ts +1 -0
  71. package/dist/index.js +3 -0
  72. package/dist/pi-extensions/canvas-stamp.js +3 -3
  73. package/dist/shared/env.d.ts +2 -0
  74. package/dist/shared/env.js +4 -0
  75. package/dist/shared/generated-context.d.ts +1 -2
  76. package/dist/shared/generated-context.js +3 -4
  77. package/package.json +1 -1
  78. package/runtime.lock.json +2 -2
@@ -43,6 +43,9 @@ import { rootOfSpine } from './nodes.js';
43
43
  import { isReviewCompanionBound } from '../review/companion.js';
44
44
  import { assertProfileActive } from '../profiles/manifest.js';
45
45
  import { isPidAlive } from '../canvas/pid.js';
46
+ import { crtrHome, contextDir, ensureNodeDirs, jobDir, nodeDir, reportsDir } from '../canvas/paths.js';
47
+ import { discoverLifecycleHookRegistry } from '../command-hooks/discovery.js';
48
+ import { invokeLifecycleHooks } from '../command-hooks/transport/exec-lifecycle.js';
46
49
  // resumeArgs — which session source a revive resumes from
47
50
  /** Pick the `--session` source for a revive. Both resume=true (a true resume)
48
51
  * and resume=false (a refresh-yield) resume by the absolute session-file path
@@ -140,8 +143,8 @@ export function hasFreshGroundState(nodeId) {
140
143
  * Throws if the node does not exist. All other failures propagate as-is —
141
144
  * callers (daemon, command) decide how to handle.
142
145
  */
143
- export function reviveNode(nodeId, opts) {
144
- const decision = decideRevive(nodeId, opts, true);
146
+ export async function reviveNode(nodeId, opts) {
147
+ const decision = await decideRevive(nodeId, opts, true);
145
148
  if (decision.parked)
146
149
  throw new Error(`reviveNode: internal parked decision for activating revive ${nodeId}`);
147
150
  return decision.result;
@@ -150,10 +153,10 @@ export function reviveNode(nodeId, opts) {
150
153
  * to wake an exact parked resident while preserving the ordinary activating
151
154
  * attach default. The fleet check and parked decision are synchronous with the
152
155
  * launch below, so an already-running broker wins before dormancy is observed. */
153
- export function decideAttachRevive(nodeId, opts, wakeParked) {
156
+ export async function decideAttachRevive(nodeId, opts, wakeParked) {
154
157
  return decideRevive(nodeId, opts, wakeParked);
155
158
  }
156
- function decideRevive(nodeId, opts, wakeParked) {
159
+ async function decideRevive(nodeId, opts, wakeParked) {
157
160
  let meta = getNode(nodeId);
158
161
  if (meta === null) {
159
162
  throw new Error(`reviveNode: unknown node ${nodeId}`);
@@ -169,13 +172,10 @@ function decideRevive(nodeId, opts, wakeParked) {
169
172
  throw new Error(`reviveNode: refusing to revive ${nodeId} — its finalization latch is set (final_report=${meta.final_report}). A final landed between the caller's reopen-gate check and this revive; re-run with --reopen if this retask is still wanted.`);
170
173
  }
171
174
  assertProfileActive(meta.profile_id);
172
- // Double-launch guard: a fleet entry IS liveness (design D-1). reviveNode
173
- // runs fully SYNCHRONOUSLY on the daemon thread — this check, the launch,
174
- // and the fleet registration inside `headlessBrokerHost.launch` complete
175
- // with no awaited gap, so no second contender can interleave past this
176
- // check before the first's registration lands. A node with a live fleet
177
- // entry was already revived by another path — re-launching would put a
178
- // SECOND broker on the same session file.
175
+ // Double-launch guard: a fleet entry IS liveness (design D-1). A lifecycle
176
+ // hook is awaited before the broker can register, so its reservation also
177
+ // owns the node while launch is in flight; a second contender must not cross
178
+ // that awaited gap and start a second broker on the same session file.
179
179
  const fleet = boundFleet();
180
180
  if (fleet.has(nodeId)) {
181
181
  // The node is already running, but the ROW may still say terminal: a reopen
@@ -193,6 +193,17 @@ function decideRevive(nodeId, opts, wakeParked) {
193
193
  },
194
194
  };
195
195
  }
196
+ if (fleet.isClaimed(nodeId)) {
197
+ return {
198
+ parked: false,
199
+ result: {
200
+ window: null,
201
+ session: meta.tmux_session ?? null,
202
+ resumed: false,
203
+ outcome: 'already-live',
204
+ },
205
+ };
206
+ }
196
207
  // Focus may establish a viewport without establishing an engine. This exact
197
208
  // row has no final latch and no fleet owner; returning before reservation is
198
209
  // what keeps lifecycle, cycle count, pid, inbox cursor, and session untouched.
@@ -236,7 +247,7 @@ function decideRevive(nodeId, opts, wakeParked) {
236
247
  };
237
248
  }
238
249
  try {
239
- return { parked: false, result: launchRevive(nodeId, meta, opts) };
250
+ return { parked: false, result: await launchRevive(nodeId, meta, opts) };
240
251
  }
241
252
  finally {
242
253
  // The slot is either consumed by the fleet registration inside `launch`
@@ -248,7 +259,7 @@ function decideRevive(nodeId, opts, wakeParked) {
248
259
  }
249
260
  /** The launch itself, from the cwd preflight through `recordPid`. Split out so
250
261
  * the capacity claim above has exactly one release path around it. */
251
- function launchRevive(nodeId, meta, opts) {
262
+ async function launchRevive(nodeId, meta, opts) {
252
263
  // Preflight, before any state mutation below: a recorded cwd that no longer
253
264
  // exists (e.g. a closed managed worktree whose tombstone dir was later
254
265
  // reaped) can never host a launch. Fail loud with a typed, user-visible
@@ -267,6 +278,10 @@ function launchRevive(nodeId, meta, opts) {
267
278
  next,
268
279
  });
269
280
  }
281
+ // This is the same durable distinction used to decide between a birth prompt
282
+ // and a revive kickoff below. Compute it before launch mutations so a stale
283
+ // session identity cannot make an ordinary fresh revive look like a birth.
284
+ const isBirth = isUnstartedBirth(nodeId);
270
285
  // Lazy host_kind coerce (§C): every launch now uses the broker host. Persist
271
286
  // that identity only after this call has passed the no-op liveness guard.
272
287
  if (meta.host_kind !== 'broker') {
@@ -452,6 +467,27 @@ function launchRevive(nodeId, meta, opts) {
452
467
  // `crash` transition (a deadline MUST survive instance death).
453
468
  if (opts.recovery !== true)
454
469
  cancelCronsOnWake(nodeId);
470
+ ensureNodeDirs(nodeId);
471
+ const lifecycleHooks = discoverLifecycleHookRegistry(meta.cwd, meta.profile_id).plans.get('node:start') ?? [];
472
+ await invokeLifecycleHooks(lifecycleHooks, {
473
+ node: {
474
+ id: nodeId,
475
+ name: meta.name,
476
+ kind: meta.kind,
477
+ mode: meta.mode,
478
+ lifecycle: meta.lifecycle,
479
+ cwd: meta.cwd,
480
+ nodeDir: nodeDir(nodeId),
481
+ contextDir: contextDir(nodeId),
482
+ jobDir: jobDir(nodeId),
483
+ reportsDir: reportsDir(nodeId),
484
+ },
485
+ runtime: {
486
+ isBirth,
487
+ canvasHome: crtrHome(),
488
+ profile: meta.profile_id ?? null,
489
+ },
490
+ });
455
491
  let launched;
456
492
  try {
457
493
  launched = headlessBrokerHost.launch(nodeId, inv, {
@@ -1,30 +1,26 @@
1
1
  import type { WireSessionTreeNode } from './broker-protocol.js';
2
2
  import type { IdentifiedMessage } from './session-cycles.js';
3
+ import { type MessageVisibility } from './stamp/protocol.js';
3
4
  import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
4
5
  export { REVIEW_BOUNDARY_CUSTOM_TYPE };
6
+ export type ProjectedMessage = IdentifiedMessage & {
7
+ visibility: MessageVisibility;
8
+ };
5
9
  /** True when an entry is a durable review boundary, optionally for one review. */
6
10
  export declare function isReviewBoundary(entry: unknown, reviewId?: string): boolean;
7
11
  /**
8
- * Project identified snapshot messages to the visible window for a review companion.
12
+ * Project identified snapshot messages for transcript presentation.
9
13
  *
10
- * A companion passes its binding's review id so the slice starts at ITS OWN
14
+ * Every retained message carries its durable visibility classification. A
15
+ * companion then passes its binding's review id so its slice starts at ITS OWN
11
16
  * boundary marker: a node forked from another companion inherits that
12
17
  * companion's earlier marker, and slicing at the first marker of any review
13
18
  * would reveal the origin companion's transcript.
14
- *
15
- * Contract (in evaluation order):
16
- * 1. This binding's marker present (any marker when the node has no binding) →
17
- * slice from the **first** occurrence (marker included) to the end.
18
- * Everything before the marker is inherited context, hidden from the person.
19
- * 2. Not a companion (`boundaryReviewId` undefined) → return input unchanged
20
- * (same reference). Ordinary nodes are not filtered.
21
- * 3. Bound but no matching marker → a leading compaction summary is replaced
22
- * with a synthetic boundary while its retained tail remains visible. Every
23
- * other markerless shape fails closed with the synthetic boundary alone.
24
19
  */
25
- export declare function visibleMessages(messages: IdentifiedMessage[], opts: {
20
+ export declare function projectTranscript(messages: IdentifiedMessage[], opts: {
26
21
  boundaryReviewId: string | undefined;
27
- }): IdentifiedMessage[];
22
+ sessionEntries?: readonly unknown[];
23
+ }): ProjectedMessage[];
28
24
  /**
29
25
  * Project a session tree to the review companion's visible transcript boundary.
30
26
  *
@@ -1,14 +1,7 @@
1
- // session-visibility.ts — transcript boundary projection for review companions.
2
- //
3
- // The companion node inherits the origin's full session context but the person
4
- // must see only the transcript from the moment the review started. A durable
5
- // marker (the boundary message) is appended to the session before the first
6
- // human-visible message, and this projection uses it to slice the visible
7
- // window.
8
- //
9
- // Applied at the two BrokerSnapshot.messages producers (live broker and
10
- // dormant node read) so every presenter — attach, web node viewer, inspect — is
11
- // boundary-safe by construction with no presenter-side changes.
1
+ // session-visibility.ts — the transcript projection shared by live and dormant
2
+ // reads. Raw session entries remain authoritative; durable stamps and review
3
+ // boundaries select which reconstructed messages a person can see.
4
+ import { readStamps, STAMP_SCHEMA_VERSION, } from './stamp/protocol.js';
12
5
  import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
13
6
  export { REVIEW_BOUNDARY_CUSTOM_TYPE };
14
7
  /** True when an entry is a durable review boundary, optionally for one review. */
@@ -29,35 +22,55 @@ function syntheticBoundary(timestamp) {
29
22
  timestamp: timestamp ?? Date.now(),
30
23
  };
31
24
  }
25
+ function classifyMessages(messages, sessionEntries) {
26
+ const internalByTimestamp = new Map();
27
+ for (const stamp of readStamps(sessionEntries ?? [])) {
28
+ if (stamp.kind !== 'message' || stamp.v !== STAMP_SCHEMA_VERSION)
29
+ continue;
30
+ const payload = stamp.payload;
31
+ if (typeof payload.msgTs !== 'number')
32
+ continue;
33
+ // Last stamp wins, matching every other message-stamp reader.
34
+ internalByTimestamp.set(payload.msgTs, payload.visibility === 'internal');
35
+ }
36
+ let visibility = 'visible';
37
+ return messages.map((identified) => {
38
+ const { message } = identified;
39
+ if (message.role === 'user') {
40
+ visibility = typeof message.timestamp === 'number'
41
+ && internalByTimestamp.get(message.timestamp) === true
42
+ ? 'internal'
43
+ : 'visible';
44
+ }
45
+ return { ...identified, visibility };
46
+ });
47
+ }
32
48
  /**
33
- * Project identified snapshot messages to the visible window for a review companion.
49
+ * Project identified snapshot messages for transcript presentation.
34
50
  *
35
- * A companion passes its binding's review id so the slice starts at ITS OWN
51
+ * Every retained message carries its durable visibility classification. A
52
+ * companion then passes its binding's review id so its slice starts at ITS OWN
36
53
  * boundary marker: a node forked from another companion inherits that
37
54
  * companion's earlier marker, and slicing at the first marker of any review
38
55
  * would reveal the origin companion's transcript.
39
- *
40
- * Contract (in evaluation order):
41
- * 1. This binding's marker present (any marker when the node has no binding) →
42
- * slice from the **first** occurrence (marker included) to the end.
43
- * Everything before the marker is inherited context, hidden from the person.
44
- * 2. Not a companion (`boundaryReviewId` undefined) → return input unchanged
45
- * (same reference). Ordinary nodes are not filtered.
46
- * 3. Bound but no matching marker → a leading compaction summary is replaced
47
- * with a synthetic boundary while its retained tail remains visible. Every
48
- * other markerless shape fails closed with the synthetic boundary alone.
49
56
  */
50
- export function visibleMessages(messages, opts) {
51
- const markerIndex = messages.findIndex(({ message }) => isReviewBoundary(message, opts.boundaryReviewId));
57
+ export function projectTranscript(messages, opts) {
58
+ const projected = classifyMessages(messages, opts.sessionEntries);
59
+ const markerIndex = projected.findIndex(({ message }) => isReviewBoundary(message, opts.boundaryReviewId));
52
60
  if (markerIndex !== -1)
53
- return messages.slice(markerIndex);
61
+ return projected.slice(markerIndex);
54
62
  if (opts.boundaryReviewId === undefined)
55
- return messages;
56
- const [first, ...retainedTail] = messages;
63
+ return projected;
64
+ const [first, ...retainedTail] = projected;
65
+ const boundary = {
66
+ id: '~boundary',
67
+ message: syntheticBoundary(first?.message.timestamp),
68
+ visibility: 'visible',
69
+ };
57
70
  if (first?.message.role === 'compactionSummary') {
58
- return [{ id: '~boundary', message: syntheticBoundary(first.message.timestamp) }, ...retainedTail];
71
+ return [boundary, ...retainedTail];
59
72
  }
60
- return [{ id: '~boundary', message: syntheticBoundary(first?.message.timestamp) }];
73
+ return [boundary];
61
74
  }
62
75
  /**
63
76
  * Project a session tree to the review companion's visible transcript boundary.
@@ -1,13 +1,17 @@
1
- import type { MessageOrigin } from './protocol.js';
1
+ import type { MessageOrigin, MessageVisibility } from './protocol.js';
2
+ export interface MessageAnnouncement {
3
+ origin: MessageOrigin;
4
+ visibility?: MessageVisibility;
5
+ }
2
6
  /** The correlation key for one message body. Announcers pass the text they are
3
7
  * about to send; the writer passes the text pi persisted. */
4
8
  export declare function messageSignature(text: string): string;
5
- /** Announce the origin of the user message `text` that is about to be sent.
6
- * Call immediately before the send. */
7
- export declare function announceMessageOrigin(text: string, origin: MessageOrigin): void;
8
- /** Take the announced origin for a persisted message body, or null when no
9
+ /** Announce the origin and visibility of the user message `text` that is about
10
+ * to be sent. Every ordinary announcement promotes the run to visible. */
11
+ export declare function announceMessageOrigin(text: string, origin: MessageOrigin, visibility?: MessageVisibility): void;
12
+ /** Take the announced metadata for a persisted message body, or null when no
9
13
  * seam announced this message. */
10
- export declare function claimMessageOrigin(text: string): MessageOrigin | null;
14
+ export declare function claimMessageAnnouncement(text: string): MessageAnnouncement | null;
11
15
  /** Contribute namespaced fields to the next stamp the writer emits. Unknown
12
16
  * namespaces pass through untouched; the core writer never interprets them. */
13
17
  export declare function contributeStampExt(namespace: string, fields: Record<string, unknown>): void;
@@ -14,6 +14,7 @@
14
14
  // (a delivery the engine refused) therefore cannot leak its origin onto a later
15
15
  // message; it simply ages out of the bounded queue.
16
16
  import { createHash } from 'node:crypto';
17
+ import { setTurnVisibility } from '../turn-visibility.js';
17
18
  const STAMP_CHANNEL = Symbol.for('@crouton-kit/crtr:stamp-channel');
18
19
  /** Unclaimed announcements held at once. The queue is a correlation buffer, not
19
20
  * a work queue: one pending announcement is the normal state. */
@@ -32,24 +33,32 @@ function channel() {
32
33
  export function messageSignature(text) {
33
34
  return createHash('sha1').update(text.trim()).digest('hex');
34
35
  }
35
- /** Announce the origin of the user message `text` that is about to be sent.
36
- * Call immediately before the send. */
37
- export function announceMessageOrigin(text, origin) {
36
+ /** Announce the origin and visibility of the user message `text` that is about
37
+ * to be sent. Every ordinary announcement promotes the run to visible. */
38
+ export function announceMessageOrigin(text, origin, visibility) {
39
+ setTurnVisibility(visibility ?? 'visible');
38
40
  const state = channel();
39
- state.announcements.push({ signature: messageSignature(text), origin });
41
+ state.announcements.push({
42
+ signature: messageSignature(text),
43
+ origin,
44
+ ...(visibility === undefined ? {} : { visibility }),
45
+ });
40
46
  while (state.announcements.length > MAX_PENDING)
41
47
  state.announcements.shift();
42
48
  }
43
- /** Take the announced origin for a persisted message body, or null when no
49
+ /** Take the announced metadata for a persisted message body, or null when no
44
50
  * seam announced this message. */
45
- export function claimMessageOrigin(text) {
51
+ export function claimMessageAnnouncement(text) {
46
52
  const state = channel();
47
53
  const signature = messageSignature(text);
48
54
  const index = state.announcements.findIndex((entry) => entry.signature === signature);
49
55
  if (index < 0)
50
56
  return null;
51
57
  const [claimed] = state.announcements.splice(index, 1);
52
- return claimed.origin;
58
+ return {
59
+ origin: claimed.origin,
60
+ ...(claimed.visibility === undefined ? {} : { visibility: claimed.visibility }),
61
+ };
53
62
  }
54
63
  /** Contribute namespaced fields to the next stamp the writer emits. Unknown
55
64
  * namespaces pass through untouched; the core writer never interprets them. */
@@ -52,12 +52,16 @@ export interface RuntimeOrigin {
52
52
  [field: string]: unknown;
53
53
  }
54
54
  export type MessageOrigin = HumanOrigin | RuntimeOrigin;
55
+ export type MessageVisibility = 'visible' | 'internal';
55
56
  export interface MessageStampPayload {
56
57
  /** The target message's own `timestamp` (unix ms) — the join key. */
57
58
  msgTs: number;
58
59
  /** Absent means the message arrived with no announced origin (unknown),
59
60
  * never a guess. */
60
61
  origin?: MessageOrigin;
62
+ /** Absent means visible. Internal messages remain in the raw session and
63
+ * carry their classification through transcript projections. */
64
+ visibility?: MessageVisibility;
61
65
  }
62
66
  export type SessionStamp = StampEnvelope<'session', SessionStampPayload>;
63
67
  export type MessageStamp = StampEnvelope<'message', MessageStampPayload>;
@@ -0,0 +1,10 @@
1
+ import type { MessageVisibility } from './stamp/protocol.js';
2
+ export type TurnVisibility = MessageVisibility;
3
+ /** The presentation classification of the current agent run. */
4
+ export declare function currentTurnVisibility(): TurnVisibility;
5
+ /** Whether the current agent run is structurally internal to crouter. */
6
+ export declare function isTurnInternal(): boolean;
7
+ /** Set visibility before handing an admitted input to pi. */
8
+ export declare function setTurnVisibility(visibility: TurnVisibility): void;
9
+ /** Return to the fail-open state after the complete agent run settles. */
10
+ export declare function clearTurnVisibility(): void;
@@ -0,0 +1,31 @@
1
+ // Process-global visibility for the agent run currently owned by the broker.
2
+ //
3
+ // The broker is native ESM while guest and crouter pi extensions are Jiti-loaded,
4
+ // so ordinary module state splits at the loader boundary. A registered symbol
5
+ // keeps crouter's decision visible to every in-process extension.
6
+ const TURN_VISIBILITY = Symbol.for('@crouton-kit/crtr:turn-visibility');
7
+ function state() {
8
+ const host = globalThis;
9
+ const existing = host[TURN_VISIBILITY];
10
+ if (existing !== undefined)
11
+ return existing;
12
+ const created = { value: 'visible' };
13
+ host[TURN_VISIBILITY] = created;
14
+ return created;
15
+ }
16
+ /** The presentation classification of the current agent run. */
17
+ export function currentTurnVisibility() {
18
+ return state().value;
19
+ }
20
+ /** Whether the current agent run is structurally internal to crouter. */
21
+ export function isTurnInternal() {
22
+ return currentTurnVisibility() === 'internal';
23
+ }
24
+ /** Set visibility before handing an admitted input to pi. */
25
+ export function setTurnVisibility(visibility) {
26
+ state().value = visibility;
27
+ }
28
+ /** Return to the fail-open state after the complete agent run settles. */
29
+ export function clearTurnVisibility() {
30
+ state().value = 'visible';
31
+ }
@@ -62,7 +62,7 @@ test('create rejects a paused profile before a node exists', async () => {
62
62
  });
63
63
  assert.equal(listNodes().length, beforeRows);
64
64
  });
65
- test('revive refuses a paused profile before launching a broker', () => {
65
+ test('revive refuses a paused profile before launching a broker', async () => {
66
66
  const profile = createProfile('paused revive', [{ path: cwd, memory: 'content' }]);
67
67
  pauseProfile(profile.profileId);
68
68
  const nodeId = 'paused-revive-node';
@@ -78,7 +78,7 @@ test('revive refuses a paused profile before launching a broker', () => {
78
78
  parent: null,
79
79
  profile_id: profile.profileId,
80
80
  });
81
- assert.throws(() => reviveNode(nodeId, { resume: true, capacity: 'freeze' }), (error) => {
81
+ await assert.rejects(reviveNode(nodeId, { resume: true, capacity: 'freeze' }), (error) => {
82
82
  assert.ok(error instanceof CrtrError);
83
83
  assert.equal(error.code, 'usage');
84
84
  assert.match(error.details?.['next'], new RegExp(`crtr profile resume ${profile.profileId}`));
@@ -384,7 +384,7 @@ export async function handleAttachUpgrade(req, socket, head, nodeId) {
384
384
  // offline messages route cannot go stale, and a message sent to the
385
385
  // node still revives it by the ordinary path. A person attaching
386
386
  // deliberately says so with `?wakeParked=1`.
387
- const decision = decideAttachRevive(nodeId, { resume: true, capacity: 'refuse' }, wakeParkedRequested(req));
387
+ const decision = await decideAttachRevive(nodeId, { resume: true, capacity: 'refuse' }, wakeParkedRequested(req));
388
388
  if (decision.parked) {
389
389
  refuseUpgrade(socket, 409, 'Conflict', 'node_dormant', `node ${nodeId} is parked; attach with ?wakeParked=1 to wake it`);
390
390
  return;
@@ -37,10 +37,10 @@ async function handleAttach(ctx) {
37
37
  let resumed = false;
38
38
  let parked = false;
39
39
  if (revive) {
40
- // This decision and reviveNode's fleet guard share one synchronous launch
41
- // boundary. Focus may stop at an exact parked resident; ordinary/direct
42
- // attach keeps wakeParked=true and preserves attach-implies-revive.
43
- const decision = decideAttachRevive(id, { resume, capacity: 'refuse' }, wakeParked);
40
+ // This decision and reviveNode's fleet claim share one launch boundary.
41
+ // Focus may stop at an exact parked resident; ordinary/direct attach keeps
42
+ // wakeParked=true and preserves attach-implies-revive.
43
+ const decision = await decideAttachRevive(id, { resume, capacity: 'refuse' }, wakeParked);
44
44
  parked = decision.parked;
45
45
  if (!decision.parked) {
46
46
  revived = decision.result.launch !== undefined;
@@ -79,7 +79,7 @@ async function handleStop(ctx) {
79
79
  const node = getNode(nodeId);
80
80
  if (node !== null && !isBrokerLive(node)) {
81
81
  try {
82
- reviveNode(nodeId, { resume: true, capacity: 'freeze' });
82
+ await reviveNode(nodeId, { resume: true, capacity: 'freeze' });
83
83
  }
84
84
  catch { /* the notice remains durable */ }
85
85
  }
@@ -224,7 +224,7 @@ async function handleMessage(ctx) {
224
224
  commitReopen(id, expectedFinalReport);
225
225
  // A fresh revive appends no inbox entry, so nothing survives a freeze for
226
226
  // the daemon to act on later: at the cap the caller is told, not ignored.
227
- const result = reviveNode(id, { resume: false, capacity: 'refuse' });
227
+ const result = await reviveNode(id, { resume: false, capacity: 'refuse' });
228
228
  const body = {
229
229
  node_id: id,
230
230
  delivered: false,
@@ -351,7 +351,7 @@ async function handleMessage(ctx) {
351
351
  // The entry is already durable, so a wake denied for capacity is only
352
352
  // delayed: the row freezes and the tick relaunches it — at which point
353
353
  // the inbox watcher delivers this entry as it always would.
354
- const result = reviveNode(id, { resume: true, capacity: 'freeze' });
354
+ const result = await reviveNode(id, { resume: true, capacity: 'freeze' });
355
355
  revived = result.launch !== undefined;
356
356
  frozen = result.outcome === 'frozen';
357
357
  }
@@ -374,6 +374,8 @@ async function handleSnapshot(ctx) {
374
374
  snapshot: {
375
375
  messages: read.snapshot.messages,
376
376
  ...(read.snapshot.messageIds === undefined ? {} : { messageIds: read.snapshot.messageIds }),
377
+ messageVisibility: read.snapshot.messageVisibility,
378
+ turnVisibility: read.snapshot.turnVisibility,
377
379
  stats: read.snapshot.stats,
378
380
  state: read.snapshot.state,
379
381
  display: read.snapshot.display,
@@ -423,6 +425,7 @@ async function handleMessages(ctx) {
423
425
  node_id: id,
424
426
  messages: read.messages,
425
427
  ...(read.messageIds === undefined ? {} : { message_ids: read.messageIds }),
428
+ message_visibility: read.messageVisibility,
426
429
  next_cursor: read.nextCursor,
427
430
  captured_at: nowIso(),
428
431
  };
@@ -491,7 +494,7 @@ async function handleFork(ctx) {
491
494
  const result = await forkNode(id);
492
495
  return { status: 201, body: detailOf(result.node.node_id) };
493
496
  }
494
- function handleRevive(ctx) {
497
+ async function handleRevive(ctx) {
495
498
  const id = ctx.params['id'];
496
499
  requireMeta(id);
497
500
  const body = ctx.body;
@@ -507,7 +510,7 @@ function handleRevive(ctx) {
507
510
  // rather than a message. A cron id that no longer resolves (canceled or
508
511
  // consumed mid-run) simply yields no block — prose, never an error.
509
512
  const cron = !resume && body?.cron_id !== undefined ? getCron(body.cron_id) : null;
510
- const result = reviveNode(id, {
513
+ const result = await reviveNode(id, {
511
514
  resume,
512
515
  // An explicit revive is a caller asking for this node NOW; at the cap it
513
516
  // gets the refusal naming it rather than a silent nothing-happened.
@@ -629,8 +632,8 @@ function handleWait(ctx) {
629
632
  setMessageWait(id, req.controller);
630
633
  return { status: 200, body: detailOf(id) };
631
634
  }
632
- function handleReviveAll() {
633
- const result = reviveAll();
635
+ async function handleReviveAll() {
636
+ const result = await reviveAll();
634
637
  const body = {
635
638
  revived: [...result.revived],
636
639
  failed: [...result.failed],
@@ -15,7 +15,7 @@ import { spawnChild } from '../../core/runtime/spawn.js';
15
15
  import { cronWakeOrigin } from '../../core/runtime/bearings.js';
16
16
  import { parseSink } from '../cron-sink.js';
17
17
  import { resolveLiveProfile, resolveLiveProfileName } from './armed-context.js';
18
- function deliverNodeSink(c, target, stdout) {
18
+ async function deliverNodeSink(c, target, stdout) {
19
19
  const meta = getNode(target);
20
20
  if (meta === null)
21
21
  throw new Error(`node sink ${target} is gone`);
@@ -38,7 +38,7 @@ function deliverNodeSink(c, target, stdout) {
38
38
  // The durable append succeeds even if its best-effort wake fails.
39
39
  if (entry.tier !== 'deferred' && !isBrokerLive(meta)) {
40
40
  try {
41
- reviveNode(target, { resume: true, capacity: 'freeze' });
41
+ await reviveNode(target, { resume: true, capacity: 'freeze' });
42
42
  }
43
43
  catch {
44
44
  /* best-effort wake — the entry is durably appended regardless */
@@ -93,7 +93,7 @@ export async function deliverToSink(c, stdout) {
93
93
  if (sink === null)
94
94
  return 'no sink armed — nothing delivered';
95
95
  if (sink.kind === 'node') {
96
- deliverNodeSink(c, sink.node, stdout);
96
+ await deliverNodeSink(c, sink.node, stdout);
97
97
  return `delivered: node ${sink.node}`;
98
98
  }
99
99
  if (sink.kind === 'spawn') {
@@ -122,10 +122,7 @@ export class DaemonFleet {
122
122
  #enqueue;
123
123
  #now;
124
124
  #map = new Map();
125
- /** Slots claimed but not yet held by a registered broker: a launch in flight,
126
- * or a broker that exited and whose exit-policy job has not yet decided
127
- * whether to relaunch it. Counting both against the cap is what keeps a
128
- * 1:1 respawn from losing its own slot to a concurrent launcher. */
125
+ /** Slots claimed by launches that have not registered a broker yet. */
129
126
  #reservations = new Set();
130
127
  #lastCapacityLogAt = Number.NEGATIVE_INFINITY;
131
128
  constructor(opts) {
@@ -191,13 +188,15 @@ export class DaemonFleet {
191
188
  this.#reservations.delete(nodeId);
192
189
  }
193
190
  onChildExit(nodeId, status) {
194
- // Move the slot from the registry to a reservation in ONE synchronous
195
- // step: the exit-policy job about to be enqueued may relaunch this node,
196
- // and holding the slot through the exit observation prevents a concurrent
197
- // launch from entering before the registry reflects the exit.
191
+ // The real child exit is the liveness boundary. Its queued observation only
192
+ // records diagnostics, so it cannot retain capacity after the process is
193
+ // gone and make an explicit replacement look already live.
194
+ const meta = getNode(nodeId);
195
+ const launchedMs = meta?.launched_at == null ? Number.NaN : Date.parse(meta.launched_at);
196
+ const uptimeMs = Number.isFinite(launchedMs) ? Math.max(0, this.#now() - launchedMs) : null;
197
+ const consecutiveFailures = meta?.respawn_failures ?? 0;
198
198
  this.#map.delete(nodeId);
199
- this.#reservations.add(nodeId);
200
- this.#enqueue(() => this.#runExitPolicy(nodeId, status));
199
+ this.#enqueue(() => this.#emitExitObserved(nodeId, status, uptimeMs, consecutiveFailures));
201
200
  }
202
201
  deliverExit(nodeId, status) {
203
202
  this.onChildExit(nodeId, status);
@@ -223,17 +222,6 @@ export class DaemonFleet {
223
222
  return true;
224
223
  }
225
224
  dispose() { }
226
- async #runExitPolicy(nodeId, status) {
227
- try {
228
- const launchedAt = getNode(nodeId)?.launched_at;
229
- const launchedMs = launchedAt == null ? Number.NaN : Date.parse(launchedAt);
230
- const uptimeMs = Number.isFinite(launchedMs) ? Math.max(0, this.#now() - launchedMs) : null;
231
- this.#emitExitObserved(nodeId, status, uptimeMs, getNode(nodeId)?.respawn_failures ?? 0);
232
- }
233
- finally {
234
- this.releaseReservation(nodeId);
235
- }
236
- }
237
225
  #emitExitObserved(nodeId, status, uptimeMs, consecutiveFailures) {
238
226
  emitEvent({
239
227
  level: 'info',
@@ -343,7 +331,7 @@ export class DaemonFleet {
343
331
  },
344
332
  });
345
333
  try {
346
- reviveNode(nodeId, {
334
+ await reviveNode(nodeId, {
347
335
  // A recovery that loses the race for the last slot freezes and waits
348
336
  // for one — the tick thaws it — rather than erroring the node away.
349
337
  capacity: 'freeze',
@@ -87,7 +87,7 @@ export async function deliverNodeMessage(args) {
87
87
  try {
88
88
  // Mail is durable, so a wake denied for capacity waits for a slot
89
89
  // rather than being lost: the row freezes and the tick relaunches it.
90
- const result = reviveNode(args.node_id, { resume: true, capacity: 'freeze' });
90
+ const result = await reviveNode(args.node_id, { resume: true, capacity: 'freeze' });
91
91
  revived = result.launch !== undefined;
92
92
  if (result.outcome === 'frozen')
93
93
  reason = 'capacity_frozen';
@@ -216,7 +216,7 @@ async function detachProfile(plan) {
216
216
  const detached = new Set(detachedNodeIds);
217
217
  for (const nodeId of restartNodeIds) {
218
218
  if (detached.has(nodeId))
219
- reviveNode(nodeId, { resume: true, recovery: true, capacity: 'freeze' });
219
+ await reviveNode(nodeId, { resume: true, recovery: true, capacity: 'freeze' });
220
220
  }
221
221
  return {
222
222
  ...resultForPlan(plan),