feat(#1708): typed documentation-sourced #853 dispatch-flatten (ADR-1239 Phase B) (#1719)

* feat(#1708): typed documentation-sourced #853 dispatch-flatten

Graduate the #853 orchestrator-backgrounding decision from a scattered RUNTIME==='codex' prose check to a typed, documentation-sourced engine decision. Adds a backgroundDispatch dispatch sub-axis (sourced per host: codex+cursor documented true, 9 documented false, 5 undocumented), shouldFlattenDispatch(dispatch) (inline UNLESS background && backgroundDispatch, fail-closed), and a gsd_run query dispatch-should-flatten the plan/execute workflows call. Cursor is newly background-eligible per its docs (inline->background) — a documentation-justified behavior change. No RUNTIME-name residue for this decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1708): backgroundDispatch citations in matrix + CONTEXT note

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1708): address review findings on typed dispatch-flatten

Code/adversarial review: convert the manager.md/autonomous.md Compound Action preamble from hardcoded 'On Codex' to FLATTEN-based branching (the handlers already use the query; the preamble contradicted them and was wrong for cursor); make shouldFlattenDispatch null-safe + type-honest (accepts raw 'undocumented' registry values); make backgroundDispatch a required descriptor field (matching its siblings, all 16 carry it); strengthen the config.runtime behavioral test; update the bug-853 prose-pin test + comment. Security review clean; Codex confirmed no fail-open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1708): backfill backgroundDispatch in role:runtime test fixtures

Making backgroundDispatch a required descriptor field broke role:runtime fixtures in capability-manifest-version/capability-registry/host-integration-descriptors tests that build a dispatch object without it (caught by full gsd-test, not scoped npm test). Backfill backgroundDispatch:false into the well-formed fixtures; the deliberately-malformed 'required-field' test fixture is left malformed by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1708): update fix-1521 dispatch-gating assertion to the FLATTEN gate

fix-1521 pinned the codex-specific run_in_background prose that #1708 graduated to the typed dispatch-should-flatten/FLATTEN gate. Update its assertions to verify FLATTEN=false gating (not a runtime name) + that the old RUNTIME===codex gate is gone. Caught by full gsd-test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1708): add changeset for typed dispatch-flatten

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1708): remove stray temp PR-body file

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1708): add issue ref to bug-853 allow-test-rule annotations

ADR-456 requires every allow-test-rule exemption to carry a see #NNN reference; the source-text-is-the-product annotations added when migrating the prose assertions lacked it (lint-tests CI gate). Add (see #1708).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-25 15:37:33 -04:00
committed by GitHub
parent 481d121dd4
commit cf2e66b39e
33 changed files with 744 additions and 149 deletions

View File

@@ -70,6 +70,7 @@ interface DispatchCapability {
maxDepth: number;
background: boolean;
subagentToolkit: SubagentToolkit;
backgroundDispatch: boolean;
}
interface HostIntegrationAxes {
@@ -97,7 +98,7 @@ interface DegradationResult {
const SAFE_DEFAULTS: HostIntegrationAxes = {
embeddingMode: 'declarative',
commandSurface: 'prose-only',
dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only' },
dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only', backgroundDispatch: false },
modelMode: 'passive',
hookBus: 'none',
stateIO: 'session-log-append',
@@ -110,7 +111,7 @@ const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli'
'programmatic-cli': Object.freeze({
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' }),
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
@@ -120,7 +121,7 @@ const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli'
'declarative-cli': Object.freeze({
embeddingMode: 'declarative',
commandSurface: 'slash-file',
dispatch: Object.freeze({ namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full' }),
dispatch: Object.freeze({ namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full', backgroundDispatch: false }),
modelMode: 'passive',
hookBus: 'host',
stateIO: 'filesystem',
@@ -130,7 +131,7 @@ const PROFILE_BASELINES: Readonly<Record<'programmatic-cli' | 'declarative-cli'
'ide': Object.freeze({
embeddingMode: 'imperative',
commandSurface: 'palette',
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full' }),
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
modelMode: 'active',
hookBus: 'engine',
stateIO: 'sandboxed-storage',
@@ -255,7 +256,7 @@ const DEFAULT_ENGINE: EngineCapabilities = {
axes: {
embeddingMode: 'imperative',
commandSurface: 'slash-file',
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full' },
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true },
modelMode: 'active',
hookBus: 'host',
stateIO: 'filesystem',
@@ -368,17 +369,19 @@ function negotiateHostCapabilities(
let effectiveNamedDispatch: boolean;
let effectiveNested: boolean;
let effectiveBackground: boolean;
let effectiveBackgroundDispatch: boolean;
let effectiveSubagentToolkit: SubagentToolkit;
let effectiveMaxDepth: number;
if (hostDispatch === null) {
// Host didn't declare dispatch at all — fail-closed to most-restrictive values
warnings.push(`host did not declare 'dispatch'`);
effectiveNamedDispatch = false;
effectiveNested = false;
effectiveBackground = false;
effectiveSubagentToolkit = 'read-only';
effectiveMaxDepth = 0;
effectiveNamedDispatch = false;
effectiveNested = false;
effectiveBackground = false;
effectiveBackgroundDispatch = false;
effectiveSubagentToolkit = 'read-only';
effectiveMaxDepth = 0;
} else {
// N1: observability warnings for 'undocumented' sentinel on dispatch fields
if (hostDispatch.namedDispatch === 'undocumented') {
@@ -393,10 +396,14 @@ function negotiateHostCapabilities(
if (hostDispatch.subagentToolkit === 'undocumented') {
warnings.push(`dispatch.subagentToolkit is undocumented — degraded closed (read-only)`);
}
if (hostDispatch.backgroundDispatch === 'undocumented') {
warnings.push(`dispatch.backgroundDispatch is undocumented — degraded closed`);
}
effectiveNamedDispatch = (hostDispatch.namedDispatch === true) && engineDispatch.namedDispatch;
effectiveNested = (hostDispatch.nested === true) && engineDispatch.nested;
effectiveBackground = (hostDispatch.background === true) && engineDispatch.background;
effectiveNamedDispatch = (hostDispatch.namedDispatch === true) && engineDispatch.namedDispatch;
effectiveNested = (hostDispatch.nested === true) && engineDispatch.nested;
effectiveBackground = (hostDispatch.background === true) && engineDispatch.background;
effectiveBackgroundDispatch = (hostDispatch.backgroundDispatch === true) && engineDispatch.backgroundDispatch;
// subagentToolkit: fail closed to read-only unless explicitly 'full'
// (an 'undocumented' or 'read-only' value → read-only)
@@ -419,20 +426,22 @@ function negotiateHostCapabilities(
const minDepth = Math.min(hDepthNum, eDepthNum);
effectiveMaxDepth = minDepth === Infinity ? -1 : minDepth;
// If namedDispatch is false, cap maxDepth/nested/background to 0/false/false (struct consistency)
// If namedDispatch is false, cap maxDepth/nested/background/backgroundDispatch to 0/false/false/false (struct consistency)
if (!effectiveNamedDispatch) {
effectiveMaxDepth = 0;
effectiveNested = false;
effectiveBackground = false;
effectiveMaxDepth = 0;
effectiveNested = false;
effectiveBackground = false;
effectiveBackgroundDispatch = false;
}
}
const effectiveDispatch: DispatchCapability = {
namedDispatch: effectiveNamedDispatch,
nested: effectiveNested,
maxDepth: effectiveMaxDepth,
background: effectiveBackground,
subagentToolkit: effectiveSubagentToolkit,
namedDispatch: effectiveNamedDispatch,
nested: effectiveNested,
maxDepth: effectiveMaxDepth,
background: effectiveBackground,
subagentToolkit: effectiveSubagentToolkit,
backgroundDispatch: effectiveBackgroundDispatch,
};
// ---------------------------------------------------------------------------
@@ -474,6 +483,35 @@ function negotiateHostCapabilities(
};
}
// ---------------------------------------------------------------------------
// shouldFlattenDispatch — ADR-1239 Phase B / #1708
// ---------------------------------------------------------------------------
/**
* Returns true when the orchestrator MUST run inline (flatten); false when it
* may be backgrounded.
*
* A host may background only if it can reliably background a nesting-capable
* orchestrator — i.e. both `background` AND `backgroundDispatch` are
* explicitly `true`. Any other value (false, missing, 'undocumented') fails
* closed to inline (the always-safe path).
*
* This graduates the #853 prose rule (originally `RUNTIME === 'codex'`, then
* extended to cursor) to a typed, documentation-sourced decision; codex AND
* cursor are both background-eligible in the registry. See
* docs/reference/host-integration-capability-matrix.md.
*
* Null-safety: if dispatch is null, undefined, or not an object, returns true
* (inline, fail-closed) instead of throwing.
*/
type UnvalidatedDispatch = (Partial<DispatchCapability> & { background?: unknown; backgroundDispatch?: unknown }) | null | undefined;
function shouldFlattenDispatch(dispatch: UnvalidatedDispatch): boolean {
if (!dispatch || typeof dispatch !== 'object') return true;
const canBackground = dispatch.background === true && dispatch.backgroundDispatch === true;
return !canBackground;
}
// ---------------------------------------------------------------------------
// Module export (CommonJS — matches existing src/*.cts pattern)
// ---------------------------------------------------------------------------
@@ -488,4 +526,5 @@ export = {
degradationFor,
profileOf,
negotiateHostCapabilities,
shouldFlattenDispatch,
};