@bridge4dev/runner 0.52.0 → 0.53.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.
@@ -8,7 +8,7 @@ import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryD
8
8
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
9
9
  import { agentPromptSizeLabel, inspectAgentPrompt, quotePath, readAgentPrompt, } from './agent-prompt.js';
10
10
  import { JournalStore } from './journal.js';
11
- import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, } from './git.js';
11
+ import { deleteSessionBranch, ensurePreviewWorktree, ensureSessionWorktree, prepareDirectWorkspace, previewWorktreePath, removePreviewWorktree, removeSessionWorktree, repoKeyFor, sessionWorktreePath, validateWorkspacePath, WorktreePrepareError, } from './git.js';
12
12
  import { readRecipeProposal } from './recipe.js';
13
13
  import { RECIPE_STEP_NAMES, parseProjectRecipe } from './recipe-schema.js';
14
14
  import { proposeCommitMessage } from './commit-message.js';
@@ -26,7 +26,7 @@ import { agentByDbValue } from './agent-registry.js';
26
26
  import { invalidateAgentVersions, measureAgentVersions, } from './agent-versions.js';
27
27
  import { rememberWorkspacePath } from './environment.js';
28
28
  import { composeMessageWithAttachments, saveAttachments, } from './attachments.js';
29
- import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, previewRewind, pruneCheckpoints, } from './checkpoints.js';
29
+ import { applyRewind, createCheckpoint, dropCheckpoints, listCheckpoints, MAX_BUSY_SESSIONS, previewRewind, pruneCheckpoints, } from './checkpoints.js';
30
30
  import { DeliverMessageArgsSchema, QuestionAnswerArgsSchema } from './protocol.js';
31
31
  import { availableModes, MODE_REFUSED_TEXT } from './adapters/types.js';
32
32
  /** Refusals shared by every checkpoint command (ticket #126). */
@@ -839,10 +839,7 @@ export class Supervisor {
839
839
  running.baseBranch = prepared.baseBranch;
840
840
  }
841
841
  catch (error) {
842
- this.reportStatus(descriptor.id, 'FAILED', {
843
- errorMessage: `Failed to prepare git worktree: ${maskSecretText(error)}`,
844
- });
845
- this.sessions.delete(descriptor.id);
842
+ this.workspacePrepareFailed(running, error, 'Failed to prepare git worktree');
846
843
  return;
847
844
  }
848
845
  // A session that is already past STARTING (re-sent because this runner
@@ -922,6 +919,50 @@ export class Supervisor {
922
919
  ...(descriptor.branchPlan ? { plan: descriptor.branchPlan } : {}),
923
920
  }));
924
921
  }
922
+ /**
923
+ * The working folder could not be built — say so in the feed, pin the fork
924
+ * point, and let the session go (#360).
925
+ *
926
+ * Both doors into `prepareWorkspace` end here. Three things happen in an
927
+ * order that is not free:
928
+ *
929
+ * 1. **The fork point is recorded first.** `git.ts` resolves it before it
930
+ * writes anything, precisely so that a failure still knows it. Without it
931
+ * the API keeps `base_sha = null` for the session's whole life, and
932
+ * «Continue» can never prove the leftover branch is the empty one this
933
+ * session created — which is the trap the ticket is about.
934
+ * 2. **The feed event goes out before the map entry is dropped**, because
935
+ * `sendEvent` needs it. Until now neither door sent one: a failed start
936
+ * was visible only as an `errorMessage` on the session row, and pressing
937
+ * «Continue» wipes that.
938
+ * 3. **Then the status, then the entry.** A FAILED session is over, and a
939
+ * leftover entry would hold one of the runner's few slots.
940
+ */
941
+ workspacePrepareFailed(running, error, prefix) {
942
+ const { descriptor } = running;
943
+ if (error instanceof WorktreePrepareError) {
944
+ if (error.baseSha && !running.baseSha)
945
+ running.baseSha = error.baseSha;
946
+ if (error.baseBranch && !running.baseBranch)
947
+ running.baseBranch = error.baseBranch;
948
+ }
949
+ const message = maskSecretText(error);
950
+ log.error('supervisor: the working folder could not be prepared', {
951
+ sessionId: descriptor.id,
952
+ workMode: descriptor.workMode ?? 'BRANCH',
953
+ code: error instanceof WorktreePrepareError ? error.code : 'unknown',
954
+ error: message,
955
+ });
956
+ this.sendEvent(running, 'error', {
957
+ message,
958
+ // Not an enum on either side (`RunnerEventPayloadSchema` is a record, and
959
+ // the dashboard carries whatever code it is given), so this stays a plain
960
+ // string and a new one costs nobody a deployment.
961
+ code: error instanceof WorktreePrepareError ? error.code : 'worktree_prepare_failed',
962
+ });
963
+ this.reportStatus(descriptor.id, 'FAILED', { errorMessage: `${prefix}: ${message}` });
964
+ this.sessions.delete(descriptor.id);
965
+ }
925
966
  /**
926
967
  * Spin the adapter up — for a fresh session, a resume-on-next-message, or a
927
968
  * free CHAT session with no prompt at all (the agent boots, reports its
@@ -1040,6 +1081,10 @@ export class Supervisor {
1040
1081
  // (`resolveGitPolicy` gives both the restrictive reading), and passing it
1041
1082
  // unconditionally keeps one code path instead of two.
1042
1083
  gitPolicy: gitPolicyOf(descriptor),
1084
+ // #361 п. 5: whether this session shares its folder with everybody else.
1085
+ // Rides down for the same reason as `gitPolicy` — it lands in
1086
+ // `PolicyContext` and it decides one line of the system prompt.
1087
+ ...(descriptor.workMode ? { workMode: descriptor.workMode } : {}),
1043
1088
  mode: running.mode,
1044
1089
  ...(running.model ? { model: running.model } : {}),
1045
1090
  ...(running.effort ? { effort: running.effort } : {}),
@@ -1726,7 +1771,8 @@ export class Supervisor {
1726
1771
  /**
1727
1772
  * Make room for one more agent process.
1728
1773
  *
1729
- * Up to `maxSessions` agents run side by side, each in its own worktree. Over
1774
+ * Up to `maxSessions` agents run side by side — since session 16 most of them
1775
+ * share the project folder rather than each having a worktree of its own. Over
1730
1776
  * that, idle-but-resumable sessions (REVIEW / WAITING_INPUT) are parked —
1731
1777
  * their provider session survives on disk and relaunches on the next message,
1732
1778
  * so parking costs context nothing. Only mid-turn sessions (RUNNING /
@@ -3106,17 +3152,42 @@ export class Supervisor {
3106
3152
  * Three reasons it declines, and each of them is a state in which a snapshot
3107
3153
  * would be a lie rather than a restore point:
3108
3154
  * - the machine's owner switched checkpoints off;
3109
- * - the agent is mid-turn, so the tree is being written to as we read it;
3155
+ * - THIS session is mid-turn, so its own tree is being written as we read it;
3110
3156
  * - a repo-mutating command holds the repository.
3157
+ *
3158
+ * «A neighbour in the same folder is working» used to be a fourth reason, and
3159
+ * it was the wrong one (#310): in DIRECT mode the folder is shared by design,
3160
+ * so that rule silently switched restore points off for everybody the moment
3161
+ * a second session opened. The neighbours are recorded on the point instead —
3162
+ * the conversation can always be rewound to it, the files cannot.
3163
+ *
3164
+ * Every refusal is now audible. A restore point that was never taken is
3165
+ * invisible until the day somebody reaches for it, and «the button is not
3166
+ * there» is not a sentence anybody can act on.
3111
3167
  */
3112
3168
  async captureCheckpoint(running, kind, messageSeq) {
3113
3169
  const worktreePath = running.worktreePath;
3114
- if (!worktreePath || this.opts.checkpointsEnabled === false)
3170
+ if (!worktreePath)
3115
3171
  return;
3116
- if (kind === 'TURN' && this.isWorktreeBusy(worktreePath))
3172
+ if (this.opts.checkpointsEnabled === false) {
3173
+ // Once per session: this is a machine-wide setting, not an event.
3174
+ if (!running.checkpointsOffSaid) {
3175
+ running.checkpointsOffSaid = true;
3176
+ this.sendEvent(running, 'notice', {
3177
+ level: 'info',
3178
+ text: 'Restore points are switched off on this dev server, so this session cannot be rewound. A server administrator turns them back on.',
3179
+ });
3180
+ }
3117
3181
  return;
3118
- if (await this.isRepoLocked(worktreePath))
3182
+ }
3183
+ if (kind === 'TURN' && this.isSessionMidTurn(running)) {
3184
+ this.noticeOncePerTurn(running, 'checkpoint-self-busy', 'No restore point was taken for this step: this session was still answering when it was due.');
3119
3185
  return;
3186
+ }
3187
+ if (await this.isRepoLocked(worktreePath)) {
3188
+ this.noticeOncePerTurn(running, 'checkpoint-repo-locked', 'No restore point was taken for this step: another git command holds this repository. It will be taken on the next one.');
3189
+ return;
3190
+ }
3120
3191
  // The anchor and the conversation it names travel TOGETHER: a point
3121
3192
  // recorded against an older provider session stays usable, because the
3122
3193
  // rewind resumes the session the point names rather than whichever one the
@@ -3128,6 +3199,10 @@ export class Supervisor {
3128
3199
  kind,
3129
3200
  ...(messageSeq === undefined ? {} : { messageSeq }),
3130
3201
  ...(anchor ? { agentAnchor: anchor.anchor, agentSession: anchor.agentSession } : {}),
3202
+ // A getter, not a value: taking the snapshot is not instantaneous, and
3203
+ // «who was working while it was taken» is a question about the whole of
3204
+ // that interval (#310).
3205
+ busySessions: () => this.busyNeighbours(running),
3131
3206
  });
3132
3207
  if (this.isStale(running))
3133
3208
  return;
@@ -3140,6 +3215,16 @@ export class Supervisor {
3140
3215
  text: 'This working tree is too large to take a restore point — rewind is unavailable for this step. Check that build output is in .gitignore.',
3141
3216
  });
3142
3217
  }
3218
+ else {
3219
+ // Not a repository, or git refused: every time, because unlike the two
3220
+ // conditions above this one does not clear itself.
3221
+ this.sendEvent(running, 'notice', {
3222
+ level: 'warn',
3223
+ text: result.reason === 'not-a-repo'
3224
+ ? 'No restore point could be taken: this folder is not a git repository, so there is nothing to rewind to.'
3225
+ : 'No restore point could be taken for this step, so it cannot be rewound. The session itself is unaffected.',
3226
+ });
3227
+ }
3143
3228
  return;
3144
3229
  }
3145
3230
  this.sendEvent(running, 'checkpoint', {
@@ -3150,8 +3235,36 @@ export class Supervisor {
3150
3235
  canRewindContext: Boolean(result.record.agentAnchor),
3151
3236
  ...(messageSeq === undefined ? {} : { messageSeq }),
3152
3237
  ...(result.skippedFiles.length ? { skippedFiles: result.skippedFiles.slice(0, 20) } : {}),
3238
+ // Only when non-empty (#310, gotcha 433): on this path the DASHBOARD
3239
+ // reads an empty list as «the folder was quiet», which is the opposite
3240
+ // convention to the rewind preview, where the presence of the key is the
3241
+ // warning. Two paths, two conventions, both deliberate.
3242
+ ...(result.record.busySessions?.length
3243
+ ? { busySessions: result.record.busySessions.slice(0, MAX_BUSY_SESSIONS) }
3244
+ : {}),
3153
3245
  });
3154
3246
  }
3247
+ /**
3248
+ * Say something once per BUSY PERIOD, not once per message (#310).
3249
+ *
3250
+ * A folder held by a neighbour stays held for minutes, and a session mid-turn
3251
+ * can be sent three follow-up notes inside one answer. Keying this on the
3252
+ * message seq would have counted each of those as its own turn and said the
3253
+ * same sentence three times — the noise the frequency policy exists to
3254
+ * prevent. The set is cleared when the session next comes to rest
3255
+ * (`reportStatus`), which is exactly when the reason stops being true.
3256
+ *
3257
+ * A SET of keys, not the last one said: two different reasons can both come
3258
+ * up inside one period, and remembering only the most recent would let them
3259
+ * take turns re-announcing each other.
3260
+ */
3261
+ noticeOncePerTurn(running, key, text) {
3262
+ running.noticesThisTurn ??= new Set();
3263
+ if (running.noticesThisTurn.has(key))
3264
+ return;
3265
+ running.noticesThisTurn.add(key);
3266
+ this.sendEvent(running, 'notice', { level: 'info', text });
3267
+ }
3155
3268
  /**
3156
3269
  * Deliver messages that raced session start (already journaled).
3157
3270
  *
@@ -3225,7 +3338,7 @@ export class Supervisor {
3225
3338
  return;
3226
3339
  }
3227
3340
  const open = running.stopCycle;
3228
- if (open && ownsProcess(open, running.session) && !open.settled) {
3341
+ if (open && Supervisor.stopInFlight(running)) {
3229
3342
  // #357 part 2, and invariant 2 of the pause plan. Ten frames arrive while
3230
3343
  // the CLI is waiting out a provider retry — from a phone and a laptop at
3231
3344
  // once, or from a person pressing a button that gave them no sign it had
@@ -3624,7 +3737,7 @@ export class Supervisor {
3624
3737
  // could not tell a tool RESULT from a tool starting, so the tail of the stop
3625
3738
  // read as a turn beginning.
3626
3739
  const cycle = running.stopCycle;
3627
- if (cycle && ownsProcess(cycle, running.session) && !cycle.settled) {
3740
+ if (cycle && Supervisor.stopInFlight(running)) {
3628
3741
  log.info('supervisor: output under a pause belongs to the stop in flight', {
3629
3742
  sessionId: running.descriptor.id,
3630
3743
  phase: cycle.phase,
@@ -3746,12 +3859,17 @@ export class Supervisor {
3746
3859
  }
3747
3860
  await this.applyLiveSettings(running, model, mode, effort);
3748
3861
  }
3749
- /** Is a turn (or a question the agent is parked on) in flight right now? */
3862
+ /**
3863
+ * Is a turn (or a question the agent is parked on) in flight right now?
3864
+ *
3865
+ * The same status list as `holdsTheTree` plus one term: an open question
3866
+ * parks the session without any of those statuses, and a live settings change
3867
+ * must not land under it. Reading the shared array rather than spelling the
3868
+ * statuses out again is the whole point of Р8 — a fourth waiting status has
3869
+ * to arrive in one place, not four.
3870
+ */
3750
3871
  isMidTurn(running) {
3751
- return (running.lastReported === 'RUNNING' ||
3752
- running.lastReported === 'STARTING' ||
3753
- running.lastReported === 'WAITING_PERMISSION' ||
3754
- running.openQuestions.size > 0);
3872
+ return (Supervisor.MID_TURN_STATUSES.includes(running.lastReported) || running.openQuestions.size > 0);
3755
3873
  }
3756
3874
  /** The three live setters, in the order that lets an explicit pick win. */
3757
3875
  async applyLiveSettings(running, model, mode, effort) {
@@ -4096,10 +4214,12 @@ export class Supervisor {
4096
4214
  running.baseBranch = prepared.baseBranch;
4097
4215
  }
4098
4216
  catch (error) {
4099
- this.reportStatus(descriptor.id, 'FAILED', {
4100
- errorMessage: `Failed to restore session worktree: ${String(error instanceof Error ? error.message : error).slice(0, 500)}`,
4101
- });
4102
- this.sessions.delete(descriptor.id);
4217
+ // The same treatment as the start door (#360). Two doors doing one
4218
+ // thing is how the pause bug of #373 stayed half-fixed for a
4219
+ // release; this one used to mask its text differently AND emit no
4220
+ // feed event at all, so a session that could not be restored went
4221
+ // FAILED with nothing to read anywhere.
4222
+ this.workspacePrepareFailed(running, error, 'Failed to restore session worktree');
4103
4223
  continue;
4104
4224
  }
4105
4225
  /**
@@ -4253,7 +4373,24 @@ export class Supervisor {
4253
4373
  }
4254
4374
  // ─── Commands ──────────────────────────────────────────────────────
4255
4375
  async runCommand(frame) {
4256
- const reply = (result) => this.ws.send({ type: 'command_result', requestId: frame.requestId, ...result });
4376
+ // How long a command actually took on THIS machine. Nothing measured it
4377
+ // before, which is why «the branch probe timed out after 4 s» could never be
4378
+ // answered with «it takes 9 s on that server» or «it never arrived» — the
4379
+ // two failures #316 turned out to be. One line, at INFO, per command.
4380
+ const startedAt = Date.now();
4381
+ let answered = false;
4382
+ const reply = (result) => {
4383
+ if (!answered) {
4384
+ answered = true;
4385
+ log.info('supervisor: command handled', {
4386
+ command: frame.name,
4387
+ ...(frame.sessionId ? { sessionId: frame.sessionId } : {}),
4388
+ elapsedMs: Date.now() - startedAt,
4389
+ ok: result.ok,
4390
+ });
4391
+ }
4392
+ this.ws.send({ type: 'command_result', requestId: frame.requestId, ...result });
4393
+ };
4257
4394
  try {
4258
4395
  switch (frame.name) {
4259
4396
  case 'validate_path': {
@@ -4446,7 +4583,9 @@ export class Supervisor {
4446
4583
  if (!running.worktreePath) {
4447
4584
  return void reply({ ok: false, error: 'The session has no working folder yet' });
4448
4585
  }
4449
- if (this.isWorktreeBusy(running.worktreePath)) {
4586
+ // #310: this session's own turn, not the folder's. A neighbour
4587
+ // working next door marks the point; it does not forbid it.
4588
+ if (this.isSessionMidTurn(running)) {
4450
4589
  return void reply({ ok: false, error: AGENT_BUSY });
4451
4590
  }
4452
4591
  const before = (await listCheckpoints(running.worktreePath, running.descriptor.id))
@@ -4477,6 +4616,12 @@ export class Supervisor {
4477
4616
  // conversation is still on disk.
4478
4617
  canRewindContext: Boolean(item.agentAnchor),
4479
4618
  ...(item.messageSeq === undefined ? {} : { messageSeq: item.messageSeq }),
4619
+ // The mark travels with the LIST as well as with the live event
4620
+ // (#310): the list is how a reloaded page rebuilds state, and a
4621
+ // mark that only ever rode the event would be lost there.
4622
+ ...(item.busySessions?.length
4623
+ ? { busySessions: item.busySessions.slice(0, MAX_BUSY_SESSIONS) }
4624
+ : {}),
4480
4625
  })),
4481
4626
  },
4482
4627
  });
@@ -4507,8 +4652,18 @@ export class Supervisor {
4507
4652
  if (ordinal === null || !running.worktreePath) {
4508
4653
  return void reply({ ok: false, error: 'ordinal is required' });
4509
4654
  }
4655
+ // The folder check STAYS here: this one writes files, and files are
4656
+ // shared. What changes is the sentence — `AGENT_BUSY` talks about
4657
+ // «the agent», meaning this session's own, and names nobody. When it
4658
+ // is a neighbour holding the folder, say so (#310).
4510
4659
  if (this.isWorktreeBusy(running.worktreePath)) {
4511
- return void reply({ ok: false, error: AGENT_BUSY });
4660
+ const neighbours = this.busyNeighbours(running);
4661
+ return void reply({
4662
+ ok: false,
4663
+ error: neighbours.length > 0
4664
+ ? `Another session in this folder is still working (${neighbours.join(', ')}), so the files cannot be put back until it stops. Only the conversation can be rewound.`
4665
+ : AGENT_BUSY,
4666
+ });
4512
4667
  }
4513
4668
  const confirmRaw = frame.args?.['confirmDeletes'];
4514
4669
  const confirmDeletes = Array.isArray(confirmRaw)
@@ -4522,6 +4677,10 @@ export class Supervisor {
4522
4677
  sessionId: running.descriptor.id,
4523
4678
  ordinal,
4524
4679
  confirmDeletes,
4680
+ // The safety point is a restore point like any other and gets the
4681
+ // same question asked of it. Non-empty only through the #373
4682
+ // window — the folder check above has already passed.
4683
+ busySessions: () => this.busyNeighbours(running),
4525
4684
  ...(typeof expectedTreeOid === 'string' && expectedTreeOid
4526
4685
  ? { expectedTreeOid }
4527
4686
  : {}),
@@ -4532,12 +4691,20 @@ export class Supervisor {
4532
4691
  createdAt: result.safety.createdAt,
4533
4692
  fileCount: result.safety.fileCount,
4534
4693
  canRewindContext: Boolean(result.safety.agentAnchor),
4694
+ ...(result.safety.busySessions?.length
4695
+ ? { busySessions: result.safety.busySessions.slice(0, MAX_BUSY_SESSIONS) }
4696
+ : {}),
4535
4697
  // This frame IS the browser's offer of «Undo rewind» — the point it
4536
4698
  // names belongs to no message, so there is nowhere else the offer
4537
4699
  // could come from. Withheld when the rewind was itself an undo:
4538
4700
  // going back again is a redo, and a button that quietly changes
4539
4701
  // meaning after one press is worse than no button.
4540
- undoable: result.rewoundToKind !== 'SAFETY',
4702
+ //
4703
+ // Also withheld when the safety point itself came out marked (#310).
4704
+ // That happens through the #373 window — a neighbour woke between
4705
+ // the folder check and the snapshot — and `applyRewind` refuses a
4706
+ // marked point, so the offer would be a button that cannot work.
4707
+ undoable: result.rewoundToKind !== 'SAFETY' && !(result.safety.busySessions?.length ?? 0),
4541
4708
  });
4542
4709
  this.sendEvent(running, 'system_note', {
4543
4710
  code: 'files_rewound',
@@ -4553,7 +4720,11 @@ export class Supervisor {
4553
4720
  if (ordinal === null || !running.worktreePath) {
4554
4721
  return void reply({ ok: false, error: 'ordinal is required' });
4555
4722
  }
4556
- if (this.isWorktreeBusy(running.worktreePath)) {
4723
+ // #310: the conversation can be rewound whatever the neighbours are
4724
+ // doing — it touches no file of theirs. This session's OWN unfinished
4725
+ // turn still has to stop first: the rewind parks and relaunches this
4726
+ // very process, and the button is hidden mid-turn anyway.
4727
+ if (this.isSessionMidTurn(running)) {
4557
4728
  return void reply({ ok: false, error: AGENT_BUSY });
4558
4729
  }
4559
4730
  const items = await listCheckpoints(running.worktreePath, running.descriptor.id);
@@ -5457,19 +5628,90 @@ export class Supervisor {
5457
5628
  return 'sessionId is required';
5458
5629
  return this.sessions.get(sessionId) ?? 'Unknown session';
5459
5630
  }
5631
+ /**
5632
+ * The statuses in which a session is holding its working tree (#310).
5633
+ *
5634
+ * One array, read by all three predicates below. There used to be one
5635
+ * literal, then two would have been needed, and a third would have been
5636
+ * written the day somebody added a waiting state — which is how «the agent
5637
+ * is busy» and «the folder is busy» come to disagree about what busy means.
5638
+ * (`rewindActionsLive` in the dashboard is a fourth reader and a deliberate
5639
+ * mirror: the runner does not depend on `@devbridge/shared`.)
5640
+ */
5641
+ static MID_TURN_STATUSES = [
5642
+ 'STARTING',
5643
+ 'RUNNING',
5644
+ 'WAITING_PERMISSION',
5645
+ ];
5646
+ /**
5647
+ * Is a stop still in flight over this session's live process?
5648
+ *
5649
+ * Three terms, written out in three places before this: an open cycle, that
5650
+ * cycle owning the process that is running NOW, and the cycle not yet
5651
+ * settled. #373 is what makes it load-bearing — the resting status is
5652
+ * published before the process dies, so this is the difference between «the
5653
+ * session is quiet» and «the session is still writing files».
5654
+ */
5655
+ static stopInFlight(running) {
5656
+ const cycle = running.stopCycle;
5657
+ return cycle !== undefined && ownsProcess(cycle, running.session) && !cycle.settled;
5658
+ }
5659
+ static holdsTheTree(running) {
5660
+ return Boolean(running.session) && Supervisor.MID_TURN_STATUSES.includes(running.lastReported);
5661
+ }
5460
5662
  /** A session actively mid-turn in this worktree — git writes must wait. */
5461
5663
  isWorktreeBusy(worktreePath) {
5462
5664
  for (const running of this.sessions.values()) {
5463
- if (running.worktreePath !== worktreePath || !running.session)
5665
+ if (running.worktreePath !== worktreePath)
5464
5666
  continue;
5465
- if (running.lastReported === 'STARTING' ||
5466
- running.lastReported === 'RUNNING' ||
5467
- running.lastReported === 'WAITING_PERMISSION') {
5667
+ if (Supervisor.holdsTheTree(running))
5468
5668
  return true;
5469
- }
5470
5669
  }
5471
5670
  return false;
5472
5671
  }
5672
+ /**
5673
+ * Is THIS session mid-turn? (#310)
5674
+ *
5675
+ * The question `isWorktreeBusy` was answering in three places where the right
5676
+ * question was this one. They are the same question only when a folder holds
5677
+ * exactly one session — and DIRECT mode, the default since session 16, is
5678
+ * precisely the arrangement in which it holds several. A restore point is a
5679
+ * snapshot of this session's own conversation; a neighbour typing in the same
5680
+ * folder is a reason to MARK it, not to refuse to take it.
5681
+ */
5682
+ isSessionMidTurn(running) {
5683
+ return Supervisor.holdsTheTree(running);
5684
+ }
5685
+ /**
5686
+ * Everybody else who is working in this folder right now (#310).
5687
+ *
5688
+ * Includes a neighbour whose stop is still running: after #373 the resting
5689
+ * status is published BEFORE the process is actually gone, so a session that
5690
+ * reports IDLE while its stop cycle still owns a live process is still
5691
+ * writing files. Reading `lastReported` alone would call that folder quiet.
5692
+ */
5693
+ busyNeighbours(running) {
5694
+ const worktreePath = running.worktreePath;
5695
+ if (!worktreePath)
5696
+ return [];
5697
+ const ids = [];
5698
+ for (const other of this.sessions.values()) {
5699
+ // By id as well as by identity: a session that was re-started under a
5700
+ // higher epoch is a NEW entry for the SAME session, and it must not end
5701
+ // up marking its own restore point with its own id.
5702
+ if (other === running ||
5703
+ other.descriptor.id === running.descriptor.id ||
5704
+ other.worktreePath !== worktreePath) {
5705
+ continue;
5706
+ }
5707
+ if (Supervisor.holdsTheTree(other) || Supervisor.stopInFlight(other)) {
5708
+ ids.push(other.descriptor.id);
5709
+ }
5710
+ if (ids.length >= MAX_BUSY_SESSIONS)
5711
+ break;
5712
+ }
5713
+ return ids;
5714
+ }
5473
5715
  // ─── Outbound helpers ──────────────────────────────────────────────
5474
5716
  // Hard cap far below the API's 128KB Zod limit — an oversized payload would
5475
5717
  // be rejected forever and wedge the journal (QA-96 F2).
@@ -5511,6 +5753,11 @@ export class Supervisor {
5511
5753
  const running = this.sessions.get(sessionId);
5512
5754
  if (running) {
5513
5755
  running.lastReported = status;
5756
+ // Coming to rest ends the busy period the once-per-turn notices were
5757
+ // limited to: the next one is about a new answer and deserves saying.
5758
+ if (!Supervisor.MID_TURN_STATUSES.includes(status)) {
5759
+ delete running.noticesThisTurn;
5760
+ }
5514
5761
  this.syncBudgetClock(running);
5515
5762
  }
5516
5763
  // The fork point rides along on the first frame after the branch was
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.52.0";
1
+ export declare const RUNNER_VERSION = "0.53.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.52.0';
2
+ export const RUNNER_VERSION = '0.53.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.52.0",
3
+ "version": "0.53.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",