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

Open
aaron wants to merge 1 commit from plane/HOMELAB-1544-async-only-dispatch into main
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 run51 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.
This pull request has changes conflicting with the target branch.
  • pi-swarm-suite/README.md
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin plane/HOMELAB-1544-async-only-dispatch:plane/HOMELAB-1544-async-only-dispatch
git switch plane/HOMELAB-1544-async-only-dispatch

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff plane/HOMELAB-1544-async-only-dispatch
git switch plane/HOMELAB-1544-async-only-dispatch
git rebase main
git switch main
git merge --ff-only plane/HOMELAB-1544-async-only-dispatch
git switch plane/HOMELAB-1544-async-only-dispatch
git rebase main
git switch main
git merge --no-ff plane/HOMELAB-1544-async-only-dispatch
git switch main
git merge --squash plane/HOMELAB-1544-async-only-dispatch
git switch main
git merge --ff-only plane/HOMELAB-1544-async-only-dispatch
git switch main
git merge plane/HOMELAB-1544-async-only-dispatch
git push origin main
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.