Skip to content

enhancement(host): a part a job holds at handoff is never archived, not even after the job ends #1433

Closed

Description

@HugoXK

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 archived warnings in total: 8 are (schedule) (:85, :195, :198, :201, :204, :207, :234, :237), which reach the { stopActivity: true } retry at parts.js:810; 1 is (job) (:965), which returns at :806.
  • ~/.dsh/storages/workspace.json → /global/archivedSessionIds holds 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')) return

What 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.

  1. New case: start with activities: { b: ['job'] }, hand the part over → the first archiveSession is refused, h.stopped stays [], and h.archived holds no entry for b. Then delete h.activities.b, drive the retry the fix names → a second archiveSession(b) with no options lands, so archivedEntry(h.archived, 'b') === 'b' and h.stopped is still [] (that pair is what proves "archived, but not by stopping it").

  2. Job still running: after the retry window the part is still unarchived, h.stopped === [], h.activities.b still ['job'].

  3. 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).

  4. #896 stays as it is: the existing six schedule cases still archive with { stopActivity: true }, and test('#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:

    • :240 archivedEntry(h.archived, 'b') === undefined → after the job ends, an archive entry exists
    • :241 h.stopped === [] → unchanged, still []
    • :242 h.activities.b === ['job'] → still ['job'] while the job runs

    Adding new cases without touching :240 would 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 stopped
  • core/test/part-archive.test.js: change :240 and :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

  1. added a commit that references this issue on Oct 27, 2026
    c3c2d32
  2. added 2 commits that reference this issue on Oct 27, 2026
    4eff9e9
    6b0111a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions