hilos-agent 0.10.1 → 0.11.2

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.
@@ -0,0 +1,211 @@
1
+ // 1182 — room directions for a daemon run.
2
+ //
3
+ // A person can add a direction to a running job from its live card. The
4
+ // server holds it on the run and lists it back on this daemon's own progress
5
+ // heartbeat as `pendingInputs` (1181); nothing is pushed, and nothing reaches
6
+ // the coding CLI mid-turn (its stdin is closed by design). So the daemon:
7
+ //
8
+ // 1. harvests `pendingInputs` off every post_progress reply, deduplicated
9
+ // by `inputId` across beats, and answers `queued` once per new direction
10
+ // (the server keeps listing it until a real answer lands; `queued` is a
11
+ // courtesy, never a claim of delivery);
12
+ // 2. after the coding turn ends, runs ONE more CLI turn with the pending
13
+ // directions as its prompt and answers `acknowledged` with that turn's
14
+ // command id — the only answer that counts as delivered;
15
+ // 3. once the run is past the point of running turns, answers
16
+ // `unsupported` for anything that arrives late, so the room is told now
17
+ // rather than at run end.
18
+ //
19
+ // Every server call here is best-effort and never throws into the run: a
20
+ // direction the daemon could not settle stays listed, and the server's
21
+ // run-end rule drops it with the room's correction when the run stops.
22
+
23
+ /** The largest batch of directions one heartbeat reply may carry. */
24
+ const PENDING_INPUTS_MAX = 20;
25
+ /** Shapes a post_progress reply into the pending list, or `[]`. */
26
+ export function readPendingInputs(res) {
27
+ const list = res && Array.isArray(res.pendingInputs) ? res.pendingInputs : [];
28
+ const out = [];
29
+ for (const item of list) {
30
+ if (!item || typeof item !== "object") continue;
31
+ const inputId = typeof item.inputId === "string" ? item.inputId : null;
32
+ const body = typeof item.body === "string" ? item.body.trim() : "";
33
+ if (!inputId || !body) continue;
34
+ out.push({
35
+ inputId,
36
+ ordinal: Number.isSafeInteger(item.ordinal) ? item.ordinal : Number.MAX_SAFE_INTEGER,
37
+ body,
38
+ });
39
+ if (out.length >= PENDING_INPUTS_MAX) break;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * The run's direction inbox.
46
+ *
47
+ * @param {{ tool: Function, runId: string, log?: (line: string) => void,
48
+ * answerTimeoutMs?: number, closeDeadlineMs?: number, now?: () => number }} o
49
+ */
50
+ export function createDirectionInbox({
51
+ tool,
52
+ runId,
53
+ log = () => {},
54
+ answerTimeoutMs = 5000,
55
+ closeDeadlineMs = 15000,
56
+ now = Date.now,
57
+ }) {
58
+ /** @type {Map<string, {inputId: string, ordinal: number, body: string}>} */
59
+ const pending = new Map();
60
+ /** Every id ever listed to us, so a beat that lists it again changes nothing. */
61
+ const seen = new Set();
62
+ /**
63
+ * Receipts the server did not confirm (a blip on the one call that counts).
64
+ * The server keeps listing an unsettled direction on every beat, so the
65
+ * next beat that names one of these is the retry: the same command id, the
66
+ * same turn, never a second story.
67
+ * @type {Map<string, {ackId: string, turn?: number}>}
68
+ */
69
+ const unacked = new Map();
70
+ let closed = false;
71
+
72
+ // Bounded: `close()` runs on the ship's critical path, and a hung server
73
+ // must delay the push by at most this per answer, never hang it.
74
+ const answer = async (inputId, outcome, extra = {}) => {
75
+ let timer = null;
76
+ try {
77
+ const call = Promise.resolve(tool("acknowledge_run_input", { runId, inputId, outcome, ...extra }));
78
+ const late = new Promise((_, reject) => {
79
+ timer = setTimeout(() => reject(new Error(`no answer within ${answerTimeoutMs}ms`)), answerTimeoutMs);
80
+ if (typeof timer.unref === "function") timer.unref();
81
+ });
82
+ const r = await Promise.race([call, late]);
83
+ return r && typeof r === "object" ? r : null;
84
+ } catch (error) {
85
+ log(` direction → ${outcome} for ${inputId} did not reach the server: ${error?.message ?? error}`);
86
+ return null;
87
+ } finally {
88
+ if (timer) clearTimeout(timer);
89
+ }
90
+ };
91
+ const acknowledgeOne = async (inputId, receipt) => {
92
+ const r = await answer(inputId, "acknowledged", {
93
+ ackId: receipt.ackId,
94
+ ...(Number.isSafeInteger(receipt.turn) ? { turn: receipt.turn } : {}),
95
+ });
96
+ if (r && r.ok === true) {
97
+ unacked.delete(inputId);
98
+ return true;
99
+ }
100
+ unacked.set(inputId, receipt);
101
+ return false;
102
+ };
103
+
104
+ return {
105
+ /** Fold one heartbeat reply's list in. New ids are answered once. */
106
+ absorb(list) {
107
+ for (const item of readPendingInputs({ pendingInputs: list })) {
108
+ if (unacked.has(item.inputId)) {
109
+ // Still listed because our receipt never landed: send it again.
110
+ void acknowledgeOne(item.inputId, unacked.get(item.inputId));
111
+ continue;
112
+ }
113
+ if (seen.has(item.inputId)) continue;
114
+ seen.add(item.inputId);
115
+ if (closed) {
116
+ log(` direction → arrived after the last turn; telling the room it will not be applied`);
117
+ void answer(item.inputId, "unsupported", { reason: "no-turn-left" });
118
+ continue;
119
+ }
120
+ pending.set(item.inputId, item);
121
+ log(` direction → held for the next turn (${pending.size} pending)`);
122
+ void answer(item.inputId, "queued");
123
+ }
124
+ },
125
+ /** Take everything pending, oldest first. The turn that follows owns them. */
126
+ take() {
127
+ const batch = [...pending.values()].sort((a, b) => a.ordinal - b.ordinal);
128
+ pending.clear();
129
+ return batch;
130
+ },
131
+ size() {
132
+ return pending.size;
133
+ },
134
+ /**
135
+ * The turn ran with these directions in its prompt: say so, with the
136
+ * exact command id and the turn it went into. Answered one by one so a
137
+ * lost reply settles the rest.
138
+ */
139
+ async acknowledge(batch, { ackId, turn }) {
140
+ let delivered = 0;
141
+ for (const item of batch) {
142
+ if (await acknowledgeOne(item.inputId, { ackId, turn })) delivered += 1;
143
+ }
144
+ return delivered;
145
+ },
146
+ /**
147
+ * No further turn will run. Whatever is still held could not be applied
148
+ * in this pass, and anything listed from now on cannot be either: both
149
+ * are answered `unsupported` with the reason `no-turn-left`, so the room
150
+ * hears the true reason now (this runner takes notes; this pass has no
151
+ * turn left) rather than when the run ends. A receipt still unconfirmed
152
+ * is tried once more.
153
+ */
154
+ async close() {
155
+ closed = true;
156
+ const held = [...pending.values()];
157
+ pending.clear();
158
+ // One overall deadline on top of the per-answer bound: the ship waits
159
+ // at most this long for the room to be told. Receipts go first (a lost
160
+ // one would otherwise become a false correction); what is not answered
161
+ // in time stays the server's to correct at the run's end.
162
+ const deadline = now() + closeDeadlineMs;
163
+ for (const [inputId, receipt] of [...unacked.entries()]) {
164
+ if (now() >= deadline) break;
165
+ await acknowledgeOne(inputId, receipt);
166
+ }
167
+ for (const item of held) {
168
+ if (now() >= deadline) {
169
+ log(` direction → out of time telling the room about ${item.inputId}; the run's end will`);
170
+ continue;
171
+ }
172
+ log(` direction → no turn left to apply it; telling the room it will not be applied`);
173
+ await answer(item.inputId, "unsupported", { reason: "no-turn-left" });
174
+ }
175
+ return held.length;
176
+ },
177
+ get closed() {
178
+ return closed;
179
+ },
180
+ /** Receipts the server has not confirmed yet (visible for tests and logs). */
181
+ unconfirmed() {
182
+ return [...unacked.keys()];
183
+ },
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Did this `runAndStage` result come from a CLI that actually ran? A spawn
189
+ * failure (a misconfigured command, ENOENT) returns in milliseconds with an
190
+ * error code and nothing else; no runtime ever saw the prompt, so no receipt
191
+ * may be written for it. A turn that ran and then failed is still a delivery:
192
+ * the runtime took the command, and the report tells the outcome.
193
+ */
194
+ export function directionTurnRan(applied) {
195
+ if (!applied || applied.aborted) return false;
196
+ return !(applied.empty && applied.failed && applied.errCode);
197
+ }
198
+
199
+
200
+ /** The prompt for the direction turn: the original task, then the directions. */
201
+ export function directionTurnPrompt(codePrompt, batch) {
202
+ const lines = batch.map((item) => `- ${item.body}`).join("\n");
203
+ return (
204
+ `${codePrompt}\n\n` +
205
+ `While you were working, a person in the room added direction for this same task. ` +
206
+ `Continue from the current state of the working tree and apply it:\n${lines}`
207
+ );
208
+ }
209
+
210
+ /** How many direction turns one run may take before the rest waits for the next pass. */
211
+ export const MAX_DIRECTION_TURNS = 3;