@bridge4dev/runner 0.44.2 → 0.45.1
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/adapters/claude.js +16 -3
- package/dist/adapters/codex.js +13 -5
- package/dist/adapters/types.d.ts +17 -2
- package/dist/index.js +5 -0
- package/dist/protocol.d.ts +63 -0
- package/dist/protocol.js +37 -10
- package/dist/supervisor.d.ts +95 -1
- package/dist/supervisor.js +384 -60
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -1983,8 +1983,9 @@ class ClaudeSession {
|
|
|
1983
1983
|
// exit: the words reach the agent immediately and the turn continues.
|
|
1984
1984
|
const openAsk = this.oldestOpenAsk();
|
|
1985
1985
|
if (openAsk) {
|
|
1986
|
-
|
|
1987
|
-
|
|
1986
|
+
// `answerQuestion` answers whether the ask took it; either way the words
|
|
1987
|
+
// are now the card's business and not the queue's.
|
|
1988
|
+
return this.answerQuestion({ askId: openAsk, action: 'discuss', text });
|
|
1988
1989
|
}
|
|
1989
1990
|
this.resumingTurn();
|
|
1990
1991
|
const accepted = this.input.push({
|
|
@@ -1993,10 +1994,15 @@ class ClaudeSession {
|
|
|
1993
1994
|
parent_tool_use_id: null,
|
|
1994
1995
|
});
|
|
1995
1996
|
if (!accepted) {
|
|
1996
|
-
|
|
1997
|
+
// #231 F8: said out loud now instead of only here. The supervisor puts
|
|
1998
|
+
// the message back on the queue — until this release the refusal reached
|
|
1999
|
+
// nobody, and the browser was left showing a bubble for words the agent
|
|
2000
|
+
// had already refused.
|
|
2001
|
+
log.warn('claude: send after session ended — message refused', {
|
|
1997
2002
|
sessionId: this.spec.sessionId,
|
|
1998
2003
|
});
|
|
1999
2004
|
}
|
|
2005
|
+
return accepted;
|
|
2000
2006
|
}
|
|
2001
2007
|
async interrupt() {
|
|
2002
2008
|
this.aborting = true;
|
|
@@ -2296,6 +2302,13 @@ class ClaudeSession {
|
|
|
2296
2302
|
// reads it to keep the streak counter honest — a turn that was
|
|
2297
2303
|
// refused is not a turn that ran.
|
|
2298
2304
|
...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
|
|
2305
|
+
// #300: the same arithmetic as the failing branch below, on the
|
|
2306
|
+
// branch where it decides something else. A successful turn that
|
|
2307
|
+
// put nothing on the wire is usually a warm-up the person never
|
|
2308
|
+
// asked for — a resumed process closing a turn of its own — and
|
|
2309
|
+
// the supervisor holds it rather than announcing «your turn»
|
|
2310
|
+
// over an agent that has not spoken yet.
|
|
2311
|
+
...(this.turnProduced ? { produced: true } : {}),
|
|
2299
2312
|
});
|
|
2300
2313
|
// #279: a turn just moved the plan, so the percentages are worth
|
|
2301
2314
|
// re-reading — throttled inside, and free either way.
|
package/dist/adapters/codex.js
CHANGED
|
@@ -552,10 +552,13 @@ class CodexSession {
|
|
|
552
552
|
}
|
|
553
553
|
send(text) {
|
|
554
554
|
if (this.stopped) {
|
|
555
|
-
|
|
555
|
+
// #231 F8: answered rather than swallowed. The supervisor puts the
|
|
556
|
+
// message back on the queue instead of leaving a bubble on screen for
|
|
557
|
+
// words this session already refused.
|
|
558
|
+
log.warn('codex: send after session ended — message refused', {
|
|
556
559
|
sessionId: this.spec.sessionId,
|
|
557
560
|
});
|
|
558
|
-
return;
|
|
561
|
+
return false;
|
|
559
562
|
}
|
|
560
563
|
// A parked elicitation takes priority: the agent is blocked on it, and
|
|
561
564
|
// starting a turn would deadlock behind the open request. Typing is a
|
|
@@ -572,7 +575,7 @@ class CodexSession {
|
|
|
572
575
|
custom: text,
|
|
573
576
|
})),
|
|
574
577
|
});
|
|
575
|
-
return;
|
|
578
|
+
return true;
|
|
576
579
|
}
|
|
577
580
|
// Answering a plan card by typing instead of clicking is normal ("do it
|
|
578
581
|
// differently"). Without this the held plan stayed forever and
|
|
@@ -592,13 +595,14 @@ class CodexSession {
|
|
|
592
595
|
// Approving the plan is the moment the agent may actually work.
|
|
593
596
|
if (!this.ready) {
|
|
594
597
|
this.queuedInput.push(text);
|
|
595
|
-
return;
|
|
598
|
+
return true;
|
|
596
599
|
}
|
|
597
600
|
if (this.activeTurnId) {
|
|
598
601
|
void this.steer(text);
|
|
599
|
-
return;
|
|
602
|
+
return true;
|
|
600
603
|
}
|
|
601
604
|
this.startTurn(text);
|
|
605
|
+
return true;
|
|
602
606
|
}
|
|
603
607
|
async steer(text) {
|
|
604
608
|
if (!this.threadId || !this.activeTurnId)
|
|
@@ -1560,6 +1564,10 @@ class CodexSession {
|
|
|
1560
1564
|
type: 'turn_end',
|
|
1561
1565
|
ok: true,
|
|
1562
1566
|
...(status === 'interrupted' ? { aborted: true } : {}),
|
|
1567
|
+
// #300, the twin of the failing branch above: a turn that ended well
|
|
1568
|
+
// having produced nothing is usually no turn at all, and the supervisor
|
|
1569
|
+
// holds it rather than saying «your turn» over a working agent.
|
|
1570
|
+
...(this.turnProduced ? { produced: true } : {}),
|
|
1563
1571
|
});
|
|
1564
1572
|
this.flushQueued();
|
|
1565
1573
|
}
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -502,6 +502,13 @@ export type AgentEvent = {
|
|
|
502
502
|
* The discriminator #257 turns on. `false` means the turn is safe to send
|
|
503
503
|
* again; `true` means files may already be written and commands already
|
|
504
504
|
* run, so it may only be RESUMED.
|
|
505
|
+
*
|
|
506
|
+
* Since ticket #300 it is reported on a SUCCESSFUL turn too, where it
|
|
507
|
+
* answers a different question with the same arithmetic: a turn that ends
|
|
508
|
+
* `ok` having produced nothing is, as often as not, no turn at all — a
|
|
509
|
+
* resumed process closing a warm-up the person never asked for. The
|
|
510
|
+
* supervisor holds such an ending briefly rather than announcing «your
|
|
511
|
+
* turn» over an agent that has not started speaking yet.
|
|
505
512
|
*/
|
|
506
513
|
produced?: boolean;
|
|
507
514
|
/**
|
|
@@ -553,8 +560,16 @@ export interface AgentSession {
|
|
|
553
560
|
* make the NEXT message be consumed as the answer to a turn that is gone.
|
|
554
561
|
*/
|
|
555
562
|
cancelQuestions(reason: QuestionInvalidationReason): void;
|
|
556
|
-
/**
|
|
557
|
-
|
|
563
|
+
/**
|
|
564
|
+
* Follow-up user input into the live session.
|
|
565
|
+
*
|
|
566
|
+
* Answers whether the session TOOK it (#231 F8). It used to return nothing,
|
|
567
|
+
* and a message pushed into a queue that had already ended was written to the
|
|
568
|
+
* log and forgotten — while the browser was showing that very sentence as a
|
|
569
|
+
* bubble with the session marked «working». The supervisor now puts a refusal
|
|
570
|
+
* back on the queue and says so in the feed.
|
|
571
|
+
*/
|
|
572
|
+
send(text: string): boolean;
|
|
558
573
|
/** Switch model mid-session (VS-Code-extension parity). */
|
|
559
574
|
setModel(model: string): Promise<void>;
|
|
560
575
|
/** Switch reasoning effort mid-session; null = the model's own default. */
|
package/dist/index.js
CHANGED
|
@@ -404,6 +404,11 @@ function runnerCapabilities(apiUrlOverride) {
|
|
|
404
404
|
// this list is what `runCommand` actually dispatches on, and the API
|
|
405
405
|
// falls back to the old frame for runners that do not name it.
|
|
406
406
|
'answer_question',
|
|
407
|
+
// Ticket #299: and an ordinary message the same way. Same reasoning as
|
|
408
|
+
// the line above, on the path people use every minute — until this
|
|
409
|
+
// release the API called a socket write a delivery, and a message written
|
|
410
|
+
// into a socket that still read OPEN could simply cease to exist.
|
|
411
|
+
'deliver_message',
|
|
407
412
|
'compact_context',
|
|
408
413
|
...(checkpointsEnabled
|
|
409
414
|
? [
|
package/dist/protocol.d.ts
CHANGED
|
@@ -344,6 +344,47 @@ export declare const QuestionAnswerArgsSchema: z.ZodObject<{
|
|
|
344
344
|
notes?: string | undefined;
|
|
345
345
|
}[] | undefined;
|
|
346
346
|
}>;
|
|
347
|
+
/**
|
|
348
|
+
* The body of a `deliver_message` command — ticket #299.
|
|
349
|
+
*
|
|
350
|
+
* The same three fields the frame carries, with `messageId` required rather
|
|
351
|
+
* than optional: an API old enough to omit it is also too old to send this
|
|
352
|
+
* command at all, and the id is the entire reason a redelivery can be
|
|
353
|
+
* recognised instead of duplicated.
|
|
354
|
+
*/
|
|
355
|
+
export declare const DeliverMessageArgsSchema: z.ZodObject<{
|
|
356
|
+
messageId: z.ZodString;
|
|
357
|
+
text: z.ZodString;
|
|
358
|
+
attachments: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
359
|
+
id: z.ZodString;
|
|
360
|
+
fileName: z.ZodString;
|
|
361
|
+
mimeType: z.ZodString;
|
|
362
|
+
fileSize: z.ZodNumber;
|
|
363
|
+
}, "strip", z.ZodTypeAny, {
|
|
364
|
+
id: string;
|
|
365
|
+
fileName: string;
|
|
366
|
+
mimeType: string;
|
|
367
|
+
fileSize: number;
|
|
368
|
+
}, {
|
|
369
|
+
id: string;
|
|
370
|
+
fileName: string;
|
|
371
|
+
mimeType: string;
|
|
372
|
+
fileSize: number;
|
|
373
|
+
}>, "many">>>;
|
|
374
|
+
}, "strip", z.ZodTypeAny, {
|
|
375
|
+
text: string;
|
|
376
|
+
messageId: string;
|
|
377
|
+
attachments?: {
|
|
378
|
+
id: string;
|
|
379
|
+
fileName: string;
|
|
380
|
+
mimeType: string;
|
|
381
|
+
fileSize: number;
|
|
382
|
+
}[] | undefined;
|
|
383
|
+
}, {
|
|
384
|
+
text: string;
|
|
385
|
+
messageId: string;
|
|
386
|
+
attachments?: unknown;
|
|
387
|
+
}>;
|
|
347
388
|
export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
348
389
|
type: z.ZodLiteral<"hello_ack">;
|
|
349
390
|
serverId: z.ZodString;
|
|
@@ -1126,6 +1167,12 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1126
1167
|
type: z.ZodLiteral<"session_message">;
|
|
1127
1168
|
sessionId: z.ZodString;
|
|
1128
1169
|
text: z.ZodString;
|
|
1170
|
+
/**
|
|
1171
|
+
* The API's name for this message (#299). Optional: an API deployed before
|
|
1172
|
+
* that ticket sends none, and a message without a name is still a message —
|
|
1173
|
+
* it just cannot be recognised if it is sent a second time.
|
|
1174
|
+
*/
|
|
1175
|
+
messageId: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
1129
1176
|
attachments: z.ZodCatch<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
1130
1177
|
id: z.ZodString;
|
|
1131
1178
|
fileName: z.ZodString;
|
|
@@ -1152,11 +1199,13 @@ export declare const GatewayFrameSchema: z.ZodDiscriminatedUnion<"type", [z.ZodO
|
|
|
1152
1199
|
mimeType: string;
|
|
1153
1200
|
fileSize: number;
|
|
1154
1201
|
}[] | undefined;
|
|
1202
|
+
messageId?: string | undefined;
|
|
1155
1203
|
}, {
|
|
1156
1204
|
text: string;
|
|
1157
1205
|
sessionId: string;
|
|
1158
1206
|
type: "session_message";
|
|
1159
1207
|
attachments?: unknown;
|
|
1208
|
+
messageId?: unknown;
|
|
1160
1209
|
}>, z.ZodObject<{
|
|
1161
1210
|
type: z.ZodLiteral<"permission_answer">;
|
|
1162
1211
|
sessionId: z.ZodString;
|
|
@@ -1401,6 +1450,20 @@ export type RunnerFrame = {
|
|
|
1401
1450
|
* land on the session that has already been picked back up.
|
|
1402
1451
|
*/
|
|
1403
1452
|
epoch?: number;
|
|
1453
|
+
/**
|
|
1454
|
+
* Subagents still running as this status is reported (ticket #236).
|
|
1455
|
+
*
|
|
1456
|
+
* `WAITING_INPUT` with live background work is not «your turn»: the agent
|
|
1457
|
+
* comes back by itself when the subagent reports. The API stores this and
|
|
1458
|
+
* two readers use it — the notification gate, so nobody is called to a
|
|
1459
|
+
* conversation with nothing to answer, and the status strip, which had no
|
|
1460
|
+
* way to know at all on a page opened after the last `agent_tasks` frame.
|
|
1461
|
+
*
|
|
1462
|
+
* Reported on every status, including the one whose only news is that the
|
|
1463
|
+
* number reached zero — that report IS the moment the turn became the
|
|
1464
|
+
* human's.
|
|
1465
|
+
*/
|
|
1466
|
+
backgroundTasks?: number;
|
|
1404
1467
|
} | {
|
|
1405
1468
|
type: 'session_unknown';
|
|
1406
1469
|
sessionId: string;
|
package/dist/protocol.js
CHANGED
|
@@ -208,6 +208,36 @@ export const QuestionAnswerShape = {
|
|
|
208
208
|
};
|
|
209
209
|
/** The `answer_question` command's arguments — the shape above on its own. */
|
|
210
210
|
export const QuestionAnswerArgsSchema = z.object(QuestionAnswerShape);
|
|
211
|
+
/**
|
|
212
|
+
* Attachment metadata, named once because two shapes now carry it: the legacy
|
|
213
|
+
* `session_message` frame and the `deliver_message` command that replaces it.
|
|
214
|
+
*
|
|
215
|
+
* `.catch(undefined)` is load-bearing and pre-dates both: one malformed entry
|
|
216
|
+
* must cost the attachments, never the sentence the person typed.
|
|
217
|
+
*/
|
|
218
|
+
const MessageAttachmentsSchema = z
|
|
219
|
+
.array(z.object({
|
|
220
|
+
id: z.string().uuid(),
|
|
221
|
+
fileName: z.string().max(500),
|
|
222
|
+
mimeType: z.string().max(200),
|
|
223
|
+
fileSize: z.number().int().min(0),
|
|
224
|
+
}))
|
|
225
|
+
.max(10)
|
|
226
|
+
.optional()
|
|
227
|
+
.catch(undefined);
|
|
228
|
+
/**
|
|
229
|
+
* The body of a `deliver_message` command — ticket #299.
|
|
230
|
+
*
|
|
231
|
+
* The same three fields the frame carries, with `messageId` required rather
|
|
232
|
+
* than optional: an API old enough to omit it is also too old to send this
|
|
233
|
+
* command at all, and the id is the entire reason a redelivery can be
|
|
234
|
+
* recognised instead of duplicated.
|
|
235
|
+
*/
|
|
236
|
+
export const DeliverMessageArgsSchema = z.object({
|
|
237
|
+
messageId: z.string().min(1).max(120),
|
|
238
|
+
text: z.string(),
|
|
239
|
+
attachments: MessageAttachmentsSchema,
|
|
240
|
+
});
|
|
211
241
|
export const GatewayFrameSchema = z.discriminatedUnion('type', [
|
|
212
242
|
z.object({
|
|
213
243
|
type: z.literal('hello_ack'),
|
|
@@ -236,19 +266,16 @@ export const GatewayFrameSchema = z.discriminatedUnion('type', [
|
|
|
236
266
|
type: z.literal('session_message'),
|
|
237
267
|
sessionId: z.string().uuid(),
|
|
238
268
|
text: z.string(),
|
|
269
|
+
/**
|
|
270
|
+
* The API's name for this message (#299). Optional: an API deployed before
|
|
271
|
+
* that ticket sends none, and a message without a name is still a message —
|
|
272
|
+
* it just cannot be recognised if it is sent a second time.
|
|
273
|
+
*/
|
|
274
|
+
messageId: z.string().max(120).optional().catch(undefined),
|
|
239
275
|
// Files the user attached (session 10) — metadata only; the bytes are
|
|
240
276
|
// fetched with the runner's own token. `.catch` so a malformed entry costs
|
|
241
277
|
// the attachments, never the message the user typed.
|
|
242
|
-
attachments:
|
|
243
|
-
.array(z.object({
|
|
244
|
-
id: z.string().uuid(),
|
|
245
|
-
fileName: z.string().max(500),
|
|
246
|
-
mimeType: z.string().max(200),
|
|
247
|
-
fileSize: z.number().int().min(0),
|
|
248
|
-
}))
|
|
249
|
-
.max(10)
|
|
250
|
-
.optional()
|
|
251
|
-
.catch(undefined),
|
|
278
|
+
attachments: MessageAttachmentsSchema,
|
|
252
279
|
}),
|
|
253
280
|
z.object({
|
|
254
281
|
type: z.literal('permission_answer'),
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -53,11 +53,42 @@ export interface SupervisorOptions {
|
|
|
53
53
|
* announced and no rewind can be started.
|
|
54
54
|
*/
|
|
55
55
|
checkpointsEnabled?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* How long a turn that produced nothing is held before it counts (#300).
|
|
58
|
+
*
|
|
59
|
+
* A test seam, like `proposeCommitMessage` above: the real window is 25
|
|
60
|
+
* seconds and no suite can wait that out, so the behaviour would go
|
|
61
|
+
* unverified — which is exactly how the independent review found the hold
|
|
62
|
+
* firing over a working agent (QA-2026-08-16 M-4). Never set in production.
|
|
63
|
+
*/
|
|
64
|
+
emptyTurnSettleMs?: number;
|
|
56
65
|
}
|
|
57
66
|
export declare class Supervisor {
|
|
58
67
|
private readonly ws;
|
|
59
68
|
private readonly opts;
|
|
60
69
|
private static readonly ORPHAN_MESSAGE_CAP;
|
|
70
|
+
/**
|
|
71
|
+
* How many delivered message ids one session remembers (#299).
|
|
72
|
+
*
|
|
73
|
+
* Only ever consulted for a REDELIVERY, and the API redelivers on a `hello`
|
|
74
|
+
* — within a reconnect of the original, not a day later. A hundred is far
|
|
75
|
+
* more than that window can hold and still nothing next to a session's own
|
|
76
|
+
* journal.
|
|
77
|
+
*/
|
|
78
|
+
private static readonly DELIVERED_MESSAGE_CAP;
|
|
79
|
+
/**
|
|
80
|
+
* How long a turn that produced nothing is held before it counts (#300).
|
|
81
|
+
*
|
|
82
|
+
* Long enough to cover the gap the owner watched — a resumed process closed
|
|
83
|
+
* a phantom turn and the real answer landed twenty-one seconds later — and
|
|
84
|
+
* short enough that a genuinely silent turn is not left looking busy. The
|
|
85
|
+
* cost of being wrong is asymmetric on purpose: a late «your turn» is a
|
|
86
|
+
* cosmetic delay, an early one is a lie that invites someone to interrupt an
|
|
87
|
+
* agent mid-thought.
|
|
88
|
+
*/
|
|
89
|
+
private static readonly EMPTY_TURN_SETTLE_MS;
|
|
90
|
+
/** The window actually used — the constant, or a test's own shorter one. */
|
|
91
|
+
private readonly emptyTurnSettleMs;
|
|
61
92
|
/** A finished session's journal is kept this long for a late reconnect. */
|
|
62
93
|
private static readonly JOURNAL_TTL_MS;
|
|
63
94
|
/** Backstop: events the API will never accept must not pile up forever. */
|
|
@@ -298,6 +329,54 @@ export declare class Supervisor {
|
|
|
298
329
|
* card the user was about to answer — the moment another session wanted a
|
|
299
330
|
* slot.
|
|
300
331
|
*/
|
|
332
|
+
/**
|
|
333
|
+
* Do what the end of a turn does — send it, and move the status.
|
|
334
|
+
*
|
|
335
|
+
* Extracted from `case 'turn_end'` because ticket #300 gave the same work a
|
|
336
|
+
* second caller: a turn held back as a possible phantom finishes here when
|
|
337
|
+
* its timer runs out, and it must end in exactly the way it would have
|
|
338
|
+
* ended immediately. A copy would be two behaviours one edit apart.
|
|
339
|
+
*/
|
|
340
|
+
private completeTurn;
|
|
341
|
+
/**
|
|
342
|
+
* Move the session to the status a finished turn leaves it in.
|
|
343
|
+
*
|
|
344
|
+
* Split out of `completeTurn` because ticket #300 gave it a second caller: a
|
|
345
|
+
* turn held as a possible phantom settles here when its timer runs out, and
|
|
346
|
+
* it has to land in exactly the status it would have landed in immediately.
|
|
347
|
+
*/
|
|
348
|
+
private settleTurnStatus;
|
|
349
|
+
/**
|
|
350
|
+
* Hold a turn that produced nothing, in case it was never a turn (#300).
|
|
351
|
+
*
|
|
352
|
+
* Returns true when the turn has been parked and the caller must stop. False
|
|
353
|
+
* means «treat it as a real ending»: a session already on its way out, or one
|
|
354
|
+
* that is not running an agent, has nothing to wait for.
|
|
355
|
+
*/
|
|
356
|
+
private holdEmptyTurn;
|
|
357
|
+
/**
|
|
358
|
+
* Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
|
|
359
|
+
*
|
|
360
|
+
* Called from `noteAgentIsWorking` — the one place that already knows the
|
|
361
|
+
* agent has produced something — and from the real turn ending, so a held
|
|
362
|
+
* phantom can never fire after the turn it belonged to has closed properly.
|
|
363
|
+
*/
|
|
364
|
+
private clearEmptyTurn;
|
|
365
|
+
/**
|
|
366
|
+
* Record how many subagents are alive, and say so when it matters (#236).
|
|
367
|
+
*
|
|
368
|
+
* One place, because there are now two callers with opposite news — a frame
|
|
369
|
+
* from the adapter, and the process going away — and «is it the human's turn»
|
|
370
|
+
* must be answered the same way by both.
|
|
371
|
+
*
|
|
372
|
+
* The report on reaching zero is the whole point: the agent is already
|
|
373
|
+
* sitting in a resting status, so nothing else will ever tell the API that
|
|
374
|
+
* the session finally became the person's. `REVIEW` counts as well as
|
|
375
|
+
* `WAITING_INPUT` — a TICKET session waiting on its own subagent is in the
|
|
376
|
+
* same position, and leaving it out was how one of the two statuses kept its
|
|
377
|
+
* notification suppressed for good (QA-2026-08-16 M-5).
|
|
378
|
+
*/
|
|
379
|
+
private setBackgroundTasks;
|
|
301
380
|
private isParkable;
|
|
302
381
|
/**
|
|
303
382
|
* Stop the agent process but keep the session resumable.
|
|
@@ -342,7 +421,22 @@ export declare class Supervisor {
|
|
|
342
421
|
* somebody says so.
|
|
343
422
|
*/
|
|
344
423
|
private onQuestionAnswer;
|
|
345
|
-
|
|
424
|
+
/**
|
|
425
|
+
* Take a message, and say what happened to it — synchronously (#299).
|
|
426
|
+
*
|
|
427
|
+
* NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
|
|
428
|
+
* concurrently (gotcha #68), so a yield between «is this a duplicate» and
|
|
429
|
+
* «record it» would let the retry of a message overtake the original and be
|
|
430
|
+
* delivered twice. The same rule the question answer next door obeys, and for
|
|
431
|
+
* the same reason: this value becomes a `command_result` the API believes.
|
|
432
|
+
*
|
|
433
|
+
* Everything here was already synchronous — the delivery itself rides on
|
|
434
|
+
* `enqueueDelivery`, which registers work on a chain rather than awaiting it.
|
|
435
|
+
* The only change is that the outcome is now spoken out loud instead of being
|
|
436
|
+
* thrown away, because «I wrote it into a socket» was never an answer to
|
|
437
|
+
* «did the runner get it».
|
|
438
|
+
*/
|
|
439
|
+
private acceptUserMessage;
|
|
346
440
|
/**
|
|
347
441
|
* Queue a message that no agent can take yet, and say so in the feed
|
|
348
442
|
* (ticket #125).
|
package/dist/supervisor.js
CHANGED
|
@@ -19,7 +19,7 @@ import { selfUpdate } from './self-update.js';
|
|
|
19
19
|
import { rememberWorkspacePath } from './environment.js';
|
|
20
20
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
21
21
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
22
|
-
import { QuestionAnswerArgsSchema } from './protocol.js';
|
|
22
|
+
import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
|
|
23
23
|
import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
|
|
24
24
|
/** Refusals shared by every checkpoint command (ticket #126). */
|
|
25
25
|
const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
|
|
@@ -57,6 +57,28 @@ export class Supervisor {
|
|
|
57
57
|
ws;
|
|
58
58
|
opts;
|
|
59
59
|
static ORPHAN_MESSAGE_CAP = 10;
|
|
60
|
+
/**
|
|
61
|
+
* How many delivered message ids one session remembers (#299).
|
|
62
|
+
*
|
|
63
|
+
* Only ever consulted for a REDELIVERY, and the API redelivers on a `hello`
|
|
64
|
+
* — within a reconnect of the original, not a day later. A hundred is far
|
|
65
|
+
* more than that window can hold and still nothing next to a session's own
|
|
66
|
+
* journal.
|
|
67
|
+
*/
|
|
68
|
+
static DELIVERED_MESSAGE_CAP = 100;
|
|
69
|
+
/**
|
|
70
|
+
* How long a turn that produced nothing is held before it counts (#300).
|
|
71
|
+
*
|
|
72
|
+
* Long enough to cover the gap the owner watched — a resumed process closed
|
|
73
|
+
* a phantom turn and the real answer landed twenty-one seconds later — and
|
|
74
|
+
* short enough that a genuinely silent turn is not left looking busy. The
|
|
75
|
+
* cost of being wrong is asymmetric on purpose: a late «your turn» is a
|
|
76
|
+
* cosmetic delay, an early one is a lie that invites someone to interrupt an
|
|
77
|
+
* agent mid-thought.
|
|
78
|
+
*/
|
|
79
|
+
static EMPTY_TURN_SETTLE_MS = 25_000;
|
|
80
|
+
/** The window actually used — the constant, or a test's own shorter one. */
|
|
81
|
+
emptyTurnSettleMs;
|
|
60
82
|
/** A finished session's journal is kept this long for a late reconnect. */
|
|
61
83
|
static JOURNAL_TTL_MS = 72 * 3_600_000;
|
|
62
84
|
/** Backstop: events the API will never accept must not pile up forever. */
|
|
@@ -94,6 +116,7 @@ export class Supervisor {
|
|
|
94
116
|
this.ws = ws;
|
|
95
117
|
this.opts = opts;
|
|
96
118
|
this.journals = opts.journals ?? new JournalStore();
|
|
119
|
+
this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
|
|
97
120
|
this.verify = new VerifyRunner({
|
|
98
121
|
enabled: opts.verifyEnabled !== false,
|
|
99
122
|
onReport: (report) => {
|
|
@@ -207,7 +230,11 @@ export class Supervisor {
|
|
|
207
230
|
await this.startSession(frame.session);
|
|
208
231
|
break;
|
|
209
232
|
case 'session_message':
|
|
210
|
-
|
|
233
|
+
// The legacy door, still open for an API that has not learned the
|
|
234
|
+
// command yet. Its outcome goes nowhere because nobody is listening —
|
|
235
|
+
// which is the whole defect ticket #299 is about, and why the command
|
|
236
|
+
// below exists.
|
|
237
|
+
this.acceptUserMessage(frame.sessionId, frame.text, frame.attachments, frame.messageId ?? undefined);
|
|
211
238
|
break;
|
|
212
239
|
case 'permission_answer': {
|
|
213
240
|
const running = this.sessions.get(frame.sessionId);
|
|
@@ -313,6 +340,8 @@ export class Supervisor {
|
|
|
313
340
|
epoch: descriptor.epoch,
|
|
314
341
|
openQuestions: new Set(),
|
|
315
342
|
answeredAsks: new Set(),
|
|
343
|
+
deliveredMessageIds: new Set(),
|
|
344
|
+
backgroundTasks: 0,
|
|
316
345
|
// Ticket #196: a pause is part of what a session IS, so it is read off
|
|
317
346
|
// the descriptor rather than waiting for a frame. Without this a runner
|
|
318
347
|
// that restarted mid-pause would come back knowing nothing and pick the
|
|
@@ -332,12 +361,25 @@ export class Supervisor {
|
|
|
332
361
|
// exist (session 9).
|
|
333
362
|
running.pendingMessages.push(...running.journal.pending());
|
|
334
363
|
this.sessions.set(descriptor.id, running);
|
|
335
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Messages that arrived for a session this runner did not know yet.
|
|
366
|
+
*
|
|
367
|
+
* They go in through the ORDINARY door now (#231 F4). They used to be
|
|
368
|
+
* pushed straight onto `pendingMessages` with `appendPending` and no
|
|
369
|
+
* `originSeq` — which meant no echo and no `message_queued`, so the words
|
|
370
|
+
* reached the agent and never appeared in the conversation at all. The
|
|
371
|
+
* person saw an empty composer, an empty feed, and then an answer to
|
|
372
|
+
* something they could not see themselves having asked.
|
|
373
|
+
*
|
|
374
|
+
* `queueMessage` is skipped in favour of the full `acceptUserMessage` so
|
|
375
|
+
* that a session which is ready by now delivers immediately rather than
|
|
376
|
+
* sitting in a queue nothing flushes.
|
|
377
|
+
*/
|
|
336
378
|
const orphaned = this.orphanMessages.get(descriptor.id);
|
|
337
379
|
if (orphaned) {
|
|
338
380
|
this.orphanMessages.delete(descriptor.id);
|
|
339
381
|
for (const message of orphaned) {
|
|
340
|
-
|
|
382
|
+
this.acceptUserMessage(descriptor.id, message.text, message.attachments, message.messageId);
|
|
341
383
|
}
|
|
342
384
|
}
|
|
343
385
|
try {
|
|
@@ -1024,6 +1066,20 @@ export class Supervisor {
|
|
|
1024
1066
|
// Idle process ended (parked or died between turns) — stay resumable.
|
|
1025
1067
|
running.session = null;
|
|
1026
1068
|
running.parkRequested = false;
|
|
1069
|
+
/**
|
|
1070
|
+
* The subagents died with it (QA-2026-08-16 M-5).
|
|
1071
|
+
*
|
|
1072
|
+
* Here rather than in `park()`, because parking only ASKS the process to
|
|
1073
|
+
* stop and this is where it actually went. The adapter stops publishing
|
|
1074
|
+
* the moment it is told to stop, so without this the last frame's count
|
|
1075
|
+
* stands for ever: the strip says «Working in background» over a parked
|
|
1076
|
+
* session and — far worse — the API goes on suppressing that session's
|
|
1077
|
+
* «your turn» notification for the rest of its life, because the number
|
|
1078
|
+
* it stored never reaches zero again.
|
|
1079
|
+
*
|
|
1080
|
+
* Before the `reportStatus` just below, so the zero rides out on it.
|
|
1081
|
+
*/
|
|
1082
|
+
running.backgroundTasks = 0;
|
|
1027
1083
|
running.costBaseUsd = running.costUsd; // next process starts from here
|
|
1028
1084
|
// Persist the clock: a parked session can sit for hours and the runner
|
|
1029
1085
|
// may be restarted before it ever runs again.
|
|
@@ -1217,6 +1273,176 @@ export class Supervisor {
|
|
|
1217
1273
|
* card the user was about to answer — the moment another session wanted a
|
|
1218
1274
|
* slot.
|
|
1219
1275
|
*/
|
|
1276
|
+
/**
|
|
1277
|
+
* Do what the end of a turn does — send it, and move the status.
|
|
1278
|
+
*
|
|
1279
|
+
* Extracted from `case 'turn_end'` because ticket #300 gave the same work a
|
|
1280
|
+
* second caller: a turn held back as a possible phantom finishes here when
|
|
1281
|
+
* its timer runs out, and it must end in exactly the way it would have
|
|
1282
|
+
* ended immediately. A copy would be two behaviours one edit apart.
|
|
1283
|
+
*/
|
|
1284
|
+
completeTurn(running, descriptor, event) {
|
|
1285
|
+
this.sendEvent(running, 'turn_end', {
|
|
1286
|
+
ok: event.ok,
|
|
1287
|
+
errorMessage: event.errorMessage,
|
|
1288
|
+
...(event.aborted ? { aborted: true } : {}),
|
|
1289
|
+
});
|
|
1290
|
+
// The session is already on its way out with a status that MEANS
|
|
1291
|
+
// something — a spent budget, a Stop, a teardown. A turn ending inside
|
|
1292
|
+
// that window is a consequence of it, and letting the line below
|
|
1293
|
+
// overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
|
|
1294
|
+
// WAITING_INPUT) replaces a true, actionable ending with a wrong one.
|
|
1295
|
+
if (running.stopRequested || running.budgetSpent)
|
|
1296
|
+
return;
|
|
1297
|
+
// A finished turn is the last instant this conversation is BOTH
|
|
1298
|
+
// complete and readable: the process can be parked or lose its slot at
|
|
1299
|
+
// any point after it, and `conversationAnchor()` needs a live one.
|
|
1300
|
+
this.currentAnchor(running);
|
|
1301
|
+
/**
|
|
1302
|
+
* A turn that produced NOTHING does not get to say «your turn» yet (#300).
|
|
1303
|
+
*
|
|
1304
|
+
* Only the STATUS waits. The `turn_end` event above has already gone out,
|
|
1305
|
+
* because the feed's own readers depend on it — the task tray reads it to
|
|
1306
|
+
* retire a finished turn's counters — and because holding it would make two
|
|
1307
|
+
* facts disagree about one moment.
|
|
1308
|
+
*
|
|
1309
|
+
* The status is the whole symptom: `WAITING_INPUT` is what the dashboard
|
|
1310
|
+
* prints as «Your turn», and a resumed agent process closes a turn it never
|
|
1311
|
+
* ran — no text, no thinking, no tool call — twenty-one seconds before the
|
|
1312
|
+
* real answer arrives. The product spent those seconds claiming to wait for
|
|
1313
|
+
* a person who was, in fact, waiting for it.
|
|
1314
|
+
*
|
|
1315
|
+
* `produced` is the adapter's own arithmetic from 0.44.1 (#252/#257),
|
|
1316
|
+
* reused rather than reinvented.
|
|
1317
|
+
*/
|
|
1318
|
+
if (event.ok && event.produced !== true && this.holdEmptyTurn(running, descriptor, event)) {
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
// Not held — so any phantom still waiting from an earlier turn is stale and
|
|
1322
|
+
// must not outlive this ending. Cleared HERE and not before the decision
|
|
1323
|
+
// above, or `holdEmptyTurn`'s «two in a row is a quiet agent, not two
|
|
1324
|
+
// phantoms» rule could never see the first one (QA-2026-08-16 M-6).
|
|
1325
|
+
this.clearEmptyTurn(running);
|
|
1326
|
+
this.settleTurnStatus(running, descriptor, event);
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Move the session to the status a finished turn leaves it in.
|
|
1330
|
+
*
|
|
1331
|
+
* Split out of `completeTurn` because ticket #300 gave it a second caller: a
|
|
1332
|
+
* turn held as a possible phantom settles here when its timer runs out, and
|
|
1333
|
+
* it has to land in exactly the status it would have landed in immediately.
|
|
1334
|
+
*/
|
|
1335
|
+
settleTurnStatus(running, descriptor, event) {
|
|
1336
|
+
// Turn end is the natural checkpoint for the budget clock: the slice
|
|
1337
|
+
// just closed, so this is the moment the API can persist it. Without a
|
|
1338
|
+
// report here `agentActiveMs` stayed 0 and every restart or resume
|
|
1339
|
+
// silently handed the session a full fresh budget.
|
|
1340
|
+
if (event.ok) {
|
|
1341
|
+
const next = descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
1342
|
+
this.reportStatus(descriptor.id, next, {
|
|
1343
|
+
costUsd: running.costUsd,
|
|
1344
|
+
activeMs: Supervisor.spentMs(running),
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
else if (event.limitBlocked) {
|
|
1348
|
+
// #258. The plan refused this turn — it never ran, so the session has
|
|
1349
|
+
// not failed at anything: it is waiting for a window to open, and the
|
|
1350
|
+
// API has just armed a clock to wake it. FAILED is terminal, and a
|
|
1351
|
+
// terminal session cannot be woken — the pause would ring into a dead
|
|
1352
|
+
// row and cancel the words the person queued behind it.
|
|
1353
|
+
this.reportStatus(descriptor.id, descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW', {
|
|
1354
|
+
costUsd: running.costUsd,
|
|
1355
|
+
activeMs: Supervisor.spentMs(running),
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
else {
|
|
1359
|
+
this.reportStatus(descriptor.id, 'FAILED', {
|
|
1360
|
+
costUsd: running.costUsd,
|
|
1361
|
+
activeMs: Supervisor.spentMs(running),
|
|
1362
|
+
errorMessage: event.errorMessage ?? 'Agent turn failed',
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Hold a turn that produced nothing, in case it was never a turn (#300).
|
|
1368
|
+
*
|
|
1369
|
+
* Returns true when the turn has been parked and the caller must stop. False
|
|
1370
|
+
* means «treat it as a real ending»: a session already on its way out, or one
|
|
1371
|
+
* that is not running an agent, has nothing to wait for.
|
|
1372
|
+
*/
|
|
1373
|
+
holdEmptyTurn(running, descriptor, event) {
|
|
1374
|
+
// An aborted turn produced nothing BY DEFINITION — the human pressed Stop,
|
|
1375
|
+
// and making them wait 25 seconds to be told the turn is over would be a
|
|
1376
|
+
// new bug in place of the old one.
|
|
1377
|
+
if (event.aborted)
|
|
1378
|
+
return false;
|
|
1379
|
+
// The session is winding down: a status is already on its way that means
|
|
1380
|
+
// something, and holding this would only delay a truthful ending.
|
|
1381
|
+
if (running.stopRequested || running.budgetSpent)
|
|
1382
|
+
return false;
|
|
1383
|
+
// Two of these in a row is not two phantoms — it is a quiet agent. The
|
|
1384
|
+
// second one ends the turn for real.
|
|
1385
|
+
if (running.emptyTurnTimer)
|
|
1386
|
+
return false;
|
|
1387
|
+
log.info('supervisor: holding a turn that produced nothing', {
|
|
1388
|
+
sessionId: descriptor.id,
|
|
1389
|
+
settleMs: this.emptyTurnSettleMs,
|
|
1390
|
+
});
|
|
1391
|
+
const timer = setTimeout(() => {
|
|
1392
|
+
running.emptyTurnTimer = undefined;
|
|
1393
|
+
// The session may have ended, been resumed or been replaced while we
|
|
1394
|
+
// waited — `isStale` is the same guard every other delayed path uses.
|
|
1395
|
+
if (this.isStale(running))
|
|
1396
|
+
return;
|
|
1397
|
+
log.info('supervisor: the empty turn was real after all', { sessionId: descriptor.id });
|
|
1398
|
+
this.settleTurnStatus(running, descriptor, event);
|
|
1399
|
+
}, this.emptyTurnSettleMs);
|
|
1400
|
+
timer.unref();
|
|
1401
|
+
running.emptyTurnTimer = timer;
|
|
1402
|
+
return true;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
|
|
1406
|
+
*
|
|
1407
|
+
* Called from `noteAgentIsWorking` — the one place that already knows the
|
|
1408
|
+
* agent has produced something — and from the real turn ending, so a held
|
|
1409
|
+
* phantom can never fire after the turn it belonged to has closed properly.
|
|
1410
|
+
*/
|
|
1411
|
+
clearEmptyTurn(running) {
|
|
1412
|
+
if (!running.emptyTurnTimer)
|
|
1413
|
+
return;
|
|
1414
|
+
clearTimeout(running.emptyTurnTimer);
|
|
1415
|
+
running.emptyTurnTimer = undefined;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Record how many subagents are alive, and say so when it matters (#236).
|
|
1419
|
+
*
|
|
1420
|
+
* One place, because there are now two callers with opposite news — a frame
|
|
1421
|
+
* from the adapter, and the process going away — and «is it the human's turn»
|
|
1422
|
+
* must be answered the same way by both.
|
|
1423
|
+
*
|
|
1424
|
+
* The report on reaching zero is the whole point: the agent is already
|
|
1425
|
+
* sitting in a resting status, so nothing else will ever tell the API that
|
|
1426
|
+
* the session finally became the person's. `REVIEW` counts as well as
|
|
1427
|
+
* `WAITING_INPUT` — a TICKET session waiting on its own subagent is in the
|
|
1428
|
+
* same position, and leaving it out was how one of the two statuses kept its
|
|
1429
|
+
* notification suppressed for good (QA-2026-08-16 M-5).
|
|
1430
|
+
*/
|
|
1431
|
+
setBackgroundTasks(running, live) {
|
|
1432
|
+
if (live === running.backgroundTasks)
|
|
1433
|
+
return;
|
|
1434
|
+
const finished = running.backgroundTasks > 0 && live === 0;
|
|
1435
|
+
running.backgroundTasks = live;
|
|
1436
|
+
const resting = running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW'
|
|
1437
|
+
? running.lastReported
|
|
1438
|
+
: null;
|
|
1439
|
+
if (finished && resting) {
|
|
1440
|
+
this.reportStatus(running.descriptor.id, resting, {
|
|
1441
|
+
costUsd: running.costUsd,
|
|
1442
|
+
activeMs: Supervisor.spentMs(running),
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1220
1446
|
isParkable(running) {
|
|
1221
1447
|
return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
|
|
1222
1448
|
running.openQuestions.size === 0 &&
|
|
@@ -1274,6 +1500,12 @@ export class Supervisor {
|
|
|
1274
1500
|
* own question must not take the card off the screen.
|
|
1275
1501
|
*/
|
|
1276
1502
|
noteAgentIsWorking(running) {
|
|
1503
|
+
// Ticket #300: the agent is producing, so a turn held back as a possible
|
|
1504
|
+
// phantom was one. Dropped BEFORE the guards below, because those are about
|
|
1505
|
+
// whether the STATUS may move — and a phantom must be cancelled even when
|
|
1506
|
+
// the status is already right, or it fires later and ends a turn that is
|
|
1507
|
+
// still going.
|
|
1508
|
+
this.clearEmptyTurn(running);
|
|
1277
1509
|
if (running.openQuestions.size > 0)
|
|
1278
1510
|
return;
|
|
1279
1511
|
if (running.stopRequested || running.parkRequested || running.budgetSpent)
|
|
@@ -1341,51 +1573,7 @@ export class Supervisor {
|
|
|
1341
1573
|
// when it finally succeeds or finally gives up.
|
|
1342
1574
|
if (!event.ok && this.armApiRetry(running, descriptor, event))
|
|
1343
1575
|
return;
|
|
1344
|
-
this.
|
|
1345
|
-
ok: event.ok,
|
|
1346
|
-
errorMessage: event.errorMessage,
|
|
1347
|
-
...(event.aborted ? { aborted: true } : {}),
|
|
1348
|
-
});
|
|
1349
|
-
// The session is already on its way out with a status that MEANS
|
|
1350
|
-
// something — a spent budget, a Stop, a teardown. A turn ending inside
|
|
1351
|
-
// that window is a consequence of it, and letting the line below
|
|
1352
|
-
// overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
|
|
1353
|
-
// WAITING_INPUT) replaces a true, actionable ending with a wrong one.
|
|
1354
|
-
if (running.stopRequested || running.budgetSpent)
|
|
1355
|
-
return;
|
|
1356
|
-
// A finished turn is the last instant this conversation is BOTH
|
|
1357
|
-
// complete and readable: the process can be parked or lose its slot at
|
|
1358
|
-
// any point after it, and `conversationAnchor()` needs a live one.
|
|
1359
|
-
this.currentAnchor(running);
|
|
1360
|
-
// Turn end is the natural checkpoint for the budget clock: the slice
|
|
1361
|
-
// just closed, so this is the moment the API can persist it. Without a
|
|
1362
|
-
// report here `agentActiveMs` stayed 0 and every restart or resume
|
|
1363
|
-
// silently handed the session a full fresh budget.
|
|
1364
|
-
if (event.ok) {
|
|
1365
|
-
const next = descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
1366
|
-
this.reportStatus(descriptor.id, next, {
|
|
1367
|
-
costUsd: running.costUsd,
|
|
1368
|
-
activeMs: Supervisor.spentMs(running),
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
|
-
else if (event.limitBlocked) {
|
|
1372
|
-
// #258. The plan refused this turn — it never ran, so the session has
|
|
1373
|
-
// not failed at anything: it is waiting for a window to open, and the
|
|
1374
|
-
// API has just armed a clock to wake it. FAILED is terminal, and a
|
|
1375
|
-
// terminal session cannot be woken — the pause would ring into a dead
|
|
1376
|
-
// row and cancel the words the person queued behind it.
|
|
1377
|
-
this.reportStatus(descriptor.id, descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW', {
|
|
1378
|
-
costUsd: running.costUsd,
|
|
1379
|
-
activeMs: Supervisor.spentMs(running),
|
|
1380
|
-
});
|
|
1381
|
-
}
|
|
1382
|
-
else {
|
|
1383
|
-
this.reportStatus(descriptor.id, 'FAILED', {
|
|
1384
|
-
costUsd: running.costUsd,
|
|
1385
|
-
activeMs: Supervisor.spentMs(running),
|
|
1386
|
-
errorMessage: event.errorMessage ?? 'Agent turn failed',
|
|
1387
|
-
});
|
|
1388
|
-
}
|
|
1576
|
+
this.completeTurn(running, descriptor, event);
|
|
1389
1577
|
return;
|
|
1390
1578
|
}
|
|
1391
1579
|
case 'error':
|
|
@@ -1592,7 +1780,7 @@ export class Supervisor {
|
|
|
1592
1780
|
case 'rate_limits':
|
|
1593
1781
|
this.sendEvent(running, 'rate_limits', { ...event.limits });
|
|
1594
1782
|
return;
|
|
1595
|
-
case 'agent_tasks':
|
|
1783
|
+
case 'agent_tasks': {
|
|
1596
1784
|
// Ticket #113. A LEVEL signal: every frame carries the whole live set,
|
|
1597
1785
|
// so the dashboard replaces rather than reconciles and a dropped frame
|
|
1598
1786
|
// cannot leave a finished subagent spinning in the tray forever.
|
|
@@ -1601,7 +1789,23 @@ export class Supervisor {
|
|
|
1601
1789
|
done: event.done,
|
|
1602
1790
|
total: event.total,
|
|
1603
1791
|
});
|
|
1792
|
+
/**
|
|
1793
|
+
* ...and keep the count where a DECISION can read it (ticket #236).
|
|
1794
|
+
*
|
|
1795
|
+
* Until now this frame only ever flew past on its way to the browser,
|
|
1796
|
+
* and the one place that had to know — «is the turn the human's now» —
|
|
1797
|
+
* was taken without it. Two symptoms, one cause: a status strip reading
|
|
1798
|
+
* «Your turn» a centimetre above a live subagent, and a notification
|
|
1799
|
+
* calling someone to a conversation with nothing to answer.
|
|
1800
|
+
*
|
|
1801
|
+
* Counted from `tasks` rather than from `total`/`done`: those are the
|
|
1802
|
+
* turn's arithmetic and a background task deliberately outlives its own
|
|
1803
|
+
* turn (`endTaskTurn` keeps the live set).
|
|
1804
|
+
*/
|
|
1805
|
+
const live = event.tasks.filter((task) => task.status === 'running').length;
|
|
1806
|
+
this.setBackgroundTasks(running, live);
|
|
1604
1807
|
return;
|
|
1808
|
+
}
|
|
1605
1809
|
case 'notice': {
|
|
1606
1810
|
// Only adapter notices are de-duplicated here. The supervisor's own
|
|
1607
1811
|
// notices (turn interrupted, session parked, budget warnings) go
|
|
@@ -1678,12 +1882,9 @@ export class Supervisor {
|
|
|
1678
1882
|
// The words must not be eaten. This is the ordinary case after a runner
|
|
1679
1883
|
// restart: the card in the browser outlived the process that asked, and
|
|
1680
1884
|
// showing the user's own message in the feed while nothing receives it is
|
|
1681
|
-
// the exact failure `
|
|
1885
|
+
// the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
|
|
1682
1886
|
if (typed) {
|
|
1683
|
-
|
|
1684
|
-
sessionId: frame.sessionId,
|
|
1685
|
-
error: String(error),
|
|
1686
|
-
}));
|
|
1887
|
+
this.acceptUserMessage(frame.sessionId, typed);
|
|
1687
1888
|
return 'not_open';
|
|
1688
1889
|
}
|
|
1689
1890
|
// Nothing to deliver — but the API optimistically flipped the session to
|
|
@@ -1691,7 +1892,22 @@ export class Supervisor {
|
|
|
1691
1892
|
this.reportStatus(frame.sessionId, statusForReport(running), {});
|
|
1692
1893
|
return 'not_open';
|
|
1693
1894
|
}
|
|
1694
|
-
|
|
1895
|
+
/**
|
|
1896
|
+
* Take a message, and say what happened to it — synchronously (#299).
|
|
1897
|
+
*
|
|
1898
|
+
* NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
|
|
1899
|
+
* concurrently (gotcha #68), so a yield between «is this a duplicate» and
|
|
1900
|
+
* «record it» would let the retry of a message overtake the original and be
|
|
1901
|
+
* delivered twice. The same rule the question answer next door obeys, and for
|
|
1902
|
+
* the same reason: this value becomes a `command_result` the API believes.
|
|
1903
|
+
*
|
|
1904
|
+
* Everything here was already synchronous — the delivery itself rides on
|
|
1905
|
+
* `enqueueDelivery`, which registers work on a chain rather than awaiting it.
|
|
1906
|
+
* The only change is that the outcome is now spoken out loud instead of being
|
|
1907
|
+
* thrown away, because «I wrote it into a socket» was never an answer to
|
|
1908
|
+
* «did the runner get it».
|
|
1909
|
+
*/
|
|
1910
|
+
acceptUserMessage(sessionId, text, attachments, messageId) {
|
|
1695
1911
|
const running = this.sessions.get(sessionId);
|
|
1696
1912
|
if (!running) {
|
|
1697
1913
|
// The API believes this session lives here but the runner lost track of
|
|
@@ -1700,10 +1916,47 @@ export class Supervisor {
|
|
|
1700
1916
|
// instead of dropping it silently.
|
|
1701
1917
|
log.warn('supervisor: message for unknown session — requesting descriptor', { sessionId });
|
|
1702
1918
|
const queued = this.orphanMessages.get(sessionId) ?? [];
|
|
1703
|
-
|
|
1919
|
+
/**
|
|
1920
|
+
* The name travels with the words (QA-2026-08-16 M-2).
|
|
1921
|
+
*
|
|
1922
|
+
* Without it the replay below took the message a SECOND time under no
|
|
1923
|
+
* name, and the API's redelivery of the row it queued for the same
|
|
1924
|
+
* `unknown_session` then looked brand new to `deliveredMessageIds` — the
|
|
1925
|
+
* agent got one instruction twice, in the one scenario that reliably
|
|
1926
|
+
* produces both copies.
|
|
1927
|
+
*/
|
|
1928
|
+
queued.push({
|
|
1929
|
+
text,
|
|
1930
|
+
...(attachments?.length ? { attachments } : {}),
|
|
1931
|
+
...(messageId ? { messageId } : {}),
|
|
1932
|
+
});
|
|
1704
1933
|
this.orphanMessages.set(sessionId, queued.slice(-Supervisor.ORPHAN_MESSAGE_CAP));
|
|
1705
1934
|
this.ws.send({ type: 'session_unknown', sessionId });
|
|
1706
|
-
return;
|
|
1935
|
+
return 'unknown_session';
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Already taken (#299).
|
|
1939
|
+
*
|
|
1940
|
+
* The API queues a message it could not get confirmed and re-sends it on
|
|
1941
|
+
* the next `hello`. «Not confirmed» is not «not delivered»: the socket can
|
|
1942
|
+
* die between this runner accepting the words and the answer reaching the
|
|
1943
|
+
* API. Without this set the retry would hand the agent the same sentence a
|
|
1944
|
+
* second time — the very failure the queue was built to avoid.
|
|
1945
|
+
*
|
|
1946
|
+
* In memory, so a runner that RESTARTED between the two attempts will take
|
|
1947
|
+
* it twice. That is the deliberate side of the trade: after a restart the
|
|
1948
|
+
* first copy almost certainly never reached the agent, and a duplicate is a
|
|
1949
|
+
* far smaller injury than a message that silently does not exist.
|
|
1950
|
+
*/
|
|
1951
|
+
if (messageId) {
|
|
1952
|
+
if (running.deliveredMessageIds.has(messageId))
|
|
1953
|
+
return 'duplicate';
|
|
1954
|
+
running.deliveredMessageIds.add(messageId);
|
|
1955
|
+
if (running.deliveredMessageIds.size > Supervisor.DELIVERED_MESSAGE_CAP) {
|
|
1956
|
+
const oldest = running.deliveredMessageIds.values().next().value;
|
|
1957
|
+
if (oldest !== undefined)
|
|
1958
|
+
running.deliveredMessageIds.delete(oldest);
|
|
1959
|
+
}
|
|
1707
1960
|
}
|
|
1708
1961
|
// The feed shows what the USER wrote plus the files they picked — not the
|
|
1709
1962
|
// composed prompt with workspace paths, which is an implementation detail.
|
|
@@ -1712,6 +1965,12 @@ export class Supervisor {
|
|
|
1712
1965
|
const echoed = this.sendEvent(running, 'message', {
|
|
1713
1966
|
role: 'user',
|
|
1714
1967
|
text,
|
|
1968
|
+
// The API's name for this message, echoed back so the browser can match
|
|
1969
|
+
// the bubble it drew itself the moment Send was pressed (#299). Matching
|
|
1970
|
+
// on text alone works right up until somebody sends the same sentence
|
|
1971
|
+
// twice, which is exactly what a person does when they think the first
|
|
1972
|
+
// one was lost.
|
|
1973
|
+
...(messageId ? { messageId } : {}),
|
|
1715
1974
|
...(attachments?.length ? { attachments } : {}),
|
|
1716
1975
|
});
|
|
1717
1976
|
const originSeq = echoed.seq;
|
|
@@ -1726,7 +1985,7 @@ export class Supervisor {
|
|
|
1726
1985
|
// own conflict tasks. Held here rather than delivered, so «на паузе»
|
|
1727
1986
|
// means the same thing whichever door the words came through.
|
|
1728
1987
|
this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
|
|
1729
|
-
return;
|
|
1988
|
+
return 'accepted';
|
|
1730
1989
|
}
|
|
1731
1990
|
this.enqueueDelivery(running, async () => {
|
|
1732
1991
|
const composed = await this.materializeAttachments(running, text, attachments);
|
|
@@ -1751,6 +2010,7 @@ export class Supervisor {
|
|
|
1751
2010
|
return;
|
|
1752
2011
|
this.deliverMessage(running, composed, [], originSeq);
|
|
1753
2012
|
});
|
|
2013
|
+
return 'accepted';
|
|
1754
2014
|
}
|
|
1755
2015
|
/**
|
|
1756
2016
|
* Queue a message that no agent can take yet, and say so in the feed
|
|
@@ -1987,13 +2247,41 @@ export class Supervisor {
|
|
|
1987
2247
|
this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
|
|
1988
2248
|
}
|
|
1989
2249
|
};
|
|
2250
|
+
// M-4 (QA-2026-08-16): a new turn is starting, so a phantom held from the
|
|
2251
|
+
// previous one must not fire 25 seconds from now and report WAITING_INPUT
|
|
2252
|
+
// over an agent that is working — the very lie #300 is about, and this time
|
|
2253
|
+
// with an irreversible notification behind it.
|
|
2254
|
+
this.clearEmptyTurn(running);
|
|
1990
2255
|
if (running.session && !running.parkRequested) {
|
|
1991
2256
|
// #252: the words this turn is actually running. `lastPrompt` deliberately
|
|
1992
2257
|
// stays put — three older latches relaunch a PROCESS with it, and handing
|
|
1993
2258
|
// them a follow-up line instead of the task would start a fresh
|
|
1994
2259
|
// conversation with «продолжай».
|
|
1995
2260
|
running.lastTurnPrompt = text;
|
|
1996
|
-
|
|
2261
|
+
/**
|
|
2262
|
+
* The process can refuse — and used to refuse in silence (#231 F8).
|
|
2263
|
+
*
|
|
2264
|
+
* `AsyncQueue.push` answers false once the queue has ended, which happens
|
|
2265
|
+
* when the agent's own iterator finished between the check above and this
|
|
2266
|
+
* line. The adapter wrote a line to its log and returned; from here the
|
|
2267
|
+
* message looked delivered, so `settle()` retired it from the queue and
|
|
2268
|
+
* the status went to RUNNING. On screen: the person's bubble, the word
|
|
2269
|
+
* «working», and an agent that had already stopped and would never answer.
|
|
2270
|
+
*
|
|
2271
|
+
* Now it goes back in the queue, exactly like a parked process below, and
|
|
2272
|
+
* the feed says why.
|
|
2273
|
+
*/
|
|
2274
|
+
if (!running.session.send(text)) {
|
|
2275
|
+
log.warn('supervisor: the agent process refused the message — requeued', {
|
|
2276
|
+
sessionId: running.descriptor.id,
|
|
2277
|
+
});
|
|
2278
|
+
this.sendEvent(running, 'system_note', {
|
|
2279
|
+
code: 'agent_gone',
|
|
2280
|
+
text: 'The agent process ended before it could take this message. It is queued and will be delivered when the session continues.',
|
|
2281
|
+
});
|
|
2282
|
+
this.requeue(running, held, text, originSeq);
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
1997
2285
|
settle();
|
|
1998
2286
|
this.reportStatus(running.descriptor.id, 'RUNNING', {});
|
|
1999
2287
|
return;
|
|
@@ -2599,6 +2887,8 @@ export class Supervisor {
|
|
|
2599
2887
|
epoch: descriptor.epoch,
|
|
2600
2888
|
openQuestions: new Set(),
|
|
2601
2889
|
answeredAsks: new Set(),
|
|
2890
|
+
deliveredMessageIds: new Set(),
|
|
2891
|
+
backgroundTasks: 0,
|
|
2602
2892
|
...pausedUntilOf(descriptor),
|
|
2603
2893
|
mode: descriptor.mode,
|
|
2604
2894
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
@@ -2864,6 +3154,35 @@ export class Supervisor {
|
|
|
2864
3154
|
? { ok: false, error: 'Unknown session', result: { outcome } }
|
|
2865
3155
|
: { ok: true, result: { outcome } });
|
|
2866
3156
|
}
|
|
3157
|
+
case 'deliver_message': {
|
|
3158
|
+
// Ticket #299 — the same treatment as `answer_question` above, for
|
|
3159
|
+
// the door people use every minute. The `session_message` frame it
|
|
3160
|
+
// replaces is fire-and-forget, so the API counted a write into a
|
|
3161
|
+
// socket as a delivery; on 16.08.2026 a message written into a socket
|
|
3162
|
+
// that still read OPEN simply ceased to exist, with the queue empty
|
|
3163
|
+
// and the composer cleared.
|
|
3164
|
+
//
|
|
3165
|
+
// No `await` before the reply. `acceptUserMessage` is synchronous
|
|
3166
|
+
// precisely so this stays true: a yield between «is this a duplicate»
|
|
3167
|
+
// and «write it down» would let a redelivery overtake the original.
|
|
3168
|
+
const sessionId = frame.sessionId;
|
|
3169
|
+
if (!sessionId)
|
|
3170
|
+
return void reply({ ok: false, error: 'sessionId is required' });
|
|
3171
|
+
const parsed = DeliverMessageArgsSchema.safeParse(frame.args ?? {});
|
|
3172
|
+
if (!parsed.success) {
|
|
3173
|
+
return void reply({
|
|
3174
|
+
ok: false,
|
|
3175
|
+
error: parsed.error.issues[0]?.message ?? 'malformed message',
|
|
3176
|
+
});
|
|
3177
|
+
}
|
|
3178
|
+
const outcome = this.acceptUserMessage(sessionId, parsed.data.text, parsed.data.attachments, parsed.data.messageId);
|
|
3179
|
+
// `ok` is about the RELAY, like the answer above: `unknown_session` is
|
|
3180
|
+
// the one case the API can fix by asking again after it has re-sent
|
|
3181
|
+
// the descriptor, so it is the one case reported as a failure.
|
|
3182
|
+
return void reply(outcome === 'unknown_session'
|
|
3183
|
+
? { ok: false, error: 'Unknown session', result: { outcome } }
|
|
3184
|
+
: { ok: true, result: { outcome } });
|
|
3185
|
+
}
|
|
2867
3186
|
case 'recall_message': {
|
|
2868
3187
|
// Ticket #125: take a queued message back before any agent sees it.
|
|
2869
3188
|
const sessionId = frame.sessionId;
|
|
@@ -3868,6 +4187,11 @@ export class Supervisor {
|
|
|
3868
4187
|
status,
|
|
3869
4188
|
...compact,
|
|
3870
4189
|
...(running ? { epoch: running.epoch } : {}),
|
|
4190
|
+
// Ticket #236: on EVERY status, not only the interesting ones. The API
|
|
4191
|
+
// stores the number, and a status frame that carries none leaves the last
|
|
4192
|
+
// one standing — which for a session whose subagents have just finished
|
|
4193
|
+
// would mean it goes on claiming background work forever.
|
|
4194
|
+
...(running ? { backgroundTasks: running.backgroundTasks } : {}),
|
|
3871
4195
|
});
|
|
3872
4196
|
}
|
|
3873
4197
|
/** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const RUNNER_VERSION = "0.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.45.1";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED