feat(spawn): split AFK vs manual model/effort defaults + generic provider-options bag (Claude Code ultracode) #19

Closed
opened 2026-07-07 13:55:05 +02:00 by dominik.polakovics · 0 comments

Summary

Let the operator configure different model/effort for AFK runs vs manual instances, and add a generic, provider-owned spawn-options bag whose first (and currently only) entry is Claude Code's AFK-only ultracode flag.

model/effort stay first-class and typed (ADR-0008 / D14 unchanged). The new options bag is the extensible seam that makes "the interface works for all providers" real: when Codex (#2) / Gemini land, each declares its own spawn options and applies them behind the same seam — zero refactor of the typed core.

Design was fully grilled with the operator on 2026-07-07; the decisions below are settled — do not re-litigate them, record them in an ADR (see Deliverables).

Decisions (settled)

Product shape

  1. Manual and AFK diverge. A manual instance always shows the operator the Start form with model/effort pre-filled, so a "manual default" is a soft pre-fill the operator overrides per spawn. An AFK run has no operator: its defaults are hard — they are literally what runs. The genuinely new capability is AFK-specific defaults that reliably differ from the manual pre-fill, plus ultracode.
  2. B2 layering — base + AFK override. The existing globals spawn_model_default/spawn_effort_default and the repo columns model_default/effort_default keep their current meaning as the base (used by manual, and by AFK as a fallback). Add optional AFK-override slots at both scopes; empty = inherit base. Manual resolution is unchanged (request → repo.base → global.base); AFK resolution becomes repo.afk ?? global.afk ?? repo.base ?? global.base. Non-breaking: existing values keep working, AFK behaviour changes only where an override is set.
  3. ultracode is AFK-only and defaults-only. It is a boolean provider option; manual users just type the keyword themselves. There is no per-run override: AFK is defaults-only for both the auto scheduler and manual start (POST /repos/{id}/afk/start stays bodyless). Confirmed against Claude Code docs/changelog/--help: ultracode is a prompt trigger keyword (renamed from "workflow" in v2.1.186), not a CLI flag, env var, or settings.json field — which is exactly why application is prompt injection, not argv.

Mechanism

  1. Hybrid, not a full generalization. model/effort remain typed & first-class across the interface, run row, repo columns, settings, API, and UI (ADR-0008/D14 intact). Beside them sits ONE generic bag: the provider declares its options schema, lab stores/validates/renders the bag generically, and the provider applies it. ultracode is claude-code's first entry.

  2. Application is provider-owned and is NOT argv. Replace the positional SpawnArgv(session, model, effort, prompt) with SpawnArgv(SpawnSpec{SessionName, Model, Effort, Options, InitialPrompt}) so future options never churn the interface signature. claude-code applies Options["ultracode"] by prepending a provider-owned ultracode directive line to a non-empty InitialPrompt; a manual instance passes an empty prompt → natural no-op (matches decision 3). internal/afk stays provider-agnostic — ultracode must not leak into the afk package (unlike incogni, which is a lab-domain repo flag). Provider-owned prepend text, tunable freely, e.g.:

    Operate in ultracode mode: decompose substantial work into a multi-agent workflow.
    
    You are an autonomous AFK run. Resolve exactly one issue, open a pull request, and stop.
    1. Run `labctl issue view N` …
    
  3. Validation mirrors model/effort. The bag validates against the resolving repo's provider schema — unknown option key or bad value → 400, same discipline as an unknown model/effort today. Empty AFK model/effort is explicitly allowed (means inherit). A global bag may span providers once >1 exists; at spawn it is filtered to the repo's provider.

Architecture

  1. Storage (base keys/columns unchanged):
    • Global settings (new rows, no schema change): spawn_model_default_afk, spawn_effort_default_afk (empty = inherit), spawn_options_afk (JSON, e.g. {"ultracode":"true"}).
    • Repo (repos, new nullable columns): afk_model_default, afk_effort_default, afk_options (JSON).
    • One migration, next free number, sqlite + postgres mirrors.
    • Deferred: no runs.options history column and no run-row options persistence (re-adoption re-adopts a live tmux session, it never re-spawns, so nothing needs the bag after launch); bool option type only (enum/string reserved).
  2. Resolver (internal/instance): ResolveModelEffort becomes run-kind-aware (AFK consults the afk-override layer first, then falls back to base). New ResolveSpawnOptions(ctx, prov, repo, kind) — manual → empty map; AFK → repo.afk_options ?? global.spawn_options_afk, filtered + validated against the provider. Both feed a new LaunchSpec.OptionsSpawnSpec.Options.
  3. Schema-driven UI. GET /api/v1/providers grows an options field (the declared schema). Global Settings and Repo Settings render an "AFK defaults" section from it: model/effort selects with an explicit "same as default" (inherit) entry + a bool→checkbox for ultracode. Future provider options render automatically. StartInstanceForm (manual) is unchanged.

Implementation notes (seams, not prescriptions)

  • internal/provider/provider.go: add SpawnOptions() []OptionSpec ({Key, Label, Type:"bool", Default}; Values/enum reserved). Replace SpawnArgv positional args with the SpawnSpec struct. Update the providertest fake and the compat SpawnArgv snapshot test accordingly.
  • internal/provider/claudecode: declare the ultracode option; implement the prompt-prepend inside SpawnArgv (guarded on non-empty InitialPrompt); leave model/effort flag-building as-is. This is our own wording, not a new pinned Claude coupling — just re-snapshot the argv builder.
  • internal/instance (start.go, credential.go): kind-aware ResolveModelEffort, new ResolveSpawnOptions, LaunchSpec.Options, thread into SpawnSpec.
  • internal/afk/launch.go: pass the run kind into the resolver and request the AFK options bag; the afk package gains no ultracode knowledge.
  • internal/store: new settings keys (settings.go min-set/typed handling), new repos columns + Update (Opt[*string] for the two strings, JSON for afk_options), scan/insert plumbing. SeedDefaultSettings unchanged (AFK keys default to empty = inherit).
  • internal/httpapi: providers.go (options in the response), settings.go (validate the 3 new keys — empty allowed for AFK model/effort; JSON bag against the provider schema; a 400 still writes nothing), repos.go (3 new optional fields).
  • migrations: next free number, add the 3 repos columns (sqlite + postgres mirrors).
  • web: Settings.tsx + RepoSettings.tsx AFK-defaults section (schema-driven, inherit option, ultracode checkbox); api.ts types (Provider.options, Repo.afk_*, AFK fields on SpawnDefaults); StartInstanceForm.tsx unchanged.

Deliverables

  • ADR at docs/adr/<next-free-number>-spawn-defaults-provider-options.md in the house style (statement title, Status, Considered options, Consequences) recording the decisions above — the hybrid typed-core + generic-options-bag choice, B2 base+AFK-override layering, ultracode as AFK-only provider-owned prompt injection, and the SpawnSpec seam. Take the next number free on main at implementation time — do NOT hardcode 0017 (other in-flight issues may claim it). The ADR must be part of this issue's commits/PR, not a follow-up.
  • CONTEXT.md: add vocabulary if warranted (e.g. Spawn options — the provider-owned bag — and the AFK-vs-manual default split) in the established Language/Relationships style, honouring the avoid-list.
  • Backend: kind-aware resolver + ResolveSpawnOptions, provider seam (SpawnSpec + SpawnOptions), claude-code ultracode application, settings + repo storage, API, migration.
  • Web UI: AFK-defaults section in global + repo settings (schema-driven), inherit semantics, ultracode checkbox.
  • Tests per the definition of done: kind-aware resolution layering (base / AFK override / inherit), options validation (unknown key + bad value → 400, empty AFK model/effort allowed), SpawnSpec compat snapshot (ultracode prepend present for AFK, no-op for manual/empty prompt), UI inherit + checkbox behaviour.

Out of scope (explicitly deferred)

Per-run AFK override form · manual-scoped provider options · enum/string option types (bool only) · runs.options history column / persisting resolved options · applying ultracode to manual instances · Codex/Gemini option declarations (#2 consumes this seam on its own).


Relates to #2 (OpenAI Codex provider — the first external consumer of the generic options bag). Evolves ADR-0008 (provider seam) and brief decisions D12d (spawn model/effort layering) and D14 (provider-owned catalogs).

## Summary Let the operator configure **different model/effort for AFK runs vs manual instances**, and add a **generic, provider-owned spawn-options bag** whose first (and currently only) entry is Claude Code's AFK-only **ultracode** flag. model/effort stay first-class and typed (ADR-0008 / D14 unchanged). The new options bag is the extensible seam that makes "the interface works for all providers" real: when Codex (#2) / Gemini land, each declares its own spawn options and applies them behind the same seam — zero refactor of the typed core. Design was fully grilled with the operator on 2026-07-07; the decisions below are settled — do not re-litigate them, record them in an ADR (see Deliverables). ## Decisions (settled) **Product shape** 1. **Manual and AFK diverge.** A manual instance always shows the operator the Start form with model/effort pre-filled, so a "manual default" is a soft *pre-fill* the operator overrides per spawn. An AFK run has no operator: its defaults are *hard* — they are literally what runs. The genuinely new capability is AFK-specific defaults that reliably differ from the manual pre-fill, plus ultracode. 2. **B2 layering — base + AFK override.** The existing globals `spawn_model_default`/`spawn_effort_default` and the repo columns `model_default`/`effort_default` keep their current meaning as the **base** (used by manual, and by AFK as a fallback). Add optional **AFK-override** slots at both scopes; empty = inherit base. Manual resolution is unchanged (`request → repo.base → global.base`); AFK resolution becomes `repo.afk ?? global.afk ?? repo.base ?? global.base`. Non-breaking: existing values keep working, AFK behaviour changes only where an override is set. 3. **ultracode is AFK-only and defaults-only.** It is a boolean provider option; manual users just type the keyword themselves. There is **no per-run override**: AFK is defaults-only for both the auto scheduler and manual start (`POST /repos/{id}/afk/start` stays bodyless). Confirmed against Claude Code docs/changelog/`--help`: ultracode is a *prompt trigger keyword* (renamed from "workflow" in v2.1.186), **not** a CLI flag, env var, or settings.json field — which is exactly why application is prompt injection, not argv. **Mechanism** 4. **Hybrid, not a full generalization.** model/effort remain typed & first-class across the interface, run row, repo columns, settings, API, and UI (ADR-0008/D14 intact). Beside them sits ONE generic bag: the provider *declares* its options schema, lab *stores/validates/renders* the bag generically, and the provider *applies* it. `ultracode` is claude-code's first entry. 5. **Application is provider-owned and is NOT argv.** Replace the positional `SpawnArgv(session, model, effort, prompt)` with `SpawnArgv(SpawnSpec{SessionName, Model, Effort, Options, InitialPrompt})` so future options never churn the interface signature. claude-code applies `Options["ultracode"]` by **prepending a provider-owned ultracode directive line** to a *non-empty* `InitialPrompt`; a manual instance passes an empty prompt → natural no-op (matches decision 3). `internal/afk` stays provider-agnostic — **ultracode must not leak into the afk package** (unlike `incogni`, which is a lab-domain repo flag). Provider-owned prepend text, tunable freely, e.g.: ``` Operate in ultracode mode: decompose substantial work into a multi-agent workflow. You are an autonomous AFK run. Resolve exactly one issue, open a pull request, and stop. 1. Run `labctl issue view N` … ``` 6. **Validation mirrors model/effort.** The bag validates against the resolving repo's provider schema — unknown option key or bad value → 400, same discipline as an unknown model/effort today. Empty AFK model/effort is explicitly allowed (means inherit). A global bag may span providers once >1 exists; at spawn it is filtered to the repo's provider. **Architecture** 7. **Storage** (base keys/columns unchanged): - Global settings (new rows, no schema change): `spawn_model_default_afk`, `spawn_effort_default_afk` (empty = inherit), `spawn_options_afk` (JSON, e.g. `{"ultracode":"true"}`). - Repo (`repos`, new nullable columns): `afk_model_default`, `afk_effort_default`, `afk_options` (JSON). - One migration, next free number, sqlite + postgres mirrors. - **Deferred:** no `runs.options` history column and no run-row options persistence (re-adoption re-adopts a live tmux session, it never re-spawns, so nothing needs the bag after launch); bool option type only (enum/string reserved). 8. **Resolver** (`internal/instance`): `ResolveModelEffort` becomes run-kind-aware (AFK consults the afk-override layer first, then falls back to base). New `ResolveSpawnOptions(ctx, prov, repo, kind)` — manual → empty map; AFK → `repo.afk_options ?? global.spawn_options_afk`, filtered + validated against the provider. Both feed a new `LaunchSpec.Options` → `SpawnSpec.Options`. 9. **Schema-driven UI.** `GET /api/v1/providers` grows an `options` field (the declared schema). Global Settings and Repo Settings render an "AFK defaults" section from it: model/effort selects with an explicit "same as default" (inherit) entry + a bool→checkbox for ultracode. Future provider options render automatically. `StartInstanceForm` (manual) is unchanged. ## Implementation notes (seams, not prescriptions) - `internal/provider/provider.go`: add `SpawnOptions() []OptionSpec` (`{Key, Label, Type:"bool", Default}`; `Values`/enum reserved). Replace `SpawnArgv` positional args with the `SpawnSpec` struct. Update the `providertest` fake and the compat `SpawnArgv` snapshot test accordingly. - `internal/provider/claudecode`: declare the `ultracode` option; implement the prompt-prepend inside `SpawnArgv` (guarded on non-empty `InitialPrompt`); leave model/effort flag-building as-is. This is our own wording, **not** a new pinned Claude coupling — just re-snapshot the argv builder. - `internal/instance` (`start.go`, `credential.go`): kind-aware `ResolveModelEffort`, new `ResolveSpawnOptions`, `LaunchSpec.Options`, thread into `SpawnSpec`. - `internal/afk/launch.go`: pass the run kind into the resolver and request the AFK options bag; the afk package gains **no** ultracode knowledge. - `internal/store`: new settings keys (`settings.go` min-set/typed handling), new `repos` columns + `Update` (`Opt[*string]` for the two strings, JSON for `afk_options`), scan/insert plumbing. `SeedDefaultSettings` unchanged (AFK keys default to empty = inherit). - `internal/httpapi`: `providers.go` (`options` in the response), `settings.go` (validate the 3 new keys — empty allowed for AFK model/effort; JSON bag against the provider schema; a 400 still writes nothing), `repos.go` (3 new optional fields). - `migrations`: next free number, add the 3 `repos` columns (sqlite + postgres mirrors). - `web`: `Settings.tsx` + `RepoSettings.tsx` AFK-defaults section (schema-driven, inherit option, ultracode checkbox); `api.ts` types (`Provider.options`, `Repo.afk_*`, AFK fields on `SpawnDefaults`); `StartInstanceForm.tsx` unchanged. ## Deliverables - [ ] **ADR** at `docs/adr/<next-free-number>-spawn-defaults-provider-options.md` in the house style (statement title, Status, Considered options, Consequences) recording the decisions above — the hybrid typed-core + generic-options-bag choice, B2 base+AFK-override layering, ultracode as AFK-only provider-owned prompt injection, and the `SpawnSpec` seam. **Take the next number free on main at implementation time — do NOT hardcode 0017** (other in-flight issues may claim it). The ADR must be part of this issue's commits/PR, not a follow-up. - [ ] `CONTEXT.md`: add vocabulary if warranted (e.g. **Spawn options** — the provider-owned bag — and the AFK-vs-manual default split) in the established Language/Relationships style, honouring the avoid-list. - [ ] Backend: kind-aware resolver + `ResolveSpawnOptions`, provider seam (`SpawnSpec` + `SpawnOptions`), claude-code ultracode application, settings + repo storage, API, migration. - [ ] Web UI: AFK-defaults section in global + repo settings (schema-driven), inherit semantics, ultracode checkbox. - [ ] Tests per the definition of done: kind-aware resolution layering (base / AFK override / inherit), options validation (unknown key + bad value → 400, empty AFK model/effort allowed), `SpawnSpec` compat snapshot (ultracode prepend present for AFK, no-op for manual/empty prompt), UI inherit + checkbox behaviour. ## Out of scope (explicitly deferred) Per-run AFK override form · manual-scoped provider options · enum/string option types (bool only) · `runs.options` history column / persisting resolved options · applying ultracode to manual instances · Codex/Gemini option declarations (#2 consumes this seam on its own). --- Relates to **#2** (OpenAI Codex provider — the first external consumer of the generic options bag). Evolves **ADR-0008** (provider seam) and brief decisions **D12d** (spawn model/effort layering) and **D14** (provider-owned catalogs).
Sign in to join this conversation.
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#19
No description provided.