* fix(#4148): dispatch wave-pre step hooks External capabilities can render step hooks before a wave, but the execute workflow consumed only contributions and silently skipped every step. Reuse the shared dispatch contract before executor spawning and pin the capability-validator boundary with a red-first regression. Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning * test(#4148): pin wave-pre dispatch ordering * test(#4148): pin wave-pre dispatch contract * chore(#4148): bind upstream changeset PR * chore(#4148): restore fork changeset identity * fix(#4148): align wave-pre dispatch contract Mirror the sibling wave-post all-shapes clarification while pruning redundant prose so the rebased workflow remains below its frozen byte ceiling. Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning * chore(#4148): restore upstream changeset identity * fix(#4148): align wave-pre capability guidance * docs(#4148): identify wave-pre manifest input Name the third-party manifest trust origin at the wave-pre dispatch boundary so the reviewer-requested validation guidance matches wave-post. Emitted-Drift-Ack-Growth: execute-phase.md — wave-pre now carries the missing generic step-dispatch contract before executor spawning * docs(#4148): preserve execute-phase byte budget Remove a redundant advisory label while retaining the non-blocking contract, keeping the reviewer-required trust-boundary wording at the enforced 93,400-byte ceiling. * fix(#4148): mark wave-pre manifest-input validation as security-relevant Reviewer nit on PR #4185: wave-pre's step-dispatch sentence had the (third-party manifest input) parenthetical but dropped the ⚠ marker that wave-post's parallel sentence (execute-phase.md:1044) carries, losing the visual flag that this validation is security-motivated. Trims the redundant "of one" from "not one shape of one" to reclaim the 4 bytes the marker adds — the ADR-857 byte-margin gate (tests/claude-orchestration.test.cjs) leaves zero slack at the 93,400-byte ceiling. * fix(#4148): trim wave-pre step-dispatch prose to clear ADR-857 byte ceiling Merging next's unrelated growth (#3990's TDD_APPLICABLE conditional) pushed execute-phase.md 116 bytes past the 93,400-byte ceiling, failing CI on all three platforms. The security-relevant ⚠ marker and ref.command validation call-out (added per prior reviewer nit) are preserved verbatim per the pinned regression test in capability-registry.test.cjs; only the non-pinned connective prose is trimmed. * fix(#4148): recalibrate execute-phase.md self-imposed margin, restore security marker next grew execute-phase.md by ~230 bytes across two unrelated merges during this fix (#3990's TDD_APPLICABLE conditional, then a further step-extraction commit), consuming this test's own self-imposed 93,400 safety buffer under ADR-857's actual, unmodified 93,600 ceiling (docs/adr/857-capability-system.md:22). The wave-pre step-dispatch sentence cannot shrink further without dropping one of the pinned substrings this same test file asserts on (kind=="step", loop-hook-dispatch, never blocks or redirects executor spawning, Validate `ref.command`). Raises the self-imposed margin to 93,550 (still 50 bytes under the real, untouched ADR ceiling) and restores the ⚠ marker the prior reviewer round required for the ref.command validation call-out, which byte pressure had dropped. * fix(#4148): restore full ref.command validation wording, drop self-imposed margin Adversarial review (agy/gemini-3.8-flash-high) flagged two issues in the prior CI-recovery commit: 1. Trimming "in-context before any shell use" from the step-dispatch warning weakened the inline operational instruction (the reader is told WHAT to validate but not the specific in-context-not-shell mechanism the referenced loop-hook-dispatch.md:45-51 threat model requires). Restored it - the merge with next since the last commit freed enough real margin (77 bytes under the untouched 93,600 ADR-857 ceiling) to afford it without any margin change. 2. The prior commit self-imposed margin bump (93400 to 93550) was, on reflection, the wrong lever: it is a number this PR invented, not an ADR value, and re-bumping it every time next grows execute-phase.md is a losing pattern (already needed twice in one session). Removed the redundant assertion; the same line existing bytes-under-93600 check against the real, frozen ADR-857 ceiling (docs/adr/857-capability-system.md:22) is the actual invariant and is untouched. workflow-size-budget.test.cjs tier hard cap (98304 bytes, extract-not-bump by design) remains the correct backstop for runaway growth. --------- Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Test <test@test.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
7.2 KiB
Long-running operations and async external jobs
Reference (Diátaxis). The operation-classification policy and the async external-job contract that GSD executors use when a task legitimately exceeds a child-agent timeout. The producer is the default-off
external-jobCapability (#1164, part of #1105); the core loop consumes the manifest (#1165 —external_job_waiting).
1. The problem
Heavy GSD phases (HPC solvers, model training, large simulations) can legitimately exceed short child-agent timeouts. Raising the timeout alone is not sufficient: it prevents legitimate subagents from being killed, but it also lets truly hung commands consume the whole agent budget. GSD must distinguish legitimate heavy work from suspicious hangs, keep safety nets finite, and avoid blocking an agent turn on hours-long compute.
2. Operation classification policy
Every executable task carries a runtime budget. Planners emit it via a
<runtime_budget> element (taught by the external-job Capability's
plan:post fragment); executors branch on it at execute:wave:post.
Contribution point.
#1164specifies classification atexecute:wave:preand recording atexecute:wave:post. This Capability still contributes executor guidance atexecute:wave:post;execute-phase.mdnow renderswave:preentries and dispatches generic step hooks there. Moving external-job classification is a separate capability change, not part of#4148. Until then, this guidance cannot classify the wave that already ran.
| Budget | Meaning | Execute behavior |
|---|---|---|
quick |
Under ~2 min | Run normally in the foreground. |
medium |
~2–30 min | Foreground, but with explicit progress expectations. |
unknown |
Runtime not characterized | Run a first-health check and set a soft-review deadline before trusting the child timeout. Define a progress signal, an abort condition, and expected output. A truly hung command must surface before the child timeout is exhausted. |
long_compute |
Over ~30–60 min | Externalize — submit an async external job, record durable state, and return external_job_waiting. Never block the agent turn. |
The classification is advisory metadata; the executor owns the decision at
dispatch time. unknown is the safety-critical class: it is what catches a
hung solver before it burns the budget, without making timeouts infinite.
3. The async external-job half-state
When an executor externalizes a long_compute task it enters a legal
deferred state, not an illegal partial-plan state:
input/code committed
external job submitted
.planning/async-jobs/<job>.json committed
handoff committed
SUMMARY.md deferred until verification
SUMMARY.md is deferred until the job reaches a terminal state and its
expected_artifacts are verified. Until then, the plan is
external_job_waiting, and every resume/pause/dispatch path reconciles
against the manifest — it never re-dispatches the plan (re-dispatching would
duplicate the external job).
4. The manifest — a versioned stability contract
The manifest schema, status enum, trust boundary, matching rules, and the
glob-safe matching probe are the stability contract documented in
planning-artifacts.md.
The core loop depends only on the named fields and ignores any others; the
version field is the evolution escape hatch. Producers MUST write the named
fields and MAY add their own.
Status enum (closed, scheduler-agnostic — producers map backend states onto these):
| Status | Class | Resume action |
|---|---|---|
submitted, running |
non-terminal | Re-check; never re-dispatch. |
completed-unverified |
finished, unverified | Verify expected_artifacts / run verification_command; on success write SUMMARY.md and close. |
failed, cancelled, timeout |
terminal failure | Surface terminal_details; offer recovery (re-run reconciliation, abort, or mark-and-skip). Resubmitting compute is a user action, never automatic. |
5. Trust boundary
The manifest crosses a trust seam: a Capability (or anything that can write
.planning/) produces it; the core loop consumes it. submit_command,
verification_command, and resume_command are therefore untrusted. The
core loop — and the slurm-adapter show subcommand — surface these commands
for explicit operator confirmation; they are never auto-executed. Validate
before trusting a manifest: recognized version, plan_id matches the plan
under reconciliation, and status is one of the closed enum values. On a
malformed manifest or multiple manifests for one plan_id, fail closed.
6. Scheduler pluggability
SLURM is the first backend. The design does not hardcode a cluster, account,
partition, or project layout — per-job artifact directories
(external_job.artifact_dir, default Artifacts/jobs/<jobid>/) avoid fixed
log paths. The backend field on the manifest (opaque to core) and the
external_job.backend config key are the pluggability seams for future
backends (LSF, PBS, Kubernetes batch). The pure producer logic — SLURM
state→manifest-status mapping, manifest build/validate, sbatch/squeue/
sacct parsers, and the fail-closed writer — is backend-aware but lives
behind a single module; a new backend adds a sibling state map and parser
without touching core.
7. Configuration
The external-job Capability declares its config keys in
capability.json and the
slurm-adapter resolves them through the canonical capability-config seam
(resolveConfigKey in capability-activation.cjs). **Precedence: env override
nested config value > registry default.**
| Key | Default | Env override | Read by |
|---|---|---|---|
external_job.enabled |
false |
— | Capability gate (when on both contributions). Master toggle; default-off. |
external_job.backend |
slurm |
— | Pluggability seam (LSF/PBS/K8s future). Core never interprets it. |
external_job.artifact_dir |
Artifacts/jobs |
GSD_EXTERNAL_JOB_ARTIFACT_DIR |
Adapter surfaces the resolved root in submit output. |
external_job.submit_timeout_ms |
30000 |
GSD_SLURM_SUBMIT_TIMEOUT_MS |
Adapter bounds the sbatch subprocess. |
external_job.poll_timeout_ms |
15000 |
GSD_SLURM_POLL_TIMEOUT_MS |
Adapter bounds the squeue/sacct subprocess. |
Config keys live nested under external_job in .planning/config.json, e.g.:
{ "external_job": { "enabled": true, "submit_timeout_ms": 45000 } }
The timeouts are load-bearing for the bounded-subprocess policy: the adapter
never unbounds a scheduler subprocess, and a non-numeric config value falls back
to the registry default rather than producing NaN (no guessing).
8. Related
- How-To:
../how-to/async-external-jobs.md— using the SLURM adapter. - Contract:
planning-artifacts.md— the manifest stability contract. - Capability manifest:
../../capabilities/external-job/capability.json. - Pure module:
gsd-core/src/external-job.cts→gsd-core/bin/lib/external-job.cjs. - Operator CLI:
scripts/slurm-adapter.cjs.