Skip to content

feat: autonomy loops — post-run verification, /goal evaluator, CI watch, autonomy budget#59

Merged
zaytcevcom merged 22 commits into
mainfrom
feat/autonomy-loops
Jul 9, 2026
Merged

feat: autonomy loops — post-run verification, /goal evaluator, CI watch, autonomy budget#59
zaytcevcom merged 22 commits into
mainfrom
feat/autonomy-loops

Conversation

@zaytcevcom

Copy link
Copy Markdown
Contributor

What

Four opt-in autonomy loops that move the bot up the delegation ladder — from "the agent checks its own work" to "the agent runs without you" — each with a hard stop condition, plus two safety rails and a team-policy update.

  • Post-run verification (ENABLE_POST_VERIFY, default on) — after a cleanly successful run, the bot itself re-runs the changed repos' own check gate (task/make/npm, preferring check > ci > test > tests > lint) and, on red, injects a fix-up run with the real failure output — up to POST_VERIFY_MAX_FIXES consecutive rounds per chat. Previously failed repos are re-gated even when a "fix" run touched nothing. The agent's "tests pass" is verified, not trusted.
  • /goal evaluator — a new reserved command (Telegram + VK): the user states a done-criterion once; after every completed run an independent fresh-session judge (no shared context with the working session) inspects the workspace, re-runs checks, and returns a strict JSON verdict. Not met → the unmet points loop back as a fix-up; met → 🎯 and the goal disarms. Bounded by GOAL_MAX_ATTEMPTS; EVALUATOR_MODEL can pick a cheaper judge.
  • CI watch (ENABLE_CI_WATCH, default off) — polls CI state on the duck/* branches checked out in the workspaces (GitHub check-runs with a legacy fallback, or Gitea combined status; no inbound webhooks). A red build injects "CI is red — fix and push" into the owning chat, exactly once per commit (durable dedupe). ENABLE_AUTO_MERGE additionally merges a branch's PR once CI is green — full hands-off mode, off by default.
  • Autonomy budgetAUTO_TASK_MAX_COST_PER_DAY caps what autonomy-originated runs (CI/verify/goal fix-ups, via the new Service.InjectAuto) may spend per chat per UTC day, with a single daily notice when exhausted. Direct messages and scheduled jobs (already gated by their creator's cost cap) are unaffected.
  • AUTO_APPROVE_SCOPE (off|trivial|standard|all, default off) — lets covered planner complexities skip the "confirm scope & wait" step; unknown values fail safe to off. The tester subagent now runs on model: sonnet (routine role, cheaper model).
  • Team policy: one feature in one repo = one PR, sliced into commits — replaces the "prefer independent flag-gated PRs" guidance; bottom-up merge stays as a recovery rule. (This PR follows the new policy.)

Notes for review

  • /goal was previously forwarded to the provider's own slash-command system; it is now bot-reserved because the bot-level loop persists across turns, meters the budget, and survives restarts. Trade-off called out in core/chat/reserved.go.
  • Scheduled fires (InjectScheduled) and poller relays are untouched; the new InjectAuto is a separate path (blocking submit + auto-resume marker + AutoUser cost attribution).
  • CI watch is wired in the Telegram main only for now; VK gets verification/goal/budget.
  • Known edge: a repo whose gate needs the dind sidecar fails verification when DOCKER_HOST is unset — bounded by the fix-round cap, output tail shows the cause.
  • README translations (README.*.md) are not updated — English only.

Test plan

  • task fmt / task lint — 0 issues; task tests (go test -race ./...) — all packages green.
  • New unit/e2e coverage: verify (real git+make repos: pass/fail/skip/retry/new-clone), goal (store lifecycle, verdict parsing table), autobudget (cap/prune/persistence), ciwatch (discovery from fake .git, both host clients via httptest, dedupe, auto-merge), chat post-run (full goal loop, attempt cap, budget gate, capped verify fix loop with a real red gate), config knobs, workspace placeholder rendering, reserved-set guard tests updated.

🤖 Generated with Claude Code

zaytcevcom and others added 22 commits July 8, 2026 15:39
The deterministic "trust but verify" pass: fingerprint every workspace repo
(HEAD + porcelain status) before a run, and after a cleanly successful run
re-execute the check gate of exactly the repos the run changed, using the
repo's OWN runner — Taskfile (task --dry probe), Makefile (make -n probe) or
package.json scripts — preferring check > ci > test > tests > lint. A repo
named on the retry list is re-gated even when unchanged, so a no-op "fix"
run cannot silently exit a red-gate loop. Failures carry the output tail and
a ready-to-inject fix-up prompt; a missing runner is a skip, not a failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/goal arms a per-chat objective persisted across restarts (single JSON file,
atomic rewrite, mirroring the other stores). The evaluator prompt scopes a
FRESH provider session to read-only inspection plus running checks and pins
the reply to a single JSON verdict; the parser scans balanced {...} spans
from the last one backwards and accepts the first carrying an "achieved"
key, so fences or stray prose around the object never break a round. The
fix-up prompt relays the verdict's unmet points back to the working session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-triggered runs are bounded by the per-user cost cap and scheduled jobs
by their creator's cap, but autonomy-originated runs (CI fix-ups, post-run
verification fix-ups, goal-evaluator rounds) had no owner to meter. This
store accumulates their cost per chat per UTC day — key "<chat>#<day>",
atomic JSON rewrite, days older than the retention window pruned on write —
and answers the reactive Allowed gate. A nil store disables the feature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… loop

After a cleanly successful run (and only then), the service now runs an
autonomy tail on the dispatcher lane: first the deterministic gate re-run
over the repos this run changed (core/verify, snapshot taken at run start),
then — on a clean tree — the /goal evaluator, an independent fresh-session
judge with no shared context, whose unmet points loop back as a fix-up run.
Both loops are bounded: consecutive verification fix-ups per chat, and
evaluation rounds per goal, with the counters reset by a real user message.

Follow-ups go through the new InjectAuto: like Inject it enqueues an
auto-resume marker and uses the blocking Submit (a fix-up for work already
paid for must survive a busy lane and a deploy), but the run is attributed
to the AutoUser sentinel so its cost lands on the chat's per-day autonomy
budget — which also gates the injection itself, pausing autonomous work
(with one daily notice) once the day's budget is spent while direct user
messages keep working. Scheduled fires and poller relays are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ge greens

Closes the CI feedback loop without a human relay. A background loop
discovers every duck/<chatid>/... branch checked out in the per-chat
workspaces (reading .git/HEAD and .git/config directly — no git exec) and
polls the git host for that branch's CI state: GitHub check-runs with a
legacy combined-status fallback, or Gitea's combined commit status, with the
head SHA resolved via the slash-safe /commits?sha= query. A red build emits
one event per (repo, branch, SHA) — deduplicated in a small durable store,
across restarts — for the adapter to inject as a fix-up prompt. Optionally
(AutoMerge) a branch whose CI is green gets its open PR merged, removing the
last human touchpoint for deployments that want it. Like core/poller it
polls because the bot can reach the host while inbound webhooks often can't.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ope)

ENABLE_POST_VERIFY (on) + POST_VERIFY_MAX_FIXES/TIMEOUT for the post-run
gate re-run; GOAL_MAX_ATTEMPTS / EVALUATOR_MODEL / GOAL_EVAL_* and the goal
store path for the /goal evaluator; ENABLE_CI_WATCH (off) + CI_POLL_INTERVAL
(floored to 30s) + ENABLE_AUTO_MERGE (off) + the dedupe-state path, enabled
for github.com or a Gitea API URL and deliberately NOT requiring
ENABLE_PR_REVIEW; AUTO_TASK_MAX_COST_PER_DAY + store path for the per-chat
autonomy budget; AUTO_APPROVE_SCOPE normalized fail-safe to "off" so a typo
can never silently skip the human approval gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The workspace renderer substitutes a fifth placeholder,
${AUTO_APPROVE_SCOPE}, defaulting an unset field to the fail-safe "off"
(always wait for scope approval). The tester subagent pins model: sonnet —
running suites and reporting failures verbatim is routine work that does not
need the lead model, per the cost advice of scaling the model to the role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…watch

/goal joins the reserved set (menu, guard test, both adapters): arm a goal,
show it, /goal off to disarm. It is therefore no longer forwarded to the
provider's own slash-command system — the bot-level loop persists across
turns, meters the budget, and survives restarts, which the in-session
command could not. Both mains assemble the post-run config (verification,
goal evaluator, autonomy budget) with the usual non-fatal store opens; the
Telegram main additionally starts the CI watch when configured, injecting
red-build fix-ups into the owning chat and announcing auto-merged PRs. The
VK Service seam gains the three goal methods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the "prefer independent, flag-gated PRs" guidance: a single
feature's work in a single repo must never be split across several PRs.
Structure it as a sequence of coherent, reviewable commits on the ONE
duck/<chatid>/<slug> branch and open ONE PR; review/CI follow-ups push more
commits to the same PR. A multi-repo feature still gets exactly one PR per
affected repo, and oversized asks are staged via a plan doc where each stage
is one self-contained PR. The bottom-up merge rule stays as a recovery path
for a chain that should not have existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The chatgpt.com/codex installer resolves the release and its asset digests
through the unauthenticated GitHub API, which rate-limits shared CI runners
and then reports the platform assets missing ("Could not find Codex package
or platform npm release assets for Codex 0.143.0") — this broke the arm64
leg of the VK image build (the Telegram image survived on layer cache). One
arch-matched, retried, direct tarball download is deterministic and mirrors
how gh and task are already installed in these images. The /opt/codex
install-state dir goes away with the installer that needed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Lane deadlock (critical): InjectAuto is called from postRun, which runs ON
  the lane's own worker; the blocking Submit against that lane's full 64-slot
  queue deadlocked the chat forever. Now TrySubmit first, and on a full lane
  the blocking wait moves to a detached goroutine (Submit is shutdown-safe;
  the auto-resume marker covers a crash mid-wait). Regression test fills the
  queue and asserts InjectAuto returns.
- Durable fix-round cap: the consecutive-round counter now rides INSIDE the
  fix-up prompt (verify.FixPrompt embeds "Auto-fix round N of M", ParseFixRound
  reads it back), so a deploy mid-loop resumes at the same round instead of
  resetting an in-memory map — POST_VERIFY_MAX_FIXES is no longer
  per-process-lifetime. The verifyFixes map is gone.
- Per-message git cost: the pre-run snapshot is now served from a cache
  refreshed at the END of each run (after delivery), so the 2-git-commands-
  per-repo cost leaves the user-visible critical path; only the first run
  after boot pays a synchronous Take.
- Kill grace: verify.runCmd SIGTERMs the gate's process group and escalates
  to SIGKILL only after 2s, mirroring core/claude — a timed-out runner can
  tear down containers and temp state instead of orphaning them.
- GOAL_MAX_ATTEMPTS floor: a non-positive cap stopped the /goal loop after
  its first evaluation round; config.EffectiveGoalMaxAttempts floors it to 1
  (ArmGoal guards too), and the goal-cap notice no longer fuses the verdict
  summary into the next sentence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch-name convention routes host events to chats and was parsed
independently by the poller and the CI watch — and the two disagreed: the
poller accepted an empty chat segment (emitting a PRComment with an empty
ChatID for a "duck//slug" ref, dropped only later by the adapter's ParseInt
guard) while the CI watch rejected it. The contract now lives in
core/teambranch with one ChatID() parser; the poller adopts it (an empty
chat id is now skipped and marked read, like any non-team branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A green build now emits CIGreen (once per repo+branch+SHA, durable dedupe)
whenever auto-merge did not consume the event. This closes the most-reported
hole in the bot's feedback loop: an agent that told its user "I'll report
when CI finishes" had NO wake-up signal for success — the PR poller only
relays new review comments — so a green build ended the conversation until
the user pinged. Auto-merge keeps its behavior (merge + notice, no extra
event); a transient PR lookup/merge failure retries next cycle without
consuming the green.

The dedupe store is now pruned every scan against the live checkout set
(a switched-away or deleted branch has no future observation to dedupe), so
it tracks live branches instead of growing toward the wholesale-wipe
backstop — whose reset would have re-emitted one duplicate fix-up per
still-red branch. The watch also adopts core/teambranch for chat routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both mains now assemble the post-run config via internal/autonomy.Build —
the verbatim buildAutonomy copy in cmd/duck-vk is gone, so a wiring change
cannot silently desynchronize the adapters (the same drift risk the review
flagged). The Telegram CI-watch wiring routes the new CIGreen event as an
InjectAuto prompt telling the agent to deliver the CI verdict it promised
(or briefly confirm and continue), in the user's language. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port back"

The no-standby rule kept being rationalized around with a false belief: the
agent assumes "the poller will wake me when the CI job finishes" and ends
its turn promising to report. It won't be woken: the poller only relays NEW
review comments from other people, and nothing starts a turn on a green
build (only a deployment with CI watch enabled does, which the agent cannot
see from inside the workspace). Spell that out and require waiting for a CI
verdict in the FOREGROUND (gh pr checks --watch / a bounded API poll loop),
or saying explicitly that the result will NOT be seen without a ping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eturns

The going-silent failure is broader than CI: any "I'll report back when X
finishes" strands the user, because nothing wakes a finished turn. This adds
the missing primitive so waiting is possible instead of forbidden-and-lied-
about: a durable one-shot store (single JSON file, atomic rewrite, ids seeded
across restarts) plus a 30s firing loop. Delays parse from a filename stem
(15m / 2h / 1d), floored to 1m and capped at 48h (longer horizons belong in
/schedule), with a per-chat cap of 10 pending items — each is an unattended,
model-cost-bearing run. Take-then-fire: a crash drops at most one firing
rather than duplicating it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…promises

Two halves of the same fix. The SWEEP runs after every terminal: files the
run wrote into the workspace's followup/ directory (filename = delay, content
= prompt — documented in a convention appended to every workspace CLAUDE.md)
are scheduled into the durable store with a confirmation notice, and fired
later through InjectAuto — budget-gated, AutoUser-attributed, auto-resumed.

The NUDGE is the mechanical safety net for the promise itself: prompt rules
demonstrably fail here (the model rationalizes "the poller will wake me"),
so when a cleanly finished run's FINAL answer matches a conservative set of
first-person return promises (EN + RU: "I'll report back", "доложу",
"отпишусь", "сообщу, как только", …) while having scheduled no follow-up,
ONE corrective run is injected: do the work now in the foreground, schedule a
real followup/<delay>.md, or retract the promise. A marker in the nudge's own
prompt guarantees it can never chain into a nudge loop; asking the USER to
follow up ("напиши, когда…") deliberately does not match. Both mains wire
the loop via the shared internal/autonomy builder; ENABLE_PROMISE_NUDGE=true
by default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The standby rules were CI-specific after the last pass; the failure is not.
The hard rule now names the follow-up convention as the one legal way to end
a turn that must wait ("followup/<delay>.md, tell the user when you'll be
back"), lists the RU phrasings that keep slipping through ("вернусь с
результатом", "доложу, когда закончится") as the bug, and the CI paragraph
points at the same mechanism. .env.example and README document the follow-up
convention and the promise nudge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
flock is DuckBug's own bot, but the team had no way to see the project's live
telemetry. The MCP config writer is now generic (WriteMCPConfig takes a named
server map; entries carry an optional Authorization: Bearer header and the
file stays 0600 since it may hold the token), and both mains assemble it from
config: context7 as before (ENABLE_CONTEXT7), plus DuckBug when
DUCKBUG_MCP_TOKEN is set — the token is the on/off switch, no separate flag.
DUCKBUG_MCP_URL defaults to the cloud endpoint (https://duckbug.io/api/mcp);
self-hosted deployments point it at <their-duckbug>/api/mcp. The workspace
agreement tells the team when to reach for the tools (debugging production
issues, verifying a fix stopped the errors). Claude backend only — codex has
its own MCP mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… role

The compose file passes .env wholesale (env_file), but the VK env template
and the Ansible role enumerate variables explicitly and had none of the new
surface. Both now carry the MCP block (ENABLE_CONTEXT7, DUCKBUG_MCP_TOKEN/URL)
and the full autonomy block (post-run verification, /goal evaluator, CI watch
+ auto-merge, scope auto-approval, the per-day autonomy budget, follow-ups +
promise nudge); the Ansible role gets matching defaults and renders the
DuckBug token from vault_duckbug_mcp_token. Telegram's .env.example gains the
previously undocumented ENABLE_CONTEXT7 alongside the DuckBug block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
govulncheck started failing every CI run on GO-2026-5856 — the Encrypted
Client Hello privacy leak in crypto/tls, published for go1.26.4 and fixed in
go1.26.5 — regardless of the diff under test. Bump the module toolchain and
the dev-tools image; the runtime images are node-based and unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaytcevcom zaytcevcom merged commit 705e3a3 into main Jul 9, 2026
4 checks passed
@zaytcevcom zaytcevcom deleted the feat/autonomy-loops branch July 9, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant