@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.
@@ -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
- const prepared = await this.prepareWorkspace(descriptor);
1072
- running.branch = prepared.branch;
1073
- running.worktreePath = prepared.worktreePath;
1074
- // Only when WE created the branch: the fork point is a fact the API can
1075
- // learn nowhere else, and it pins the first answer it gets.
1076
- if (prepared.baseSha)
1077
- running.baseSha = prepared.baseSha;
1078
- if (prepared.baseBranch)
1079
- running.baseBranch = prepared.baseBranch;
1080
- }
1081
- catch (error) {
1082
- this.workspacePrepareFailed(running, error, 'Failed to prepare git worktree');
1083
- return;
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
- else {
1103
- if (descriptor.epoch > 0) {
1104
- // The API owns the resume transition; the feed marker has to come from
1105
- // here because the runner is the only writer of the event seq.
1106
- //
1107
- // Ticket #177: which of the two sentences is true depends on whether
1108
- // there is a conversation to go back to. `providerSessionId` is the
1109
- // agent's own name for it, and it is what the next launch hands to
1110
- // `--resume` / `thread/resume`. Without it the next process starts the
1111
- // conversation over — which is a real loss, and promising «continue
1112
- // where the agent left off» there is the one thing the feed must not do.
1113
- // It happens for real: a process that dies before it reports its session
1114
- // id (the SIGABRT this ticket came from) leaves the row with none.
1115
- //
1116
- // Ticket #370: the sentence about the agent's MEMORY waits for proof.
1117
- // It used to be written here, on the strength of a stored id and before
1118
- // the CLI had been asked anything — and on Athanor it appeared sixty
1119
- // seconds before `thread/resume` timed out and took the session with it.
1120
- // What is honest at this moment is that the runner is back; whether the
1121
- // conversation reopens is answered by the process, in `provider_session`.
1122
- if (descriptor.providerSessionId)
1123
- running.resumeClaimPending = true;
1124
- this.sendEvent(running, 'system_note', {
1125
- text: descriptor.providerSessionId
1126
- ? 'Session resumed — send a message and the agent picks its conversation back up.'
1127
- : '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.',
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
- running.lastReported = descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT';
1131
- this.reportStatus(descriptor.id, running.lastReported, {
1132
- branch: running.branch,
1133
- worktreePath: running.worktreePath,
1134
- activeMs: running.activeMs,
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
- return; // superseded (shouldn't happen in v0)
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
- running.session = null;
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
- running.session = null;
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: typed
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 (typed) {
2891
- this.acceptUserMessage(frame.sessionId, typed);
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
- * Every refusal is now audible. A restore point that was never taken is
3405
- * invisible until the day somebody reaches for it, and «the button is not
3406
- * there» is not a sentence anybody can act on.
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
- this.noticeOncePerTurn(running, 'checkpoint-self-busy', 'No restore point was taken for this step: this session was still answering when it was due.');
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 result = await createCheckpoint({
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 folder held by a neighbour stays held for minutes, and a session mid-turn
3491
- * can be sent three follow-up notes inside one answer. Keying this on the
3492
- * message seq would have counted each of those as its own turn and said the
3493
- * same sentence three times — the noise the frequency policy exists to
3494
- * prevent. The set is cleared when the session next comes to rest
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
- void pruneCheckpoints({ liveSessionIds: known }).then((result) => {
4279
- if (result.droppedRefs > 0) {
4280
- log.info('supervisor: collected orphaned restore points', {
4281
- sessions: result.droppedSessions.length,
4282
- refs: result.droppedRefs,
4283
- });
4284
- }
4285
- }, (error) => log.warn('supervisor: restore-point GC failed', { error: String(error) }));
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
- // A session being restored after a runner restart already has its
4446
- // branch, so the API sends `CONTINUE` — the NEW guard inside would
4447
- // otherwise fire on the runner's own previous work.
4448
- const prepared = await this.prepareWorkspace(descriptor);
4449
- running.branch = prepared.branch;
4450
- running.worktreePath = prepared.worktreePath;
4451
- if (prepared.baseSha)
4452
- running.baseSha = prepared.baseSha;
4453
- if (prepared.baseBranch)
4454
- running.baseBranch = prepared.baseBranch;
4455
- }
4456
- catch (error) {
4457
- // The same treatment as the start door (#360). Two doors doing one
4458
- // thing is how the pause bug of #373 stayed half-fixed for a
4459
- // release; this one used to mask its text differently AND emit no
4460
- // feed event at all, so a session that could not be restored went
4461
- // FAILED with nothing to read anywhere.
4462
- this.workspacePrepareFailed(running, error, 'Failed to restore session worktree');
4463
- continue;
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
- // Could not start — an exhausted budget, or a launch that crashed
4514
- // (ticket #225: this is the exact line the incident died on, and the
4515
- // throw took the WHOLE restore loop with it). Fall through to the old
4516
- // behaviour and say so honestly; a crash has already put its own
4517
- // reason in the feed, so this note would only repeat it.
4518
- if (continued.reason === 'refused') {
4519
- this.sendEvent(running, 'system_note', {
4520
- text: 'Could not continue automatically — send a message to pick the work back up.',
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