@bridge4dev/runner 0.44.2 → 0.45.0
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 +69 -1
- package/dist/supervisor.js +333 -59
- 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
|
@@ -58,6 +58,26 @@ export declare class Supervisor {
|
|
|
58
58
|
private readonly ws;
|
|
59
59
|
private readonly opts;
|
|
60
60
|
private static readonly ORPHAN_MESSAGE_CAP;
|
|
61
|
+
/**
|
|
62
|
+
* How many delivered message ids one session remembers (#299).
|
|
63
|
+
*
|
|
64
|
+
* Only ever consulted for a REDELIVERY, and the API redelivers on a `hello`
|
|
65
|
+
* — within a reconnect of the original, not a day later. A hundred is far
|
|
66
|
+
* more than that window can hold and still nothing next to a session's own
|
|
67
|
+
* journal.
|
|
68
|
+
*/
|
|
69
|
+
private static readonly DELIVERED_MESSAGE_CAP;
|
|
70
|
+
/**
|
|
71
|
+
* How long a turn that produced nothing is held before it counts (#300).
|
|
72
|
+
*
|
|
73
|
+
* Long enough to cover the gap the owner watched — a resumed process closed
|
|
74
|
+
* a phantom turn and the real answer landed twenty-one seconds later — and
|
|
75
|
+
* short enough that a genuinely silent turn is not left looking busy. The
|
|
76
|
+
* cost of being wrong is asymmetric on purpose: a late «your turn» is a
|
|
77
|
+
* cosmetic delay, an early one is a lie that invites someone to interrupt an
|
|
78
|
+
* agent mid-thought.
|
|
79
|
+
*/
|
|
80
|
+
private static readonly EMPTY_TURN_SETTLE_MS;
|
|
61
81
|
/** A finished session's journal is kept this long for a late reconnect. */
|
|
62
82
|
private static readonly JOURNAL_TTL_MS;
|
|
63
83
|
/** Backstop: events the API will never accept must not pile up forever. */
|
|
@@ -298,6 +318,39 @@ export declare class Supervisor {
|
|
|
298
318
|
* card the user was about to answer — the moment another session wanted a
|
|
299
319
|
* slot.
|
|
300
320
|
*/
|
|
321
|
+
/**
|
|
322
|
+
* Do what the end of a turn does — send it, and move the status.
|
|
323
|
+
*
|
|
324
|
+
* Extracted from `case 'turn_end'` because ticket #300 gave the same work a
|
|
325
|
+
* second caller: a turn held back as a possible phantom finishes here when
|
|
326
|
+
* its timer runs out, and it must end in exactly the way it would have
|
|
327
|
+
* ended immediately. A copy would be two behaviours one edit apart.
|
|
328
|
+
*/
|
|
329
|
+
private completeTurn;
|
|
330
|
+
/**
|
|
331
|
+
* Move the session to the status a finished turn leaves it in.
|
|
332
|
+
*
|
|
333
|
+
* Split out of `completeTurn` because ticket #300 gave it a second caller: a
|
|
334
|
+
* turn held as a possible phantom settles here when its timer runs out, and
|
|
335
|
+
* it has to land in exactly the status it would have landed in immediately.
|
|
336
|
+
*/
|
|
337
|
+
private settleTurnStatus;
|
|
338
|
+
/**
|
|
339
|
+
* Hold a turn that produced nothing, in case it was never a turn (#300).
|
|
340
|
+
*
|
|
341
|
+
* Returns true when the turn has been parked and the caller must stop. False
|
|
342
|
+
* means «treat it as a real ending»: a session already on its way out, or one
|
|
343
|
+
* that is not running an agent, has nothing to wait for.
|
|
344
|
+
*/
|
|
345
|
+
private holdEmptyTurn;
|
|
346
|
+
/**
|
|
347
|
+
* Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
|
|
348
|
+
*
|
|
349
|
+
* Called from `noteAgentIsWorking` — the one place that already knows the
|
|
350
|
+
* agent has produced something — and from the real turn ending, so a held
|
|
351
|
+
* phantom can never fire after the turn it belonged to has closed properly.
|
|
352
|
+
*/
|
|
353
|
+
private clearEmptyTurn;
|
|
301
354
|
private isParkable;
|
|
302
355
|
/**
|
|
303
356
|
* Stop the agent process but keep the session resumable.
|
|
@@ -342,7 +395,22 @@ export declare class Supervisor {
|
|
|
342
395
|
* somebody says so.
|
|
343
396
|
*/
|
|
344
397
|
private onQuestionAnswer;
|
|
345
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Take a message, and say what happened to it — synchronously (#299).
|
|
400
|
+
*
|
|
401
|
+
* NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
|
|
402
|
+
* concurrently (gotcha #68), so a yield between «is this a duplicate» and
|
|
403
|
+
* «record it» would let the retry of a message overtake the original and be
|
|
404
|
+
* delivered twice. The same rule the question answer next door obeys, and for
|
|
405
|
+
* the same reason: this value becomes a `command_result` the API believes.
|
|
406
|
+
*
|
|
407
|
+
* Everything here was already synchronous — the delivery itself rides on
|
|
408
|
+
* `enqueueDelivery`, which registers work on a chain rather than awaiting it.
|
|
409
|
+
* The only change is that the outcome is now spoken out loud instead of being
|
|
410
|
+
* thrown away, because «I wrote it into a socket» was never an answer to
|
|
411
|
+
* «did the runner get it».
|
|
412
|
+
*/
|
|
413
|
+
private acceptUserMessage;
|
|
346
414
|
/**
|
|
347
415
|
* Queue a message that no agent can take yet, and say so in the feed
|
|
348
416
|
* (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,26 @@ 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;
|
|
60
80
|
/** A finished session's journal is kept this long for a late reconnect. */
|
|
61
81
|
static JOURNAL_TTL_MS = 72 * 3_600_000;
|
|
62
82
|
/** Backstop: events the API will never accept must not pile up forever. */
|
|
@@ -207,7 +227,11 @@ export class Supervisor {
|
|
|
207
227
|
await this.startSession(frame.session);
|
|
208
228
|
break;
|
|
209
229
|
case 'session_message':
|
|
210
|
-
|
|
230
|
+
// The legacy door, still open for an API that has not learned the
|
|
231
|
+
// command yet. Its outcome goes nowhere because nobody is listening —
|
|
232
|
+
// which is the whole defect ticket #299 is about, and why the command
|
|
233
|
+
// below exists.
|
|
234
|
+
this.acceptUserMessage(frame.sessionId, frame.text, frame.attachments, frame.messageId ?? undefined);
|
|
211
235
|
break;
|
|
212
236
|
case 'permission_answer': {
|
|
213
237
|
const running = this.sessions.get(frame.sessionId);
|
|
@@ -313,6 +337,8 @@ export class Supervisor {
|
|
|
313
337
|
epoch: descriptor.epoch,
|
|
314
338
|
openQuestions: new Set(),
|
|
315
339
|
answeredAsks: new Set(),
|
|
340
|
+
deliveredMessageIds: new Set(),
|
|
341
|
+
backgroundTasks: 0,
|
|
316
342
|
// Ticket #196: a pause is part of what a session IS, so it is read off
|
|
317
343
|
// the descriptor rather than waiting for a frame. Without this a runner
|
|
318
344
|
// that restarted mid-pause would come back knowing nothing and pick the
|
|
@@ -332,12 +358,25 @@ export class Supervisor {
|
|
|
332
358
|
// exist (session 9).
|
|
333
359
|
running.pendingMessages.push(...running.journal.pending());
|
|
334
360
|
this.sessions.set(descriptor.id, running);
|
|
335
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Messages that arrived for a session this runner did not know yet.
|
|
363
|
+
*
|
|
364
|
+
* They go in through the ORDINARY door now (#231 F4). They used to be
|
|
365
|
+
* pushed straight onto `pendingMessages` with `appendPending` and no
|
|
366
|
+
* `originSeq` — which meant no echo and no `message_queued`, so the words
|
|
367
|
+
* reached the agent and never appeared in the conversation at all. The
|
|
368
|
+
* person saw an empty composer, an empty feed, and then an answer to
|
|
369
|
+
* something they could not see themselves having asked.
|
|
370
|
+
*
|
|
371
|
+
* `queueMessage` is skipped in favour of the full `acceptUserMessage` so
|
|
372
|
+
* that a session which is ready by now delivers immediately rather than
|
|
373
|
+
* sitting in a queue nothing flushes.
|
|
374
|
+
*/
|
|
336
375
|
const orphaned = this.orphanMessages.get(descriptor.id);
|
|
337
376
|
if (orphaned) {
|
|
338
377
|
this.orphanMessages.delete(descriptor.id);
|
|
339
378
|
for (const message of orphaned) {
|
|
340
|
-
|
|
379
|
+
this.acceptUserMessage(descriptor.id, message.text, message.attachments);
|
|
341
380
|
}
|
|
342
381
|
}
|
|
343
382
|
try {
|
|
@@ -1217,6 +1256,142 @@ export class Supervisor {
|
|
|
1217
1256
|
* card the user was about to answer — the moment another session wanted a
|
|
1218
1257
|
* slot.
|
|
1219
1258
|
*/
|
|
1259
|
+
/**
|
|
1260
|
+
* Do what the end of a turn does — send it, and move the status.
|
|
1261
|
+
*
|
|
1262
|
+
* Extracted from `case 'turn_end'` because ticket #300 gave the same work a
|
|
1263
|
+
* second caller: a turn held back as a possible phantom finishes here when
|
|
1264
|
+
* its timer runs out, and it must end in exactly the way it would have
|
|
1265
|
+
* ended immediately. A copy would be two behaviours one edit apart.
|
|
1266
|
+
*/
|
|
1267
|
+
completeTurn(running, descriptor, event) {
|
|
1268
|
+
this.sendEvent(running, 'turn_end', {
|
|
1269
|
+
ok: event.ok,
|
|
1270
|
+
errorMessage: event.errorMessage,
|
|
1271
|
+
...(event.aborted ? { aborted: true } : {}),
|
|
1272
|
+
});
|
|
1273
|
+
// The session is already on its way out with a status that MEANS
|
|
1274
|
+
// something — a spent budget, a Stop, a teardown. A turn ending inside
|
|
1275
|
+
// that window is a consequence of it, and letting the line below
|
|
1276
|
+
// overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
|
|
1277
|
+
// WAITING_INPUT) replaces a true, actionable ending with a wrong one.
|
|
1278
|
+
if (running.stopRequested || running.budgetSpent)
|
|
1279
|
+
return;
|
|
1280
|
+
// A finished turn is the last instant this conversation is BOTH
|
|
1281
|
+
// complete and readable: the process can be parked or lose its slot at
|
|
1282
|
+
// any point after it, and `conversationAnchor()` needs a live one.
|
|
1283
|
+
this.currentAnchor(running);
|
|
1284
|
+
/**
|
|
1285
|
+
* A turn that produced NOTHING does not get to say «your turn» yet (#300).
|
|
1286
|
+
*
|
|
1287
|
+
* Only the STATUS waits. The `turn_end` event above has already gone out,
|
|
1288
|
+
* because the feed's own readers depend on it — the task tray reads it to
|
|
1289
|
+
* retire a finished turn's counters — and because holding it would make two
|
|
1290
|
+
* facts disagree about one moment.
|
|
1291
|
+
*
|
|
1292
|
+
* The status is the whole symptom: `WAITING_INPUT` is what the dashboard
|
|
1293
|
+
* prints as «Your turn», and a resumed agent process closes a turn it never
|
|
1294
|
+
* ran — no text, no thinking, no tool call — twenty-one seconds before the
|
|
1295
|
+
* real answer arrives. The product spent those seconds claiming to wait for
|
|
1296
|
+
* a person who was, in fact, waiting for it.
|
|
1297
|
+
*
|
|
1298
|
+
* `produced` is the adapter's own arithmetic from 0.44.1 (#252/#257),
|
|
1299
|
+
* reused rather than reinvented.
|
|
1300
|
+
*/
|
|
1301
|
+
if (event.ok && event.produced !== true && this.holdEmptyTurn(running, descriptor, event)) {
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
this.settleTurnStatus(running, descriptor, event);
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Move the session to the status a finished turn leaves it in.
|
|
1308
|
+
*
|
|
1309
|
+
* Split out of `completeTurn` because ticket #300 gave it a second caller: a
|
|
1310
|
+
* turn held as a possible phantom settles here when its timer runs out, and
|
|
1311
|
+
* it has to land in exactly the status it would have landed in immediately.
|
|
1312
|
+
*/
|
|
1313
|
+
settleTurnStatus(running, descriptor, event) {
|
|
1314
|
+
// Turn end is the natural checkpoint for the budget clock: the slice
|
|
1315
|
+
// just closed, so this is the moment the API can persist it. Without a
|
|
1316
|
+
// report here `agentActiveMs` stayed 0 and every restart or resume
|
|
1317
|
+
// silently handed the session a full fresh budget.
|
|
1318
|
+
if (event.ok) {
|
|
1319
|
+
const next = descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW';
|
|
1320
|
+
this.reportStatus(descriptor.id, next, {
|
|
1321
|
+
costUsd: running.costUsd,
|
|
1322
|
+
activeMs: Supervisor.spentMs(running),
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
else if (event.limitBlocked) {
|
|
1326
|
+
// #258. The plan refused this turn — it never ran, so the session has
|
|
1327
|
+
// not failed at anything: it is waiting for a window to open, and the
|
|
1328
|
+
// API has just armed a clock to wake it. FAILED is terminal, and a
|
|
1329
|
+
// terminal session cannot be woken — the pause would ring into a dead
|
|
1330
|
+
// row and cancel the words the person queued behind it.
|
|
1331
|
+
this.reportStatus(descriptor.id, descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW', {
|
|
1332
|
+
costUsd: running.costUsd,
|
|
1333
|
+
activeMs: Supervisor.spentMs(running),
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
else {
|
|
1337
|
+
this.reportStatus(descriptor.id, 'FAILED', {
|
|
1338
|
+
costUsd: running.costUsd,
|
|
1339
|
+
activeMs: Supervisor.spentMs(running),
|
|
1340
|
+
errorMessage: event.errorMessage ?? 'Agent turn failed',
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Hold a turn that produced nothing, in case it was never a turn (#300).
|
|
1346
|
+
*
|
|
1347
|
+
* Returns true when the turn has been parked and the caller must stop. False
|
|
1348
|
+
* means «treat it as a real ending»: a session already on its way out, or one
|
|
1349
|
+
* that is not running an agent, has nothing to wait for.
|
|
1350
|
+
*/
|
|
1351
|
+
holdEmptyTurn(running, descriptor, event) {
|
|
1352
|
+
// An aborted turn produced nothing BY DEFINITION — the human pressed Stop,
|
|
1353
|
+
// and making them wait 25 seconds to be told the turn is over would be a
|
|
1354
|
+
// new bug in place of the old one.
|
|
1355
|
+
if (event.aborted)
|
|
1356
|
+
return false;
|
|
1357
|
+
// The session is winding down: a status is already on its way that means
|
|
1358
|
+
// something, and holding this would only delay a truthful ending.
|
|
1359
|
+
if (running.stopRequested || running.budgetSpent)
|
|
1360
|
+
return false;
|
|
1361
|
+
// Two of these in a row is not two phantoms — it is a quiet agent. The
|
|
1362
|
+
// second one ends the turn for real.
|
|
1363
|
+
if (running.emptyTurnTimer)
|
|
1364
|
+
return false;
|
|
1365
|
+
log.info('supervisor: holding a turn that produced nothing', {
|
|
1366
|
+
sessionId: descriptor.id,
|
|
1367
|
+
settleMs: Supervisor.EMPTY_TURN_SETTLE_MS,
|
|
1368
|
+
});
|
|
1369
|
+
const timer = setTimeout(() => {
|
|
1370
|
+
running.emptyTurnTimer = undefined;
|
|
1371
|
+
// The session may have ended, been resumed or been replaced while we
|
|
1372
|
+
// waited — `isStale` is the same guard every other delayed path uses.
|
|
1373
|
+
if (this.isStale(running))
|
|
1374
|
+
return;
|
|
1375
|
+
log.info('supervisor: the empty turn was real after all', { sessionId: descriptor.id });
|
|
1376
|
+
this.settleTurnStatus(running, descriptor, event);
|
|
1377
|
+
}, Supervisor.EMPTY_TURN_SETTLE_MS);
|
|
1378
|
+
timer.unref();
|
|
1379
|
+
running.emptyTurnTimer = timer;
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* Drop a held turn: the agent spoke, so the turn it «ended» was a phantom.
|
|
1384
|
+
*
|
|
1385
|
+
* Called from `noteAgentIsWorking` — the one place that already knows the
|
|
1386
|
+
* agent has produced something — and from the real turn ending, so a held
|
|
1387
|
+
* phantom can never fire after the turn it belonged to has closed properly.
|
|
1388
|
+
*/
|
|
1389
|
+
clearEmptyTurn(running) {
|
|
1390
|
+
if (!running.emptyTurnTimer)
|
|
1391
|
+
return;
|
|
1392
|
+
clearTimeout(running.emptyTurnTimer);
|
|
1393
|
+
running.emptyTurnTimer = undefined;
|
|
1394
|
+
}
|
|
1220
1395
|
isParkable(running) {
|
|
1221
1396
|
return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
|
|
1222
1397
|
running.openQuestions.size === 0 &&
|
|
@@ -1274,6 +1449,12 @@ export class Supervisor {
|
|
|
1274
1449
|
* own question must not take the card off the screen.
|
|
1275
1450
|
*/
|
|
1276
1451
|
noteAgentIsWorking(running) {
|
|
1452
|
+
// Ticket #300: the agent is producing, so a turn held back as a possible
|
|
1453
|
+
// phantom was one. Dropped BEFORE the guards below, because those are about
|
|
1454
|
+
// whether the STATUS may move — and a phantom must be cancelled even when
|
|
1455
|
+
// the status is already right, or it fires later and ends a turn that is
|
|
1456
|
+
// still going.
|
|
1457
|
+
this.clearEmptyTurn(running);
|
|
1277
1458
|
if (running.openQuestions.size > 0)
|
|
1278
1459
|
return;
|
|
1279
1460
|
if (running.stopRequested || running.parkRequested || running.budgetSpent)
|
|
@@ -1341,51 +1522,12 @@ export class Supervisor {
|
|
|
1341
1522
|
// when it finally succeeds or finally gives up.
|
|
1342
1523
|
if (!event.ok && this.armApiRetry(running, descriptor, event))
|
|
1343
1524
|
return;
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
-
}
|
|
1525
|
+
// A previous turn's phantom, if any, is over: this turn has ended for
|
|
1526
|
+
// real, and a timer still holding its predecessor would report a status
|
|
1527
|
+
// for a turn that is two turns old (#300 — the hold itself lives in
|
|
1528
|
+
// `completeTurn`, where the decision belongs).
|
|
1529
|
+
this.clearEmptyTurn(running);
|
|
1530
|
+
this.completeTurn(running, descriptor, event);
|
|
1389
1531
|
return;
|
|
1390
1532
|
}
|
|
1391
1533
|
case 'error':
|
|
@@ -1592,7 +1734,7 @@ export class Supervisor {
|
|
|
1592
1734
|
case 'rate_limits':
|
|
1593
1735
|
this.sendEvent(running, 'rate_limits', { ...event.limits });
|
|
1594
1736
|
return;
|
|
1595
|
-
case 'agent_tasks':
|
|
1737
|
+
case 'agent_tasks': {
|
|
1596
1738
|
// Ticket #113. A LEVEL signal: every frame carries the whole live set,
|
|
1597
1739
|
// so the dashboard replaces rather than reconciles and a dropped frame
|
|
1598
1740
|
// cannot leave a finished subagent spinning in the tray forever.
|
|
@@ -1601,7 +1743,37 @@ export class Supervisor {
|
|
|
1601
1743
|
done: event.done,
|
|
1602
1744
|
total: event.total,
|
|
1603
1745
|
});
|
|
1746
|
+
/**
|
|
1747
|
+
* ...and keep the count where a DECISION can read it (ticket #236).
|
|
1748
|
+
*
|
|
1749
|
+
* Until now this frame only ever flew past on its way to the browser,
|
|
1750
|
+
* and the one place that had to know — «is the turn the human's now» —
|
|
1751
|
+
* was taken without it. Two symptoms, one cause: a status strip reading
|
|
1752
|
+
* «Your turn» a centimetre above a live subagent, and a notification
|
|
1753
|
+
* calling someone to a conversation with nothing to answer.
|
|
1754
|
+
*
|
|
1755
|
+
* Counted from `tasks` rather than from `total`/`done`: those are the
|
|
1756
|
+
* turn's arithmetic and a background task deliberately outlives its own
|
|
1757
|
+
* turn (`endTaskTurn` keeps the live set).
|
|
1758
|
+
*/
|
|
1759
|
+
const live = event.tasks.filter((task) => task.status === 'running').length;
|
|
1760
|
+
if (live !== running.backgroundTasks) {
|
|
1761
|
+
const wasWaiting = running.backgroundTasks > 0 && live === 0;
|
|
1762
|
+
running.backgroundTasks = live;
|
|
1763
|
+
// The moment the last subagent finishes is the moment the session
|
|
1764
|
+
// really does become the human's — and the agent is sitting in
|
|
1765
|
+
// WAITING_INPUT, so nothing else will ever say so. `reportStatus`
|
|
1766
|
+
// carries the new number and the API turns it into the notification
|
|
1767
|
+
// it withheld earlier.
|
|
1768
|
+
if (wasWaiting && running.lastReported === 'WAITING_INPUT') {
|
|
1769
|
+
this.reportStatus(running.descriptor.id, 'WAITING_INPUT', {
|
|
1770
|
+
costUsd: running.costUsd,
|
|
1771
|
+
activeMs: Supervisor.spentMs(running),
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1604
1775
|
return;
|
|
1776
|
+
}
|
|
1605
1777
|
case 'notice': {
|
|
1606
1778
|
// Only adapter notices are de-duplicated here. The supervisor's own
|
|
1607
1779
|
// notices (turn interrupted, session parked, budget warnings) go
|
|
@@ -1678,12 +1850,9 @@ export class Supervisor {
|
|
|
1678
1850
|
// The words must not be eaten. This is the ordinary case after a runner
|
|
1679
1851
|
// restart: the card in the browser outlived the process that asked, and
|
|
1680
1852
|
// showing the user's own message in the feed while nothing receives it is
|
|
1681
|
-
// the exact failure `
|
|
1853
|
+
// the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
|
|
1682
1854
|
if (typed) {
|
|
1683
|
-
|
|
1684
|
-
sessionId: frame.sessionId,
|
|
1685
|
-
error: String(error),
|
|
1686
|
-
}));
|
|
1855
|
+
this.acceptUserMessage(frame.sessionId, typed);
|
|
1687
1856
|
return 'not_open';
|
|
1688
1857
|
}
|
|
1689
1858
|
// Nothing to deliver — but the API optimistically flipped the session to
|
|
@@ -1691,7 +1860,22 @@ export class Supervisor {
|
|
|
1691
1860
|
this.reportStatus(frame.sessionId, statusForReport(running), {});
|
|
1692
1861
|
return 'not_open';
|
|
1693
1862
|
}
|
|
1694
|
-
|
|
1863
|
+
/**
|
|
1864
|
+
* Take a message, and say what happened to it — synchronously (#299).
|
|
1865
|
+
*
|
|
1866
|
+
* NOT A SINGLE `await` FROM HERE TO THE RETURN. Frames are handled
|
|
1867
|
+
* concurrently (gotcha #68), so a yield between «is this a duplicate» and
|
|
1868
|
+
* «record it» would let the retry of a message overtake the original and be
|
|
1869
|
+
* delivered twice. The same rule the question answer next door obeys, and for
|
|
1870
|
+
* the same reason: this value becomes a `command_result` the API believes.
|
|
1871
|
+
*
|
|
1872
|
+
* Everything here was already synchronous — the delivery itself rides on
|
|
1873
|
+
* `enqueueDelivery`, which registers work on a chain rather than awaiting it.
|
|
1874
|
+
* The only change is that the outcome is now spoken out loud instead of being
|
|
1875
|
+
* thrown away, because «I wrote it into a socket» was never an answer to
|
|
1876
|
+
* «did the runner get it».
|
|
1877
|
+
*/
|
|
1878
|
+
acceptUserMessage(sessionId, text, attachments, messageId) {
|
|
1695
1879
|
const running = this.sessions.get(sessionId);
|
|
1696
1880
|
if (!running) {
|
|
1697
1881
|
// The API believes this session lives here but the runner lost track of
|
|
@@ -1703,7 +1887,31 @@ export class Supervisor {
|
|
|
1703
1887
|
queued.push({ text, ...(attachments?.length ? { attachments } : {}) });
|
|
1704
1888
|
this.orphanMessages.set(sessionId, queued.slice(-Supervisor.ORPHAN_MESSAGE_CAP));
|
|
1705
1889
|
this.ws.send({ type: 'session_unknown', sessionId });
|
|
1706
|
-
return;
|
|
1890
|
+
return 'unknown_session';
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Already taken (#299).
|
|
1894
|
+
*
|
|
1895
|
+
* The API queues a message it could not get confirmed and re-sends it on
|
|
1896
|
+
* the next `hello`. «Not confirmed» is not «not delivered»: the socket can
|
|
1897
|
+
* die between this runner accepting the words and the answer reaching the
|
|
1898
|
+
* API. Without this set the retry would hand the agent the same sentence a
|
|
1899
|
+
* second time — the very failure the queue was built to avoid.
|
|
1900
|
+
*
|
|
1901
|
+
* In memory, so a runner that RESTARTED between the two attempts will take
|
|
1902
|
+
* it twice. That is the deliberate side of the trade: after a restart the
|
|
1903
|
+
* first copy almost certainly never reached the agent, and a duplicate is a
|
|
1904
|
+
* far smaller injury than a message that silently does not exist.
|
|
1905
|
+
*/
|
|
1906
|
+
if (messageId) {
|
|
1907
|
+
if (running.deliveredMessageIds.has(messageId))
|
|
1908
|
+
return 'duplicate';
|
|
1909
|
+
running.deliveredMessageIds.add(messageId);
|
|
1910
|
+
if (running.deliveredMessageIds.size > Supervisor.DELIVERED_MESSAGE_CAP) {
|
|
1911
|
+
const oldest = running.deliveredMessageIds.values().next().value;
|
|
1912
|
+
if (oldest !== undefined)
|
|
1913
|
+
running.deliveredMessageIds.delete(oldest);
|
|
1914
|
+
}
|
|
1707
1915
|
}
|
|
1708
1916
|
// The feed shows what the USER wrote plus the files they picked — not the
|
|
1709
1917
|
// composed prompt with workspace paths, which is an implementation detail.
|
|
@@ -1712,6 +1920,12 @@ export class Supervisor {
|
|
|
1712
1920
|
const echoed = this.sendEvent(running, 'message', {
|
|
1713
1921
|
role: 'user',
|
|
1714
1922
|
text,
|
|
1923
|
+
// The API's name for this message, echoed back so the browser can match
|
|
1924
|
+
// the bubble it drew itself the moment Send was pressed (#299). Matching
|
|
1925
|
+
// on text alone works right up until somebody sends the same sentence
|
|
1926
|
+
// twice, which is exactly what a person does when they think the first
|
|
1927
|
+
// one was lost.
|
|
1928
|
+
...(messageId ? { messageId } : {}),
|
|
1715
1929
|
...(attachments?.length ? { attachments } : {}),
|
|
1716
1930
|
});
|
|
1717
1931
|
const originSeq = echoed.seq;
|
|
@@ -1726,7 +1940,7 @@ export class Supervisor {
|
|
|
1726
1940
|
// own conflict tasks. Held here rather than delivered, so «на паузе»
|
|
1727
1941
|
// means the same thing whichever door the words came through.
|
|
1728
1942
|
this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
|
|
1729
|
-
return;
|
|
1943
|
+
return 'accepted';
|
|
1730
1944
|
}
|
|
1731
1945
|
this.enqueueDelivery(running, async () => {
|
|
1732
1946
|
const composed = await this.materializeAttachments(running, text, attachments);
|
|
@@ -1751,6 +1965,7 @@ export class Supervisor {
|
|
|
1751
1965
|
return;
|
|
1752
1966
|
this.deliverMessage(running, composed, [], originSeq);
|
|
1753
1967
|
});
|
|
1968
|
+
return 'accepted';
|
|
1754
1969
|
}
|
|
1755
1970
|
/**
|
|
1756
1971
|
* Queue a message that no agent can take yet, and say so in the feed
|
|
@@ -1993,7 +2208,30 @@ export class Supervisor {
|
|
|
1993
2208
|
// them a follow-up line instead of the task would start a fresh
|
|
1994
2209
|
// conversation with «продолжай».
|
|
1995
2210
|
running.lastTurnPrompt = text;
|
|
1996
|
-
|
|
2211
|
+
/**
|
|
2212
|
+
* The process can refuse — and used to refuse in silence (#231 F8).
|
|
2213
|
+
*
|
|
2214
|
+
* `AsyncQueue.push` answers false once the queue has ended, which happens
|
|
2215
|
+
* when the agent's own iterator finished between the check above and this
|
|
2216
|
+
* line. The adapter wrote a line to its log and returned; from here the
|
|
2217
|
+
* message looked delivered, so `settle()` retired it from the queue and
|
|
2218
|
+
* the status went to RUNNING. On screen: the person's bubble, the word
|
|
2219
|
+
* «working», and an agent that had already stopped and would never answer.
|
|
2220
|
+
*
|
|
2221
|
+
* Now it goes back in the queue, exactly like a parked process below, and
|
|
2222
|
+
* the feed says why.
|
|
2223
|
+
*/
|
|
2224
|
+
if (!running.session.send(text)) {
|
|
2225
|
+
log.warn('supervisor: the agent process refused the message — requeued', {
|
|
2226
|
+
sessionId: running.descriptor.id,
|
|
2227
|
+
});
|
|
2228
|
+
this.sendEvent(running, 'system_note', {
|
|
2229
|
+
code: 'agent_gone',
|
|
2230
|
+
text: 'The agent process ended before it could take this message. It is queued and will be delivered when the session continues.',
|
|
2231
|
+
});
|
|
2232
|
+
this.requeue(running, held, text, originSeq);
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
1997
2235
|
settle();
|
|
1998
2236
|
this.reportStatus(running.descriptor.id, 'RUNNING', {});
|
|
1999
2237
|
return;
|
|
@@ -2599,6 +2837,8 @@ export class Supervisor {
|
|
|
2599
2837
|
epoch: descriptor.epoch,
|
|
2600
2838
|
openQuestions: new Set(),
|
|
2601
2839
|
answeredAsks: new Set(),
|
|
2840
|
+
deliveredMessageIds: new Set(),
|
|
2841
|
+
backgroundTasks: 0,
|
|
2602
2842
|
...pausedUntilOf(descriptor),
|
|
2603
2843
|
mode: descriptor.mode,
|
|
2604
2844
|
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
@@ -2864,6 +3104,35 @@ export class Supervisor {
|
|
|
2864
3104
|
? { ok: false, error: 'Unknown session', result: { outcome } }
|
|
2865
3105
|
: { ok: true, result: { outcome } });
|
|
2866
3106
|
}
|
|
3107
|
+
case 'deliver_message': {
|
|
3108
|
+
// Ticket #299 — the same treatment as `answer_question` above, for
|
|
3109
|
+
// the door people use every minute. The `session_message` frame it
|
|
3110
|
+
// replaces is fire-and-forget, so the API counted a write into a
|
|
3111
|
+
// socket as a delivery; on 16.08.2026 a message written into a socket
|
|
3112
|
+
// that still read OPEN simply ceased to exist, with the queue empty
|
|
3113
|
+
// and the composer cleared.
|
|
3114
|
+
//
|
|
3115
|
+
// No `await` before the reply. `acceptUserMessage` is synchronous
|
|
3116
|
+
// precisely so this stays true: a yield between «is this a duplicate»
|
|
3117
|
+
// and «write it down» would let a redelivery overtake the original.
|
|
3118
|
+
const sessionId = frame.sessionId;
|
|
3119
|
+
if (!sessionId)
|
|
3120
|
+
return void reply({ ok: false, error: 'sessionId is required' });
|
|
3121
|
+
const parsed = DeliverMessageArgsSchema.safeParse(frame.args ?? {});
|
|
3122
|
+
if (!parsed.success) {
|
|
3123
|
+
return void reply({
|
|
3124
|
+
ok: false,
|
|
3125
|
+
error: parsed.error.issues[0]?.message ?? 'malformed message',
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
const outcome = this.acceptUserMessage(sessionId, parsed.data.text, parsed.data.attachments, parsed.data.messageId);
|
|
3129
|
+
// `ok` is about the RELAY, like the answer above: `unknown_session` is
|
|
3130
|
+
// the one case the API can fix by asking again after it has re-sent
|
|
3131
|
+
// the descriptor, so it is the one case reported as a failure.
|
|
3132
|
+
return void reply(outcome === 'unknown_session'
|
|
3133
|
+
? { ok: false, error: 'Unknown session', result: { outcome } }
|
|
3134
|
+
: { ok: true, result: { outcome } });
|
|
3135
|
+
}
|
|
2867
3136
|
case 'recall_message': {
|
|
2868
3137
|
// Ticket #125: take a queued message back before any agent sees it.
|
|
2869
3138
|
const sessionId = frame.sessionId;
|
|
@@ -3868,6 +4137,11 @@ export class Supervisor {
|
|
|
3868
4137
|
status,
|
|
3869
4138
|
...compact,
|
|
3870
4139
|
...(running ? { epoch: running.epoch } : {}),
|
|
4140
|
+
// Ticket #236: on EVERY status, not only the interesting ones. The API
|
|
4141
|
+
// stores the number, and a status frame that carries none leaves the last
|
|
4142
|
+
// one standing — which for a session whose subagents have just finished
|
|
4143
|
+
// would mean it goes on claiming background work forever.
|
|
4144
|
+
...(running ? { backgroundTasks: running.backgroundTasks } : {}),
|
|
3871
4145
|
});
|
|
3872
4146
|
}
|
|
3873
4147
|
/** 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.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED