@bridge4dev/runner 0.56.0 → 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.
@@ -28,6 +28,21 @@ export declare function mirrorOptions(questions: AgentQuestion[]): string[];
28
28
  export declare function answerValue(answer: AgentQuestionAnswer): string;
29
29
  /** One short line for the resolved card: «Postgres · Auth, Search». */
30
30
  export declare function answerSummary(answers: AgentQuestionAnswer[]): string;
31
+ /**
32
+ * The same answer as something to SAY, when the card that asked is gone (#401).
33
+ *
34
+ * Not `answerSummary`: that one is a label for a resolved card, so it is
35
+ * clipped to an option's width and drops the notes. This is the person's reply
36
+ * being handed to the agent as an ordinary message, and nothing they typed may
37
+ * be shortened away on the path. The questions themselves cannot be named —
38
+ * their text lived in the process that asked and is gone with it — so the
39
+ * answer is given as the words it was made of, which is what the person
40
+ * actually chose.
41
+ *
42
+ * Empty when there is nothing in it: the caller uses that to tell «the reply
43
+ * was lost» from «there was no reply to lose».
44
+ */
45
+ export declare function answersAsMessage(answers: AgentQuestionAnswer[]): string;
31
46
  /**
32
47
  * The «discuss instead» exit.
33
48
  *
@@ -52,6 +52,38 @@ export function answerValue(answer) {
52
52
  export function answerSummary(answers) {
53
53
  return clip(answers.map(answerValue).filter(Boolean).join(' · '), OPTION_TEXT_LIMIT);
54
54
  }
55
+ /**
56
+ * The same answer as something to SAY, when the card that asked is gone (#401).
57
+ *
58
+ * Not `answerSummary`: that one is a label for a resolved card, so it is
59
+ * clipped to an option's width and drops the notes. This is the person's reply
60
+ * being handed to the agent as an ordinary message, and nothing they typed may
61
+ * be shortened away on the path. The questions themselves cannot be named —
62
+ * their text lived in the process that asked and is gone with it — so the
63
+ * answer is given as the words it was made of, which is what the person
64
+ * actually chose.
65
+ *
66
+ * Empty when there is nothing in it: the caller uses that to tell «the reply
67
+ * was lost» from «there was no reply to lose».
68
+ */
69
+ export function answersAsMessage(answers) {
70
+ return clip(answers
71
+ .map((answer) => {
72
+ const value = answerValue(answer);
73
+ const notes = answer.notes?.trim();
74
+ if (value && notes)
75
+ return `${value} (${notes})`;
76
+ return value || notes || '';
77
+ })
78
+ .filter(Boolean)
79
+ .join('\n'),
80
+ // The same ceiling `discussMessage` uses, and for a harder reason: this text
81
+ // becomes a feed event, and an event over the API's size limit is replaced
82
+ // wholesale by a truncation marker. The frame this is built from allows four
83
+ // answers of sixteen 2 000-char values plus a 10 000-char custom field —
84
+ // ~176 KB — and the runner takes that frame straight off the socket.
85
+ 8_000);
86
+ }
55
87
  /**
56
88
  * The «discuss instead» exit.
57
89
  *
@@ -6,6 +6,7 @@ import { pruneNativeClaudeVersions } from './agent-cleanup.js';
6
6
  import { type AgentVersionsMeasurement } from './agent-versions.js';
7
7
  import { type HostLoadFrame } from './host-load.js';
8
8
  import { type ScopeMemoryStatus } from './session-cage.js';
9
+ import { createCheckpoint } from './checkpoints.js';
9
10
  import type { RunnerWsClient } from './ws-client.js';
10
11
  import type { SessionDescriptor } from './protocol.js';
11
12
  import type { AgentAdapter } from './adapters/types.js';
@@ -50,6 +51,17 @@ export interface SupervisorOptions {
50
51
  verifyEnabled?: boolean;
51
52
  /** Test seam for the one-shot commit-message run. */
52
53
  proposeCommitMessage?: typeof proposeCommitMessage;
54
+ /**
55
+ * Test seam for taking a restore point — #401.
56
+ *
57
+ * A seam and not a detail: the opening restore point is the whole window this
58
+ * ticket is about. It runs between «the working folder is ready» and «the
59
+ * first process exists», it takes SECONDS on a dirty tree, and a message that
60
+ * arrives inside it used to open a second door to `launchAgent`. Timing that
61
+ * window from the outside is guesswork; holding it open from a test is the
62
+ * only way the race is reproducible on demand.
63
+ */
64
+ createCheckpoint?: typeof createCheckpoint;
53
65
  /**
54
66
  * `[checkpoints] enabled` from the runner's own config (ticket #126), by the
55
67
  * same rule as `[verify] enabled`: restore points are copies of the working
@@ -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';
@@ -1017,6 +1018,9 @@ export class Supervisor {
1017
1018
  levels: freshLevels(),
1018
1019
  stopRequested: false,
1019
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,
1020
1024
  pendingMessages: [],
1021
1025
  activeMs: descriptor.activeMsBase,
1022
1026
  extraBudgetMinutes: descriptor.extraBudgetMinutes,
@@ -1070,71 +1074,84 @@ export class Supervisor {
1070
1074
  }
1071
1075
  }
1072
1076
  try {
1073
- const prepared = await this.prepareWorkspace(descriptor);
1074
- running.branch = prepared.branch;
1075
- running.worktreePath = prepared.worktreePath;
1076
- // Only when WE created the branch: the fork point is a fact the API can
1077
- // learn nowhere else, and it pins the first answer it gets.
1078
- if (prepared.baseSha)
1079
- running.baseSha = prepared.baseSha;
1080
- if (prepared.baseBranch)
1081
- running.baseBranch = prepared.baseBranch;
1082
- }
1083
- catch (error) {
1084
- this.workspacePrepareFailed(running, error, 'Failed to prepare git worktree');
1085
- return;
1086
- }
1087
- // A session that is already past STARTING (re-sent because this runner
1088
- // asked for it) resumes on the next message instead of replaying its
1089
- // original prompt.
1090
- if (descriptor.status === 'STARTING') {
1091
- // Ticket #126: the point before the agent has touched anything. It is
1092
- // anchored to seq 0 — the synthetic opening bubble the dashboard puts in
1093
- // front of every feed — so "put it all back" is reachable from the very
1094
- // first thing on the page.
1095
- await this.captureCheckpoint(running, 'TURN', 0);
1096
- if (this.isStale(running))
1097
- return;
1098
- // A launch that failed has already said so and reported a status the
1099
- // person can act on. Flushing the queue into it would only walk the same
1100
- // failure again, once per waiting message (ticket #225).
1101
- 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');
1102
1090
  return;
1103
- }
1104
- else {
1105
- if (descriptor.epoch > 0) {
1106
- // The API owns the resume transition; the feed marker has to come from
1107
- // here because the runner is the only writer of the event seq.
1108
- //
1109
- // Ticket #177: which of the two sentences is true depends on whether
1110
- // there is a conversation to go back to. `providerSessionId` is the
1111
- // agent's own name for it, and it is what the next launch hands to
1112
- // `--resume` / `thread/resume`. Without it the next process starts the
1113
- // conversation over — which is a real loss, and promising «continue
1114
- // where the agent left off» there is the one thing the feed must not do.
1115
- // It happens for real: a process that dies before it reports its session
1116
- // id (the SIGABRT this ticket came from) leaves the row with none.
1117
- //
1118
- // Ticket #370: the sentence about the agent's MEMORY waits for proof.
1119
- // It used to be written here, on the strength of a stored id and before
1120
- // the CLI had been asked anything — and on Athanor it appeared sixty
1121
- // seconds before `thread/resume` timed out and took the session with it.
1122
- // What is honest at this moment is that the runner is back; whether the
1123
- // conversation reopens is answered by the process, in `provider_session`.
1124
- if (descriptor.providerSessionId)
1125
- running.resumeClaimPending = true;
1126
- this.sendEvent(running, 'system_note', {
1127
- text: descriptor.providerSessionId
1128
- ? 'Session resumed — send a message and the agent picks its conversation back up.'
1129
- : '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,
1130
1142
  });
1131
1143
  }
1132
- running.lastReported = descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT';
1133
- this.reportStatus(descriptor.id, running.lastReported, {
1134
- branch: running.branch,
1135
- worktreePath: running.worktreePath,
1136
- activeMs: running.activeMs,
1137
- });
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;
1138
1155
  }
1139
1156
  this.flushPendingMessages(running);
1140
1157
  }
@@ -1227,6 +1244,40 @@ export class Supervisor {
1227
1244
  const adapter = this.opts.adapters[descriptor.agent];
1228
1245
  if (!adapter || !running.worktreePath || !running.branch)
1229
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
+ }
1230
1281
  // An exhausted USD budget must not relaunch $0.01-floor processes (QA-96 F4).
1231
1282
  // Codex reports no cost at all, so its costUsd never leaves 0 — gating on it
1232
1283
  // would be a limit that can never fire while the UI shows $0.00. Those
@@ -1693,8 +1744,35 @@ export class Supervisor {
1693
1744
  });
1694
1745
  }
1695
1746
  // Stream ended — the agent process is gone.
1696
- if (running.session !== session)
1697
- 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;
1698
1776
  // Backstop for a process that died without going through `stop()` (a crash,
1699
1777
  // an SDK error, an agent that exited mid-question): the adapter never got
1700
1778
  // to withdraw its cards, and a card nobody can answer must not stay live.
@@ -1768,7 +1846,9 @@ export class Supervisor {
1768
1846
  const { priorStatus } = running.modeRelaunch;
1769
1847
  delete running.modeRelaunch;
1770
1848
  running.parkRequested = false;
1771
- 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.
1772
1852
  running.costBaseUsd = running.costUsd; // the next process starts from here
1773
1853
  const mode = running.mode;
1774
1854
  // «Your conversation is kept» is only true when there IS one to keep
@@ -1832,7 +1912,7 @@ export class Supervisor {
1832
1912
  !isTerminal(running.lastReported) &&
1833
1913
  running.descriptor.providerSessionId) {
1834
1914
  // Idle process ended (parked or died between turns) — stay resumable.
1835
- running.session = null;
1915
+ // The slot itself was emptied above, where the process was found gone.
1836
1916
  running.parkRequested = false;
1837
1917
  /**
1838
1918
  * The subagents died with it (QA-2026-08-16 M-5).
@@ -2880,8 +2960,26 @@ export class Supervisor {
2880
2960
  // words below have now been published once, and a redelivery must not
2881
2961
  // publish them again.
2882
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');
2883
2981
  this.sendEvent(running, 'system_note', {
2884
- text: typed
2982
+ text: rescued
2885
2983
  ? 'That question is no longer open — sending your reply as an ordinary message instead.'
2886
2984
  : 'That question is no longer open — the agent has already moved on.',
2887
2985
  });
@@ -2889,8 +2987,8 @@ export class Supervisor {
2889
2987
  // restart: the card in the browser outlived the process that asked, and
2890
2988
  // showing the user's own message in the feed while nothing receives it is
2891
2989
  // the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
2892
- if (typed) {
2893
- this.acceptUserMessage(frame.sessionId, typed);
2990
+ if (rescued) {
2991
+ this.acceptUserMessage(frame.sessionId, rescued);
2894
2992
  return 'not_open';
2895
2993
  }
2896
2994
  // Nothing to deliver — but the API optimistically flipped the session to
@@ -2980,11 +3078,22 @@ export class Supervisor {
2980
3078
  ...(attachments?.length ? { attachments } : {}),
2981
3079
  });
2982
3080
  const originSeq = echoed.seq;
2983
- if (!running.worktreePath || Supervisor.isPaused(running)) {
3081
+ if (running.starting || !running.worktreePath || Supervisor.isPaused(running)) {
2984
3082
  // Session is still being prepared — deliver after launch (QA-96 F3).
2985
3083
  // Attachments travel as metadata and are downloaded at delivery time,
2986
3084
  // which is the first moment the worktree is guaranteed to exist.
2987
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
+ //
2988
3097
  // Or the session is held under a clock (#196). The API refuses live
2989
3098
  // messages for a paused session, but not every path goes through that
2990
3099
  // check — the outbox flushes on reconnect, and the git service posts its
@@ -3452,7 +3561,8 @@ export class Supervisor {
3452
3561
  // rewind resumes the session the point names rather than whichever one the
3453
3562
  // session happens to be on now.
3454
3563
  const anchor = this.currentAnchor(running);
3455
- const result = await createCheckpoint({
3564
+ const takeCheckpoint = this.opts.createCheckpoint ?? createCheckpoint;
3565
+ const result = await takeCheckpoint({
3456
3566
  worktreePath,
3457
3567
  sessionId: running.descriptor.id,
3458
3568
  kind,
@@ -3539,6 +3649,33 @@ export class Supervisor {
3539
3649
  // pause. Held work stays held until the clock is off.
3540
3650
  if (Supervisor.isPaused(running))
3541
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;
3542
3679
  const pending = running.pendingMessages.splice(0);
3543
3680
  if (pending.length === 0)
3544
3681
  return;
@@ -4450,6 +4587,9 @@ export class Supervisor {
4450
4587
  levels: freshLevels(),
4451
4588
  stopRequested: false,
4452
4589
  parkRequested: false,
4590
+ // Registered before the worktree await, so the gate has to be up
4591
+ // before it too (#401).
4592
+ starting: true,
4453
4593
  pendingMessages: [],
4454
4594
  // Seed from the API, not 0: a runner restart used to hand the session
4455
4595
  // a full fresh budget silently.
@@ -4475,110 +4615,132 @@ export class Supervisor {
4475
4615
  running.pendingMessages.push(...running.journal.pending());
4476
4616
  this.sessions.set(descriptor.id, running);
4477
4617
  try {
4478
- // A session being restored after a runner restart already has its
4479
- // branch, so the API sends `CONTINUE` — the NEW guard inside would
4480
- // otherwise fire on the runner's own previous work.
4481
- const prepared = await this.prepareWorkspace(descriptor);
4482
- running.branch = prepared.branch;
4483
- running.worktreePath = prepared.worktreePath;
4484
- if (prepared.baseSha)
4485
- running.baseSha = prepared.baseSha;
4486
- if (prepared.baseBranch)
4487
- running.baseBranch = prepared.baseBranch;
4488
- }
4489
- catch (error) {
4490
- // The same treatment as the start door (#360). Two doors doing one
4491
- // thing is how the pause bug of #373 stayed half-fixed for a
4492
- // release; this one used to mask its text differently AND emit no
4493
- // feed event at all, so a session that could not be restored went
4494
- // FAILED with nothing to read anywhere.
4495
- this.workspacePrepareFailed(running, error, 'Failed to restore session worktree');
4496
- continue;
4497
- }
4498
- /**
4499
- * Was a turn actually in flight when the process died?
4500
- *
4501
- * `RUNNING` and `WAITING_PERMISSION` are the mid-turn statuses; the
4502
- * others mean the agent was already waiting for a human, and there is
4503
- * nothing to continue. REVIEW is deliberately excluded — the work is
4504
- * finished and waiting to be looked at.
4505
- */
4506
- const wasMidTurn = descriptor.status === 'RUNNING' || descriptor.status === 'WAITING_PERMISSION';
4507
- const resumeId = descriptor.providerSessionId;
4508
- // Ticket #177: `resumeId` is required, not merely nice to have. Without
4509
- // it the relaunch starts a FRESH conversation, and `AUTO_RESUME_PROMPT`
4510
- // — "continue from where you stopped, re-check what you were in the
4511
- // middle of" — would be addressed to an agent that remembers none of
4512
- // it. A process killed before it reported its session id (the SIGABRT
4513
- // this ticket came from) leaves the row in exactly that state.
4514
- // Ticket #196: a paused session is never continued automatically. The
4515
- // row still says RUNNING — a pause interrupts the turn but is not a
4516
- // status — so without this the reconnect would read «mid-turn» and
4517
- // relaunch the agent with «continue from where you stopped», which is
4518
- // the exact opposite of what the clock was set for.
4519
- const willContinue = wasMidTurn &&
4520
- !Supervisor.isPaused(running) &&
4521
- Boolean(resumeId) &&
4522
- claimAutoResume(descriptor.id);
4523
- // The note stays either way (owner's call): an interruption is a fact
4524
- // about the session and must not disappear just because we recovered
4525
- // from it. Only the instruction at the end changes — telling someone to
4526
- // send a message while the agent is already working again would be a lie.
4527
- this.sendEvent(running, 'system_note', {
4528
- text: willContinue
4529
- ? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
4530
- : resumeId
4531
- ? 'Runner reconnected. The session was resumed — send a message to continue.'
4532
- : '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.',
4533
- });
4534
- if (willContinue) {
4535
- // Resumed through the PROVIDER session, so the agent keeps its whole
4536
- // conversation; the prompt is only the nudge a human would otherwise
4537
- // have to type. Exactly what «продолжай» did by hand — no new class of
4538
- // risk, and the same ceiling protects against a crash loop doing it
4539
- // forever (see `auto-resume.ts`).
4540
- const continued = this.launchAgent(running, AUTO_RESUME_PROMPT, resumeId);
4541
- if (continued.ok) {
4542
- this.reportStatus(descriptor.id, 'RUNNING', {});
4543
- 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');
4544
4637
  continue;
4545
4638
  }
4546
- // Could not start — an exhausted budget, or a launch that crashed
4547
- // (ticket #225: this is the exact line the incident died on, and the
4548
- // throw took the WHOLE restore loop with it). Fall through to the old
4549
- // behaviour and say so honestly; a crash has already put its own
4550
- // reason in the feed, so this note would only repeat it.
4551
- if (continued.reason === 'refused') {
4552
- this.sendEvent(running, 'system_note', {
4553
- text: 'Could not continue automatically — send a message to pick the work back up.',
4554
- });
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
+ }
4555
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;
4556
4743
  }
4557
- /**
4558
- * REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
4559
- * mid-turn statuses are downgraded to "waiting for the user".
4560
- *
4561
- * REVIEW is now REPORTED rather than skipped — ticket #356, and the
4562
- * news in that frame is not the status, which has not moved. It is
4563
- * the ZERO riding on it.
4564
- *
4565
- * `running.backgroundTasks` is seeded to 0 above, because a runner
4566
- * restart takes every subagent with it. But the number the API holds
4567
- * is written by the runner ALONE, and it is only ever written by a
4568
- * frame that carries the field — which `reportStatus` attaches only
4569
- * for a tracked session. Skipping the report here left the API
4570
- * believing whatever the dead process last said, for good: the badge
4571
- * would keep saying «Agents», the Inbox would keep hiding the card,
4572
- * and no later frame would ever correct either. `setBackgroundTasks`
4573
- * cannot help — it compares against the in-memory 0 and returns
4574
- * early, having nothing to announce.
4575
- *
4576
- * Same-status frames are legal (`canDevSessionTransition` answers
4577
- * `true` for `from === to`), so this costs one no-op write and buys
4578
- * back a session that would otherwise have been lost.
4579
- */
4580
- this.reportStatus(descriptor.id, statusForReport(running), {});
4581
- this.flushPendingMessages(running);
4582
4744
  }
4583
4745
  else if (descriptor.status === 'WAITING_INPUT') {
4584
4746
  // A session that never had a turn (a free session still waiting for
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.56.0";
1
+ export declare const RUNNER_VERSION = "0.57.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.56.0';
2
+ export const RUNNER_VERSION = '0.57.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",