feat(spawn): AFK vs manual model/effort defaults + generic provider-options bag (ADR-0021) #20

Merged
dominik.polakovics merged 1 commit from feat/spawn-defaults-provider-options into main 2026-07-07 15:18:13 +02:00

Implements #19 — split AFK-run spawn defaults from the manual pre-fill, and add a generic provider-owned spawn-options bag (first entry: claude-code's AFK-only ultracode). model/effort stay typed and first-class; the bag is the extensible seam #2 (Codex) applies behind. Design was fully grilled with the operator on 2026-07-07 and recorded in ADR-0021.

What changed

Provider seamSpawnArgv(SpawnSpec{SessionName, Model, Effort, Options, InitialPrompt}) replaces the positional signature, so a new option never churns the interface. OptionSpec + SpawnOptions() declare a schema; Filter/ValidateSpawnOptions helpers. claude-code declares ultracode and applies it by prepending a provider-owned directive to a non-empty InitialPrompt — a manual/empty-prompt spawn is a natural no-op. Not a pinned Claude coupling (it's our wording); the argv builder is just re-snapshot tested.

ResolverResolveModelEffort is run-kind-aware (manual = request → repo.base → global.base; AFK = repo.afk ?? global.afk ?? repo.base ?? global.base, empty = inherit). New ResolveSpawnOptions (manual → empty; AFK → repo.afk_options ?? global.spawn_options_afk, filtered + validated to the provider). Both feed LaunchSpec.OptionsSpawnSpec.Options. The dialog-capture --settings flag stays among the flags, before the trailing prompt.

afk — passes the opaque bag through; ultracode never leaks into internal/afk.

Storage — global settings spawn_model_default_afk / spawn_effort_default_afk (empty = inherit) / spawn_options_afk (JSON); nullable repo columns afk_model_default / afk_effort_default / afk_options (JSON). Migration 0004 (sqlite + postgres). nil map = NULL = inherit; a present map (even {}) = explicit. Non-breaking: an operator who sets no override keeps today's behaviour. Deferred: no runs.options persistence (re-adoption never re-spawns); bool option type only.

APIGET /providers exposes options; settings PATCH validates the 3 AFK keys (empty model/effort allowed; bag keys/values against the provider schema → 400, all-or-nothing); repo PATCH plumbs the 3 fields (validated at spawn, mirroring model_default).

Web — schema-driven "AFK defaults" section in global + repo settings (shared CatalogSelect with an inherit entry + an ultracode checkbox; future options render automatically). StartInstanceForm unchanged. Also fixes a latent CatalogSelect selection-drop when the provider catalog resolves after the form mounts.

Docs — ADR-0021 + CONTEXT.md vocabulary (Spawn options; AFK default vs manual pre-fill).

Tests

Kind-aware resolver layering (base / AFK override / inherit), options resolve/filter/validate, LaunchSpecSpawnSpec threading, settings validation 400s + AFK roundtrip, repo afk-column roundtrip, ultracode compat snapshots (prepend for AFK, no-op for manual/empty prompt), and web inherit + checkbox behaviour.

Validation

  • go build + go test ./... — green
  • web build + 375 tests + lint — green
  • nix flake check (lab, labctl, web, golangci-lint, nixos-module) — all passed
  • Adversarial multi-agent review across 6 dimensions — 0 findings

Evolves ADR-0008 and brief decisions D12d/D14. Relates to #2.

Implements #19 — split AFK-run spawn defaults from the manual pre-fill, and add a generic provider-owned spawn-options bag (first entry: claude-code's AFK-only `ultracode`). model/effort stay typed and first-class; the bag is the extensible seam #2 (Codex) applies behind. Design was fully grilled with the operator on 2026-07-07 and recorded in **ADR-0021**. ## What changed **Provider seam** — `SpawnArgv(SpawnSpec{SessionName, Model, Effort, Options, InitialPrompt})` replaces the positional signature, so a new option never churns the interface. `OptionSpec` + `SpawnOptions()` declare a schema; `Filter/ValidateSpawnOptions` helpers. claude-code declares `ultracode` and applies it by prepending a **provider-owned directive** to a non-empty `InitialPrompt` — a manual/empty-prompt spawn is a natural no-op. Not a pinned Claude coupling (it's our wording); the argv builder is just re-snapshot tested. **Resolver** — `ResolveModelEffort` is run-kind-aware (manual = request → repo.base → global.base; AFK = repo.afk ?? global.afk ?? repo.base ?? global.base, empty = inherit). New `ResolveSpawnOptions` (manual → empty; AFK → `repo.afk_options ?? global.spawn_options_afk`, filtered + validated to the provider). Both feed `LaunchSpec.Options` → `SpawnSpec.Options`. The dialog-capture `--settings` flag stays among the flags, before the trailing prompt. **afk** — passes the opaque bag through; `ultracode` never leaks into `internal/afk`. **Storage** — global settings `spawn_model_default_afk` / `spawn_effort_default_afk` (empty = inherit) / `spawn_options_afk` (JSON); nullable repo columns `afk_model_default` / `afk_effort_default` / `afk_options` (JSON). Migration **0004** (sqlite + postgres). `nil` map = NULL = inherit; a present map (even `{}`) = explicit. Non-breaking: an operator who sets no override keeps today's behaviour. Deferred: no `runs.options` persistence (re-adoption never re-spawns); bool option type only. **API** — `GET /providers` exposes `options`; settings PATCH validates the 3 AFK keys (empty model/effort allowed; bag keys/values against the provider schema → 400, all-or-nothing); repo PATCH plumbs the 3 fields (validated at spawn, mirroring `model_default`). **Web** — schema-driven "AFK defaults" section in global + repo settings (shared `CatalogSelect` with an inherit entry + an `ultracode` checkbox; future options render automatically). `StartInstanceForm` unchanged. Also fixes a latent `CatalogSelect` selection-drop when the provider catalog resolves after the form mounts. **Docs** — ADR-0021 + CONTEXT.md vocabulary (**Spawn options**; **AFK default vs manual pre-fill**). ## Tests Kind-aware resolver layering (base / AFK override / inherit), options resolve/filter/validate, `LaunchSpec` → `SpawnSpec` threading, settings validation 400s + AFK roundtrip, repo afk-column roundtrip, ultracode compat snapshots (prepend for AFK, no-op for manual/empty prompt), and web inherit + checkbox behaviour. ## Validation - `go build` + `go test ./...` — green - web `build` + 375 tests + `lint` — green - `nix flake check` (lab, labctl, web, golangci-lint, nixos-module) — all passed - Adversarial multi-agent review across 6 dimensions — 0 findings Evolves ADR-0008 and brief decisions D12d/D14. Relates to #2.
Let the operator set different model/effort for AFK runs vs manual instances,
and add a generic, provider-owned spawn-options bag whose first entry is
claude-code's AFK-only ultracode flag. model/effort stay typed and first-class;
the bag is the extensible seam future providers (#2 Codex) apply behind.

- provider: SpawnSpec replaces positional SpawnArgv args; OptionSpec +
  SpawnOptions() declare a schema; Filter/ValidateSpawnOptions helpers.
  claude-code declares ultracode and applies it by prepending a provider-owned
  directive to a NON-EMPTY InitialPrompt (manual/empty prompt = natural no-op).
- instance: ResolveModelEffort is run-kind-aware (base + AFK-override layering);
  new ResolveSpawnOptions (manual -> empty; AFK -> repo.afk_options ?? global,
  filtered + validated). Both feed LaunchSpec.Options -> SpawnSpec.Options. The
  dialog-capture --settings flag stays before the trailing prompt positional.
- afk: passes the opaque bag through; ultracode never leaks into the package.
- store: settings keys spawn_model_default_afk / spawn_effort_default_afk
  (empty = inherit) / spawn_options_afk (JSON); nullable repo columns
  afk_model_default / afk_effort_default / afk_options (JSON). Migration 0004
  (sqlite + postgres). nil map = NULL = inherit; present (even {}) = explicit.
- httpapi: providers expose options; settings PATCH validates the 3 AFK keys
  (empty model/effort allowed; bag keys/values against the provider schema ->
  400, all-or-nothing); repo PATCH plumbs the 3 fields (validated at spawn).
- web: schema-driven "AFK defaults" section in global + repo settings (shared
  CatalogSelect with an inherit entry + ultracode checkbox); StartInstanceForm
  unchanged. Fix a latent CatalogSelect selection-drop when the provider catalog
  resolves after the form mounts.
- docs: ADR-0021 + CONTEXT.md vocabulary (Spawn options; AFK default vs manual
  pre-fill).

Tests: kind-aware resolver layering, options resolve/filter/validate, LaunchSpec
-> SpawnSpec threading, settings 400s + AFK roundtrip, repo afk-column roundtrip,
ultracode compat snapshots (prepend for AFK, no-op for manual/empty prompt), and
web inherit + checkbox behaviour.

Closes #19

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

This was generated by AI while landing a PR.

Landing audit — verdict: PASS

Verification signal relied on: local nix flake check (full build). The repo's ci / flake-check action has no runner and stays pending forever, so it cannot vouch — validated locally instead. All check derivations realized and all checks passed: lab, labctl, web (build + tests), golangci-lint, nixos-module eval. Head SHA 63ddb15 unchanged, so these are the exact outputs for this tree.

Checked

  • Gate: full nix flake check — green (Go build + go test ./..., web build + tests + lint, golangci-lint, nixos-module eval).
  • Convention: title feat(spawn): … — valid Conventional Commits.
  • Sync/conflicts: branch up to date with main, merge-base = main, zero conflicts.
  • Diff correctness review (7 surfaces, 37 files / +1832 −177): migrations 0004 sqlite⇄postgres parity + NULL=inherit; run-kind resolver precedence + empty-string=inherit; atomic all-or-nothing settings validation (400 before write); nil-vs-empty-map round-trip in store; ultracode prepend no-op on empty prompt. All match ADR-0021 and the tests.

Non-blocking follow-ups (not merge blockers)

  • CONCERNweb/src/routes/RepoSettings.tsx (~L283): the ultracode checkbox is 2-state over a 3-state inherit model. A repo inheriting a global ultracode:"true" renders unchecked while effectively running ultracode; unchecking stores explicit false with no UI path back to inherit. By-design per the ADR (null=inherit / present=explicit) and the hint text, but can mislead an operator about whether an AFK fleet runs ultracode. Worth a UI follow-up.
  • NITinternal/provider/claudecode/claudecode.go (~L283): whitespace-only InitialPrompt isn't treated as empty, so it'd receive the directive. Unreachable in current flows (manual passes "", AFK passes substantive text).

Note: the body says "Implements #19", which is not a Forgejo closing keyword — the merge will not auto-close #19; it will be closed manually on merge.

> *This was generated by AI while landing a PR.* ## Landing audit — verdict: **PASS** **Verification signal relied on:** local `nix flake check` (full build). The repo's `ci / flake-check` action has no runner and stays pending forever, so it cannot vouch — validated locally instead. All check derivations realized and **all checks passed**: `lab`, `labctl`, `web` (build + tests), `golangci-lint`, `nixos-module` eval. Head SHA `63ddb15` unchanged, so these are the exact outputs for this tree. **Checked** - **Gate:** full `nix flake check` — green (Go build + `go test ./...`, web build + tests + lint, golangci-lint, nixos-module eval). - **Convention:** title `feat(spawn): …` — valid Conventional Commits. - **Sync/conflicts:** branch up to date with `main`, merge-base = `main`, zero conflicts. - **Diff correctness review** (7 surfaces, 37 files / +1832 −177): migrations 0004 sqlite⇄postgres parity + NULL=inherit; run-kind resolver precedence + empty-string=inherit; atomic all-or-nothing settings validation (400 before write); nil-vs-empty-map round-trip in store; ultracode prepend no-op on empty prompt. All match ADR-0021 and the tests. **Non-blocking follow-ups (not merge blockers)** - *CONCERN* — `web/src/routes/RepoSettings.tsx` (~L283): the ultracode checkbox is 2-state over a 3-state inherit model. A repo inheriting a global `ultracode:"true"` renders **unchecked** while effectively running ultracode; unchecking stores explicit `false` with no UI path back to inherit. By-design per the ADR (null=inherit / present=explicit) and the hint text, but can mislead an operator about whether an AFK fleet runs ultracode. Worth a UI follow-up. - *NIT* — `internal/provider/claudecode/claudecode.go` (~L283): whitespace-only `InitialPrompt` isn't treated as empty, so it'd receive the directive. Unreachable in current flows (manual passes `""`, AFK passes substantive text). **Note:** the body says *"Implements #19"*, which is not a Forgejo closing keyword — the merge will **not** auto-close #19; it will be closed manually on merge.
Sign in to join this conversation.
No reviewers
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
Cloonar/coding-lab!20
No description provided.