@detergent-software/conductor 3.5.0-dev.9 → 3.5.1-dev.1

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +7 -4
  3. package/build/blocking.js +53 -0
  4. package/build/blocking.js.map +1 -0
  5. package/build/brief.js +9 -2
  6. package/build/brief.js.map +1 -1
  7. package/build/cli.js +12 -4
  8. package/build/cli.js.map +1 -1
  9. package/build/client.js +18 -8
  10. package/build/client.js.map +1 -1
  11. package/build/config.js +4 -0
  12. package/build/config.js.map +1 -1
  13. package/build/daemon.js +100 -26
  14. package/build/daemon.js.map +1 -1
  15. package/build/help.js +1 -1
  16. package/build/help.js.map +1 -1
  17. package/build/host/worktree-processes.js.map +1 -1
  18. package/build/inbox.js +74 -4
  19. package/build/inbox.js.map +1 -1
  20. package/build/loop-lag.js +97 -9
  21. package/build/loop-lag.js.map +1 -1
  22. package/build/manager.js +237 -48
  23. package/build/manager.js.map +1 -1
  24. package/build/notes.js +132 -21
  25. package/build/notes.js.map +1 -1
  26. package/build/plugin.js +9 -3
  27. package/build/plugin.js.map +1 -1
  28. package/build/profile-layers.js +2 -1
  29. package/build/profile-layers.js.map +1 -1
  30. package/build/run.js +11 -1
  31. package/build/run.js.map +1 -1
  32. package/build/scheduler.js +8 -2
  33. package/build/scheduler.js.map +1 -1
  34. package/build/server.js +1 -1
  35. package/build/server.js.map +1 -1
  36. package/build/store.js +6 -1
  37. package/build/store.js.map +1 -1
  38. package/build/watchers/linear-api.js +73 -13
  39. package/build/watchers/linear-api.js.map +1 -1
  40. package/build/watchers/linear-queue.js +11 -4
  41. package/build/watchers/linear-queue.js.map +1 -1
  42. package/build/watchers/linear.js +18 -17
  43. package/build/watchers/linear.js.map +1 -1
  44. package/build/worktrees.js +18 -6
  45. package/build/worktrees.js.map +1 -1
  46. package/package.json +1 -1
  47. package/plugin/.claude-plugin/plugin.json +4 -2
  48. package/plugin/hooks/model.ts +45 -11
  49. package/plugin/hooks/register.tsx +54 -22
  50. package/plugin/skills/work-item/SKILL.md +3 -3
  51. package/plugin/types/index.d.ts +5 -1
package/build/manager.js CHANGED
@@ -117,8 +117,11 @@ const TRANSCRIPT_SCAN_MS = 60_000;
117
117
  // The process scan for `claude attach` clients runs at most this often, and only while a worker is
118
118
  // attached: on Windows it takes about a second (ATK-520).
119
119
  const CLIENT_SCAN_MS = 30_000;
120
- // This many scans in a row without the client, once one had found it, mean the tab closed.
120
+ // This many scans in a row without the client, once one had found it, mean the tab closed. So do
121
+ // as many with no client ever found for a tab the daemon opened, counted from CLIENT_START_MS after
122
+ // the attach, so its client has had time to start (ATK-878).
121
123
  const CLIENT_MISSES = 2;
124
+ const CLIENT_START_MS = 15_000;
122
125
  // A compaction with no boundary in the transcript this long after it started, its turn over, failed.
123
126
  const COMPACT_MINUTES = 10;
124
127
  // A resumed worker with no reply in its transcript this long after the resume, its turn over, parks
@@ -133,6 +136,8 @@ const SWEPT = ['running', 'parked', 'stopped-unexpectedly'];
133
136
  // listing only shows whether someone woke a parked or stopped session outside the daemon, and gates
134
137
  // a parked worker's compaction, both of which can wait this long.
135
138
  const IDLE_LISTING_MS = 60_000;
139
+ // The events of a parked worker's session running again, as the inbox reads them.
140
+ const RUNNING_AGAIN = new Set(['worker.resumed', 'worker.revived', 'worker.running-again']);
136
141
  // The resume text: facts only, as a user turn (protocol 2's wake text), such as
137
142
  // `[conductor] event: time 2026-10-10T22:05:00.000Z reached 22:05`. The worker re-reads the source.
138
143
  // Each fact once, at its first wait's time: a merge resolves a PR's merged, checks and comment
@@ -155,6 +160,9 @@ export function sessionManager(deps) {
155
160
  const busy = new Set();
156
161
  const tasks = new Set();
157
162
  const stalls = new Map();
163
+ // By worker id, apart from stalls, which a compaction's re-park starts afresh (in memory: a restart
164
+ // checks each long park again). Dropped when the worker runs again or retires, as its item is.
165
+ const parkHeld = new Map();
158
166
  const transcripts = new Map();
159
167
  const transcriptScans = new Map();
160
168
  // worker id → a session id the listing shows for it that it hasn't followed yet, since when
@@ -174,7 +182,8 @@ export function sessionManager(deps) {
174
182
  const held = new Set();
175
183
  // Workers whose compaction failed: not tried again while the daemon runs (their inbox item says so).
176
184
  const uncompactable = new Set();
177
- // worker id → the attach (its attachedAt) a retire was last refused for, so each is logged once.
185
+ // worker id → the attach (its attachedAt) a retire was last refused for, and the reasons logged
186
+ // for it, so each is logged once per attach.
178
187
  const attachRefusals = new Map();
179
188
  // Every session the conductor starts loads its plugin, and a resume whose saved --plugin-dir went
180
189
  // missing runs on without it, silently (ATK-522). The mod reports each session start (pluginSeen),
@@ -208,6 +217,10 @@ export function sessionManager(deps) {
208
217
  subject: worker.item,
209
218
  };
210
219
  store.appendEvent(entry);
220
+ // What resolves a long park's held-process item (inbox.ts) ends its check too. A compaction's
221
+ // resume doesn't: the item stays open through it.
222
+ if (RUNNING_AGAIN.has(kind) || (kind === 'worker.attached' && data.resumed === true))
223
+ parkHeld.delete(worker.id);
211
224
  };
212
225
  // Its session was (re)started at `since`, before the claude call: its report must come after.
213
226
  // Only a worker launched with the plugin keeps it in its saved options: one launched before it (by
@@ -357,6 +370,91 @@ export function sessionManager(deps) {
357
370
  ? { error: `couldn't stop ${processesText(held)}`, held, ok: false, step: name, ...done }
358
371
  : { ok: true, step: name, ...done };
359
372
  };
373
+ // A worker parked stopProcessesAfterParkMinutes or longer has its worktree's processes stopped, as a
374
+ // retire stops them, once per park (ATK-876): a dev server it left up holds its memory through the
375
+ // wait. Its ports stay assigned, so a server its work skill starts again serves on them; the resume
376
+ // starts nothing. The clock is the park, which a compaction's re-park starts again; after a daemon
377
+ // restart, with no park seen, it is the last stop. A listing that fails is tried again
378
+ // PARK_RETRY_MS later. Processes that wouldn't stop are listed again until they have exited
379
+ // (recheckHeld), through the re-parks of a compaction too.
380
+ const stopLongParked = async (worker) => {
381
+ const after = deps.stopProcessesAfterParkMinutes;
382
+ if (after <= 0 || worker.worktree === null || worker.stoppedAt === null)
383
+ return;
384
+ const state = stalls.get(worker.id) ?? {};
385
+ const minutes = (now().getTime() - (state.parkedAt ?? Date.parse(worker.stoppedAt))) / 60_000;
386
+ const check = state.parkProcesses;
387
+ if (minutes < after || check === 'checked' || (check !== undefined && now().getTime() < check.retryAt)) {
388
+ const held = parkHeld.get(worker.id);
389
+ if (held !== undefined && now().getTime() >= held.retryAt)
390
+ await recheckHeld(worker, worker.worktree, held, minutes);
391
+ return;
392
+ }
393
+ state.parkProcesses = 'checked';
394
+ stalls.set(worker.id, state);
395
+ const done = await stopProcesses(worker.worktree, worktreePorts(store, worker.worktree));
396
+ if (done.note?.includes('not checked'))
397
+ log.log('warn', 'parked worker processes not all checked', { note: done.note, worker: worker.id });
398
+ if (done.stopped === undefined && done.held === undefined) {
399
+ // A listing that failed outright (no `none`) is tried again; none found waits for the retire.
400
+ if (done.note?.startsWith('not checked'))
401
+ state.parkProcesses = { retryAt: now().getTime() + PARK_RETRY_MS };
402
+ return;
403
+ }
404
+ if (done.held !== undefined) {
405
+ // An earlier park's held ones that this pass stopped or no longer lists stay until a check sees
406
+ // them gone, since their item still names them: a compaction's re-park checks again.
407
+ const held = done.held;
408
+ const earlier = (parkHeld.get(worker.id)?.held ?? []).filter((p) => !held.some((h) => h.pid === p.pid));
409
+ parkHeld.set(worker.id, { held: [...held, ...earlier], retryAt: now().getTime() + PARK_RETRY_MS });
410
+ }
411
+ event(worker, 'worker.processes-stopped', {
412
+ minutes: Math.round(minutes),
413
+ ...(done.note === undefined ? {} : { note: done.note }),
414
+ stopped: done.stopped ?? [],
415
+ ...(done.held === undefined ? {} : { error: done.error, held: done.held }),
416
+ });
417
+ };
418
+ // A long park's processes that wouldn't stop, listed again every PARK_RETRY_MS (ATK-880): they can
419
+ // exit on their own, or the operator can end them, while the worker stays parked, and their item's
420
+ // kill commands would then name PIDs the OS may give to an unrelated process. This stops nothing.
421
+ // One has exited when its PID is no longer alive, or when a complete listing of the worktree no
422
+ // longer shows one that runs from it: the listing is the worktree's, so a reused PID isn't in it.
423
+ // One found by its port or its parent can still run unlisted (the port probe failed, the parent
424
+ // exited), so it needs its PID gone. Those that exited are logged as worker.processes-exited, with
425
+ // those still held, and the inbox raises the item again for the rest. A listing that fails changes
426
+ // nothing, nor does one the worker's resume or retire overtook.
427
+ const recheckHeld = async (worker, worktree, check, minutes) => {
428
+ check.retryAt = now().getTime() + PARK_RETRY_MS;
429
+ let found;
430
+ try {
431
+ found = await deps.processes.find(worktree, worktreePorts(store, worktree));
432
+ }
433
+ catch (error) {
434
+ log.log('warn', "parked worker's held processes not checked again", {
435
+ error: messageOf(error),
436
+ worker: worker.id,
437
+ });
438
+ return;
439
+ }
440
+ if (parkHeld.get(worker.id) !== check)
441
+ return;
442
+ const listed = new Set(found.processes.map((p) => p.pid));
443
+ const complete = found.unseen.length === 0;
444
+ const exited = [];
445
+ const still = [];
446
+ for (const p of check.held) {
447
+ const gone = !alive(p.pid) || (!listed.has(p.pid) && complete && p.why === 'path');
448
+ (gone ? exited : still).push(p);
449
+ }
450
+ if (exited.length === 0)
451
+ return;
452
+ if (still.length === 0)
453
+ parkHeld.delete(worker.id);
454
+ else
455
+ check.held = still;
456
+ event(worker, 'worker.processes-exited', { exited, held: still, minutes: Math.round(minutes) });
457
+ };
360
458
  // What still holds the worktree after a removal failed: its processes as listed again, or none
361
459
  // when the OS doesn't say (on Windows, a process whose working folder alone is in it).
362
460
  const holders = async (worktree, ports) => deps.processes
@@ -535,7 +633,7 @@ export function sessionManager(deps) {
535
633
  return;
536
634
  }
537
635
  const parked = update(worker, { status: 'parked', stoppedAt: now().toISOString() });
538
- stalls.delete(worker.id);
636
+ stalls.set(worker.id, { parkedAt: now().getTime() });
539
637
  starting.delete(worker.id);
540
638
  replies.delete(worker.id);
541
639
  event(parked, 'worker.parked', { waits: waits.map((w) => w.condition) }, decision.rule);
@@ -696,8 +794,10 @@ export function sessionManager(deps) {
696
794
  };
697
795
  // Once the process scan has seen the worker's `claude attach` client and then missed it
698
796
  // CLIENT_MISSES times in a row, and its turn is over, the tab closed: the worker is the daemon's
699
- // again (ATK-520). A client seen still doesn't hold it past the releases below: a tab left open
700
- // with nobody in it would hold its waits as before ATK-516, and a park closes the tab.
797
+ // again (ATK-520). So it is when a tab the daemon opened is closed before any scan found its
798
+ // client (ATK-878; `watchClient` says when). A client seen still doesn't hold it past the
799
+ // releases below: a tab left open with nobody in it would hold its waits as before ATK-516, and
800
+ // a park closes the tab.
701
801
  // Attached means "the operator may be in it" only while the session is
702
802
  // in use. Once its turn has been over `idleMinutes`, the worker is the daemon's again, and its
703
803
  // waits park and resume it as usual. A resolved wait takes it back at once when the transcript
@@ -735,23 +835,44 @@ export function sessionManager(deps) {
735
835
  };
736
836
  // What a client of the worker's session attaches by: its short id, session id or name.
737
837
  const clientNames = (worker) => [worker.handle, worker.sessionId, worker.name].filter((name) => name !== null);
838
+ const clientWatch = (opened) => ({
839
+ misses: 0,
840
+ names: [],
841
+ opened,
842
+ scan: 0,
843
+ seen: false,
844
+ });
738
845
  // Each scan since the attach counts once. One that finds the client marks it seen and starts the
739
- // misses over; one that doesn't, after it was seen, is a miss. So a tab that never opened (its
740
- // terminal failed, and the operator runs `claude attach` by hand) or hasn't started its client yet
741
- // is no closed tab. The names include every one the worker had since the attach: a client keeps
742
- // the id it attached by when the worker follows a new one. A scan that couldn't read the
743
- // processes tells nothing.
744
- const watchClient = (worker, state) => {
745
- if (worker.attachedAt === null)
746
- return;
747
- const watch = (state.attachedClient ??= { misses: 0, names: [], scan: 0, seen: false });
846
+ // misses over; one that doesn't, after it was seen, is a miss. A tab the daemon opened can close
847
+ // before any scan finds its client (ATK-878), so for one the scans never saw, a scan from
848
+ // CLIENT_START_MS after the attach without it is a miss too: the grace lets its client start. A
849
+ // tab that didn't open (its terminal failed, and the operator may run `claude attach` by hand), a
850
+ // session woken outside the daemon, or an attach from before a daemon restart is no closed tab
851
+ // until a scan has seen its client. The names include every one the worker had since the attach:
852
+ // a client keeps the id it attached by when the worker follows a new one. A scan that couldn't
853
+ // read the processes tells nothing.
854
+ const stallOf = (workerId) => {
855
+ const state = stalls.get(workerId) ?? {};
856
+ stalls.set(workerId, state);
857
+ return state;
858
+ };
859
+ // The attached worker's client watch, with the names it has now.
860
+ const watchOf = (worker, state) => {
861
+ const watch = (state.attachedClient ??= clientWatch(false));
748
862
  for (const name of clientNames(worker))
749
863
  if (!watch.names.includes(name))
750
864
  watch.names.push(name);
865
+ return watch;
866
+ };
867
+ const watchClient = (worker, state) => {
868
+ if (worker.attachedAt === null)
869
+ return;
870
+ const watch = watchOf(worker, state);
751
871
  const scan = clientScan;
752
872
  if (scan === null || scan.targets === null || scan.scan === watch.scan)
753
873
  return;
754
- if (scan.at < Date.parse(worker.attachedAt))
874
+ const attachedAt = Date.parse(worker.attachedAt);
875
+ if (scan.at < attachedAt)
755
876
  return;
756
877
  const { targets } = scan;
757
878
  watch.scan = scan.scan;
@@ -759,9 +880,15 @@ export function sessionManager(deps) {
759
880
  watch.seen = true;
760
881
  watch.misses = 0;
761
882
  }
762
- else if (watch.seen)
883
+ else if (watch.seen || (watch.opened && scan.at >= attachedAt + CLIENT_START_MS))
763
884
  watch.misses += 1;
764
885
  };
886
+ const detach = (worker, data) => {
887
+ forgetAttach(worker.id);
888
+ const released = update(worker, { attachedAt: null });
889
+ event(released, 'worker.detached', data);
890
+ return released;
891
+ };
765
892
  const releaseAttached = (worker, session, resolved) => {
766
893
  const state = stalls.get(worker.id) ?? {};
767
894
  stalls.set(worker.id, state);
@@ -770,23 +897,27 @@ export function sessionManager(deps) {
770
897
  state.attachedIdleSince = undefined;
771
898
  return worker;
772
899
  }
773
- const detach = (data) => {
774
- forgetAttach(worker.id);
775
- const released = update(worker, { attachedAt: null });
776
- event(released, 'worker.detached', data);
777
- return released;
778
- };
779
900
  // Nobody has it open: its waits park and resume it as usual, with no idle spell first.
780
- if ((state.attachedClient?.misses ?? 0) >= CLIENT_MISSES)
781
- return detach({ reason: 'tab closed' });
782
901
  const attachedAt = worker.attachedAt;
902
+ const client = state.attachedClient;
903
+ if (client && client.misses >= CLIENT_MISSES) {
904
+ // A turn since the attach with no client ever found: the tab may be in use where the scan can't
905
+ // see its client (a `ps` that shows only a process title), or the attach came mid-turn. Either
906
+ // way the worker is held as before ATK-878, until the idle release.
907
+ if (!client.seen && attachedAt !== null && turnSince(worker, Date.parse(attachedAt))) {
908
+ client.opened = false;
909
+ client.misses = 0;
910
+ }
911
+ else
912
+ return detach(worker, { reason: 'tab closed', seen: client.seen });
913
+ }
783
914
  if (resolved.length > 0 && attachedAt !== null) {
784
915
  const since = Date.parse(attachedAt);
785
916
  // Once seen, a turn since the attach stays seen: the transcript isn't read again.
786
917
  state.attachedUsed ||= usedSince(worker, since);
787
918
  const fresh = resolved.filter((w) => w.resolvedAt !== null && Date.parse(w.resolvedAt) > since);
788
919
  if (fresh.length > 0 && !state.attachedUsed) {
789
- return detach({ reason: 'wait resolved', waits: fresh.map((w) => w.condition) });
920
+ return detach(worker, { reason: 'wait resolved', waits: fresh.map((w) => w.condition) });
790
921
  }
791
922
  if (!state.attachedHeld) {
792
923
  state.attachedHeld = true;
@@ -797,7 +928,36 @@ export function sessionManager(deps) {
797
928
  const minutes = minutesSince(state.attachedIdleSince);
798
929
  if (minutes < config.idleMinutes)
799
930
  return worker;
800
- return detach({ idleMinutes: Math.floor(minutes) });
931
+ return detach(worker, { idleMinutes: Math.floor(minutes) });
932
+ };
933
+ // An attached worker's tab, for a retire (ATK-890): `closed` when a scan no longer finds a client
934
+ // that one found before, or, for a tab the daemon opened that no scan found, by the sweep's
935
+ // CLIENT_MISSES (its client may start slowly); `open` when the client is found; `unseen` when no
936
+ // scan ever found it and the session took a turn since the attach (held as the sweep holds it,
937
+ // ATK-878) or the daemon didn't open the tab; `unknown` when the processes couldn't be read, or
938
+ // the tab may still be starting its client. The operator's retire scans afresh; the scheduler's
939
+ // retries reuse a readable scan from after the attach under CLIENT_SCAN_MS old, as the sweep does.
940
+ const attachedTab = async (worker, attachedAt, reuse) => {
941
+ const since = Date.parse(attachedAt);
942
+ const recent = clientScan !== null &&
943
+ clientScan.targets !== null &&
944
+ clientScan.at >= since &&
945
+ now().getTime() - clientScan.at < CLIENT_SCAN_MS;
946
+ if (!reuse || !recent)
947
+ await scanClients();
948
+ const state = stallOf(worker.id);
949
+ watchClient(worker, state);
950
+ const watch = watchOf(worker, state);
951
+ const targets = clientScan?.targets ?? null;
952
+ if (targets === null)
953
+ return 'unknown';
954
+ if (watch.names.some((name) => targets.has(name)))
955
+ return 'open';
956
+ if (watch.seen)
957
+ return 'closed';
958
+ if (!watch.opened || turnSince(worker, since))
959
+ return 'unseen';
960
+ return watch.misses >= CLIENT_MISSES ? 'closed' : 'unknown';
801
961
  };
802
962
  const sweepWorker = async (worker) => {
803
963
  let current = worker;
@@ -893,10 +1053,13 @@ export function sessionManager(deps) {
893
1053
  // (ATK-519): the retire, tried again, takes it on.
894
1054
  if (waits.length === 0)
895
1055
  return;
896
- if (resolved.length > 0)
1056
+ if (resolved.length > 0) {
897
1057
  await resume(current, resolved, session);
898
- else
899
- await compact(current, session);
1058
+ return;
1059
+ }
1060
+ // Not with a resolved wait: the resume about to happen may want its server.
1061
+ await stopLongParked(current);
1062
+ await compact(current, session);
900
1063
  };
901
1064
  // The transcript is under the slug of the session's cwd, which is usually its worktree. Failing
902
1065
  // that, every project directory is scanned, at most once a minute per worker.
@@ -939,13 +1102,12 @@ export function sessionManager(deps) {
939
1102
  const last = lastTurnOf(worker);
940
1103
  return last === undefined || (last !== null && last.getTime() > since);
941
1104
  };
942
- // No transcript, no evidence of a turn.
943
- const tookTurnSinceStop = (worker) => {
944
- if (worker.stoppedAt === null)
945
- return true;
1105
+ // A turn since `since` (ms). No transcript, no evidence of a turn.
1106
+ const turnSince = (worker, since) => {
946
1107
  const last = lastTurnOf(worker);
947
- return last !== null && last !== undefined && last.getTime() > Date.parse(worker.stoppedAt);
1108
+ return last !== null && last !== undefined && last.getTime() > since;
948
1109
  };
1110
+ const tookTurnSinceStop = (worker) => worker.stoppedAt === null || turnSince(worker, Date.parse(worker.stoppedAt));
949
1111
  // Whether a resumed worker still waits for the model to answer its event. A reply after the resume
950
1112
  // delivers the event's waits; so does no transcript to tell, as before ATK-863.
951
1113
  // An attached one is the operator's, who has the event in front of them: delivered too, so the
@@ -1187,6 +1349,11 @@ export function sessionManager(deps) {
1187
1349
  current = update(current, { attachedAt: now().toISOString() });
1188
1350
  forgetAttach(current.id);
1189
1351
  }
1352
+ // Seen: once it's gone, the tab closed, also for a retire before the next sweep's scan
1353
+ // (ATK-890). The watch skips this scan, which can start before the attach.
1354
+ const watch = watchOf(current, stallOf(current.id));
1355
+ watch.seen = true;
1356
+ watch.misses = 0;
1190
1357
  event(current, 'worker.attached', { resumed, tabOpen: true });
1191
1358
  return { already: true, hint: tabs.hint(window), resumed, tab: null, worker: current };
1192
1359
  }
@@ -1214,6 +1381,12 @@ export function sessionManager(deps) {
1214
1381
  current = resumed ? nowRunning(current, patch) : update(current, { ...patch, status: 'running' });
1215
1382
  forgetAttach(current.id);
1216
1383
  const tab = await tabs.open({ cwd: current.worktree ?? cloneOf(current), handle, title: current.name, window });
1384
+ if (tab === null) {
1385
+ // Its client is due: a scan that never finds it means the tab closed (ATK-878).
1386
+ const state = stalls.get(current.id) ?? {};
1387
+ stalls.set(current.id, state);
1388
+ state.attachedClient = clientWatch(true);
1389
+ }
1217
1390
  event(current, 'worker.attached', { resumed, ...(tab ? { tabError: tab } : {}) });
1218
1391
  if (started !== null)
1219
1392
  expectPlugin(current, started);
@@ -1262,7 +1435,7 @@ export function sessionManager(deps) {
1262
1435
  .find((w) => w.sessionId === sessionId || newSessionIds.get(w.id)?.sessionId === sessionId) ?? null);
1263
1436
  },
1264
1437
  retire: async (workerId, { cause, force = false, keep = false } = {}) => {
1265
- const worker = activeWorker(workerId);
1438
+ let worker = activeWorker(workerId);
1266
1439
  if (busy.has(worker.id) || worker.status === 'launching') {
1267
1440
  return {
1268
1441
  outcome: 'refused',
@@ -1282,22 +1455,37 @@ export function sessionManager(deps) {
1282
1455
  });
1283
1456
  return { outcome: 'refused', reason, transient, ...(work ? { work } : {}), worker };
1284
1457
  };
1285
- // The operator has its session open in a tab (ATK-519). The scheduler tries again every tick,
1286
- // so it is logged once per attach. Only a running worker is attached: one whose session
1287
- // stopped unexpectedly keeps the attach it had.
1458
+ // The operator has its session open in a tab (ATK-519). A tab a scan finds closed is released
1459
+ // here and the retire goes on (ATK-890). The scheduler tries again every tick, so each refusal
1460
+ // is logged once per attach. Only a running worker is attached: one whose session stopped
1461
+ // unexpectedly keeps the attach it had.
1288
1462
  const attachedAt = worker.status === 'running' ? worker.attachedAt : null;
1289
1463
  if (attachedAt !== null && !force) {
1290
- const reason = 'attached: retire once its tab is released, or retire --force';
1291
- if (attachRefusals.get(worker.id) !== attachedAt) {
1292
- attachRefusals.set(worker.id, attachedAt);
1293
- event(worker, 'worker.retire-refused', {
1294
- attached: true,
1295
- reason,
1296
- transient: true,
1297
- ...(cause === undefined ? {} : { cause }),
1298
- });
1464
+ const tab = await attachedTab(worker, attachedAt, cause !== undefined);
1465
+ if (tab === 'closed') {
1466
+ const seen = stalls.get(worker.id)?.attachedClient?.seen ?? false;
1467
+ worker = detach(worker, { reason: 'tab closed', retire: true, seen });
1468
+ }
1469
+ else {
1470
+ const reason = tab === 'unseen'
1471
+ ? 'attached, but no scan has found its tab: retire --force retires it'
1472
+ : 'attached: retire once its tab is released, or retire --force';
1473
+ const logged = attachRefusals.get(worker.id);
1474
+ if (logged?.attachedAt !== attachedAt)
1475
+ attachRefusals.set(worker.id, { attachedAt, reasons: new Set() });
1476
+ const reasons = attachRefusals.get(worker.id)?.reasons;
1477
+ if (reasons && !reasons.has(reason)) {
1478
+ reasons.add(reason);
1479
+ event(worker, 'worker.retire-refused', {
1480
+ attached: true,
1481
+ reason,
1482
+ tab,
1483
+ transient: true,
1484
+ ...(cause === undefined ? {} : { cause }),
1485
+ });
1486
+ }
1487
+ return { attached: true, outcome: 'refused', reason, transient: true, worker };
1299
1488
  }
1300
- return { attached: true, outcome: 'refused', reason, transient: true, worker };
1301
1489
  }
1302
1490
  attachRefusals.delete(worker.id);
1303
1491
  // Fresh: the last sweep's listing can be a minute old. Null when it couldn't be read.
@@ -1408,6 +1596,7 @@ export function sessionManager(deps) {
1408
1596
  if (!worker.probe)
1409
1597
  releaseLocks(store, worker.item, 'worker retired');
1410
1598
  stalls.delete(worker.id);
1599
+ parkHeld.delete(worker.id);
1411
1600
  starting.delete(worker.id);
1412
1601
  replies.delete(worker.id);
1413
1602
  resends.delete(worker.id);