Repository navigation
Description
Written-by: Chief
Summary
A part that a job holds at handoff time is never archived, and nothing comes back for it once the job is over.
runSwitch archives the earlier part when a handoff lands (core/src/host/parts.js:798). If DSH refuses because the Session is still active, the catch reads the refusal's activity kinds: a refusal a scheduled task alone caused is retried with { stopActivity: true } (#896), and any other kind returns at parts.js:806 with no retry at all. A job is one of those kinds, and when the job finishes nothing re-reads that id, so the earlier part stays active for the life of the instance.
This is filed as an enhancement, not a regression: the #901 review deliberately left job/turn/subagent refusals unretried ("left to finish"), and #896 shrank the leak to this one branch. What is left is a tail that never heals, which is what this asks to close.
Observed on the running instance (2026-10-27)
dsh-web.log:965(11:09:36Z):earlier part session-cb45538c… not archived: WorkspaceActiveSessionError: cannot archive session 'session-cb45538c…': the session is active (job).- The log holds 9
not archivedwarnings in total: 8 are(schedule)(:85,:195,:198,:201,:204,:207,:234,:237), which reach the{ stopActivity: true }retry atparts.js:810; 1 is(job)(:965), which returns at:806. ~/.dsh/storages/workspace.json→/global/archivedSessionIdsholds 47 ids as of this read;session-cb45538c…is not one of them, and that id appears nowhere in the file.
So the part is still active, still counted as a live Session, and no code path will archive it now.
Where
core/src/host/parts.js:798-812 (live main 26657c8a), the catch around the handoff archive:
const kinds = Array.isArray(error?.activity) ? error.activity.map(entry => entry?.kind) : []
if (kinds.length === 0 || kinds.some(kind => kind !== 'schedule')) returnWhat it should do instead
Keep the rule that a holder is never stopped: no stopActivity for a job, turn or subagent, ever. Add a second chance once the holder is gone:
- remember the id when the refusal is not schedule-only
- after the job ends, make one more
archiveSession(id)attempt with no options; the Session is idle by then, so DSH accepts and the part is archived like any other - if that retry is refused too, force nothing: log it and drop it (the same best-effort behaviour as today)
The trigger for "the job ended" is the implementer's choice, but the PR must name it: a bounded, deterministic hook in this repository (swept on an existing host event, or a single bounded retry), not an unbounded timer loop.
Acceptance
core/test/part-archive.test.js — the fake registry already refuses from activity, and h.activities is the same mutable object it reads, so a test can end the job with delete h.activities.b.
-
New case: start with
activities: { b: ['job'] }, hand the part over → the firstarchiveSessionis refused,h.stoppedstays[], andh.archivedholds no entry forb. Thendelete h.activities.b, drive the retry the fix names → a secondarchiveSession(b)with no options lands, soarchivedEntry(h.archived, 'b') === 'b'andh.stoppedis still[](that pair is what proves "archived, but not by stopping it"). -
Job still running: after the retry window the part is still unarchived,
h.stopped === [],h.activities.bstill['job']. -
Bounded: one retry per id; nothing fires again after it fails, and the remembered id is dropped on success and on failure (no unbounded growth on a long-lived instance).
-
#896stays as it is: the existing six schedule cases still archive with{ stopActivity: true }, andtest('#896: a part held by a job is never force-archived')(:232) keeps its no-force-stop assertion. Two of that test's assertions change meaning and must be edited in the same PR rather than left asserting the leak::240archivedEntry(h.archived, 'b') === undefined→ after the job ends, an archive entry exists:241h.stopped === []→ unchanged, still[]:242h.activities.b === ['job']→ still['job']while the job runs
Adding new cases without touching
:240would leave the file self-contradictory.
Scope
core/src/host/parts.js: the retry bookkeeping and the second attempt, plus a comment saying why a job gets retried after it ends and is never stoppedcore/test/part-archive.test.js: change:240and:242, add the new case- No client change, no new config key, no new store, no docs page. Effort S–M.
Already-leaked ids are not in scope: the state file is per-instance and this only stops the tail from growing. If the chosen trigger happens to pick up an id that is already pending, say so in the PR; do not add a migration.
Activity
fix(parts): archive a job-held part once the job ends
fix(parts): archive a job-held part once its job ends
fix(parts): archive a job-held part once its job ends