@sagentlab/navarch-runtime 0.1.130 → 0.1.131

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,458 @@
1
+ "use strict";
2
+ // Runtime half of live steering (docs/navarch/live-steering.md sections 5, 6).
3
+ //
4
+ // The control plane hands a running lease the messages a human sent to its
5
+ // task, through the lease heartbeat and the steering long-poll. This module
6
+ // holds them until session.cts can deliver each one: a `queue` message when
7
+ // the current turn ends, an `interrupt_restart` message by stopping the turn
8
+ // and starting the next one in the same worktree. Every message is then
9
+ // acknowledged, so the sender sees what happened to it and a message that
10
+ // could not be delivered falls back to the next session instead of vanishing.
11
+ //
12
+ // Most adapters take no input during a turn, so their messages are delivered
13
+ // at the turn boundary or by restarting the turn. An adapter that reports
14
+ // `steer: native` (Claude Code over streaming input) also takes a message into
15
+ // the running turn, and confirms it when the agent has read it.
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.SteeringSession = void 0;
18
+ exports.liveSteeringCapabilities = liveSteeringCapabilities;
19
+ exports.canTakeLiveMessages = canTakeLiveMessages;
20
+ exports.steeringPendingMessages = steeringPendingMessages;
21
+ exports.steeringSenderName = steeringSenderName;
22
+ const api_cjs_1 = require("./api.cjs");
23
+ const logger_cjs_1 = require("./logger.cjs");
24
+ const prompt_cjs_1 = require("./prompt.cjs");
25
+ const log = (0, logger_cjs_1.createLogger)("steering");
26
+ /** Seconds the long-poll holds a request open (section 6.2; the server caps it at 25). */
27
+ const LONG_POLL_WAIT_SECONDS = 20;
28
+ /** A poll that returns faster than this is paced, so a misbehaving proxy cannot spin the loop. */
29
+ const MIN_POLL_INTERVAL_MS = 500;
30
+ const MAX_POLL_BACKOFF_MS = 30_000;
31
+ /** Consecutive 404s from the long-poll that mean the control plane has no steering endpoint. */
32
+ const LONG_POLL_NOT_FOUND_LIMIT = 3;
33
+ /** Waits between attempts at one ack; about 15 s in all, well inside the 90 s `deliver_by`. */
34
+ const ACK_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
35
+ const SENDER_NAME_MAX_LENGTH = 80;
36
+ /**
37
+ * The record to send at claim, or undefined for a legacy runtime: an adapter
38
+ * that declares none, or an operator who set NAVARCH_LIVE_STEERING=off.
39
+ */
40
+ function liveSteeringCapabilities(adapter, config) {
41
+ if (config.liveSteering === false)
42
+ return undefined;
43
+ const { steering } = adapter;
44
+ return steering ? { steer: steering.steer, queue: steering.queue, interrupt: steering.interrupt } : undefined;
45
+ }
46
+ /** True when the record lets the control plane send this runtime anything live. */
47
+ function canTakeLiveMessages(capabilities) {
48
+ return Boolean(capabilities && (capabilities.steer !== "none" || capabilities.queue !== "none" || capabilities.interrupt !== "none"));
49
+ }
50
+ /**
51
+ * The messages a 409 `steering_pending` completion refusal lists, or null when
52
+ * the error is anything else (section 6.6).
53
+ */
54
+ function steeringPendingMessages(err) {
55
+ if (!(err instanceof api_cjs_1.NavarchApiError) || err.status !== 409)
56
+ return null;
57
+ const body = err.body;
58
+ if (typeof body !== "object" || body === null || Array.isArray(body))
59
+ return null;
60
+ const record = body;
61
+ if (record.code !== "steering_pending")
62
+ return null;
63
+ return Array.isArray(record.messages) ? record.messages : [];
64
+ }
65
+ function defaultSleep(ms, signal) {
66
+ return new Promise((resolve) => {
67
+ const timer = setTimeout(resolve, ms);
68
+ signal?.addEventListener("abort", () => {
69
+ clearTimeout(timer);
70
+ resolve();
71
+ }, { once: true });
72
+ });
73
+ }
74
+ /** One display line for the prompt header; the name is profile data, so it cannot add structure. */
75
+ function steeringSenderName(name) {
76
+ const clean = (name ?? "").replace(/[\u0000-\u001f\u007f\[\]]+/g, " ").replace(/\s+/g, " ").trim();
77
+ return clean ? clean.slice(0, SENDER_NAME_MAX_LENGTH) : null;
78
+ }
79
+ /**
80
+ * Per-lease steering state: what was received, what each turn was handed, and
81
+ * the acks. The session loop owns the turns; this owns the messages.
82
+ */
83
+ class SteeringSession {
84
+ options;
85
+ entries = new Map();
86
+ acks = new Set();
87
+ pollAbort = new AbortController();
88
+ sleep;
89
+ ackRetryDelaysMs;
90
+ cursorSeq = 0;
91
+ stopped = false;
92
+ poller = null;
93
+ capabilities;
94
+ channel = null;
95
+ /** What the adapter calls while it runs a turn that takes input (native steering). */
96
+ liveInput = {
97
+ attach: (channel) => this.attachChannel(channel),
98
+ consumed: (id, afterTurnEnd) => this.steerConsumed(id, afterTurnEnd),
99
+ };
100
+ constructor(options) {
101
+ this.options = options;
102
+ this.capabilities = options.capabilities;
103
+ this.sleep = options.sleep ?? defaultSleep;
104
+ this.ackRetryDelaysMs = options.ackRetryDelaysMs ?? ACK_RETRY_DELAYS_MS;
105
+ }
106
+ /** The adapter's probe found more than the claim promised: use (and report) the upgraded record. */
107
+ setCapabilities(capabilities) {
108
+ this.capabilities = capabilities;
109
+ }
110
+ /** Highest `seq` taken so far: the `steering_after_seq` of the next pull. */
111
+ get cursor() {
112
+ return this.cursorSeq;
113
+ }
114
+ /** A message is waiting for a turn to carry it. */
115
+ hasDeliverable() {
116
+ for (const entry of this.entries.values())
117
+ if (entry.phase === "held")
118
+ return true;
119
+ return false;
120
+ }
121
+ /** Starts the long-poll. A no-op for a record that takes nothing, and after stop(). */
122
+ start() {
123
+ if (this.poller || this.stopped || !canTakeLiveMessages(this.capabilities))
124
+ return;
125
+ this.poller = this.pollLoop();
126
+ }
127
+ /** Ends the long-poll. Acks already started still finish; no new retries begin. */
128
+ stop() {
129
+ this.stopped = true;
130
+ this.pollAbort.abort();
131
+ }
132
+ /**
133
+ * Takes what one pull returned. Safe to call with the same message again:
134
+ * a pull from an earlier cursor, or the heartbeat after the long-poll, can
135
+ * return a message this session already holds.
136
+ */
137
+ ingest(messages, cursor) {
138
+ if (typeof cursor === "number" && cursor > this.cursorSeq)
139
+ this.cursorSeq = cursor;
140
+ if (!messages)
141
+ return;
142
+ for (const message of [...messages].sort((a, b) => a.seq - b.seq))
143
+ this.ingestOne(message);
144
+ }
145
+ /** One immediate pull, without waiting. Used to close the completion race (section 6.6). */
146
+ async pullNow(afterSeq = this.cursorSeq) {
147
+ const pulled = await this.options.transport.pollSteering(this.options.leaseId, {
148
+ afterSeq,
149
+ waitSeconds: 0,
150
+ });
151
+ this.ingest(pulled.steering, pulled.steering_cursor);
152
+ }
153
+ /**
154
+ * Selects every held message, in `seq` order, for the turn that starts now.
155
+ * An interrupt carries each earlier queued message with it (section 6.4), and
156
+ * at a turn boundary a queued message joins whatever else is waiting, so one
157
+ * turn takes the lot.
158
+ */
159
+ takeForTurn() {
160
+ const held = [...this.entries.values()]
161
+ .filter((entry) => entry.phase === "held")
162
+ .sort((a, b) => a.message.seq - b.message.seq);
163
+ return held.map((entry) => {
164
+ entry.phase = "taken";
165
+ return {
166
+ message: entry.message,
167
+ // `interrupt_restart` is reported only when a running turn really was
168
+ // stopped; one that arrived between turns, or that carried an earlier
169
+ // queued note, was delivered at a turn boundary.
170
+ mode: entry.message.effective_mode === "interrupt_restart" && entry.stoppedTurn
171
+ ? "interrupt_restart"
172
+ : "queue",
173
+ stoppedTurn: entry.stoppedTurn,
174
+ };
175
+ });
176
+ }
177
+ /** How the prompt introduces a taken message (see prompt.cts#renderMidFlightGuidanceSection). */
178
+ toGuidance(taken) {
179
+ return taken.map(({ message, stoppedTurn }) => this.guidanceFor(message, stoppedTurn));
180
+ }
181
+ guidanceFor(message, interrupted) {
182
+ return {
183
+ // The `guidance.given` event id is what the claim bundle and the legacy
184
+ // heartbeat list use, so the session's de-duplication sees the same id.
185
+ id: message.guidance_event_id ?? message.id,
186
+ content: message.content,
187
+ actor_id: message.actor_id,
188
+ given_at: message.created_at,
189
+ message_id: message.id,
190
+ steering: {
191
+ seq: message.seq,
192
+ mode: message.requested_mode,
193
+ sender: steeringSenderName(message.actor_display_name),
194
+ interrupted,
195
+ ...(this.capabilities.steer === "native" && this.options.token ? { token: this.options.token } : {}),
196
+ },
197
+ };
198
+ }
199
+ /**
200
+ * The turn's agent process is gone. A message written to it that was never
201
+ * read goes back to waiting, to ride in the next turn's prompt (and be acked
202
+ * as delivered at the turn boundary).
203
+ */
204
+ turnEnded() {
205
+ this.channel = null;
206
+ for (const entry of this.entries.values())
207
+ if (entry.phase === "sent")
208
+ entry.phase = "held";
209
+ }
210
+ /** The agent process for the turn started, so it holds the messages: report them delivered. */
211
+ markInjected(taken) {
212
+ for (const item of taken) {
213
+ const entry = this.entries.get(item.message.id);
214
+ if (!entry || entry.phase === "done")
215
+ continue;
216
+ entry.phase = "done";
217
+ void this.sendAck(item.message.id, entry.cancelRequested
218
+ ? {
219
+ state: "too_late",
220
+ effective_mode: item.mode,
221
+ detail: "The cancel arrived after the message was handed to the agent.",
222
+ }
223
+ : { state: "delivered", effective_mode: item.mode });
224
+ }
225
+ }
226
+ /** The turn could not carry these messages: hand them to the next session. */
227
+ failTaken(taken, detail) {
228
+ for (const item of taken) {
229
+ const entry = this.entries.get(item.message.id);
230
+ if (!entry || entry.phase === "done")
231
+ continue;
232
+ entry.phase = "done";
233
+ void this.sendAck(item.message.id, { state: "failed", detail });
234
+ }
235
+ return this.flush();
236
+ }
237
+ /** The session is ending with messages unread: hand them to the next session. */
238
+ failHeld(detail) {
239
+ for (const [id, entry] of this.entries) {
240
+ if (entry.phase !== "held")
241
+ continue;
242
+ entry.phase = "done";
243
+ void this.sendAck(id, { state: "failed", detail });
244
+ }
245
+ return this.flush();
246
+ }
247
+ /**
248
+ * A completion refusal listed messages this runtime has nothing to deliver
249
+ * them with: hand them to the next session (section 6.6).
250
+ */
251
+ failListed(messages, detail) {
252
+ for (const { id } of messages) {
253
+ const entry = this.entries.get(id);
254
+ if (entry?.phase === "held")
255
+ continue;
256
+ if (entry)
257
+ entry.phase = "done";
258
+ void this.sendAck(id, { state: "failed", detail });
259
+ }
260
+ return this.flush();
261
+ }
262
+ /** Resolves when every ack started so far has been answered or abandoned. */
263
+ async flush() {
264
+ while (this.acks.size > 0)
265
+ await Promise.allSettled([...this.acks]);
266
+ }
267
+ attachChannel(channel) {
268
+ this.channel = channel;
269
+ if (!channel)
270
+ return;
271
+ // A steer that arrived after this turn's prompt was built has no other way in.
272
+ for (const entry of this.entries.values())
273
+ this.trySteer(entry);
274
+ }
275
+ /** Writes a held `steer` message to the running agent. Left held when there is no open input. */
276
+ trySteer(entry) {
277
+ if (entry.phase !== "held" || entry.message.effective_mode !== "steer" || !this.channel)
278
+ return;
279
+ const sent = this.channel.send({
280
+ id: entry.message.id,
281
+ text: (0, prompt_cjs_1.renderSteerInjection)(this.guidanceFor(entry.message, false)),
282
+ });
283
+ if (!sent)
284
+ return;
285
+ entry.phase = "sent";
286
+ log.info(`steer message ${entry.message.id} (seq ${entry.message.seq}) written to the running agent.`);
287
+ }
288
+ /** The agent read a steered message: only now is it delivered. */
289
+ steerConsumed(id, afterTurnEnd) {
290
+ const entry = this.entries.get(id);
291
+ if (!entry || entry.phase !== "sent")
292
+ return;
293
+ entry.phase = "done";
294
+ this.options.onSteerConsumed?.(this.guidanceFor(entry.message, false));
295
+ // A message the agent read only after its turn ended began the next turn:
296
+ // that is a boundary delivery, whatever it was sent as.
297
+ const mode = afterTurnEnd ? "queue" : "steer";
298
+ void this.sendAck(id, entry.cancelRequested
299
+ ? {
300
+ state: "too_late",
301
+ effective_mode: mode,
302
+ detail: "The cancel arrived after the message was handed to the agent.",
303
+ }
304
+ : { state: "delivered", effective_mode: mode });
305
+ }
306
+ ingestOne(message) {
307
+ const known = this.entries.get(message.id);
308
+ if (message.cancel_requested) {
309
+ if (!known) {
310
+ // Never seen, so never delivered: honor the cancel.
311
+ this.entries.set(message.id, { message, phase: "done", stoppedTurn: false, cancelRequested: true });
312
+ void this.sendAck(message.id, { state: "canceled", detail: "Canceled before the runtime read it." });
313
+ }
314
+ else if (known.phase === "held") {
315
+ known.phase = "done";
316
+ void this.sendAck(message.id, { state: "canceled", detail: "Canceled before the next turn started." });
317
+ }
318
+ else if (known.phase === "taken" || known.phase === "sent") {
319
+ // Already in the agent's hands: the ack will say the cancel was too late.
320
+ known.cancelRequested = true;
321
+ }
322
+ return;
323
+ }
324
+ if (known) {
325
+ // A copy of a message this session already knows. Only a held message can
326
+ // still change. The CX-1.1b substrate retargets only a `pending` message
327
+ // (a queued one is dispatched at once), so this is a guard for a control
328
+ // plane that retargets a dispatched one, as the design describes.
329
+ if (known.phase === "held" && known.message.effective_mode !== message.effective_mode) {
330
+ known.message = message;
331
+ this.stopTurnForInterrupt(known);
332
+ }
333
+ return;
334
+ }
335
+ const entry = { message, phase: "held", stoppedTurn: false, cancelRequested: false };
336
+ this.entries.set(message.id, entry);
337
+ const refusal = this.refusal(message);
338
+ if (refusal) {
339
+ entry.phase = "done";
340
+ log.warn(`message ${message.id} (seq ${message.seq}) refused: ${refusal}`);
341
+ void this.sendAck(message.id, { state: "failed", detail: refusal });
342
+ return;
343
+ }
344
+ this.stopTurnForInterrupt(entry);
345
+ this.trySteer(entry);
346
+ }
347
+ /** Why this runtime cannot deliver `message` as the control plane resolved it, or null. */
348
+ refusal(message) {
349
+ const { capabilities } = this;
350
+ switch (message.effective_mode) {
351
+ case "queue":
352
+ return capabilities.queue === "none" ? "This runtime does not queue messages." : null;
353
+ case "interrupt_restart":
354
+ return capabilities.interrupt === "restart" ? null : "This runtime cannot restart a turn for an interrupt.";
355
+ case "steer":
356
+ return capabilities.steer === "native" ? null : "This runtime cannot inject a message into a running turn.";
357
+ case "interrupt_native":
358
+ return "This runtime cannot interrupt a running turn without restarting it.";
359
+ default:
360
+ return `This runtime cannot deliver a message resolved as ${String(message.effective_mode)}.`;
361
+ }
362
+ }
363
+ stopTurnForInterrupt(entry) {
364
+ if (entry.message.effective_mode !== "interrupt_restart" || entry.stoppedTurn)
365
+ return;
366
+ entry.stoppedTurn = this.options.interruptTurn();
367
+ log.info(`interrupt message ${entry.message.id} (seq ${entry.message.seq}) ` +
368
+ `${entry.stoppedTurn ? "stopped the running turn; the next turn reads it" : "is held for the next turn"}.`);
369
+ }
370
+ sendAck(messageId, ack) {
371
+ const task = this.ackWithRetry(messageId, ack).finally(() => this.acks.delete(task));
372
+ this.acks.add(task);
373
+ return task;
374
+ }
375
+ async ackWithRetry(messageId, ack) {
376
+ for (let attempt = 0;; attempt += 1) {
377
+ try {
378
+ await this.options.transport.ackSteering(this.options.leaseId, messageId, ack);
379
+ log.info(`message ${messageId}: acked ${ack.state}${ack.effective_mode ? ` as ${ack.effective_mode}` : ""}.`);
380
+ return;
381
+ }
382
+ catch (err) {
383
+ if (err instanceof api_cjs_1.NavarchApiError && err.status >= 400 && err.status < 500 && err.status !== 408 && err.status !== 429) {
384
+ // 409 already_fell_back: the control plane gave up on this lease and
385
+ // will hand the message to the next session, so a message already in
386
+ // a prompt may be read twice. The prompt header carries the id.
387
+ log.warn(`message ${messageId}: control plane refused the ${ack.state} ack (${err.status} ${describeBody(err.body)})` +
388
+ `${ack.state === "delivered" ? "; the next session may read it again." : "."}`);
389
+ return;
390
+ }
391
+ const delay = this.ackRetryDelaysMs[attempt];
392
+ if (delay === undefined || this.stopped) {
393
+ log.warn(`message ${messageId}: giving up on the ${ack.state} ack: ${String(err)}`);
394
+ return;
395
+ }
396
+ await this.sleep(delay);
397
+ }
398
+ }
399
+ }
400
+ async pollLoop() {
401
+ let failures = 0;
402
+ let notFound = 0;
403
+ while (!this.stopped) {
404
+ const startedAt = Date.now();
405
+ try {
406
+ const pulled = await this.options.transport.pollSteering(this.options.leaseId, {
407
+ afterSeq: this.cursorSeq,
408
+ waitSeconds: LONG_POLL_WAIT_SECONDS,
409
+ signal: this.pollAbort.signal,
410
+ });
411
+ failures = 0;
412
+ notFound = 0;
413
+ if (this.stopped)
414
+ return;
415
+ this.ingest(pulled.steering, pulled.steering_cursor);
416
+ const elapsed = Date.now() - startedAt;
417
+ if (elapsed < MIN_POLL_INTERVAL_MS)
418
+ await this.sleep(MIN_POLL_INTERVAL_MS - elapsed, this.pollAbort.signal);
419
+ }
420
+ catch (err) {
421
+ if (this.stopped)
422
+ return;
423
+ if (err instanceof api_cjs_1.NavarchApiError && (err.status === 403 || err.status === 410)) {
424
+ // The lease is gone, and the heartbeat path is what stops the session.
425
+ log.info(`steering long-poll ended for ${this.options.leaseId}: ${err.status}.`);
426
+ return;
427
+ }
428
+ if (err instanceof api_cjs_1.NavarchApiError && err.status === 404) {
429
+ // A control plane without the endpoint answers 404 every time, and the
430
+ // heartbeat is then the only transport. One 404 can be a rollout or a
431
+ // proxy, and giving up on a session for it would turn a message's 90 s
432
+ // deadline into the heartbeat interval, so ask again before concluding.
433
+ notFound += 1;
434
+ if (notFound >= LONG_POLL_NOT_FOUND_LIMIT) {
435
+ log.info(`steering long-poll ended for ${this.options.leaseId}: the control plane has no steering endpoint.`);
436
+ return;
437
+ }
438
+ }
439
+ else {
440
+ notFound = 0;
441
+ }
442
+ failures += 1;
443
+ const backoff = Math.min(MAX_POLL_BACKOFF_MS, 1_000 * 2 ** Math.min(failures, 5));
444
+ log.warn(`steering long-poll failed for ${this.options.leaseId}: ${String(err)}; retrying in ${backoff / 1000}s.`);
445
+ await this.sleep(backoff, this.pollAbort.signal);
446
+ }
447
+ }
448
+ }
449
+ }
450
+ exports.SteeringSession = SteeringSession;
451
+ function describeBody(body) {
452
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) {
453
+ const code = body.code;
454
+ if (typeof code === "string")
455
+ return code;
456
+ }
457
+ return "no detail";
458
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.130",
3
+ "version": "0.1.131",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",