@bridge4dev/runner 0.27.0 → 0.29.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 +136 -8
- package/dist/adapters/codex.js +96 -2
- package/dist/adapters/types.d.ts +60 -1
- package/dist/attachments.d.ts +27 -0
- package/dist/attachments.js +150 -8
- package/dist/checkpoints.d.ts +175 -0
- package/dist/checkpoints.js +816 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +17 -0
- package/dist/index.js +30 -0
- package/dist/journal.d.ts +34 -1
- package/dist/journal.js +51 -2
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +12 -0
- package/dist/policy.js +15 -0
- package/dist/protocol.d.ts +5 -5
- package/dist/supervisor.d.ts +86 -0
- package/dist/supervisor.js +555 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -16,6 +16,10 @@ import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailu
|
|
|
16
16
|
import { selfUpdate } from './self-update.js';
|
|
17
17
|
import { rememberWorkspacePath } from './environment.js';
|
|
18
18
|
import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
|
|
19
|
+
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
20
|
+
/** Refusals shared by every checkpoint command (ticket #126). */
|
|
21
|
+
const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
|
|
22
|
+
const AGENT_BUSY = 'The agent is still working — stop the turn first';
|
|
19
23
|
export class Supervisor {
|
|
20
24
|
ws;
|
|
21
25
|
opts;
|
|
@@ -37,6 +41,17 @@ export class Supervisor {
|
|
|
37
41
|
authRelay = new AuthRelay();
|
|
38
42
|
/** Serialises repo-mutating git commands per workspace repo (QA-99 MAJOR-1). */
|
|
39
43
|
repoLocks = new Map();
|
|
44
|
+
/**
|
|
45
|
+
* Repo keys with work in flight right now (ticket #126).
|
|
46
|
+
*
|
|
47
|
+
* A checkpoint deliberately does NOT take the repo lock — it only reads the
|
|
48
|
+
* worktree, and queueing a user's message behind a 150-second push would be
|
|
49
|
+
* a plain regression. What it does instead is decline to run while a
|
|
50
|
+
* mutating command holds the repo: a snapshot taken halfway through an apply
|
|
51
|
+
* would be a state that never existed, and offering to restore it is worse
|
|
52
|
+
* than having no restore point for that one message.
|
|
53
|
+
*/
|
|
54
|
+
repoLockDepth = new Map();
|
|
40
55
|
/** An update is installing right now — a second one would fight it. */
|
|
41
56
|
selfUpdateInFlight = false;
|
|
42
57
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
@@ -268,6 +283,13 @@ export class Supervisor {
|
|
|
268
283
|
// asked for it) resumes on the next message instead of replaying its
|
|
269
284
|
// original prompt.
|
|
270
285
|
if (descriptor.status === 'STARTING') {
|
|
286
|
+
// Ticket #126: the point before the agent has touched anything. It is
|
|
287
|
+
// anchored to seq 0 — the synthetic opening bubble the dashboard puts in
|
|
288
|
+
// front of every feed — so "put it all back" is reachable from the very
|
|
289
|
+
// first thing on the page.
|
|
290
|
+
await this.captureCheckpoint(running, 'TURN', 0);
|
|
291
|
+
if (this.isStale(running))
|
|
292
|
+
return;
|
|
271
293
|
this.launchAgent(running, composeInitialPrompt(descriptor), null);
|
|
272
294
|
}
|
|
273
295
|
else {
|
|
@@ -360,6 +382,14 @@ export class Supervisor {
|
|
|
360
382
|
// Facts about this session only. The project's own documentation is read by
|
|
361
383
|
// each agent itself — see `composeWorkspaceContext`.
|
|
362
384
|
const workspaceContext = composeWorkspaceContext(descriptor);
|
|
385
|
+
const rewind = running.rewindAnchor;
|
|
386
|
+
delete running.rewindAnchor;
|
|
387
|
+
// A rewind resumes the conversation the POINT names, which is not always
|
|
388
|
+
// the one this session is on now: rewinding twice, or rewinding the first
|
|
389
|
+
// message after a rewind, both reach back into the thread the fork came
|
|
390
|
+
// from. Its transcript is still on disk — that is what makes the point
|
|
391
|
+
// usable at all.
|
|
392
|
+
const resumeTarget = rewind ? rewind.agentSession : resumeId;
|
|
363
393
|
running.session = adapter.startSession({
|
|
364
394
|
sessionId: descriptor.id,
|
|
365
395
|
cwd: running.worktreePath,
|
|
@@ -372,7 +402,12 @@ export class Supervisor {
|
|
|
372
402
|
mode: running.mode,
|
|
373
403
|
...(running.model ? { model: running.model } : {}),
|
|
374
404
|
...(running.effort ? { effort: running.effort } : {}),
|
|
375
|
-
...(
|
|
405
|
+
...(resumeTarget ? { resumeProviderSessionId: resumeTarget } : {}),
|
|
406
|
+
// Ticket #126: a conversation rewind takes effect exactly here, on the
|
|
407
|
+
// next process this session starts. Consumed rather than kept — a rewind
|
|
408
|
+
// is one event, not a standing setting, and re-applying it on a later
|
|
409
|
+
// relaunch would silently throw away everything said since.
|
|
410
|
+
...(rewind ? { resumeAtAnchor: rewind.anchor } : {}),
|
|
376
411
|
// Descriptor MCP (auto-issued per-workspace key) wins over config.toml.
|
|
377
412
|
...((descriptor.mcp ?? this.opts.mcp) ? { mcp: descriptor.mcp ?? this.opts.mcp } : {}),
|
|
378
413
|
// The SDK budget is per-process; hand the RESIDUAL session budget down.
|
|
@@ -571,6 +606,16 @@ export class Supervisor {
|
|
|
571
606
|
this.launchAgent(running, prompt, null);
|
|
572
607
|
return;
|
|
573
608
|
}
|
|
609
|
+
// The way back when the CLI refuses the rewind anchor: one relaunch WITHOUT
|
|
610
|
+
// it, keeping the conversation. Nothing was rewound, so nothing is cut.
|
|
611
|
+
if (running.rewindRetry && !running.stopRequested) {
|
|
612
|
+
const { prompt } = running.rewindRetry;
|
|
613
|
+
delete running.rewindRetry;
|
|
614
|
+
running.rewindRetryDone = true;
|
|
615
|
+
running.costBaseUsd = running.costUsd;
|
|
616
|
+
this.launchAgent(running, prompt, running.descriptor.providerSessionId);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
574
619
|
// Auth recovery: one relaunch that KEEPS the provider session, so a
|
|
575
620
|
// credential hiccup does not cost the agent its whole conversation.
|
|
576
621
|
if (running.authRetry && !running.stopRequested) {
|
|
@@ -795,29 +840,48 @@ export class Supervisor {
|
|
|
795
840
|
running.openQuestions.size === 0 &&
|
|
796
841
|
Boolean(running.descriptor.providerSessionId));
|
|
797
842
|
}
|
|
798
|
-
|
|
843
|
+
/**
|
|
844
|
+
* Stop the agent process but keep the session resumable.
|
|
845
|
+
*
|
|
846
|
+
* `quiet` is for the callers that say it better themselves: a conversation
|
|
847
|
+
* rewind parks the session too, and announcing «the runner switched to
|
|
848
|
+
* another session» there is simply untrue — nothing switched, the user
|
|
849
|
+
* rewound (ticket #126).
|
|
850
|
+
*/
|
|
851
|
+
park(running, options = {}) {
|
|
799
852
|
if (!running.session)
|
|
800
853
|
return;
|
|
854
|
+
// Ask the process where the conversation stands while it is still there to
|
|
855
|
+
// ask (ticket #126) — a restore point taken after this would otherwise
|
|
856
|
+
// have no way to rewind the agent's memory.
|
|
857
|
+
this.currentAnchor(running);
|
|
801
858
|
running.parkRequested = true;
|
|
802
859
|
// `code` is what the dashboard reads to tell «parked» from «your turn»
|
|
803
860
|
// (#124). The prose stays for runners older than 0.23.0, which the
|
|
804
861
|
// dashboard still matches on; delete that fallback once the fleet has
|
|
805
862
|
// moved, not before.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
863
|
+
if (!options.quiet) {
|
|
864
|
+
this.sendEvent(running, 'system_note', {
|
|
865
|
+
code: 'session_parked',
|
|
866
|
+
text: 'Session parked — the runner switched to another session. Send a message to resume.',
|
|
867
|
+
});
|
|
868
|
+
}
|
|
810
869
|
running.session.stop('session_parked');
|
|
811
870
|
}
|
|
812
871
|
forwardEvent(running, event) {
|
|
813
872
|
const { descriptor } = running;
|
|
814
873
|
switch (event.type) {
|
|
815
|
-
case 'provider_session':
|
|
874
|
+
case 'provider_session': {
|
|
816
875
|
running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
|
|
817
876
|
this.reportStatus(descriptor.id, statusForReport(running), {
|
|
818
877
|
providerSessionId: event.providerSessionId,
|
|
819
878
|
});
|
|
879
|
+
// The CLI resumed at the anchor and forked, so the cut announced when
|
|
880
|
+
// the rewind was asked for stands. Nothing to send — it is already in
|
|
881
|
+
// the feed; this only retires the promise to take it back.
|
|
882
|
+
delete running.pendingRewind;
|
|
820
883
|
return;
|
|
884
|
+
}
|
|
821
885
|
case 'cost':
|
|
822
886
|
// event.costUsd is the live process's running total; the session
|
|
823
887
|
// total also includes what earlier processes spent (park/restart).
|
|
@@ -829,7 +893,22 @@ export class Supervisor {
|
|
|
829
893
|
});
|
|
830
894
|
return;
|
|
831
895
|
case 'turn_end': {
|
|
832
|
-
this.sendEvent(running, 'turn_end', {
|
|
896
|
+
this.sendEvent(running, 'turn_end', {
|
|
897
|
+
ok: event.ok,
|
|
898
|
+
errorMessage: event.errorMessage,
|
|
899
|
+
...(event.aborted ? { aborted: true } : {}),
|
|
900
|
+
});
|
|
901
|
+
// The session is already on its way out with a status that MEANS
|
|
902
|
+
// something — a spent budget, a Stop, a teardown. A turn ending inside
|
|
903
|
+
// that window is a consequence of it, and letting the line below
|
|
904
|
+
// overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
|
|
905
|
+
// WAITING_INPUT) replaces a true, actionable ending with a wrong one.
|
|
906
|
+
if (running.stopRequested || running.budgetSpent)
|
|
907
|
+
return;
|
|
908
|
+
// A finished turn is the last instant this conversation is BOTH
|
|
909
|
+
// complete and readable: the process can be parked or lose its slot at
|
|
910
|
+
// any point after it, and `conversationAnchor()` needs a live one.
|
|
911
|
+
this.currentAnchor(running);
|
|
833
912
|
// Turn end is the natural checkpoint for the budget clock: the slice
|
|
834
913
|
// just closed, so this is the moment the API can persist it. Without a
|
|
835
914
|
// report here `agentActiveMs` stayed 0 and every restart or resume
|
|
@@ -854,6 +933,31 @@ export class Supervisor {
|
|
|
854
933
|
// A provider session the CLI no longer knows (state wiped, expired
|
|
855
934
|
// history): don't fail the session — relaunch it fresh once with the
|
|
856
935
|
// same prompt, the git worktree still holds all the work.
|
|
936
|
+
// The CLI does not know the point we asked it to resume at. The
|
|
937
|
+
// conversation is intact — only the rewind did not happen — so the
|
|
938
|
+
// session must survive, the feed must NOT be cut (nothing was rewound),
|
|
939
|
+
// and the person must be told plainly rather than left believing the
|
|
940
|
+
// agent has forgotten something it still remembers.
|
|
941
|
+
if (event.code === 'rewind_failed') {
|
|
942
|
+
// Put the conversation back on the page: the agent still has every
|
|
943
|
+
// word of it, and a transcript that shows less than the agent knows
|
|
944
|
+
// is the one outcome this feature must never produce.
|
|
945
|
+
const cutToUndo = running.pendingRewind;
|
|
946
|
+
delete running.pendingRewind;
|
|
947
|
+
if (cutToUndo) {
|
|
948
|
+
this.sendEvent(running, 'session_rewind_undone', {
|
|
949
|
+
rewoundSeq: cutToUndo.rewoundSeq,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
if (!event.recovered && !running.rewindRetryDone) {
|
|
953
|
+
running.rewindRetry = { prompt: running.lastPrompt };
|
|
954
|
+
this.sendEvent(running, 'notice', {
|
|
955
|
+
level: 'warn',
|
|
956
|
+
text: 'The conversation could not be rewound to that point — the agent still remembers everything after it. Its files were not affected.',
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
857
961
|
if (event.code === 'resume_failed' && !running.freshRetryDone) {
|
|
858
962
|
running.freshRetry = { prompt: running.lastPrompt };
|
|
859
963
|
this.sendEvent(running, 'notice', {
|
|
@@ -1126,16 +1230,19 @@ export class Supervisor {
|
|
|
1126
1230
|
}
|
|
1127
1231
|
// The feed shows what the USER wrote plus the files they picked — not the
|
|
1128
1232
|
// composed prompt with workspace paths, which is an implementation detail.
|
|
1129
|
-
this
|
|
1233
|
+
// Its seq is the name this message answers to for the rest of its life
|
|
1234
|
+
// (ticket #125): recall, checkpoint anchor and rewind all quote it.
|
|
1235
|
+
const echoed = this.sendEvent(running, 'message', {
|
|
1130
1236
|
role: 'user',
|
|
1131
1237
|
text,
|
|
1132
1238
|
...(attachments?.length ? { attachments } : {}),
|
|
1133
1239
|
});
|
|
1240
|
+
const originSeq = echoed.seq;
|
|
1134
1241
|
if (!running.worktreePath) {
|
|
1135
1242
|
// Session is still being prepared — deliver after launch (QA-96 F3).
|
|
1136
1243
|
// Attachments travel as metadata and are downloaded at delivery time,
|
|
1137
1244
|
// which is the first moment the worktree is guaranteed to exist.
|
|
1138
|
-
|
|
1245
|
+
this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
|
|
1139
1246
|
return;
|
|
1140
1247
|
}
|
|
1141
1248
|
this.enqueueDelivery(running, async () => {
|
|
@@ -1143,15 +1250,40 @@ export class Supervisor {
|
|
|
1143
1250
|
if (this.isStale(running)) {
|
|
1144
1251
|
// The session ended while the files downloaded. Park the message on
|
|
1145
1252
|
// disk rather than dropping it — «Продолжить» carries it to the agent.
|
|
1146
|
-
|
|
1253
|
+
// Announced like every other queueing decision: a bubble that is
|
|
1254
|
+
// waiting must say so, whichever door it came through.
|
|
1255
|
+
const parked = running.journal.appendPending(text, attachments, originSeq);
|
|
1256
|
+
if (parked.seq !== undefined) {
|
|
1257
|
+
this.sendEvent(running, 'message_queued', { targetSeq: parked.seq });
|
|
1258
|
+
}
|
|
1147
1259
|
log.warn('supervisor: session ended before the message could be delivered', {
|
|
1148
1260
|
sessionId: running.descriptor.id,
|
|
1149
1261
|
});
|
|
1150
1262
|
return;
|
|
1151
1263
|
}
|
|
1152
|
-
this
|
|
1264
|
+
// Ticket #126: the restore point belongs in FRONT of this message, and
|
|
1265
|
+
// this is the last moment the tree is still as the user left it.
|
|
1266
|
+
await this.captureCheckpoint(running, 'TURN', originSeq);
|
|
1267
|
+
if (this.isStale(running))
|
|
1268
|
+
return;
|
|
1269
|
+
this.deliverMessage(running, composed, [], originSeq);
|
|
1153
1270
|
});
|
|
1154
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Queue a message that no agent can take yet, and say so in the feed
|
|
1274
|
+
* (ticket #125).
|
|
1275
|
+
*
|
|
1276
|
+
* Every queueing decision goes through here so the browser's idea of "this
|
|
1277
|
+
* one can still be taken back" cannot drift from the runner's. Records with
|
|
1278
|
+
* no seq — orphans, and anything written by a runner older than 0.28.0 —
|
|
1279
|
+
* stay silent: they have no name the browser could quote back.
|
|
1280
|
+
*/
|
|
1281
|
+
queueMessage(running, record) {
|
|
1282
|
+
running.pendingMessages.push(record);
|
|
1283
|
+
if (record.seq !== undefined) {
|
|
1284
|
+
this.sendEvent(running, 'message_queued', { targetSeq: record.seq });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1155
1287
|
/**
|
|
1156
1288
|
* Run delivery work for one session, strictly after whatever is already
|
|
1157
1289
|
* queued for it. Order is the whole point: two messages typed seconds apart
|
|
@@ -1211,7 +1343,7 @@ export class Supervisor {
|
|
|
1211
1343
|
* resolves them and a refusal puts exactly those records back in the queue —
|
|
1212
1344
|
* the message is retired from disk only once an agent has it.
|
|
1213
1345
|
*/
|
|
1214
|
-
deliverMessage(running, text, held = []) {
|
|
1346
|
+
deliverMessage(running, text, held = [], originSeq) {
|
|
1215
1347
|
// Older instructions are already waiting: send them together, in order.
|
|
1216
1348
|
// Without this a message that CAN go now jumps the queue — and the ones it
|
|
1217
1349
|
// jumped are stranded, because the drain only runs when a process exits
|
|
@@ -1223,13 +1355,23 @@ export class Supervisor {
|
|
|
1223
1355
|
// would deliver the words while silently dropping the screenshot
|
|
1224
1356
|
// (QA-104 MAJOR-4). `text` is already composed by the caller, so it
|
|
1225
1357
|
// needs no attachments of its own.
|
|
1226
|
-
|
|
1358
|
+
this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
|
|
1227
1359
|
this.flushPendingMessages(running);
|
|
1228
1360
|
return;
|
|
1229
1361
|
}
|
|
1230
1362
|
const settle = () => {
|
|
1231
|
-
|
|
1363
|
+
const delivered = [];
|
|
1364
|
+
for (const record of held) {
|
|
1232
1365
|
running.journal.resolvePending(record.id);
|
|
1366
|
+
if (record.seq !== undefined)
|
|
1367
|
+
delivered.push(record.seq);
|
|
1368
|
+
}
|
|
1369
|
+
// Ticket #125: the queue is now empty of these, so the browser must stop
|
|
1370
|
+
// offering to take them back. Only for messages that were ever announced
|
|
1371
|
+
// as queued — the ordinary path never queues and needs no frame.
|
|
1372
|
+
if (delivered.length > 0) {
|
|
1373
|
+
this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
|
|
1374
|
+
}
|
|
1233
1375
|
};
|
|
1234
1376
|
if (running.session) {
|
|
1235
1377
|
running.session.send(text);
|
|
@@ -1250,7 +1392,7 @@ export class Supervisor {
|
|
|
1250
1392
|
// carries it once a slot frees up, instead of the user's instruction
|
|
1251
1393
|
// vanishing into a system note. On disk, too — the wait can outlive the
|
|
1252
1394
|
// daemon (session 9).
|
|
1253
|
-
|
|
1395
|
+
this.requeue(running, held, text, originSeq);
|
|
1254
1396
|
return;
|
|
1255
1397
|
}
|
|
1256
1398
|
// A person typing into the session is the clearest signal that the work is
|
|
@@ -1263,7 +1405,105 @@ export class Supervisor {
|
|
|
1263
1405
|
// The agent did not start (an exhausted budget is the only way here). The
|
|
1264
1406
|
// instruction stays on disk, so «Продолжить» — which is what raises the
|
|
1265
1407
|
// budget — carries it to the agent instead of dropping it.
|
|
1266
|
-
|
|
1408
|
+
this.requeue(running, held, text, originSeq);
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Nothing could take the message: put it back where it came from.
|
|
1412
|
+
*
|
|
1413
|
+
* Records that were already held keep their identity (and their queued
|
|
1414
|
+
* announcement); a first-time refusal mints one and announces it.
|
|
1415
|
+
*/
|
|
1416
|
+
requeue(running, held, text, originSeq) {
|
|
1417
|
+
if (held.length > 0) {
|
|
1418
|
+
// Already announced as queued when they first went in — re-announcing
|
|
1419
|
+
// would be a second frame saying what the feed already knows.
|
|
1420
|
+
running.pendingMessages.push(...held);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* Where this session's conversation stands, live process or not (ticket #126).
|
|
1427
|
+
*
|
|
1428
|
+
* Reads the running agent when there is one and remembers what it said;
|
|
1429
|
+
* falls back to that memory when there is not. The provider session id is
|
|
1430
|
+
* checked on the way out rather than on the way in: an anchor minted before
|
|
1431
|
+
* a fork is not wrong, it simply belongs to a conversation this session no
|
|
1432
|
+
* longer has.
|
|
1433
|
+
*/
|
|
1434
|
+
currentAnchor(running) {
|
|
1435
|
+
const providerSessionId = running.descriptor.providerSessionId;
|
|
1436
|
+
const live = providerSessionId ? (running.session?.conversationAnchor() ?? null) : null;
|
|
1437
|
+
if (live && providerSessionId) {
|
|
1438
|
+
running.journal.recordAnchor(live, providerSessionId);
|
|
1439
|
+
return { anchor: live, agentSession: providerSessionId };
|
|
1440
|
+
}
|
|
1441
|
+
// No live process to ask — and that is the COMMON case at the moment a
|
|
1442
|
+
// restore point is taken: a parked session, a runner that just
|
|
1443
|
+
// reconnected, the message right after a rewind. The journal keeps the
|
|
1444
|
+
// tip, together with the conversation it belongs to.
|
|
1445
|
+
const remembered = running.journal.lastAnchor;
|
|
1446
|
+
return remembered
|
|
1447
|
+
? { anchor: remembered.anchor, agentSession: remembered.providerSessionId }
|
|
1448
|
+
: null;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* Take a restore point in front of the work that is about to start
|
|
1452
|
+
* (ticket #126).
|
|
1453
|
+
*
|
|
1454
|
+
* Called from the delivery chain — which is already asynchronous because of
|
|
1455
|
+
* attachments — rather than from `deliverMessage`, which is synchronous and
|
|
1456
|
+
* has five exits. Never throws and never blocks delivery: a message must
|
|
1457
|
+
* reach the agent whether or not a restore point could be taken.
|
|
1458
|
+
*
|
|
1459
|
+
* Three reasons it declines, and each of them is a state in which a snapshot
|
|
1460
|
+
* would be a lie rather than a restore point:
|
|
1461
|
+
* - the machine's owner switched checkpoints off;
|
|
1462
|
+
* - the agent is mid-turn, so the tree is being written to as we read it;
|
|
1463
|
+
* - a repo-mutating command holds the repository.
|
|
1464
|
+
*/
|
|
1465
|
+
async captureCheckpoint(running, kind, messageSeq) {
|
|
1466
|
+
const worktreePath = running.worktreePath;
|
|
1467
|
+
if (!worktreePath || this.opts.checkpointsEnabled === false)
|
|
1468
|
+
return;
|
|
1469
|
+
if (kind === 'TURN' && this.isWorktreeBusy(worktreePath))
|
|
1470
|
+
return;
|
|
1471
|
+
if (await this.isRepoLocked(worktreePath))
|
|
1472
|
+
return;
|
|
1473
|
+
// The anchor and the conversation it names travel TOGETHER: a point
|
|
1474
|
+
// recorded against an older provider session stays usable, because the
|
|
1475
|
+
// rewind resumes the session the point names rather than whichever one the
|
|
1476
|
+
// session happens to be on now.
|
|
1477
|
+
const anchor = this.currentAnchor(running);
|
|
1478
|
+
const result = await createCheckpoint({
|
|
1479
|
+
worktreePath,
|
|
1480
|
+
sessionId: running.descriptor.id,
|
|
1481
|
+
kind,
|
|
1482
|
+
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
1483
|
+
...(anchor ? { agentAnchor: anchor.anchor, agentSession: anchor.agentSession } : {}),
|
|
1484
|
+
});
|
|
1485
|
+
if (this.isStale(running))
|
|
1486
|
+
return;
|
|
1487
|
+
if (!result.created) {
|
|
1488
|
+
if (result.reason === 'too-large') {
|
|
1489
|
+
// Loud, because the alternative is a session that quietly has no way
|
|
1490
|
+
// back and a button that quietly is not there.
|
|
1491
|
+
this.sendEvent(running, 'notice', {
|
|
1492
|
+
level: 'warn',
|
|
1493
|
+
text: 'This working tree is too large to take a restore point — rewind is unavailable for this step. Check that build output is in .gitignore.',
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
this.sendEvent(running, 'checkpoint', {
|
|
1499
|
+
ordinal: result.record.ordinal,
|
|
1500
|
+
kind: result.record.kind,
|
|
1501
|
+
createdAt: result.record.createdAt,
|
|
1502
|
+
fileCount: result.record.fileCount,
|
|
1503
|
+
canRewindContext: Boolean(result.record.agentAnchor),
|
|
1504
|
+
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
1505
|
+
...(result.skippedFiles.length ? { skippedFiles: result.skippedFiles.slice(0, 20) } : {}),
|
|
1506
|
+
});
|
|
1267
1507
|
}
|
|
1268
1508
|
/**
|
|
1269
1509
|
* Deliver messages that raced session start (already journaled).
|
|
@@ -1299,6 +1539,13 @@ export class Supervisor {
|
|
|
1299
1539
|
});
|
|
1300
1540
|
return;
|
|
1301
1541
|
}
|
|
1542
|
+
// Anchored to the FIRST of the joined messages: that bubble is the one a
|
|
1543
|
+
// person means by "put it back to before I said this".
|
|
1544
|
+
await this.captureCheckpoint(running, 'TURN', pending[0]?.seq);
|
|
1545
|
+
if (this.isStale(running)) {
|
|
1546
|
+
running.pendingMessages.unshift(...pending);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1302
1549
|
this.deliverMessage(running, parts.join('\n\n'), pending);
|
|
1303
1550
|
});
|
|
1304
1551
|
}
|
|
@@ -1381,6 +1628,19 @@ export class Supervisor {
|
|
|
1381
1628
|
// dashboard while this runner was offline) must not keep an agent process
|
|
1382
1629
|
// running and holding the single runner slot (QA-99 MAJOR-2).
|
|
1383
1630
|
const known = new Set(descriptors.map((d) => d.id));
|
|
1631
|
+
// Ticket #126: this list is the only moment the runner learns which
|
|
1632
|
+
// sessions still exist. Deleting a session while its dev server is
|
|
1633
|
+
// switched off leaves restore points — a full copy of a working tree —
|
|
1634
|
+
// with no row anywhere pointing at them, and nothing else on this machine
|
|
1635
|
+
// would ever collect them.
|
|
1636
|
+
void pruneCheckpoints({ liveSessionIds: known }).then((result) => {
|
|
1637
|
+
if (result.droppedRefs > 0) {
|
|
1638
|
+
log.info('supervisor: collected orphaned restore points', {
|
|
1639
|
+
sessions: result.droppedSessions.length,
|
|
1640
|
+
refs: result.droppedRefs,
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
}, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }));
|
|
1384
1644
|
for (const [sessionId, running] of [...this.sessions]) {
|
|
1385
1645
|
if (known.has(sessionId))
|
|
1386
1646
|
continue;
|
|
@@ -1657,6 +1917,11 @@ export class Supervisor {
|
|
|
1657
1917
|
});
|
|
1658
1918
|
this.journals.closeAndDelete(sessionId);
|
|
1659
1919
|
this.orphanMessages.delete(sessionId);
|
|
1920
|
+
// Ticket #126: the restore points are copies of this session's
|
|
1921
|
+
// working tree. The session is gone from the API, so they are the
|
|
1922
|
+
// last thing on this machine that still holds its content.
|
|
1923
|
+
if (workspacePath)
|
|
1924
|
+
await dropCheckpoints(workspacePath, sessionId);
|
|
1660
1925
|
return void reply({ ok: true, result: { removed: true, ...result } });
|
|
1661
1926
|
}
|
|
1662
1927
|
case 'reset_workspace':
|
|
@@ -1664,6 +1929,245 @@ export class Supervisor {
|
|
|
1664
1929
|
ok: false,
|
|
1665
1930
|
error: 'reset_workspace is not supported by this runner version',
|
|
1666
1931
|
});
|
|
1932
|
+
case 'recall_message': {
|
|
1933
|
+
// Ticket #125: take a queued message back before any agent sees it.
|
|
1934
|
+
const sessionId = frame.sessionId;
|
|
1935
|
+
if (!sessionId)
|
|
1936
|
+
return void reply({ ok: false, error: 'sessionId is required' });
|
|
1937
|
+
const running = this.sessions.get(sessionId);
|
|
1938
|
+
if (!running)
|
|
1939
|
+
return void reply({ ok: false, error: 'Unknown session' });
|
|
1940
|
+
const targetSeq = num(frame.args?.['targetSeq']);
|
|
1941
|
+
if (targetSeq === null)
|
|
1942
|
+
return void reply({ ok: false, error: 'targetSeq is required' });
|
|
1943
|
+
// NOT A SINGLE `await` FROM HERE TO THE REPLY. Frames are handled
|
|
1944
|
+
// concurrently (`void this.onFrame(frame)`, gotcha #68), so a yield
|
|
1945
|
+
// in this window would let the delivery chain hand the agent the
|
|
1946
|
+
// very message we are removing — and both sides would report success.
|
|
1947
|
+
const record = running.journal.findPendingBySeq(targetSeq);
|
|
1948
|
+
const index = record
|
|
1949
|
+
? running.pendingMessages.findIndex((held) => held.id === record.id)
|
|
1950
|
+
: -1;
|
|
1951
|
+
if (!record || index < 0) {
|
|
1952
|
+
// Never queued, or a flush already took it out of the queue and is
|
|
1953
|
+
// downloading its files. Both read the same from where the user
|
|
1954
|
+
// stands: the agent has it. Answering `not_found` here would be a
|
|
1955
|
+
// lie about a session we know, and answering success would be a
|
|
1956
|
+
// promise we cannot keep — this is the only safe direction.
|
|
1957
|
+
return void reply({
|
|
1958
|
+
ok: true,
|
|
1959
|
+
result: { recalled: false, reason: 'already_delivered' },
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
running.pendingMessages.splice(index, 1);
|
|
1963
|
+
running.journal.cancelPending(record.id);
|
|
1964
|
+
this.sendEvent(running, 'message_recalled', { targetSeq });
|
|
1965
|
+
return void reply({ ok: true, result: { recalled: true } });
|
|
1966
|
+
}
|
|
1967
|
+
// ─── Ticket #126: restore points ────────────────────────────────
|
|
1968
|
+
case 'session_checkpoint': {
|
|
1969
|
+
const running = this.requireSession(frame.sessionId);
|
|
1970
|
+
if (typeof running === 'string')
|
|
1971
|
+
return void reply({ ok: false, error: running });
|
|
1972
|
+
if (this.opts.checkpointsEnabled === false) {
|
|
1973
|
+
return void reply({ ok: false, error: CHECKPOINTS_OFF });
|
|
1974
|
+
}
|
|
1975
|
+
if (!running.worktreePath) {
|
|
1976
|
+
return void reply({ ok: false, error: 'The session has no working folder yet' });
|
|
1977
|
+
}
|
|
1978
|
+
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
1979
|
+
return void reply({ ok: false, error: AGENT_BUSY });
|
|
1980
|
+
}
|
|
1981
|
+
const before = (await listCheckpoints(running.worktreePath, running.descriptor.id))
|
|
1982
|
+
.length;
|
|
1983
|
+
await this.captureCheckpoint(running, 'MANUAL', num(frame.args?.['messageSeq']) ?? undefined);
|
|
1984
|
+
const after = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
1985
|
+
if (after.length === before) {
|
|
1986
|
+
return void reply({ ok: false, error: 'A restore point could not be taken right now' });
|
|
1987
|
+
}
|
|
1988
|
+
return void reply({ ok: true, result: { ordinal: after.at(-1)?.ordinal } });
|
|
1989
|
+
}
|
|
1990
|
+
case 'session_checkpoints': {
|
|
1991
|
+
const running = this.requireSession(frame.sessionId);
|
|
1992
|
+
if (typeof running === 'string')
|
|
1993
|
+
return void reply({ ok: false, error: running });
|
|
1994
|
+
if (!running.worktreePath)
|
|
1995
|
+
return void reply({ ok: true, result: { items: [] } });
|
|
1996
|
+
const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
1997
|
+
return void reply({
|
|
1998
|
+
ok: true,
|
|
1999
|
+
result: {
|
|
2000
|
+
items: items.map((item) => ({
|
|
2001
|
+
ordinal: item.ordinal,
|
|
2002
|
+
kind: item.kind,
|
|
2003
|
+
createdAt: item.createdAt,
|
|
2004
|
+
fileCount: item.fileCount,
|
|
2005
|
+
// An anchor is enough: it names its own conversation, and that
|
|
2006
|
+
// conversation is still on disk.
|
|
2007
|
+
canRewindContext: Boolean(item.agentAnchor),
|
|
2008
|
+
...(item.messageSeq === undefined ? {} : { messageSeq: item.messageSeq }),
|
|
2009
|
+
})),
|
|
2010
|
+
},
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
case 'session_rewind_preview': {
|
|
2014
|
+
const running = this.requireSession(frame.sessionId);
|
|
2015
|
+
if (typeof running === 'string')
|
|
2016
|
+
return void reply({ ok: false, error: running });
|
|
2017
|
+
const ordinal = num(frame.args?.['ordinal']);
|
|
2018
|
+
if (ordinal === null || !running.worktreePath) {
|
|
2019
|
+
return void reply({ ok: false, error: 'ordinal is required' });
|
|
2020
|
+
}
|
|
2021
|
+
const preview = await previewRewind({
|
|
2022
|
+
worktreePath: running.worktreePath,
|
|
2023
|
+
sessionId: running.descriptor.id,
|
|
2024
|
+
ordinal,
|
|
2025
|
+
});
|
|
2026
|
+
return void reply({ ok: true, result: preview });
|
|
2027
|
+
}
|
|
2028
|
+
case 'session_rewind_files': {
|
|
2029
|
+
const running = this.requireSession(frame.sessionId);
|
|
2030
|
+
if (typeof running === 'string')
|
|
2031
|
+
return void reply({ ok: false, error: running });
|
|
2032
|
+
if (this.opts.checkpointsEnabled === false) {
|
|
2033
|
+
return void reply({ ok: false, error: CHECKPOINTS_OFF });
|
|
2034
|
+
}
|
|
2035
|
+
const ordinal = num(frame.args?.['ordinal']);
|
|
2036
|
+
if (ordinal === null || !running.worktreePath) {
|
|
2037
|
+
return void reply({ ok: false, error: 'ordinal is required' });
|
|
2038
|
+
}
|
|
2039
|
+
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
2040
|
+
return void reply({ ok: false, error: AGENT_BUSY });
|
|
2041
|
+
}
|
|
2042
|
+
const confirmRaw = frame.args?.['confirmDeletes'];
|
|
2043
|
+
const confirmDeletes = Array.isArray(confirmRaw)
|
|
2044
|
+
? confirmRaw.filter((p) => typeof p === 'string')
|
|
2045
|
+
: [];
|
|
2046
|
+
const expectedTreeOid = frame.args?.['expectedTreeOid'];
|
|
2047
|
+
// The repo lock, unlike the read-only checkpoint, IS taken here: this
|
|
2048
|
+
// writes the working tree, exactly like commit, apply and revert.
|
|
2049
|
+
const result = await this.withRepoLockFor(running.worktreePath, () => applyRewind({
|
|
2050
|
+
worktreePath: running.worktreePath,
|
|
2051
|
+
sessionId: running.descriptor.id,
|
|
2052
|
+
ordinal,
|
|
2053
|
+
confirmDeletes,
|
|
2054
|
+
...(typeof expectedTreeOid === 'string' && expectedTreeOid
|
|
2055
|
+
? { expectedTreeOid }
|
|
2056
|
+
: {}),
|
|
2057
|
+
}));
|
|
2058
|
+
this.sendEvent(running, 'checkpoint', {
|
|
2059
|
+
ordinal: result.safety.ordinal,
|
|
2060
|
+
kind: result.safety.kind,
|
|
2061
|
+
createdAt: result.safety.createdAt,
|
|
2062
|
+
fileCount: result.safety.fileCount,
|
|
2063
|
+
canRewindContext: Boolean(result.safety.agentAnchor),
|
|
2064
|
+
// This frame IS the browser's offer of «Undo rewind» — the point it
|
|
2065
|
+
// names belongs to no message, so there is nowhere else the offer
|
|
2066
|
+
// could come from. Withheld when the rewind was itself an undo:
|
|
2067
|
+
// going back again is a redo, and a button that quietly changes
|
|
2068
|
+
// meaning after one press is worse than no button.
|
|
2069
|
+
undoable: result.rewoundToKind !== 'SAFETY',
|
|
2070
|
+
});
|
|
2071
|
+
this.sendEvent(running, 'system_note', {
|
|
2072
|
+
code: 'files_rewound',
|
|
2073
|
+
text: `Working tree put back to an earlier point: ${result.restored} file(s) restored, ${result.recreated} brought back, ${result.deleted} removed. This is undoable — the state you just left was saved first.`,
|
|
2074
|
+
});
|
|
2075
|
+
return void reply({ ok: true, result });
|
|
2076
|
+
}
|
|
2077
|
+
case 'context_rewind': {
|
|
2078
|
+
const running = this.requireSession(frame.sessionId);
|
|
2079
|
+
if (typeof running === 'string')
|
|
2080
|
+
return void reply({ ok: false, error: running });
|
|
2081
|
+
const ordinal = num(frame.args?.['ordinal']);
|
|
2082
|
+
if (ordinal === null || !running.worktreePath) {
|
|
2083
|
+
return void reply({ ok: false, error: 'ordinal is required' });
|
|
2084
|
+
}
|
|
2085
|
+
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
2086
|
+
return void reply({ ok: false, error: AGENT_BUSY });
|
|
2087
|
+
}
|
|
2088
|
+
const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
2089
|
+
const point = items.find((item) => item.ordinal === ordinal);
|
|
2090
|
+
// No equality check against the CURRENT provider session any more.
|
|
2091
|
+
// The point names the conversation its anchor lives in, and that
|
|
2092
|
+
// conversation is still on disk — which is what makes rewinding
|
|
2093
|
+
// twice, or rewinding the first message after a rewind, work at all.
|
|
2094
|
+
// Requiring the ids to match refused both, on the grounds that the
|
|
2095
|
+
// point «predates a conversation rewind» — true, and beside the
|
|
2096
|
+
// point.
|
|
2097
|
+
const agentSession = point?.agentSession ?? running.descriptor.providerSessionId;
|
|
2098
|
+
if (!point?.agentAnchor || !agentSession) {
|
|
2099
|
+
return void reply({
|
|
2100
|
+
ok: false,
|
|
2101
|
+
error: 'The agent had not answered yet at this point, so there is no conversation to rewind to. Its files can still be restored.',
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
// What an agent remembers is fixed when its process starts, so the
|
|
2105
|
+
// rewind is applied by replacing that process. Park the live one and
|
|
2106
|
+
// bring it straight back — the CLI's own rewind happens when you
|
|
2107
|
+
// press it, and so does this one. Waiting for the user's next message
|
|
2108
|
+
// would leave them staring at a page where nothing had changed.
|
|
2109
|
+
running.rewindAnchor = { anchor: point.agentAnchor, agentSession };
|
|
2110
|
+
// Parked, not relaunched. A launch with no prompt does not resume
|
|
2111
|
+
// the conversation at all — the CLI reads the anchor only when the
|
|
2112
|
+
// first input arrives — so spawning a process here would idle in a
|
|
2113
|
+
// runner slot and change nothing. The anchor is consumed by the
|
|
2114
|
+
// launch the user's next message triggers, which is the same moment
|
|
2115
|
+
// the CLI validates it either way.
|
|
2116
|
+
if (running.session)
|
|
2117
|
+
this.park(running, { quiet: true });
|
|
2118
|
+
// The conversation tip is now THIS point — that is what a rewind
|
|
2119
|
+
// means — so the next restore point must record it, not the message
|
|
2120
|
+
// the user just rewound away from (which `park()` above would
|
|
2121
|
+
// otherwise have left behind, QA-120 M3). Writing it here is also
|
|
2122
|
+
// what lets the very next message be rewound in turn: it inherits a
|
|
2123
|
+
// tip that names a conversation still on disk.
|
|
2124
|
+
running.journal.recordAnchor(point.agentAnchor, agentSession);
|
|
2125
|
+
// The cut is announced NOW, and taken back if the CLI turns out not
|
|
2126
|
+
// to know the anchor.
|
|
2127
|
+
//
|
|
2128
|
+
// Waiting for confirmation was tried and is not possible: a launch
|
|
2129
|
+
// with no prompt does not resume at all until the first input, so
|
|
2130
|
+
// `system:init` — and with it any proof the fork happened — never
|
|
2131
|
+
// arrives on its own (measured: 20s, no init). Holding the cut until
|
|
2132
|
+
// then meant the page did not change when the button was pressed,
|
|
2133
|
+
// and the late cut then swallowed the message the user had sent in
|
|
2134
|
+
// the meantime.
|
|
2135
|
+
//
|
|
2136
|
+
// So the feed is cut optimistically and CORRECTED if the resume is
|
|
2137
|
+
// refused — an amendment, exactly like every other removal here.
|
|
2138
|
+
if (point?.messageSeq !== undefined) {
|
|
2139
|
+
const cut = this.sendEvent(running, 'session_rewound', {
|
|
2140
|
+
targetSeq: point.messageSeq,
|
|
2141
|
+
});
|
|
2142
|
+
running.pendingRewind = { rewoundSeq: cut.seq };
|
|
2143
|
+
}
|
|
2144
|
+
this.sendEvent(running, 'system_note', {
|
|
2145
|
+
code: 'context_rewound',
|
|
2146
|
+
text: (point?.messageSeq ?? 0) > 0
|
|
2147
|
+
? 'Rewound to an earlier point — the agent no longer remembers anything after it, and your message is back in the composer.'
|
|
2148
|
+
: 'Rewound to an earlier point — the agent no longer remembers anything after it.',
|
|
2149
|
+
});
|
|
2150
|
+
return void reply({ ok: true, result: { rewound: true } });
|
|
2151
|
+
}
|
|
2152
|
+
case 'compact_context': {
|
|
2153
|
+
const running = this.requireSession(frame.sessionId);
|
|
2154
|
+
if (typeof running === 'string')
|
|
2155
|
+
return void reply({ ok: false, error: running });
|
|
2156
|
+
if (!running.session) {
|
|
2157
|
+
return void reply({
|
|
2158
|
+
ok: false,
|
|
2159
|
+
error: 'The agent is not running — send a message first, then compact',
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
const done = await running.session.compact();
|
|
2163
|
+
if (!done) {
|
|
2164
|
+
return void reply({
|
|
2165
|
+
ok: false,
|
|
2166
|
+
error: 'The agent could not compact right now — wait for the current turn to finish',
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
return void reply({ ok: true, result: { started: true } });
|
|
2170
|
+
}
|
|
1667
2171
|
case 'git_status': {
|
|
1668
2172
|
const paths = gitCommandPaths(frame.args);
|
|
1669
2173
|
if (!paths)
|
|
@@ -2265,15 +2769,30 @@ export class Supervisor {
|
|
|
2265
2769
|
*/
|
|
2266
2770
|
withRepoLock(repoKey, fn) {
|
|
2267
2771
|
const previous = this.repoLocks.get(repoKey) ?? Promise.resolve();
|
|
2772
|
+
this.repoLockDepth.set(repoKey, (this.repoLockDepth.get(repoKey) ?? 0) + 1);
|
|
2268
2773
|
const run = previous.catch(() => undefined).then(fn);
|
|
2269
2774
|
const tail = run.catch(() => undefined);
|
|
2270
2775
|
this.repoLocks.set(repoKey, tail);
|
|
2271
2776
|
void tail.then(() => {
|
|
2777
|
+
const depth = (this.repoLockDepth.get(repoKey) ?? 1) - 1;
|
|
2778
|
+
if (depth <= 0)
|
|
2779
|
+
this.repoLockDepth.delete(repoKey);
|
|
2780
|
+
else
|
|
2781
|
+
this.repoLockDepth.set(repoKey, depth);
|
|
2272
2782
|
if (this.repoLocks.get(repoKey) === tail)
|
|
2273
2783
|
this.repoLocks.delete(repoKey);
|
|
2274
2784
|
});
|
|
2275
2785
|
return run;
|
|
2276
2786
|
}
|
|
2787
|
+
/** Is a repo-mutating command queued or running for this path right now? */
|
|
2788
|
+
async isRepoLocked(pathInsideRepo) {
|
|
2789
|
+
try {
|
|
2790
|
+
return (this.repoLockDepth.get(await repoKeyFor(pathInsideRepo)) ?? 0) > 0;
|
|
2791
|
+
}
|
|
2792
|
+
catch {
|
|
2793
|
+
return false;
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2277
2796
|
/**
|
|
2278
2797
|
* Same lock, keyed by the shared repository rather than by whichever path the
|
|
2279
2798
|
* caller happened to have. A worktree commit and a workspace squash-merge
|
|
@@ -2282,6 +2801,17 @@ export class Supervisor {
|
|
|
2282
2801
|
async withRepoLockFor(pathInsideRepo, fn) {
|
|
2283
2802
|
return this.withRepoLock(await repoKeyFor(pathInsideRepo), fn);
|
|
2284
2803
|
}
|
|
2804
|
+
/**
|
|
2805
|
+
* The live session this command names, or the sentence to refuse it with.
|
|
2806
|
+
*
|
|
2807
|
+
* A string return is deliberately unmistakable for a session: every caller
|
|
2808
|
+
* has to branch on the type, so "I did not check" cannot compile.
|
|
2809
|
+
*/
|
|
2810
|
+
requireSession(sessionId) {
|
|
2811
|
+
if (!sessionId)
|
|
2812
|
+
return 'sessionId is required';
|
|
2813
|
+
return this.sessions.get(sessionId) ?? 'Unknown session';
|
|
2814
|
+
}
|
|
2285
2815
|
/** A session actively mid-turn in this worktree — git writes must wait. */
|
|
2286
2816
|
isWorktreeBusy(worktreePath) {
|
|
2287
2817
|
for (const running of this.sessions.values()) {
|
|
@@ -2299,6 +2829,13 @@ export class Supervisor {
|
|
|
2299
2829
|
// Hard cap far below the API's 128KB Zod limit — an oversized payload would
|
|
2300
2830
|
// be rejected forever and wedge the journal (QA-96 F2).
|
|
2301
2831
|
static EVENT_PAYLOAD_CAP = 100_000;
|
|
2832
|
+
/**
|
|
2833
|
+
* Journal an event, put it on the wire, and return it.
|
|
2834
|
+
*
|
|
2835
|
+
* The return value matters since ticket #125: the seq assigned here is the
|
|
2836
|
+
* only name the browser and the runner share for one message, so the caller
|
|
2837
|
+
* that echoes a user bubble has to be able to read it back.
|
|
2838
|
+
*/
|
|
2302
2839
|
sendEvent(running, eventType, payload) {
|
|
2303
2840
|
let compact = Object.fromEntries(Object.entries(maskSecrets(payload)).filter(([, v]) => v !== undefined));
|
|
2304
2841
|
if (JSON.stringify(compact).length > Supervisor.EVENT_PAYLOAD_CAP) {
|
|
@@ -2323,6 +2860,7 @@ export class Supervisor {
|
|
|
2323
2860
|
running.lastReported = 'WAITING_PERMISSION';
|
|
2324
2861
|
this.syncBudgetClock(running);
|
|
2325
2862
|
}
|
|
2863
|
+
return event;
|
|
2326
2864
|
}
|
|
2327
2865
|
reportStatus(sessionId, status, extra) {
|
|
2328
2866
|
const running = this.sessions.get(sessionId);
|