Adds harness-core::engine::jobs, the JobBoard tracking foreground/background subagent jobs with persistence, plus permission-rule intersection so a spawned subagent's effective permissions are the parent's rules narrowed by its own.
Adds the task tool and subagent spawner in harness-tools, and wires spawning/aliasing/reuse and depth limits through harness-app and the job board, so an orchestrator session can launch and track child sessions.
Lets subagents report context files back to the job board (surfaced from the read tool) so the parent session's next-turn context includes what a background child has been looking at.
Adds the jobs pane to the TUI with a new snapshot test, subtask drill-in navigation, and periodic reminders injected into the engine loop for long-running background jobs.
Adds a dedicated provider entry for opencode's Zen proxy on top of the existing OpenAI-compatible codec, with its own config wiring in harness-app and harness-core::config::load.
darman
requested review from claude-reviewer 2026-07-11 14:42:09 +02:00
This is the most intricate milestone and it's impressively coherent: permission intersection (evaluate_intersected, deny > ask > allow) is correct and well-tested, the depth limit is enforced and covered, foreground/background token scoping is thought through (background childs the parent run token so the job outlives the tool call), reuse genuinely continues the same child session, and the job board persists + reloads. One real bug and a couple of smaller notes.
Job-board alias collisions after trimming (medium)
JobBoard::next_alias (engine/jobs.rs:1756) derives the numeric suffix from the current count of that agent's jobs:
But trim_reusable (jobs.rs:1980) deletes completed jobs once max_reusable_per_agent is exceeded. So the count goes stale and a later launch reuses a live alias:
explorer launches → exp-1, exp-2, exp-3 (max_reusable = 2)
all complete; trim drops the LRU exp-1; board now holds {exp-2, exp-3}
next explorer launch: count == 2 → alias exp-3 — collides with the existing exp-3
Since jobs are keyed by task_id (not alias), both survive, and resolve_reusable/touch/the prompt board now match an arbitrary one of the two — the orchestrator can reuse or reference the wrong session. Use a monotonic per-agent counter (e.g. max(existing suffix for agent) + 1, or a persisted counter) instead of the live filtered count.
register_launch failure silently untracks the job (low)
In spawn (harness-app/src/lib.rs:565) the launch registration is fire-and-forget (let _ = board.register_launch(...)), and the alias is then re-derived via board.snapshot().find(...) rather than using the value register_launch already returned. If the store write fails, the child still runs but never appears on the board, and the later board.finish(&task_id, ...) no-ops (the record doesn't exist) — the subtask vanishes from tracking with no signal. Consider using the returned alias directly and at least logging a registration failure.
depth_guard_rules (lib.rs:249) injects a blanket taskdeny for every session at depth > 0. So regardless of orchestration.depth_limit (default 3), a subagent can never delegate further unless an agent/config rule re-grants task. That's a reasonable conservative default, but it makes the depth counter and the depth_limit knob mostly moot for delegation — worth a doc note so depth_limit: 3 isn't read as "3 levels of auto-delegation".
Nice touches: filtered_tools exact-name-over-wildcard precedence, {{SUBAGENTS}} routing regeneration so disabled agents drop out of prompts, MIN_REPORTED_LINES + max-lines dedupe on context files.
— automated review (Claude)
## Review: M4 — Multiagent
This is the most intricate milestone and it's impressively coherent: permission intersection (`evaluate_intersected`, `deny > ask > allow`) is correct and well-tested, the depth limit is enforced and covered, foreground/background token scoping is thought through (background childs the parent *run* token so the job outlives the tool call), reuse genuinely continues the same child session, and the job board persists + reloads. One real bug and a couple of smaller notes.
### Job-board alias collisions after trimming (medium)
`JobBoard::next_alias` (`engine/jobs.rs:1756`) derives the numeric suffix from the *current* count of that agent's jobs:
```rust
let n = self.jobs.read().unwrap().values().filter(|j| j.agent == agent).count() + 1;
format!("{prefix}-{n}")
```
But `trim_reusable` (`jobs.rs:1980`) deletes completed jobs once `max_reusable_per_agent` is exceeded. So the count goes stale and a later launch reuses a live alias:
- explorer launches → `exp-1`, `exp-2`, `exp-3` (max_reusable = 2)
- all complete; trim drops the LRU `exp-1`; board now holds `{exp-2, exp-3}`
- next explorer launch: `count == 2` → alias `exp-3` — **collides with the existing `exp-3`**
Since jobs are keyed by `task_id` (not alias), both survive, and `resolve_reusable`/`touch`/the prompt board now match an arbitrary one of the two — the orchestrator can reuse or reference the wrong session. Use a monotonic per-agent counter (e.g. `max(existing suffix for agent) + 1`, or a persisted counter) instead of the live filtered count.
### `register_launch` failure silently untracks the job (low)
In `spawn` (`harness-app/src/lib.rs:565`) the launch registration is fire-and-forget (`let _ = board.register_launch(...)`), and the alias is then re-derived via `board.snapshot().find(...)` rather than using the value `register_launch` already returned. If the store write fails, the child still runs but never appears on the board, and the later `board.finish(&task_id, ...)` no-ops (the record doesn't exist) — the subtask vanishes from tracking with no signal. Consider using the returned alias directly and at least logging a registration failure.
### Design note: `depth_limit > 1` doesn't enable nested delegation by default (low)
`depth_guard_rules` (`lib.rs:249`) injects a blanket `task` **deny** for every session at `depth > 0`. So regardless of `orchestration.depth_limit` (default 3), a subagent can never delegate further unless an agent/config rule re-grants `task`. That's a reasonable conservative default, but it makes the depth counter and the `depth_limit` knob mostly moot for delegation — worth a doc note so `depth_limit: 3` isn't read as "3 levels of auto-delegation".
Nice touches: `filtered_tools` exact-name-over-wildcard precedence, `{{SUBAGENTS}}` routing regeneration so disabled agents drop out of prompts, `MIN_REPORTED_LINES` + max-lines dedupe on context files.
— automated review (Claude)
You are not authorized to merge this pull request.
This pull request can be merged automatically.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Review: M4 — Multiagent
This is the most intricate milestone and it's impressively coherent: permission intersection (
evaluate_intersected,deny > ask > allow) is correct and well-tested, the depth limit is enforced and covered, foreground/background token scoping is thought through (background childs the parent run token so the job outlives the tool call), reuse genuinely continues the same child session, and the job board persists + reloads. One real bug and a couple of smaller notes.Job-board alias collisions after trimming (medium)
JobBoard::next_alias(engine/jobs.rs:1756) derives the numeric suffix from the current count of that agent's jobs:But
trim_reusable(jobs.rs:1980) deletes completed jobs oncemax_reusable_per_agentis exceeded. So the count goes stale and a later launch reuses a live alias:exp-1,exp-2,exp-3(max_reusable = 2)exp-1; board now holds{exp-2, exp-3}count == 2→ aliasexp-3— collides with the existingexp-3Since jobs are keyed by
task_id(not alias), both survive, andresolve_reusable/touch/the prompt board now match an arbitrary one of the two — the orchestrator can reuse or reference the wrong session. Use a monotonic per-agent counter (e.g.max(existing suffix for agent) + 1, or a persisted counter) instead of the live filtered count.register_launchfailure silently untracks the job (low)In
spawn(harness-app/src/lib.rs:565) the launch registration is fire-and-forget (let _ = board.register_launch(...)), and the alias is then re-derived viaboard.snapshot().find(...)rather than using the valueregister_launchalready returned. If the store write fails, the child still runs but never appears on the board, and the laterboard.finish(&task_id, ...)no-ops (the record doesn't exist) — the subtask vanishes from tracking with no signal. Consider using the returned alias directly and at least logging a registration failure.Design note:
depth_limit > 1doesn't enable nested delegation by default (low)depth_guard_rules(lib.rs:249) injects a blankettaskdeny for every session atdepth > 0. So regardless oforchestration.depth_limit(default 3), a subagent can never delegate further unless an agent/config rule re-grantstask. That's a reasonable conservative default, but it makes the depth counter and thedepth_limitknob mostly moot for delegation — worth a doc note sodepth_limit: 3isn't read as "3 levels of auto-delegation".Nice touches:
filtered_toolsexact-name-over-wildcard precedence,{{SUBAGENTS}}routing regeneration so disabled agents drop out of prompts,MIN_REPORTED_LINES+ max-lines dedupe on context files.— automated review (Claude)
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.