@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.
- package/dist/api/dto/broker.d.ts +11 -2
- package/dist/api/dto/broker.js +2 -2
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/commands/pkg/plugin-inspect.js +2 -2
- package/dist/commands/pkg/plugin-manage.js +3 -3
- package/dist/commands/sys/doctor.js +4 -3
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.js +1 -0
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/integration/lifecycle-hooks.test.d.ts +1 -0
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +182 -0
- package/dist/core/__tests__/integration/revive.test.js +15 -15
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/revive-capacity.test.js +20 -20
- package/dist/core/__tests__/revive-parked-fresh.test.js +14 -14
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +2 -2
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +2 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +7 -7
- package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +1 -1
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +2 -2
- package/dist/core/command-hooks/discovery.d.ts +27 -1
- package/dist/core/command-hooks/discovery.js +52 -0
- package/dist/core/command-hooks/index.d.ts +4 -3
- package/dist/core/command-hooks/index.js +2 -1
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +3 -0
- package/dist/core/command-hooks/lifecycle-catalog.js +4 -0
- package/dist/core/command-hooks/report.d.ts +11 -2
- package/dist/core/command-hooks/report.js +10 -1
- package/dist/core/command-hooks/schema.d.ts +16 -1
- package/dist/core/command-hooks/schema.js +128 -40
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +12 -0
- package/dist/core/command-hooks/transport/exec-lifecycle.js +152 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/engine-drive.d.ts +14 -3
- package/dist/core/runtime/broker/engine-drive.js +29 -1
- package/dist/core/runtime/broker/frame-client.js +3 -4
- package/dist/core/runtime/broker/rebind.js +7 -0
- package/dist/core/runtime/broker-protocol.d.ts +11 -2
- package/dist/core/runtime/broker.d.ts +1 -1
- package/dist/core/runtime/broker.js +13 -3
- package/dist/core/runtime/fleet.d.ts +5 -6
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +18 -9
- package/dist/core/runtime/nodes.js +6 -1
- package/dist/core/runtime/revive-all.d.ts +1 -1
- package/dist/core/runtime/revive-all.js +2 -2
- package/dist/core/runtime/revive.d.ts +2 -2
- package/dist/core/runtime/revive.js +49 -13
- package/dist/core/runtime/session-visibility.d.ts +10 -14
- package/dist/core/runtime/session-visibility.js +43 -30
- package/dist/core/runtime/stamp/channel.d.ts +10 -6
- package/dist/core/runtime/stamp/channel.js +16 -7
- package/dist/core/runtime/stamp/protocol.d.ts +4 -0
- package/dist/core/runtime/turn-visibility.d.ts +10 -0
- package/dist/core/runtime/turn-visibility.js +31 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/bridge.js +1 -1
- package/dist/daemon/api/handlers/attach.js +4 -4
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +2 -2
- package/dist/daemon/api/handlers/nodes.js +7 -4
- package/dist/daemon/cron/sinks.js +3 -3
- package/dist/daemon/fleet.js +10 -22
- package/dist/daemon/messaging/node-message.js +1 -1
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +6 -6
- package/dist/hook-authoring.d.ts +31 -1
- package/dist/hook-authoring.js +42 -5
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -0
- package/dist/pi-extensions/canvas-stamp.js +3 -3
- package/dist/shared/env.d.ts +2 -0
- package/dist/shared/env.js +4 -0
- package/dist/shared/generated-context.d.ts +1 -2
- package/dist/shared/generated-context.js +3 -4
- package/package.json +1 -1
- 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).
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
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
|
|
12
|
+
* Project identified snapshot messages for transcript presentation.
|
|
9
13
|
*
|
|
10
|
-
*
|
|
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
|
|
20
|
+
export declare function projectTranscript(messages: IdentifiedMessage[], opts: {
|
|
26
21
|
boundaryReviewId: string | undefined;
|
|
27
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
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
|
|
49
|
+
* Project identified snapshot messages for transcript presentation.
|
|
34
50
|
*
|
|
35
|
-
*
|
|
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
|
|
51
|
-
const
|
|
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
|
|
61
|
+
return projected.slice(markerIndex);
|
|
54
62
|
if (opts.boundaryReviewId === undefined)
|
|
55
|
-
return
|
|
56
|
-
const [first, ...retainedTail] =
|
|
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 [
|
|
71
|
+
return [boundary, ...retainedTail];
|
|
59
72
|
}
|
|
60
|
-
return [
|
|
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
|
|
6
|
-
*
|
|
7
|
-
export declare function announceMessageOrigin(text: string, origin: MessageOrigin): void;
|
|
8
|
-
/** Take the announced
|
|
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
|
|
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
|
|
36
|
-
*
|
|
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({
|
|
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
|
|
49
|
+
/** Take the announced metadata for a persisted message body, or null when no
|
|
44
50
|
* seam announced this message. */
|
|
45
|
-
export function
|
|
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
|
|
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.
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
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') {
|
package/dist/daemon/fleet.js
CHANGED
|
@@ -122,10 +122,7 @@ export class DaemonFleet {
|
|
|
122
122
|
#enqueue;
|
|
123
123
|
#now;
|
|
124
124
|
#map = new Map();
|
|
125
|
-
/** Slots claimed
|
|
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
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// and
|
|
197
|
-
|
|
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.#
|
|
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),
|