lab: Automatic AFK runs — per-project toggle + scheduler #64

Closed
opened 2026-06-01 12:23:40 +02:00 by dominik.polakovics · 1 comment

Parent

#61

What to build

The automatic mode. Persist a per-project autoEnabled flag, surface it as a toggle in the menu, and add a scheduler that launches AFK runs on its own while the toggle is on and ready issues remain — serial, one auto-run per project, under the global cap.

Acceptance criteria

  • New persisted per-project field autoEnabled in lab's JSON store (survives restart).
  • The menu shows an Auto AFK runs toggle rendered as a stateful form-button whose label reflects On/Off — NOT an <input type=checkbox>, because the morph treats checked as client-owned and won't sync it from the server. Toggling POSTs to flip + persist the flag and re-renders.
  • A scheduler goroutine (~30–60s, separate from the existing ~4s client fragment poll) iterates auto-on Forgejo projects.
  • For each such project under the global instance cap with no auto-run already in flight, it launches the next run via Slice 1's claim → worktree → spawn path.
  • Serial per project: at most one AUTO run per project at a time. A manually-started AFK run is additive and does not block the auto-loop beyond the global cap.
  • Auto vs manual is distinguished in a restart-proof way (e.g. a marker in the AFK session name), so "one auto-run per project" holds even after a lab restart re-adopts live sessions.
  • The loop drains the queue one issue at a time, idles when no ready issues remain, and launches immediately on toggle-on if work exists.
  • At the global cap the loop waits (launches nothing) rather than erroring.
  • Go unit tests cover the launch-decision predicate (autoEnabled ∧ under-cap ∧ no-auto-in-flight ∧ ready-issue-exists).

Blocked by

  • #62 (Slice 1 — the run path the scheduler reuses)
  • #63 (Slice 2 — reaping, so the "in-flight" signal clears and the loop advances)
## Parent #61 ## What to build The automatic mode. Persist a per-project `autoEnabled` flag, surface it as a toggle in the `⋯` menu, and add a scheduler that launches AFK runs on its own while the toggle is on and ready issues remain — serial, one auto-run per project, under the global cap. ## Acceptance criteria - [ ] New persisted per-project field `autoEnabled` in `lab`'s JSON store (survives restart). - [ ] The `⋯` menu shows an **Auto AFK runs** toggle rendered as a stateful form-button whose label reflects On/Off — NOT an `<input type=checkbox>`, because the morph treats `checked` as client-owned and won't sync it from the server. Toggling POSTs to flip + persist the flag and re-renders. - [ ] A scheduler goroutine (~30–60s, separate from the existing ~4s client fragment poll) iterates auto-on Forgejo projects. - [ ] For each such project under the global instance cap with no auto-run already in flight, it launches the next run via Slice 1's claim → worktree → spawn path. - [ ] Serial per project: at most one AUTO run per project at a time. A manually-started AFK run is additive and does not block the auto-loop beyond the global cap. - [ ] Auto vs manual is distinguished in a restart-proof way (e.g. a marker in the AFK session name), so "one auto-run per project" holds even after a `lab` restart re-adopts live sessions. - [ ] The loop drains the queue one issue at a time, idles when no ready issues remain, and launches immediately on toggle-on if work exists. - [ ] At the global cap the loop waits (launches nothing) rather than erroring. - [ ] Go unit tests cover the launch-decision predicate (autoEnabled ∧ under-cap ∧ no-auto-in-flight ∧ ready-issue-exists). ## Blocked by - #62 (Slice 1 — the run path the scheduler reuses) - #63 (Slice 2 — reaping, so the "in-flight" signal clears and the loop advances)
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Add the per-project automatic AFK mode — a persisted autoEnabled toggle in the ⋯ menu plus a server-side scheduler that launches AFK runs on its own, one at a time per project, under the global instance cap, while ready issues remain.

Context: Parent #61. Blockers #62 (Slice 1 — manual run) and #63 (Slice 2 — reaping) are merged, so the claim path and the lifecycle/reaping this slice depends on already exist. The design is governed by ADR-0007 ("lab claims the issue and owns the AFK-run lifecycle"); read it first — its Consequences section is the spec for this slice (the ~30–60s scheduler goroutine distinct from the ~4s client poll, the autoEnabled store field, one auto-run per project under the cap, restart re-adoption from session names).

Current behavior:
AFK runs are manual-only. A user clicks Start AFK run in a project's ⋯ menu; lab picks the lowest open ready-for-agent issue, claims it (label flip ready-for-agent → in-progress, rolled back on any failure before a live session), creates an isolated git worktree on an afk/<N> branch, and spawns a seeded claude --remote-control session whose name encodes project + slot + issue. One long-lived background worker — the reaper — sweeps live runs on a ~30s ticker: it completes a run when an open/merged afk/<N> PR appears, and fails it on session death or a ~45-minute budget overrun, freeing the global-cap slot. Nothing launches a run without a human click, and there is no persisted per-project automatic state.

Desired behavior:
Each Forgejo-hosted project gains a persisted autoEnabled flag, toggled from its ⋯ menu. A second server-side worker (the scheduler) runs on its own ~30–60s cadence. On each tick, for every project that is autoEnabled, has a ready-for-agent issue available, and has no auto-run already in flight, it launches the next run through the same claim → worktree → spawn path the manual Start uses — provided lab is under its global instance cap. The loop is serial per project (at most one auto run per project at a time), drains the queue one issue per launch, idles when no ready issues remain, launches promptly after a toggle-on if work exists, and at the cap simply launches nothing (never errors). A manually started AFK run is additive — it does not block the auto-loop beyond consuming a cap slot. Toggling autoEnabled off stops new auto-launches; runs already in flight are left to the reaper.

Key interfaces / contracts (durable anchors — explore for the current shapes):

  • Per-project store record (today projectState behind Store, persisted as one atomic JSON file): add an autoEnabled boolean with an accessor + mutator pair that persists on write, mirroring the existing URL/SetURL (and LastOpenedAt/StampOpened) pattern. Must round-trip across a restart.
  • The manual launch sequence (today the select → cap-check → claim → worktree → trust-seed → spawn → rollback core inside the handleAFKStart HTTP handler): this is currently welded to the http.ResponseWriter/*http.Request (it ends in s.fail/s.ok/s.noReady), so the scheduler cannot reuse it as written. Factor the core into a routine callable without an HTTP request — returning whether a run was launched (and any error) — so the handler and the scheduler share one claim path. ADR-0007's race-freedom depends on that claim staying single-flighted under the existing afkMu, so the shared routine (not two copies) must hold that mutex.
  • The AFK session-name marker (afkLabel/parseAFKLabel/afkLabelPrefix, round-tripped via composeSessionName/parseSessionName and recognized by parseAFKRun): extend it to distinguish auto from manual runs in a restart-proof way. ⚠️ Hazard: parseAFKLabel currently treats the entire suffix after the afk- prefix as the issue number (strconv.Atoi), so a naive afk-auto-<N> label would fail to parse and the reaper would silently stop reaping auto runs. Whatever encoding you choose must keep both kinds recognized as AFK runs by parseAFKRun/parseAFKLabel (both must still be reaped), and must survive sanitizeLabel (which allows only [A-Za-z0-9._-]).
  • The scheduler goroutine: a second long-lived server worker started at boot the same way the reaper (watchAFKRuns) is. Like the reaper, it must no-op (return immediately) when the AFK seams are not wired (tracker/git nil) rather than spin an idle ticker.
  • The cap check: reuse the existing liveInstanceCount vs maxInstances guard rather than reimplementing it.
  • The ⋯ menu toggle: render it as a stateful form-button whose label text reflects On/Off — not an <input type=checkbox>. The client-side DOM morph deliberately treats form-control state (checked, input .value, <details open>) as client-owned and never repaints it from the server; only server-rendered text is synced. So a checkbox's checked would never reflect the persisted flag after a poll. Model it on the sibling Start AFK run menu item: a <form method="post" data-action> posting to a new endpoint that flips + persists autoEnabled and re-renders, with the button label reading e.g. "Auto AFK runs: On" / "Auto AFK runs: Off". Gate it to Forgejo-hosted projects exactly like Start AFK run (a direct POST for a non-Forgejo project must also be refused, not just hidden).

Acceptance criteria:

  • A persisted per-project autoEnabled boolean survives a lab restart (round-trips through the JSON store like the existing per-project fields).
  • The ⋯ menu shows an Auto AFK runs control rendered as a stateful form-button whose label reflects On/Off (never <input type=checkbox>); activating it POSTs to flip + persist the flag and re-renders with the new label. Shown/enabled only on Forgejo-hosted projects, and a direct POST against a non-Forgejo project is refused.
  • A scheduler runs server-side on its own ~30–60s cadence, separate from both the ~4s client fragment poll and the existing reaper ticker.
  • On each tick, for every autoEnabled Forgejo project that is under the global cap, has no auto-run in flight, and has a ready issue, it launches the next run via the shared claim → worktree → spawn path (with the manual path's claim-rollback-on-failure intact).
  • At most one auto run per project at a time; a manually started AFK run is additive and does not block the auto-loop except by consuming a cap slot.
  • Auto vs manual is distinguished restart-proof via the session-name marker, so "one auto-run per project" holds after a restart re-adopts live sessions — and auto runs are still recognized and reaped by the existing reaper exactly like manual ones.
  • The loop drains the queue one issue at a time, idles when no ready issues remain, and launches promptly after toggle-on if work exists.
  • At the global cap the scheduler launches nothing and does not error.
  • A pure, table-tested predicate decides whether to launch (autoEnabled ∧ under-cap ∧ no-auto-in-flight ∧ ready-issue-exists), unit-tested in isolation from tmux/tea/clock — mirroring how classifyAFKRun is tested today.

Out of scope:

  • consecutiveFailures counting and the three-strikes auto-pause + UI reset — that is #65. Do not add the failure counter or pause here. Do shape the launch predicate so a later ∧ not-paused term can be added without restructuring.
  • The menu "(N ready)" count hint — that is #66.
  • Any change to the manual Start AFK run UX, or to the reaper's classification / timeout logic (#62/#63), beyond what the auto marker requires.
  • Changing the global instance cap or its value.
  • Auto-retry of failed runs — explicitly rejected in ADR-0007; a failed run parks its issue in in-progress and (in #65) increments the failure counter.

Verification: This is a Go change in the lab module; its tests are not exercised by the repo's pre-commit hook (which is eval-only). Run go test ./..., go vet ./..., and go build ./... in the lab module locally before opening the PR.

> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Add the per-project *automatic* AFK mode — a persisted `autoEnabled` toggle in the ⋯ menu plus a server-side scheduler that launches AFK runs on its own, one at a time per project, under the global instance cap, while ready issues remain. **Context:** Parent #61. Blockers **#62 (Slice 1 — manual run) and #63 (Slice 2 — reaping) are merged**, so the claim path and the lifecycle/reaping this slice depends on already exist. The design is governed by **ADR-0007** ("lab claims the issue and owns the AFK-run lifecycle"); read it first — its Consequences section is the spec for this slice (the ~30–60s scheduler goroutine distinct from the ~4s client poll, the `autoEnabled` store field, one auto-run per project under the cap, restart re-adoption from session names). **Current behavior:** AFK runs are manual-only. A user clicks **Start AFK run** in a project's ⋯ menu; `lab` picks the lowest open `ready-for-agent` issue, claims it (label flip `ready-for-agent → in-progress`, rolled back on any failure before a live session), creates an isolated git worktree on an `afk/<N>` branch, and spawns a seeded `claude --remote-control` session whose name encodes project + slot + issue. One long-lived background worker — the reaper — sweeps live runs on a ~30s ticker: it completes a run when an open/merged `afk/<N>` PR appears, and fails it on session death or a ~45-minute budget overrun, freeing the global-cap slot. Nothing launches a run without a human click, and there is no persisted per-project automatic state. **Desired behavior:** Each Forgejo-hosted project gains a persisted `autoEnabled` flag, toggled from its ⋯ menu. A second server-side worker (the *scheduler*) runs on its own ~30–60s cadence. On each tick, for every project that is `autoEnabled`, has a `ready-for-agent` issue available, and has no auto-run already in flight, it launches the next run through the **same claim → worktree → spawn path** the manual Start uses — provided `lab` is under its global instance cap. The loop is serial per project (at most one *auto* run per project at a time), drains the queue one issue per launch, idles when no ready issues remain, launches promptly after a toggle-on if work exists, and at the cap simply launches nothing (never errors). A manually started AFK run is additive — it does not block the auto-loop beyond consuming a cap slot. Toggling `autoEnabled` off stops new auto-launches; runs already in flight are left to the reaper. **Key interfaces / contracts** (durable anchors — explore for the current shapes): - **Per-project store record** (today `projectState` behind `Store`, persisted as one atomic JSON file): add an `autoEnabled` boolean with an accessor + mutator pair that persists on write, mirroring the existing `URL`/`SetURL` (and `LastOpenedAt`/`StampOpened`) pattern. Must round-trip across a restart. - **The manual launch sequence** (today the select → cap-check → claim → worktree → trust-seed → spawn → rollback core inside the `handleAFKStart` HTTP handler): this is currently welded to the `http.ResponseWriter`/`*http.Request` (it ends in `s.fail`/`s.ok`/`s.noReady`), so the scheduler **cannot reuse it as written**. Factor the core into a routine callable without an HTTP request — returning *whether a run was launched* (and any error) — so the handler and the scheduler share one claim path. ADR-0007's race-freedom depends on that claim staying single-flighted under the existing `afkMu`, so the shared routine (not two copies) must hold that mutex. - **The AFK session-name marker** (`afkLabel`/`parseAFKLabel`/`afkLabelPrefix`, round-tripped via `composeSessionName`/`parseSessionName` and recognized by `parseAFKRun`): extend it to distinguish *auto* from *manual* runs in a restart-proof way. ⚠️ **Hazard:** `parseAFKLabel` currently treats the entire suffix after the `afk-` prefix as the issue number (`strconv.Atoi`), so a naive `afk-auto-<N>` label would fail to parse and the reaper would **silently stop reaping auto runs**. Whatever encoding you choose must keep *both* kinds recognized as AFK runs by `parseAFKRun`/`parseAFKLabel` (both must still be reaped), and must survive `sanitizeLabel` (which allows only `[A-Za-z0-9._-]`). - **The scheduler goroutine:** a second long-lived server worker started at boot the same way the reaper (`watchAFKRuns`) is. Like the reaper, it must no-op (return immediately) when the AFK seams are not wired (`tracker`/`git` nil) rather than spin an idle ticker. - **The cap check:** reuse the existing `liveInstanceCount` vs `maxInstances` guard rather than reimplementing it. - **The ⋯ menu toggle:** render it as a stateful form-button whose **label text** reflects On/Off — **not** an `<input type=checkbox>`. The client-side DOM morph deliberately treats form-control state (`checked`, input `.value`, `<details open>`) as client-owned and never repaints it from the server; only server-rendered text is synced. So a checkbox's `checked` would never reflect the persisted flag after a poll. Model it on the sibling **Start AFK run** menu item: a `<form method="post" data-action>` posting to a new endpoint that flips + persists `autoEnabled` and re-renders, with the button label reading e.g. "Auto AFK runs: On" / "Auto AFK runs: Off". Gate it to Forgejo-hosted projects exactly like Start AFK run (a direct POST for a non-Forgejo project must also be refused, not just hidden). **Acceptance criteria:** - [ ] A persisted per-project `autoEnabled` boolean survives a `lab` restart (round-trips through the JSON store like the existing per-project fields). - [ ] The ⋯ menu shows an **Auto AFK runs** control rendered as a stateful form-button whose label reflects On/Off (never `<input type=checkbox>`); activating it POSTs to flip + persist the flag and re-renders with the new label. Shown/enabled only on Forgejo-hosted projects, and a direct POST against a non-Forgejo project is refused. - [ ] A scheduler runs server-side on its own ~30–60s cadence, separate from both the ~4s client fragment poll and the existing reaper ticker. - [ ] On each tick, for every `autoEnabled` Forgejo project that is under the global cap, has no auto-run in flight, and has a ready issue, it launches the next run via the shared claim → worktree → spawn path (with the manual path's claim-rollback-on-failure intact). - [ ] At most one **auto** run per project at a time; a manually started AFK run is additive and does not block the auto-loop except by consuming a cap slot. - [ ] Auto vs manual is distinguished restart-proof via the session-name marker, so "one auto-run per project" holds after a restart re-adopts live sessions — and auto runs are still recognized and reaped by the existing reaper exactly like manual ones. - [ ] The loop drains the queue one issue at a time, idles when no ready issues remain, and launches promptly after toggle-on if work exists. - [ ] At the global cap the scheduler launches nothing and does not error. - [ ] A pure, table-tested predicate decides whether to launch (`autoEnabled ∧ under-cap ∧ no-auto-in-flight ∧ ready-issue-exists`), unit-tested in isolation from tmux/tea/clock — mirroring how `classifyAFKRun` is tested today. **Out of scope:** - `consecutiveFailures` counting and the **three-strikes auto-pause + UI reset** — that is **#65**. Do not add the failure counter or pause here. *Do* shape the launch predicate so a later `∧ not-paused` term can be added without restructuring. - The menu **"(N ready)" count hint** — that is **#66**. - Any change to the manual **Start AFK run** UX, or to the reaper's classification / timeout logic (#62/#63), beyond what the auto marker requires. - Changing the global instance cap or its value. - **Auto-retry of failed runs** — explicitly rejected in ADR-0007; a failed run parks its issue in `in-progress` and (in #65) increments the failure counter. **Verification:** This is a Go change in the `lab` module; its tests are not exercised by the repo's pre-commit hook (which is eval-only). Run `go test ./...`, `go vet ./...`, and `go build ./...` in the `lab` module locally before opening the PR.
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/nixos#64
No description provided.