@bridge4dev/runner 0.60.0 → 0.62.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.
@@ -67,6 +67,12 @@ function gitPolicyOf(descriptor) {
67
67
  ...(w.agentAllowDestructiveGit !== undefined
68
68
  ? { agentAllowDestructiveGit: w.agentAllowDestructiveGit }
69
69
  : {}),
70
+ // #418 — «may the agent work outside the project folder». Not a git
71
+ // question; it rides in this object because the object is the pipe (plan
72
+ // R8, and the comment on `AgentGitPolicy` in `policy.ts`).
73
+ ...(w.agentAllowOutsideFolder !== undefined
74
+ ? { agentAllowOutsideFolder: w.agentAllowOutsideFolder }
75
+ : {}),
70
76
  };
71
77
  }
72
78
  function freshLevels() {
@@ -123,6 +129,8 @@ export class Supervisor {
123
129
  static EMPTY_TURN_SETTLE_MS = 25_000;
124
130
  /** The window actually used — the constant, or a test's own shorter one. */
125
131
  emptyTurnSettleMs;
132
+ /** The compaction net actually used — the constant, or a test's own. */
133
+ compactionWatchdogMs;
126
134
  rateLimitsResendMs;
127
135
  /** The stop-settle window actually used — the constant, or a test's own. */
128
136
  stopSettleMs;
@@ -177,6 +185,22 @@ export class Supervisor {
177
185
  restarting = false;
178
186
  /** A restore-point collection is running; a second reconnect must not start another (#388). */
179
187
  checkpointGcInFlight = false;
188
+ /**
189
+ * Sessions the current `hello_ack` names, while `reconcile` is walking them
190
+ * (#392).
191
+ *
192
+ * The unacked replay at the top of `reconcile` sends every journal's
193
+ * leftovers before a single session is registered, and the acks come back
194
+ * while the loop is still awaiting an earlier session's worktree. The ack
195
+ * handler deletes the journal of a session it does not know once nothing is
196
+ * unacked — which used to be exactly the journal the loop was about to
197
+ * restore from: its open cards, its queued messages, its anchor, gone a
198
+ * moment before they were read. Membership here is what «this session is
199
+ * known» means until the loop reaches it.
200
+ */
201
+ reconciling = new Map();
202
+ /** How long a dashboard-requested compaction may go unreported before the session is handed back. */
203
+ static COMPACTION_WATCHDOG_MS = 10 * 60_000;
180
204
  /** Session 14: one project-recipe run per machine, and its verdict queue. */
181
205
  verify;
182
206
  verifyReports = new VerifyReportQueue();
@@ -185,6 +209,7 @@ export class Supervisor {
185
209
  this.opts = opts;
186
210
  this.journals = opts.journals ?? new JournalStore();
187
211
  this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
212
+ this.compactionWatchdogMs = opts.compactionWatchdogMs ?? Supervisor.COMPACTION_WATCHDOG_MS;
188
213
  this.rateLimitsResendMs = opts.rateLimitsResendMs ?? RATE_LIMITS_RESEND_INTERVAL_MS;
189
214
  this.stopSettleMs = opts.stopSettleMs ?? Supervisor.STOP_SETTLE_MS;
190
215
  this.verify = new VerifyRunner({
@@ -1740,7 +1765,11 @@ export class Supervisor {
1740
1765
  const policyChanged = frame.agentPushBan !== undefined ||
1741
1766
  frame.agentProtectedBranches !== undefined ||
1742
1767
  frame.agentAllowForcePush !== undefined ||
1743
- frame.agentAllowDestructiveGit !== undefined;
1768
+ frame.agentAllowDestructiveGit !== undefined ||
1769
+ // #418 travels in the same frame and the same object: it is read on
1770
+ // every file tool call, so taking the permission away has to reach
1771
+ // a running agent without stopping it.
1772
+ frame.agentAllowOutsideFolder !== undefined;
1744
1773
  if (policyChanged) {
1745
1774
  const next = {
1746
1775
  ...(frame.agentPushBan !== undefined ? { agentPushBan: frame.agentPushBan } : {}),
@@ -1753,6 +1782,9 @@ export class Supervisor {
1753
1782
  ...(frame.agentAllowDestructiveGit !== undefined
1754
1783
  ? { agentAllowDestructiveGit: frame.agentAllowDestructiveGit }
1755
1784
  : {}),
1785
+ ...(frame.agentAllowOutsideFolder !== undefined
1786
+ ? { agentAllowOutsideFolder: frame.agentAllowOutsideFolder }
1787
+ : {}),
1756
1788
  };
1757
1789
  Object.assign(running.descriptor.workspace, next);
1758
1790
  }
@@ -1811,7 +1843,8 @@ export class Supervisor {
1811
1843
  const journal = this.journals.open(frame.sessionId);
1812
1844
  journal.ack(frame.seq); // nack retires the seq too — it is permanent
1813
1845
  const running = this.sessions.get(frame.sessionId);
1814
- if (!running && journal.unacked().length === 0) {
1846
+ // Not while a reconnect is still about to restore it — see `reconciling`.
1847
+ if (!running && !this.reconciling.has(frame.sessionId) && journal.unacked().length === 0) {
1815
1848
  this.journals.closeAndDelete(frame.sessionId);
1816
1849
  }
1817
1850
  break;
@@ -1824,7 +1857,15 @@ export class Supervisor {
1824
1857
  }
1825
1858
  }
1826
1859
  // ─── Lifecycle ─────────────────────────────────────────────────────
1827
- async startSession(descriptor) {
1860
+ async startSession(descriptor,
1861
+ /**
1862
+ * What to tell a person about a card a previous life of this session left
1863
+ * open (#392): `runner_restarted` from the reconnect, `not_held` from an
1864
+ * ordinary `session_start` — a «Continue» after an ending the runner never
1865
+ * got to write down, where the honest fact is only that no process holds
1866
+ * the ask any more.
1867
+ */
1868
+ withdrawReason = 'not_held') {
1828
1869
  const existing = this.sessions.get(descriptor.id);
1829
1870
  if (existing) {
1830
1871
  // A resume (higher epoch) can land while the previous life is still
@@ -1920,6 +1961,11 @@ export class Supervisor {
1920
1961
  // exist (session 9).
1921
1962
  running.pendingMessages.push(...running.journal.pending());
1922
1963
  this.sessions.set(descriptor.id, running);
1964
+ // A card a previous life of this session left open is dead now: whatever
1965
+ // starts here is a new process, and the process that asked is gone (#392).
1966
+ // A first start finds nothing; a resume after an ungraceful ending finds
1967
+ // the cards the restore branch would otherwise have closed.
1968
+ this.withdrawJournaledQuestions(running, withdrawReason);
1923
1969
  /**
1924
1970
  * Messages that arrived for a session this runner did not know yet.
1925
1971
  *
@@ -2206,6 +2252,8 @@ export class Supervisor {
2206
2252
  delete running.stopCycle;
2207
2253
  running.liveToolUses.clear();
2208
2254
  running.openPermissions.clear();
2255
+ // …and a compaction the previous process never reported the end of (#348).
2256
+ this.endCompaction(running, 'Compaction ended with the agent process.', 'warn');
2209
2257
  try {
2210
2258
  const agentPrompt = this.resolveAgentPrompt(running, resuming && !rewinding);
2211
2259
  const workspaceContext = composeWorkspaceContext(descriptor, agentPrompt?.text);
@@ -2657,6 +2705,18 @@ export class Supervisor {
2657
2705
  // Both sets describe THIS process and nothing else (#373).
2658
2706
  running.liveToolUses.clear();
2659
2707
  running.openPermissions.clear();
2708
+ // So does a compaction it was in the middle of (#348). Guarded like the
2709
+ // withdrawal below: a journal that cannot be written must not take the
2710
+ // exit path with it.
2711
+ try {
2712
+ this.endCompaction(running, 'Compaction ended with the agent process.', 'warn');
2713
+ }
2714
+ catch (error) {
2715
+ log.warn('supervisor: could not close the compaction', {
2716
+ sessionId: descriptor.id,
2717
+ error: String(error),
2718
+ });
2719
+ }
2660
2720
  try {
2661
2721
  this.clearApiRetry(running);
2662
2722
  this.withdrawOpenQuestions(running, 'session_stopped');
@@ -2885,6 +2945,37 @@ export class Supervisor {
2885
2945
  });
2886
2946
  }
2887
2947
  }
2948
+ /**
2949
+ * Close out the asks a PREVIOUS process of this runner left open (#392).
2950
+ *
2951
+ * `withdrawOpenQuestions` above reads `running.openQuestions`, and that set
2952
+ * is memory: a process that died without reaching `shutdown()` — killed,
2953
+ * crashed, machine rebooted — took it along, and the restore used to create
2954
+ * a fresh empty set and write only «Runner reconnected». The card in the
2955
+ * browser is closed by one thing, a `question_resolved` with its askId in
2956
+ * the feed, so it stayed answerable for ever, and everything typed into it
2957
+ * was lost (the 07.09.2026 session in the ticket: six restarts, zero
2958
+ * tombstones).
2959
+ *
2960
+ * The journal is the set that survived: `SessionJournal.append` notes every
2961
+ * card this runner published and every resolution it published, whoever
2962
+ * wrote it. That is also the «already closed» check the plan asks to name:
2963
+ * a graceful `shutdown()` sends its `question_resolved` through the same
2964
+ * `append`, so by the time a restore runs, a card closed that way is no
2965
+ * longer in the set — one tombstone per card, never two.
2966
+ */
2967
+ withdrawJournaledQuestions(running, reason) {
2968
+ for (const askId of running.journal.openAskIds()) {
2969
+ running.openQuestions.delete(askId);
2970
+ // `sendEvent` → `journal.append` marks the card closed as a side effect.
2971
+ this.sendEvent(running, 'question_resolved', {
2972
+ askId,
2973
+ outcome: 'invalidated',
2974
+ source: 'runner',
2975
+ reason,
2976
+ });
2977
+ }
2978
+ }
2888
2979
  /**
2889
2980
  * Deliver messages that were held because every slot was taken.
2890
2981
  *
@@ -3085,6 +3176,11 @@ export class Supervisor {
3085
3176
  // #366: the ring at rest must be exact – the value the gate held back
3086
3177
  // during the turn goes out first, so it is older than `turn_end` by seq.
3087
3178
  this.flushHeldContextUsage(running);
3179
+ // A turn ending is the agent speaking; the compaction net is for silence —
3180
+ // and on Claude the turn IS the compaction, so a compaction still open here
3181
+ // ended with it, whatever the adapter managed to say (a cancel before the
3182
+ // CLI's first «compacting» status, a status stream that lost the end).
3183
+ this.endCompaction(running, event.aborted ? 'Compaction cancelled.' : 'Compaction ended with the turn.');
3088
3184
  this.sendEvent(running, 'turn_end', {
3089
3185
  ok: event.ok,
3090
3186
  errorMessage: event.errorMessage,
@@ -3241,6 +3337,73 @@ export class Supervisor {
3241
3337
  clearTimeout(running.emptyTurnTimer);
3242
3338
  running.emptyTurnTimer = undefined;
3243
3339
  }
3340
+ /**
3341
+ * A compaction is over for a reason other than the agent saying so (#348):
3342
+ * the turn it rode on ended, the process died, a new process was launched.
3343
+ * The feed gets its closing line so the pair is complete, and the runner's
3344
+ * own state is dropped so the NEXT compaction opens a new pair — left set,
3345
+ * every later start would be read as a repeat and written nowhere (QA S4).
3346
+ */
3347
+ endCompaction(running, text, level = 'info') {
3348
+ this.clearCompactionWatchdog(running);
3349
+ if (!running.compaction)
3350
+ return;
3351
+ delete running.compaction;
3352
+ this.sendEvent(running, 'system_note', {
3353
+ code: level === 'warn' ? 'compaction_failed' : 'compaction_finished',
3354
+ ...(level === 'warn' ? { level } : {}),
3355
+ text,
3356
+ });
3357
+ }
3358
+ /** A compaction has begun: say so in the feed, once, and remember who started it (#348). */
3359
+ openCompaction(running, source) {
3360
+ running.compaction = { source };
3361
+ this.sendEvent(running, 'system_note', {
3362
+ code: 'compaction_started',
3363
+ source,
3364
+ text: 'Compacting the conversation…',
3365
+ });
3366
+ }
3367
+ /**
3368
+ * The net under a dashboard-requested compaction (#348) — see
3369
+ * `RunningSession.compactionWatchdog`. Fires only into a session that has
3370
+ * said nothing since: every agent event and every ending clears it.
3371
+ */
3372
+ armCompactionWatchdog(running) {
3373
+ this.clearCompactionWatchdog(running);
3374
+ const timer = setTimeout(() => {
3375
+ delete running.compactionWatchdog;
3376
+ if (this.isStale(running))
3377
+ return;
3378
+ if (running.lastReported !== 'RUNNING' || running.stopRequested || running.budgetSpent) {
3379
+ return;
3380
+ }
3381
+ // Not over a card the agent is parked on (the compaction is queued
3382
+ // behind it and will run once the person answers), and not over a turn
3383
+ // that demonstrably has tools in flight: both are an agent that is
3384
+ // busy, not one that fell silent.
3385
+ if (running.openQuestions.size > 0 || running.liveToolUses.size > 0)
3386
+ return;
3387
+ log.warn('supervisor: no end of the compaction was reported — handing the session back', {
3388
+ sessionId: running.descriptor.id,
3389
+ });
3390
+ delete running.compaction;
3391
+ this.sendEvent(running, 'system_note', {
3392
+ code: 'compaction_failed',
3393
+ level: 'warn',
3394
+ text: 'The agent did not report the end of the compaction in ten minutes — the session is yours again.',
3395
+ });
3396
+ this.settleTurnStatus(running, running.descriptor, { ok: true });
3397
+ }, this.compactionWatchdogMs);
3398
+ timer.unref();
3399
+ running.compactionWatchdog = timer;
3400
+ }
3401
+ clearCompactionWatchdog(running) {
3402
+ if (!running.compactionWatchdog)
3403
+ return;
3404
+ clearTimeout(running.compactionWatchdog);
3405
+ delete running.compactionWatchdog;
3406
+ }
3244
3407
  /**
3245
3408
  * Record how many subagents are alive, and say so when it matters (#236).
3246
3409
  *
@@ -3258,16 +3421,35 @@ export class Supervisor {
3258
3421
  setBackgroundTasks(running, live) {
3259
3422
  if (live === running.backgroundTasks)
3260
3423
  return;
3261
- const finished = running.backgroundTasks > 0 && live === 0;
3262
3424
  running.backgroundTasks = live;
3263
3425
  const resting = running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW'
3264
3426
  ? running.lastReported
3265
3427
  : null;
3266
- if (finished && resting) {
3428
+ if (resting) {
3429
+ // On reaching zero this is the report that makes the session the
3430
+ // person's (#236); on any other change it is the same frame the
3431
+ // five-minute heartbeat would send later, sent now — so the number the
3432
+ // API holds is the number that is true, whenever a restart reads it.
3267
3433
  this.reportStatus(running.descriptor.id, resting, {
3268
3434
  costUsd: running.costUsd,
3269
3435
  activeMs: Supervisor.spentMs(running),
3270
3436
  });
3437
+ return;
3438
+ }
3439
+ /**
3440
+ * Mid-turn, the number moves with the frame that carries it (#393).
3441
+ *
3442
+ * The API's copy of the count is written only from `session_status`, and
3443
+ * until now a change during a turn sent none: subagents that finished (or
3444
+ * were spawned) between two status frames left the row holding the count
3445
+ * of the frame before. The row is what a restarting runner is told, and
3446
+ * «the restart stopped 2 background agents» about two agents that had
3447
+ * already reported — or silence about eight that had not — is the exact
3448
+ * lie #393 is about. A same-status frame is legal and moves nothing on the
3449
+ * API but the count and its stamp.
3450
+ */
3451
+ if (running.lastReported === 'RUNNING') {
3452
+ this.reportStatus(running.descriptor.id, 'RUNNING', {});
3271
3453
  }
3272
3454
  }
3273
3455
  isParkable(running) {
@@ -3326,8 +3508,9 @@ export class Supervisor {
3326
3508
  * It happens for real: a background subagent's report wakes a new turn inside
3327
3509
  * the agent process (SDK 0.3.226 emits a second `system:init` and a `result`
3328
3510
  * carrying `origin: {kind:'task-notification'}`), and nothing here reports a
3329
- * turn the runner did not start. Compaction does the same, and so does a
3330
- * question this runner withdraws by itself.
3511
+ * turn the runner did not start. So does a question this runner withdraws by
3512
+ * itself. (A compaction used to be the third case; `compact_context` reports
3513
+ * RUNNING itself now, and #348 gave the compaction lines of its own.)
3331
3514
  *
3332
3515
  * An open question is the one thing that must survive: there, WAITING_INPUT
3333
3516
  * means a tool call is parked on a person, and the agent narrating around its
@@ -3365,6 +3548,9 @@ export class Supervisor {
3365
3548
  // before the switch so every such case gets it, including the ones added
3366
3549
  // after this line was written.
3367
3550
  if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type)) {
3551
+ // A working agent is not a silent one: the compaction net is for the
3552
+ // session that hears nothing at all (#348).
3553
+ this.clearCompactionWatchdog(running);
3368
3554
  // Ticket #196. The same signal, read twice for opposite reasons: when the
3369
3555
  // session is free it proves the agent is working (#185), and when the
3370
3556
  // session is held under a clock it proves something started that should
@@ -3789,6 +3975,69 @@ export class Supervisor {
3789
3975
  this.sendEvent(running, 'notice', { level: event.level, text: event.text });
3790
3976
  return;
3791
3977
  }
3978
+ case 'compaction': {
3979
+ /**
3980
+ * #348. A `system_note` with a code, never a `notice`: the de-dup
3981
+ * above is per session for life, so «Conversation compacted» reached
3982
+ * the feed once and every later compaction was three minutes of
3983
+ * silence and a bare `turn_end` (gotcha §148). The dashboard reads its
3984
+ * «compacting…» indicator off these lines — the last one open means a
3985
+ * compaction is under way, on any tab, after any reload — so every
3986
+ * compaction has to leave its pair.
3987
+ *
3988
+ * One pair per compaction: a start while one is open is the CLI
3989
+ * repeating itself (Claude re-says «compacting» every thirty seconds
3990
+ * while it waits on a precomputed summary), or the agent confirming a
3991
+ * compaction `compact_context` already announced. An end with no start
3992
+ * open is still written — a process that came up mid-compaction has
3993
+ * nothing else to say about it.
3994
+ */
3995
+ if (event.phase === 'started') {
3996
+ // The agent confirmed it is compacting: the net under a REQUESTED
3997
+ // compaction is for an agent that never did, and a confirmed one
3998
+ // reports its end through the adapter — or ends with its turn or
3999
+ // its process, both of which close the pair. Left armed, it would
4000
+ // hand back a session in the middle of a long, honest compaction.
4001
+ this.clearCompactionWatchdog(running);
4002
+ if (running.compaction)
4003
+ return;
4004
+ this.openCompaction(running, 'agent');
4005
+ return;
4006
+ }
4007
+ delete running.compaction;
4008
+ this.clearCompactionWatchdog(running);
4009
+ if (event.ok) {
4010
+ this.sendEvent(running, 'system_note', {
4011
+ code: 'compaction_finished',
4012
+ text: event.skipped ? 'Compaction skipped.' : 'Conversation compacted.',
4013
+ });
4014
+ }
4015
+ else {
4016
+ this.sendEvent(running, 'system_note', {
4017
+ code: 'compaction_failed',
4018
+ level: 'warn',
4019
+ text: `Compaction failed: ${event.error ?? 'unknown'}`,
4020
+ });
4021
+ }
4022
+ /**
4023
+ * Codex runs a compaction started from the dashboard outside any turn,
4024
+ * so no `turn_end` is coming to take the session off RUNNING — which
4025
+ * `compact_context` put it on, for good reasons (a WAITING_INPUT
4026
+ * session with no open question is parkable). Left alone it read
4027
+ * «Working» until the person's next message: the «eternal Working» of
4028
+ * the ticket. Settled here, directly and without the phantom-turn
4029
+ * hold: that hold exists for a turn that produced nothing, and this
4030
+ * is not a turn. A compaction inside a live turn (`standalone: false`)
4031
+ * is left to that turn's own ending.
4032
+ */
4033
+ if (event.standalone &&
4034
+ running.lastReported === 'RUNNING' &&
4035
+ !running.stopRequested &&
4036
+ !running.budgetSpent) {
4037
+ this.settleTurnStatus(running, descriptor, { ok: true });
4038
+ }
4039
+ return;
4040
+ }
3792
4041
  case 'tool':
3793
4042
  // #373: which calls are in flight, so a stop can tell its own tail from
3794
4043
  // work starting up. Kept here rather than in the adapters — both of them
@@ -3849,11 +4098,44 @@ export class Supervisor {
3849
4098
  this.sendEvent(running, 'message', { role: 'user', text: typed });
3850
4099
  return 'answered';
3851
4100
  }
3852
- running.openQuestions.delete(frame.askId);
4101
+ // Was the ask ours at all? A refusal for a card this process still holds
4102
+ // (the adapter turned the answer down but kept the ask parked) is not a
4103
+ // card nobody holds, and must not be announced as one: the rescued words
4104
+ // below reach the adapter as an ordinary message, which closes the ask in
4105
+ // its own honest words (QA S4 №14).
4106
+ const held = running.openQuestions.delete(frame.askId);
3853
4107
  // Remembered even though it was a miss: whatever happened to the ask, the
3854
4108
  // words below have now been published once, and a redelivery must not
3855
4109
  // publish them again.
3856
4110
  running.answeredAsks.add(frame.askId);
4111
+ /**
4112
+ * The card dies where the browser looks (#392).
4113
+ *
4114
+ * The grey note below is words; the only thing that closes a question card
4115
+ * in the feed is a `question_resolved` carrying its askId, and this exit
4116
+ * never sent one. So the card stayed answerable: the next press met the
4117
+ * API's «no longer open» for a day, and the day after that a fresh
4118
+ * attempt reached this branch again and printed the note again — a card
4119
+ * that could not die and published its own obituary once a day. This is
4120
+ * the one second the runner is CERTAIN, and it now writes it down.
4121
+ * `not_held` rather than a guess at why: a card from a previous life of
4122
+ * the process and an ask withdrawn a moment ago look the same from here.
4123
+ *
4124
+ * Unless the journal says the card is ALREADY closed: an answer queued
4125
+ * while the runner was away arrives right after a restore that has just
4126
+ * closed the same card with `runner_restarted`, and a second tombstone
4127
+ * would overwrite that honest reason with this vaguer one. The words are
4128
+ * still rescued below; only the card is left as it is.
4129
+ */
4130
+ const alreadyClosed = running.journal.askState(frame.askId) === 'closed';
4131
+ if (!alreadyClosed && !held) {
4132
+ this.sendEvent(running, 'question_resolved', {
4133
+ askId: frame.askId,
4134
+ outcome: 'invalidated',
4135
+ source: 'runner',
4136
+ reason: 'not_held',
4137
+ });
4138
+ }
3857
4139
  /**
3858
4140
  * The reply, whichever control the person used to give it (#401).
3859
4141
  *
@@ -3872,16 +4154,16 @@ export class Supervisor {
3872
4154
  * order the card puts them in.
3873
4155
  */
3874
4156
  const rescued = [typed, answersAsMessage(frame.answers ?? [])].filter(Boolean).join('\n');
3875
- this.sendEvent(running, 'system_note', {
3876
- text: rescued
3877
- ? 'That question is no longer open — sending your reply as an ordinary message instead.'
3878
- : 'That question is no longer open — the agent has already moved on.',
3879
- });
3880
4157
  // The words must not be eaten. This is the ordinary case after a runner
3881
4158
  // restart: the card in the browser outlived the process that asked, and
3882
4159
  // showing the user's own message in the feed while nothing receives it is
3883
4160
  // the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
4161
+ // The grey line explains where the words went; with nothing to rescue the
4162
+ // card's own «withdrawn» line already says everything there is to say.
3884
4163
  if (rescued) {
4164
+ this.sendEvent(running, 'system_note', {
4165
+ text: 'That question is no longer open — sending your reply as an ordinary message instead.',
4166
+ });
3885
4167
  this.acceptUserMessage(frame.sessionId, rescued);
3886
4168
  return 'not_open';
3887
4169
  }
@@ -4302,6 +4584,9 @@ export class Supervisor {
4302
4584
  return;
4303
4585
  }
4304
4586
  settle();
4587
+ // A message handed to the agent starts a turn of its own: whatever the
4588
+ // compaction net was waiting for, this session is no longer silent (#348).
4589
+ this.clearCompactionWatchdog(running);
4305
4590
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
4306
4591
  return;
4307
4592
  }
@@ -4939,6 +5224,8 @@ export class Supervisor {
4939
5224
  this.flushHeldContextUsage(running);
4940
5225
  // A phantom held from an earlier turn must not fire after this one closed.
4941
5226
  this.clearEmptyTurn(running);
5227
+ // As in `completeTurn`: a stopped turn takes its compaction with it (#348).
5228
+ this.endCompaction(running, 'Compaction cancelled.');
4942
5229
  this.sendEvent(running, 'turn_end', {
4943
5230
  ok: true,
4944
5231
  // A turn that finished on its own in the same instant is not «stopped».
@@ -5317,11 +5604,107 @@ export class Supervisor {
5317
5604
  this.publishSlots();
5318
5605
  }
5319
5606
  // ─── Reconciliation (hello_ack) ────────────────────────────────────
5607
+ /**
5608
+ * The terminal status a reconnect should replay for this session instead
5609
+ * of restoring it, or null (QA-96 F1).
5610
+ *
5611
+ * Only a terminal status from THIS life of the session. A resumed session
5612
+ * carries a higher epoch, and replaying the FAILED it was resumed from would
5613
+ * kill it again the moment the runner reconnects. A journal written by a
5614
+ * runner from before session 13 could hold a `DONE` — it is no longer a
5615
+ * status this runner may report, and replaying one would close the session
5616
+ * for the user. Drop it and let the session be picked back up like any
5617
+ * other. Pure: reads the journal and decides, so the pre-registration pass
5618
+ * and the loop answer the question the same way.
5619
+ */
5620
+ terminalStatusToReplay(descriptor) {
5621
+ if (!this.journals.exists(descriptor.id))
5622
+ return null;
5623
+ const last = this.journals.open(descriptor.id).lastStatus;
5624
+ if (last &&
5625
+ isTerminal(last.status) &&
5626
+ last.status !== 'DONE' &&
5627
+ (last.epoch ?? 0) >= descriptor.epoch) {
5628
+ return last;
5629
+ }
5630
+ return null;
5631
+ }
5632
+ /**
5633
+ * Build and register the entry for a session a runner restart interrupted,
5634
+ * and close the cards its dead process left open (#392).
5635
+ *
5636
+ * Synchronous on purpose, and called for EVERY such session before the
5637
+ * reconnect awaits anything: registered, the session is known (a message
5638
+ * for it waits in its queue behind the `starting` gate of #401 instead of
5639
+ * coming back as `session_unknown`), and its cards go dark first thing —
5640
+ * above the line about the restart, so a person reading down sees the card
5641
+ * close before they read why.
5642
+ */
5643
+ registerRestoredSession(descriptor) {
5644
+ const running = {
5645
+ descriptor,
5646
+ journal: this.journals.open(descriptor.id),
5647
+ session: null,
5648
+ lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
5649
+ costUsd: descriptor.costUsd,
5650
+ costBaseUsd: descriptor.costUsd,
5651
+ levels: freshLevels(),
5652
+ stopRequested: false,
5653
+ parkRequested: false,
5654
+ // Registered before the worktree await, so the gate has to be up
5655
+ // before it too (#401).
5656
+ starting: true,
5657
+ pendingMessages: [],
5658
+ // Seed from the API, not 0: a runner restart used to hand the session
5659
+ // a full fresh budget silently.
5660
+ activeMs: descriptor.activeMsBase,
5661
+ extraBudgetMinutes: descriptor.extraBudgetMinutes,
5662
+ epoch: descriptor.epoch,
5663
+ openQuestions: new Set(),
5664
+ openPermissions: new Set(),
5665
+ liveToolUses: new Set(),
5666
+ pauseEpoch: 0,
5667
+ processSeq: 0,
5668
+ answeredAsks: new Set(),
5669
+ deliveredMessageIds: new Set(),
5670
+ backgroundTasks: 0,
5671
+ ...pausedUntilOf(descriptor),
5672
+ mode: descriptor.mode,
5673
+ ...(descriptor.model ? { model: descriptor.model } : {}),
5674
+ ...(descriptor.effort ? { effort: descriptor.effort } : {}),
5675
+ lastPrompt: '',
5676
+ };
5677
+ running.journal.ensureSeqAbove(descriptor.lastSeq);
5678
+ // Anything the API handed us before the daemon stopped (session 9).
5679
+ running.pendingMessages.push(...running.journal.pending());
5680
+ this.sessions.set(descriptor.id, running);
5681
+ this.withdrawJournaledQuestions(running, 'runner_restarted');
5682
+ return running;
5683
+ }
5320
5684
  async reconcile(descriptors) {
5321
5685
  // Sessions the API no longer considers live (e.g. stopped from the
5322
5686
  // dashboard while this runner was offline) must not keep an agent process
5323
5687
  // running and holding the single runner slot (QA-99 MAJOR-2).
5324
5688
  const known = new Set(descriptors.map((d) => d.id));
5689
+ // Counted, not assigned: a second `hello_ack` can start while this one is
5690
+ // still walking, and whichever finishes first must not strip the other's
5691
+ // sessions of their protection.
5692
+ for (const id of known)
5693
+ this.reconciling.set(id, (this.reconciling.get(id) ?? 0) + 1);
5694
+ try {
5695
+ await this.reconcileKnown(descriptors, known);
5696
+ }
5697
+ finally {
5698
+ for (const id of known) {
5699
+ const left = (this.reconciling.get(id) ?? 1) - 1;
5700
+ if (left <= 0)
5701
+ this.reconciling.delete(id);
5702
+ else
5703
+ this.reconciling.set(id, left);
5704
+ }
5705
+ }
5706
+ }
5707
+ async reconcileKnown(descriptors, known) {
5325
5708
  // Ticket #126: this list is the only moment the runner learns which
5326
5709
  // sessions still exist. Deleting a session while its dev server is
5327
5710
  // switched off leaves restore points — a full copy of a working tree —
@@ -5388,6 +5771,41 @@ export class Supervisor {
5388
5771
  });
5389
5772
  }
5390
5773
  }
5774
+ /**
5775
+ * Every session this pass is going to restore is registered NOW, before
5776
+ * the first await (#392, QA S4).
5777
+ *
5778
+ * The loop below awaits per session — a pause, a worktree, a launch — and
5779
+ * anything that arrives for a LATER session meanwhile (the API flushes its
5780
+ * outbox right after `hello_ack`) used to meet a runner that did not know
5781
+ * it: `session_unknown`, a `session_start` in reply, and `startSession`
5782
+ * restoring that session through the wrong door, with the wrong reason on
5783
+ * its cards and no line about the restart. Registered here, the session is
5784
+ * known and `starting`, so a message waits in its queue and the
5785
+ * descriptor re-sent for it is a no-op — and its cards are closed before
5786
+ * anybody can answer them.
5787
+ */
5788
+ const restoring = new Map();
5789
+ for (const descriptor of descriptors) {
5790
+ // The whole classification is per session, like the loop below (#225):
5791
+ // a journal that cannot be read throws on open, and that must cost this
5792
+ // session its restore and nothing else.
5793
+ try {
5794
+ if (this.sessions.has(descriptor.id))
5795
+ continue;
5796
+ if (this.terminalStatusToReplay(descriptor))
5797
+ continue;
5798
+ if (descriptor.status === 'STARTING' || !descriptor.providerSessionId)
5799
+ continue;
5800
+ restoring.set(descriptor.id, this.registerRestoredSession(descriptor));
5801
+ }
5802
+ catch (error) {
5803
+ log.error('supervisor: could not register a session for restore', {
5804
+ sessionId: descriptor.id,
5805
+ error: String(error),
5806
+ });
5807
+ }
5808
+ }
5391
5809
  for (const descriptor of descriptors) {
5392
5810
  // One session must never cost the others their restore (ticket #225).
5393
5811
  // Everything below runs per descriptor, and until this guard existed a
@@ -5396,9 +5814,10 @@ export class Supervisor {
5396
5814
  // unrestored, on every reconnect, with nothing said anywhere. The blast
5397
5815
  // radius of a broken session is now that session.
5398
5816
  try {
5817
+ const restored = restoring.get(descriptor.id);
5399
5818
  // Live local session: statuses are fire-and-forget on the wire, so a
5400
5819
  // status reached while the WS was down is re-reported here (QA-96 F1).
5401
- const tracked = this.sessions.get(descriptor.id);
5820
+ const tracked = restored ? undefined : this.sessions.get(descriptor.id);
5402
5821
  if (tracked) {
5403
5822
  // The session was resumed server-side while this runner was offline, and
5404
5823
  // the local copy is that same work. Adopt the new epoch BEFORE reporting:
@@ -5435,79 +5854,33 @@ export class Supervisor {
5435
5854
  // Session that went terminal while we were offline: the journal keeps
5436
5855
  // the last reported status — replay it instead of resurrecting the
5437
5856
  // session as resumable (QA-96 F1).
5438
- if (this.journals.exists(descriptor.id)) {
5439
- const journal = this.journals.open(descriptor.id);
5440
- const last = journal.lastStatus;
5441
- // Only replay a terminal status from THIS life of the session. A
5442
- // resumed session carries a higher epoch, and replaying the FAILED it
5443
- // was resumed from would kill it again the moment the runner reconnects.
5444
- // A journal written by a runner from before session 13 could hold a
5445
- // `DONE` — it is no longer a status this runner may report, and
5446
- // replaying one would close the session for the user. Drop it and let
5447
- // the session be picked back up like any other.
5448
- if (last &&
5449
- isTerminal(last.status) &&
5450
- last.status !== 'DONE' &&
5451
- (last.epoch ?? 0) >= descriptor.epoch) {
5452
- this.ws.send({
5453
- type: 'session_status',
5454
- sessionId: descriptor.id,
5455
- status: last.status,
5456
- ...(last.extra ?? {}),
5457
- // Guarded above to be >= the descriptor's epoch, so the API keeps it.
5458
- ...(last.epoch === undefined ? {} : { epoch: last.epoch }),
5459
- });
5460
- if (journal.unacked().length === 0) {
5461
- this.journals.closeAndDelete(descriptor.id);
5462
- }
5463
- continue;
5857
+ const last = restored ? null : this.terminalStatusToReplay(descriptor);
5858
+ if (last) {
5859
+ this.ws.send({
5860
+ type: 'session_status',
5861
+ sessionId: descriptor.id,
5862
+ status: last.status,
5863
+ ...(last.extra ?? {}),
5864
+ // Guarded in `terminalStatusToReplay` to be >= the descriptor's
5865
+ // epoch, so the API keeps it.
5866
+ ...(last.epoch === undefined ? {} : { epoch: last.epoch }),
5867
+ });
5868
+ if (this.journals.open(descriptor.id).unacked().length === 0) {
5869
+ this.journals.closeAndDelete(descriptor.id);
5464
5870
  }
5871
+ continue;
5465
5872
  }
5466
5873
  if (descriptor.status === 'STARTING') {
5467
- await this.startSession(descriptor);
5874
+ await this.startSession(descriptor, 'runner_restarted');
5468
5875
  }
5469
- else if (descriptor.providerSessionId) {
5876
+ else if (restored) {
5470
5877
  // Runner restarted mid-session. The provider session is resumable —
5471
- // park it until the user sends the next instruction. The map entry is
5472
- // registered BEFORE the worktree await so a racing message is
5473
- // buffered instead of dropped (QA-96 F3).
5474
- const running = {
5475
- descriptor,
5476
- journal: this.journals.open(descriptor.id),
5477
- session: null,
5478
- lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
5479
- costUsd: descriptor.costUsd,
5480
- costBaseUsd: descriptor.costUsd,
5481
- levels: freshLevels(),
5482
- stopRequested: false,
5483
- parkRequested: false,
5484
- // Registered before the worktree await, so the gate has to be up
5485
- // before it too (#401).
5486
- starting: true,
5487
- pendingMessages: [],
5488
- // Seed from the API, not 0: a runner restart used to hand the session
5489
- // a full fresh budget silently.
5490
- activeMs: descriptor.activeMsBase,
5491
- extraBudgetMinutes: descriptor.extraBudgetMinutes,
5492
- epoch: descriptor.epoch,
5493
- openQuestions: new Set(),
5494
- openPermissions: new Set(),
5495
- liveToolUses: new Set(),
5496
- pauseEpoch: 0,
5497
- processSeq: 0,
5498
- answeredAsks: new Set(),
5499
- deliveredMessageIds: new Set(),
5500
- backgroundTasks: 0,
5501
- ...pausedUntilOf(descriptor),
5502
- mode: descriptor.mode,
5503
- ...(descriptor.model ? { model: descriptor.model } : {}),
5504
- ...(descriptor.effort ? { effort: descriptor.effort } : {}),
5505
- lastPrompt: '',
5506
- };
5507
- running.journal.ensureSeqAbove(descriptor.lastSeq);
5508
- // Anything the API handed us before the daemon stopped (session 9).
5509
- running.pendingMessages.push(...running.journal.pending());
5510
- this.sessions.set(descriptor.id, running);
5878
+ // park it until the user sends the next instruction. The map entry
5879
+ // was registered before the first await of this pass (see
5880
+ // `restoring` above) so a racing message is buffered instead of
5881
+ // dropped (QA-96 F3), and the cards of the dead process are already
5882
+ // closed (#392).
5883
+ const running = restored;
5511
5884
  try {
5512
5885
  try {
5513
5886
  // A session being restored after a runner restart already has its
@@ -5560,12 +5933,42 @@ export class Supervisor {
5560
5933
  // from it. Only the instruction at the end changes — telling someone to
5561
5934
  // send a message while the agent is already working again would be a lie.
5562
5935
  this.sendEvent(running, 'system_note', {
5936
+ // The code is for the dashboard (#348): a compaction the dead
5937
+ // process was in the middle of ends here, and this line is the
5938
+ // only thing in the feed that says so.
5939
+ code: 'runner_reconnected',
5563
5940
  text: willContinue
5564
5941
  ? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
5565
5942
  : resumeId
5566
5943
  ? 'Runner reconnected. The session was resumed — send a message to continue.'
5567
5944
  : '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.',
5568
5945
  });
5946
+ /**
5947
+ * The subagents that died with the process (#393).
5948
+ *
5949
+ * «Four of twelve. Waiting.» followed by «Runner reconnected» reads
5950
+ * as «the other eight are still coming». They are not: a restart
5951
+ * kills the agent process and every subagent inside it, and
5952
+ * `--resume` brings back the conversation, never the work. One short
5953
+ * line with the number, and only when there is a number to give —
5954
+ * the API sends `backgroundTasks` only while it still believes the
5955
+ * count (its own trust window), and sends nothing when everyone had
5956
+ * reported before the process died.
5957
+ *
5958
+ * BEFORE `reportStatus` below, which carries this session's fresh
5959
+ * zero: once that frame lands, the number is gone everywhere. And
5960
+ * once per restart rather than per reconnect by construction — a
5961
+ * session this process already tracks never reaches this branch.
5962
+ * «Stopped», never «finished»: to a person waiting for a report the
5963
+ * two are opposites.
5964
+ */
5965
+ if (descriptor.backgroundTasks && descriptor.backgroundTasks > 0) {
5966
+ const n = descriptor.backgroundTasks;
5967
+ this.sendEvent(running, 'system_note', {
5968
+ code: 'background_tasks_lost',
5969
+ text: `The runner restart stopped ${n} background agent${n === 1 ? '' : 's'}.`,
5970
+ });
5971
+ }
5569
5972
  if (willContinue) {
5570
5973
  // Resumed through the PROVIDER session, so the agent keeps its whole
5571
5974
  // conversation; the prompt is only the nudge a human would otherwise
@@ -5640,7 +6043,7 @@ export class Supervisor {
5640
6043
  // A session that never had a turn (a free session still waiting for
5641
6044
  // its first message) has nothing to resume — just bring the agent back
5642
6045
  // up and keep waiting, instead of failing the session.
5643
- await this.startSession({ ...descriptor, status: 'STARTING', prompt: '' });
6046
+ await this.startSession({ ...descriptor, status: 'STARTING', prompt: '' }, 'runner_restarted');
5644
6047
  }
5645
6048
  else {
5646
6049
  this.reportStatus(descriptor.id, 'FAILED', {
@@ -6137,12 +6540,14 @@ export class Supervisor {
6137
6540
  error: 'The agent could not compact right now — wait for the current turn to finish',
6138
6541
  });
6139
6542
  }
6140
- // Ticket #185. Compaction is a whole agent turn — `/compact` goes in
6141
- // as an ordinary user message — and until now nobody said so: the
6142
- // session read «Ваш ход» for the entire squeeze, and the dashboard
6143
- // papered over it with `compactingSince`, a variable that exists only
6144
- // in the tab that pressed the button. A second tab, a teammate or a
6145
- // reload saw a session waiting for a person who had nothing to do.
6543
+ // Ticket #185. On Claude a compaction is a whole agent turn —
6544
+ // `/compact` goes in as an ordinary user message — and until 0.36
6545
+ // nobody said so: the session read «Ваш ход» for the entire squeeze.
6546
+ // The end of it is the turn's own ending there; on Codex, which runs
6547
+ // it outside any turn, the end is the adapter's `compaction` event
6548
+ // (#348, `forwardEvent`), and both agents now leave their lines in
6549
+ // the feed — the dashboard reads its indicator from those, not from
6550
+ // a variable of the tab that pressed the button.
6146
6551
  //
6147
6552
  // It is also not only cosmetic here: a session reading WAITING_INPUT
6148
6553
  // with no open question is parkable, and parking it would kill the
@@ -6152,6 +6557,16 @@ export class Supervisor {
6152
6557
  this.syncBudgetClock(running);
6153
6558
  this.reportStatus(running.descriptor.id, 'RUNNING', {});
6154
6559
  }
6560
+ // The start line comes from HERE, not from the agent (#348): the
6561
+ // agent's own «compacting» arrives when it gets round to it — on
6562
+ // Codex the `contextCompaction` item may only show up at the end —
6563
+ // and the moment a person can see the indicator is the moment the
6564
+ // request was taken, on every tab. The agent's later start is then
6565
+ // the same compaction (de-duplicated above). `user`, because that is
6566
+ // who asked: only this kind is offered a «Cancel».
6567
+ if (!running.compaction)
6568
+ this.openCompaction(running, 'user');
6569
+ this.armCompactionWatchdog(running);
6155
6570
  return void reply({ ok: true, result: { started: true } });
6156
6571
  }
6157
6572
  case 'git_status': {
@@ -7200,6 +7615,9 @@ export class Supervisor {
7200
7615
  // redelivered on the next connection (QA-106 M1).
7201
7616
  try {
7202
7617
  this.withdrawOpenQuestions(running, 'runner_restarted');
7618
+ // …and what only the journal still knows about (#392): a card from a
7619
+ // previous life this process inherited the file from but never held.
7620
+ this.withdrawJournaledQuestions(running, 'runner_restarted');
7203
7621
  }
7204
7622
  catch (error) {
7205
7623
  log.warn('supervisor: could not withdraw open questions on shutdown', {