HOMELAB-1544: fix(subagents): make async dispatch the enforceable default and stop teardown eating sibling results #14

Merged
aaron merged 3 commits from plane/HOMELAB-1544-async-only-dispatch into main 2026-09-20 00:16:22 +00:00
Owner

Closes HOMELAB-1544.

Problem

Three paths could freeze the orchestrator's session loop on subagent work. All three live in pi-swarm-suite, not pi core:

Path Site Blocking behavior
Agent foreground core/index.ts await manager.spawnAndWait(...) — no timeout
get_subagent_result wait:true core/index.ts await record.promise — unbounded
agent_swarm fan-out swarm/swarm-tool.ts await collector.allTerminal() (unchanged here)

The foreground path was the one that bit on 2026-09-13 (3425s, 34 tool uses on a single scout, chat frozen, user killed it and lost two siblings' results): its effective default was hardcoded false in invocation-config.ts, so any LLM call that omitted run_in_background blocked.

Changes

  1. defaultRunInBackground (new, subagents.json) — defaults to false, so behavior is unchanged until explicitly enabled. When true, Agent returns an agent id immediately. Precedence stays frontmatter → explicit tool param → setting, so run_in_background: false remains an escape hatch for callers that need the result inline.

  2. toolWaitCapSec (new, default 120) — caps get_subagent_result's wait. On expiry the tool returns the agent's status snapshot, and re-arms the completion notification (resultConsumed reset) so the result is still announced when the agent actually finishes rather than being silently swallowed.

  3. runBudgetMinutes (new, default 10, 0 disables) — soft wall-clock budget. The existing 60s manager sweep now also flags agents past budget, once each, via a setBudgetHook callback. The agent keeps running — the budget makes a runaway visible, it does not kill work.

  4. dispose() no longer calls agents.clear() — it discarded every finished agent's result along with the live ones, which is how killing one agent took two completed siblings with it. Only records that could still run are removed.

Latent bug fixed in passing

notifyApplied() persisted snapshotSettings(), which carries only the keys the /agents menu manages — so any settings change silently dropped every other knob in the project file (including the pre-existing taskMaxAttempts). It now merges over the existing project settings, matching what the suite settings page already did. extractCoreSettings gained matching range checks for the same reason: a value it drops is silently erased on save.

All three knobs are surfaced on /agents → Settings and the unified pi-swarm-suite settings page.

Verification

tsc --noEmit                             clean
vitest run                               49 files / 405 tests passing

Baseline on main was 48 files / 389 tests, so the new suite adds 16 cases and nothing regressed.

New tests in tests/core/async-dispatch.test.ts cover the four ticket requirements: background-by-default precedence (including both escape-hatch directions), wait-cap clamping, the budget firing exactly once and never stopping the agent, and sibling results surviving both dispose() and abort(id).

Not covered here: scope item 1 asked for an audit of the agent_swarm synchronous fan-out. It does block by design (allTerminal()), but it's a synchronous tool whose contract is "run this fan-out and report" — making it async would need a job-id + notification model of its own, so it's left as-is rather than half-converted. Flagging in case that should become its own ticket.

Note for review

~/.pi/agent/extensions is the live extensions directory, and it had 20 uncommitted files (HOMELAB-1534/1542 result-contract work) plus 3 failing tests when this work started. Those are parked on rescue/HOMELAB-1534-1542-inflight (with the failing tiers.test.ts fixed — it still expected deepseek/deepseek-v4-flash after the rename to deepseek-flash) and are not part of this PR. This branch is based cleanly on main.

Closes HOMELAB-1544. ## Problem Three paths could freeze the orchestrator's session loop on subagent work. All three live in `pi-swarm-suite`, not pi core: | Path | Site | Blocking behavior | |---|---|---| | Agent foreground | `core/index.ts` | `await manager.spawnAndWait(...)` — no timeout | | `get_subagent_result wait:true` | `core/index.ts` | `await record.promise` — unbounded | | `agent_swarm` fan-out | `swarm/swarm-tool.ts` | `await collector.allTerminal()` (unchanged here) | The foreground path was the one that bit on 2026-09-13 (3425s, 34 tool uses on a single scout, chat frozen, user killed it and lost two siblings' results): its effective default was hardcoded `false` in `invocation-config.ts`, so any LLM call that omitted `run_in_background` blocked. ## Changes 1. **`defaultRunInBackground`** (new, `subagents.json`) — defaults to `false`, so behavior is unchanged until explicitly enabled. When `true`, `Agent` returns an agent id immediately. Precedence stays frontmatter → explicit tool param → setting, so `run_in_background: false` remains an escape hatch for callers that need the result inline. 2. **`toolWaitCapSec`** (new, default `120`) — caps `get_subagent_result`'s wait. On expiry the tool returns the agent's status snapshot, and re-arms the completion notification (`resultConsumed` reset) so the result is still announced when the agent actually finishes rather than being silently swallowed. 3. **`runBudgetMinutes`** (new, default `10`, `0` disables) — soft wall-clock budget. The existing 60s manager sweep now also flags agents past budget, once each, via a `setBudgetHook` callback. **The agent keeps running** — the budget makes a runaway visible, it does not kill work. 4. **`dispose()` no longer calls `agents.clear()`** — it discarded every finished agent's result along with the live ones, which is how killing one agent took two completed siblings with it. Only records that could still run are removed. ### Latent bug fixed in passing `notifyApplied()` persisted `snapshotSettings()`, which carries only the keys the `/agents` menu manages — so **any** settings change silently dropped every other knob in the project file (including the pre-existing `taskMaxAttempts`). It now merges over the existing project settings, matching what the suite settings page already did. `extractCoreSettings` gained matching range checks for the same reason: a value it drops is silently erased on save. All three knobs are surfaced on `/agents → Settings` and the unified `pi-swarm-suite` settings page. ## Verification ``` tsc --noEmit clean vitest run 49 files / 405 tests passing ``` Baseline on `main` was 48 files / 389 tests, so the new suite adds 16 cases and nothing regressed. New tests in `tests/core/async-dispatch.test.ts` cover the four ticket requirements: background-by-default precedence (including both escape-hatch directions), wait-cap clamping, the budget firing exactly once and never stopping the agent, and sibling results surviving both `dispose()` and `abort(id)`. **Not covered here:** scope item 1 asked for an audit of the `agent_swarm` synchronous fan-out. It does block by design (`allTerminal()`), but it's a synchronous tool whose contract is "run this fan-out and report" — making it async would need a job-id + notification model of its own, so it's left as-is rather than half-converted. Flagging in case that should become its own ticket. ## Note for review `~/.pi/agent/extensions` is the live extensions directory, and it had 20 uncommitted files (HOMELAB-1534/1542 result-contract work) plus 3 failing tests when this work started. Those are parked on `rescue/HOMELAB-1534-1542-inflight` (with the failing `tiers.test.ts` fixed — it still expected `deepseek/deepseek-v4-flash` after the rename to `deepseek-flash`) and are **not** part of this PR. This branch is based cleanly on `main`.
A subagent could freeze the orchestrator's session loop indefinitely, and
killing one discarded its siblings' finished work.

Blocking paths found (all in this extension, not pi core):

  - Agent's foreground branch awaited spawnAndWait() with no timeout
    (core/index.ts). It was reached whenever an LLM call omitted
    run_in_background, because the effective default was hardcoded `false`.
  - get_subagent_result({ wait: true }) awaited record.promise unbounded.
  - agent_swarm always awaits collector.allTerminal() (unchanged here).

Changes:

1. `defaultRunInBackground` (new, subagents.json) — default false, so behavior
   is unchanged until explicitly enabled. When true, Agent returns an agent id
   immediately instead of awaiting spawnAndWait(). Precedence stays
   frontmatter -> explicit tool param -> this setting, so
   run_in_background: false remains an escape hatch for callers that need the
   result inline.

2. `toolWaitCapSec` (new, default 120s) — caps get_subagent_result's wait. On
   expiry the tool returns the agent's current status snapshot and re-arms the
   completion notification (resultConsumed is reset) so the orchestrator is
   still told when the agent actually finishes rather than having that
   notification silently suppressed.

3. `runBudgetMinutes` (new, default 10, 0 disables) — soft wall-clock budget for
   a running agent. The existing 60s manager sweep now also flags agents past
   budget, once each, via a setBudgetHook callback wired in core/index.ts. The
   agent keeps running: the budget makes a runaway visible, it does not kill
   work.

4. dispose() no longer calls agents.clear(). It dropped every finished agent's
   result along with the live ones, which is how killing one agent took two
   completed siblings with it. Only records that could still be running are
   removed now.

Also fixes a latent settings-clobber: notifyApplied() persisted
snapshotSettings(), which only carries the keys the /agents menu manages, so
any settings change silently dropped every other knob in the project file
(including taskMaxAttempts). It now merges over the existing project settings,
matching what the suite page already did.

All three knobs are surfaced on /agents -> Settings and the unified
pi-swarm-suite settings page (extractCoreSettings gained matching range checks,
since a value it drops would otherwise be silently erased on save).

New tests (tests/core/async-dispatch.test.ts, 16 cases) cover the four ticket
requirements: background-by-default precedence, wait-cap clamping, the budget
firing once and never stopping the agent, and sibling results surviving both
dispose() and abort(id).

Verified: tsc --noEmit clean; vitest 49 files / 405 tests passing (was 48/389
on main).
Author
Owner

Heads-up from HOMELAB-1548 (merged as 142a866): main moved while this PR was open, so Forgejo now reports the branch as not mergeable.

I ran a merge of main into this branch locally to check the damage:

  • Only conflict: pi-swarm-suite/README.md — both branches append rows to the same settings table. Take both sets: keep defaultRunInBackground / toolWaitCapSec / runBudgetMinutes (this PR), keep the defaultModel row and the updated tier-* rows (main).
  • Everything else auto-merges, and the combined tree is green: tsc --noEmit clean, vitest run → 51 files / 419 tests passing.
  • Note main also changed the swarm tier defaults to deepseek/deepseek-flash (HOMELAB-1548). If you carry test fixtures that pin tier model strings (e.g. from the rescue/HOMELAB-1534-1542-inflight stash), update those to the new default.

Recommended: git merge forgejo/main on the branch, resolve the README table as above, and re-run typecheck + tests before merging.

Heads-up from HOMELAB-1548 (merged as 142a866): `main` moved while this PR was open, so Forgejo now reports the branch as not mergeable. I ran a merge of `main` into this branch locally to check the damage: - **Only conflict:** `pi-swarm-suite/README.md` — both branches append rows to the same settings table. Take both sets: keep `defaultRunInBackground` / `toolWaitCapSec` / `runBudgetMinutes` (this PR), keep the `defaultModel` row and the updated `tier-*` rows (main). - Everything else auto-merges, and the combined tree is green: `tsc --noEmit` clean, `vitest run` → **51 files / 419 tests passing**. - Note main also changed the swarm tier defaults to `deepseek/deepseek-flash` (HOMELAB-1548). If you carry test fixtures that pin tier model strings (e.g. from the `rescue/HOMELAB-1534-1542-inflight` stash), update those to the new default. Recommended: `git merge forgejo/main` on the branch, resolve the README table as above, and re-run typecheck + tests before merging.
HOMELAB-1544: merge origin/main into plane/HOMELAB-1544-async-only-dispatch
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / pi-swarm-suite (pull_request) Successful in 2m45s
CI / pi-otel-telemetry (pull_request) Successful in 38s
7286d36d9b
Resolve six conflicts against a main that moved from HOMELAB-1548 through
HOMELAB-1575 (the 2026-09-14 PR comment claimed only the README conflicted;
that was 20 merges stale).

- README: union both row sets — the three HOMELAB-1544 knobs plus main's
  watchPane / persistChildSessions — and one tier block on main's
  deepseek-flash defaults (HOMELAB-1548); drops main's stray trailing pipe on
  the tier-premium row.
- settings.ts: union interface fields, SettingsAppliers setters, sanitize
  ranges and applier calls; the three HOMELAB-1544 decision-time getters now
  call refreshSettingsIfStale() like every other HOMELAB-1569 participant.
- agent-manager.ts (semantic): take main's validateResultForLanding import —
  the merged body calls it — plus the HOMELAB-1558 child-session imports, and
  keep the HOMELAB-1544 dispose() fix. The targeted running/queued-only
  removal replaces main's blanket `agents.clear(); drivers.clear();
  lastStatus.clear()`: removeRecord() already drops each removed record's
  driver/last-status entries, and a blanket clear would strip the labels of
  the terminal records the fix deliberately keeps.
- index.ts / suite-settings.ts: union imports, core appliers, unified-page
  descriptors and extractCoreSettings ranges.
- Three of main's newer SettingsAppliers test fixtures gain the three
  HOMELAB-1544 setters (kept required, so a missing wire-up still fails tsc).

Also restores the line break between the OnAgentComplete/OnAgentStart type
aliases (the PR branch had them concatenated — cosmetic, no behavior change).

Validation: tsc --noEmit clean; vitest run 92 files / 1197 tests passing.
Author
Owner

Conflict resolved — branch is mergeable again

origin/main merged into this branch as 7286d36 (normal fast-forward push of one merge commit; no force-push, no rebase). Forgejo now reports Mergeable: true.

The 2026-09-14 comment (written against HOMELAB-1548) described a single README conflict. main had since moved ~20 merges (HOMELAB-1557 → 1575), so the merge had six conflicts, one of them semantic. Resolutions:

pi-swarm-suite/README.md — union as prescribed: the three HOMELAB-1544 rows (defaultRunInBackground, toolWaitCapSec, runBudgetMinutes) plus main's watchPane / persistChildSessions rows, then a single tier block on main's deepseek/deepseek-flash defaults. Also dropped main's stray trailing | | on the tier-premium row, which broke that row's column count.

src/core/settings.ts — union of the interface fields, SettingsAppliers setters, sanitizeSettings ranges and applySettings applier calls. Merge adaptation: the three HOMELAB-1544 getters are decision-time reads, so they now call refreshSettingsIfStale() like every other HOMELAB-1569 participant — otherwise these would have been the only knobs a second session's write could not reach.

src/core/agent-manager.ts — the one semantic conflict:

  • imports: took main's validateResultForLanding / ContractWarning (the merged body calls validateResultForLanding, not validateResult), kept getRunBudgetMinutes and the HOMELAB-1558 child-session imports;
  • dispose(): kept the HOMELAB-1544 fix — the targeted running/queued-only removal — and did not reinstate main's blanket agents.clear(); drivers.clear(); lastStatus.clear(). removeRecord() already deletes each removed record's driver/last-status entries, and a blanket clear would have stripped the labels of exactly the terminal records this PR keeps.
  • restored the line break between the OnAgentComplete / OnAgentStart type aliases (this branch had them concatenated on one line — cosmetic, no behavior).

src/core/index.ts / src/suite-settings.ts — union of the settings imports, coreAppliers, the unified-page descriptors and the extractCoreSettings ranges.

Test fixtures from main newer than this PR (tests/core/settings-freshness.test.ts, tests/suite-settings-watchpane.test.ts, tests/suite-settings-persist-child-sessions.test.ts) construct SettingsAppliers literals, so they gained the three HOMELAB-1544 setters. I deliberately did not make those setters optional to dodge this: they stay required, so a missing wire-up still fails tsc.

Validation (local, on the merged tree)

npm ci            (pi-swarm-suite)
tsc --noEmit      clean
vitest run        92 files / 1197 tests passing
git diff --check  clean, no conflict markers
git diff --numstat origin/main — only PR-intended changes

CI on 7286d36

pr-title ✅ · pi-swarm-suite ✅ · pi-otel-telemetry ✅

For the reviewer

The dispose() resolution above is the one judgement call worth a second pair of eyes: this branch previously asserted "no stash/other-branch work is included", and that still holds — the merge only unions both sides of the same shared files. Everything else is mechanical.

## Conflict resolved — branch is mergeable again `origin/main` merged into this branch as **7286d36** (normal fast-forward push of one merge commit; no force-push, no rebase). Forgejo now reports `Mergeable: true`. The 2026-09-14 comment (written against HOMELAB-1548) described a single README conflict. `main` had since moved ~20 merges (HOMELAB-1557 → 1575), so the merge had **six** conflicts, one of them semantic. Resolutions: **`pi-swarm-suite/README.md`** — union as prescribed: the three HOMELAB-1544 rows (`defaultRunInBackground`, `toolWaitCapSec`, `runBudgetMinutes`) plus main's `watchPane` / `persistChildSessions` rows, then a single tier block on main's `deepseek/deepseek-flash` defaults. Also dropped main's stray trailing `| |` on the `tier-premium` row, which broke that row's column count. **`src/core/settings.ts`** — union of the interface fields, `SettingsAppliers` setters, `sanitizeSettings` ranges and `applySettings` applier calls. Merge adaptation: the three HOMELAB-1544 getters are decision-time reads, so they now call `refreshSettingsIfStale()` like every other HOMELAB-1569 participant — otherwise these would have been the only knobs a second session's write could not reach. **`src/core/agent-manager.ts`** — the one semantic conflict: - imports: took main's `validateResultForLanding` / `ContractWarning` (the merged body calls `validateResultForLanding`, not `validateResult`), kept `getRunBudgetMinutes` and the HOMELAB-1558 child-session imports; - `dispose()`: **kept the HOMELAB-1544 fix** — the targeted `running`/`queued`-only removal — and did *not* reinstate main's blanket `agents.clear(); drivers.clear(); lastStatus.clear()`. `removeRecord()` already deletes each removed record's driver/last-status entries, and a blanket clear would have stripped the labels of exactly the terminal records this PR keeps. - restored the line break between the `OnAgentComplete` / `OnAgentStart` type aliases (this branch had them concatenated on one line — cosmetic, no behavior). **`src/core/index.ts` / `src/suite-settings.ts`** — union of the settings imports, `coreAppliers`, the unified-page descriptors and the `extractCoreSettings` ranges. **Test fixtures from main newer than this PR** (`tests/core/settings-freshness.test.ts`, `tests/suite-settings-watchpane.test.ts`, `tests/suite-settings-persist-child-sessions.test.ts`) construct `SettingsAppliers` literals, so they gained the three HOMELAB-1544 setters. I deliberately did *not* make those setters optional to dodge this: they stay required, so a missing wire-up still fails `tsc`. ### Validation (local, on the merged tree) ``` npm ci (pi-swarm-suite) tsc --noEmit clean vitest run 92 files / 1197 tests passing git diff --check clean, no conflict markers git diff --numstat origin/main — only PR-intended changes ``` ### CI on 7286d36 `pr-title` ✅ · `pi-swarm-suite` ✅ · `pi-otel-telemetry` ✅ ### For the reviewer The `dispose()` resolution above is the one judgement call worth a second pair of eyes: this branch previously asserted "no stash/other-branch work is included", and that still holds — the merge only unions both sides of the same shared files. Everything else is mechanical.
Author
Owner

Fresh-context review — head 7286d36, vs origin/main (a02fe1f)

Reviewed from primary sources only: the PR diff vs current main, the pre-merge head 7994424, the ticket's acceptance criteria, and the repo's own code paths. No implementer reasoning was consulted. Nothing was pushed, committed, or merged.

Verification run locally on 7286d36 (pi-swarm-suite/): tsc --noEmit → clean (exit 0). vitest run → 92 files / 1197 tests passing, 0 failures. CI on 7286d36 green (pr-title, pi-swarm-suite, pi-otel-telemetry).


1. Merge delta (7994424..7286d36) — CLEAN

Only 26 lines are removed across the PR-vs-main diff, and every one is accounted for: the three teardown clear() calls, two replaced tool-description strings, the two superseded settings.js import lines, the replaced dispose() loop, snapshotSettings() → the merge-over-project call, the old ?? false background default, the old unlock message, and one README row (see below). Nothing from main was dropped:

  • agent-manager.ts — took main's validateResultForLanding / ContractWarning (the merged body calls them), kept the HOMELAB-1558 child-session imports and getRunBudgetMinutes.
  • index.ts / suite-settings.ts — union of imports, coreAppliers, unified-page descriptors and extractCoreSettings ranges; the three new setters are deliberately non-optional so a missed wire-up fails tsc.
  • README.md — union table keeps main's defaultModel, toolDescriptionMode, watchPane, persistChildSessions and the single deepseek/deepseek-flash tier block, plus the three HOMELAB-1544 rows. The one deleted README line is main's malformed tier-premium row (| | trailing pipe), replaced by a well-formed row. Good catch, not a loss.
  • Test fixtures main added after this branch forked (settings-freshness, suite-settings-watchpane, suite-settings-persist-child-sessions, team-settings) were adapted with the three new setters — mechanical, and tsc proves it.

2. The dispose() resolution — CONCERN (keeping it is right; its premise and its shipped test are not)

(a) Blanket clear would have stripped kept terminal labels — CONFIRMED. removeRecord() (agent-manager.ts:1240-1247) is the only place drivers / lastStatus entries are deleted, and the roster row's label is read through serializeAgentRecord → driverOf(id) → manager.getDriver() → drivers.get(id), with the field simply omitted when absent (roster-json.ts:93, :121). So removeRecord's per-record deletes are the correct discipline, and taking main's blanket drivers.clear(); lastStatus.clear() alongside kept records would have dropped the driver label off exactly those rows. removeRecord() also runs releaseChildSession() first, which the blanket clear does not. The resolution itself is correct.

(b) But on the only production teardown path the loop is dead code, and dispose() now removes nothing. manager.dispose() has exactly one caller: disposeSession() (core/index.ts:864), and it always runs immediately after manager.abortAll() (:849), which first flips every running/queued record to status = "stopped" (agent-manager.ts:1312, :1334). The removal condition status !== "running" && status !== "queued" (:1421) therefore matches nothing, so the loop deletes zero records and removeRecord() — the only thing that nulls record.session and frees the maps — never runs. dispose() retains all records and every disposed session object.

I confirmed this with a throwaway probe test (mimicking the real abortAll() → dispose() order, then deleted): after teardown, getRecord(stuckId) still returns the record with session field retained true. The shipped test in async-dispatch.test.ts ("drops only the agents that cannot still run after dispose()") asserts getRecord(stuckId) is undefined — an ordering dispose() is never called in, and its own comment ("the still-running one was stopped by abortAll() during teardown") describes the path it didn't take. So no test covers the real ordering, and the source comment "only records that could still run are dropped (abortAll() upstream has already stopped them)" is self-defeating: if abortAll() already stopped them, none can still run, so none are dropped.

Not a blocker — the session is ending, disposeSession() has already nulled currentCtx (:838) and unregistered the manager handle (:840), so nothing reads those records afterwards, and retention is bounded by whatever still references the manager. But dispose()'s teardown contract is now effectively "release nothing". Two small follow-ups would close it: (a) in the retain path, null record.session after disposing it (best of both — the result survives, the session object is released), or (b) if full retention is intended, say so and fix the test to exercise abortAll() → dispose(). Nit for the same hunk: the new comment at core/index.ts:847 ("dispose() below clears the roster") is now stale.

3. Ticket HOMELAB-1544 Verify coverage — CONCERN (all four items need a live session; one is not delivered by merging)

Read the ticket via plane_list_items (its HOMELAB-1544 seq-id worked; the 1557/1558/1577 duplicates were avoided as instructed).

  • toolWaitCapSec = 120 — implemented, CLEAN. Default 120 (settings.ts:580), clamped [5, 3600] in both sanitizeSettings and setToolWaitCapSec (:599-603), and the /agents menu refuses < 5. The re-arm is genuinely wired: on expiry record.resultConsumed = false (core/index.ts:1757), and the individual-nudge send re-checks that flag at send time (:333, scheduled at :354), so the completion notification still fires. A promise that settles concurrently with the cap is handled too — waitTimedOut also requires record.status === "running", so a settled agent's result is returned inline. Ticket Verify #2 satisfied.
  • runBudgetMinutes = 10, 0 disables — implemented, CLEAN. Exactly-once per agent via budgetWarned; the hook only sends a follow-up notification and never touches the run; cleanup() drops the id when a terminal record is reaped so a resumed agent can warn again. Ticket Verify #4's guardrail satisfied.
  • Sibling results preserved — implemented in the preserve direction, subject to (2b).
  • defaultRunInBackground default = false → the ticket's headline is NOT delivered by this merge. The ticket is "subagent execution blocks the main session — need async-only dispatch", scope item 2: "Agent always returns immediately (run_in_background default/true)", Verify #1: "main session stays interactive throughout". This PR deliberately ships the mechanism inert (precedence frontmatter → explicit param → setting, invocation-config.ts:48), which is defensible for compat but means merging changes no behavior. The live ~/.pi/agent/subagents.json today is {maxConcurrent: 20, defaultModel: "deepseek/deepseek-flash", taskMaxAttempts: 2, taskLeaseTtlSec: 90, watchPane: "off"} — none of the three new knobs present. So post-merge someone must set "defaultRunInBackground": true (hand-edit, /agents → Settings, or the unified page; the getters read at decision time via refreshSettingsIfStale(), so no restart is needed). Merging is not the same as fixing the ticket — and all four Verify items are runtime checks on a live session, none of which this PR or its tests perform. Also: agent_swarm's synchronous fan-out is explicitly left blocking, with no follow-up ticket — it is a real remaining block path named in the ticket's scope item 1.

Bonus fix is real and worth keeping: notifyApplied used to persist snapshotSettings() (the menu's subset), silently dropping every other knob in the file — which is precisely how defaultRunInBackground would have appeared to reset itself. {...loadProjectSettings(process.cwd()), ...snapshotSettings()} (core/index.ts:2674) fixes it, and extractCoreSettings gained matching ranges so a dropped value can't be erased on save.

4. README settings table (union) — CLEAN

Row-by-row against the code: defaultRunInBackground bool false ✓; toolWaitCapSec int 5–3600 default 120 ✓ (matches TOOL_WAIT_CAP_SEC_FLOOR/CEILING); runBudgetMinutes int ≥0, 0 = off, default 10 ✓. Main's rows are intact and the single tier block is correct. The new prose section and the suite-settings.ts page description mirror the same numbers and honestly state that the agent keeps running under budget. Nit: the runBudgetMinutes code ceiling (1440, settings.ts:201) is not documented, unlike toolWaitCapSec's range.

5. Tests — CLEAN (with the one gap in 2b)

tsc --noEmit clean; 92 files / 1197 tests pass on 7286d36 (baseline main is smaller; the PR's 16 new cases in async-dispatch.test.ts assert behavior — precedence both directions, wait-cap clamping, budget firing once and never stopping the agent, sibling survival — not implementation). Green CI confirms it independently. The dispose() case is the only place a test asserts a behavior the production ordering cannot produce.


Verdict

Dimension Verdict
Merge delta / conflict resolution CLEAN — nothing from main lost; all six conflicts resolved as union
dispose() semantics CONCERN — discipline correct, but the retain-path keeps every record and its disposed session on the only real teardown path; the shipped test covers an ordering dispose() is never called in
defaultRunInBackground / toolWaitCapSec / runBudgetMinutes implementation CLEAN — defaults, clamps and the wait re-arm all verified
Ticket Verify coverage CONCERN — 3 of 4 items remain live-session-runtime checks; the headline "async-only" behavior is inert until the knob is set, and agent_swarm still blocks
README union table CLEAN
Typecheck / tests / CI CLEAN — 1197 passing, CI green

No blocker findings. The merge resolution is safe to merge as-is; the dispose() retention is a follow-up worth a small commit (null record.session on the retain path, and a test that exercises abortAll() → dispose()), and the human should treat the enablement decision ("defaultRunInBackground": true) as the actual close of HOMELAB-1544.

## Fresh-context review — head `7286d36`, vs `origin/main` (`a02fe1f`) Reviewed from primary sources only: the PR diff vs current `main`, the pre-merge head `7994424`, the ticket's acceptance criteria, and the repo's own code paths. No implementer reasoning was consulted. Nothing was pushed, committed, or merged. **Verification run locally on `7286d36`** (`pi-swarm-suite/`): `tsc --noEmit` → clean (exit 0). `vitest run` → **92 files / 1197 tests passing**, 0 failures. CI on `7286d36` green (`pr-title`, `pi-swarm-suite`, `pi-otel-telemetry`). --- ### 1. Merge delta (`7994424..7286d36`) — **CLEAN** Only 26 lines are removed across the PR-vs-main diff, and every one is accounted for: the three teardown `clear()` calls, two replaced tool-description strings, the two superseded `settings.js` import lines, the replaced `dispose()` loop, `snapshotSettings()` → the merge-over-project call, the old `?? false` background default, the old unlock message, and one README row (see below). Nothing from `main` was dropped: - `agent-manager.ts` — took main's `validateResultForLanding` / `ContractWarning` (the merged body calls them), kept the HOMELAB-1558 child-session imports **and** `getRunBudgetMinutes`. - `index.ts` / `suite-settings.ts` — union of imports, `coreAppliers`, unified-page descriptors and `extractCoreSettings` ranges; the three new setters are deliberately non-optional so a missed wire-up fails `tsc`. - `README.md` — union table keeps main's `defaultModel`, `toolDescriptionMode`, `watchPane`, `persistChildSessions` and the single `deepseek/deepseek-flash` tier block, plus the three HOMELAB-1544 rows. The one deleted README line is main's malformed `tier-premium` row (`| |` trailing pipe), replaced by a well-formed row. Good catch, not a loss. - Test fixtures main added after this branch forked (`settings-freshness`, `suite-settings-watchpane`, `suite-settings-persist-child-sessions`, `team-settings`) were adapted with the three new setters — mechanical, and `tsc` proves it. ### 2. The `dispose()` resolution — **CONCERN** (keeping it is right; its premise and its shipped test are not) **(a) Blanket clear *would* have stripped kept terminal labels — CONFIRMED.** `removeRecord()` (`agent-manager.ts:1240-1247`) is the *only* place `drivers` / `lastStatus` entries are deleted, and the roster row's label is read through `serializeAgentRecord` → `driverOf(id)` → `manager.getDriver()` → `drivers.get(id)`, with the field simply omitted when absent (`roster-json.ts:93`, `:121`). So `removeRecord`'s per-record deletes are the correct discipline, and taking main's blanket `drivers.clear(); lastStatus.clear()` alongside kept records would have dropped the `driver` label off exactly those rows. `removeRecord()` also runs `releaseChildSession()` first, which the blanket clear does not. **The resolution itself is correct.** **(b) But on the only production teardown path the loop is dead code, and dispose() now removes nothing.** `manager.dispose()` has exactly one caller: `disposeSession()` (`core/index.ts:864`), and it always runs immediately after `manager.abortAll()` (`:849`), which first flips *every* running/queued record to `status = "stopped"` (`agent-manager.ts:1312`, `:1334`). The removal condition `status !== "running" && status !== "queued"` (`:1421`) therefore matches nothing, so the loop deletes zero records and `removeRecord()` — the only thing that nulls `record.session` and frees the maps — never runs. `dispose()` retains **all** records and every disposed session object. I confirmed this with a throwaway probe test (mimicking the real `abortAll()` → `dispose()` order, then deleted): after teardown, `getRecord(stuckId)` still returns the record with `session` field retained `true`. The shipped test in `async-dispatch.test.ts` ("drops only the agents that cannot still run after dispose()") asserts `getRecord(stuckId)` is **`undefined`** — an ordering `dispose()` is never called in, and its own comment ("the still-running one was stopped by `abortAll()` during teardown") describes the path it *didn't* take. So no test covers the real ordering, and the source comment "only records that could still run are dropped (abortAll() upstream has already stopped them)" is self-defeating: if `abortAll()` already stopped them, none can still run, so none are dropped. Not a blocker — the session is ending, `disposeSession()` has already nulled `currentCtx` (`:838`) and unregistered the manager handle (`:840`), so nothing reads those records afterwards, and retention is bounded by whatever still references the manager. But `dispose()`'s teardown contract is now effectively "release nothing". Two small follow-ups would close it: (a) in the retain path, null `record.session` after disposing it (best of both — the result survives, the session object is released), or (b) if full retention is intended, say so and fix the test to exercise `abortAll()` → `dispose()`. **Nit for the same hunk:** the new comment at `core/index.ts:847` ("`dispose()` below clears the roster") is now stale. ### 3. Ticket `HOMELAB-1544` Verify coverage — **CONCERN** (all four items need a live session; one is not delivered by merging) Read the ticket via `plane_list_items` (its `HOMELAB-1544` seq-id worked; the 1557/1558/1577 duplicates were avoided as instructed). - **`toolWaitCapSec` = 120 — implemented, CLEAN.** Default `120` (`settings.ts:580`), clamped `[5, 3600]` in both `sanitizeSettings` and `setToolWaitCapSec` (`:599-603`), and the `/agents` menu refuses `< 5`. The re-arm is genuinely wired: on expiry `record.resultConsumed = false` (`core/index.ts:1757`), and the individual-nudge send re-checks that flag at send time (`:333`, scheduled at `:354`), so the completion notification still fires. A promise that settles concurrently with the cap is handled too — `waitTimedOut` also requires `record.status === "running"`, so a settled agent's result is returned inline. **Ticket Verify #2 satisfied.** - **`runBudgetMinutes` = 10, 0 disables — implemented, CLEAN.** Exactly-once per agent via `budgetWarned`; the hook only sends a follow-up notification and never touches the run; `cleanup()` drops the id when a terminal record is reaped so a *resumed* agent can warn again. **Ticket Verify #4's guardrail satisfied.** - **Sibling results preserved — implemented in the preserve direction**, subject to (2b). - **`defaultRunInBackground` default = `false` → the ticket's headline is NOT delivered by this merge.** The ticket is "subagent execution blocks the main session — need async-only dispatch", scope item 2: "Agent always returns immediately (run_in_background default/true)", Verify #1: "main session stays interactive throughout". This PR deliberately ships the mechanism **inert** (precedence frontmatter → explicit param → setting, `invocation-config.ts:48`), which is defensible for compat but means merging changes no behavior. The live `~/.pi/agent/subagents.json` today is `{maxConcurrent: 20, defaultModel: "deepseek/deepseek-flash", taskMaxAttempts: 2, taskLeaseTtlSec: 90, watchPane: "off"}` — **none of the three new knobs present**. So post-merge someone must set `"defaultRunInBackground": true` (hand-edit, `/agents → Settings`, or the unified page; the getters read at decision time via `refreshSettingsIfStale()`, so no restart is needed). **Merging is not the same as fixing the ticket** — and all four Verify items are runtime checks on a live session, none of which this PR or its tests perform. Also: `agent_swarm`'s synchronous fan-out is explicitly left blocking, with no follow-up ticket — it is a real remaining block path named in the ticket's scope item 1. **Bonus fix is real and worth keeping:** `notifyApplied` used to persist `snapshotSettings()` (the menu's subset), silently dropping every other knob in the file — which is precisely how `defaultRunInBackground` would have appeared to reset itself. `{...loadProjectSettings(process.cwd()), ...snapshotSettings()}` (`core/index.ts:2674`) fixes it, and `extractCoreSettings` gained matching ranges so a dropped value can't be erased on save. ### 4. README settings table (union) — **CLEAN** Row-by-row against the code: `defaultRunInBackground` bool `false` ✓; `toolWaitCapSec` int 5–3600 default `120` ✓ (matches `TOOL_WAIT_CAP_SEC_FLOOR/CEILING`); `runBudgetMinutes` int ≥0, 0 = off, default `10` ✓. Main's rows are intact and the single tier block is correct. The new prose section and the `suite-settings.ts` page description mirror the same numbers and honestly state that the agent keeps running under budget. **Nit:** the `runBudgetMinutes` code ceiling (`1440`, `settings.ts:201`) is not documented, unlike `toolWaitCapSec`'s range. ### 5. Tests — **CLEAN** (with the one gap in 2b) `tsc --noEmit` clean; **92 files / 1197 tests pass** on `7286d36` (baseline `main` is smaller; the PR's 16 new cases in `async-dispatch.test.ts` assert behavior — precedence both directions, wait-cap clamping, budget firing once and never stopping the agent, sibling survival — not implementation). Green CI confirms it independently. The `dispose()` case is the only place a test asserts a behavior the production ordering cannot produce. --- ### Verdict | Dimension | Verdict | |---|---| | Merge delta / conflict resolution | **CLEAN** — nothing from `main` lost; all six conflicts resolved as union | | `dispose()` semantics | **CONCERN** — discipline correct, but the retain-path keeps every record *and* its disposed session on the only real teardown path; the shipped test covers an ordering `dispose()` is never called in | | `defaultRunInBackground` / `toolWaitCapSec` / `runBudgetMinutes` implementation | **CLEAN** — defaults, clamps and the wait re-arm all verified | | Ticket Verify coverage | **CONCERN** — 3 of 4 items remain live-session-runtime checks; the headline "async-only" behavior is inert until the knob is set, and `agent_swarm` still blocks | | README union table | **CLEAN** | | Typecheck / tests / CI | **CLEAN** — 1197 passing, CI green | **No blocker findings.** The merge resolution is safe to merge as-is; the `dispose()` retention is a follow-up worth a small commit (null `record.session` on the retain path, and a test that exercises `abortAll()` → `dispose()`), and the human should treat the enablement decision (`"defaultRunInBackground": true`) as the actual close of HOMELAB-1544.
HOMELAB-1544: fix(swarm-suite): release session on dispose retain path
All checks were successful
CI / pr-title (pull_request) Successful in 0s
CI / pi-swarm-suite (pull_request) Successful in 3m13s
CI / pi-otel-telemetry (pull_request) Successful in 30s
53f8392d8c
dispose() disposes every record's session but only removes running/queued
records. On the production teardown order (abortAll() then dispose())
abortAll() has already stopped everything, so the removal loop matches
nothing and every retained record kept a reference to its disposed
session.

Null record.session in the dispose loop so a kept record releases the
session it disposed, and correct the stale/self-defeating comments (the
retain loop no longer claims abortAll() drops anything, and the index.ts
teardown note no longer says dispose() clears the roster).

Replace the shipped dispose test (which asserted getRecord(stuckId) ===
undefined for an ordering dispose() is never called in) with one that
exercises the real abortAll() -> dispose() order: terminal records survive
with labels intact and their session reference released.
Author
Owner

Review concern [2b] addressed in 53f8392: dispose()'s retain path now nulls record.session after disposing it, and the shipped dispose test was replaced with one exercising the real abortAll() → dispose() order (terminal records survive with labels intact + session released) — typecheck clean, vitest 92 files / 1197 tests green.

Review concern [2b] addressed in 53f8392: dispose()'s retain path now nulls `record.session` after disposing it, and the shipped dispose test was replaced with one exercising the real `abortAll()` → `dispose()` order (terminal records survive with labels intact + session released) — typecheck clean, vitest 92 files / 1197 tests green.
Author
Owner

Merging per Aaron's approval 2026-09-19.

Pre-flight verified live:

  • head 53f8392d8c868293722881c39ae5a6be6b537fd4, base main, mergeable: true, unmerged.
  • CI on head: 3/3 success (CI / pr-title, CI / pi-swarm-suite 3m13s, CI / pi-otel-telemetry 30s).
  • Branch is based on current main HEAD (a02fe1f) — merge-base == main, zero commits on main since, so no regression risk to the ~18 PRs merged after this one was opened.

Branch plane/HOMELAB-1544-async-only-dispatch is intentionally left in place for now (not deleted at merge).

Merging per Aaron's approval 2026-09-19. Pre-flight verified live: - head `53f8392d8c868293722881c39ae5a6be6b537fd4`, base `main`, mergeable: true, unmerged. - CI on head: 3/3 success (CI / pr-title, CI / pi-swarm-suite 3m13s, CI / pi-otel-telemetry 30s). - Branch is based on current `main` HEAD (`a02fe1f`) — merge-base == main, zero commits on main since, so no regression risk to the ~18 PRs merged after this one was opened. Branch `plane/HOMELAB-1544-async-only-dispatch` is intentionally left in place for now (not deleted at merge).
aaron merged commit f9cdc27e0a into main 2026-09-20 00:16:22 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/pi-extensions!14
No description provided.