Skip to content

fix(parts): archive a job-held part once its job ends - #1436

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev2/fix/1433-job-part-archive
Oct 27, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev2/fix/1433-job-part-archive

Conversation

@HugoXK

@HugoXK HugoXK commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1433

Written-by: Dev-2
Owner: Chief
Reviewer: Reviewer

Base: 55e1c27
Tip: 6b0111a (the replayed head; its only parent is the base above, and it carries tree 2c5e9f967b5e403b55864f4751908d29e42686bd)
Tree: 2c5e9f967b5e403b55864f4751908d29e42686bd

What was wrong

The handoff archived the earlier part and, when DSH refused because the Session was still active, answered only a refusal a scheduled task alone caused ({ stopActivity: true }, the #896 rule). A refusal naming a job, a turn or a subagent returned at parts.js:827 and nothing ever read that Session id again, so the part stayed active for the life of the instance (#1433). An instance logged one at 11:09:36Z on session-cb45538c… with (job).

The trigger, named

  • The job registry's settlement — ctx.get('jobs').events.subscribe({ owners: 'all' }, …), inside ctx.effect(…) at parts.js:1149: on an event with type === 'settled' whose job.owner is a waiting Session id, one ctx.workspaceRegistry.archiveSession(id) with no options. @deepseek-ai/dsh-jobs is also what answers workspace/session-activity with kind: 'job', so the thing that held the part is the thing that reports it gone.
  • The Session's own agent/status idle handler (parts.js:1186, before the rt.isBotSession check, since a part that was handed over is no longer the Bot's current Session): the same single attempt, for a turn or subagent holder.
  • Bounded: one attempt per id in flight (an attemptingArchive guard), no timer and no loop. At most 32 ids wait (PENDING_ARCHIVE_MAX), oldest dropped from the insertion-ordered set; the table can only fill from holder-end signals, never grow on its own.
  • A refused retry keeps the id. If the attempt is refused again and error.activity still names a job, a turn or a subagent, the holder has not ended after all, so the id stays for the next signal instead of spending the one chance (parts.js:1167). It leaves when the attempt lands, or when the refusal names no activity at all, while a scheduled task alone still takes the same stop-and-archive the handoff uses ({ stopActivity: true }, parts.js:832) — so a task that could not be moved never leaves the part active, and the id is never what keeps it there.
  • stopActivity is untouched. A schedule-only refusal takes { stopActivity: true } at the handoff (parts.js:832) and on the retry; a job, turn or subagent is never stopped — h.stopped stays [] in all three job cases, and the one case that does stop pins the stop to the scheduled task.

Why the idle event alone cannot carry this

DSH derives agent/status from the loop phase (@deepseek-ai/dsh-agent-loop: idle/maintenance → idle, anything else → running). The handoff's archive runs inside runMaintenance, so the Session is already idle when the refusal arrives — the idle transition was published before the attempt and is not published again for it. A job is a separate admission family (@deepseek-ai/dsh-jobs answers from its own registry), so it keeps holding the Session while that Session sits idle. And the part that was handed over takes no further turn at all: input for it is forwarded to the fresh part (forward(), parts.js:747). So the ordering Chief asked about — an idle that arrives while the job is still running — is the normal case, not the corner: the job's settlement is the only signal that names the end, and the keep-on-refusal rule is what stops an early idle from spending it (case 4 below stands exactly there).

The tests

core/test/part-archive.test.js — 7 cases; with the source taken from the base 0915151, 4 fail (cases 3, 4, 5, 6) and 3 pass; at this tip the file is 7/7 green, and the knife (this tip with only the retry's schedule branch reverted) leaves case 6 alone red:

  1. #896: a part whose task moved is archived once the move finished — unchanged.
  2. #896: a failed task move does not leave an armed earlier part active — unchanged.
  3. #896: a part held by a job is never force-archived — the base :240 assertion (archivedEntry(h.archived, 'b') === undefined, "stays active, as before this fix") is rewritten in the diff to "the part stays active while the job holds it" and then asserts the part is archived once the job settles; :241 h.stopped === [] and :242 h.activities.b === ['job'] (job still running) are kept as they were. Nothing is stopped, and the archive carries no options.
  4. #1433: an idle before the job ended spends nothing, and the part is archived when it ends — idle while the job runs archives nothing and stops nothing, the retry really happened (2 archiveSession calls for b), and the id survives to the settlement.
  5. #1433: one idle after the job ended archives the part, and the id is dropped once it lands — one idle after the end archives it; a later idle and a later settlement ask nothing more (the call count does not move).
  6. #1433: a scheduled task alone still holding the part takes the handoff stop on the retry — the task move fails (failPut), so the task stays armed on the earlier part; at the handoff the job refuses the archive and nothing is stopped, then the job ends and the schedule-only refusal takes { stopActivity: true }: the part lands archived with its task stopped, and h.stopped holds that one stop.
  7. #1169: a folder under the harness home refuses the move and names itself — unchanged.

core/test/install.test.js: the new ctx.effect is a registration, so the pinned order in apply registers listeners, tools, and effects in a fixed order gains its one line.

docs/subsystems/core-host.md:410 said a refusal caused by a job, turn or subagent "is not retried"; that sentence is now false, so it is corrected in this diff rather than waved through with a Docs-not-needed: trailer.

Files (8, not the 5 in the plan)

file +/−
changelog.d/1433-job-part-archive.fixed.md +1 / −0
core/src/host/parts.js +90 / −5
core/test/install.test.js +1 / −0
core/test/part-archive.test.js +109 / −3
docs/subsystems/core-host.md +1 / −1
patches/ds-bot/0001-core-fixes.patch +8 / −0
patches/ds-bot/0014-flybot-branding.patch +110 / −2
patches/ds-bot/files/test/part-archive.test.js +109 / −3

The two extra files are the pinned registration order and the now-false doc sentence; the patch mirrors are generate output. Total +429 / −14.

Ran at the reviewed tip (tree 3b94f195, local commit 9a0c01f, pushed as c3c2d32; before the replay onto main)

  • cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test test/part-archive.test.js — 7/7 green here; with core/src/host/parts.js taken from 0915151 (that file is byte-identical on 405e408f and 0915151, sha256 5cca6335d5a6dea6…): 4 fail, 3 pass (#896: a part held by a job is never force-archived → until: the condition did not hold within 2 s, #1433: one idle after the job ended… → same, #1433: a scheduled task alone… → same, #1433: an idle before the job ended… → the retry was actually attempted / 1 !== 2). The knife — this tip with only the retry's schedule branch reverted (a85f912's parts.js) — leaves case 6 alone red (6/7). Restoring the file byte-for-byte (sha256 17c8065773b621dc… before and after) returns 7/7.
  • TZ=UTC npm run verify — exit 0: core 1828/1828 (1825 on the base: three cases are new), social 525, tree 125, timeline 125, workflow 55 (Settings → Workflows: rename and delete user-owned Workflows #1435 added three), .github/scripts 545, interop 37, live-e2e 24 — every suite # fail 0; lint 0 errors; arch-docs: OK (8 changed file(s)).
  • node scripts/ds-bot-patches.mjs generate then check — three ok lines (ds-bot/ pin, 10 patches apply, core/ equals ds-bot/ + patches).
  • The base moved while this was in review (405e408f → 0915151, Settings → Workflows: rename and delete user-owned Workflows #1435): the branch was rebuilt on the new tip, patches/ds-bot was regenerated from scratch, and Settings → Workflows: rename and delete user-owned Workflows #1435 touches neither core/src/host/parts.js nor core/test/part-archive.test.js, so the red/knife measurements above hold unchanged on it.

Not run

Replay onto main (Rework)

Replayed onto 55e1c27a09acb86757aa3c07caef5a073860fc49 (main) as 6b0111a68013a3e5abde53218df2aa2504fca27e; the new commit's only parent is that
head, and the change set is still the eight files above. Main's #1447/#1500
(steward retry) edits core/src/host/parts.js and
docs/subsystems/core-host.md, so those two files are merges of both changes;
the two patches/ds-bot/*.patch mirrors were rebuilt from the merged core. The
other four files keep their reviewed blobs byte for byte (core/test/install.test.js,
core/test/part-archive.test.js, the vendored copy and the changelog fragment
are identical at both tips).

Ran at 6b0111a68013 (parent 55e1c27a): part-archive.test.js
7/7; with the merged parts.js swapped for main's, 3 pass / 4 fail (cases
3-6); install.test.js 4/4; part-archive, install, steward-retry,
steward, pre-step-compaction, fresh-part-model, bot-messages and
session-identity serially 68/68; ds-bot-patches check ok (10 patches
apply in order, core equals ds-bot/ + patches). Not run: core/dev/bench.sh smoke
(no harness checkout here).

Checkpoint

{
"pr": 1436,
"claim": "Chief",
"issue": 1433,
"summary": "A handoff left a part active for the life of the instance when a job, turn or subagent refused the archive: only a schedule-only refusal was answered, with a stop. The Session id is now remembered and the archive is asked for once more with no options when its holder is reported ended — the job registry's settled event, or the Session's next idle — keeping the id while a refusal still names a holder and holding at most 32, oldest dropped. Nothing is ever stopped for a job, a turn or a subagent. The subsystem doc sentence that said such a refusal is not retried is corrected.",
"files": [
"core/src/host/parts.js",
"core/test/part-archive.test.js",
"core/test/install.test.js",
"docs/subsystems/core-host.md",
"changelog.d/1433-job-part-archive.fixed.md",
"patches/ds-bot/0014-flybot-branding.patch",
"patches/ds-bot/0001-core-fixes.patch",
"patches/ds-bot/files/test/part-archive.test.js"
],
"checks": {
"ran": "Rework replay at the new tip: part-archive 7/7 (base source 3 pass / 4 fail), install 4/4, affected parts suites 68/68, ds-bot-patches check ok",
"result": "green",
"at": "6b0111a68013a3e5abde53218df2aa2504fca27e"
},
"next": "fresh Verdict at this replayed tip, then enqueue"
}

@HugoXK HugoXK changed the title Fixes #1433 fix(parts): archive a job-held part once its job ends Oct 27, 2026
@Birfy Birfy added the agent-ready Maintainer-approved spec: an agent may take it. Only maintainers set this; never in a template label Oct 27, 2026
@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Written-by: Dev

Claimed. Branch: workflow-settings-canvas-editor. Owner screenshot attached on the agent task; replace the prompt-wall Settings page with a canvas-first drag-drop editor.

@Birfy Birfy added agent-working An agent has claimed this issue (the claim comment names the branch) and removed agent-ready Maintainer-approved spec: an agent may take it. Only maintainers set this; never in a template agent-working An agent has claimed this issue (the claim comment names the branch) labels Oct 27, 2026
@HugoXK
HugoXK force-pushed the dev2/fix/1433-job-part-archive branch from bb24a31 to c3c2d32 Compare October 27, 2026 11:30
@HugoXK

HugoXK commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Self-test: PASS at c3c2d32
Written-by: Test

Measured on this tree, not read off the PR body. This is our team's self-test: it is not the repository's formal verdict, and it authorizes no merge.

Ran: from core/, node --import ./test/harness-resolve.mjs --test test/part-archive.test.js on the tip, again with core/src/host/parts.js swapped for the base version, and again with only the schedule-only retry block reverted; TZ=UTC npm run verify at this head; the core suite on base 0915151 in a worktree of its own; a blob-by-blob tree compare against that base; live checkpointReasons(body, {pr: 1436, files: 8}) with .github/scripts taken from live main; and each parts.js: reference in the body resolved against core/src/host/parts.js at tree 3b94f195.

Where this tip stands

head c3c2d32eb644c72de401e618fb8d2f3ad76a7f1c, tree 3b94f19529d2321b6db3894fb7a6a566833b092b, base 0915151605a9a424f731e9d27f63ad705c571520, one commit, 8 files +429/−14. Against that base the tree differs in exactly 8 paths — changelog.d/1433-job-part-archive.fixed.md added; core/src/host/parts.js (1e718558f9→3be5dac6fa), core/test/install.test.js (91fe0d4610→5bb485f993), core/test/part-archive.test.js (95b90a6256→f45652fea9), docs/subsystems/core-host.md (a722fa5a65→a40c5745a2), patches/ds-bot/0001-core-fixes.patch (384d24798e→3b0d4b34b6), patches/ds-bot/0014-flybot-branding.patch (8f96ec3907→ffb5437c14) modified, and patches/ds-bot/files/test/part-archive.test.js (95b90a6256→f45652fea9). The vendored copy and core/test/part-archive.test.js are the same blob f45652fea9 at both tips. The per-file numstat matches the body's table line for line.

Red, then green, then the knife

node --import ./test/harness-resolve.mjs --test test/part-archive.test.js from core/:

tree result red
base 0915151 parts.js (5cca6335d5a6dea6bad2f06633384f5f91bca2697bbfa7c86af67decd8114e01) in this tree # tests 7 / # pass 3 / # fail 4 3, 4, 5, 6
rejected head bb24a319 not measured — void —
this tree # tests 7 / # pass 7 / # fail 0 —

The four reds, by name and line: case 3 #896: a part held by a job is never force-archived — test/part-archive.test.js:264, until: the condition did not hold within 2 s; case 4 #1433: an idle before the job ended spends nothing, and the part is archived when it ends — test/part-archive.test.js:291, the retry was actually attempted 1 !== 2; case 5 #1433: one idle after the job ended archives the part, and the id is dropped once it lands — test/part-archive.test.js:311, same until timeout; case 6 #1433: a scheduled task alone still holding the part takes the handoff stop on the retry — test/part-archive.test.js:345, same until timeout.

Knife — only the schedule-only retry block reverted in place (parts.js:1176–1180, the if (kinds.length > 0) { …archiveSession(sessionId, { stopActivity: true })… return }), everything else kept: # tests 7 / # pass 6 / # fail 1, case 6 alone, test/part-archive.test.js:345, same until timeout. Restoring returns 7/7, core/src/host/parts.js is sha256 17c8065773b621dc1d522d46d6daa824d2ce9f75de568edf0b540b6d7de1c5ea again, and git status --short shows only the two node_modules symlinks.

The base suite is green on its own: base 0915151, full core, 1825/1825, # fail 0. That test file holds 4 cases on the base and 7 here, so this PR adds 3 — cases 4, 5 and 6.

The seven behaviours, with the lines

  1. The trigger is the job registry's own settlement. ctx.get('jobs')?.events is subscribed at parts.js:1144 (events.subscribe({ owners: 'all' }, …)), held inside ctx.effect(…) at parts.js:1149 so the disposer is the effect's, and filtered at parts.js:1145 on event?.type !== 'settled' || typeof event.job?.owner !== 'string'. That matches @deepseek-ai/dsh-jobs: subscribe(filter, listener) returns a disposer, JobEventFilter accepts owners: 'all', and JobView.owner is the Session id. The test drives it through h.settleJob(sessionId), which pushes a settled event into the listeners the host registered — no timer, no wall clock.
  2. An idle that arrives while the job runs spends nothing. The refusal still names a holder, so parts.js:1167 (if (kinds.some(kind => kind !== 'schedule'))) warns and returns without deleting the id. Case 4 asserts archived === undefined, stopped === [], activities.b === ['job'], and attempts for b at 2.
  3. Exactly one archive, with no options, when the holder ends. parts.js:1161 is ctx.workspaceRegistry.archiveSession(sessionId) with no arguments; :1162 deletes the id when it lands. Case 5 then emits one more idle and one more settlement and re-asserts the attempt count, which does not move.
  4. A schedule-only retry takes the handoff's stop. parts.js:1176–1180 answers a refusal whose kinds are all schedule with { stopActivity: true } (:1177), the same semantics as the handoff's #896 stop at parts.js:832. Case 6 asserts archived === ['b', { stopActivity: true }], schedule.armedOn('b') === [], and stopped === ['b'] — one stop, and it is the scheduled task that was stopped, never the job.
  5. The three job cases stop nothing. Cases 3, 4 and 5 each end with stopped === []. The old test/part-archive.test.js:240 assertion (archivedEntry(…) === undefined, 'the earlier part stays active, as before this fix') is changed inside this diff into the two-step shape — not archived while the job holds it, archived once the settlement arrives — while :241/:242 (stopped empty, activities.b === ['job']) keep their old meaning.
  6. The table is bounded and has no timer. PENDING_ARCHIVE_MAX = 32 at parts.js:732, applied at parts.js:1128 as while (pendingArchive.size > PENDING_ARCHIVE_MAX) pendingArchive.delete(pendingArchive.values().next().value) — oldest inserted dropped first. The new block adds no setTimeout/setInterval, and attemptingArchive keeps one attempt in flight per Session (parts.js:1159, :1183).
  7. A second refusal that still names a holder keeps the id. parts.js:1167 is the only path for that case: it warns and returns, never deletes, so a later signal still finds the id — which is what case 4 exercises.

Two smaller things checked in the same pass: attemptPendingArchive is also called from the agent/status idle handler at parts.js:1186, before the rt.isBotSession check at :1191, so a part that is no longer the current Session is still reached; and docs/subsystems/core-host.md at :410 no longer says such a refusal "is not retried" — it reads "a refusal caused by a job, turn or subagent is never answered with stopActivity … the id is remembered instead and the archive is asked for once more, with no options, when that holder is reported ended … holding at most 32, oldest dropped (#1433)". grep -c 'is not retried' on that file is 0. changelog.d/1433-job-part-archive.fixed.md exists and matches the behaviour, including that nothing is force-stopped.

Gates at this head

TZ=UTC npm run verify exit 0: core 1828/1828, social 525/525, tree 125, timeline 125, workflow 55, .github/scripts 545, interop 37, live-e2e 24 — every package # fail 0; lint 62 / 36 / 9 / 2 / 1 warnings across the packages, 0 errors; ds-bot-patches check three ok (the ds-bot/ pin, the 10 patches apply, and core/ equalling ds-bot/ plus the patches); build-client --check exit 0. arch-docs printed 42 changed inside verify because my clone's origin/main was still 26657c8a; after git fetch origin main (= 0915151) it is arch-docs: OK (8 changed file(s)), matching this tree.

Process items

  • checkpointReasons(live body, {pr: 1436, files: 8}) = [], with CHECKPOINT_SUMMARY_MAX = 600 in the script I ran (live main 0915151). The body I read is 9880 B / 9782 chars / sha256 2cfd195ce0d1221e4ec4943a9e5b00d82f1486b6d595a563301646fc698f8f2f. Its checkpoint block is 1216 characters: summary 580 characters in 4 sentences, next 23, files 8, checks.at this head. Tip:, Base: and Tree: name c3c2d32e… / 09151516… / 3b94f195….
  • The body was PATCHed after my first read (that version was sha256 347a0d96…), and the delta is 7 lines, all of them corrections I had raised: the base count, and six parts.js: references. Re-resolved against the tree, each now lands on the line it describes — :747 async function forward(, :827 the branch's return (the if it belongs to is :821), :832 archiveSession(agent.id, { stopActivity: true }) (cited twice, handoff and retry), :1149 ctx.effect(() => () => jobUnwatch?.(), …), :1167 if (kinds.some(kind => kind !== 'schedule')), :1186 ctx.on('agent/status', ({ agent, status }) => {. The count sentence now reads "core 1828/1828 (1825 on the base: three cases are new)", which is what I measured on base 0915151. Nothing else in the body changed.
  • Not run still records that core/dev/bench.sh smoke was not run and why; I read it as not run. Two of its three reasons I can confirm here — there is no ../deepseek-harness checkout and DSH_HARNESS is unset; the third (a read-only harness mount) I have no mount to check.

as of my read: 1 comment, newest 6440696784 (a Claimed. line for another branch, not a verdict — I did not answer it); 0 review comments. The enqueue-guard check is the only failure, with the refusal "no Verifier report names the tip c3c2d32", which is the expected state before a verdict exists. I did not re-run checks, add labels, or touch anything else.

@github-actions github-actions Bot added the risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff label Oct 27, 2026
@Birfy
Birfy force-pushed the dev2/fix/1433-job-part-archive branch from c3c2d32 to 4eff9e9 Compare October 27, 2026 22:15
Birfy added a commit to HugoXK/FlyBot that referenced this pull request Oct 27, 2026
…ries it (ThinkFlowLab#1500)

fix(parts): ask the context steward again before moving a part on —
carrying ThinkFlowLab#1449 with the review fixes

Fixes ThinkFlowLab#1447

This branch carries **ThinkFlowLab#1449** (author: @HugoXK, `Written-by: Dev-2`) —
the same seven-file change, byte for byte — plus the three fixes the
review asked for at `12c266e6`. It is opened here because ThinkFlowLab#1449's head
lives in `HugoXK/FlyBot`, where this account has `pull` only: git object
writes answer 404 and a ref update answers 422, and `PUT
/pulls/1449/update-branch` can only merge `main` in, never carry a
change. The commit is a plain fast-forward of that head, so if its
author moves the branch first, both heads are the same tree.

Written-by: Dev-2
Owner: Chief
Reviewer: Reviewer

Base: 882b09b
Tip: 8a94c49
Tree: 87fd310

## What was wrong (from ThinkFlowLab#1449)

One throttled steward call moved the whole part on. `decide()` wrapped
the ask in a single `try`, and any exception — including a per-minute
429 that recovers in seconds — fell to `return 'switch'`;
`checkContext()` then called `startSwitch()` below the check line, so a
part that was still working paid the most expensive path there is: a
fresh Session, the recent and earlier messages re-sent, a note written
through the same limited key, and a handoff whose reason said "the
previous part grew long".

The note writer next door already knew better: `writeNote` has
`NOTE_ATTEMPTS` (2) and `NOTE_COOLDOWN_MS` ([60 s, 300 s, 900 s]). Only
the call that decides *whether to move* had neither a retry nor a
cooldown.

## What the review found at `12c266e6`

A real provider route reports a failed request as a **terminal `finish`
chunk**: `{reason:{kind:'error', failure:{message, code:'RATE_LIMIT',
status?, providerRetryAfterMs?}}}`. `askSteward`
(`core/src/host/parts.js:556`) threw `new
Error(finish.failure?.message)`, keeping only the message, so
`transientFailure` never saw `code`/`status` and classified the 429 as
permanent: one ask, no retry, no cooldown — the defect this PR exists to
fix was still live on the real path. The two added cases in ThinkFlowLab#1449 only
covered `llm.stream` throwing synchronously, which the provider does not
do.

## What this adds on top (`8a94c490`, 6 files, +153/−28)

1. `core/src/host/parts.js` `askSteward` — the chunk's `failure` is
carried onto the thrown Error (`Object.assign(new Error(message),
finish.failure)` through a `stewardFailure` helper), so `code`, `status`
and `providerRetryAfterMs` survive.
2. `transientFailure` — reads `status` first (429 / 5xx), then the
harness codes `RATE_LIMIT`, `SERVER`, `TRANSPORT`, `TIMEOUT`; the raw
adapter codes stay.
3. `stewardRetryDelay` — reads `providerRetryAfterMs` (already
milliseconds) before the adapters' `retryAfter` (seconds).
4. `core/test/steward-retry.test.js` and its `patches/ds-bot/files/`
mirror — two real-shape cases: a finish chunk with `failure.code =
'RATE_LIMIT'`, and one carrying only `status: 429` plus
`providerRetryAfterMs: 400`, which also asserts the wait is that 400 ms.
5. `patches/ds-bot/0014-flybot-branding.patch` updated by hand for the
same hunks (no `git` on this box); `docs/subsystems/core-host.md` and
the changelog fragment follow the behaviour.

## Ran (Rework, at tree `87fd310e`)

- Red first: `# tests 5 / # pass 3 / # fail 2` — exactly the two new
real-shape cases. After the fix: `5/5`.
- Whole `core` suite, serially: `1844 tests / 1839 pass / 5 fail`; all
five fail identically at `12c266e6` in this sandbox and are
environmental (`check-arch-docs` without `git`, three suites needing
`jsdom`, the `usage.sqlite` fixture).
- `node scripts/ds-bot-patches.mjs check` → `10 patches apply cleanly in
order`, `core/ equals ds-bot/ + patches/ds-bot/`;
`core/scripts/build-client.mjs --check` exit 0; `changelog.mjs check` →
43 fragments well formed; lint 0 errors.
- **Not run**: `core/dev/bench.sh smoke` — no harness checkout in this
sandbox (read-only mount), the same limitation as ThinkFlowLab#1388, ThinkFlowLab#1392 and
ThinkFlowLab#1436.

## Boundaries

- ThinkFlowLab#1449 stays open and stays its author's; nothing is closed or reworded
there, and no force-push is possible from here anyway.
- ThinkFlowLab#1436 (`dev2/fix/1433-job-part-archive`, tip `c3c2d32`) is not
touched: it also edits `core/src/host/parts.js`, so whichever lands
first makes the other rebase mechanically, logic unchanged.

## Checkpoint

<!-- flybot:checkpoint -->
{
  "pr": 1500,
  "claim": "Chief",
  "issue": 1447,
"summary": "One throttled steward call moved the whole part on. The
retry added in ThinkFlowLab#1449 never fired on the real path: a provider failure
arrives as a terminal finish chunk, and askSteward kept only its
message, so a 429 looked permanent. The chunk's failure facts are now
carried onto the Error, classified by status and by the harness codes
RATE_LIMIT/SERVER/TRANSPORT/TIMEOUT, and the wait honours
providerRetryAfterMs. Red first 3 pass / 2 fail on the two new
real-shape cases, 5/5 after.",
"files": ["core/src/host/parts.js", "core/test/steward-retry.test.js",
"docs/subsystems/core-host.md",
"changelog.d/1447-steward-retry.fixed.md",
"patches/ds-bot/0014-flybot-branding.patch",
"patches/ds-bot/files/test/steward-retry.test.js",
"patches/ds-bot/topics.json"],
"checks": { "ran": "steward-retry red/green, core serially 1844/1839
with environment-only failures, ds-bot-patches check, build-client
--check, changelog check, lint", "result": "green", "at":
"8a94c490007c74b7bc382a28d1a42ca20c85ea00" },
"next": "fresh PASS at this tip, then enqueue; ThinkFlowLab#1449 is superseded by
this head once it lands"
}
<!-- /flybot:checkpoint -->

---------

Co-authored-by: Day Dreamer <87640074+HugoXK@users.noreply.github.com>
Fixes ThinkFlowLab#1433
Owner: Dev2
Reviewer: Reviewer
Written-by: Rework

Replayed onto 55e1c27 (main). The change set is unchanged where main did not
touch it: core/test/install.test.js, core/test/part-archive.test.js and the
changelog fragment keep their reviewed blobs byte for byte;
core/src/host/parts.js and docs/subsystems/core-host.md were merged with main's
ThinkFlowLab#1500 (steward retry) edits, and the two generated ds-bot patch mirrors were
rebuilt from the merged core.

Local: part-archive 7/7 (base source: 3 pass / 4 fail), install 4/4, the
affected parts suites 68/68, ds-bot-patches check ok.
@Birfy
Birfy force-pushed the dev2/fix/1433-job-part-archive branch from 4eff9e9 to 6b0111a Compare October 27, 2026 22:18
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 27, 2026
@Birfy

Birfy commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Rework

Replayed onto main 55e1c27a09a as 6b0111a6801 — the new commit's only parent is that head, and the PR still carries the same eight files. agent-pr is now on the PR (the body already carries Fixes #1433); nothing else about the body's analysis changed.

What the replay had to merge: main's #1500 (steward retry) also edits core/src/host/parts.js and docs/subsystems/core-host.md, so those two files combine both changes; the two generated patches/ds-bot/*.patch mirrors were rebuilt from the merged core (ds-bot-patches check: 10 patches apply in order, core equals ds-bot/ + patches). The other four files are byte-identical to the reviewed tip c3c2d32: core/test/install.test.js, core/test/part-archive.test.js, the vendored copy and the changelog fragment.

Ran at 6b0111a6801: part-archive.test.js 7/7; with main's parts.js in place of the merged one, 3 pass / 4 fail (cases 3-6); install.test.js 4/4; part-archive, install, steward-retry, steward, pre-step-compaction, fresh-part-model, bot-messages, session-identity serially 68/68. Not run: core/dev/bench.sh smoke (no harness checkout here).

CI on this tip: tests-ok, merge-gate and the Node 24 suite are green; enqueue-guard is red only because no Verifier report names 6b0111a6801. The self-test and any earlier PASS name c3c2d32, which this head replaces — a fresh PASS (and its Ran: line) at this tip is the one thing left before the queue.

@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 6b0111a
Written-by: Reviewer
Attestation: 6b0111a role=Reviewer

Ran at this tip (2026-10-27T23:12Z):

$ # 本地树逐 blob 对过 GitHub 的 commit 6b0111a:8 个改动路径 8/8 相同,整树 1236 个 blob 0 missing / 0 mismatch
$ cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test test/part-archive.test.js
# tests 7 / # pass 7 / # fail 0
$ # 同一测试,core/src/host/parts.js 换回 main 55e1c27 的 blob(542f6660…)
not ok 3 - #896: a part held by a job is never force-archived
not ok 4 - #1433: an idle before the job ended spends nothing, and the part is archived when it ends
not ok 5 - #1433: one idle after the job ended archives the part, and the id is dropped once it lands
not ok 6 - #1433: a scheduled task alone still holding the part takes the handoff stop on the retry
# pass 3 / # fail 4   (文件恢复后 sha256 5966cf76… 与 tip 一致,重跑 7/7)
$ TZ=UTC node --import ./test/harness-resolve.mjs --test test/part-archive.test.js test/install.test.js test/steward-retry.test.js test/steward.test.js test/pre-step-compaction.test.js test/fresh-part-model.test.js test/bot-messages.test.js test/session-identity.test.js
# tests 68 / # pass 68 / # fail 0
$ node scripts/ds-bot-patches.mjs check
skipped pin (no git): the tree hash cannot be computed here
ok   10 patches apply cleanly in order
ok   core/ equals ds-bot/ + patches/ds-bot/
$ node .github/scripts/changelog.mjs check
changelog: 44 fragment(s) in changelog.d/ are well formed
$ node core/scripts/build-client.mjs --check
ERR_MODULE_NOT_FOUND: esbuild   (本机没有 npm ci;CI 的 package job 在这个 head 上是绿的)
$ # check-runs at 6b0111a:16 条全终态,14 success + 1 cancelled(judge,同 head 另有 success judge)+ 1 failure;唯一 failure 是 enqueue-guard 的 "no Verifier report names the tip 6b0111a68013" —— 本评论即解

审查要点:

  • tip 是 22:17Z 重放后的 6b0111a68013(brief 里的 c3c2d32eb 已不是 head):唯一父提交正是当前 main 55e1c27a,树 2c5e9f96 与正文一致,8 文件 +441/−22;8 个改动路径的 blob 全部对得上,重放只合并了 main 的 fix(parts): read the steward's failure from the finish chunk that carries it #1500。
  • 语义:handoff 的拒单若点名 job/turn/subagent,现在把 Session id 记进 pendingArchive(上限 32,最旧先丢),并在 job 的 settled 事件或该 Session 下一次 idle 时再发一次不带 options 的 archiveSession;再被这几类 holder 拒就保留 id 等下一个信号,只有 schedule-only 的拒单才走 handoff 原有的 stopActivity 路径 —— job/turn/subagent 永远不会被 force-stop。
  • 红与覆盖:换回 main 的 parts.js 后 cases 3-6 全红、tip 上 7/7;install 的注册顺序 +1 行、docs:410 那句 “is not retried” 的更正、changelog 片段都与代码一致。
  • 残留(不阻断):settlement 恰好落在一次 in-flight 的 idle 重试期间(attemptingArchive 挡住 settle,随后这次又按 job 被拒)时,id 会留到下一个信号或 32 上限淘汰 —— 退化成修复前的行为,不是回归;正文没有单独的 Risk 小节,风险写散在 “Bounded” / “stopActivity is untouched” 里。
  • 正文头部已按 PASS 改成 Owner: Chief + Reviewer: Reviewer(Written-by: Dev-2 保留);checkpoint 的 claim 同步改成 Chief 以与 Owner: 行一致,其余字段原样保留。
  • head 在 fork(HugoXK/FlyBot):enqueue-guard 对 fork 不查 issue 引用/agent-pr/正文头,只差本 tip 的 PASS;入队与合并留给 Chief。

@github-actions github-actions Bot added the pass label Oct 27, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 27, 2026
Merged via the queue into ThinkFlowLab:main with commit 4d9e1f1 Oct 27, 2026
15 of 17 checks passed
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants