@bridge4dev/runner 0.27.0 → 0.30.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 +391 -16
- package/dist/adapters/codex.js +187 -6
- package/dist/adapters/types.d.ts +115 -4
- package/dist/adapters/types.js +31 -0
- 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.d.ts +40 -0
- package/dist/policy.js +60 -6
- package/dist/protocol.d.ts +5 -5
- package/dist/supervisor.d.ts +90 -0
- package/dist/supervisor.js +692 -18
- 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,11 @@ 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
|
+
import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
|
|
21
|
+
/** Refusals shared by every checkpoint command (ticket #126). */
|
|
22
|
+
const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
|
|
23
|
+
const AGENT_BUSY = 'The agent is still working — stop the turn first';
|
|
19
24
|
export class Supervisor {
|
|
20
25
|
ws;
|
|
21
26
|
opts;
|
|
@@ -37,6 +42,17 @@ export class Supervisor {
|
|
|
37
42
|
authRelay = new AuthRelay();
|
|
38
43
|
/** Serialises repo-mutating git commands per workspace repo (QA-99 MAJOR-1). */
|
|
39
44
|
repoLocks = new Map();
|
|
45
|
+
/**
|
|
46
|
+
* Repo keys with work in flight right now (ticket #126).
|
|
47
|
+
*
|
|
48
|
+
* A checkpoint deliberately does NOT take the repo lock — it only reads the
|
|
49
|
+
* worktree, and queueing a user's message behind a 150-second push would be
|
|
50
|
+
* a plain regression. What it does instead is decline to run while a
|
|
51
|
+
* mutating command holds the repo: a snapshot taken halfway through an apply
|
|
52
|
+
* would be a state that never existed, and offering to restore it is worse
|
|
53
|
+
* than having no restore point for that one message.
|
|
54
|
+
*/
|
|
55
|
+
repoLockDepth = new Map();
|
|
40
56
|
/** An update is installing right now — a second one would fight it. */
|
|
41
57
|
selfUpdateInFlight = false;
|
|
42
58
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
@@ -268,6 +284,13 @@ export class Supervisor {
|
|
|
268
284
|
// asked for it) resumes on the next message instead of replaying its
|
|
269
285
|
// original prompt.
|
|
270
286
|
if (descriptor.status === 'STARTING') {
|
|
287
|
+
// Ticket #126: the point before the agent has touched anything. It is
|
|
288
|
+
// anchored to seq 0 — the synthetic opening bubble the dashboard puts in
|
|
289
|
+
// front of every feed — so "put it all back" is reachable from the very
|
|
290
|
+
// first thing on the page.
|
|
291
|
+
await this.captureCheckpoint(running, 'TURN', 0);
|
|
292
|
+
if (this.isStale(running))
|
|
293
|
+
return;
|
|
271
294
|
this.launchAgent(running, composeInitialPrompt(descriptor), null);
|
|
272
295
|
}
|
|
273
296
|
else {
|
|
@@ -360,6 +383,14 @@ export class Supervisor {
|
|
|
360
383
|
// Facts about this session only. The project's own documentation is read by
|
|
361
384
|
// each agent itself — see `composeWorkspaceContext`.
|
|
362
385
|
const workspaceContext = composeWorkspaceContext(descriptor);
|
|
386
|
+
const rewind = running.rewindAnchor;
|
|
387
|
+
delete running.rewindAnchor;
|
|
388
|
+
// A rewind resumes the conversation the POINT names, which is not always
|
|
389
|
+
// the one this session is on now: rewinding twice, or rewinding the first
|
|
390
|
+
// message after a rewind, both reach back into the thread the fork came
|
|
391
|
+
// from. Its transcript is still on disk — that is what makes the point
|
|
392
|
+
// usable at all.
|
|
393
|
+
const resumeTarget = rewind ? rewind.agentSession : resumeId;
|
|
363
394
|
running.session = adapter.startSession({
|
|
364
395
|
sessionId: descriptor.id,
|
|
365
396
|
cwd: running.worktreePath,
|
|
@@ -372,7 +403,12 @@ export class Supervisor {
|
|
|
372
403
|
mode: running.mode,
|
|
373
404
|
...(running.model ? { model: running.model } : {}),
|
|
374
405
|
...(running.effort ? { effort: running.effort } : {}),
|
|
375
|
-
...(
|
|
406
|
+
...(resumeTarget ? { resumeProviderSessionId: resumeTarget } : {}),
|
|
407
|
+
// Ticket #126: a conversation rewind takes effect exactly here, on the
|
|
408
|
+
// next process this session starts. Consumed rather than kept — a rewind
|
|
409
|
+
// is one event, not a standing setting, and re-applying it on a later
|
|
410
|
+
// relaunch would silently throw away everything said since.
|
|
411
|
+
...(rewind ? { resumeAtAnchor: rewind.anchor } : {}),
|
|
376
412
|
// Descriptor MCP (auto-issued per-workspace key) wins over config.toml.
|
|
377
413
|
...((descriptor.mcp ?? this.opts.mcp) ? { mcp: descriptor.mcp ?? this.opts.mcp } : {}),
|
|
378
414
|
// The SDK budget is per-process; hand the RESIDUAL session budget down.
|
|
@@ -571,6 +607,16 @@ export class Supervisor {
|
|
|
571
607
|
this.launchAgent(running, prompt, null);
|
|
572
608
|
return;
|
|
573
609
|
}
|
|
610
|
+
// The way back when the CLI refuses the rewind anchor: one relaunch WITHOUT
|
|
611
|
+
// it, keeping the conversation. Nothing was rewound, so nothing is cut.
|
|
612
|
+
if (running.rewindRetry && !running.stopRequested) {
|
|
613
|
+
const { prompt } = running.rewindRetry;
|
|
614
|
+
delete running.rewindRetry;
|
|
615
|
+
running.rewindRetryDone = true;
|
|
616
|
+
running.costBaseUsd = running.costUsd;
|
|
617
|
+
this.launchAgent(running, prompt, running.descriptor.providerSessionId);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
574
620
|
// Auth recovery: one relaunch that KEEPS the provider session, so a
|
|
575
621
|
// credential hiccup does not cost the agent its whole conversation.
|
|
576
622
|
if (running.authRetry && !running.stopRequested) {
|
|
@@ -581,6 +627,54 @@ export class Supervisor {
|
|
|
581
627
|
this.launchAgent(running, prompt, running.descriptor.providerSessionId);
|
|
582
628
|
return;
|
|
583
629
|
}
|
|
630
|
+
// The mode crossed the `full` boundary: a NEW process, resuming the same
|
|
631
|
+
// conversation (ticket #156). Placed with the other one-shot relaunches and
|
|
632
|
+
// after the cleanup above, which is the whole point of routing it here.
|
|
633
|
+
if (running.modeRelaunch && !running.stopRequested) {
|
|
634
|
+
const { priorStatus } = running.modeRelaunch;
|
|
635
|
+
delete running.modeRelaunch;
|
|
636
|
+
running.parkRequested = false;
|
|
637
|
+
running.session = null;
|
|
638
|
+
running.costBaseUsd = running.costUsd; // the next process starts from here
|
|
639
|
+
const mode = running.mode;
|
|
640
|
+
// «Your conversation is kept» is only true when there IS one to keep
|
|
641
|
+
// (QA-128): a session switched before its first turn has no provider
|
|
642
|
+
// session id, so the new process starts the conversation over — and
|
|
643
|
+
// saying otherwise is a promise the feed can be checked against.
|
|
644
|
+
const kept = Boolean(running.descriptor.providerSessionId);
|
|
645
|
+
this.sendEvent(running, 'notice', {
|
|
646
|
+
level: 'info',
|
|
647
|
+
text: (mode === 'full'
|
|
648
|
+
? 'Switched to «Unrestricted». The agent was restarted — it no longer asks about anything. '
|
|
649
|
+
: 'Left «Unrestricted». The agent was restarted — permission checks are back on. ') +
|
|
650
|
+
(kept
|
|
651
|
+
? 'Your conversation is kept; send a message to carry on.'
|
|
652
|
+
: 'This session had not started a conversation yet, so nothing was lost — send a message to begin.'),
|
|
653
|
+
});
|
|
654
|
+
this.sendEvent(running, 'settings', { mode });
|
|
655
|
+
// Empty prompt: the agent boots, reports its capabilities and waits, the
|
|
656
|
+
// same as a free CHAT session. It must NOT start a turn of its own here.
|
|
657
|
+
if (this.launchAgent(running, '', running.descriptor.providerSessionId)) {
|
|
658
|
+
// Anything typed during the park window is waiting on disk (see
|
|
659
|
+
// `deliverMessage`), and the new process is the one that can take it.
|
|
660
|
+
this.flushPendingMessages(running);
|
|
661
|
+
if (priorStatus === 'REVIEW') {
|
|
662
|
+
this.reportStatus(descriptor.id, 'REVIEW', {
|
|
663
|
+
costUsd: running.costUsd,
|
|
664
|
+
activeMs: running.activeMs,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
// The agent did not start — an exhausted budget is the only way here. The
|
|
670
|
+
// session stays parked and resumable rather than silently disappearing.
|
|
671
|
+
this.reportStatus(descriptor.id, statusForReport(running), {
|
|
672
|
+
costUsd: running.costUsd,
|
|
673
|
+
activeMs: running.activeMs,
|
|
674
|
+
});
|
|
675
|
+
this.drainSessionsWaitingForCapacity();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
584
678
|
if (running.stopRequested) {
|
|
585
679
|
// Report before removing from the map — reportStatus records
|
|
586
680
|
// lastReported on the live entry, and the journal cleanup below
|
|
@@ -795,29 +889,48 @@ export class Supervisor {
|
|
|
795
889
|
running.openQuestions.size === 0 &&
|
|
796
890
|
Boolean(running.descriptor.providerSessionId));
|
|
797
891
|
}
|
|
798
|
-
|
|
892
|
+
/**
|
|
893
|
+
* Stop the agent process but keep the session resumable.
|
|
894
|
+
*
|
|
895
|
+
* `quiet` is for the callers that say it better themselves: a conversation
|
|
896
|
+
* rewind parks the session too, and announcing «the runner switched to
|
|
897
|
+
* another session» there is simply untrue — nothing switched, the user
|
|
898
|
+
* rewound (ticket #126).
|
|
899
|
+
*/
|
|
900
|
+
park(running, options = {}) {
|
|
799
901
|
if (!running.session)
|
|
800
902
|
return;
|
|
903
|
+
// Ask the process where the conversation stands while it is still there to
|
|
904
|
+
// ask (ticket #126) — a restore point taken after this would otherwise
|
|
905
|
+
// have no way to rewind the agent's memory.
|
|
906
|
+
this.currentAnchor(running);
|
|
801
907
|
running.parkRequested = true;
|
|
802
908
|
// `code` is what the dashboard reads to tell «parked» from «your turn»
|
|
803
909
|
// (#124). The prose stays for runners older than 0.23.0, which the
|
|
804
910
|
// dashboard still matches on; delete that fallback once the fleet has
|
|
805
911
|
// moved, not before.
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
912
|
+
if (!options.quiet) {
|
|
913
|
+
this.sendEvent(running, 'system_note', {
|
|
914
|
+
code: 'session_parked',
|
|
915
|
+
text: 'Session parked — the runner switched to another session. Send a message to resume.',
|
|
916
|
+
});
|
|
917
|
+
}
|
|
810
918
|
running.session.stop('session_parked');
|
|
811
919
|
}
|
|
812
920
|
forwardEvent(running, event) {
|
|
813
921
|
const { descriptor } = running;
|
|
814
922
|
switch (event.type) {
|
|
815
|
-
case 'provider_session':
|
|
923
|
+
case 'provider_session': {
|
|
816
924
|
running.descriptor = { ...descriptor, providerSessionId: event.providerSessionId };
|
|
817
925
|
this.reportStatus(descriptor.id, statusForReport(running), {
|
|
818
926
|
providerSessionId: event.providerSessionId,
|
|
819
927
|
});
|
|
928
|
+
// The CLI resumed at the anchor and forked, so the cut announced when
|
|
929
|
+
// the rewind was asked for stands. Nothing to send — it is already in
|
|
930
|
+
// the feed; this only retires the promise to take it back.
|
|
931
|
+
delete running.pendingRewind;
|
|
820
932
|
return;
|
|
933
|
+
}
|
|
821
934
|
case 'cost':
|
|
822
935
|
// event.costUsd is the live process's running total; the session
|
|
823
936
|
// total also includes what earlier processes spent (park/restart).
|
|
@@ -829,7 +942,22 @@ export class Supervisor {
|
|
|
829
942
|
});
|
|
830
943
|
return;
|
|
831
944
|
case 'turn_end': {
|
|
832
|
-
this.sendEvent(running, 'turn_end', {
|
|
945
|
+
this.sendEvent(running, 'turn_end', {
|
|
946
|
+
ok: event.ok,
|
|
947
|
+
errorMessage: event.errorMessage,
|
|
948
|
+
...(event.aborted ? { aborted: true } : {}),
|
|
949
|
+
});
|
|
950
|
+
// The session is already on its way out with a status that MEANS
|
|
951
|
+
// something — a spent budget, a Stop, a teardown. A turn ending inside
|
|
952
|
+
// that window is a consequence of it, and letting the line below
|
|
953
|
+
// overwrite `STOPPED / TIME_BUDGET` with `FAILED` (or with a cheerful
|
|
954
|
+
// WAITING_INPUT) replaces a true, actionable ending with a wrong one.
|
|
955
|
+
if (running.stopRequested || running.budgetSpent)
|
|
956
|
+
return;
|
|
957
|
+
// A finished turn is the last instant this conversation is BOTH
|
|
958
|
+
// complete and readable: the process can be parked or lose its slot at
|
|
959
|
+
// any point after it, and `conversationAnchor()` needs a live one.
|
|
960
|
+
this.currentAnchor(running);
|
|
833
961
|
// Turn end is the natural checkpoint for the budget clock: the slice
|
|
834
962
|
// just closed, so this is the moment the API can persist it. Without a
|
|
835
963
|
// report here `agentActiveMs` stayed 0 and every restart or resume
|
|
@@ -854,6 +982,31 @@ export class Supervisor {
|
|
|
854
982
|
// A provider session the CLI no longer knows (state wiped, expired
|
|
855
983
|
// history): don't fail the session — relaunch it fresh once with the
|
|
856
984
|
// same prompt, the git worktree still holds all the work.
|
|
985
|
+
// The CLI does not know the point we asked it to resume at. The
|
|
986
|
+
// conversation is intact — only the rewind did not happen — so the
|
|
987
|
+
// session must survive, the feed must NOT be cut (nothing was rewound),
|
|
988
|
+
// and the person must be told plainly rather than left believing the
|
|
989
|
+
// agent has forgotten something it still remembers.
|
|
990
|
+
if (event.code === 'rewind_failed') {
|
|
991
|
+
// Put the conversation back on the page: the agent still has every
|
|
992
|
+
// word of it, and a transcript that shows less than the agent knows
|
|
993
|
+
// is the one outcome this feature must never produce.
|
|
994
|
+
const cutToUndo = running.pendingRewind;
|
|
995
|
+
delete running.pendingRewind;
|
|
996
|
+
if (cutToUndo) {
|
|
997
|
+
this.sendEvent(running, 'session_rewind_undone', {
|
|
998
|
+
rewoundSeq: cutToUndo.rewoundSeq,
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
if (!event.recovered && !running.rewindRetryDone) {
|
|
1002
|
+
running.rewindRetry = { prompt: running.lastPrompt };
|
|
1003
|
+
this.sendEvent(running, 'notice', {
|
|
1004
|
+
level: 'warn',
|
|
1005
|
+
text: 'The conversation could not be rewound to that point — the agent still remembers everything after it. Its files were not affected.',
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
857
1010
|
if (event.code === 'resume_failed' && !running.freshRetryDone) {
|
|
858
1011
|
running.freshRetry = { prompt: running.lastPrompt };
|
|
859
1012
|
this.sendEvent(running, 'notice', {
|
|
@@ -1126,16 +1279,19 @@ export class Supervisor {
|
|
|
1126
1279
|
}
|
|
1127
1280
|
// The feed shows what the USER wrote plus the files they picked — not the
|
|
1128
1281
|
// composed prompt with workspace paths, which is an implementation detail.
|
|
1129
|
-
this
|
|
1282
|
+
// Its seq is the name this message answers to for the rest of its life
|
|
1283
|
+
// (ticket #125): recall, checkpoint anchor and rewind all quote it.
|
|
1284
|
+
const echoed = this.sendEvent(running, 'message', {
|
|
1130
1285
|
role: 'user',
|
|
1131
1286
|
text,
|
|
1132
1287
|
...(attachments?.length ? { attachments } : {}),
|
|
1133
1288
|
});
|
|
1289
|
+
const originSeq = echoed.seq;
|
|
1134
1290
|
if (!running.worktreePath) {
|
|
1135
1291
|
// Session is still being prepared — deliver after launch (QA-96 F3).
|
|
1136
1292
|
// Attachments travel as metadata and are downloaded at delivery time,
|
|
1137
1293
|
// which is the first moment the worktree is guaranteed to exist.
|
|
1138
|
-
|
|
1294
|
+
this.queueMessage(running, running.journal.appendPending(text, attachments, originSeq));
|
|
1139
1295
|
return;
|
|
1140
1296
|
}
|
|
1141
1297
|
this.enqueueDelivery(running, async () => {
|
|
@@ -1143,15 +1299,40 @@ export class Supervisor {
|
|
|
1143
1299
|
if (this.isStale(running)) {
|
|
1144
1300
|
// The session ended while the files downloaded. Park the message on
|
|
1145
1301
|
// disk rather than dropping it — «Продолжить» carries it to the agent.
|
|
1146
|
-
|
|
1302
|
+
// Announced like every other queueing decision: a bubble that is
|
|
1303
|
+
// waiting must say so, whichever door it came through.
|
|
1304
|
+
const parked = running.journal.appendPending(text, attachments, originSeq);
|
|
1305
|
+
if (parked.seq !== undefined) {
|
|
1306
|
+
this.sendEvent(running, 'message_queued', { targetSeq: parked.seq });
|
|
1307
|
+
}
|
|
1147
1308
|
log.warn('supervisor: session ended before the message could be delivered', {
|
|
1148
1309
|
sessionId: running.descriptor.id,
|
|
1149
1310
|
});
|
|
1150
1311
|
return;
|
|
1151
1312
|
}
|
|
1152
|
-
this
|
|
1313
|
+
// Ticket #126: the restore point belongs in FRONT of this message, and
|
|
1314
|
+
// this is the last moment the tree is still as the user left it.
|
|
1315
|
+
await this.captureCheckpoint(running, 'TURN', originSeq);
|
|
1316
|
+
if (this.isStale(running))
|
|
1317
|
+
return;
|
|
1318
|
+
this.deliverMessage(running, composed, [], originSeq);
|
|
1153
1319
|
});
|
|
1154
1320
|
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Queue a message that no agent can take yet, and say so in the feed
|
|
1323
|
+
* (ticket #125).
|
|
1324
|
+
*
|
|
1325
|
+
* Every queueing decision goes through here so the browser's idea of "this
|
|
1326
|
+
* one can still be taken back" cannot drift from the runner's. Records with
|
|
1327
|
+
* no seq — orphans, and anything written by a runner older than 0.28.0 —
|
|
1328
|
+
* stay silent: they have no name the browser could quote back.
|
|
1329
|
+
*/
|
|
1330
|
+
queueMessage(running, record) {
|
|
1331
|
+
running.pendingMessages.push(record);
|
|
1332
|
+
if (record.seq !== undefined) {
|
|
1333
|
+
this.sendEvent(running, 'message_queued', { targetSeq: record.seq });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1155
1336
|
/**
|
|
1156
1337
|
* Run delivery work for one session, strictly after whatever is already
|
|
1157
1338
|
* queued for it. Order is the whole point: two messages typed seconds apart
|
|
@@ -1211,7 +1392,7 @@ export class Supervisor {
|
|
|
1211
1392
|
* resolves them and a refusal puts exactly those records back in the queue —
|
|
1212
1393
|
* the message is retired from disk only once an agent has it.
|
|
1213
1394
|
*/
|
|
1214
|
-
deliverMessage(running, text, held = []) {
|
|
1395
|
+
deliverMessage(running, text, held = [], originSeq) {
|
|
1215
1396
|
// Older instructions are already waiting: send them together, in order.
|
|
1216
1397
|
// Without this a message that CAN go now jumps the queue — and the ones it
|
|
1217
1398
|
// jumped are stranded, because the drain only runs when a process exits
|
|
@@ -1223,20 +1404,40 @@ export class Supervisor {
|
|
|
1223
1404
|
// would deliver the words while silently dropping the screenshot
|
|
1224
1405
|
// (QA-104 MAJOR-4). `text` is already composed by the caller, so it
|
|
1225
1406
|
// needs no attachments of its own.
|
|
1226
|
-
|
|
1407
|
+
this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
|
|
1227
1408
|
this.flushPendingMessages(running);
|
|
1228
1409
|
return;
|
|
1229
1410
|
}
|
|
1230
1411
|
const settle = () => {
|
|
1231
|
-
|
|
1412
|
+
const delivered = [];
|
|
1413
|
+
for (const record of held) {
|
|
1232
1414
|
running.journal.resolvePending(record.id);
|
|
1415
|
+
if (record.seq !== undefined)
|
|
1416
|
+
delivered.push(record.seq);
|
|
1417
|
+
}
|
|
1418
|
+
// Ticket #125: the queue is now empty of these, so the browser must stop
|
|
1419
|
+
// offering to take them back. Only for messages that were ever announced
|
|
1420
|
+
// as queued — the ordinary path never queues and needs no frame.
|
|
1421
|
+
if (delivered.length > 0) {
|
|
1422
|
+
this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
|
|
1423
|
+
}
|
|
1233
1424
|
};
|
|
1234
|
-
if (running.session) {
|
|
1425
|
+
if (running.session && !running.parkRequested) {
|
|
1235
1426
|
running.session.send(text);
|
|
1236
1427
|
settle();
|
|
1237
1428
|
this.reportStatus(running.descriptor.id, 'RUNNING', {});
|
|
1238
1429
|
return;
|
|
1239
1430
|
}
|
|
1431
|
+
// The process is on its way out and `running.session` has not been cleared
|
|
1432
|
+
// yet — parking only ASKS it to stop (QA-128). Handing the text to a dying
|
|
1433
|
+
// process and then reporting it delivered is the one outcome worse than
|
|
1434
|
+
// making the user wait: the words are gone and the interface says they
|
|
1435
|
+
// arrived. This window is short but it is exactly when somebody types,
|
|
1436
|
+
// because they have just changed the mode.
|
|
1437
|
+
if (running.parkRequested) {
|
|
1438
|
+
this.requeue(running, held, text, originSeq);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1240
1441
|
// Parked session: the follow-up message becomes the resume prompt.
|
|
1241
1442
|
if (!this.ensureCapacity(running.descriptor.id)) {
|
|
1242
1443
|
this.sendEvent(running, 'system_note', {
|
|
@@ -1250,7 +1451,7 @@ export class Supervisor {
|
|
|
1250
1451
|
// carries it once a slot frees up, instead of the user's instruction
|
|
1251
1452
|
// vanishing into a system note. On disk, too — the wait can outlive the
|
|
1252
1453
|
// daemon (session 9).
|
|
1253
|
-
|
|
1454
|
+
this.requeue(running, held, text, originSeq);
|
|
1254
1455
|
return;
|
|
1255
1456
|
}
|
|
1256
1457
|
// A person typing into the session is the clearest signal that the work is
|
|
@@ -1263,7 +1464,105 @@ export class Supervisor {
|
|
|
1263
1464
|
// The agent did not start (an exhausted budget is the only way here). The
|
|
1264
1465
|
// instruction stays on disk, so «Продолжить» — which is what raises the
|
|
1265
1466
|
// budget — carries it to the agent instead of dropping it.
|
|
1266
|
-
|
|
1467
|
+
this.requeue(running, held, text, originSeq);
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Nothing could take the message: put it back where it came from.
|
|
1471
|
+
*
|
|
1472
|
+
* Records that were already held keep their identity (and their queued
|
|
1473
|
+
* announcement); a first-time refusal mints one and announces it.
|
|
1474
|
+
*/
|
|
1475
|
+
requeue(running, held, text, originSeq) {
|
|
1476
|
+
if (held.length > 0) {
|
|
1477
|
+
// Already announced as queued when they first went in — re-announcing
|
|
1478
|
+
// would be a second frame saying what the feed already knows.
|
|
1479
|
+
running.pendingMessages.push(...held);
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
this.queueMessage(running, running.journal.appendPending(text, undefined, originSeq));
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Where this session's conversation stands, live process or not (ticket #126).
|
|
1486
|
+
*
|
|
1487
|
+
* Reads the running agent when there is one and remembers what it said;
|
|
1488
|
+
* falls back to that memory when there is not. The provider session id is
|
|
1489
|
+
* checked on the way out rather than on the way in: an anchor minted before
|
|
1490
|
+
* a fork is not wrong, it simply belongs to a conversation this session no
|
|
1491
|
+
* longer has.
|
|
1492
|
+
*/
|
|
1493
|
+
currentAnchor(running) {
|
|
1494
|
+
const providerSessionId = running.descriptor.providerSessionId;
|
|
1495
|
+
const live = providerSessionId ? (running.session?.conversationAnchor() ?? null) : null;
|
|
1496
|
+
if (live && providerSessionId) {
|
|
1497
|
+
running.journal.recordAnchor(live, providerSessionId);
|
|
1498
|
+
return { anchor: live, agentSession: providerSessionId };
|
|
1499
|
+
}
|
|
1500
|
+
// No live process to ask — and that is the COMMON case at the moment a
|
|
1501
|
+
// restore point is taken: a parked session, a runner that just
|
|
1502
|
+
// reconnected, the message right after a rewind. The journal keeps the
|
|
1503
|
+
// tip, together with the conversation it belongs to.
|
|
1504
|
+
const remembered = running.journal.lastAnchor;
|
|
1505
|
+
return remembered
|
|
1506
|
+
? { anchor: remembered.anchor, agentSession: remembered.providerSessionId }
|
|
1507
|
+
: null;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Take a restore point in front of the work that is about to start
|
|
1511
|
+
* (ticket #126).
|
|
1512
|
+
*
|
|
1513
|
+
* Called from the delivery chain — which is already asynchronous because of
|
|
1514
|
+
* attachments — rather than from `deliverMessage`, which is synchronous and
|
|
1515
|
+
* has five exits. Never throws and never blocks delivery: a message must
|
|
1516
|
+
* reach the agent whether or not a restore point could be taken.
|
|
1517
|
+
*
|
|
1518
|
+
* Three reasons it declines, and each of them is a state in which a snapshot
|
|
1519
|
+
* would be a lie rather than a restore point:
|
|
1520
|
+
* - the machine's owner switched checkpoints off;
|
|
1521
|
+
* - the agent is mid-turn, so the tree is being written to as we read it;
|
|
1522
|
+
* - a repo-mutating command holds the repository.
|
|
1523
|
+
*/
|
|
1524
|
+
async captureCheckpoint(running, kind, messageSeq) {
|
|
1525
|
+
const worktreePath = running.worktreePath;
|
|
1526
|
+
if (!worktreePath || this.opts.checkpointsEnabled === false)
|
|
1527
|
+
return;
|
|
1528
|
+
if (kind === 'TURN' && this.isWorktreeBusy(worktreePath))
|
|
1529
|
+
return;
|
|
1530
|
+
if (await this.isRepoLocked(worktreePath))
|
|
1531
|
+
return;
|
|
1532
|
+
// The anchor and the conversation it names travel TOGETHER: a point
|
|
1533
|
+
// recorded against an older provider session stays usable, because the
|
|
1534
|
+
// rewind resumes the session the point names rather than whichever one the
|
|
1535
|
+
// session happens to be on now.
|
|
1536
|
+
const anchor = this.currentAnchor(running);
|
|
1537
|
+
const result = await createCheckpoint({
|
|
1538
|
+
worktreePath,
|
|
1539
|
+
sessionId: running.descriptor.id,
|
|
1540
|
+
kind,
|
|
1541
|
+
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
1542
|
+
...(anchor ? { agentAnchor: anchor.anchor, agentSession: anchor.agentSession } : {}),
|
|
1543
|
+
});
|
|
1544
|
+
if (this.isStale(running))
|
|
1545
|
+
return;
|
|
1546
|
+
if (!result.created) {
|
|
1547
|
+
if (result.reason === 'too-large') {
|
|
1548
|
+
// Loud, because the alternative is a session that quietly has no way
|
|
1549
|
+
// back and a button that quietly is not there.
|
|
1550
|
+
this.sendEvent(running, 'notice', {
|
|
1551
|
+
level: 'warn',
|
|
1552
|
+
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.',
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
this.sendEvent(running, 'checkpoint', {
|
|
1558
|
+
ordinal: result.record.ordinal,
|
|
1559
|
+
kind: result.record.kind,
|
|
1560
|
+
createdAt: result.record.createdAt,
|
|
1561
|
+
fileCount: result.record.fileCount,
|
|
1562
|
+
canRewindContext: Boolean(result.record.agentAnchor),
|
|
1563
|
+
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
1564
|
+
...(result.skippedFiles.length ? { skippedFiles: result.skippedFiles.slice(0, 20) } : {}),
|
|
1565
|
+
});
|
|
1267
1566
|
}
|
|
1268
1567
|
/**
|
|
1269
1568
|
* Deliver messages that raced session start (already journaled).
|
|
@@ -1299,6 +1598,13 @@ export class Supervisor {
|
|
|
1299
1598
|
});
|
|
1300
1599
|
return;
|
|
1301
1600
|
}
|
|
1601
|
+
// Anchored to the FIRST of the joined messages: that bubble is the one a
|
|
1602
|
+
// person means by "put it back to before I said this".
|
|
1603
|
+
await this.captureCheckpoint(running, 'TURN', pending[0]?.seq);
|
|
1604
|
+
if (this.isStale(running)) {
|
|
1605
|
+
running.pendingMessages.unshift(...pending);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1302
1608
|
this.deliverMessage(running, parts.join('\n\n'), pending);
|
|
1303
1609
|
});
|
|
1304
1610
|
}
|
|
@@ -1326,6 +1632,22 @@ export class Supervisor {
|
|
|
1326
1632
|
const running = this.sessions.get(sessionId);
|
|
1327
1633
|
if (!running)
|
|
1328
1634
|
return;
|
|
1635
|
+
// Validated HERE, before anything is remembered (QA-128). `running.mode` is
|
|
1636
|
+
// what a parked session launches with and what the API is told the session
|
|
1637
|
+
// is in, so storing a mode this workspace forbids made a parked session
|
|
1638
|
+
// report itself as «Unrestricted» and made the adapter refuse it again on
|
|
1639
|
+
// every single relaunch — a red line in the feed with no user action behind
|
|
1640
|
+
// it. The adapters keep their own guard; this one stops the value from ever
|
|
1641
|
+
// being written down.
|
|
1642
|
+
if (mode && !availableModes(running.descriptor.workspace.trustMode).includes(mode)) {
|
|
1643
|
+
this.sendEvent(running, 'notice', { level: 'warn', text: MODE_REFUSED_TEXT });
|
|
1644
|
+
this.sendEvent(running, 'settings', { mode: running.mode });
|
|
1645
|
+
mode = undefined;
|
|
1646
|
+
if (model === undefined && effort === undefined)
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
// Kept so a refused switch can put the picker back where it was.
|
|
1650
|
+
const previousMode = running.mode;
|
|
1329
1651
|
// Remember first: a parked session applies them on its next launch.
|
|
1330
1652
|
if (model)
|
|
1331
1653
|
running.model = model;
|
|
@@ -1339,6 +1661,67 @@ export class Supervisor {
|
|
|
1339
1661
|
this.sendEvent(running, 'settings', { model, mode, effort });
|
|
1340
1662
|
return;
|
|
1341
1663
|
}
|
|
1664
|
+
// «Unrestricted» is the one mode the agent cannot be talked into on a
|
|
1665
|
+
// process that was not launched for it (ticket #156). The CLI refuses the
|
|
1666
|
+
// control request outright — `Cannot set permission mode to
|
|
1667
|
+
// bypassPermissions because the session was not launched with
|
|
1668
|
+
// --dangerously-skip-permissions` — and until this branch existed that
|
|
1669
|
+
// refusal became a `notice` in the catch below, leaving the picker reading
|
|
1670
|
+
// «Unrestricted» over a session that went on asking.
|
|
1671
|
+
const needsRelaunch = Boolean(mode && running.session.modeSwitchNeedsRelaunch(mode));
|
|
1672
|
+
if (needsRelaunch && this.isMidTurn(running)) {
|
|
1673
|
+
// A new process would take the turn — and the open card, and the parked
|
|
1674
|
+
// question — down with it. The dashboard locks the picker while the agent
|
|
1675
|
+
// RUNS, but a session sitting on a permission card is WAITING_PERMISSION
|
|
1676
|
+
// and the picker is live there by design (that is the very moment somebody
|
|
1677
|
+
// reaches for it), so this guard is reachable through the interface and
|
|
1678
|
+
// not only through a direct API call (QA-128).
|
|
1679
|
+
this.sendEvent(running, 'notice', {
|
|
1680
|
+
level: 'warn',
|
|
1681
|
+
// Both directions: getting OUT of «Unrestricted» needs a new process
|
|
1682
|
+
// just as much as getting in, and a sentence written for one of them
|
|
1683
|
+
// reads as nonsense during the other.
|
|
1684
|
+
text: (mode === 'full'
|
|
1685
|
+
? 'Switching to «Unrestricted» starts a new agent process, so it can only be done between turns. '
|
|
1686
|
+
: 'Leaving «Unrestricted» starts a new agent process, so it can only be done between turns. ') +
|
|
1687
|
+
(running.openQuestions.size > 0 || running.lastReported === 'WAITING_PERMISSION'
|
|
1688
|
+
? 'Answer what the agent is asking first, or press Stop.'
|
|
1689
|
+
: 'Stop the turn, or wait for it to finish.'),
|
|
1690
|
+
});
|
|
1691
|
+
running.mode = previousMode;
|
|
1692
|
+
this.sendEvent(running, 'settings', { mode: previousMode });
|
|
1693
|
+
// The rest of the request still stands — refusing the mode is no reason
|
|
1694
|
+
// to drop a model or effort change that travelled with it (QA-128).
|
|
1695
|
+
await this.applyLiveSettings(running, model, undefined, effort);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (needsRelaunch && mode) {
|
|
1699
|
+
await this.applyLiveSettings(running, model, undefined, effort);
|
|
1700
|
+
// Handed to `pumpEvents` rather than done here, exactly like the auth and
|
|
1701
|
+
// stale-resume recoveries above it (QA-128). `park()` only ASKS the
|
|
1702
|
+
// process to stop; the cleanup that follows — settling the active clock,
|
|
1703
|
+
// clearing the budget timers, moving the cost baseline so the next
|
|
1704
|
+
// process does not re-count what this one spent — happens when the event
|
|
1705
|
+
// stream ends. Relaunching inline wins the race against all of it: the
|
|
1706
|
+
// new session lands in `running.session` first, and the old pump then
|
|
1707
|
+
// sees `running.session !== session` and returns without cleaning up.
|
|
1708
|
+
running.modeRelaunch = { priorStatus: running.lastReported };
|
|
1709
|
+
this.park(running, { quiet: true });
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
await this.applyLiveSettings(running, model, mode, effort);
|
|
1713
|
+
}
|
|
1714
|
+
/** Is a turn (or a question the agent is parked on) in flight right now? */
|
|
1715
|
+
isMidTurn(running) {
|
|
1716
|
+
return (running.lastReported === 'RUNNING' ||
|
|
1717
|
+
running.lastReported === 'STARTING' ||
|
|
1718
|
+
running.lastReported === 'WAITING_PERMISSION' ||
|
|
1719
|
+
running.openQuestions.size > 0);
|
|
1720
|
+
}
|
|
1721
|
+
/** The three live setters, in the order that lets an explicit pick win. */
|
|
1722
|
+
async applyLiveSettings(running, model, mode, effort) {
|
|
1723
|
+
if (!running.session)
|
|
1724
|
+
return;
|
|
1342
1725
|
try {
|
|
1343
1726
|
// Model first: switching models can invalidate the picked effort, and
|
|
1344
1727
|
// the adapter drops it in that case — applying effort after lets an
|
|
@@ -1381,6 +1764,19 @@ export class Supervisor {
|
|
|
1381
1764
|
// dashboard while this runner was offline) must not keep an agent process
|
|
1382
1765
|
// running and holding the single runner slot (QA-99 MAJOR-2).
|
|
1383
1766
|
const known = new Set(descriptors.map((d) => d.id));
|
|
1767
|
+
// Ticket #126: this list is the only moment the runner learns which
|
|
1768
|
+
// sessions still exist. Deleting a session while its dev server is
|
|
1769
|
+
// switched off leaves restore points — a full copy of a working tree —
|
|
1770
|
+
// with no row anywhere pointing at them, and nothing else on this machine
|
|
1771
|
+
// would ever collect them.
|
|
1772
|
+
void pruneCheckpoints({ liveSessionIds: known }).then((result) => {
|
|
1773
|
+
if (result.droppedRefs > 0) {
|
|
1774
|
+
log.info('supervisor: collected orphaned restore points', {
|
|
1775
|
+
sessions: result.droppedSessions.length,
|
|
1776
|
+
refs: result.droppedRefs,
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
}, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }));
|
|
1384
1780
|
for (const [sessionId, running] of [...this.sessions]) {
|
|
1385
1781
|
if (known.has(sessionId))
|
|
1386
1782
|
continue;
|
|
@@ -1657,6 +2053,11 @@ export class Supervisor {
|
|
|
1657
2053
|
});
|
|
1658
2054
|
this.journals.closeAndDelete(sessionId);
|
|
1659
2055
|
this.orphanMessages.delete(sessionId);
|
|
2056
|
+
// Ticket #126: the restore points are copies of this session's
|
|
2057
|
+
// working tree. The session is gone from the API, so they are the
|
|
2058
|
+
// last thing on this machine that still holds its content.
|
|
2059
|
+
if (workspacePath)
|
|
2060
|
+
await dropCheckpoints(workspacePath, sessionId);
|
|
1660
2061
|
return void reply({ ok: true, result: { removed: true, ...result } });
|
|
1661
2062
|
}
|
|
1662
2063
|
case 'reset_workspace':
|
|
@@ -1664,6 +2065,245 @@ export class Supervisor {
|
|
|
1664
2065
|
ok: false,
|
|
1665
2066
|
error: 'reset_workspace is not supported by this runner version',
|
|
1666
2067
|
});
|
|
2068
|
+
case 'recall_message': {
|
|
2069
|
+
// Ticket #125: take a queued message back before any agent sees it.
|
|
2070
|
+
const sessionId = frame.sessionId;
|
|
2071
|
+
if (!sessionId)
|
|
2072
|
+
return void reply({ ok: false, error: 'sessionId is required' });
|
|
2073
|
+
const running = this.sessions.get(sessionId);
|
|
2074
|
+
if (!running)
|
|
2075
|
+
return void reply({ ok: false, error: 'Unknown session' });
|
|
2076
|
+
const targetSeq = num(frame.args?.['targetSeq']);
|
|
2077
|
+
if (targetSeq === null)
|
|
2078
|
+
return void reply({ ok: false, error: 'targetSeq is required' });
|
|
2079
|
+
// NOT A SINGLE `await` FROM HERE TO THE REPLY. Frames are handled
|
|
2080
|
+
// concurrently (`void this.onFrame(frame)`, gotcha #68), so a yield
|
|
2081
|
+
// in this window would let the delivery chain hand the agent the
|
|
2082
|
+
// very message we are removing — and both sides would report success.
|
|
2083
|
+
const record = running.journal.findPendingBySeq(targetSeq);
|
|
2084
|
+
const index = record
|
|
2085
|
+
? running.pendingMessages.findIndex((held) => held.id === record.id)
|
|
2086
|
+
: -1;
|
|
2087
|
+
if (!record || index < 0) {
|
|
2088
|
+
// Never queued, or a flush already took it out of the queue and is
|
|
2089
|
+
// downloading its files. Both read the same from where the user
|
|
2090
|
+
// stands: the agent has it. Answering `not_found` here would be a
|
|
2091
|
+
// lie about a session we know, and answering success would be a
|
|
2092
|
+
// promise we cannot keep — this is the only safe direction.
|
|
2093
|
+
return void reply({
|
|
2094
|
+
ok: true,
|
|
2095
|
+
result: { recalled: false, reason: 'already_delivered' },
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
running.pendingMessages.splice(index, 1);
|
|
2099
|
+
running.journal.cancelPending(record.id);
|
|
2100
|
+
this.sendEvent(running, 'message_recalled', { targetSeq });
|
|
2101
|
+
return void reply({ ok: true, result: { recalled: true } });
|
|
2102
|
+
}
|
|
2103
|
+
// ─── Ticket #126: restore points ────────────────────────────────
|
|
2104
|
+
case 'session_checkpoint': {
|
|
2105
|
+
const running = this.requireSession(frame.sessionId);
|
|
2106
|
+
if (typeof running === 'string')
|
|
2107
|
+
return void reply({ ok: false, error: running });
|
|
2108
|
+
if (this.opts.checkpointsEnabled === false) {
|
|
2109
|
+
return void reply({ ok: false, error: CHECKPOINTS_OFF });
|
|
2110
|
+
}
|
|
2111
|
+
if (!running.worktreePath) {
|
|
2112
|
+
return void reply({ ok: false, error: 'The session has no working folder yet' });
|
|
2113
|
+
}
|
|
2114
|
+
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
2115
|
+
return void reply({ ok: false, error: AGENT_BUSY });
|
|
2116
|
+
}
|
|
2117
|
+
const before = (await listCheckpoints(running.worktreePath, running.descriptor.id))
|
|
2118
|
+
.length;
|
|
2119
|
+
await this.captureCheckpoint(running, 'MANUAL', num(frame.args?.['messageSeq']) ?? undefined);
|
|
2120
|
+
const after = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
2121
|
+
if (after.length === before) {
|
|
2122
|
+
return void reply({ ok: false, error: 'A restore point could not be taken right now' });
|
|
2123
|
+
}
|
|
2124
|
+
return void reply({ ok: true, result: { ordinal: after.at(-1)?.ordinal } });
|
|
2125
|
+
}
|
|
2126
|
+
case 'session_checkpoints': {
|
|
2127
|
+
const running = this.requireSession(frame.sessionId);
|
|
2128
|
+
if (typeof running === 'string')
|
|
2129
|
+
return void reply({ ok: false, error: running });
|
|
2130
|
+
if (!running.worktreePath)
|
|
2131
|
+
return void reply({ ok: true, result: { items: [] } });
|
|
2132
|
+
const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
2133
|
+
return void reply({
|
|
2134
|
+
ok: true,
|
|
2135
|
+
result: {
|
|
2136
|
+
items: items.map((item) => ({
|
|
2137
|
+
ordinal: item.ordinal,
|
|
2138
|
+
kind: item.kind,
|
|
2139
|
+
createdAt: item.createdAt,
|
|
2140
|
+
fileCount: item.fileCount,
|
|
2141
|
+
// An anchor is enough: it names its own conversation, and that
|
|
2142
|
+
// conversation is still on disk.
|
|
2143
|
+
canRewindContext: Boolean(item.agentAnchor),
|
|
2144
|
+
...(item.messageSeq === undefined ? {} : { messageSeq: item.messageSeq }),
|
|
2145
|
+
})),
|
|
2146
|
+
},
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
case 'session_rewind_preview': {
|
|
2150
|
+
const running = this.requireSession(frame.sessionId);
|
|
2151
|
+
if (typeof running === 'string')
|
|
2152
|
+
return void reply({ ok: false, error: running });
|
|
2153
|
+
const ordinal = num(frame.args?.['ordinal']);
|
|
2154
|
+
if (ordinal === null || !running.worktreePath) {
|
|
2155
|
+
return void reply({ ok: false, error: 'ordinal is required' });
|
|
2156
|
+
}
|
|
2157
|
+
const preview = await previewRewind({
|
|
2158
|
+
worktreePath: running.worktreePath,
|
|
2159
|
+
sessionId: running.descriptor.id,
|
|
2160
|
+
ordinal,
|
|
2161
|
+
});
|
|
2162
|
+
return void reply({ ok: true, result: preview });
|
|
2163
|
+
}
|
|
2164
|
+
case 'session_rewind_files': {
|
|
2165
|
+
const running = this.requireSession(frame.sessionId);
|
|
2166
|
+
if (typeof running === 'string')
|
|
2167
|
+
return void reply({ ok: false, error: running });
|
|
2168
|
+
if (this.opts.checkpointsEnabled === false) {
|
|
2169
|
+
return void reply({ ok: false, error: CHECKPOINTS_OFF });
|
|
2170
|
+
}
|
|
2171
|
+
const ordinal = num(frame.args?.['ordinal']);
|
|
2172
|
+
if (ordinal === null || !running.worktreePath) {
|
|
2173
|
+
return void reply({ ok: false, error: 'ordinal is required' });
|
|
2174
|
+
}
|
|
2175
|
+
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
2176
|
+
return void reply({ ok: false, error: AGENT_BUSY });
|
|
2177
|
+
}
|
|
2178
|
+
const confirmRaw = frame.args?.['confirmDeletes'];
|
|
2179
|
+
const confirmDeletes = Array.isArray(confirmRaw)
|
|
2180
|
+
? confirmRaw.filter((p) => typeof p === 'string')
|
|
2181
|
+
: [];
|
|
2182
|
+
const expectedTreeOid = frame.args?.['expectedTreeOid'];
|
|
2183
|
+
// The repo lock, unlike the read-only checkpoint, IS taken here: this
|
|
2184
|
+
// writes the working tree, exactly like commit, apply and revert.
|
|
2185
|
+
const result = await this.withRepoLockFor(running.worktreePath, () => applyRewind({
|
|
2186
|
+
worktreePath: running.worktreePath,
|
|
2187
|
+
sessionId: running.descriptor.id,
|
|
2188
|
+
ordinal,
|
|
2189
|
+
confirmDeletes,
|
|
2190
|
+
...(typeof expectedTreeOid === 'string' && expectedTreeOid
|
|
2191
|
+
? { expectedTreeOid }
|
|
2192
|
+
: {}),
|
|
2193
|
+
}));
|
|
2194
|
+
this.sendEvent(running, 'checkpoint', {
|
|
2195
|
+
ordinal: result.safety.ordinal,
|
|
2196
|
+
kind: result.safety.kind,
|
|
2197
|
+
createdAt: result.safety.createdAt,
|
|
2198
|
+
fileCount: result.safety.fileCount,
|
|
2199
|
+
canRewindContext: Boolean(result.safety.agentAnchor),
|
|
2200
|
+
// This frame IS the browser's offer of «Undo rewind» — the point it
|
|
2201
|
+
// names belongs to no message, so there is nowhere else the offer
|
|
2202
|
+
// could come from. Withheld when the rewind was itself an undo:
|
|
2203
|
+
// going back again is a redo, and a button that quietly changes
|
|
2204
|
+
// meaning after one press is worse than no button.
|
|
2205
|
+
undoable: result.rewoundToKind !== 'SAFETY',
|
|
2206
|
+
});
|
|
2207
|
+
this.sendEvent(running, 'system_note', {
|
|
2208
|
+
code: 'files_rewound',
|
|
2209
|
+
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.`,
|
|
2210
|
+
});
|
|
2211
|
+
return void reply({ ok: true, result });
|
|
2212
|
+
}
|
|
2213
|
+
case 'context_rewind': {
|
|
2214
|
+
const running = this.requireSession(frame.sessionId);
|
|
2215
|
+
if (typeof running === 'string')
|
|
2216
|
+
return void reply({ ok: false, error: running });
|
|
2217
|
+
const ordinal = num(frame.args?.['ordinal']);
|
|
2218
|
+
if (ordinal === null || !running.worktreePath) {
|
|
2219
|
+
return void reply({ ok: false, error: 'ordinal is required' });
|
|
2220
|
+
}
|
|
2221
|
+
if (this.isWorktreeBusy(running.worktreePath)) {
|
|
2222
|
+
return void reply({ ok: false, error: AGENT_BUSY });
|
|
2223
|
+
}
|
|
2224
|
+
const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
|
|
2225
|
+
const point = items.find((item) => item.ordinal === ordinal);
|
|
2226
|
+
// No equality check against the CURRENT provider session any more.
|
|
2227
|
+
// The point names the conversation its anchor lives in, and that
|
|
2228
|
+
// conversation is still on disk — which is what makes rewinding
|
|
2229
|
+
// twice, or rewinding the first message after a rewind, work at all.
|
|
2230
|
+
// Requiring the ids to match refused both, on the grounds that the
|
|
2231
|
+
// point «predates a conversation rewind» — true, and beside the
|
|
2232
|
+
// point.
|
|
2233
|
+
const agentSession = point?.agentSession ?? running.descriptor.providerSessionId;
|
|
2234
|
+
if (!point?.agentAnchor || !agentSession) {
|
|
2235
|
+
return void reply({
|
|
2236
|
+
ok: false,
|
|
2237
|
+
error: 'The agent had not answered yet at this point, so there is no conversation to rewind to. Its files can still be restored.',
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
// What an agent remembers is fixed when its process starts, so the
|
|
2241
|
+
// rewind is applied by replacing that process. Park the live one and
|
|
2242
|
+
// bring it straight back — the CLI's own rewind happens when you
|
|
2243
|
+
// press it, and so does this one. Waiting for the user's next message
|
|
2244
|
+
// would leave them staring at a page where nothing had changed.
|
|
2245
|
+
running.rewindAnchor = { anchor: point.agentAnchor, agentSession };
|
|
2246
|
+
// Parked, not relaunched. A launch with no prompt does not resume
|
|
2247
|
+
// the conversation at all — the CLI reads the anchor only when the
|
|
2248
|
+
// first input arrives — so spawning a process here would idle in a
|
|
2249
|
+
// runner slot and change nothing. The anchor is consumed by the
|
|
2250
|
+
// launch the user's next message triggers, which is the same moment
|
|
2251
|
+
// the CLI validates it either way.
|
|
2252
|
+
if (running.session)
|
|
2253
|
+
this.park(running, { quiet: true });
|
|
2254
|
+
// The conversation tip is now THIS point — that is what a rewind
|
|
2255
|
+
// means — so the next restore point must record it, not the message
|
|
2256
|
+
// the user just rewound away from (which `park()` above would
|
|
2257
|
+
// otherwise have left behind, QA-120 M3). Writing it here is also
|
|
2258
|
+
// what lets the very next message be rewound in turn: it inherits a
|
|
2259
|
+
// tip that names a conversation still on disk.
|
|
2260
|
+
running.journal.recordAnchor(point.agentAnchor, agentSession);
|
|
2261
|
+
// The cut is announced NOW, and taken back if the CLI turns out not
|
|
2262
|
+
// to know the anchor.
|
|
2263
|
+
//
|
|
2264
|
+
// Waiting for confirmation was tried and is not possible: a launch
|
|
2265
|
+
// with no prompt does not resume at all until the first input, so
|
|
2266
|
+
// `system:init` — and with it any proof the fork happened — never
|
|
2267
|
+
// arrives on its own (measured: 20s, no init). Holding the cut until
|
|
2268
|
+
// then meant the page did not change when the button was pressed,
|
|
2269
|
+
// and the late cut then swallowed the message the user had sent in
|
|
2270
|
+
// the meantime.
|
|
2271
|
+
//
|
|
2272
|
+
// So the feed is cut optimistically and CORRECTED if the resume is
|
|
2273
|
+
// refused — an amendment, exactly like every other removal here.
|
|
2274
|
+
if (point?.messageSeq !== undefined) {
|
|
2275
|
+
const cut = this.sendEvent(running, 'session_rewound', {
|
|
2276
|
+
targetSeq: point.messageSeq,
|
|
2277
|
+
});
|
|
2278
|
+
running.pendingRewind = { rewoundSeq: cut.seq };
|
|
2279
|
+
}
|
|
2280
|
+
this.sendEvent(running, 'system_note', {
|
|
2281
|
+
code: 'context_rewound',
|
|
2282
|
+
text: (point?.messageSeq ?? 0) > 0
|
|
2283
|
+
? 'Rewound to an earlier point — the agent no longer remembers anything after it, and your message is back in the composer.'
|
|
2284
|
+
: 'Rewound to an earlier point — the agent no longer remembers anything after it.',
|
|
2285
|
+
});
|
|
2286
|
+
return void reply({ ok: true, result: { rewound: true } });
|
|
2287
|
+
}
|
|
2288
|
+
case 'compact_context': {
|
|
2289
|
+
const running = this.requireSession(frame.sessionId);
|
|
2290
|
+
if (typeof running === 'string')
|
|
2291
|
+
return void reply({ ok: false, error: running });
|
|
2292
|
+
if (!running.session) {
|
|
2293
|
+
return void reply({
|
|
2294
|
+
ok: false,
|
|
2295
|
+
error: 'The agent is not running — send a message first, then compact',
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
const done = await running.session.compact();
|
|
2299
|
+
if (!done) {
|
|
2300
|
+
return void reply({
|
|
2301
|
+
ok: false,
|
|
2302
|
+
error: 'The agent could not compact right now — wait for the current turn to finish',
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
return void reply({ ok: true, result: { started: true } });
|
|
2306
|
+
}
|
|
1667
2307
|
case 'git_status': {
|
|
1668
2308
|
const paths = gitCommandPaths(frame.args);
|
|
1669
2309
|
if (!paths)
|
|
@@ -2265,15 +2905,30 @@ export class Supervisor {
|
|
|
2265
2905
|
*/
|
|
2266
2906
|
withRepoLock(repoKey, fn) {
|
|
2267
2907
|
const previous = this.repoLocks.get(repoKey) ?? Promise.resolve();
|
|
2908
|
+
this.repoLockDepth.set(repoKey, (this.repoLockDepth.get(repoKey) ?? 0) + 1);
|
|
2268
2909
|
const run = previous.catch(() => undefined).then(fn);
|
|
2269
2910
|
const tail = run.catch(() => undefined);
|
|
2270
2911
|
this.repoLocks.set(repoKey, tail);
|
|
2271
2912
|
void tail.then(() => {
|
|
2913
|
+
const depth = (this.repoLockDepth.get(repoKey) ?? 1) - 1;
|
|
2914
|
+
if (depth <= 0)
|
|
2915
|
+
this.repoLockDepth.delete(repoKey);
|
|
2916
|
+
else
|
|
2917
|
+
this.repoLockDepth.set(repoKey, depth);
|
|
2272
2918
|
if (this.repoLocks.get(repoKey) === tail)
|
|
2273
2919
|
this.repoLocks.delete(repoKey);
|
|
2274
2920
|
});
|
|
2275
2921
|
return run;
|
|
2276
2922
|
}
|
|
2923
|
+
/** Is a repo-mutating command queued or running for this path right now? */
|
|
2924
|
+
async isRepoLocked(pathInsideRepo) {
|
|
2925
|
+
try {
|
|
2926
|
+
return (this.repoLockDepth.get(await repoKeyFor(pathInsideRepo)) ?? 0) > 0;
|
|
2927
|
+
}
|
|
2928
|
+
catch {
|
|
2929
|
+
return false;
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2277
2932
|
/**
|
|
2278
2933
|
* Same lock, keyed by the shared repository rather than by whichever path the
|
|
2279
2934
|
* caller happened to have. A worktree commit and a workspace squash-merge
|
|
@@ -2282,6 +2937,17 @@ export class Supervisor {
|
|
|
2282
2937
|
async withRepoLockFor(pathInsideRepo, fn) {
|
|
2283
2938
|
return this.withRepoLock(await repoKeyFor(pathInsideRepo), fn);
|
|
2284
2939
|
}
|
|
2940
|
+
/**
|
|
2941
|
+
* The live session this command names, or the sentence to refuse it with.
|
|
2942
|
+
*
|
|
2943
|
+
* A string return is deliberately unmistakable for a session: every caller
|
|
2944
|
+
* has to branch on the type, so "I did not check" cannot compile.
|
|
2945
|
+
*/
|
|
2946
|
+
requireSession(sessionId) {
|
|
2947
|
+
if (!sessionId)
|
|
2948
|
+
return 'sessionId is required';
|
|
2949
|
+
return this.sessions.get(sessionId) ?? 'Unknown session';
|
|
2950
|
+
}
|
|
2285
2951
|
/** A session actively mid-turn in this worktree — git writes must wait. */
|
|
2286
2952
|
isWorktreeBusy(worktreePath) {
|
|
2287
2953
|
for (const running of this.sessions.values()) {
|
|
@@ -2299,6 +2965,13 @@ export class Supervisor {
|
|
|
2299
2965
|
// Hard cap far below the API's 128KB Zod limit — an oversized payload would
|
|
2300
2966
|
// be rejected forever and wedge the journal (QA-96 F2).
|
|
2301
2967
|
static EVENT_PAYLOAD_CAP = 100_000;
|
|
2968
|
+
/**
|
|
2969
|
+
* Journal an event, put it on the wire, and return it.
|
|
2970
|
+
*
|
|
2971
|
+
* The return value matters since ticket #125: the seq assigned here is the
|
|
2972
|
+
* only name the browser and the runner share for one message, so the caller
|
|
2973
|
+
* that echoes a user bubble has to be able to read it back.
|
|
2974
|
+
*/
|
|
2302
2975
|
sendEvent(running, eventType, payload) {
|
|
2303
2976
|
let compact = Object.fromEntries(Object.entries(maskSecrets(payload)).filter(([, v]) => v !== undefined));
|
|
2304
2977
|
if (JSON.stringify(compact).length > Supervisor.EVENT_PAYLOAD_CAP) {
|
|
@@ -2323,6 +2996,7 @@ export class Supervisor {
|
|
|
2323
2996
|
running.lastReported = 'WAITING_PERMISSION';
|
|
2324
2997
|
this.syncBudgetClock(running);
|
|
2325
2998
|
}
|
|
2999
|
+
return event;
|
|
2326
3000
|
}
|
|
2327
3001
|
reportStatus(sessionId, status, extra) {
|
|
2328
3002
|
const running = this.sessions.get(sessionId);
|