@crouton-kit/crouter 0.3.82 → 0.3.84
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/builtin-pi-packages/pi-mode-switch/README.md +3 -3
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +21 -4
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +17 -44
- package/dist/clients/attach/attach-cmd.d.ts +2 -3
- package/dist/clients/attach/attach-cmd.js +689 -689
- package/dist/clients/attach/attach-def.d.ts +2 -0
- package/dist/clients/attach/attach-def.js +93 -0
- package/dist/clients/attach/canvas-panels.d.ts +10 -3
- package/dist/clients/attach/canvas-panels.js +42 -16
- package/dist/clients/attach/chat-view.d.ts +20 -1
- package/dist/clients/attach/chat-view.js +85 -2
- package/dist/commands/__tests__/human.test.js +7 -22
- package/dist/commands/attention.js +31 -30
- package/dist/commands/human/prompts.js +57 -38
- package/dist/commands/human/queue.d.ts +1 -28
- package/dist/commands/human/queue.js +156 -520
- package/dist/commands/human/shared.d.ts +28 -68
- package/dist/commands/human/shared.js +37 -120
- package/dist/commands/human.js +15 -17
- package/dist/commands/node.js +5 -2
- package/dist/commands/surface.js +1 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +85 -0
- package/dist/commands/sys/setup-core.d.ts +10 -0
- package/dist/commands/sys/setup-core.js +39 -1
- package/dist/commands/sys/setup.js +10 -1
- package/dist/core/__tests__/boot.test.js +141 -6
- package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
- package/dist/core/__tests__/human-deliver.test.js +332 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
- package/dist/core/__tests__/pid-identity-match.test.js +145 -0
- package/dist/core/__tests__/tmux-surface.test.js +180 -2
- package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/attention.test.js +126 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
- package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
- package/dist/core/canvas/attention.d.ts +16 -14
- package/dist/core/canvas/attention.js +34 -29
- package/dist/core/canvas/boot.d.ts +45 -0
- package/dist/core/canvas/boot.js +36 -3
- package/dist/core/canvas/browse/app.js +3 -3
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +65 -1
- package/dist/core/canvas/pid.d.ts +86 -17
- package/dist/core/canvas/pid.js +152 -37
- package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
- package/dist/core/canvas/remote-canvas-source.js +4 -2
- package/dist/core/canvas/render.d.ts +5 -5
- package/dist/core/canvas/render.js +14 -14
- package/dist/core/canvas/source.d.ts +2 -2
- package/dist/core/canvas/source.js +3 -3
- package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +4 -2
- package/dist/core/keybindings/inbox.d.ts +21 -0
- package/dist/core/keybindings/inbox.js +35 -0
- package/dist/core/keybindings/index.d.ts +1 -0
- package/dist/core/keybindings/index.js +1 -0
- package/dist/core/preview-registry.js +23 -29
- package/dist/core/profiles/select.js +4 -4
- package/dist/core/runtime/pi-vendored.js +15 -1
- package/dist/core/runtime/placement.d.ts +1 -10
- package/dist/core/runtime/placement.js +43 -26
- package/dist/core/runtime/session-list-cache.d.ts +22 -2
- package/dist/core/runtime/session-list-cache.js +92 -24
- package/dist/core/runtime/spawn.js +2 -1
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +1 -0
- package/dist/core/runtime/tmux.js +55 -1
- package/dist/core/spawn.d.ts +0 -59
- package/dist/core/spawn.js +5 -163
- package/dist/daemon/crtrd.js +36 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +2 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
- package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
- package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
- package/dist/core/__tests__/human-surface-target.test.js +0 -98
- package/dist/web-client/assets/index--md2ylfi.js +0 -78
- /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
- /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
|
@@ -6,166 +6,21 @@ import { paginate } from '../../core/pagination.js';
|
|
|
6
6
|
import { getNode, listNodes, subscribersOf } from '../../core/canvas/index.js';
|
|
7
7
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
8
8
|
import { appendInbox } from '../../core/feed/inbox.js';
|
|
9
|
-
import {
|
|
10
|
-
import { basename, join } from 'node:path';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** A deterministic one-line-per-interaction summary, mirroring humanloop's
|
|
18
|
-
* `<id>: <option>[ — <freetext>]` shape. Used when reconstructing a
|
|
19
|
-
* ResolutionEnvelope from a bare on-disk response.json (which stores only
|
|
20
|
-
* `{responses, completedAt}`), so a healed deliver-back reads like the live one. */
|
|
21
|
-
function summarizeResponses(responses, deck) {
|
|
22
|
-
const interactions = new Map((deck?.interactions ?? []).map((it) => [it.id, it]));
|
|
23
|
-
return responses
|
|
24
|
-
.map((r) => {
|
|
25
|
-
const it = interactions.get(r.id);
|
|
26
|
-
let picked = '';
|
|
27
|
-
if (r.selectedOptionIds !== undefined) {
|
|
28
|
-
picked = r.selectedOptionIds
|
|
29
|
-
.map((id) => {
|
|
30
|
-
const label = it?.options.find((o) => o.id === id)?.label ?? id;
|
|
31
|
-
const note = r.optionComments?.[id];
|
|
32
|
-
return typeof note === 'string' && note.trim() !== '' ? `${label} ("${note.trim()}")` : label;
|
|
33
|
-
})
|
|
34
|
-
.join(', ');
|
|
35
|
-
}
|
|
36
|
-
else if (r.selectedOptionId !== undefined) {
|
|
37
|
-
picked = it?.options.find((o) => o.id === r.selectedOptionId)?.label ?? r.selectedOptionId;
|
|
38
|
-
}
|
|
39
|
-
const ft = typeof r.freetext === 'string' && r.freetext.trim() !== '' ? ` — ${r.freetext.trim()}` : '';
|
|
40
|
-
return `${it?.title ?? r.id}: ${picked}${ft}`;
|
|
41
|
-
})
|
|
42
|
-
.join('\n');
|
|
43
|
-
}
|
|
44
|
-
/** Deliver-back + reap an interaction whose answer is on disk but was never
|
|
45
|
-
* delivered. The detached `_run` worker's `pushFinal` is normally the SOLE
|
|
46
|
-
* deliver-back + reap step; when it never ran (e.g. a broker asker pre-fix, or
|
|
47
|
-
* a human draining via `crtr human inbox`, which writes response.json directly
|
|
48
|
-
* and never calls pushFinal), the answer strands and the bridge node leaks.
|
|
49
|
-
*
|
|
50
|
-
* Idempotent and self-gating: a no-op unless the bridge node is still LIVE
|
|
51
|
-
* (active|idle) AND its interaction is resolved on disk — so it never
|
|
52
|
-
* double-delivers an interaction the `_run` worker already finalized (pushFinal
|
|
53
|
-
* flips status=done). Reconstructs the same pushFinal body `_run` would have
|
|
54
|
-
* emitted, per mode; a canceled-on-disk response reaps the node without
|
|
55
|
-
* delivering a result (mirrors `human cancel`). Returns true iff it acted. */
|
|
56
|
-
export async function finalizeResolvedInteraction(jobId, claim) {
|
|
57
|
-
const firstNode = getNode(jobId);
|
|
58
|
-
if (firstNode === null)
|
|
59
|
-
return false;
|
|
60
|
-
const idir = interactionDir(jobId, firstNode.cwd);
|
|
61
|
-
const ownedClaim = claim ?? acquireInteractionClaim(idir, jobId, { allowResolved: true });
|
|
62
|
-
if (ownedClaim === 'already_resolved' || ownedClaim === 'claimed')
|
|
63
|
-
return false;
|
|
9
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { basename, join, resolve } from 'node:path';
|
|
11
|
+
import { scanInbox, parseDeck, deckPath, reviewPath, isResolved, readJson, readTicketResult, claimTicket, completeDeck, cancelTicket, listInboxRoots, } from '@crouton-kit/humanloop';
|
|
12
|
+
/** Canonicalize a completion-event path the same way humanloop's registry does
|
|
13
|
+
* (realpath-resolved), falling back to a plain `resolve()` when the path no
|
|
14
|
+
* longer exists on disk, so the `_deliver` containment/registration checks
|
|
15
|
+
* compare like against like regardless of symlinked path components. */
|
|
16
|
+
function canonicalPath(p) {
|
|
64
17
|
try {
|
|
65
|
-
|
|
66
|
-
if (node === null)
|
|
67
|
-
return false;
|
|
68
|
-
if (node.status !== 'active' && node.status !== 'idle')
|
|
69
|
-
return false;
|
|
70
|
-
if (!isResolved(idir))
|
|
71
|
-
return false;
|
|
72
|
-
const rc = readJson(join(idir, 'run.json'));
|
|
73
|
-
if (rc === null)
|
|
74
|
-
return false;
|
|
75
|
-
const resp = readJson(responsePath(idir));
|
|
76
|
-
if (resp === null)
|
|
77
|
-
return false;
|
|
78
|
-
// Canceled out-of-band (a raw canceled response.json, not via `human cancel`):
|
|
79
|
-
// there is no answer to deliver — just reap the node and tell waiting
|
|
80
|
-
// subscribers no answer is coming, the same quiet deferred note `human cancel`
|
|
81
|
-
// emits. (`finalize` is legal from active|idle; the status guard above holds.)
|
|
82
|
-
if (resp['canceled'] === true) {
|
|
83
|
-
transition(jobId, 'finalize');
|
|
84
|
-
const note = typeof resp['reason'] === 'string' && resp['reason'] !== '' ? ` — ${resp['reason']}` : '';
|
|
85
|
-
for (const sub of subscribersOf(jobId)) {
|
|
86
|
-
appendInbox(sub.node_id, {
|
|
87
|
-
from: jobId,
|
|
88
|
-
tier: 'deferred',
|
|
89
|
-
kind: 'message',
|
|
90
|
-
label: `human interaction ${jobId} canceled — no answer is coming${note}`,
|
|
91
|
-
data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
const responses = resp['responses'] ?? [];
|
|
97
|
-
const completedAt = resp['completedAt'] ?? new Date().toISOString();
|
|
98
|
-
let deck;
|
|
99
|
-
try {
|
|
100
|
-
deck = parseDeck(deckPath(idir));
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
deck = undefined;
|
|
104
|
-
}
|
|
105
|
-
const summary = summarizeResponses(responses, deck);
|
|
106
|
-
// ask (and any other answered deck): the full ResolutionEnvelope shape.
|
|
107
|
-
const env = {
|
|
108
|
-
summary,
|
|
109
|
-
responsePath: responsePath(idir),
|
|
110
|
-
schema: 'humanloop.response/v2',
|
|
111
|
-
responses,
|
|
112
|
-
completedAt,
|
|
113
|
-
};
|
|
114
|
-
await pushFinal(jobId, JSON.stringify(env));
|
|
115
|
-
return true;
|
|
18
|
+
return realpathSync(p);
|
|
116
19
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
ownedClaim.release();
|
|
20
|
+
catch {
|
|
21
|
+
return resolve(p);
|
|
120
22
|
}
|
|
121
23
|
}
|
|
122
|
-
/** Sweep every interaction under the cwd's interactions root and deliver-back +
|
|
123
|
-
* reap any that are answered-but-undelivered (see finalizeResolvedInteraction).
|
|
124
|
-
* Interaction dir names ARE the bridge node ids. Returns how many it healed. */
|
|
125
|
-
export async function healStrandedInteractions(cwd) {
|
|
126
|
-
const root = interactionsRoot(cwd);
|
|
127
|
-
if (!existsSync(root))
|
|
128
|
-
return 0;
|
|
129
|
-
let healed = 0;
|
|
130
|
-
for (const ent of readdirSync(root, { withFileTypes: true })) {
|
|
131
|
-
if (!ent.isDirectory())
|
|
132
|
-
continue;
|
|
133
|
-
if (await finalizeResolvedInteraction(ent.name))
|
|
134
|
-
healed++;
|
|
135
|
-
}
|
|
136
|
-
return healed;
|
|
137
|
-
}
|
|
138
|
-
// ---------------------------------------------------------------------------
|
|
139
|
-
// inbox (human-invoked, blocking)
|
|
140
|
-
// ---------------------------------------------------------------------------
|
|
141
|
-
export const humanInbox = defineLeaf({
|
|
142
|
-
name: 'inbox',
|
|
143
|
-
description: 'interactively drain pending interactions',
|
|
144
|
-
whenToUse: 'a human is clearing the queue at their terminal',
|
|
145
|
-
help: {
|
|
146
|
-
name: 'human inbox',
|
|
147
|
-
summary: 'interactively drain pending interactions at your own terminal',
|
|
148
|
-
params: [],
|
|
149
|
-
inputNote: 'No input. Run this at a human terminal — it blocks until the backlog is drained or you quit.',
|
|
150
|
-
output: [
|
|
151
|
-
{ name: 'drained', type: 'boolean', required: true, constraint: 'True once the loop returns.' },
|
|
152
|
-
{ name: 'delivered', type: 'integer', required: true, constraint: 'How many answered-but-undelivered interactions were delivered back to their askers and reaped.' },
|
|
153
|
-
],
|
|
154
|
-
outputKind: 'object',
|
|
155
|
-
effects: [
|
|
156
|
-
'Resolves pending interactions in the per-project interactions root via the TUI.',
|
|
157
|
-
'Delivers any answered-but-undelivered interaction back to its asking node and reaps the bridge node (the deliver-back the detached _run worker would have done).',
|
|
158
|
-
],
|
|
159
|
-
},
|
|
160
|
-
run: async () => {
|
|
161
|
-
await openInbox({ roots: [interactionsRoot(process.cwd())] });
|
|
162
|
-
// humanloop's openInbox() writes response.json but never calls pushFinal — so a
|
|
163
|
-
// deck drained here would strand (no answer-back, leaked bridge node). Heal
|
|
164
|
-
// every resolved-but-live interaction: deliver its answer to the asker + reap.
|
|
165
|
-
const delivered = await healStrandedInteractions(process.cwd());
|
|
166
|
-
return { drained: true, delivered };
|
|
167
|
-
},
|
|
168
|
-
});
|
|
169
24
|
function allInteractionRoots() {
|
|
170
25
|
const roots = new Set();
|
|
171
26
|
roots.add(interactionsRoot(process.cwd()));
|
|
@@ -191,8 +46,8 @@ function resolveConversation(asking, nodes) {
|
|
|
191
46
|
}
|
|
192
47
|
return cur;
|
|
193
48
|
}
|
|
194
|
-
function askingNodeFor(jobId,
|
|
195
|
-
const stamped =
|
|
49
|
+
function askingNodeFor(jobId, dir, source, nodes) {
|
|
50
|
+
const stamped = source?.nodeId;
|
|
196
51
|
if (stamped !== undefined && stamped !== '') {
|
|
197
52
|
const found = nodes.find((n) => n.node_id === stamped);
|
|
198
53
|
if (found !== undefined)
|
|
@@ -211,22 +66,25 @@ function askingNodeFor(jobId, deck, dir, nodes) {
|
|
|
211
66
|
}
|
|
212
67
|
return bridge !== null ? { node_id: bridge.node_id, name: bridge.name, cwd: bridge.cwd, parent: bridge.parent ?? null } : undefined;
|
|
213
68
|
}
|
|
214
|
-
function
|
|
215
|
-
const jobId = basename(
|
|
69
|
+
function summarizeTicket(summary, nodes) {
|
|
70
|
+
const jobId = basename(summary.dir);
|
|
71
|
+
// A bare-shell ticket with no canvas node is intentionally omitted from the
|
|
72
|
+
// canvas-scoped list; the popup still shows it globally.
|
|
216
73
|
if (getNode(jobId) === null)
|
|
217
74
|
return null;
|
|
218
|
-
const asking = askingNodeFor(jobId,
|
|
75
|
+
const asking = askingNodeFor(jobId, summary.dir, summary.source, nodes);
|
|
219
76
|
const conversation = resolveConversation(asking, nodes);
|
|
77
|
+
const interactionCount = summary.kind === 'deck' ? readJson(deckPath(summary.dir))?.interactions.length : undefined;
|
|
220
78
|
return {
|
|
221
|
-
id:
|
|
79
|
+
id: summary.id,
|
|
222
80
|
job_id: jobId,
|
|
223
|
-
dir:
|
|
224
|
-
title:
|
|
225
|
-
kind:
|
|
226
|
-
blocked_since:
|
|
81
|
+
dir: summary.dir,
|
|
82
|
+
title: summary.title,
|
|
83
|
+
kind: summary.kind === 'deck' ? summary.interactionKind ?? null : 'review',
|
|
84
|
+
blocked_since: summary.blockedSince,
|
|
227
85
|
...(asking !== undefined ? { asking_node_id: asking.node_id, asking_node_name: asking.name } : {}),
|
|
228
86
|
...(conversation !== undefined ? { conversation_id: conversation.node_id, conversation_title: conversation.name } : {}),
|
|
229
|
-
...(
|
|
87
|
+
...(interactionCount !== undefined ? { interaction_count: interactionCount } : {}),
|
|
230
88
|
};
|
|
231
89
|
}
|
|
232
90
|
function interactionDirForJob(jobId) {
|
|
@@ -245,7 +103,7 @@ export const humanList = defineLeaf({
|
|
|
245
103
|
whenToUse: 'discovering what is blocked on a human',
|
|
246
104
|
help: {
|
|
247
105
|
name: 'human list',
|
|
248
|
-
summary: 'paginated list of pending
|
|
106
|
+
summary: 'paginated list of pending interactions, oldest first',
|
|
249
107
|
params: [
|
|
250
108
|
{ kind: 'flag', name: 'limit', type: 'int', required: false, default: 20, constraint: 'Default 20, max 100.' },
|
|
251
109
|
{ kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: "Opaque token from a previous response's next_cursor. Omit on first call." },
|
|
@@ -265,8 +123,8 @@ export const humanList = defineLeaf({
|
|
|
265
123
|
const nodes = nodeRefs();
|
|
266
124
|
const raw = scanInbox(allInteractionRoots());
|
|
267
125
|
const items = raw
|
|
268
|
-
.flatMap((
|
|
269
|
-
const item =
|
|
126
|
+
.flatMap((s) => {
|
|
127
|
+
const item = summarizeTicket(s, nodes);
|
|
270
128
|
return item === null ? [] : [item];
|
|
271
129
|
})
|
|
272
130
|
.sort((a, b) => {
|
|
@@ -315,7 +173,7 @@ export const humanDeck = defineLeaf({
|
|
|
315
173
|
throw new InputError({ error: 'not_found', message: `no deck for interaction: ${jobId}`, field: 'job_id', next: 'Pass a pending job_id from `crtr human list`.' });
|
|
316
174
|
}
|
|
317
175
|
const nodes = nodeRefs();
|
|
318
|
-
const asking = askingNodeFor(jobId,
|
|
176
|
+
const asking = askingNodeFor(jobId, dir, deck.source, nodes);
|
|
319
177
|
const conversation = resolveConversation(asking, nodes);
|
|
320
178
|
const first = deck.interactions[0];
|
|
321
179
|
return {
|
|
@@ -337,155 +195,6 @@ export const humanDeck = defineLeaf({
|
|
|
337
195
|
};
|
|
338
196
|
},
|
|
339
197
|
});
|
|
340
|
-
function isErrno(e, code) {
|
|
341
|
-
return typeof e === 'object' && e !== null && 'code' in e && e.code === code;
|
|
342
|
-
}
|
|
343
|
-
function pidAlive(pid) {
|
|
344
|
-
try {
|
|
345
|
-
process.kill(pid, 0);
|
|
346
|
-
return true;
|
|
347
|
-
}
|
|
348
|
-
catch (e) {
|
|
349
|
-
if (isErrno(e, 'ESRCH'))
|
|
350
|
-
return false;
|
|
351
|
-
return true;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
function interactionLockPath(dir) {
|
|
355
|
-
return join(dir, 'resolve.lock');
|
|
356
|
-
}
|
|
357
|
-
function releaseClaim(dir, token) {
|
|
358
|
-
const lockFile = interactionLockPath(dir);
|
|
359
|
-
const cur = readJson(lockFile);
|
|
360
|
-
if (cur?.token === token) {
|
|
361
|
-
try {
|
|
362
|
-
unlinkSync(lockFile);
|
|
363
|
-
}
|
|
364
|
-
catch (e) {
|
|
365
|
-
if (!isErrno(e, 'ENOENT'))
|
|
366
|
-
throw e;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
const progressFile = progressPath(dir);
|
|
370
|
-
const progress = readJson(progressFile);
|
|
371
|
-
if (progress?.claimToken !== token)
|
|
372
|
-
return;
|
|
373
|
-
try {
|
|
374
|
-
unlinkSync(progressFile);
|
|
375
|
-
}
|
|
376
|
-
catch (e) {
|
|
377
|
-
if (!isErrno(e, 'ENOENT'))
|
|
378
|
-
throw e;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
export function acquireInteractionClaim(dir, jobId, opts = {}) {
|
|
382
|
-
const allowResolved = opts.allowResolved === true;
|
|
383
|
-
const markProgress = opts.markProgress === true;
|
|
384
|
-
const respectProgress = opts.respectProgress === true;
|
|
385
|
-
const lockFile = interactionLockPath(dir);
|
|
386
|
-
const progressFile = progressPath(dir);
|
|
387
|
-
const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
388
|
-
for (;;) {
|
|
389
|
-
if (!allowResolved && isResolved(dir))
|
|
390
|
-
return 'already_resolved';
|
|
391
|
-
if (!allowResolved && respectProgress && existsSync(progressFile) && !existsSync(lockFile)) {
|
|
392
|
-
const progress = readJson(progressFile);
|
|
393
|
-
if (progress?.claim === 'crtr human resolve') {
|
|
394
|
-
try {
|
|
395
|
-
unlinkSync(progressFile);
|
|
396
|
-
}
|
|
397
|
-
catch (e) {
|
|
398
|
-
if (!isErrno(e, 'ENOENT'))
|
|
399
|
-
return 'claimed';
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
else {
|
|
403
|
-
return 'claimed';
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
let fd = null;
|
|
407
|
-
try {
|
|
408
|
-
fd = openSync(lockFile, 'wx');
|
|
409
|
-
writeFileSync(fd, JSON.stringify({ pid: process.pid, token, jobId, claimedAt: new Date().toISOString() }, null, 2));
|
|
410
|
-
closeSync(fd);
|
|
411
|
-
fd = null;
|
|
412
|
-
if (markProgress) {
|
|
413
|
-
atomicWriteJson(progressFile, { partial: true, claim: 'crtr human resolve', claimToken: token, jobId, claimedAt: new Date().toISOString() });
|
|
414
|
-
}
|
|
415
|
-
if (!allowResolved && isResolved(dir)) {
|
|
416
|
-
releaseClaim(dir, token);
|
|
417
|
-
return 'already_resolved';
|
|
418
|
-
}
|
|
419
|
-
return { release: () => releaseClaim(dir, token) };
|
|
420
|
-
}
|
|
421
|
-
catch (e) {
|
|
422
|
-
if (fd !== null)
|
|
423
|
-
closeSync(fd);
|
|
424
|
-
if (isErrno(e, 'EEXIST')) {
|
|
425
|
-
const owner = readJson(lockFile);
|
|
426
|
-
if (typeof owner?.pid === 'number' && !pidAlive(owner.pid)) {
|
|
427
|
-
try {
|
|
428
|
-
unlinkSync(lockFile);
|
|
429
|
-
}
|
|
430
|
-
catch (unlinkErr) {
|
|
431
|
-
if (!isErrno(unlinkErr, 'ENOENT'))
|
|
432
|
-
return 'claimed';
|
|
433
|
-
}
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
return 'claimed';
|
|
437
|
-
}
|
|
438
|
-
throw e;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
function writeResponseOnce(dir, responses, completedAt) {
|
|
443
|
-
const finalPath = responsePath(dir);
|
|
444
|
-
const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
445
|
-
writeFileSync(tmpPath, JSON.stringify({ responses, completedAt }, null, 2), { flag: 'wx' });
|
|
446
|
-
try {
|
|
447
|
-
linkSync(tmpPath, finalPath);
|
|
448
|
-
return 'written';
|
|
449
|
-
}
|
|
450
|
-
catch (e) {
|
|
451
|
-
if (isErrno(e, 'EEXIST'))
|
|
452
|
-
return 'already_resolved';
|
|
453
|
-
throw e;
|
|
454
|
-
}
|
|
455
|
-
finally {
|
|
456
|
-
try {
|
|
457
|
-
unlinkSync(tmpPath);
|
|
458
|
-
}
|
|
459
|
-
catch (e) {
|
|
460
|
-
if (!isErrno(e, 'ENOENT'))
|
|
461
|
-
throw e;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
function validateResolveResponses(deck, responses) {
|
|
466
|
-
const byId = new Map(deck.interactions.map((it) => [it.id, it]));
|
|
467
|
-
for (const response of responses) {
|
|
468
|
-
const interaction = byId.get(response.id);
|
|
469
|
-
if (interaction === undefined) {
|
|
470
|
-
throw new InputError({ error: 'invalid_field', message: `unknown interaction_id: ${response.id}`, field: 'responses', next: 'Use interaction ids from `crtr human deck <job_id> --json`.' });
|
|
471
|
-
}
|
|
472
|
-
if (interaction.multiSelect === true) {
|
|
473
|
-
if (response.selectedOptionId !== undefined) {
|
|
474
|
-
throw new InputError({ error: 'invalid_field', message: `interaction ${response.id} is multi-select; use selected_option_ids`, field: 'responses', next: 'Use selected_option_ids for multi-select interactions.' });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
else if (response.selectedOptionIds !== undefined && response.selectedOptionIds.length > 1) {
|
|
478
|
-
throw new InputError({ error: 'invalid_field', message: `interaction ${response.id} is single-select`, field: 'responses', next: 'Use selected_option_id or one selected_option_ids value for single-select interactions.' });
|
|
479
|
-
}
|
|
480
|
-
const selected = response.selectedOptionIds ?? (response.selectedOptionId !== undefined ? [response.selectedOptionId] : []);
|
|
481
|
-
const optionIds = new Set(interaction.options.map((o) => o.id));
|
|
482
|
-
for (const id of selected) {
|
|
483
|
-
if (!optionIds.has(id)) {
|
|
484
|
-
throw new InputError({ error: 'invalid_field', message: `unknown option id for ${response.id}: ${id}`, field: 'responses', next: 'Use option ids from `crtr human deck <job_id> --json`.' });
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
198
|
function parseResolveBody(raw) {
|
|
490
199
|
let parsed;
|
|
491
200
|
try {
|
|
@@ -497,81 +206,68 @@ function parseResolveBody(raw) {
|
|
|
497
206
|
if (!Array.isArray(parsed.responses)) {
|
|
498
207
|
throw new InputError({ error: 'invalid_field', message: 'responses must be an array', field: 'responses', next: 'Pass {"responses":[{"interaction_id":"...","selected_option_ids":["..."]}]}.' });
|
|
499
208
|
}
|
|
500
|
-
return {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
}),
|
|
516
|
-
completedAt: parsed.completed_at ?? parsed.completedAt ?? new Date().toISOString(),
|
|
517
|
-
};
|
|
209
|
+
return parsed.responses.map((r) => {
|
|
210
|
+
const id = r.interaction_id ?? r.id;
|
|
211
|
+
if (id === undefined || id === '') {
|
|
212
|
+
throw new InputError({ error: 'invalid_field', message: 'each response needs interaction_id', field: 'responses', next: 'Include interaction_id for every response.' });
|
|
213
|
+
}
|
|
214
|
+
const selectedOptionIds = r.selected_option_ids ?? r.selectedOptionIds;
|
|
215
|
+
const selectedOptionId = r.selected_option_id ?? r.selectedOptionId;
|
|
216
|
+
return {
|
|
217
|
+
id,
|
|
218
|
+
...(selectedOptionIds !== undefined ? { selectedOptionIds } : {}),
|
|
219
|
+
...(selectedOptionId !== undefined ? { selectedOptionId } : {}),
|
|
220
|
+
...(r.freetext !== undefined ? { freetext: r.freetext } : {}),
|
|
221
|
+
...(r.optionComments !== undefined ? { optionComments: r.optionComments } : {}),
|
|
222
|
+
};
|
|
223
|
+
});
|
|
518
224
|
}
|
|
519
225
|
export const humanResolve = defineLeaf({
|
|
520
226
|
name: 'resolve',
|
|
521
227
|
description: 'resolve one humanloop deck from stdin answers',
|
|
522
|
-
whenToUse: 'a browser or local tool is submitting answers for a pending human interaction; this
|
|
228
|
+
whenToUse: 'a browser or local tool is submitting answers for a pending human interaction; this delegates to the humanloop finalize/completion path',
|
|
523
229
|
tier: 'hidden',
|
|
524
230
|
help: {
|
|
525
231
|
name: 'human resolve',
|
|
526
|
-
summary: '
|
|
232
|
+
summary: 'claim a pending deck through humanloop, finalize it with the given answers, and let the completion boundary deliver back to the asking node',
|
|
527
233
|
params: [
|
|
528
234
|
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'job_id from human ask/review/list.' },
|
|
529
|
-
{ kind: 'stdin', name: 'body', required: true, constraint: 'JSON {responses:[{interaction_id, selected_option_ids?, freetext?}]
|
|
235
|
+
{ kind: 'stdin', name: 'body', required: true, constraint: 'JSON {responses:[{interaction_id, selected_option_ids?, freetext?}]}.' },
|
|
530
236
|
],
|
|
531
237
|
output: [
|
|
532
|
-
{ name: 'resolved', type: 'boolean', required: true, constraint: 'True when this call
|
|
238
|
+
{ name: 'resolved', type: 'boolean', required: true, constraint: 'True when this call won and finalized the deck. Delivery to the asking node happens asynchronously at humanloop\'s completion boundary and may lag or fail independently of this result.' },
|
|
533
239
|
{ name: 'job_id', type: 'string', required: true, constraint: 'Interaction job id.' },
|
|
534
|
-
{ name: 'delivered', type: 'boolean', required: false, constraint: 'True when delivered through crouter push/finalize.' },
|
|
535
240
|
{ name: 'reason', type: 'string', required: false, constraint: 'already_resolved or claimed when resolved is false.' },
|
|
536
241
|
],
|
|
537
242
|
outputKind: 'object',
|
|
538
|
-
effects: ['Claims the
|
|
243
|
+
effects: ['Claims the ticket via humanloop, finalizes response.json through humanloop\'s exclusive publish, and lets humanloop invoke crouter\'s registered completion handler. Never writes response.json itself.'],
|
|
539
244
|
},
|
|
540
245
|
run: async (input) => {
|
|
541
246
|
const jobId = input['job_id'];
|
|
542
247
|
const { dir } = interactionDirForJob(jobId);
|
|
543
|
-
const
|
|
544
|
-
|
|
545
|
-
parseDeck(deckPath(dir));
|
|
546
|
-
}
|
|
547
|
-
catch {
|
|
248
|
+
const responses = parseResolveBody(input['body']);
|
|
249
|
+
if (!existsSync(deckPath(dir))) {
|
|
548
250
|
throw new InputError({ error: 'not_found', message: `no deck for interaction: ${jobId}`, field: 'job_id', next: 'Pass a pending job_id from `crtr human list`.' });
|
|
549
251
|
}
|
|
550
|
-
|
|
551
|
-
if (claim === 'already_resolved')
|
|
252
|
+
if (isResolved(dir))
|
|
552
253
|
return { resolved: false, job_id: jobId, reason: 'already_resolved' };
|
|
553
|
-
|
|
554
|
-
|
|
254
|
+
const claim = claimTicket(dir);
|
|
255
|
+
if (claim === null) {
|
|
256
|
+
return { resolved: false, job_id: jobId, reason: isResolved(dir) ? 'already_resolved' : 'claimed' };
|
|
257
|
+
}
|
|
258
|
+
let won;
|
|
555
259
|
try {
|
|
556
|
-
|
|
557
|
-
try {
|
|
558
|
-
deck = parseDeck(deckPath(dir));
|
|
559
|
-
}
|
|
560
|
-
catch {
|
|
561
|
-
throw new InputError({ error: 'not_found', message: `no deck for interaction: ${jobId}`, field: 'job_id', next: 'Pass a pending job_id from `crtr human list`.' });
|
|
562
|
-
}
|
|
563
|
-
validateResolveResponses(deck, responses);
|
|
564
|
-
const writeResult = writeResponseOnce(dir, responses, completedAt);
|
|
565
|
-
if (writeResult === 'already_resolved')
|
|
566
|
-
return { resolved: false, job_id: jobId, reason: 'already_resolved' };
|
|
567
|
-
const delivered = await finalizeResolvedInteraction(jobId, claim);
|
|
568
|
-
if (!delivered)
|
|
569
|
-
return { resolved: false, job_id: jobId, reason: 'already_resolved' };
|
|
570
|
-
return { resolved: true, job_id: jobId, delivered };
|
|
260
|
+
({ won } = await completeDeck(dir, responses, claim.token));
|
|
571
261
|
}
|
|
572
|
-
|
|
573
|
-
|
|
262
|
+
catch (e) {
|
|
263
|
+
throw new InputError({ error: 'invalid_field', message: String(e), field: 'responses', next: 'Use interaction/option ids from `crtr human deck <job_id> --json`.' });
|
|
574
264
|
}
|
|
265
|
+
if (!won)
|
|
266
|
+
return { resolved: false, job_id: jobId, reason: 'already_resolved' };
|
|
267
|
+
// Finalization won. Delivery back to the asking node is humanloop's
|
|
268
|
+
// completion-boundary responsibility (may lag or fail → delivery-error.json),
|
|
269
|
+
// so we do NOT claim delivery here — only that the deck was finalized.
|
|
270
|
+
return { resolved: true, job_id: jobId };
|
|
575
271
|
},
|
|
576
272
|
});
|
|
577
273
|
// ---------------------------------------------------------------------------
|
|
@@ -583,8 +279,8 @@ export const humanCancel = defineLeaf({
|
|
|
583
279
|
whenToUse: 'a question went stale before the human answered',
|
|
584
280
|
help: {
|
|
585
281
|
name: 'human cancel',
|
|
586
|
-
summary: 'retract a pending ask/review you posed —
|
|
587
|
-
guide: 'Pass the job_id returned by `human ask`/`review`. Best-effort and idempotent: if the human already answered, or it was already canceled, it reports canceled:false with reason "already_resolved" and changes nothing. The agent that posed the deck is almost always the one canceling it, so the caller is never messaged — only OTHER subscribers
|
|
282
|
+
summary: 'retract a pending ask/review you posed — drops it from the human inbox and retires the bridge node. Reach for this the moment a question goes stale (you answered it yourself, the situation changed) so a human is not left resolving a prompt whose answer no longer matters',
|
|
283
|
+
guide: 'Pass the job_id returned by `human ask`/`review`. Best-effort and idempotent: if the human already answered, or it was already canceled, it reports canceled:false with reason "already_resolved" and changes nothing. Cancellation races the human\'s submit through humanloop\'s exclusive final publish; whichever wins is authoritative. The agent that posed the deck is almost always the one canceling it, so the caller is never messaged — only OTHER subscribers get a quiet deferred note that no answer is coming.',
|
|
588
284
|
params: [
|
|
589
285
|
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Node id of the interaction to cancel — the job_id returned by ask/review.' },
|
|
590
286
|
{ kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Optional short note delivered to subscribers explaining why it was retracted.' },
|
|
@@ -596,9 +292,8 @@ export const humanCancel = defineLeaf({
|
|
|
596
292
|
],
|
|
597
293
|
outputKind: 'object',
|
|
598
294
|
effects: [
|
|
599
|
-
|
|
600
|
-
'
|
|
601
|
-
'Marks the node done and, only for subscribers other than the caller, drops a deferred note that no answer is coming.',
|
|
295
|
+
'Races a canceled response.json through humanloop so the interaction drops out of the human inbox.',
|
|
296
|
+
'On a winning cancellation, humanloop invokes crouter\'s completion handler, which retires the bridge node and drops a deferred no-answer note to waiting subscribers other than the caller.',
|
|
602
297
|
],
|
|
603
298
|
},
|
|
604
299
|
run: async (input) => {
|
|
@@ -616,169 +311,110 @@ export const humanCancel = defineLeaf({
|
|
|
616
311
|
// Resolve the interaction dir from the node's RECORDED cwd: interaction dirs
|
|
617
312
|
// are keyed by the asking process's cwd, which may differ from the caller's.
|
|
618
313
|
const idir = interactionDir(jobId, node.cwd);
|
|
619
|
-
// Nothing live to cancel:
|
|
620
|
-
//
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if (node.status === 'done' || node.status === 'dead' || node.status === 'canceled' || isResolved(idir)) {
|
|
314
|
+
// Nothing live to cancel: already answered/canceled/retired, or the ticket
|
|
315
|
+
// never carried a request descriptor humanloop can cancel.
|
|
316
|
+
const hasDescriptor = existsSync(deckPath(idir)) || existsSync(reviewPath(idir));
|
|
317
|
+
if (node.status === 'done' || node.status === 'dead' || node.status === 'canceled' || isResolved(idir) || !hasDescriptor) {
|
|
624
318
|
return { canceled: false, job_id: jobId, reason: 'already_resolved' };
|
|
625
319
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
const rc = readJson(join(idir, 'run.json'));
|
|
631
|
-
if (rc?.pane_id !== undefined && rc.pane_id !== '')
|
|
632
|
-
killPane(rc.pane_id, idir);
|
|
633
|
-
// A review also opened a live termrender render pane beside the editor —
|
|
634
|
-
// kill it too or it outlives the canceled job. Verified against the
|
|
635
|
-
// reviewed file path, which termrender bakes into the pane's start command
|
|
636
|
-
// (`termrender doc watch ... <file>`), so this can only ever hit our pane.
|
|
637
|
-
if (rc?.render_pane_id !== undefined && rc.render_pane_id !== '' && typeof rc.file === 'string' && rc.file !== '') {
|
|
638
|
-
killPane(rc.render_pane_id, rc.file);
|
|
639
|
-
}
|
|
640
|
-
// (2) Drop it from the human queue: a response.json marks the dir resolved,
|
|
641
|
-
// so scanInbox (human list/inbox) skips it.
|
|
642
|
-
if (existsSync(idir)) {
|
|
643
|
-
atomicWriteJson(responsePath(idir), {
|
|
644
|
-
canceled: true,
|
|
645
|
-
canceledAt: new Date().toISOString(),
|
|
646
|
-
...(reason !== undefined && reason !== '' ? { reason } : {}),
|
|
647
|
-
});
|
|
648
|
-
}
|
|
649
|
-
// (3) Retire the node. We do NOT push a -final.md report: a cancel must not
|
|
650
|
-
// masquerade as a human-submitted result. Subscribers get the quiet
|
|
651
|
-
// deferred 'no answer is coming' note below instead.
|
|
652
|
-
transition(jobId, 'finalize');
|
|
653
|
-
// Almost always the asking agent cancels its OWN deck — it already knows, so
|
|
654
|
-
// never message the caller. Only a third-party cancel (a human, an
|
|
655
|
-
// orchestrator) leaves a genuinely-waiting asker uninformed; give them a
|
|
656
|
-
// quiet deferred note (informational, never nudges) so nobody waits forever.
|
|
657
|
-
const caller = process.env['CRTR_NODE_ID'] ?? 'human';
|
|
658
|
-
const note = reason !== undefined && reason !== '' ? ` — ${reason}` : '';
|
|
659
|
-
for (const sub of subscribersOf(jobId)) {
|
|
660
|
-
if (sub.node_id === caller)
|
|
661
|
-
continue; // don't ping whoever issued the cancel
|
|
662
|
-
appendInbox(sub.node_id, {
|
|
663
|
-
from: caller,
|
|
664
|
-
tier: 'deferred',
|
|
665
|
-
kind: 'message',
|
|
666
|
-
label: `human interaction ${jobId} canceled — no answer is coming${note}`,
|
|
667
|
-
data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
|
|
668
|
-
});
|
|
320
|
+
const actor = process.env['CRTR_NODE_ID'] ?? 'human';
|
|
321
|
+
const { status } = await cancelTicket(idir, { ...(reason !== undefined && reason !== '' ? { reason } : {}), actor });
|
|
322
|
+
if (status === 'already_resolved') {
|
|
323
|
+
return { canceled: false, job_id: jobId, reason: 'already_resolved' };
|
|
669
324
|
}
|
|
325
|
+
// The winning cancellation dispatched completion → `_deliver` synchronously
|
|
326
|
+
// retired the bridge and notified subscribers. Nothing to do here.
|
|
670
327
|
return { canceled: true, job_id: jobId };
|
|
671
328
|
},
|
|
672
329
|
render: (r) => r['canceled'] === true
|
|
673
|
-
? `Canceled human interaction ${r['job_id']} —
|
|
330
|
+
? `Canceled human interaction ${r['job_id']} — it left the human inbox and subscribers were notified no answer is coming.`
|
|
674
331
|
: `Nothing to cancel for ${r['job_id']} — ${r['reason'] ?? 'nothing to cancel'}.`,
|
|
675
332
|
});
|
|
676
333
|
// ---------------------------------------------------------------------------
|
|
677
|
-
//
|
|
334
|
+
// _deliver (hidden completion handler; not listed in branch help)
|
|
678
335
|
// ---------------------------------------------------------------------------
|
|
679
|
-
|
|
680
|
-
|
|
336
|
+
/** Build the ResolutionEnvelope crouter delivers back for an answered deck,
|
|
337
|
+
* from the canonical deck result. */
|
|
338
|
+
function envelopeFromDeckResult(responses, summary, completedAt, respPath) {
|
|
339
|
+
return { summary, responsePath: respPath, schema: 'humanloop.response/v2', responses, completedAt };
|
|
340
|
+
}
|
|
341
|
+
export const humanDeliver = defineLeaf({
|
|
342
|
+
name: '_deliver',
|
|
681
343
|
tier: 'hidden',
|
|
682
344
|
help: {
|
|
683
|
-
name: 'human
|
|
684
|
-
summary: 'internal: the
|
|
685
|
-
params: [
|
|
686
|
-
|
|
687
|
-
|
|
345
|
+
name: 'human _deliver',
|
|
346
|
+
summary: 'internal: the registered humanloop completion handler — turns a resolved/canceled ticket into the existing pushFinal delivery and bridge finalization',
|
|
347
|
+
params: [
|
|
348
|
+
{ kind: 'stdin', name: 'body', required: true, constraint: 'A humanloop.completion/v1 event JSON on stdin. Not for manual use.' },
|
|
349
|
+
],
|
|
350
|
+
output: [{ name: 'delivered', type: 'boolean', required: true, constraint: 'True once the bridge is delivered/retired (or already terminal).' }],
|
|
688
351
|
outputKind: 'object',
|
|
689
|
-
effects: ['
|
|
352
|
+
effects: ['Reads the completion event, verifies root registration/containment, then pushFinals the answer (ask/review) or retires the bridge (canceled). Idempotent: replay after a crash is safe.'],
|
|
690
353
|
},
|
|
691
|
-
run: async () => {
|
|
692
|
-
const
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
}
|
|
354
|
+
run: async (input) => {
|
|
355
|
+
const event = JSON.parse(input['body']);
|
|
356
|
+
if (event.schema !== 'humanloop.completion/v1') {
|
|
357
|
+
throw new InputError({ error: 'invalid_field', message: 'not a humanloop.completion/v1 event', field: 'stdin', next: 'Only humanloop invokes this handler.' });
|
|
358
|
+
}
|
|
359
|
+
// Canonicalize both paths through realpathSync before comparing. Humanloop's
|
|
360
|
+
// registry stores every root REALPATH-resolved, but its dispatchCompletion
|
|
361
|
+
// only `resolve()`s the dir into the event (not realpath) and crouter's own
|
|
362
|
+
// `human cancel` hands it a CRTR_HOME-derived dir that is likewise not
|
|
363
|
+
// realpath'd. So on any host whose interaction path has a symlinked component
|
|
364
|
+
// (macOS `/tmp`→`/private/tmp`, `/var`→`/private/var`), a plain `resolve()`
|
|
365
|
+
// comparison mismatches the registry's realpath and the containment/registration
|
|
366
|
+
// checks below spuriously reject a legitimate completion. Realpath'ing both
|
|
367
|
+
// sides makes the trust boundary robust to whichever caller produced the event.
|
|
368
|
+
const root = canonicalPath(event.root);
|
|
369
|
+
const dir = canonicalPath(event.dir);
|
|
370
|
+
// Containment: the interaction dir must be a direct child of the event root,
|
|
371
|
+
// and that root must be registered to crouter.
|
|
372
|
+
if (resolve(dir, '..') !== root) {
|
|
373
|
+
throw new InputError({ error: 'invalid_field', message: 'interaction dir is not a direct child of the event root', field: 'stdin', next: 'Only humanloop invokes this handler.' });
|
|
374
|
+
}
|
|
375
|
+
const registered = listInboxRoots().some((r) => r.root === root && r.owner === 'crouter');
|
|
376
|
+
if (!registered) {
|
|
377
|
+
throw new InputError({ error: 'invalid_field', message: `root is not registered to crouter: ${root}`, field: 'stdin', next: 'Only humanloop invokes this handler.' });
|
|
378
|
+
}
|
|
379
|
+
const jobId = basename(dir);
|
|
696
380
|
const rc = readJson(join(dir, 'run.json'));
|
|
697
|
-
|
|
698
|
-
|
|
381
|
+
// notify: a durable deck with no bridge node and no answer delivery.
|
|
382
|
+
if (rc?.mode === 'notify')
|
|
383
|
+
return { delivered: true };
|
|
384
|
+
const result = readTicketResult(event.responsePath);
|
|
385
|
+
if (result === null) {
|
|
386
|
+
throw new InputError({ error: 'not_found', message: 'no canonical result to deliver', field: 'stdin', next: 'Only humanloop invokes this handler.' });
|
|
699
387
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const claim = acquireInteractionClaim(dir, rc.job_id);
|
|
705
|
-
if (claim === 'already_resolved') {
|
|
706
|
-
await finalizeResolvedInteraction(rc.job_id);
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
if (claim === 'claimed')
|
|
710
|
-
return;
|
|
711
|
-
try {
|
|
712
|
-
await ask(deck, { dir });
|
|
713
|
-
// Pass the held claim: finalize re-acquires the same exclusive
|
|
714
|
-
// resolve.lock, which THIS worker still holds — without the claim it
|
|
715
|
-
// would see its own live lock, return 'claimed', and never deliver
|
|
716
|
-
// the answer (stranding the asker). The held claim makes finalize
|
|
717
|
-
// run under our lock; _run's finally still owns the release.
|
|
718
|
-
await finalizeResolvedInteraction(rc.job_id, claim);
|
|
719
|
-
}
|
|
720
|
-
finally {
|
|
721
|
-
claim.release();
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
else {
|
|
725
|
-
await ask(deck, { dir });
|
|
726
|
-
}
|
|
727
|
-
// notify: no job — nothing to write
|
|
728
|
-
}
|
|
729
|
-
else if (rc.mode === 'review') {
|
|
730
|
-
// The _run worker is already its own dedicated tmux pane with a TTY, so
|
|
731
|
-
// run nvim directly in it (noTmux) instead of letting launchReview
|
|
732
|
-
// split off a SECOND pane and sit polling.
|
|
733
|
-
//
|
|
734
|
-
// BUG REGRESSION (raw-markdown review): the nvim buffer must stay the
|
|
735
|
-
// RAW source — anchored comments hang off source line numbers — so the
|
|
736
|
-
// termrender render the help promises (panels/callouts/mermaid) lives
|
|
737
|
-
// in its OWN live pane opened beside this worker. `display` spawns the
|
|
738
|
-
// managed termrender binary in watch mode, so it re-renders on every
|
|
739
|
-
// save exactly like the nvim buffer reloads. Best-effort: off-tmux or
|
|
740
|
-
// renderer-unavailable degrades to editor-only. The pane id is merged
|
|
741
|
-
// into run.json so `human cancel` can kill it; the finally clears it on
|
|
742
|
-
// every exit of the editor (submit, quit, or failure).
|
|
743
|
-
let renderPane;
|
|
744
|
-
try {
|
|
745
|
-
renderPane = display(rc.file, { window: 'split' }).paneId;
|
|
746
|
-
}
|
|
747
|
-
catch {
|
|
748
|
-
/* render pane is best-effort; the review itself must not die */
|
|
749
|
-
}
|
|
750
|
-
// Dock the render pane BESIDE this worker pane. tmux resolves an
|
|
751
|
-
// untargeted split-window against the attached client's current window
|
|
752
|
-
// (which wins over $TMUX_PANE), so the pane can land in whatever window
|
|
753
|
-
// the user happens to be looking at — away from the editor. move-pane
|
|
754
|
-
// with explicit src/dst is deterministic: raw source and rendered doc
|
|
755
|
-
// always sit side by side.
|
|
756
|
-
const selfPane = process.env['TMUX_PANE'];
|
|
757
|
-
if (renderPane !== undefined && selfPane !== undefined && selfPane !== '') {
|
|
758
|
-
spawnSync('tmux', ['move-pane', '-h', '-s', renderPane, '-t', selfPane], { stdio: 'ignore' });
|
|
759
|
-
}
|
|
760
|
-
if (renderPane !== undefined) {
|
|
761
|
-
const rcPath = join(dir, 'run.json');
|
|
762
|
-
const cur = readJson(rcPath);
|
|
763
|
-
if (cur !== null)
|
|
764
|
-
atomicWriteJson(rcPath, { ...cur, render_pane_id: renderPane });
|
|
765
|
-
}
|
|
766
|
-
try {
|
|
767
|
-
const res = await launchReview(rc.file, {
|
|
768
|
-
output: rc.output,
|
|
769
|
-
});
|
|
770
|
-
await pushFinal(rc.job_id, JSON.stringify(res));
|
|
771
|
-
}
|
|
772
|
-
finally {
|
|
773
|
-
if (renderPane !== undefined)
|
|
774
|
-
killPane(renderPane, rc.file);
|
|
775
|
-
}
|
|
776
|
-
}
|
|
388
|
+
const node = getNode(jobId);
|
|
389
|
+
// Replay-safe: an already-terminal (or reaped) bridge needs no further work.
|
|
390
|
+
if (node === null || node.status === 'done' || node.status === 'dead' || node.status === 'canceled') {
|
|
391
|
+
return { delivered: true };
|
|
777
392
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
393
|
+
if (result.kind === 'canceled') {
|
|
394
|
+
transition(jobId, 'finalize');
|
|
395
|
+
const note = result.reason !== undefined && result.reason !== '' ? ` — ${result.reason}` : '';
|
|
396
|
+
const actor = result.actor;
|
|
397
|
+
for (const sub of subscribersOf(jobId)) {
|
|
398
|
+
if (actor !== undefined && sub.node_id === actor)
|
|
399
|
+
continue;
|
|
400
|
+
appendInbox(sub.node_id, {
|
|
401
|
+
from: jobId,
|
|
402
|
+
tier: 'deferred',
|
|
403
|
+
kind: 'message',
|
|
404
|
+
label: `human interaction ${jobId} canceled — no answer is coming${note}`,
|
|
405
|
+
data: { body: `The human interaction ${jobId} was canceled${note}. No response will arrive.` },
|
|
406
|
+
});
|
|
781
407
|
}
|
|
408
|
+
return { delivered: true };
|
|
409
|
+
}
|
|
410
|
+
if (result.kind === 'review') {
|
|
411
|
+
const feedback = result.result;
|
|
412
|
+
await pushFinal(jobId, JSON.stringify(feedback));
|
|
413
|
+
return { delivered: true };
|
|
782
414
|
}
|
|
415
|
+
// deck
|
|
416
|
+
const env = envelopeFromDeckResult(result.responses, result.summary, result.completedAt, event.responsePath);
|
|
417
|
+
await pushFinal(jobId, JSON.stringify(env));
|
|
418
|
+
return { delivered: true };
|
|
783
419
|
},
|
|
784
420
|
});
|