@bridge4dev/runner 0.55.1 → 0.57.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-usage.d.ts +122 -1
- package/dist/adapters/claude-usage.js +310 -7
- package/dist/adapters/claude.js +68 -28
- package/dist/adapters/questions.d.ts +15 -0
- package/dist/adapters/questions.js +32 -0
- package/dist/auth-relay.js +19 -0
- package/dist/checkpoints.d.ts +12 -1
- package/dist/checkpoints.js +272 -42
- package/dist/supervisor.d.ts +37 -10
- package/dist/supervisor.js +385 -190
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -31,6 +31,7 @@ import { composeMessageWithAttachments, saveAttachments, } from './attachments.j
|
|
|
31
31
|
import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
|
|
32
32
|
import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
|
|
33
33
|
import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
|
|
34
|
+
import { answersAsMessage } from './adapters/questions.js';
|
|
34
35
|
/** Refusals shared by every checkpoint command (ticket #126). */
|
|
35
36
|
const CHECKPOINTS_OFF = 'Restore points are switched off on this server ([checkpoints] enabled = false)';
|
|
36
37
|
const AGENT_BUSY = 'The agent is still working — stop the turn first';
|
|
@@ -144,6 +145,8 @@ export class Supervisor {
|
|
|
144
145
|
* which of the two is running.
|
|
145
146
|
*/
|
|
146
147
|
installInFlight = null;
|
|
148
|
+
/** A restore-point collection is running; a second reconnect must not start another (#388). */
|
|
149
|
+
checkpointGcInFlight = false;
|
|
147
150
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
148
151
|
verify;
|
|
149
152
|
verifyReports = new VerifyReportQueue();
|
|
@@ -1015,6 +1018,9 @@ export class Supervisor {
|
|
|
1015
1018
|
levels: freshLevels(),
|
|
1016
1019
|
stopRequested: false,
|
|
1017
1020
|
parkRequested: false,
|
|
1021
|
+
// Set here rather than at the first `await`: frames are dispatched
|
|
1022
|
+
// concurrently, so anything assigned later is already too late (#401).
|
|
1023
|
+
starting: true,
|
|
1018
1024
|
pendingMessages: [],
|
|
1019
1025
|
activeMs: descriptor.activeMsBase,
|
|
1020
1026
|
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
@@ -1068,71 +1074,84 @@ export class Supervisor {
|
|
|
1068
1074
|
}
|
|
1069
1075
|
}
|
|
1070
1076
|
try {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
}
|
|
1085
|
-
// A session that is already past STARTING (re-sent because this runner
|
|
1086
|
-
// asked for it) resumes on the next message instead of replaying its
|
|
1087
|
-
// original prompt.
|
|
1088
|
-
if (descriptor.status === 'STARTING') {
|
|
1089
|
-
// Ticket #126: the point before the agent has touched anything. It is
|
|
1090
|
-
// anchored to seq 0 — the synthetic opening bubble the dashboard puts in
|
|
1091
|
-
// front of every feed — so "put it all back" is reachable from the very
|
|
1092
|
-
// first thing on the page.
|
|
1093
|
-
await this.captureCheckpoint(running, 'TURN', 0);
|
|
1094
|
-
if (this.isStale(running))
|
|
1095
|
-
return;
|
|
1096
|
-
// A launch that failed has already said so and reported a status the
|
|
1097
|
-
// person can act on. Flushing the queue into it would only walk the same
|
|
1098
|
-
// failure again, once per waiting message (ticket #225).
|
|
1099
|
-
if (!this.launchAgent(running, composeInitialPrompt(descriptor), null).ok)
|
|
1077
|
+
try {
|
|
1078
|
+
const prepared = await this.prepareWorkspace(descriptor);
|
|
1079
|
+
running.branch = prepared.branch;
|
|
1080
|
+
running.worktreePath = prepared.worktreePath;
|
|
1081
|
+
// Only when WE created the branch: the fork point is a fact the API can
|
|
1082
|
+
// learn nowhere else, and it pins the first answer it gets.
|
|
1083
|
+
if (prepared.baseSha)
|
|
1084
|
+
running.baseSha = prepared.baseSha;
|
|
1085
|
+
if (prepared.baseBranch)
|
|
1086
|
+
running.baseBranch = prepared.baseBranch;
|
|
1087
|
+
}
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
this.workspacePrepareFailed(running, error, 'Failed to prepare git worktree');
|
|
1100
1090
|
return;
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
//
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1091
|
+
}
|
|
1092
|
+
// A session that is already past STARTING (re-sent because this runner
|
|
1093
|
+
// asked for it) resumes on the next message instead of replaying its
|
|
1094
|
+
// original prompt.
|
|
1095
|
+
if (descriptor.status === 'STARTING') {
|
|
1096
|
+
// Ticket #126: the point before the agent has touched anything. It is
|
|
1097
|
+
// anchored to seq 0 — the synthetic opening bubble the dashboard puts in
|
|
1098
|
+
// front of every feed — so "put it all back" is reachable from the very
|
|
1099
|
+
// first thing on the page.
|
|
1100
|
+
await this.captureCheckpoint(running, 'TURN', 0);
|
|
1101
|
+
if (this.isStale(running))
|
|
1102
|
+
return;
|
|
1103
|
+
// A launch that failed has already said so and reported a status the
|
|
1104
|
+
// person can act on. Flushing the queue into it would only walk the same
|
|
1105
|
+
// failure again, once per waiting message (ticket #225).
|
|
1106
|
+
if (!this.launchAgent(running, composeInitialPrompt(descriptor), null).ok)
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
if (descriptor.epoch > 0) {
|
|
1111
|
+
// The API owns the resume transition; the feed marker has to come from
|
|
1112
|
+
// here because the runner is the only writer of the event seq.
|
|
1113
|
+
//
|
|
1114
|
+
// Ticket #177: which of the two sentences is true depends on whether
|
|
1115
|
+
// there is a conversation to go back to. `providerSessionId` is the
|
|
1116
|
+
// agent's own name for it, and it is what the next launch hands to
|
|
1117
|
+
// `--resume` / `thread/resume`. Without it the next process starts the
|
|
1118
|
+
// conversation over — which is a real loss, and promising «continue
|
|
1119
|
+
// where the agent left off» there is the one thing the feed must not do.
|
|
1120
|
+
// It happens for real: a process that dies before it reports its session
|
|
1121
|
+
// id (the SIGABRT this ticket came from) leaves the row with none.
|
|
1122
|
+
//
|
|
1123
|
+
// Ticket #370: the sentence about the agent's MEMORY waits for proof.
|
|
1124
|
+
// It used to be written here, on the strength of a stored id and before
|
|
1125
|
+
// the CLI had been asked anything — and on Athanor it appeared sixty
|
|
1126
|
+
// seconds before `thread/resume` timed out and took the session with it.
|
|
1127
|
+
// What is honest at this moment is that the runner is back; whether the
|
|
1128
|
+
// conversation reopens is answered by the process, in `provider_session`.
|
|
1129
|
+
if (descriptor.providerSessionId)
|
|
1130
|
+
running.resumeClaimPending = true;
|
|
1131
|
+
this.sendEvent(running, 'system_note', {
|
|
1132
|
+
text: descriptor.providerSessionId
|
|
1133
|
+
? 'Session resumed — send a message and the agent picks its conversation back up.'
|
|
1134
|
+
: 'Session resumed, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
running.lastReported = descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT';
|
|
1138
|
+
this.reportStatus(descriptor.id, running.lastReported, {
|
|
1139
|
+
branch: running.branch,
|
|
1140
|
+
worktreePath: running.worktreePath,
|
|
1141
|
+
activeMs: running.activeMs,
|
|
1128
1142
|
});
|
|
1129
1143
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1144
|
+
}
|
|
1145
|
+
finally {
|
|
1146
|
+
/**
|
|
1147
|
+
* The startup gate opens again on EVERY exit, including the failed ones.
|
|
1148
|
+
*
|
|
1149
|
+
* #401: this flag is what makes `acceptUserMessage` hold a message until
|
|
1150
|
+
* the first process exists. Left standing by an early return it would
|
|
1151
|
+
* hold the person's words in the queue for the life of the session, so
|
|
1152
|
+
* it is cleared here rather than at each `return` above.
|
|
1153
|
+
*/
|
|
1154
|
+
running.starting = false;
|
|
1136
1155
|
}
|
|
1137
1156
|
this.flushPendingMessages(running);
|
|
1138
1157
|
}
|
|
@@ -1225,6 +1244,40 @@ export class Supervisor {
|
|
|
1225
1244
|
const adapter = this.opts.adapters[descriptor.agent];
|
|
1226
1245
|
if (!adapter || !running.worktreePath || !running.branch)
|
|
1227
1246
|
return LAUNCH_REFUSED;
|
|
1247
|
+
/**
|
|
1248
|
+
* One process per session, enforced where the slot is written (#401).
|
|
1249
|
+
*
|
|
1250
|
+
* `running.session` is a single field, and until this check it was assigned
|
|
1251
|
+
* unconditionally: a second launch overwrote the reference to a process
|
|
1252
|
+
* that was still running, and nobody held it any more. Everything the
|
|
1253
|
+
* session then did — a message, an answer to a question, `stop`, the memory
|
|
1254
|
+
* watch — addressed the survivor, while the orphan kept working, kept its
|
|
1255
|
+
* own cgroup and outlived even the service restart.
|
|
1256
|
+
*
|
|
1257
|
+
* Safe as an entry check because this method is SYNCHRONOUS from here to
|
|
1258
|
+
* the assignment: nothing yields in between, so «free now» is still true
|
|
1259
|
+
* when the slot is filled.
|
|
1260
|
+
*
|
|
1261
|
+
* Every legitimate caller reaches this with an empty slot, by one of three
|
|
1262
|
+
* arguments: the four one-shot relaunches run in the tail of `pumpEvents`,
|
|
1263
|
+
* where the slot was emptied because the process is gone; `deliverMessage`
|
|
1264
|
+
* only gets here past its own `running.session` test and is serialised by
|
|
1265
|
+
* the session's delivery chain; and the two opening doors hold `starting`
|
|
1266
|
+
* for exactly as long as it takes them to decide, which is what keeps the
|
|
1267
|
+
* queue from starting an agent out from under them.
|
|
1268
|
+
*
|
|
1269
|
+
* A refusal here is therefore not a race being caught — it is a bug, and
|
|
1270
|
+
* the log line says so. Read it together with `starting`: this guard alone
|
|
1271
|
+
* would turn «two processes» into «the session's own task never sent», the
|
|
1272
|
+
* quieter of the two failures.
|
|
1273
|
+
*/
|
|
1274
|
+
if (running.session) {
|
|
1275
|
+
log.error('supervisor: refusing to launch a second agent for one session', {
|
|
1276
|
+
sessionId: descriptor.id,
|
|
1277
|
+
processSeq: running.processSeq,
|
|
1278
|
+
});
|
|
1279
|
+
return LAUNCH_REFUSED;
|
|
1280
|
+
}
|
|
1228
1281
|
// An exhausted USD budget must not relaunch $0.01-floor processes (QA-96 F4).
|
|
1229
1282
|
// Codex reports no cost at all, so its costUsd never leaves 0 — gating on it
|
|
1230
1283
|
// would be a limit that can never fire while the UI shows $0.00. Those
|
|
@@ -1691,8 +1744,35 @@ export class Supervisor {
|
|
|
1691
1744
|
});
|
|
1692
1745
|
}
|
|
1693
1746
|
// Stream ended — the agent process is gone.
|
|
1694
|
-
if (running.session !== session)
|
|
1695
|
-
|
|
1747
|
+
if (running.session !== session) {
|
|
1748
|
+
/**
|
|
1749
|
+
* Somebody else holds the slot while this process's stream ends.
|
|
1750
|
+
*
|
|
1751
|
+
* Since #401 the launch REFUSES to overwrite a live process, and the slot
|
|
1752
|
+
* is emptied only where the process is known to be gone — the line below
|
|
1753
|
+
* and `launchCrashed`. So this branch no longer means «a live agent was
|
|
1754
|
+
* orphaned»; it means the entry was rebuilt under this stream, and the
|
|
1755
|
+
* cleanup below belongs to whoever holds it now. Said out loud rather
|
|
1756
|
+
* than returned in silence: the old comment here called the case
|
|
1757
|
+
* impossible, and it was the shape four production sessions took.
|
|
1758
|
+
*/
|
|
1759
|
+
log.warn('supervisor: event stream ended for a process that no longer holds the slot', {
|
|
1760
|
+
sessionId: descriptor.id,
|
|
1761
|
+
processSeq: running.processSeq,
|
|
1762
|
+
});
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* The slot describes a LIVE process, so it is emptied the moment there is
|
|
1767
|
+
* none — here, and not by whoever launches next (#401).
|
|
1768
|
+
*
|
|
1769
|
+
* Three of the one-shot relaunches below (`freshRetry`, `rewindRetry`,
|
|
1770
|
+
* `authRetry`) used to call `launchAgent` with the dead adapter still in
|
|
1771
|
+
* the slot and rely on being overwritten. That overwrite is exactly what
|
|
1772
|
+
* the launch now refuses, so without this line the recovery paths would
|
|
1773
|
+
* refuse themselves. Nothing between here and them reads the field.
|
|
1774
|
+
*/
|
|
1775
|
+
running.session = null;
|
|
1696
1776
|
// Backstop for a process that died without going through `stop()` (a crash,
|
|
1697
1777
|
// an SDK error, an agent that exited mid-question): the adapter never got
|
|
1698
1778
|
// to withdraw its cards, and a card nobody can answer must not stay live.
|
|
@@ -1766,7 +1846,9 @@ export class Supervisor {
|
|
|
1766
1846
|
const { priorStatus } = running.modeRelaunch;
|
|
1767
1847
|
delete running.modeRelaunch;
|
|
1768
1848
|
running.parkRequested = false;
|
|
1769
|
-
|
|
1849
|
+
// The slot was emptied where the process was found gone (#401) — this
|
|
1850
|
+
// branch used to be the only one that remembered to do it, and the three
|
|
1851
|
+
// above did not. One place, so they cannot disagree again.
|
|
1770
1852
|
running.costBaseUsd = running.costUsd; // the next process starts from here
|
|
1771
1853
|
const mode = running.mode;
|
|
1772
1854
|
// «Your conversation is kept» is only true when there IS one to keep
|
|
@@ -1830,7 +1912,7 @@ export class Supervisor {
|
|
|
1830
1912
|
!isTerminal(running.lastReported) &&
|
|
1831
1913
|
running.descriptor.providerSessionId) {
|
|
1832
1914
|
// Idle process ended (parked or died between turns) — stay resumable.
|
|
1833
|
-
|
|
1915
|
+
// The slot itself was emptied above, where the process was found gone.
|
|
1834
1916
|
running.parkRequested = false;
|
|
1835
1917
|
/**
|
|
1836
1918
|
* The subagents died with it (QA-2026-08-16 M-5).
|
|
@@ -2878,8 +2960,26 @@ export class Supervisor {
|
|
|
2878
2960
|
// words below have now been published once, and a redelivery must not
|
|
2879
2961
|
// publish them again.
|
|
2880
2962
|
running.answeredAsks.add(frame.askId);
|
|
2963
|
+
/**
|
|
2964
|
+
* The reply, whichever control the person used to give it (#401).
|
|
2965
|
+
*
|
|
2966
|
+
* `frame.text` alone was not it: that field carries the bottom input of the
|
|
2967
|
+
* card («Discuss instead»), and the dashboard sends the ORDINARY answer as
|
|
2968
|
+
* `answers` with no text at all. So a branch that rescued only the text
|
|
2969
|
+
* lost the entire reply of everyone who answered by tapping the options —
|
|
2970
|
+
* silently, because the card stayed live and said nothing. That is what
|
|
2971
|
+
* happened to the owner on 08.09.2026 and it is the second half of this
|
|
2972
|
+
* ticket: a question that could not be answered AND an answer that
|
|
2973
|
+
* vanished.
|
|
2974
|
+
*
|
|
2975
|
+
* Both halves, joined rather than one or the other: the frame allows a
|
|
2976
|
+
* person to have typed AND picked, and dropping either would be the same
|
|
2977
|
+
* defect on a narrower path. The sentence comes first because that is the
|
|
2978
|
+
* order the card puts them in.
|
|
2979
|
+
*/
|
|
2980
|
+
const rescued = [typed, answersAsMessage(frame.answers ?? [])].filter(Boolean).join('\n');
|
|
2881
2981
|
this.sendEvent(running, 'system_note', {
|
|
2882
|
-
text:
|
|
2982
|
+
text: rescued
|
|
2883
2983
|
? 'That question is no longer open — sending your reply as an ordinary message instead.'
|
|
2884
2984
|
: 'That question is no longer open — the agent has already moved on.',
|
|
2885
2985
|
});
|
|
@@ -2887,8 +2987,8 @@ export class Supervisor {
|
|
|
2887
2987
|
// restart: the card in the browser outlived the process that asked, and
|
|
2888
2988
|
// showing the user's own message in the feed while nothing receives it is
|
|
2889
2989
|
// the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
|
|
2890
|
-
if (
|
|
2891
|
-
this.acceptUserMessage(frame.sessionId,
|
|
2990
|
+
if (rescued) {
|
|
2991
|
+
this.acceptUserMessage(frame.sessionId, rescued);
|
|
2892
2992
|
return 'not_open';
|
|
2893
2993
|
}
|
|
2894
2994
|
// Nothing to deliver — but the API optimistically flipped the session to
|
|
@@ -2978,11 +3078,22 @@ export class Supervisor {
|
|
|
2978
3078
|
...(attachments?.length ? { attachments } : {}),
|
|
2979
3079
|
});
|
|
2980
3080
|
const originSeq = echoed.seq;
|
|
2981
|
-
if (!running.worktreePath || Supervisor.isPaused(running)) {
|
|
3081
|
+
if (running.starting || !running.worktreePath || Supervisor.isPaused(running)) {
|
|
2982
3082
|
// Session is still being prepared — deliver after launch (QA-96 F3).
|
|
2983
3083
|
// Attachments travel as metadata and are downloaded at delivery time,
|
|
2984
3084
|
// which is the first moment the worktree is guaranteed to exist.
|
|
2985
3085
|
//
|
|
3086
|
+
// #401: `starting` is the half of that promise the condition used to be
|
|
3087
|
+
// missing. The folder is ready in milliseconds and the first process only
|
|
3088
|
+
// seconds later, so testing the folder alone let a message through into a
|
|
3089
|
+
// session that had no agent yet — and the delivery path then started one
|
|
3090
|
+
// of its own, beside the one `startSession` was about to start. Both
|
|
3091
|
+
// halves are named because they fail apart: a session past STARTING has a
|
|
3092
|
+
// folder and no `starting`, and waking it with a message is correct.
|
|
3093
|
+
//
|
|
3094
|
+
// Nothing is lost by waiting: `flushPendingMessages` runs on the way out
|
|
3095
|
+
// of `startSession`, after the launch, and delivers this queue in order.
|
|
3096
|
+
//
|
|
2986
3097
|
// Or the session is held under a clock (#196). The API refuses live
|
|
2987
3098
|
// messages for a paused session, but not every path goes through that
|
|
2988
3099
|
// check — the outbox flushes on reconnect, and the git service posts its
|
|
@@ -3401,9 +3512,20 @@ export class Supervisor {
|
|
|
3401
3512
|
* a second session opened. The neighbours are recorded on the point instead —
|
|
3402
3513
|
* the conversation can always be rewound to it, the files cannot.
|
|
3403
3514
|
*
|
|
3404
|
-
*
|
|
3405
|
-
*
|
|
3406
|
-
*
|
|
3515
|
+
* A refusal is audible when it is a refusal — when the person can do
|
|
3516
|
+
* something about it, or when a way back they might reach for is not there.
|
|
3517
|
+
* A restore point that was never taken is invisible until the day somebody
|
|
3518
|
+
* reaches for it, and «the button is not there» is not a sentence anybody can
|
|
3519
|
+
* act on.
|
|
3520
|
+
*
|
|
3521
|
+
* «This session was still answering» is the exception, and it is the only one
|
|
3522
|
+
* (#384). It is not a fault and not a state to act on: it is what a follow-up
|
|
3523
|
+
* note to a working agent looks like from in here, thirty times in a day on
|
|
3524
|
+
* one machine, and it costs almost nothing — the point in front of the turn
|
|
3525
|
+
* already stands, and rewinding to it takes back the files AND the
|
|
3526
|
+
* conversation, including the note. So it goes to the runner's own log, where
|
|
3527
|
+
* support can answer «why is there no point for that step», and not into the
|
|
3528
|
+
* feed, where it read as breakage.
|
|
3407
3529
|
*/
|
|
3408
3530
|
async captureCheckpoint(running, kind, messageSeq) {
|
|
3409
3531
|
const worktreePath = running.worktreePath;
|
|
@@ -3421,7 +3543,13 @@ export class Supervisor {
|
|
|
3421
3543
|
return;
|
|
3422
3544
|
}
|
|
3423
3545
|
if (kind === 'TURN' && this.isSessionMidTurn(running)) {
|
|
3424
|
-
|
|
3546
|
+
// Silent in the feed, on purpose (#384) — see the note above. The `return`
|
|
3547
|
+
// is not cosmetic and does not go: reading a tree the agent is writing
|
|
3548
|
+
// produces a restore point that restores half a file (gotcha 438).
|
|
3549
|
+
log.info('supervisor: no restore point — the session was mid-turn', {
|
|
3550
|
+
sessionId: running.descriptor.id,
|
|
3551
|
+
...(messageSeq === undefined ? {} : { messageSeq }),
|
|
3552
|
+
});
|
|
3425
3553
|
return;
|
|
3426
3554
|
}
|
|
3427
3555
|
if (await this.isRepoLocked(worktreePath)) {
|
|
@@ -3433,7 +3561,8 @@ export class Supervisor {
|
|
|
3433
3561
|
// rewind resumes the session the point names rather than whichever one the
|
|
3434
3562
|
// session happens to be on now.
|
|
3435
3563
|
const anchor = this.currentAnchor(running);
|
|
3436
|
-
const
|
|
3564
|
+
const takeCheckpoint = this.opts.createCheckpoint ?? createCheckpoint;
|
|
3565
|
+
const result = await takeCheckpoint({
|
|
3437
3566
|
worktreePath,
|
|
3438
3567
|
sessionId: running.descriptor.id,
|
|
3439
3568
|
kind,
|
|
@@ -3487,16 +3616,18 @@ export class Supervisor {
|
|
|
3487
3616
|
/**
|
|
3488
3617
|
* Say something once per BUSY PERIOD, not once per message (#310).
|
|
3489
3618
|
*
|
|
3490
|
-
* A
|
|
3491
|
-
*
|
|
3492
|
-
* message seq would have counted each of those as its own
|
|
3493
|
-
* same sentence three times — the noise the frequency
|
|
3494
|
-
* prevent. The set is cleared when the session next comes to
|
|
3495
|
-
* (`reportStatus`), which is exactly when the reason stops being true.
|
|
3619
|
+
* A repository held by another git command stays held for as long as that
|
|
3620
|
+
* command runs, and three follow-up notes can arrive inside one answer.
|
|
3621
|
+
* Keying this on the message seq would have counted each of those as its own
|
|
3622
|
+
* turn and said the same sentence three times — the noise the frequency
|
|
3623
|
+
* policy exists to prevent. The set is cleared when the session next comes to
|
|
3624
|
+
* rest (`reportStatus`), which is exactly when the reason stops being true.
|
|
3496
3625
|
*
|
|
3497
3626
|
* A SET of keys, not the last one said: two different reasons can both come
|
|
3498
3627
|
* up inside one period, and remembering only the most recent would let them
|
|
3499
|
-
* take turns re-announcing each other.
|
|
3628
|
+
* take turns re-announcing each other. One key uses this today — «another git
|
|
3629
|
+
* command holds this repository» — and the set stays a set for that reason,
|
|
3630
|
+
* not out of habit: the mid-turn key left when it stopped being said (#384).
|
|
3500
3631
|
*/
|
|
3501
3632
|
noticeOncePerTurn(running, key, text) {
|
|
3502
3633
|
running.noticesThisTurn ??= new Set();
|
|
@@ -3518,6 +3649,33 @@ export class Supervisor {
|
|
|
3518
3649
|
// pause. Held work stays held until the clock is off.
|
|
3519
3650
|
if (Supervisor.isPaused(running))
|
|
3520
3651
|
return;
|
|
3652
|
+
/**
|
|
3653
|
+
* …and by the same argument, none of them knew about a session that has not
|
|
3654
|
+
* finished starting (#401, found by the independent QA of this fix).
|
|
3655
|
+
*
|
|
3656
|
+
* The queue is drained from six places, and two of them fire on somebody
|
|
3657
|
+
* else's news: `drainSessionsWaitingForCapacity` runs whenever ANY session
|
|
3658
|
+
* on this runner frees a slot, and the pause release runs when a clock comes
|
|
3659
|
+
* off. Either can land in the middle of another session's opening, where the
|
|
3660
|
+
* slot is legitimately empty — so delivery would start the agent with the
|
|
3661
|
+
* queued message, and the opening's own launch would then be refused by the
|
|
3662
|
+
* guard in `launchAgent`. The session's task would never be handed over at
|
|
3663
|
+
* all, and the only trace would be one line in the daemon's log.
|
|
3664
|
+
*
|
|
3665
|
+
* That is a QUIETER failure than the two processes this ticket started
|
|
3666
|
+
* from, so the check belongs here, at the one place the queue turns into
|
|
3667
|
+
* delivery, and not at each of the six callers.
|
|
3668
|
+
*
|
|
3669
|
+
* This does NOT make the `starting` test in `acceptUserMessage` redundant:
|
|
3670
|
+
* that one decides whether a message joins the queue at all, and without it
|
|
3671
|
+
* a message would go straight down the delivery chain and never be seen
|
|
3672
|
+
* here. Two different questions, both needed.
|
|
3673
|
+
*
|
|
3674
|
+
* The opening procedure drains its own queue on the way out, after clearing
|
|
3675
|
+
* the flag — `startSession` and the reconnect branch both do it.
|
|
3676
|
+
*/
|
|
3677
|
+
if (running.starting)
|
|
3678
|
+
return;
|
|
3521
3679
|
const pending = running.pendingMessages.splice(0);
|
|
3522
3680
|
if (pending.length === 0)
|
|
3523
3681
|
return;
|
|
@@ -4275,14 +4433,26 @@ export class Supervisor {
|
|
|
4275
4433
|
// switched off leaves restore points — a full copy of a working tree —
|
|
4276
4434
|
// with no row anywhere pointing at them, and nothing else on this machine
|
|
4277
4435
|
// would ever collect them.
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4436
|
+
//
|
|
4437
|
+
// One at a time (#388): reconnects come in runs, and since the collection
|
|
4438
|
+
// holds the store while it collects, a second one started on top of the
|
|
4439
|
+
// first would only queue — in front of the restore points of whoever is
|
|
4440
|
+
// working. The next reconnect collects whatever this pass leaves.
|
|
4441
|
+
if (!this.checkpointGcInFlight) {
|
|
4442
|
+
this.checkpointGcInFlight = true;
|
|
4443
|
+
void pruneCheckpoints({ liveSessionIds: known })
|
|
4444
|
+
.then((result) => {
|
|
4445
|
+
if (result.droppedRefs > 0) {
|
|
4446
|
+
log.info('supervisor: collected orphaned restore points', {
|
|
4447
|
+
sessions: result.droppedSessions.length,
|
|
4448
|
+
refs: result.droppedRefs,
|
|
4449
|
+
});
|
|
4450
|
+
}
|
|
4451
|
+
}, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }))
|
|
4452
|
+
.finally(() => {
|
|
4453
|
+
this.checkpointGcInFlight = false;
|
|
4454
|
+
});
|
|
4455
|
+
}
|
|
4286
4456
|
for (const [sessionId, running] of [...this.sessions]) {
|
|
4287
4457
|
if (known.has(sessionId))
|
|
4288
4458
|
continue;
|
|
@@ -4417,6 +4587,9 @@ export class Supervisor {
|
|
|
4417
4587
|
levels: freshLevels(),
|
|
4418
4588
|
stopRequested: false,
|
|
4419
4589
|
parkRequested: false,
|
|
4590
|
+
// Registered before the worktree await, so the gate has to be up
|
|
4591
|
+
// before it too (#401).
|
|
4592
|
+
starting: true,
|
|
4420
4593
|
pendingMessages: [],
|
|
4421
4594
|
// Seed from the API, not 0: a runner restart used to hand the session
|
|
4422
4595
|
// a full fresh budget silently.
|
|
@@ -4442,110 +4615,132 @@ export class Supervisor {
|
|
|
4442
4615
|
running.pendingMessages.push(...running.journal.pending());
|
|
4443
4616
|
this.sessions.set(descriptor.id, running);
|
|
4444
4617
|
try {
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
}
|
|
4465
|
-
/**
|
|
4466
|
-
* Was a turn actually in flight when the process died?
|
|
4467
|
-
*
|
|
4468
|
-
* `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
|
|
4469
|
-
* others mean the agent was already waiting for a human, and there is
|
|
4470
|
-
* nothing to continue. REVIEW is deliberately excluded — the work is
|
|
4471
|
-
* finished and waiting to be looked at.
|
|
4472
|
-
*/
|
|
4473
|
-
const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
|
|
4474
|
-
const resumeId = descriptor.providerSessionId;
|
|
4475
|
-
// Ticket #177: `resumeId` is required, not merely nice to have. Without
|
|
4476
|
-
// it the relaunch starts a FRESH conversation, and `AUTO_RESUME_PROMPT`
|
|
4477
|
-
// — "continue from where you stopped, re-check what you were in the
|
|
4478
|
-
// middle of" — would be addressed to an agent that remembers none of
|
|
4479
|
-
// it. A process killed before it reported its session id (the SIGABRT
|
|
4480
|
-
// this ticket came from) leaves the row in exactly that state.
|
|
4481
|
-
// Ticket #196: a paused session is never continued automatically. The
|
|
4482
|
-
// row still says RUNNING — a pause interrupts the turn but is not a
|
|
4483
|
-
// status — so without this the reconnect would read «mid-turn» and
|
|
4484
|
-
// relaunch the agent with «continue from where you stopped», which is
|
|
4485
|
-
// the exact opposite of what the clock was set for.
|
|
4486
|
-
const willContinue = wasMidTurn &&
|
|
4487
|
-
!Supervisor.isPaused(running) &&
|
|
4488
|
-
Boolean(resumeId) &&
|
|
4489
|
-
claimAutoResume(descriptor.id);
|
|
4490
|
-
// The note stays either way (owner's call): an interruption is a fact
|
|
4491
|
-
// about the session and must not disappear just because we recovered
|
|
4492
|
-
// from it. Only the instruction at the end changes — telling someone to
|
|
4493
|
-
// send a message while the agent is already working again would be a lie.
|
|
4494
|
-
this.sendEvent(running, 'system_note', {
|
|
4495
|
-
text: willContinue
|
|
4496
|
-
? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
|
|
4497
|
-
: resumeId
|
|
4498
|
-
? 'Runner reconnected. The session was resumed — send a message to continue.'
|
|
4499
|
-
: 'Runner reconnected, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
|
|
4500
|
-
});
|
|
4501
|
-
if (willContinue) {
|
|
4502
|
-
// Resumed through the PROVIDER session, so the agent keeps its whole
|
|
4503
|
-
// conversation; the prompt is only the nudge a human would otherwise
|
|
4504
|
-
// have to type. Exactly what «продолжай» did by hand — no new class of
|
|
4505
|
-
// risk, and the same ceiling protects against a crash loop doing it
|
|
4506
|
-
// forever (see `auto-resume.ts`).
|
|
4507
|
-
const continued = this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId);
|
|
4508
|
-
if (continued.ok) {
|
|
4509
|
-
this.reportStatus(descriptor.id, 'RUNNING', {});
|
|
4510
|
-
this.flushPendingMessages(running);
|
|
4618
|
+
try {
|
|
4619
|
+
// A session being restored after a runner restart already has its
|
|
4620
|
+
// branch, so the API sends `CONTINUE` — the NEW guard inside would
|
|
4621
|
+
// otherwise fire on the runner's own previous work.
|
|
4622
|
+
const prepared = await this.prepareWorkspace(descriptor);
|
|
4623
|
+
running.branch = prepared.branch;
|
|
4624
|
+
running.worktreePath = prepared.worktreePath;
|
|
4625
|
+
if (prepared.baseSha)
|
|
4626
|
+
running.baseSha = prepared.baseSha;
|
|
4627
|
+
if (prepared.baseBranch)
|
|
4628
|
+
running.baseBranch = prepared.baseBranch;
|
|
4629
|
+
}
|
|
4630
|
+
catch (error) {
|
|
4631
|
+
// The same treatment as the start door (#360). Two doors doing one
|
|
4632
|
+
// thing is how the pause bug of #373 stayed half-fixed for a
|
|
4633
|
+
// release; this one used to mask its text differently AND emit no
|
|
4634
|
+
// feed event at all, so a session that could not be restored went
|
|
4635
|
+
// FAILED with nothing to read anywhere.
|
|
4636
|
+
this.workspacePrepareFailed(running, error, 'Failed to restore session worktree');
|
|
4511
4637
|
continue;
|
|
4512
4638
|
}
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4639
|
+
/**
|
|
4640
|
+
* Was a turn actually in flight when the process died?
|
|
4641
|
+
*
|
|
4642
|
+
* `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
|
|
4643
|
+
* others mean the agent was already waiting for a human, and there is
|
|
4644
|
+
* nothing to continue. REVIEW is deliberately excluded — the work is
|
|
4645
|
+
* finished and waiting to be looked at.
|
|
4646
|
+
*/
|
|
4647
|
+
const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
|
|
4648
|
+
const resumeId = descriptor.providerSessionId;
|
|
4649
|
+
// Ticket #177: `resumeId` is required, not merely nice to have. Without
|
|
4650
|
+
// it the relaunch starts a FRESH conversation, and `AUTO_RESUME_PROMPT`
|
|
4651
|
+
// — "continue from where you stopped, re-check what you were in the
|
|
4652
|
+
// middle of" — would be addressed to an agent that remembers none of
|
|
4653
|
+
// it. A process killed before it reported its session id (the SIGABRT
|
|
4654
|
+
// this ticket came from) leaves the row in exactly that state.
|
|
4655
|
+
// Ticket #196: a paused session is never continued automatically. The
|
|
4656
|
+
// row still says RUNNING — a pause interrupts the turn but is not a
|
|
4657
|
+
// status — so without this the reconnect would read «mid-turn» and
|
|
4658
|
+
// relaunch the agent with «continue from where you stopped», which is
|
|
4659
|
+
// the exact opposite of what the clock was set for.
|
|
4660
|
+
const willContinue = wasMidTurn &&
|
|
4661
|
+
!Supervisor.isPaused(running) &&
|
|
4662
|
+
Boolean(resumeId) &&
|
|
4663
|
+
claimAutoResume(descriptor.id);
|
|
4664
|
+
// The note stays either way (owner's call): an interruption is a fact
|
|
4665
|
+
// about the session and must not disappear just because we recovered
|
|
4666
|
+
// from it. Only the instruction at the end changes — telling someone to
|
|
4667
|
+
// send a message while the agent is already working again would be a lie.
|
|
4668
|
+
this.sendEvent(running, 'system_note', {
|
|
4669
|
+
text: willContinue
|
|
4670
|
+
? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
|
|
4671
|
+
: resumeId
|
|
4672
|
+
? 'Runner reconnected. The session was resumed — send a message to continue.'
|
|
4673
|
+
: 'Runner reconnected, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
|
|
4674
|
+
});
|
|
4675
|
+
if (willContinue) {
|
|
4676
|
+
// Resumed through the PROVIDER session, so the agent keeps its whole
|
|
4677
|
+
// conversation; the prompt is only the nudge a human would otherwise
|
|
4678
|
+
// have to type. Exactly what «продолжай» did by hand — no new class of
|
|
4679
|
+
// risk, and the same ceiling protects against a crash loop doing it
|
|
4680
|
+
// forever (see `auto-resume.ts`).
|
|
4681
|
+
const continued = this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId);
|
|
4682
|
+
if (continued.ok) {
|
|
4683
|
+
this.reportStatus(descriptor.id, 'RUNNING', {});
|
|
4684
|
+
// The opening is over — hand the queue back before draining it,
|
|
4685
|
+
// because `flushPendingMessages` refuses a session that is still
|
|
4686
|
+
// starting (#401). The `finally` below is the net for the exits
|
|
4687
|
+
// that never get here.
|
|
4688
|
+
running.starting = false;
|
|
4689
|
+
this.flushPendingMessages(running);
|
|
4690
|
+
continue;
|
|
4691
|
+
}
|
|
4692
|
+
// Could not start — an exhausted budget, or a launch that crashed
|
|
4693
|
+
// (ticket #225: this is the exact line the incident died on, and the
|
|
4694
|
+
// throw took the WHOLE restore loop with it). Fall through to the old
|
|
4695
|
+
// behaviour and say so honestly; a crash has already put its own
|
|
4696
|
+
// reason in the feed, so this note would only repeat it.
|
|
4697
|
+
if (continued.reason === 'refused') {
|
|
4698
|
+
this.sendEvent(running, 'system_note', {
|
|
4699
|
+
text: 'Could not continue automatically — send a message to pick the work back up.',
|
|
4700
|
+
});
|
|
4701
|
+
}
|
|
4522
4702
|
}
|
|
4703
|
+
/**
|
|
4704
|
+
* REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
|
|
4705
|
+
* mid-turn statuses are downgraded to "waiting for the user".
|
|
4706
|
+
*
|
|
4707
|
+
* REVIEW is now REPORTED rather than skipped — ticket #356, and the
|
|
4708
|
+
* news in that frame is not the status, which has not moved. It is
|
|
4709
|
+
* the ZERO riding on it.
|
|
4710
|
+
*
|
|
4711
|
+
* `running.backgroundTasks` is seeded to 0 above, because a runner
|
|
4712
|
+
* restart takes every subagent with it. But the number the API holds
|
|
4713
|
+
* is written by the runner ALONE, and it is only ever written by a
|
|
4714
|
+
* frame that carries the field — which `reportStatus` attaches only
|
|
4715
|
+
* for a tracked session. Skipping the report here left the API
|
|
4716
|
+
* believing whatever the dead process last said, for good: the badge
|
|
4717
|
+
* would keep saying «Agents», the Inbox would keep hiding the card,
|
|
4718
|
+
* and no later frame would ever correct either. `setBackgroundTasks`
|
|
4719
|
+
* cannot help — it compares against the in-memory 0 and returns
|
|
4720
|
+
* early, having nothing to announce.
|
|
4721
|
+
*
|
|
4722
|
+
* Same-status frames are legal (`canDevSessionTransition` answers
|
|
4723
|
+
* `true` for `from === to`), so this costs one no-op write and buys
|
|
4724
|
+
* back a session that would otherwise have been lost.
|
|
4725
|
+
*/
|
|
4726
|
+
this.reportStatus(descriptor.id, statusForReport(running), {});
|
|
4727
|
+
// As above: the flag comes off first, or the drain below is a no-op.
|
|
4728
|
+
running.starting = false;
|
|
4729
|
+
this.flushPendingMessages(running);
|
|
4730
|
+
}
|
|
4731
|
+
finally {
|
|
4732
|
+
/**
|
|
4733
|
+
* The same gate as the start door, for the same reason (#401).
|
|
4734
|
+
*
|
|
4735
|
+
* This branch registers the entry BEFORE awaiting the worktree —
|
|
4736
|
+
* deliberately, so a racing message is buffered rather than dropped
|
|
4737
|
+
* (QA-96 F3) — and then decides for itself whether to bring the
|
|
4738
|
+
* agent back up. A message let through in between would make that
|
|
4739
|
+
* decision instead, and the feed would go on to say the session
|
|
4740
|
+
* could not be continued automatically while it plainly was.
|
|
4741
|
+
*/
|
|
4742
|
+
running.starting = false;
|
|
4523
4743
|
}
|
|
4524
|
-
/**
|
|
4525
|
-
* REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
|
|
4526
|
-
* mid-turn statuses are downgraded to "waiting for the user".
|
|
4527
|
-
*
|
|
4528
|
-
* REVIEW is now REPORTED rather than skipped — ticket #356, and the
|
|
4529
|
-
* news in that frame is not the status, which has not moved. It is
|
|
4530
|
-
* the ZERO riding on it.
|
|
4531
|
-
*
|
|
4532
|
-
* `running.backgroundTasks` is seeded to 0 above, because a runner
|
|
4533
|
-
* restart takes every subagent with it. But the number the API holds
|
|
4534
|
-
* is written by the runner ALONE, and it is only ever written by a
|
|
4535
|
-
* frame that carries the field — which `reportStatus` attaches only
|
|
4536
|
-
* for a tracked session. Skipping the report here left the API
|
|
4537
|
-
* believing whatever the dead process last said, for good: the badge
|
|
4538
|
-
* would keep saying «Agents», the Inbox would keep hiding the card,
|
|
4539
|
-
* and no later frame would ever correct either. `setBackgroundTasks`
|
|
4540
|
-
* cannot help — it compares against the in-memory 0 and returns
|
|
4541
|
-
* early, having nothing to announce.
|
|
4542
|
-
*
|
|
4543
|
-
* Same-status frames are legal (`canDevSessionTransition` answers
|
|
4544
|
-
* `true` for `from === to`), so this costs one no-op write and buys
|
|
4545
|
-
* back a session that would otherwise have been lost.
|
|
4546
|
-
*/
|
|
4547
|
-
this.reportStatus(descriptor.id, statusForReport(running), {});
|
|
4548
|
-
this.flushPendingMessages(running);
|
|
4549
4744
|
}
|
|
4550
4745
|
else if (descriptor.status === 'WAITING_INPUT') {
|
|
4551
4746
|
// A session that never had a turn (a free session still waiting for
|