fix(#2598): declare OpenCode subagent dispatch synchronous, not background (#2682)

* fix(#2598): declare OpenCode subagent dispatch synchronous, not background

capabilities/opencode/capability.json advertised dispatch.background: true and
dispatch.backgroundDispatch: true. negotiateHostCapabilities and every
degradationFor / shouldFlattenDispatch consumer trusts these per-field values, so
declaring a capability the host lacks OVERSTATES it — the opposite of the
fail-closed posture the negotiation is built for.

The issue's own citations needed checking before acting: the host-integration
matrix (ADR-1239's designated deployment source-of-truth) documented `true` with
NEWER evidence than the issue cited, and explicitly marked the issue's
sst/opencode#5887 reference as a stale snapshot superseded by #2087. git log
confirms #2087 deliberately flipped these from false to true, citing OpenCode
v1.15.0/v1.17 as "background subagents enabled by default in all modes". Applying
the issue as filed would, on that evidence, have REGRESSED a deliberate update.

So the claim was verified against current upstream rather than either document.
`packages/opencode/src/effect/runtime-flags.ts` on `dev` today reads:

    experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS")

`enabledByExperimental` falls back to the `experimental` flag and `bool()`
defaults to false — the parameter is hidden from the model unless an operator
opts in by env var. Upstream #29638 is still OPEN and confirms the session loop
`tasks.pop()`s one subtask at a time. #2087's "default-on in all modes" reading
does not hold against current dev.

The issue's CONCLUSION is therefore right even though part of its evidence was
superseded: concurrent dispatch cannot be relied on, so both fields are false.

The matrix rows are corrected with the verified citation rather than reverted to
the old #5887 quote, so the record shows why the value is false TODAY rather than
re-asserting evidence that was legitimately superseded. Neighbouring sub-fields
are untouched and pinned by test: namedDispatch, subagentToolkit, and
isolation:'orchestrator-worktree' (which works via `opencode run --dir` at the OS
process level and is unaffected — #2584 does not depend on this value either way).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ

* fix(#2598): re-pin the dispatch contract tests to synchronous OpenCode dispatch

gsd-test on the descriptor change came back FAILED (5 unique, both node
versions). The failures were not incidental — they were deliberate contract-pin
tests encoding #2087's decision, one named literally "background UPGRADE":

  tests/host-integration-descriptors.test.cjs
    - EXPECTED_FLATTEN[opencode] === false (background-eligible)
    - the derived background-eligible set pin
  tests/opencode-imperative-reference.test.cjs
    - "descriptor declares background dispatch true/true (v1.15/v1.17 upgrade)"
    - "background UPGRADE changes shouldFlattenDispatch: false now"

So this is a recorded decision being reversed, not drift being corrected, and it
is reversed on evidence: current upstream `dev` gates the capability behind
OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS (default false) and upstream #29638
(OPEN) confirms the session loop still handles one subtask at a time. The issue
is filed by the maintainer and explicitly directs "update golden-parity /
validator fixtures as needed", which sanctions re-pinning.

Behavioral consequence, verified: shouldFlattenDispatch(opencode) now returns
TRUE, so GSD serializes opencode dispatch instead of trusting concurrency it
cannot get. That is the correct fail-closed direction and is safe today — no
shipped GSD flow drives OpenCode background waves (per the issue), and
isolation:'orchestrator-worktree' is unaffected because it works at the OS
process level via `opencode run --dir`, not via the native subagent.

Each re-pinned test now asserts the retracted contract in the opposite
direction — feeding the #2087 axes back in must still yield "would not flatten" —
so a silent re-flip of either field is caught rather than merely un-asserted.

lint:ci exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ

* chore(#2598): backfill changeset pr number (#2682)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-26 21:47:54 -04:00
committed by GitHub
parent a5633bb32f
commit a3853472de
7 changed files with 141 additions and 28 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2682
---
**OpenCode no longer declares background subagent dispatch it does not have** — `capabilities/opencode/capability.json` advertised `dispatch.background` and `dispatch.backgroundDispatch` as `true`, but OpenCode's native subagent dispatch is synchronous: the Task tool's `background` parameter is hidden from the model behind the opt-in `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag, which defaults to false, and the session loop still handles one subtask at a time. Since `negotiateHostCapabilities` and every `degradationFor` consumer trusts these per-field values, declaring an absent capability overstated it — the opposite of the fail-closed posture the negotiation exists to enforce. Both fields are now `false`, and the host-integration capability matrix carries the corrected values with current upstream citations. (#2598)

View File

@@ -75,9 +75,9 @@
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"background": false,
"subagentToolkit": "full",
"backgroundDispatch": true,
"backgroundDispatch": false,
"isolation": "orchestrator-worktree"
},
"modelMode": "active",

View File

@@ -142,9 +142,9 @@ Documentation gaps:
| dispatch.namedDispatch | true | https://opencode.ai/docs/agents | "\"Subagents can be invoked: Automatically by primary agents for specialized tasks based on their descriptions. Manually b" |
| dispatch.nested | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
| dispatch.maxDepth | undocumented | no authoritative doc — searched: https://opencode.ai/docs/agents | — |
| dispatch.background | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/tool/task.ts (v1.15.0, commit 22de34c4d) + src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) | "New in v1.15.0: experimental background subagents — the Task tool gains a `background` parameter (`Schema.optional(Schema.Boolean)`) that launches subagents asynchronously with completion notifications. v1.17: `BACKGROUND_SUBAGENTS_ENABLED = true` (\"feat: enable background subagents by default\") — default-on, concurrent execution in all modes. (#2087, superseding the stale sst/opencode#5887 snapshot)" |
| dispatch.background | false | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts ; https://github.com/anomalyco/opencode/issues/29638 | "`experimentalBackgroundSubagents: enabledByExperimental(\"OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS\")` — `enabledByExperimental` falls back to the `experimental` flag, and `bool()` defaults to `false`, so the Task tool's `background` parameter is hidden from the model unless the operator opts in by env var. #29638 (OPEN) confirms the session loop still `tasks.pop()`s one subtask at a time. (#2598 — corrects #2087, whose \"v1.17 default-on in all modes\" reading does not hold against current `dev`)" |
| dispatch.subagentToolkit | full | https://opencode.ai/docs/agents | "The 'general' subagent \"Has full tool access (except todo), so it can make file changes when needed.\"" |
| dispatch.backgroundDispatch | true | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts (v1.17, commit 81f6e0668) + src/server/routes/instance/httpapi/handlers/experimental.ts | "v1.17 `BACKGROUND_SUBAGENTS_ENABLED = true` enables background subagent execution by default in all modes; the experimental capabilities endpoint exposes `{ backgroundSubagents: true }`. Background-spawned subagents run concurrently without blocking the main interaction flow. (#2087)" |
| dispatch.backgroundDispatch | false | https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/runtime-flags.ts ; https://github.com/anomalyco/opencode/issues/29638 ; https://github.com/anomalyco/opencode/issues/14195 | "Concurrent dispatch requires the opt-in `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag (default `false`), so it cannot be relied on. #14195: \"the session loop does `tasks.pop()` to grab a single subtask, `await`s it, then `continue`s the loop — so even 3 simultaneous Task calls run sequentially.\" Declaring `true` would overstate the capability, against the fail-closed posture negotiation is built for. (#2598 — corrects #2087)" |
| dispatch.isolation | orchestrator-worktree | https://opencode.ai/docs/cli ; opencode.ai/docs/plugins ; opencode issues #14195/#29638/#5887 | "`opencode run --dir <path>` sets an explicit working root at the process level" — native subagent dispatch is synchronous-only, so GSD creates + manages the worktree and process-spawns the executor into it via `--dir` (#2584) |
Sources consulted:

View File

@@ -2174,9 +2174,9 @@ const capabilities = {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"background": false,
"subagentToolkit": "full",
"backgroundDispatch": true,
"backgroundDispatch": false,
"isolation": "orchestrator-worktree"
},
"modelMode": "active",
@@ -5411,9 +5411,9 @@ const runtimes = {
"namedDispatch": true,
"nested": "undocumented",
"maxDepth": "undocumented",
"background": true,
"background": false,
"subagentToolkit": "full",
"backgroundDispatch": true,
"backgroundDispatch": false,
"isolation": "orchestrator-worktree"
},
"modelMode": "active",

View File

@@ -0,0 +1,91 @@
/**
* #2598 — the OpenCode descriptor declared background/concurrent subagent
* dispatch that OpenCode does not actually provide by default.
*
* `capabilities/opencode/capability.json` carried
* `runtime.hostIntegration.dispatch.background: true` and
* `dispatch.backgroundDispatch: true`. OpenCode's native subagent dispatch
* (Task tool / `@`-mention / `subtask`) is synchronous: the `background`
* parameter is hidden from the model behind the opt-in
* `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` flag, which defaults to false
* (`enabledByExperimental(...)` over a `bool()` that defaults false), and the
* session loop still `tasks.pop()`s one subtask at a time (upstream #14195,
* #29638 — the latter still open).
*
* `negotiateHostCapabilities` and every `degradationFor`/`shouldFlattenDispatch`
* consumer TRUSTS these per-field values, so declaring a capability the host
* lacks overstates it — the opposite of the fail-closed posture the negotiation
* exists to enforce.
*
* History note: these fields were flipped to `true` by #2087 citing a reading of
* OpenCode v1.17 as "background subagents enabled by default in all modes".
* That reading does not hold against current upstream `dev`, where the flag is
* opt-in. This test pins the corrected values so a future descriptor edit cannot
* silently re-assert an unsupported capability.
*/
// allow-test-rule: runtime-contract-is-the-product #2598 — the descriptor JSON and the
// host-integration matrix ARE the negotiated contract; asserting their values is behavioral.
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const DESCRIPTOR = path.join(ROOT, 'capabilities', 'opencode', 'capability.json');
const MATRIX = path.join(ROOT, 'docs', 'reference', 'host-integration-capability-matrix.md');
function opencodeDispatch() {
const parsed = JSON.parse(fs.readFileSync(DESCRIPTOR, 'utf8'));
return parsed.runtime.hostIntegration.dispatch;
}
describe('#2598: OpenCode does not declare background/concurrent subagent dispatch', () => {
test('descriptor declares background: false', () => {
assert.equal(
opencodeDispatch().background,
false,
'OpenCode subagent dispatch is synchronous unless an experimental opt-in flag is set',
);
});
test('descriptor declares backgroundDispatch: false', () => {
assert.equal(
opencodeDispatch().backgroundDispatch,
false,
'concurrent dispatch requires an opt-in flag, so it must not be declared as available',
);
});
test('the capabilities that ARE real are left intact', () => {
// Narrow the blast radius: this fix must not quietly downgrade neighbouring
// sub-fields that were never in question.
const d = opencodeDispatch();
assert.equal(d.namedDispatch, true, 'named subagent dispatch is genuinely supported');
assert.equal(d.subagentToolkit, 'full', 'the general subagent has full tool access');
assert.equal(d.isolation, 'orchestrator-worktree',
'isolation is orchestrator-managed via `opencode run --dir`, unaffected by #2598');
});
test('the host-integration matrix agrees with the descriptor', () => {
// ADR-1239 designates the matrix the deployment source-of-truth; a
// descriptor/matrix disagreement is how this defect survived in the first
// place (the matrix said true, the ADR binding table said false).
const matrix = fs.readFileSync(MATRIX, 'utf8');
const section = matrix.slice(matrix.indexOf('## opencode'));
const end = section.indexOf('\n## ');
const opencodeSection = end === -1 ? section : section.slice(0, end);
for (const field of ['dispatch.background', 'dispatch.backgroundDispatch']) {
const row = opencodeSection.split('\n').find((l) => l.startsWith(`| ${field} |`));
assert.ok(row, `matrix must document ${field} for opencode`);
const value = row.split('|')[2].trim();
assert.equal(value, 'false', `matrix ${field} must match the descriptor`);
}
});
});

View File

@@ -268,8 +268,8 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
// ─── shouldFlattenDispatch per-host (#853 discriminator) ─────────────────────
// Expected: false (may background) for codex, cursor, kimi, and opencode;
// true (must inline) for the other 13.
// Expected: false (may background) for codex, cursor, kimi, and kimi-code;
// true (must inline) for the other 14.
const EXPECTED_FLATTEN = {
antigravity: true,
augment: true,
@@ -290,9 +290,12 @@ describe('ADR-1239 Phase A: hostIntegration descriptors', () => {
// kimi-cli per Kimi Code docs (dispatch.background/backgroundDispatch both
// true) → NOT force-flattened.
'kimi-code': false,
// #2087: OpenCode background subagents (v1.15 param, v1.17 default-on) →
// dispatch.background/backgroundDispatch true → NOT force-flattened.
opencode: false,
// #2598: OpenCode's background subagents sit behind the opt-in
// OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS flag (default false), and the
// session loop still handles one subtask at a time (upstream #29638, OPEN).
// #2087 read v1.15/v1.17 as default-on; that does not hold against current
// `dev`, so dispatch.background/backgroundDispatch are false → force-flattened.
opencode: true,
// #2102: pi's dispatch.background/backgroundDispatch are both false
// (undocumented background-subagent primitive) → force-flattened.
pi: true,

View File

@@ -6,9 +6,10 @@
*
* Proves opencode is driven through the PUBLIC Host-Integration Interface (the
* imperative adapter), that its negotiated axes classify + negotiate correctly,
* that negotiation fails CLOSED on a corrupted descriptor, that the Context7-
* verified dispatch UPGRADE (background subagents, v1.15/v1.17) changes
* `shouldFlattenDispatch`, and that the migration retired the hardcoded
* that negotiation fails CLOSED on a corrupted descriptor, that opencode's
* SYNCHRONOUS dispatch force-flattens (#2598 retracts #2087's background
* "upgrade" — the capability is behind an opt-in flag, not default-on), and that
* the migration retired the hardcoded
* `runtime === 'opencode'` / `isOpencode` branches (folded into descriptor-driven
* `runtime.hostBehaviors` + the combined-family engine install path).
*/
@@ -48,21 +49,34 @@ test('opencode axes classify as the programmatic-cli reference profile', () => {
assert.equal(profileOf(OC_AXES), 'programmatic-cli');
});
// -- AC4: the Context7-verified UPGRADE (background dispatch) -----------------
// -- AC4: dispatch is synchronous — the #2087 "upgrade" is retracted (#2598) --
test('opencode descriptor declares background dispatch true/true (v1.15/v1.17 upgrade)', () => {
assert.equal(OC_AXES.dispatch.background, true, 'background subagents (v1.15 param, v1.17 default-on)');
assert.equal(OC_AXES.dispatch.backgroundDispatch, true);
test('opencode descriptor declares background dispatch false/false (#2598)', () => {
// #2087 set these true, reading OpenCode v1.15/v1.17 as "background subagents
// enabled by default in all modes". That reading does not hold against current
// upstream `dev`, where the capability is opt-in:
// experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS")
// `enabledByExperimental` falls back to the `experimental` flag and `bool()`
// defaults false, so the Task tool's `background` parameter is hidden from the
// model unless an operator opts in. Upstream #29638 (OPEN) confirms the session
// loop still `tasks.pop()`s one subtask at a time.
assert.equal(OC_AXES.dispatch.background, false,
'background subagents are behind an opt-in experimental flag, not default-on');
assert.equal(OC_AXES.dispatch.backgroundDispatch, false,
'concurrent dispatch cannot be relied on, so it must not be declared');
});
test('background UPGRADE changes shouldFlattenDispatch: false now (may background), true for the old axes', () => {
// Post-upgrade: opencode may run subagents concurrently → NOT force-flattened.
assert.equal(shouldFlattenDispatch(OC_AXES.dispatch), false,
'with background:true+backgroundDispatch:true, GSD must NOT force-flatten opencode dispatch');
// Pin the behavioral change: the pre-#2087 axes DID force-flatten.
const preUpgrade = { ...OC_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' };
assert.equal(shouldFlattenDispatch(preUpgrade), true,
'pre-upgrade (background:false) opencode was force-flattened — this is the behavioral change #2087 lands');
test('synchronous dispatch force-flattens; the retracted axes would not have', () => {
// Declaring a capability the host lacks is the failure mode #2598 closes:
// negotiation is built to fail CLOSED, so an unavailable concurrency
// capability must serialize rather than be trusted.
assert.equal(shouldFlattenDispatch(OC_AXES.dispatch), true,
'with background:false, GSD must force-flatten opencode dispatch (fail closed)');
// Pin the retracted contract so a silent re-flip is caught: had the #2087
// values been accurate, dispatch would NOT have been flattened.
const retracted = { ...OC_AXES.dispatch, background: true, backgroundDispatch: true };
assert.equal(shouldFlattenDispatch(retracted), false,
'the #2087 axes did not flatten — that is exactly the overstatement #2598 retracts');
});
test('opencode extension-event surface includes the #2087 additions (permission + session.error)', () => {