@north-light/crouter 0.3.203 → 0.3.205
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 +2 -0
- package/dist/api/dto/nodes.d.ts +8 -5
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/clients/attach/__tests__/context-message.test.js +54 -19
- package/dist/clients/attach/__tests__/page-block.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/page-block.test.js +54 -0
- package/dist/clients/attach/render/chat-view.d.ts +3 -0
- package/dist/clients/attach/render/chat-view.js +56 -27
- package/dist/clients/attach/render/context-message.d.ts +6 -0
- package/dist/clients/attach/render/context-message.js +21 -3
- package/dist/clients/attach/render/group-activity.d.ts +5 -3
- package/dist/clients/attach/render/group-activity.js +10 -6
- package/dist/clients/attach/render/page-block.js +11 -11
- package/dist/clients/attach/viewer.js +717 -703
- package/dist/clients/conversation/projection.js +7 -37
- package/dist/commands/node/create.js +1 -1
- package/dist/commands/profile/project.js +1 -1
- package/dist/commands/profile/show.js +1 -1
- package/dist/commands/profile.js +1 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +88 -21
- package/dist/core/__tests__/human-deliver.test.js +66 -5
- package/dist/core/__tests__/serial/broker-snapshot-history.test.js +5 -2
- package/dist/core/__tests__/serial/deferred-no-wake.test.js +7 -4
- package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -1
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +4 -1
- package/dist/core/__tests__/serial/revive.test.js +8 -3
- package/dist/core/__tests__/session-cycles.test.js +10 -6
- package/dist/core/canvas/render-source.js +3 -0
- package/dist/core/feed/feed.d.ts +7 -1
- package/dist/core/feed/feed.js +8 -7
- package/dist/core/feed/inbox.d.ts +16 -12
- package/dist/core/feed/inbox.js +56 -56
- package/dist/core/human/page-eval.d.ts +9 -4
- package/dist/core/human/page-eval.js +3 -1
- package/dist/core/human/page-markdown.d.ts +3 -0
- package/dist/core/human/page-markdown.js +295 -0
- package/dist/core/human/page.js +4 -0
- package/dist/core/runtime/broker/client-registry.d.ts +1 -0
- package/dist/core/runtime/broker/client-registry.js +1 -0
- package/dist/core/runtime/broker/fault-retry.js +8 -3
- package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
- package/dist/core/runtime/broker/frame-dispatch.js +4 -2
- package/dist/core/runtime/broker/inbox.d.ts +14 -2
- package/dist/core/runtime/broker/inbox.js +52 -25
- package/dist/core/runtime/broker/passive.d.ts +1 -0
- package/dist/core/runtime/broker/tool-groups.js +3 -2
- package/dist/core/runtime/broker-protocol.d.ts +9 -0
- package/dist/core/runtime/broker-protocol.js +11 -0
- package/dist/core/runtime/broker.d.ts +2 -2
- package/dist/core/runtime/broker.js +31 -6
- package/dist/core/runtime/deliver-live.js +2 -1
- package/dist/core/runtime/kickoff.d.ts +3 -4
- package/dist/core/runtime/kickoff.js +23 -13
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +11 -3
- package/dist/core/runtime/session-cycles.d.ts +6 -1
- package/dist/core/runtime/session-cycles.js +17 -11
- package/dist/core/runtime/session-visibility.d.ts +7 -12
- package/dist/core/runtime/session-visibility.js +7 -13
- package/dist/core/runtime/situational-live.js +2 -1
- package/dist/core/runtime/stop-guard.js +8 -4
- package/dist/core/runtime/warm-pool.d.ts +1 -1
- package/dist/core/runtime/warm-pool.js +6 -9
- package/dist/daemon/api/handlers/nodes.js +10 -7
- package/dist/daemon/companion-retire.d.ts +2 -2
- package/dist/daemon/companion-retire.js +16 -9
- package/dist/daemon/human/finish.d.ts +2 -2
- package/dist/daemon/human/finish.js +48 -8
- package/dist/daemon/human/sweep.js +3 -2
- package/dist/daemon/messaging/node-message.d.ts +5 -0
- package/dist/daemon/messaging/node-message.js +6 -1
- package/dist/daemon/review/comment-notify.js +8 -0
- package/dist/daemon/review/deliver.js +2 -2
- package/dist/daemon/review/finish.js +3 -2
- package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
- package/dist/pi-extensions/broker-local.d.ts +0 -2
- package/dist/pi-extensions/broker-local.js +0 -2
- package/dist/pi-extensions/canvas-context-intro.js +4 -3
- package/dist/pi-extensions/canvas-goal-capture.js +10 -6
- package/dist/pi-extensions/canvas-inbox-watcher.js +36 -25
- package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
- package/dist/pi-extensions/canvas-passive-context.js +23 -3
- package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
- package/dist/pi-extensions/canvas-review-boundary.js +11 -3
- package/dist/pi-extensions/canvas-stophook.js +3 -3
- package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
- package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
- package/dist/shared/generated-context.d.ts +57 -36
- package/dist/shared/generated-context.js +273 -174
- package/dist/shared/tool-groups.js +3 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/postinstall.mjs +9 -0
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// boundary, then the live cycle's context — so the broker welcome snapshot
|
|
15
15
|
// (broker.ts) and the dormant snapshot (node-snapshot.ts) both render one
|
|
16
16
|
// continuous conversation.
|
|
17
|
-
import {
|
|
17
|
+
import { buildContextEntries, sessionEntryToContextMessages, } from '@earendil-works/pi-coding-agent';
|
|
18
18
|
/** customType of the persisted tree entry that ROOTS each post-yield cycle.
|
|
19
19
|
* Data payload: `{ cycle, fromLeaf }` — `cycle` is the node's cycle counter at
|
|
20
20
|
* relaunch, `fromLeaf` the previous cycle's leaf entry id. */
|
|
@@ -55,9 +55,14 @@ export function withoutYieldAbort(message) {
|
|
|
55
55
|
const { stopReason: _stopReason, errorMessage: _errorMessage, ...visible } = message;
|
|
56
56
|
return visible;
|
|
57
57
|
}
|
|
58
|
+
function identifiedContextMessages(entries, leafId, byId) {
|
|
59
|
+
return buildContextEntries(entries, leafId, byId).flatMap((entry) => sessionEntryToContextMessages(entry).map((message) => ({ id: entry.id, message: message })));
|
|
60
|
+
}
|
|
58
61
|
function priorCycleMessages(messages) {
|
|
59
62
|
const last = messages.at(-1);
|
|
60
|
-
return last === undefined
|
|
63
|
+
return last === undefined
|
|
64
|
+
? messages
|
|
65
|
+
: [...messages.slice(0, -1), { ...last, message: withoutYieldAbort(last.message) }];
|
|
61
66
|
}
|
|
62
67
|
/** The full multi-cycle message history for display: prior cycles (oldest
|
|
63
68
|
* first, each closed by a divider), then the live cycle's context. Falls back
|
|
@@ -66,11 +71,13 @@ function priorCycleMessages(messages) {
|
|
|
66
71
|
* tree-navigated back into a pre-yield branch (pi's active-branch semantics
|
|
67
72
|
* then apply unchanged). */
|
|
68
73
|
export function cycleAwareMessages(sm) {
|
|
69
|
-
const current = sm.buildSessionContext().messages;
|
|
70
74
|
if (typeof sm.getEntries !== 'function' || typeof sm.getBranch !== 'function')
|
|
71
|
-
return
|
|
75
|
+
return undefined;
|
|
72
76
|
// File order = append order = chronological cycle order.
|
|
73
77
|
const entries = sm.getEntries();
|
|
78
|
+
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
79
|
+
const currentBranch = sm.getBranch();
|
|
80
|
+
const current = identifiedContextMessages(entries, currentBranch.at(-1)?.id ?? null, byId);
|
|
74
81
|
const markers = entries.filter((e) => e.type === 'custom' && e.customType === CRTR_CYCLE_CUSTOM_TYPE);
|
|
75
82
|
if (markers.length === 0)
|
|
76
83
|
return current;
|
|
@@ -78,14 +85,10 @@ export function cycleAwareMessages(sm) {
|
|
|
78
85
|
// not a cycle marker, the leaf sits in the ORIGINAL (pre-yield) branch —
|
|
79
86
|
// render just that branch. Otherwise replay every cycle up to and including
|
|
80
87
|
// the one the marker closes.
|
|
81
|
-
const currentRootId =
|
|
88
|
+
const currentRootId = currentBranch[0]?.id;
|
|
82
89
|
const rootMarkerIndex = markers.findIndex((m) => m.id === currentRootId);
|
|
83
90
|
if (rootMarkerIndex === -1)
|
|
84
91
|
return current;
|
|
85
|
-
// Shared byId map — buildSessionContext would otherwise rebuild one from
|
|
86
|
-
// `entries` on every call; one pass here amortizes it across every replayed
|
|
87
|
-
// marker.
|
|
88
|
-
const byId = new Map(entries.map((e) => [e.id, e]));
|
|
89
92
|
const out = [];
|
|
90
93
|
for (let i = 0; i <= rootMarkerIndex; i++) {
|
|
91
94
|
const marker = markers[i];
|
|
@@ -95,9 +98,12 @@ export function cycleAwareMessages(sm) {
|
|
|
95
98
|
// builder — preserving compaction semantics (one compactionSummary +
|
|
96
99
|
// kept + post-compaction messages only) exactly like the live cycle
|
|
97
100
|
// below.
|
|
98
|
-
out.push(...priorCycleMessages(
|
|
101
|
+
out.push(...priorCycleMessages(identifiedContextMessages(entries, fromLeaf, byId)));
|
|
99
102
|
}
|
|
100
|
-
out.push(
|
|
103
|
+
out.push({
|
|
104
|
+
id: `${marker.id}~divider`,
|
|
105
|
+
message: cycleDivider(marker.data?.cycle, marker.timestamp),
|
|
106
|
+
});
|
|
101
107
|
}
|
|
102
108
|
out.push(...current);
|
|
103
109
|
return out;
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* The marker itself is a crtr-rendered divider shown to the person; everything
|
|
7
|
-
* above it in the inherited session is context they cannot see.
|
|
8
|
-
*/
|
|
9
|
-
export declare const REVIEW_BOUNDARY_CUSTOM_TYPE = "crtr-review-boundary";
|
|
1
|
+
import type { WireSessionTreeNode } from './broker-protocol.js';
|
|
2
|
+
import type { IdentifiedMessage } from './session-cycles.js';
|
|
3
|
+
import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
|
|
4
|
+
export { REVIEW_BOUNDARY_CUSTOM_TYPE };
|
|
10
5
|
/** True when an entry is a durable review boundary, optionally for one review. */
|
|
11
6
|
export declare function isReviewBoundary(entry: unknown, reviewId?: string): boolean;
|
|
12
7
|
/**
|
|
13
|
-
* Project snapshot messages to the visible window for a review companion.
|
|
8
|
+
* Project identified snapshot messages to the visible window for a review companion.
|
|
14
9
|
*
|
|
15
10
|
* A companion passes its binding's review id so the slice starts at ITS OWN
|
|
16
11
|
* boundary marker: a node forked from another companion inherits that
|
|
@@ -27,9 +22,9 @@ export declare function isReviewBoundary(entry: unknown, reviewId?: string): boo
|
|
|
27
22
|
* with a synthetic boundary while its retained tail remains visible. Every
|
|
28
23
|
* other markerless shape fails closed with the synthetic boundary alone.
|
|
29
24
|
*/
|
|
30
|
-
export declare function visibleMessages(messages:
|
|
25
|
+
export declare function visibleMessages(messages: IdentifiedMessage[], opts: {
|
|
31
26
|
boundaryReviewId: string | undefined;
|
|
32
|
-
}):
|
|
27
|
+
}): IdentifiedMessage[];
|
|
33
28
|
/**
|
|
34
29
|
* Project a session tree to the review companion's visible transcript boundary.
|
|
35
30
|
*
|
|
@@ -9,14 +9,8 @@
|
|
|
9
9
|
// Applied at the two BrokerSnapshot.messages producers (live broker and
|
|
10
10
|
// dormant node read) so every presenter — attach, web node viewer, inspect — is
|
|
11
11
|
// boundary-safe by construction with no presenter-side changes.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* targetFile }` — per-review facts that complement the inheritance prefix.
|
|
15
|
-
*
|
|
16
|
-
* The marker itself is a crtr-rendered divider shown to the person; everything
|
|
17
|
-
* above it in the inherited session is context they cannot see.
|
|
18
|
-
*/
|
|
19
|
-
export const REVIEW_BOUNDARY_CUSTOM_TYPE = 'crtr-review-boundary';
|
|
12
|
+
import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
|
|
13
|
+
export { REVIEW_BOUNDARY_CUSTOM_TYPE };
|
|
20
14
|
/** True when an entry is a durable review boundary, optionally for one review. */
|
|
21
15
|
export function isReviewBoundary(entry, reviewId) {
|
|
22
16
|
if (entry === null || typeof entry !== 'object')
|
|
@@ -36,7 +30,7 @@ function syntheticBoundary(timestamp) {
|
|
|
36
30
|
};
|
|
37
31
|
}
|
|
38
32
|
/**
|
|
39
|
-
* Project snapshot messages to the visible window for a review companion.
|
|
33
|
+
* Project identified snapshot messages to the visible window for a review companion.
|
|
40
34
|
*
|
|
41
35
|
* A companion passes its binding's review id so the slice starts at ITS OWN
|
|
42
36
|
* boundary marker: a node forked from another companion inherits that
|
|
@@ -54,16 +48,16 @@ function syntheticBoundary(timestamp) {
|
|
|
54
48
|
* other markerless shape fails closed with the synthetic boundary alone.
|
|
55
49
|
*/
|
|
56
50
|
export function visibleMessages(messages, opts) {
|
|
57
|
-
const markerIndex = messages.findIndex((message) => isReviewBoundary(message, opts.boundaryReviewId));
|
|
51
|
+
const markerIndex = messages.findIndex(({ message }) => isReviewBoundary(message, opts.boundaryReviewId));
|
|
58
52
|
if (markerIndex !== -1)
|
|
59
53
|
return messages.slice(markerIndex);
|
|
60
54
|
if (opts.boundaryReviewId === undefined)
|
|
61
55
|
return messages;
|
|
62
56
|
const [first, ...retainedTail] = messages;
|
|
63
|
-
if (first?.role === 'compactionSummary') {
|
|
64
|
-
return [syntheticBoundary(first.timestamp), ...retainedTail];
|
|
57
|
+
if (first?.message.role === 'compactionSummary') {
|
|
58
|
+
return [{ id: '~boundary', message: syntheticBoundary(first.message.timestamp) }, ...retainedTail];
|
|
65
59
|
}
|
|
66
|
-
return [syntheticBoundary(first?.timestamp)];
|
|
60
|
+
return [{ id: '~boundary', message: syntheticBoundary(first?.message.timestamp) }];
|
|
67
61
|
}
|
|
68
62
|
/**
|
|
69
63
|
* Project a session tree to the review companion's visible transcript boundary.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// (`deliver-live.ts`), which the claim's bearings delivery also rides.
|
|
17
17
|
import { getNode } from '../canvas/index.js';
|
|
18
18
|
import { deliverCustomMessageLive } from './deliver-live.js';
|
|
19
|
+
import { formatCard } from '../../shared/generated-context.js';
|
|
19
20
|
import { appendSituationalContext, situationalContextBlock, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from './situational-context.js';
|
|
20
21
|
/** Write `text` to the node's sidecar and, when its broker is live, deliver the
|
|
21
22
|
* rendered block into the running session for the NEXT turn.
|
|
@@ -34,7 +35,7 @@ export async function setSituationalContextLive(nodeId, text) {
|
|
|
34
35
|
return; // blank text — appendSituationalContext no-ops, nothing to deliver
|
|
35
36
|
await deliverCustomMessageLive(nodeId, {
|
|
36
37
|
customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
|
|
37
|
-
content: block,
|
|
38
|
+
content: formatCard('situational', {}, block),
|
|
38
39
|
deliverAs: 'nextTurn',
|
|
39
40
|
});
|
|
40
41
|
}
|
|
@@ -23,16 +23,16 @@
|
|
|
23
23
|
// final pushed. Re-prompt it to finish or escalate.
|
|
24
24
|
import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
|
|
25
25
|
import { activeBackgroundBashJobs } from '../bash-jobs.js';
|
|
26
|
-
import { formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
|
|
26
|
+
import { formatCard, formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
|
|
27
27
|
import { readOutputRequest } from './structured-output.js';
|
|
28
28
|
export { STALL_REPROMPT } from '../../shared/generated-context.js';
|
|
29
29
|
/** Format the stop guard's reprompt for a valid structured-output request. */
|
|
30
30
|
function formatValidStructuredOutputReprompt(schema) {
|
|
31
|
-
return formatStructuredOutputReprompt(JSON.stringify(schema, null, 2));
|
|
31
|
+
return formatCard('stop-guard', { reason: 'structured-output' }, formatStructuredOutputReprompt(JSON.stringify(schema, null, 2)));
|
|
32
32
|
}
|
|
33
33
|
/** Format the stop guard's reprompt for an invalid structured-output request file. */
|
|
34
34
|
function formatInvalidStructuredOutputReprompt(error) {
|
|
35
|
-
return (`Structured-output request file is invalid and cannot be used: ${error}\n\n` +
|
|
35
|
+
return formatCard('stop-guard', { reason: 'structured-output' }, `Structured-output request file is invalid and cannot be used: ${error}\n\n` +
|
|
36
36
|
`You have two options:\n` +
|
|
37
37
|
`1. Remove the invalid file (the output-schema.json in your node directory has been corrupted)\n` +
|
|
38
38
|
`2. Fix the corruption if you know the original request schema\n\n` +
|
|
@@ -96,5 +96,9 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
|
|
|
96
96
|
|| backgroundJobsRunning)
|
|
97
97
|
return { action: 'allow', reason: 'awaiting' };
|
|
98
98
|
// A terminal node with nothing live and no final pushed has stalled.
|
|
99
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
action: 'reprompt',
|
|
101
|
+
reason: 'stalled',
|
|
102
|
+
message: formatCard('stop-guard', { reason: 'stalled' }, STALL_REPROMPT),
|
|
103
|
+
};
|
|
100
104
|
}
|
|
@@ -17,7 +17,7 @@ export interface WarmRequest {
|
|
|
17
17
|
kind: string;
|
|
18
18
|
mode: Mode;
|
|
19
19
|
/** Where the node runs — already resolved by the create handler (pin >
|
|
20
|
-
* spawner > profile home)
|
|
20
|
+
* spawner > launch cwd > profile home). */
|
|
21
21
|
cwd: string;
|
|
22
22
|
/** The RESOLVED profile id, or null for explicitly no profile. */
|
|
23
23
|
profileId: string | null;
|
|
@@ -47,10 +47,9 @@
|
|
|
47
47
|
// and at daemon start for the most recently used profiles, so the FIRST node
|
|
48
48
|
// after sitting down is warm too.
|
|
49
49
|
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// a question about the profile alone.
|
|
50
|
+
// A spare freezes the directory it was booted in, so a create launched from a
|
|
51
|
+
// different directory than the last one simply misses and cold-spawns — the
|
|
52
|
+
// pool serves the common case of returning to the same working directory.
|
|
54
53
|
//
|
|
55
54
|
// Nothing ever RESUMES a spare: the daemon's recovery sweep reads `listNodes`,
|
|
56
55
|
// which hides the pool, so a spare whose broker dies (every daemon handover
|
|
@@ -63,7 +62,6 @@ import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registe
|
|
|
63
62
|
import { recordedPidLiveness } from '../canvas/pid.js';
|
|
64
63
|
import { nowIso } from '../fs-utils.js';
|
|
65
64
|
import { spawnChildPrepared } from './spawn.js';
|
|
66
|
-
import { profileHome } from '../profiles/manifest.js';
|
|
67
65
|
import { buildLaunchSpec } from './launch.js';
|
|
68
66
|
import { setModelLive } from './model-swap.js';
|
|
69
67
|
import { setSituationalContextLive } from './situational-live.js';
|
|
@@ -335,10 +333,9 @@ export function prewarmRecentRecipes() {
|
|
|
335
333
|
if (seen.has(profileId ?? ''))
|
|
336
334
|
continue;
|
|
337
335
|
seen.add(profileId ?? '');
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
candidates.push({ cwd: profileHome(profileId) ?? row.cwd, profileId });
|
|
336
|
+
// A node lands in the directory its launch names, so the profile's last
|
|
337
|
+
// root predicts the next one better than the profile's home does.
|
|
338
|
+
candidates.push({ cwd: row.cwd, profileId });
|
|
342
339
|
if (candidates.length >= PREWARM_PROFILES)
|
|
343
340
|
break;
|
|
344
341
|
}
|
|
@@ -98,14 +98,14 @@ function parseCreateBody(body) {
|
|
|
98
98
|
}
|
|
99
99
|
/** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
|
|
100
100
|
* managed child inherits its spawner's cwd (which is what keeps a worktree
|
|
101
|
-
* node's whole subtree inside that worktree); else the
|
|
102
|
-
*
|
|
103
|
-
* the last resort, for a profile that has no home (notably root).
|
|
101
|
+
* node's whole subtree inside that worktree); else the launch's own `cwd`, the
|
|
102
|
+
* directory the create came from; else the profile's home.
|
|
104
103
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
104
|
+
* The launch directory beating the profile home is what lets one profile cover
|
|
105
|
+
* many working directories — a root started inside a planted instance runs in
|
|
106
|
+
* that instance while still taking its identity, purview, and memory from the
|
|
107
|
+
* profile covering it. The profile supplies a home only for a create that named
|
|
108
|
+
* no directory at all. */
|
|
109
109
|
function resolveNodeCwd(req, parent, profileId, contextCwd) {
|
|
110
110
|
if (req.pin_cwd !== undefined && req.pin_cwd !== '')
|
|
111
111
|
return resolve(req.pin_cwd);
|
|
@@ -114,6 +114,8 @@ function resolveNodeCwd(req, parent, profileId, contextCwd) {
|
|
|
114
114
|
if (spawner !== null)
|
|
115
115
|
return spawner.cwd;
|
|
116
116
|
}
|
|
117
|
+
if (req.cwd !== undefined && req.cwd !== '')
|
|
118
|
+
return resolve(req.cwd);
|
|
117
119
|
return profileHome(profileId) ?? contextCwd;
|
|
118
120
|
}
|
|
119
121
|
/** Build the immediate spawn recipe. */
|
|
@@ -328,6 +330,7 @@ async function handleMessages(ctx) {
|
|
|
328
330
|
const body = {
|
|
329
331
|
node_id: id,
|
|
330
332
|
messages: read.messages,
|
|
333
|
+
...(read.messageIds === undefined ? {} : { message_ids: read.messageIds }),
|
|
331
334
|
next_cursor: read.nextCursor,
|
|
332
335
|
captured_at: nowIso(),
|
|
333
336
|
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
/** Retire a companion only through
|
|
2
|
-
export declare function retireCompanion(nodeId: string, outcome: 'approved' | 'canceled'): 'retired' | 'already' | 'missing'
|
|
1
|
+
/** Retire a companion only through the terminal transition legal for its current status. */
|
|
2
|
+
export declare function retireCompanion(nodeId: string, outcome: 'approved' | 'canceled'): Promise<'retired' | 'already' | 'missing'>;
|
|
@@ -3,25 +3,32 @@
|
|
|
3
3
|
// can retire a feedback companion without importing the review finisher (which
|
|
4
4
|
// itself imports human/finish.ts).
|
|
5
5
|
import { getNode } from '../core/canvas/canvas.js';
|
|
6
|
+
import { FinalizationError, pushFinal } from '../core/feed/feed.js';
|
|
6
7
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
7
8
|
import { headlessBrokerHost } from '../core/runtime/host.js';
|
|
8
|
-
/** Retire a companion only through
|
|
9
|
-
export function retireCompanion(nodeId, outcome) {
|
|
9
|
+
/** Retire a companion only through the terminal transition legal for its current status. */
|
|
10
|
+
export async function retireCompanion(nodeId, outcome) {
|
|
10
11
|
const node = getNode(nodeId);
|
|
11
12
|
if (node === null)
|
|
12
13
|
return 'missing';
|
|
13
|
-
if (node.status === '
|
|
14
|
+
if (node.status === 'canceled' || (outcome === 'canceled' && node.status === 'done'))
|
|
14
15
|
return 'already';
|
|
15
|
-
if (outcome === 'approved' && (node.status === 'active' || node.status === 'idle')) {
|
|
16
|
-
|
|
16
|
+
if (outcome === 'approved' && (node.status === 'active' || node.status === 'idle' || node.status === 'done')) {
|
|
17
|
+
let result = 'retired';
|
|
18
|
+
try {
|
|
19
|
+
await pushFinal(nodeId, 'The companion conversation is complete.');
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!(error instanceof FinalizationError) || error.code !== 'already_finalized')
|
|
23
|
+
throw error;
|
|
24
|
+
result = 'already';
|
|
25
|
+
}
|
|
17
26
|
try {
|
|
18
27
|
headlessBrokerHost.teardown(nodeId);
|
|
19
28
|
}
|
|
20
|
-
catch { /* the
|
|
21
|
-
return
|
|
29
|
+
catch { /* the canonical final is authoritative */ }
|
|
30
|
+
return result;
|
|
22
31
|
}
|
|
23
|
-
// Cancellation is legal from every status. An already-dead companion cannot
|
|
24
|
-
// be finished, so it is retired as canceled even when approval won.
|
|
25
32
|
transition(nodeId, 'cancel');
|
|
26
33
|
if (node.status === 'active' || node.status === 'idle') {
|
|
27
34
|
try {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { TicketResult } from '../../core/human/types.js';
|
|
2
2
|
/** Every ticket-ending action retires the ticket's feedback companion — done
|
|
3
|
-
*
|
|
3
|
+
* with a canonical final when approved, never left resident. Idempotent and replay-safe; a
|
|
4
4
|
* corrupt feedback file must not block result publication. Exported for the
|
|
5
5
|
* daemon-start sweep, which must settle terminal tickets whether or not a
|
|
6
6
|
* reply bridge exists. */
|
|
7
|
-
export declare function settleFeedbackCompanion(ticketId: string): void
|
|
7
|
+
export declare function settleFeedbackCompanion(ticketId: string, outcome: 'approved' | 'canceled'): Promise<void>;
|
|
8
8
|
/**
|
|
9
9
|
* The one completion path for a page ticket: claim takeover, exclusive result
|
|
10
10
|
* publication, then optional reply delivery. Every addressing surface routes
|
|
@@ -9,20 +9,20 @@ import { pagePath, readTicketReplyRoute, responsePath } from '../../core/human/c
|
|
|
9
9
|
import { readPageFeedback } from '../../core/human/feedback.js';
|
|
10
10
|
import { pageDocumentSha256 } from '../../core/human/feedback-companion.js';
|
|
11
11
|
import { appendInbox } from '../../core/feed/inbox.js';
|
|
12
|
-
import { FinalDeliveryError, pushFinal } from '../../core/feed/feed.js';
|
|
12
|
+
import { FinalDeliveryError, pushFinal, pushUpdate } from '../../core/feed/feed.js';
|
|
13
13
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
14
14
|
import { emitEvent } from '../../core/events/emit.js';
|
|
15
15
|
import { retireCompanion } from '../companion-retire.js';
|
|
16
16
|
/** Every ticket-ending action retires the ticket's feedback companion — done
|
|
17
|
-
*
|
|
17
|
+
* with a canonical final when approved, never left resident. Idempotent and replay-safe; a
|
|
18
18
|
* corrupt feedback file must not block result publication. Exported for the
|
|
19
19
|
* daemon-start sweep, which must settle terminal tickets whether or not a
|
|
20
20
|
* reply bridge exists. */
|
|
21
|
-
export function settleFeedbackCompanion(ticketId) {
|
|
21
|
+
export async function settleFeedbackCompanion(ticketId, outcome) {
|
|
22
22
|
try {
|
|
23
23
|
const companionNodeId = readPageFeedback(ticketDir(ticketId)).companionNodeId;
|
|
24
24
|
if (companionNodeId !== undefined)
|
|
25
|
-
retireCompanion(companionNodeId,
|
|
25
|
+
await retireCompanion(companionNodeId, outcome);
|
|
26
26
|
}
|
|
27
27
|
catch (error) {
|
|
28
28
|
emitEvent({ level: 'warn', event: 'human.feedback_companion.retire_failed', error });
|
|
@@ -80,15 +80,51 @@ function pageTitle(dir) {
|
|
|
80
80
|
return undefined;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
/** Person-authored response values, derived from the structured answer rather
|
|
84
|
+
* than display prose. Empty values have no words to attribute. */
|
|
85
|
+
function personWords(answer) {
|
|
86
|
+
if (answer.kind === 'acknowledgement')
|
|
87
|
+
return '';
|
|
88
|
+
const words = [];
|
|
89
|
+
for (const slot of answer.slots) {
|
|
90
|
+
if (slot.state !== 'answered')
|
|
91
|
+
continue;
|
|
92
|
+
if (slot.raw !== undefined) {
|
|
93
|
+
words.push(JSON.stringify(slot.raw));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (slot.text !== undefined) {
|
|
97
|
+
if (slot.text.edited || slot.text.singleLine === true)
|
|
98
|
+
words.push(slot.text.value);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const group of slot.groups)
|
|
102
|
+
words.push(...group.picked.map((item) => item.label));
|
|
103
|
+
if (slot.freetext !== undefined)
|
|
104
|
+
words.push(slot.freetext);
|
|
105
|
+
words.push(...slot.comments.map((comment) => comment.text));
|
|
106
|
+
}
|
|
107
|
+
return words.filter((word) => word !== '').join('\n\n');
|
|
108
|
+
}
|
|
109
|
+
/** The answer push: the person's words alone when the page captured any, tagged
|
|
110
|
+
* so every reader attributes them to the person without re-deriving it from
|
|
111
|
+
* prose. A page that captured nothing stays an ordinary report carrying the
|
|
112
|
+
* full record, because there is nothing of the person's to attribute. */
|
|
83
113
|
function renderPageAnswer(dir, result, resultPath) {
|
|
84
114
|
let manifest;
|
|
85
115
|
try {
|
|
86
116
|
manifest = parsePage(dir);
|
|
87
117
|
}
|
|
88
118
|
catch {
|
|
89
|
-
return renderCorruptPageAnswer(result, resultPath);
|
|
119
|
+
return { body: renderCorruptPageAnswer(result, resultPath) };
|
|
90
120
|
}
|
|
91
|
-
|
|
121
|
+
const answer = describePageAnswer(manifest, result.responses, { completedAt: result.completedAt });
|
|
122
|
+
const rendered = renderAnswerText(answer, resultPath);
|
|
123
|
+
const addendum = feedbackAddendum(dir);
|
|
124
|
+
const words = personWords(answer);
|
|
125
|
+
return words === ''
|
|
126
|
+
? { body: rendered + addendum }
|
|
127
|
+
: { body: words, disposition: 'human-answer', ...(addendum === '' ? {} : { feedback: addendum.trim() }) };
|
|
92
128
|
}
|
|
93
129
|
function deliveryFailed(error) {
|
|
94
130
|
// A canonical final is committed inside its own write boundary and can never
|
|
@@ -155,7 +191,7 @@ export async function deliverTerminalResult(ticketId) {
|
|
|
155
191
|
// through here, and an inbox-only page with no reply bridge still settles
|
|
156
192
|
// its companion. The daemon-start sweep settles the tickets that die before
|
|
157
193
|
// reaching this call.
|
|
158
|
-
settleFeedbackCompanion(ticketId);
|
|
194
|
+
await settleFeedbackCompanion(ticketId, result.kind === 'canceled' ? 'canceled' : 'approved');
|
|
159
195
|
const replyRoute = readTicketReplyRoute(dir);
|
|
160
196
|
if (replyRoute === null)
|
|
161
197
|
return false;
|
|
@@ -198,6 +234,7 @@ export async function deliverTerminalResult(ticketId) {
|
|
|
198
234
|
kind: 'message',
|
|
199
235
|
label: subject,
|
|
200
236
|
data: { body },
|
|
237
|
+
disposition: 'human-canceled',
|
|
201
238
|
});
|
|
202
239
|
}
|
|
203
240
|
catch {
|
|
@@ -210,7 +247,10 @@ export async function deliverTerminalResult(ticketId) {
|
|
|
210
247
|
return true;
|
|
211
248
|
}
|
|
212
249
|
if (result.kind === 'page') {
|
|
213
|
-
|
|
250
|
+
const answer = renderPageAnswer(dir, result, responsePath(dir));
|
|
251
|
+
if (answer.feedback !== undefined)
|
|
252
|
+
await pushUpdate(bridgeNodeId, answer.feedback);
|
|
253
|
+
await pushFinal(bridgeNodeId, answer.body, answer.disposition === undefined ? undefined : { disposition: answer.disposition });
|
|
214
254
|
}
|
|
215
255
|
return true;
|
|
216
256
|
}
|
|
@@ -21,7 +21,8 @@ export async function reconcileUndeliveredTickets() {
|
|
|
21
21
|
if (!statSync(dir).isDirectory())
|
|
22
22
|
continue;
|
|
23
23
|
scanned += 1;
|
|
24
|
-
|
|
24
|
+
const ticketResult = readTicketResult(dir);
|
|
25
|
+
if (ticketResult === null)
|
|
25
26
|
continue;
|
|
26
27
|
// Canonical reviews own terminal delivery and bridge retirement. Their
|
|
27
28
|
// finisher retries only when its winning operation is explicitly repeated.
|
|
@@ -30,7 +31,7 @@ export async function reconcileUndeliveredTickets() {
|
|
|
30
31
|
// A terminal page ticket retires its feedback companion whether or not a
|
|
31
32
|
// reply bridge exists: inbox-only pages have no bridge row, yet the
|
|
32
33
|
// daemon can die between result publication and companion retirement.
|
|
33
|
-
settleFeedbackCompanion(entry);
|
|
34
|
+
await settleFeedbackCompanion(entry, ticketResult.kind === 'canceled' ? 'canceled' : 'approved');
|
|
34
35
|
// The ROW is what carries status; `getNode` would answer from a pruned
|
|
35
36
|
// bridge's surviving meta.json with no status at all.
|
|
36
37
|
const bridge = getRow(entry);
|
|
@@ -4,6 +4,10 @@ export interface NodeMessageDelivery {
|
|
|
4
4
|
via: 'engine' | 'inbox';
|
|
5
5
|
revived: boolean;
|
|
6
6
|
}
|
|
7
|
+
export interface RuntimeMessageCard {
|
|
8
|
+
kind: string;
|
|
9
|
+
facts: Record<string, string | number | boolean | undefined>;
|
|
10
|
+
}
|
|
7
11
|
/**
|
|
8
12
|
* Deliver one daemon-originated node message. Interactive messages use the live
|
|
9
13
|
* broker when available; every other successful path is a durable inbox entry.
|
|
@@ -15,4 +19,5 @@ export declare function deliverNodeMessage(args: {
|
|
|
15
19
|
from?: string | null;
|
|
16
20
|
mode: NodeMessageMode;
|
|
17
21
|
data?: Record<string, unknown>;
|
|
22
|
+
runtime?: RuntimeMessageCard;
|
|
18
23
|
}): Promise<NodeMessageDelivery>;
|
|
@@ -7,6 +7,7 @@ import { isBrokerLive } from '../../core/runtime/model-swap.js';
|
|
|
7
7
|
import { reviveNode } from '../../core/runtime/revive.js';
|
|
8
8
|
import { hasNoNaturalCycle } from '../../core/runtime/revive-all.js';
|
|
9
9
|
import { ReviewOperationError } from '../../core/review/types.js';
|
|
10
|
+
import { formatCard } from '../../shared/generated-context.js';
|
|
10
11
|
function deliveryUnroutable(nodeId, reason) {
|
|
11
12
|
throw new ReviewOperationError('delivery_unroutable', reason === 'origin_missing' ? 'message target was not found' : 'message target has no natural delivery cycle', { node_id: nodeId, reason });
|
|
12
13
|
}
|
|
@@ -33,7 +34,11 @@ export async function deliverNodeMessage(args) {
|
|
|
33
34
|
deliveryUnroutable(args.node_id, 'origin_missing');
|
|
34
35
|
if (args.mode === 'interactive' && isBrokerLive(target)) {
|
|
35
36
|
try {
|
|
36
|
-
|
|
37
|
+
// Inbox delivery supplies the raw body as an entry in its outer inbox
|
|
38
|
+
// card; only live delivery needs this message's own envelope.
|
|
39
|
+
await deliverLive(args.node_id, args.runtime === undefined
|
|
40
|
+
? args.body
|
|
41
|
+
: formatCard(args.runtime.kind, args.runtime.facts, args.body));
|
|
37
42
|
// The broker acknowledgement is asynchronous to the SQLite guard. Recheck
|
|
38
43
|
// before reporting an engine receipt so a final that committed during the
|
|
39
44
|
// request is not represented as a routable delivery.
|
|
@@ -70,6 +70,14 @@ export async function notifyComment(args) {
|
|
|
70
70
|
comment_id: args.comment.comment_id,
|
|
71
71
|
verb: args.verb,
|
|
72
72
|
},
|
|
73
|
+
runtime: {
|
|
74
|
+
kind: 'review-comment',
|
|
75
|
+
facts: {
|
|
76
|
+
review_id: args.review.review_id,
|
|
77
|
+
comment_id: args.comment.comment_id,
|
|
78
|
+
verb: args.verb,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
73
81
|
});
|
|
74
82
|
}
|
|
75
83
|
catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FinalizationError, pushFinal, redeliverFinalReport } from '../../core/feed/feed.js';
|
|
2
|
-
import {
|
|
2
|
+
import { formatCard } from '../../shared/generated-context.js';
|
|
3
3
|
import { deliverNodeMessage } from '../messaging/node-message.js';
|
|
4
4
|
function approvalBody(review, resultPath) {
|
|
5
5
|
const summary = review.changed
|
|
@@ -9,7 +9,7 @@ function approvalBody(review, resultPath) {
|
|
|
9
9
|
}
|
|
10
10
|
/** Render the immutable approval fact without replaying comment text. */
|
|
11
11
|
export function renderApprovalMessage(review, resultPath) {
|
|
12
|
-
return
|
|
12
|
+
return formatCard('review-approval', { review: review.review_id, file: review.file }, approvalBody(review, resultPath));
|
|
13
13
|
}
|
|
14
14
|
function recordedFailedFanout(review, reportBasename) {
|
|
15
15
|
const error = review.delivery_error;
|
|
@@ -89,7 +89,7 @@ async function finishApproved(review, result) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
current = requireVisibleReview(review.review_id);
|
|
92
|
-
retireCompanion(current.companion_node_id, 'approved');
|
|
92
|
+
await retireCompanion(current.companion_node_id, 'approved');
|
|
93
93
|
return requireVisibleReview(review.review_id);
|
|
94
94
|
}
|
|
95
95
|
async function finishCanceled(review, winningWrite, args) {
|
|
@@ -150,7 +150,7 @@ async function finishCanceled(review, winningWrite, args) {
|
|
|
150
150
|
current = requireVisibleReview(review.review_id);
|
|
151
151
|
if (canceledTicket === undefined)
|
|
152
152
|
canceledTicket = ticketResult(current);
|
|
153
|
-
retireCompanion(current.companion_node_id, 'canceled');
|
|
153
|
+
await retireCompanion(current.companion_node_id, 'canceled');
|
|
154
154
|
return { record: requireVisibleReview(review.review_id), ...(canceledTicket === undefined ? {} : { ticket_result: canceledTicket }) };
|
|
155
155
|
}
|
|
156
156
|
/** Tell the origin its review was submitted but is finishing behind the
|
|
@@ -164,6 +164,7 @@ async function noteSubmitQueued(review) {
|
|
|
164
164
|
mode: 'quiet',
|
|
165
165
|
label: 'human review submitted — result to follow',
|
|
166
166
|
data: { review_id: review.review_id },
|
|
167
|
+
runtime: { kind: 'review-queued', facts: { review_id: review.review_id } },
|
|
167
168
|
body: `The human submitted their review of \`${review.file}\`; the document is still being edited. `
|
|
168
169
|
+ "You'll be notified with the result once the review completes. Nothing is needed from you now.",
|
|
169
170
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|