diff --git a/README.md b/README.md index 24c9ea2..90c1f4f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Everything else in [`.env.example`](adapters/telegram/.env.example) has sensible - **The conversation is the task source** — describe what you want in chat and review the PR that comes back; the agent's shell and editor are sandboxed inside the container. - **A real dev-team pipeline, not a single prompt** — spec-first acceptance criteria, build/regression gates, and an arbiter that breaks loops. +- **Autonomy loops** — the bot **verifies its own "done"** by re-running the repo's check gate itself; `/goal` arms an **independent evaluator** that loops the team until your criterion actually holds; `/schedule` runs recurring jobs; an optional **CI watch** reacts to red builds (and can auto-merge green PRs) — all under a per-chat daily autonomy budget. See [Autonomy loops](#autonomy-loops). - **Multi-transport** — **Telegram** and **VK** today, both on the same core; a new platform is a thin adapter, not a fork. - **PR reactions without inbound webhooks** — the bot *polls* your git host for new review comments and routes each back to the chat that opened the PR. - **Subscription-friendly** — authenticate with a Claude Pro/Max token (no per-token cost) or an Anthropic API key. @@ -72,6 +73,19 @@ The five subagents — **planner → coder → tester → reviewer → arbiter** The team is built for a **microservices** workspace: a feature can span several services, and it coordinates branches and one cross-linked PR per repo. The full pipeline, guardrails, and role table live in [`core/README.md`](core/README.md). +## Autonomy loops + +Four opt-in loops move you up the delegation ladder — from "the agent checks its own work" to "the agent runs without you" — each with a hard stop condition (env keys in [`.env.example`](adapters/telegram/.env.example)): + +- **Post-run verification** (`ENABLE_POST_VERIFY`, default **on**) — after a run reports done, the bot itself re-runs the changed repos' own check gate (`task`/`make`/`npm` `check`/`test`/`lint`) and, on red, sends the team back with the real failure output — up to `POST_VERIFY_MAX_FIXES` consecutive rounds. The agent's "tests pass" is verified, not trusted. +- **`/goal` evaluator** — `/goal all list views paginate correctly` arms a goal; 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 verdict. Not met → the unmet points are injected back as a fix-up; met → 🎯 and the goal disarms. Bounded by `GOAL_MAX_ATTEMPTS`; `EVALUATOR_MODEL` can pick a cheaper judge; `/goal off` disarms. +- **`/schedule` cron jobs** — durable per-chat recurring prompts with per-chat timezones (see the SCHEDULER block in `.env.example`); fired as normal team runs, gated by the creator's allow-list status and cost cap at fire time. +- **CI watch** (`ENABLE_CI_WATCH`) — polls CI state on the `duck/*` branches (GitHub check-runs or Gitea commit status). A red build injects "CI is red — fix and push" into the owning chat; a green build wakes the chat too, so "I'll report when CI finishes" actually happens — each once per commit. With `ENABLE_AUTO_MERGE=true` a green PR is merged automatically — the full hands-off mode; leave it false to keep the human merge. + +Runs can also legally **come back later**: writing `followup/.md` (content = the prompt) schedules a durable one-shot return — and a **promise nudge** fires one corrective run whenever a final answer says "I'll report back" without scheduling anything that actually would, so the bot stops going silent on its own promises. + +Two safety rails apply across all of it: `AUTO_TASK_MAX_COST_PER_DAY` caps what autonomy-originated runs may spend per chat per day (direct messages are unaffected), and `AUTO_APPROVE_SCOPE` controls which planner complexities may skip the "confirm scope & wait" step (`off` by default). + ## Repo layout (monorepo) The platform-agnostic dev-team brain lives in [`core/`](core/); each platform is a thin adapter under `adapters//` that shares it. diff --git a/adapters/telegram/.env.example b/adapters/telegram/.env.example index 020e4bb..319715c 100644 --- a/adapters/telegram/.env.example +++ b/adapters/telegram/.env.example @@ -123,6 +123,55 @@ MISTRAL_API_KEY= # for VOICE_PROVIDER=mistral (console.mistral.ai) # ENABLE_SCHEDULER=false # SCHEDULE_STORE_PATH= # JSON job store; default /schedules.json +# ===== MCP TOOLS (Claude backend only) ===== +# context7 — up-to-date library/API docs tools for the team (free tier, no key). +ENABLE_CONTEXT7=true +# DuckBug — the team reads your project's LIVE errors/logs from DuckBug monitoring. +# The token is the on/off switch: create an API token (sk-duck-api01-…) in the +# DuckBug UI and set it here. Self-hosted DuckBug: point the URL at /api/mcp. +DUCKBUG_MCP_TOKEN= +# DUCKBUG_MCP_URL=https://duckbug.io/api/mcp + +# ===== AUTONOMY LOOPS — quality gates without a human in the loop ===== +# Post-run verification (ON by default): after a run reports done, the bot itself +# re-runs the changed repos' own check gate (task/make/npm check|test|lint) and, on +# red, sends the team back with the real failure — up to POST_VERIFY_MAX_FIXES +# consecutive rounds per chat. "Tests pass" is verified, not trusted. +ENABLE_POST_VERIFY=true +POST_VERIFY_MAX_FIXES=2 +POST_VERIFY_TIMEOUT_SECONDS=1800 # bounds ONE repo's gate command +# /goal evaluator: /goal arms a goal; after every completed run an +# INDEPENDENT fresh-session judge re-checks the workspace against it and loops the +# team until it holds — up to GOAL_MAX_ATTEMPTS evaluation rounds (/goal off disarms). +# EVALUATOR_MODEL picks a (cheaper) judge model; empty inherits CLAUDE_MODEL. +GOAL_MAX_ATTEMPTS=5 +EVALUATOR_MODEL= +GOAL_EVAL_TIMEOUT_SECONDS=1800 +# CI watch (OFF by default): poll CI state on the duck/* branches and, once per +# commit, inject "CI is red — fix it" into the owning chat; a GREEN build wakes the +# chat too ("CI passed — report/continue"), so an agent that promised to report the +# CI verdict actually comes back instead of going silent. Needs GIT_TOKEN plus +# GIT_HOST=github.com or GITEA_API_URL. ENABLE_AUTO_MERGE additionally merges a +# branch's PR once CI is green — full hands-off; leave false to keep the human merge. +ENABLE_CI_WATCH=false +CI_POLL_INTERVAL=120 +ENABLE_AUTO_MERGE=false +# Scope auto-approval: skip the "confirm scope & wait" step for tasks at or below +# this planner complexity. off (always wait) | trivial | standard | all. +AUTO_APPROVE_SCOPE=off +# Autonomy budget: max USD per chat per UTC day that AUTONOMY-originated runs +# (CI fix-ups, verification fix-ups, goal rounds, follow-ups) may spend; direct user +# messages and scheduled jobs (gated by their creator's cost cap) are not affected. +# 0 disables. +AUTO_TASK_MAX_COST_PER_DAY=20.0 +# Follow-ups + promise nudge: a run may legally "come back later" by writing +# followup/.md (content = the prompt to run then; min 1m, max 48h) — swept +# after every run into a durable store and fired once when due. The promise nudge +# additionally fires ONE corrective run when a final answer says "I'll report back" +# without scheduling anything that actually would. +ENABLE_PROMISE_NUDGE=true +# FOLLOWUP_STORE_PATH= # JSON store; default /followups.json + # ===== DOCKER-IN-DOCKER (optional, with `--profile dind`) — dockerized linters/tests for the team ===== # DOCKER_HOST=tcp://dind:2375 diff --git a/adapters/telegram/Dockerfile b/adapters/telegram/Dockerfile index d1ac0d6..6ecef28 100644 --- a/adapters/telegram/Dockerfile +++ b/adapters/telegram/Dockerfile @@ -60,12 +60,30 @@ RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin; \ npm install -g @anthropic-ai/claude-code; \ - mkdir -p /opt/codex; \ - curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_HOME=/opt/codex CODEX_NON_INTERACTIVE=1 CODEX_INSTALL_DIR=/usr/local/bin sh; \ - chmod -R a+rX /opt/codex; \ npm cache clean --force; \ apt-get clean; rm -rf /var/lib/apt/lists/* +# Codex CLI — installed from the pinned GitHub release tarball, NOT the +# `curl | sh` installer: the installer resolves the release and its asset +# digests through the unauthenticated GitHub API, which rate-limits shared CI +# runners and then intermittently reports the platform assets missing ("Could +# not find Codex package or platform npm release assets"), breaking the arm64 +# leg of the multi-arch build. One arch-matched, retried, direct download +# mirrors the pinned `gh`/`task` installs below. +ARG CODEX_VERSION=0.143.0 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) target="x86_64-unknown-linux-musl" ;; \ + arm64) target="aarch64-unknown-linux-musl" ;; \ + *) echo "unsupported arch for codex: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL --retry 5 --retry-all-errors -o /tmp/codex.tgz \ + "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${target}.tar.gz"; \ + tar -xzf /tmp/codex.tgz -C /tmp; \ + install -m 0755 "/tmp/codex-${target}" /usr/local/bin/codex; \ + rm -f /tmp/codex.tgz "/tmp/codex-${target}" + # GitHub CLI (gh) for PR creation on github.com — installed from the official release # tarball, NOT the apt repo: that extra apt source tipped the QEMU-emulated arm64 # build over its memory limit during `apt-get update` (OOM: "Cannot allocate diff --git a/adapters/telegram/commands.go b/adapters/telegram/commands.go index 37fa606..f0d594a 100644 --- a/adapters/telegram/commands.go +++ b/adapters/telegram/commands.go @@ -90,7 +90,9 @@ const HelpText = "Flock Telegram assistant — available commands:\n\n" + "/help — show this message\n" + "/new — start a fresh session (forget the current conversation)\n" + "/stop — stop the run currently in progress\n" + - "/schedule — manage scheduled jobs (when enabled)\n\n" + + "/schedule — manage scheduled jobs (when enabled)\n" + + "/goal — arm a goal an independent evaluator re-checks after every run " + + "(/goal off to disarm)\n\n" + "Send any other message to run it through the assistant." // WelcomeText is the static usage message replied to an allowed user who sends diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml index e6ffde9..96329bd 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml +++ b/adapters/telegram/deploy/roles/claude_tg_bot/defaults/main.yml @@ -120,3 +120,31 @@ dind_image: "docker:27-dind" enable_docker_prune: true docker_prune_schedule: "weekly" # systemd OnCalendar (weekly | daily | "Sun *-*-* 04:00:00") docker_prune_keep_hours: 168 # keep anything newer than this many hours (168 = 7 days) + +# MCP tools (Claude backend only). context7 = live library docs (free, no key). +# DuckBug MCP activates when vault_duckbug_mcp_token is set in vault.yml — the team +# then reads the project's live errors/logs from DuckBug; self-hosted deployments +# override duckbug_mcp_url with /api/mcp. +enable_context7: true +duckbug_mcp_url: "https://duckbug.io/api/mcp" + +# Autonomy loops (see adapters/telegram/.env.example for the full story). +# Post-run verification re-runs changed repos' own check gates after every run. +enable_post_verify: true +post_verify_max_fixes: 2 +post_verify_timeout_seconds: 1800 +# /goal evaluator rounds cap + optional cheaper judge model. +goal_max_attempts: 5 +evaluator_model: "" +goal_eval_timeout_seconds: 1800 +# CI watch: red build → fix-up run; green build → wake the chat. Auto-merge merges +# a green PR (full hands-off) — keep false to leave merges to the human. +enable_ci_watch: false +ci_poll_interval: 120 +enable_auto_merge: false +# Scope auto-approval: off | trivial | standard | all. +auto_approve_scope: off +# Per-chat per-day USD ceiling for autonomy-originated runs (verify/goal/CI/follow-ups). +auto_task_max_cost_per_day: "20.0" +# followup/.md convention + the "I'll report back" promise nudge. +enable_promise_nudge: true diff --git a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 index 5f8b132..4f06267 100644 --- a/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 +++ b/adapters/telegram/deploy/roles/claude_tg_bot/templates/env.j2 @@ -108,6 +108,38 @@ MISTRAL_API_KEY={{ vault_mistral_api_key }} DATABASE_URL=sqlite:///data/bot.db SESSION_TIMEOUT_HOURS=24 +# === MCP TOOLS (Claude backend only) === +# context7: up-to-date library/API docs tools for the team (free tier, no key). +ENABLE_CONTEXT7={{ enable_context7 | default(true) | string | lower }} +{% if vault_duckbug_mcp_token is defined and vault_duckbug_mcp_token %} +# DuckBug MCP: the team reads the project's live errors/logs from DuckBug monitoring. +DUCKBUG_MCP_TOKEN={{ vault_duckbug_mcp_token }} +DUCKBUG_MCP_URL={{ duckbug_mcp_url | default('https://duckbug.io/api/mcp') }} +{% endif %} + +# === AUTONOMY LOOPS === +# Post-run verification: the bot re-runs changed repos' own check gates after a run +# and bounces failures back to the team (POST_VERIFY_MAX_FIXES consecutive rounds). +ENABLE_POST_VERIFY={{ enable_post_verify | default(true) | string | lower }} +POST_VERIFY_MAX_FIXES={{ post_verify_max_fixes | default(2) }} +POST_VERIFY_TIMEOUT_SECONDS={{ post_verify_timeout_seconds | default(1800) }} +# /goal evaluator: independent fresh-session judge loops the team until the user's +# criterion holds (bounded by GOAL_MAX_ATTEMPTS rounds). +GOAL_MAX_ATTEMPTS={{ goal_max_attempts | default(5) }} +EVALUATOR_MODEL={{ evaluator_model | default('') }} +GOAL_EVAL_TIMEOUT_SECONDS={{ goal_eval_timeout_seconds | default(1800) }} +# CI watch: poll CI on duck/* branches; red build → fix-up run, green build → wake +# the chat; ENABLE_AUTO_MERGE merges a green PR (full hands-off; default off). +ENABLE_CI_WATCH={{ enable_ci_watch | default(false) | string | lower }} +CI_POLL_INTERVAL={{ ci_poll_interval | default(120) }} +ENABLE_AUTO_MERGE={{ enable_auto_merge | default(false) | string | lower }} +# Scope auto-approval: off | trivial | standard | all. +AUTO_APPROVE_SCOPE={{ auto_approve_scope | default('off') }} +# Per-chat per-day USD cap for autonomy-originated runs (0 disables). +AUTO_TASK_MAX_COST_PER_DAY={{ auto_task_max_cost_per_day | default('20.0') }} +# followup/.md convention + the "I'll report back" promise nudge. +ENABLE_PROMISE_NUDGE={{ enable_promise_nudge | default(true) | string | lower }} + # === MISC === LOG_LEVEL=INFO ENABLE_TELEMETRY=false diff --git a/adapters/vk/.env.example b/adapters/vk/.env.example index cd63d56..b12b010 100644 --- a/adapters/vk/.env.example +++ b/adapters/vk/.env.example @@ -72,6 +72,45 @@ MISTRAL_API_KEY= # for VOICE_PROVIDER=mistral (console.mistral.ai) # ENABLE_SCHEDULER=false # SCHEDULE_STORE_PATH= # JSON job store; default /schedules.json +# ===== MCP TOOLS (Claude backend only) ===== +# context7 — up-to-date library/API docs tools for the team (free tier, no key). +ENABLE_CONTEXT7=true +# DuckBug — the team reads your project's LIVE errors/logs from DuckBug monitoring. +# The token is the on/off switch: create an API token (sk-duck-api01-…) in the +# DuckBug UI and set it here. Self-hosted DuckBug: point the URL at /api/mcp. +DUCKBUG_MCP_TOKEN= +# DUCKBUG_MCP_URL=https://duckbug.io/api/mcp + +# ===== AUTONOMY LOOPS — quality gates without a human in the loop ===== +# Post-run verification (ON by default): after a run reports done, the bot itself +# re-runs the changed repos' own check gate (task/make/npm check|test|lint) and, on +# red, sends the team back with the real failure — up to POST_VERIFY_MAX_FIXES +# consecutive rounds per chat. "Tests pass" is verified, not trusted. +ENABLE_POST_VERIFY=true +POST_VERIFY_MAX_FIXES=2 +POST_VERIFY_TIMEOUT_SECONDS=1800 # bounds ONE repo's gate command +# /goal evaluator: /goal arms a goal; after every completed run an +# INDEPENDENT fresh-session judge re-checks the workspace against it and loops the +# team until it holds — up to GOAL_MAX_ATTEMPTS evaluation rounds (/goal off disarms). +# EVALUATOR_MODEL picks a (cheaper) judge model; empty inherits CLAUDE_MODEL. +GOAL_MAX_ATTEMPTS=5 +EVALUATOR_MODEL= +GOAL_EVAL_TIMEOUT_SECONDS=1800 +# Scope auto-approval: skip the "confirm scope & wait" step for tasks at or below +# this planner complexity. off (always wait) | trivial | standard | all. +AUTO_APPROVE_SCOPE=off +# Autonomy budget: max USD per chat per UTC day that AUTONOMY-originated runs +# (verification fix-ups, goal rounds, follow-ups) may spend; direct user messages +# and scheduled jobs (gated by their creator's cost cap) are not affected. 0 disables. +AUTO_TASK_MAX_COST_PER_DAY=20.0 +# Follow-ups + promise nudge: a run may legally "come back later" by writing +# followup/.md (content = the prompt to run then; min 1m, max 48h) — swept +# after every run into a durable store and fired once when due. The promise nudge +# additionally fires ONE corrective run when a final answer says "I'll report back" +# without scheduling anything that actually would. +ENABLE_PROMISE_NUDGE=true +# FOLLOWUP_STORE_PATH= # JSON store; default /followups.json + # ===== DOCKER-IN-DOCKER (optional, with `--profile dind`) — dockerized linters/tests for the team ===== # DOCKER_HOST=tcp://dind:2375 diff --git a/adapters/vk/Dockerfile b/adapters/vk/Dockerfile index be179e5..1af5730 100644 --- a/adapters/vk/Dockerfile +++ b/adapters/vk/Dockerfile @@ -62,10 +62,30 @@ RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin; \ npm install -g @anthropic-ai/claude-code; \ - curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 CODEX_INSTALL_DIR=/usr/local/bin sh; \ npm cache clean --force; \ apt-get clean; rm -rf /var/lib/apt/lists/* +# Codex CLI — installed from the pinned GitHub release tarball, NOT the +# `curl | sh` installer: the installer resolves the release and its asset +# digests through the unauthenticated GitHub API, which rate-limits shared CI +# runners and then intermittently reports the platform assets missing ("Could +# not find Codex package or platform npm release assets"), breaking the arm64 +# leg of the multi-arch build. One arch-matched, retried, direct download +# mirrors the pinned `gh`/`task` installs below. +ARG CODEX_VERSION=0.143.0 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) target="x86_64-unknown-linux-musl" ;; \ + arm64) target="aarch64-unknown-linux-musl" ;; \ + *) echo "unsupported arch for codex: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL --retry 5 --retry-all-errors -o /tmp/codex.tgz \ + "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-${target}.tar.gz"; \ + tar -xzf /tmp/codex.tgz -C /tmp; \ + install -m 0755 "/tmp/codex-${target}" /usr/local/bin/codex; \ + rm -f /tmp/codex.tgz "/tmp/codex-${target}" + # GitHub CLI (gh) for PR creation on github.com — installed from the official release # tarball, NOT the apt repo: that extra apt source tipped the QEMU-emulated arm64 # build over its memory limit during `apt-get update` (OOM: "Cannot allocate diff --git a/adapters/vk/commands.go b/adapters/vk/commands.go index 4fe1fb6..5c01dc8 100644 --- a/adapters/vk/commands.go +++ b/adapters/vk/commands.go @@ -8,5 +8,14 @@ const HelpText = "Flock VK assistant — available commands:\n\n" + "/help — show this message\n" + "/new — start a fresh session (forget the current conversation)\n" + "/stop — stop the run currently in progress\n" + - "/schedule — manage scheduled jobs (when enabled)\n\n" + + "/schedule — manage scheduled jobs (when enabled)\n" + + "/goal — arm a goal an independent evaluator re-checks after every run " + + "(/goal off to disarm)\n\n" + "Send any other message to run it through the assistant." + +// goalUsageText is the /goal usage reply, mirroring the Telegram adapter. +const goalUsageText = "Usage:\n" + + "/goal — arm a goal; after every completed run an independent evaluator " + + "re-checks the workspace against it and sends the team back until it holds\n" + + "/goal — show the armed goal\n" + + "/goal off — disarm" diff --git a/adapters/vk/receiver.go b/adapters/vk/receiver.go index b9c2a7d..51b0f41 100644 --- a/adapters/vk/receiver.go +++ b/adapters/vk/receiver.go @@ -3,6 +3,7 @@ package vk import ( "context" "encoding/json" + "fmt" "log/slog" "strconv" "strings" @@ -11,6 +12,7 @@ import ( "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" + "github.com/duckbugio/flock/core/goal" "github.com/duckbugio/flock/core/schedule" ) @@ -38,6 +40,9 @@ type Service interface { StopChat(chatID chat.ChatID) bool NewSession(chatID chat.ChatID) error StarPress() (toast string, ok bool) + ArmGoal(chatID chat.ChatID, criterion string) (goal.Goal, bool) + GoalStatus(chatID chat.ChatID) (goal.Goal, bool) + DisarmGoal(chatID chat.ChatID) bool } // guardChecker decides whether an allowed user's accepted message may run (rate @@ -383,6 +388,39 @@ func (r *Receiver) dispatchReserved(ctx context.Context, name string, msg messag r.svc.StopChat(chatIDStr(peerID)) case "schedule": r.dispatchSchedule(ctx, msg) + case "goal": + r.dispatchGoal(ctx, msg) + } +} + +// dispatchGoal serves /goal: arm, show, or disarm the calling chat's goal via +// the shared Service, mirroring the Telegram adapter's goalHandler. +func (r *Receiver) dispatchGoal(ctx context.Context, msg messageObject) { + peerID := msg.PeerID + args := commandArgs(msg.Text) + switch strings.ToLower(args) { + case "": + if g, armed := r.svc.GoalStatus(chatIDStr(peerID)); armed { + r.notify(ctx, peerID, fmt.Sprintf( + "Armed goal (evaluation round %d/%d):\n%s\n\n%s", g.Attempts, g.MaxAttempts, g.Criterion, goalUsageText)) + return + } + r.notify(ctx, peerID, "No goal armed.\n\n"+goalUsageText) + case "off", "clear", "stop": + if r.svc.DisarmGoal(chatIDStr(peerID)) { + r.notify(ctx, peerID, "Goal disarmed.") + return + } + r.notify(ctx, peerID, "No goal was armed.") + default: + g, armed := r.svc.ArmGoal(chatIDStr(peerID), args) + if !armed { + r.notify(ctx, peerID, "The goal evaluator is disabled on this deployment.") + return + } + r.notify(ctx, peerID, fmt.Sprintf( + "🎯 Goal armed (up to %d evaluation rounds). After every completed run an independent "+ + "evaluator will judge:\n%s", g.MaxAttempts, g.Criterion)) } } diff --git a/adapters/vk/receiver_test.go b/adapters/vk/receiver_test.go index e52ab08..ce2e7c8 100644 --- a/adapters/vk/receiver_test.go +++ b/adapters/vk/receiver_test.go @@ -12,6 +12,7 @@ import ( "github.com/duckbugio/flock/core/agent" "github.com/duckbugio/flock/core/chat" + "github.com/duckbugio/flock/core/goal" "github.com/duckbugio/flock/core/schedule" ) @@ -24,6 +25,7 @@ type fakeService struct { stopChats []string newSessions []string starPresses int + goals map[string]goal.Goal } type handleCall struct { @@ -74,6 +76,32 @@ func (f *fakeService) StarPress() (string, bool) { return "ok", true } +func (f *fakeService) ArmGoal(chatID chat.ChatID, criterion string) (goal.Goal, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if f.goals == nil { + f.goals = map[string]goal.Goal{} + } + g := goal.Goal{Criterion: criterion, MaxAttempts: 5} + f.goals[chatID] = g + return g, true +} + +func (f *fakeService) GoalStatus(chatID chat.ChatID) (goal.Goal, bool) { + f.mu.Lock() + defer f.mu.Unlock() + g, ok := f.goals[chatID] + return g, ok +} + +func (f *fakeService) DisarmGoal(chatID chat.ChatID) bool { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.goals[chatID] + delete(f.goals, chatID) + return ok +} + // fakeNotice records notice calls. type fakeNotice struct { mu sync.Mutex diff --git a/cmd/duck-vk/main.go b/cmd/duck-vk/main.go index 374360c..28fa443 100644 --- a/cmd/duck-vk/main.go +++ b/cmd/duck-vk/main.go @@ -39,6 +39,7 @@ import ( "github.com/duckbugio/flock/core/voice" "github.com/duckbugio/flock/core/workspace" "github.com/duckbugio/flock/internal/airunner" + "github.com/duckbugio/flock/internal/autonomy" "github.com/duckbugio/flock/internal/config" ) @@ -119,13 +120,21 @@ func run() int { // Wire the context7 MCP docs server (up-to-date, version-specific library/API // docs) into every run when enabled. A write failure is non-fatal — the bot // runs without it, exactly as before. - if provider.Name == config.AIBackendClaude && cfg.EnableContext7 { + if provider.Name == config.AIBackendClaude { + servers := map[string]claude.MCPServer{} + if cfg.EnableContext7 { + servers["context7"] = claude.MCPServer{URL: claude.Context7URL} + } + if cfg.DuckBugMCPEnabled() { + servers["duckbug"] = claude.MCPServer{URL: cfg.DuckBugMCPURL, BearerToken: cfg.DuckBugMCPToken} + } mcpPath := filepath.Join(cfg.ApprovedDirectory, ".flock-mcp.json") - if err := claude.WriteContext7MCPConfig(mcpPath); err != nil { - logger.Warn("write context7 mcp config; running without docs MCP", "error", err) - } else { + if ok, err := claude.WriteMCPConfig(mcpPath, servers); err != nil { + logger.Warn("write mcp config; running without MCP tools", "error", err) + } else if ok { opts.MCPConfig = mcpPath - logger.Info("context7 MCP enabled", "config", mcpPath) + logger.Info("MCP servers enabled", + "config", mcpPath, "context7", cfg.EnableContext7, "duckbug", cfg.DuckBugMCPEnabled()) } } @@ -210,6 +219,11 @@ func run() int { // Post-task GitHub star nudge (GitHub-only; the gate is the off switch). starNudge := buildStarNudge(cfg, logger) + // Autonomy loops (post-run verification, the /goal evaluator, the per-chat + // autonomy budget). Store-open failures are non-fatal: log and run with that + // single feature disabled. The CI watch is Telegram-only for now. + postRun := autonomy.Build(cfg, logger) + svc := chat.New(chat.Config{ Runner: runner, Transport: transport, @@ -220,12 +234,16 @@ func run() int { CostCapUSD: cfg.EffectiveCostCapUSD(), Outbox: outbox, StarNudge: starNudge, + PostRun: postRun, Opts: opts, Timeout: cfg.ClaudeTimeout(), RetryAfter: vk.RetryAfter, Logger: logger, }) + // One-shot follow-ups (the workspace followup/.md convention). + autonomy.StartFollowups(ctx, svc, postRun, logger) + // Background cron scheduler (OFF by default). When enabled, open the durable // per-chat job store and start the scheduler loop, firing each due job into the // same dispatch lane a user message uses (svc.InjectScheduled, non-blocking + diff --git a/cmd/flock-telegram/autonomy.go b/cmd/flock-telegram/autonomy.go new file mode 100644 index 0000000..9a94812 --- /dev/null +++ b/cmd/flock-telegram/autonomy.go @@ -0,0 +1,155 @@ +// Autonomy-loop glue for the Telegram adapter: the /goal command, the CI-watch +// wiring, and the prompt formatters for injected CI events. The mechanics live +// in core/{goal,ciwatch,verify,autobudget} and internal/autonomy; this file +// only parses commands, replies, and routes events into chat.Service.InjectAuto. +package main + +import ( + "context" + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/duckbugio/flock/core/chat" + "github.com/duckbugio/flock/core/ciwatch" + "github.com/duckbugio/flock/internal/config" +) + +// goalUsage is the /goal help reply (engineering artifact, plain English). +const goalUsage = "Usage:\n" + + "/goal — arm a goal; after every completed run an independent evaluator " + + "re-checks the workspace against it and sends the team back until it holds\n" + + "/goal — show the armed goal\n" + + "/goal off — disarm" + +// goalHandler serves /goal from an allowed user: arm, show, or disarm the +// calling chat's goal. +func goalHandler(cfg config.Config, svc *chat.Service) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + chatID, ok := commandSender(cfg, update.Message) + if !ok { + return + } + args := commandArgs(update.Message.Text) + switch strings.ToLower(args) { + case "": + if g, armed := svc.GoalStatus(chatIDStr(chatID)); armed { + sendCommandReply(ctx, b, chatID, fmt.Sprintf( + "Armed goal (evaluation round %d/%d):\n%s\n\n%s", g.Attempts, g.MaxAttempts, g.Criterion, goalUsage)) + return + } + sendCommandReply(ctx, b, chatID, "No goal armed.\n\n"+goalUsage) + case "off", "clear", "stop": + if svc.DisarmGoal(chatIDStr(chatID)) { + sendCommandReply(ctx, b, chatID, "Goal disarmed.") + return + } + sendCommandReply(ctx, b, chatID, "No goal was armed.") + default: + g, armed := svc.ArmGoal(chatIDStr(chatID), args) + if !armed { + sendCommandReply(ctx, b, chatID, "The goal evaluator is disabled on this deployment.") + return + } + sendCommandReply(ctx, b, chatID, fmt.Sprintf( + "🎯 Goal armed (up to %d evaluation rounds). After every completed run an independent "+ + "evaluator will judge:\n%s", g.MaxAttempts, g.Criterion)) + } + } +} + +// wireCIWatch starts the CI polling loop and routes its events: a red build is +// injected into the owning chat as a fix-up run; an auto-merged PR is announced +// with a plain notice (no agent run needed). +func wireCIWatch(ctx context.Context, cfg config.Config, b *bot.Bot, svc *chat.Service, logger *slog.Logger) { + var host ciwatch.Host + if cfg.CIWatchGitHub() { + host = ciwatch.NewGitHub(cfg.GitToken, nil) + } else { + host = ciwatch.NewGitea(cfg.GiteaAPIURL, cfg.GitToken, nil) + } + state, err := ciwatch.OpenState(cfg.CIStateFile()) + if err != nil { + logger.Error("open ci-watch state; ci watch disabled", "path", cfg.CIStateFile(), "error", err) + return + } + out := make(chan ciwatch.Event) + go func() { + if err := ciwatch.Run(ctx, ciwatch.Config{ + BaseDir: cfg.ApprovedDirectory, + Host: host, + Interval: cfg.CIPollDuration(), + AutoMerge: cfg.EnableAutoMerge, + State: state, + Logger: logger, + }, out); err != nil && ctx.Err() == nil { + logger.Error("ci watch stopped", "error", err) + } + }() + go func() { + for { + select { + case <-ctx.Done(): + return + case ev := <-out: + id, err := strconv.ParseInt(ev.ChatID, 10, 64) + if err != nil { + logger.Warn("ci watch: bad chat id in branch", "chat_id", ev.ChatID) + continue + } + switch ev.Kind { + case ciwatch.CIFailed: + svc.InjectAuto(ctx, ev.ChatID, formatCIFailure(ev)) + case ciwatch.CIGreen: + svc.InjectAuto(ctx, ev.ChatID, formatCIGreen(ev)) + case ciwatch.PRMerged: + sendNotice(ctx, b, id, fmt.Sprintf( + "✅ CI is green on %s (%s) — auto-merged PR #%d.", ev.Branch, ev.Repo, ev.PRIndex)) + } + } + } + }() +} + +// shortSHALen is how many SHA characters the CI fix-up prompt shows. +const shortSHALen = 10 + +// formatCIFailure builds the injected prompt for a red build. Neutral English, +// no token, mirrors formatPRComment's tone. +func formatCIFailure(ev ciwatch.Event) string { + sha := ev.SHA + if len(sha) > shortSHALen { + sha = sha[:shortSHALen] + } + detail := ev.Detail + if detail == "" { + detail = "no failing-check details were reported" + } + return fmt.Sprintf( + "CI is RED on branch %s in %s (commit %s) — %s.\n\n"+ + "Read the failing check logs via the git host, reproduce the failure locally with the repo's "+ + "own runner, fix it (never weaken or skip tests to get green), and push the fix to the SAME branch.", + ev.Branch, ev.Repo, sha, detail) +} + +// formatCIGreen builds the injected prompt for a green build. Its whole job is +// to WAKE the session: an agent that told its user "I'll report when CI +// finishes" has no other wake-up signal for success (the poller only relays +// new PR comments), so a green build would otherwise end the conversation +// until the user pings. +func formatCIGreen(ev ciwatch.Event) string { + sha := ev.SHA + if len(sha) > shortSHALen { + sha = sha[:shortSHALen] + } + return fmt.Sprintf( + "CI completed SUCCESSFULLY on branch %s in %s (commit %s).\n\n"+ + "If you told the user you would report the CI result, do that now, in their language. "+ + "Otherwise confirm the green build briefly and continue any follow-up you deferred on it "+ + "(e.g. asking the human to merge). Do not re-run the checks.", + ev.Branch, ev.Repo, sha) +} diff --git a/cmd/flock-telegram/main.go b/cmd/flock-telegram/main.go index 81c14b9..26456fc 100644 --- a/cmd/flock-telegram/main.go +++ b/cmd/flock-telegram/main.go @@ -41,6 +41,7 @@ import ( "github.com/duckbugio/flock/core/voice" "github.com/duckbugio/flock/core/workspace" "github.com/duckbugio/flock/internal/airunner" + "github.com/duckbugio/flock/internal/autonomy" "github.com/duckbugio/flock/internal/config" ) @@ -118,27 +119,36 @@ func run() int { // Wire the context7 MCP docs server (up-to-date, version-specific library/API // docs) into every run when enabled. A write failure is non-fatal — the bot // runs without it, exactly as before. - if provider.Name == config.AIBackendClaude && cfg.EnableContext7 { + if provider.Name == config.AIBackendClaude { + servers := map[string]claude.MCPServer{} + if cfg.EnableContext7 { + servers["context7"] = claude.MCPServer{URL: claude.Context7URL} + } + if cfg.DuckBugMCPEnabled() { + servers["duckbug"] = claude.MCPServer{URL: cfg.DuckBugMCPURL, BearerToken: cfg.DuckBugMCPToken} + } mcpPath := filepath.Join(cfg.ApprovedDirectory, ".flock-mcp.json") - if err := claude.WriteContext7MCPConfig(mcpPath); err != nil { - logger.Warn("write context7 mcp config; running without docs MCP", "error", err) - } else { + if ok, err := claude.WriteMCPConfig(mcpPath, servers); err != nil { + logger.Warn("write mcp config; running without MCP tools", "error", err) + } else if ok { opts.MCPConfig = mcpPath - logger.Info("context7 MCP enabled", "config", mcpPath) + logger.Info("MCP servers enabled", + "config", mcpPath, "context7", cfg.EnableContext7, "duckbug", cfg.DuckBugMCPEnabled()) } } // Per-chat workspaces (isolated /workspace/chat_ with a rendered CLAUDE.md // + agents) and the dispatcher enforcing parallel-across-chats / serial-within. ws := &workspace.Renderer{ - BaseDir: cfg.ApprovedDirectory, - TemplatePath: cfg.TeamTemplatePath, - AgentsDir: cfg.TeamAgentsDir, - SkillsDir: cfg.TeamSkillsDir, - PrePRCycles: cfg.PrePRCycles, - PrReviewCycles: cfg.PrReviewCycles, - EnablePRReview: cfg.EnablePRReview, - GitHost: cfg.GitHost, + BaseDir: cfg.ApprovedDirectory, + TemplatePath: cfg.TeamTemplatePath, + AgentsDir: cfg.TeamAgentsDir, + SkillsDir: cfg.TeamSkillsDir, + PrePRCycles: cfg.PrePRCycles, + PrReviewCycles: cfg.PrReviewCycles, + EnablePRReview: cfg.EnablePRReview, + GitHost: cfg.GitHost, + AutoApproveScope: cfg.AutoApproveScopeLevel(), } // Durable per-chat session store: chatID -> session_id, reloaded on startup so // conversations survive a restart and resumed via --resume (plan §4). @@ -280,6 +290,11 @@ func run() int { // and disable the feature rather than crash-loop the bot. starNudge := buildStarNudge(cfg, logger) + // Autonomy loops (durable stores + post-run config). Every store-open failure + // is non-fatal, matching the rest of the best-effort startup wiring: log and + // run with that single feature disabled rather than crash-loop the bot. + postRun := autonomy.Build(cfg, logger) + svc = chat.New(chat.Config{ Runner: runner, Transport: telegram.NewBotChat(b, cfg.EnableRichMessages), @@ -291,6 +306,7 @@ func run() int { CostCapUSD: cfg.EffectiveCostCapUSD(), Outbox: outbox, StarNudge: starNudge, + PostRun: postRun, Opts: opts, Timeout: cfg.ClaudeTimeout(), RetryAfter: telegram.RetryAfter, @@ -306,6 +322,17 @@ func run() int { // goroutine runs — the path is a true no-op. mgr := buildScheduler(ctx, cfg, svc, logger) + // One-shot follow-ups (the workspace followup/.md convention): fire + // each due item as an autonomy-budgeted injected run. + autonomy.StartFollowups(ctx, svc, postRun, logger) + + // CI watch: poll the git host for CI state on the duck/* branches checked out + // in the workspaces; a red build is relayed into the owning chat as a fix-up + // prompt, and (opt-in) a green PR is auto-merged. + if cfg.CIWatchEnabled() { + wireCIWatch(ctx, cfg, b, svc, logger) + } + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, telegram.CallbackMatch(), bot.MatchTypePrefix, stopHandler(cfg, svc)) b.RegisterHandler( bot.HandlerTypeCallbackQueryData, telegram.StarCallbackPrefix(), bot.MatchTypePrefix, starHandler(cfg, svc), @@ -327,7 +354,7 @@ func run() int { } // Publish the bot's own command menu from the canonical reserved set (the same - // source the handlers above route on). Native Claude commands (/loop, /goal, …) + // source the handlers above route on). Native Claude commands (/loop, /security-review, …) // are intentionally NOT listed — they pass through as free text. Best-effort: // a failure here only means an empty/stale menu, so log and continue. if _, err := b.SetMyCommands(ctx, &bot.SetMyCommandsParams{Commands: reservedBotCommands()}); err != nil { @@ -875,6 +902,7 @@ func reservedHandlers(cfg config.Config, svc *chat.Service, sched *schedule.Mana "new": newHandler(cfg, svc), "stop": stopCommandHandler(cfg, svc), "schedule": scheduleHandler(cfg, sched), + "goal": goalHandler(cfg, svc), } } diff --git a/core/CLAUDE.workspace.md.tmpl b/core/CLAUDE.workspace.md.tmpl index ec5e225..027a21c 100644 --- a/core/CLAUDE.workspace.md.tmpl +++ b/core/CLAUDE.workspace.md.tmpl @@ -51,6 +51,10 @@ work, including quick direct tasks that skip the full pipeline — use them. a third-party dependency or hit an API you're not 100% sure of — pull the version the project actually depends on. A stale-API guess is a top source of subtly-wrong code; a quick docs lookup beats a wrong guess you then have to debug. +- **DuckBug MCP tools** (present only when the deployment wires a DuckBug token) read the + project's LIVE errors and logs from DuckBug monitoring. Reach for them when debugging a + production issue, verifying a fix actually stopped the errors, or when the user mentions an + error you can't reproduce locally — real telemetry beats guessing from code alone. ## Scheduling recurring tasks — hand the user a ready `/schedule` command @@ -101,6 +105,11 @@ feature's existing `duck//` branch and continues on it (go straigh 2. **Confirm scope with the user.** Send the spec summary + the **exact repos/services you will modify** as a normal message, then **stop and wait** for approval before writing or pushing. (A trivial one-liner may skip this.) + **Scope auto-approval is `${AUTO_APPROVE_SCOPE}`.** When the planner's COMPLEXITY is covered by + it (`trivial` covers trivial; `standard` covers trivial+standard; `all` covers everything; + `off` covers nothing), do NOT wait: still post the spec summary + repo list for visibility, + then proceed immediately. A COMPLEXITY above the covered level always waits for approval, and + NEEDS_CONTEXT always asks regardless of this setting. 3. **`coder`** — FIRST sync each repo to a fresh default branch (`git fetch` + checkout main/master + hard-reset to `origin/`; the persistent clone is often stale after a merge), THEN branch `duck//` from it (`` = the number in your workspace @@ -219,6 +228,21 @@ you're busy, so you do NOT need to post a status update. NEVER write "suite runn poke you just to re-run it. About to type "standing by"? STOP — run it in the foreground. You have the full per-turn timeout (hours); a multi-minute suite fits easily. +**Waiting on remote CI is the SAME rule — and here the false belief bites hardest.** The +PR poller does NOT wake you when a CI run finishes: it only relays NEW review comments from +OTHER people. Never reason "the poller will wake me when the job completes" — it won't, and a +promise like "доложу результат, как только джоба отработает" simply strands the user until +they ping you. When the user needs the CI verdict, wait for it in the FOREGROUND in this same +turn: `gh pr checks --watch` (or `gh run watch`) on github.com, or a bounded poll loop +against the host's API (`sleep 30` between checks, with a sensible overall `timeout`) on +Gitea/GitLab — then report the real result before you finish. Only if the deployment's CI +watch is enabled will a finished build (red or green) start a new turn by itself; you +generally cannot know that from inside the workspace, so never rely on it. If you genuinely +cannot wait it out, schedule a follow-up (see "Coming back later" below: write +`followup/.md` with the prompt to re-check) and tell the user when you'll be back — or +say explicitly: "CI is still running; I will NOT see its result unless you ping me". Never +imply you'll come back on your own without one of those two. + Use the project's OWN runner — the same entrypoint CI uses — not ad-hoc tool calls. Detect it first and prefer, in order: - `Taskfile.yml` → `task ` (e.g. `task lint`, `task test`, `task check`) @@ -233,17 +257,20 @@ NO such runner, fall back to invoking tools directly (`golangci-lint`, `pytest`, Hard rules: - Never push to or merge a default branch (main/master/beta…). Only `duck//`. - **Never merge the PR — the human merges.** APPROVE just means "ready for human merge". -- **Prefer independent, flag-gated PRs over deep stacks.** Default to slicing a big feature into PRs - that each merge to the default branch on their own (partial work hidden **behind a feature flag, - off by default**), the way trunk-based projects do — not a long dependent chain. Keep any stack - short (≈2–3) and only to make review readable. The rule below is the fallback for when a stack - is unavoidable, not the target shape. -- **Stacked/dependent PRs merge bottom-up or as one PR.** When a feature ships as a chain of PRs - each based on the previous branch (not the default), never merge them out of order or in parallel. - Merge **strictly bottom-up** — let the host retarget each child onto the default branch as its - base merges — **or collapse the chain into a single PR** to the default branch. Intermediate PRs - may auto-close (their commits still land); verify the default branch's tree afterward, and never - mask a merge command's failure (e.g. by piping it through `tail`). +- **One feature in one repo = ONE PR — slice into COMMITS, not PRs.** Never split a single + feature's work in a single repo across several PRs (no stacks, no "part 1/2/3"). Structure the + work as a sequence of coherent, reviewable commits on the ONE `duck//` branch — + e.g. `refactor: extract seam` → `feat: add X` → `test: cover X` — and open ONE PR. Follow-up + fixes from review/CI push MORE commits to the same branch/PR, never a new PR. A feature spanning + SEVERAL repos still gets exactly one PR per affected repo (cross-linked); only a genuinely + separate feature (its own spec/acceptance criteria) warrants its own PR. Oversized ask → + stage it via a plan doc (see "Planning docs"), where each stage is one self-contained PR. +- **If a dependent PR chain somehow exists, merge bottom-up or collapse it.** This is a recovery + rule, not a target shape: never merge chained PRs out of order or in parallel — merge strictly + bottom-up (the host retargets each child as its base merges), or collapse the chain into a + single PR to the default branch. Intermediate PRs may auto-close (their commits still land); + verify the default branch's tree afterward, and never mask a merge command's failure (e.g. by + piping it through `tail`). - The **arbiter governs termination**; the reviewer advises. Same blocking finding twice → the arbiter stops (APPROVE or ESCALATE), never another round. - Trivial one-line asks may skip the `planner`, but still branch + PR + Phase 2. @@ -255,15 +282,17 @@ Hard rules: NEVER call the `AskUserQuestion` tool: in this bot it has no transport and silently returns empty. - **Never "stand by" on your own work — even if the user tells you to.** Don't end a turn waiting on a backgrounded job or a long command you launched — there is no "later" to resume in. Block on it - in the foreground and report in the SAME turn. (Only a new user message or a PR-poller event can - start a turn.) This holds **even when the user says "go to the background", "wait and tell me", or - "report back when it's done"** — you have no working background channel, so obeying literally just - makes you go silent and forces the user to poke you ("ну что?"). Instead: run it in the foreground - **now** (the `Working…` spinner already signals you're busy — that IS "working in the background" - from their side) and answer with the real result in this turn. A turn whose entire content is an - acknowledgement — "I'll wait…", "standing by", "вернусь с результатом" — with no result and no - concretely scheduled wake-up is the bug; never emit one. Reply in the **user's language**, never a - terse English status line. + in the foreground and report in the SAME turn. (Only a new user message, a PR-poller event, or a + follow-up you explicitly scheduled can start a turn.) This holds **even when the user says "go to + the background", "wait and tell me", or "report back when it's done"** — you have no free-running + background channel, so obeying literally just makes you go silent and forces the user to poke you + ("ну что?"). Instead: run it in the foreground **now** (the `Working…` spinner already signals + you're busy — that IS "working in the background" from their side) and answer with the real result + in this turn; for a wait that genuinely exceeds the turn, schedule a follow-up + (`followup/.md`, see "Coming back later") and tell the user when you'll be back. A turn + whose entire content is an acknowledgement — "I'll wait…", "standing by", "вернусь с результатом", + "доложу, когда закончится" — with no result and no follow-up file scheduled is the bug; never emit + one. Reply in the **user's language**, never a terse English status line. - **NEEDS_CONTEXT → ESCALATE.** When any agent lacks the context to proceed well, surface it to the user and wait — never guess or burn cycles looping. - `./.team/memory.md` is workspace state — **never commit it** to a repo. diff --git a/core/agents/tester.md b/core/agents/tester.md index c146f27..9b17128 100644 --- a/core/agents/tester.md +++ b/core/agents/tester.md @@ -2,6 +2,7 @@ name: tester description: Runs and writes tests — the green-build gate. Covers per-repo unit/integration tests and the cross-service seam when a feature spans services. tools: Read, Edit, Write, Bash, Grep, Glob +model: sonnet --- You are the Tester. You make the change verifiable against the spec. diff --git a/core/autobudget/autobudget.go b/core/autobudget/autobudget.go new file mode 100644 index 0000000..14f3670 --- /dev/null +++ b/core/autobudget/autobudget.go @@ -0,0 +1,157 @@ +// Package autobudget persists a small, durable per-chat per-day accumulator of +// the USD cost spent by AUTONOMOUS runs — runs no human triggered: PR-poller +// relays, CI-watch fix-ups, scheduled tasks, goal-evaluator rounds and their +// injected follow-ups. It backs the autonomy spending cap: user-triggered runs +// are bounded by the per-user cost cap (core/cost), but an autonomous loop could +// burn budget overnight with nobody watching, so it gets its own, much smaller, +// per-day ceiling. +// +// The implementation mirrors core/cost.Store: a single JSON file, loaded once on +// Open and rewritten atomically on each mutation, guarded by a mutex. Keys are +// "#" (UTC day), and days older than the retention window are +// pruned on write so the file never grows unbounded. A nil *Store is a valid +// no-op store (auto budget disabled): Allowed always returns true and Add does +// nothing. +package autobudget + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/duckbugio/flock/internal/atomicfile" +) + +// dirPerm is the owner-only permission for bot-created directories. +const dirPerm os.FileMode = 0o750 + +// dayFormat renders the UTC day segment of a store key. +const dayFormat = "2006-01-02" + +// retainDays is how many trailing days of accounting are kept on write. Two days +// covers the current day plus the previous one across any timezone skew; older +// entries only matter for a cap that resets daily, so they are pruned. +const retainDays = 2 + +// Store persists chatID+day -> accumulated USD durably. The whole map is held in +// memory and the file is rewritten atomically on every Add. It is safe for +// concurrent use. A nil *Store is a valid no-op store (auto budget disabled). +type Store struct { + path string + + mu sync.Mutex + totals map[string]float64 +} + +// Open loads (or creates) a Store at path. A missing file yields an empty store; +// a present file is parsed as the persisted map. The parent directory is created +// if needed. Reopening the SAME path returns whatever was last persisted, which +// is how the daily totals survive a process restart. +func Open(path string) (*Store, error) { + if path == "" { + return nil, errors.New("autobudget: store path is empty") + } + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return nil, fmt.Errorf("autobudget: create store dir: %w", err) + } + s := &Store{path: path, totals: map[string]float64{}} + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +// load reads and decodes the backing file. A non-existent or empty file is not +// an error (a fresh store). The on-disk format is a JSON object keyed by +// "#", e.g. {"100#2026-07-08": 1.25}. +func (s *Store) load() error { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("autobudget: read store: %w", err) + } + if len(data) == 0 { + return nil + } + if err := json.Unmarshal(data, &s.totals); err != nil { + return fmt.Errorf("autobudget: parse store %s: %w", s.path, err) + } + return nil +} + +// key builds the per-chat per-UTC-day accumulator key. +func key(chatID string, now time.Time) string { + return chatID + "#" + now.UTC().Format(dayFormat) +} + +// SpentToday returns the USD already spent by autonomous runs in chatID during +// now's UTC day. A nil store reports zero. +func (s *Store) SpentToday(chatID string, now time.Time) float64 { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + return s.totals[key(chatID, now)] +} + +// Allowed reports whether chatID is still under the per-day autonomy cap. It +// returns false once the day's accumulated total is >= capUSD. A non-positive +// capUSD disables the cap (always allowed), and a nil store is likewise always +// allowed. Like the per-user cost cap, this is REACTIVE: cost is known only +// after a run completes, so the crossing run still ran. +func (s *Store) Allowed(chatID string, now time.Time, capUSD float64) bool { + if s == nil || capUSD <= 0 { + return true + } + return s.SpentToday(chatID, now) < capUSD +} + +// Add accumulates usd onto chatID's total for now's UTC day and rewrites the +// file atomically, pruning entries older than the retention window. A +// non-positive usd is a no-op so callers can Add unconditionally with whatever a +// run captured. A nil store is also a no-op. +func (s *Store) Add(chatID string, now time.Time, usd float64) error { + if s == nil || usd <= 0 { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + s.totals[key(chatID, now)] += usd + s.pruneLocked(now) + return s.persistLocked() +} + +// pruneLocked drops accumulator entries whose day is older than the retention +// window, keeping the file small. Malformed keys are dropped too (the store is +// best-effort accounting, not authoritative data). The caller must hold s.mu. +func (s *Store) pruneLocked(now time.Time) { + cutoff := now.UTC().AddDate(0, 0, -retainDays).Format(dayFormat) + for k := range s.totals { + i := strings.LastIndexByte(k, '#') + if i < 0 || k[i+1:] < cutoff { + delete(s.totals, k) + } + } +} + +// persistLocked writes the current map to a temp file in the same directory and +// renames it over the target, so a crash mid-write never leaves a half-written +// or corrupt store. The caller must hold s.mu. +func (s *Store) persistLocked() error { + data, err := json.Marshal(s.totals) + if err != nil { + return fmt.Errorf("autobudget: encode store: %w", err) + } + if err := atomicfile.Write(s.path, data, ".autobudget-*.tmp"); err != nil { + return fmt.Errorf("autobudget: %w", err) + } + return nil +} diff --git a/core/autobudget/autobudget_test.go b/core/autobudget/autobudget_test.go new file mode 100644 index 0000000..b17e844 --- /dev/null +++ b/core/autobudget/autobudget_test.go @@ -0,0 +1,111 @@ +//nolint:testpackage // intentionally whitebox to test unexported autobudget internals +package autobudget + +import ( + "path/filepath" + "testing" + "time" +) + +var day1 = time.Date(2026, 7, 8, 12, 0, 0, 0, time.UTC) + +func TestNilStoreIsNoop(t *testing.T) { + var s *Store + if !s.Allowed("1", day1, 5) { + t.Fatal("nil store must always allow") + } + if err := s.Add("1", day1, 1); err != nil { + t.Fatalf("nil Add: %v", err) + } + if got := s.SpentToday("1", day1); got != 0 { + t.Fatalf("nil SpentToday = %v, want 0", got) + } +} + +func TestAccumulateAndCap(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "auto.json")) + if err != nil { + t.Fatal(err) + } + if !s.Allowed("1", day1, 2) { + t.Fatal("fresh store must allow") + } + if err := s.Add("1", day1, 1.5); err != nil { + t.Fatal(err) + } + if !s.Allowed("1", day1, 2) { + t.Fatal("1.5 < 2 must still allow") + } + if err := s.Add("1", day1, 0.6); err != nil { + t.Fatal(err) + } + if s.Allowed("1", day1, 2) { + t.Fatal("2.1 >= 2 must deny") + } + // Other chats and other days are independent buckets. + if !s.Allowed("2", day1, 2) { + t.Fatal("another chat must be unaffected") + } + if !s.Allowed("1", day1.AddDate(0, 0, 1), 2) { + t.Fatal("the next day must reset the bucket") + } + // A non-positive cap disables the gate. + if !s.Allowed("1", day1, 0) { + t.Fatal("zero cap must always allow") + } +} + +func TestPersistAcrossReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "auto.json") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := s.Add("1", day1, 3); err != nil { + t.Fatal(err) + } + re, err := Open(path) + if err != nil { + t.Fatal(err) + } + if got := re.SpentToday("1", day1); got != 3 { + t.Fatalf("reopened SpentToday = %v, want 3", got) + } +} + +func TestPruneOldDays(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "auto.json")) + if err != nil { + t.Fatal(err) + } + if err := s.Add("1", day1, 1); err != nil { + t.Fatal(err) + } + // A write 3 days later prunes the old bucket (retention is 2 days). + later := day1.AddDate(0, 0, 3) + if err := s.Add("1", later, 1); err != nil { + t.Fatal(err) + } + if got := s.SpentToday("1", day1); got != 0 { + t.Fatalf("old day should be pruned, got %v", got) + } + if got := s.SpentToday("1", later); got != 1 { + t.Fatalf("current day = %v, want 1", got) + } +} + +func TestNonPositiveAddIsNoop(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "auto.json")) + if err != nil { + t.Fatal(err) + } + if err := s.Add("1", day1, 0); err != nil { + t.Fatal(err) + } + if err := s.Add("1", day1, -5); err != nil { + t.Fatal(err) + } + if got := s.SpentToday("1", day1); got != 0 { + t.Fatalf("SpentToday = %v, want 0", got) + } +} diff --git a/core/chat/followup.go b/core/chat/followup.go new file mode 100644 index 0000000..6bf2c9e --- /dev/null +++ b/core/chat/followup.go @@ -0,0 +1,130 @@ +package chat + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/duckbugio/flock/core/followup" +) + +// followupDirName is the workspace-root directory the follow-up convention +// sweeps (a sibling of the repos, like uploads/ and outbox/). +const followupDirName = "followup" + +// sweepFollowups collects the follow-up files a run wrote into +// /followup/ and schedules each in the durable store: the filename +// stem is the delay ("15m.md" → 15 minutes), the content is the prompt to run +// then. Every consumed file is deleted (scheduled or rejected — leaving a bad +// file would re-reject it after every subsequent run); each outcome is +// surfaced to the chat with a short notice. Returns how many follow-ups were +// scheduled. A nil store (feature disabled) or an absent directory is a no-op. +func (s *Service) sweepFollowups(ctx context.Context, chatID ChatID, workdir string) int { + if s.postrun.Followups == nil { + return 0 + } + dir := filepath.Join(workdir, followupDirName) + entries, err := os.ReadDir(dir) + if err != nil { + return 0 // absent dir — the run scheduled nothing + } + scheduled := 0 + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + path := filepath.Join(dir, name) + stem := strings.TrimSuffix(name, filepath.Ext(name)) + delay, ok := followup.ParseDelay(stem) + data, readErr := os.ReadFile(path) //nolint:gosec // path is a join of internal workspace dirs. + prompt := strings.TrimSpace(string(data)) + switch { + case !ok: + s.notify(ctx, chatID, fmt.Sprintf( + "⚠️ followup/%s ignored — the filename must be a delay like 15m.md or 2h.md.", name)) + case readErr != nil || prompt == "": + s.notify(ctx, chatID, fmt.Sprintf( + "⚠️ followup/%s ignored — the file content must be the prompt to run.", name)) + default: + due := s.nowFunc().Add(delay) + if _, err := s.postrun.Followups.Add(chatID, prompt, due); err != nil { + s.log.Warn("schedule follow-up", "chat_id", chatID, "error", err) + s.notify(ctx, chatID, "⚠️ followup/"+name+" not scheduled: "+err.Error()) + } else { + scheduled++ + s.notify(ctx, chatID, fmt.Sprintf( + "⏰ Follow-up scheduled in %s (%s).", delay, due.Format("15:04 MST"))) + } + } + if err := os.Remove(path); err != nil { + s.log.Warn("remove follow-up file", "chat_id", chatID, "path", path, "error", err) + } + } + return scheduled +} + +// FollowupPrompt renders the injected prompt for a fired follow-up. +func FollowupPrompt(it followup.Item) string { + return "Scheduled follow-up fired — you asked to come back to this now. Do the work in the " + + "foreground and report the result to the user in their language.\n\n" + it.Prompt +} + +// promiseNudgeMarker is the first line of the corrective prompt injected when +// a run's final answer promised to come back on its own. Its presence in a +// run's OWN prompt suppresses the detector for that run, so a nudge can never +// chain into a nudge loop. +const promiseNudgeMarker = "Self-follow-up check." + +// promiseRe matches first-person "I'll come back on my own" promises in a +// final answer — the exact phrases that strand users, in the two languages the +// bot's audiences use. Deliberately conservative: asking the USER to follow up +// ("let me know", "напиши, когда") must not match. +var promiseRe = regexp.MustCompile(`(?i)` + + `i(?:['’]|\x60)?ll (?:report|follow up|check back|update you|keep (?:you posted|an eye|watching)|let you know|get back to you)` + + `|i will (?:report|follow up|update you|let you know|get back to you)` + + `|report back (?:once|when|after|as soon as)` + + `|standing by` + + `|доложу` + + `|отпишусь` + + `|вернусь с результат` + + `|сообщу,? (?:когда|как только)` + + `|дам знать,? (?:когда|как только)` + + `|напишу,? (?:когда|как только)` + + `|буду следить`) + +// maybeNudgePromise fires ONE corrective run when a cleanly finished run's +// final answer promised to come back on its own while scheduling nothing that +// actually would (no follow-up file, and this run is not itself the nudge). +// Prompt rules alone demonstrably fail here — the model rationalizes "the +// poller will wake me" — so the safety net is mechanical: the nudge makes the +// run either do the work now, schedule a real follow-up, or retract the +// promise. Bounded to one step by promiseNudgeMarker and budget-gated by +// InjectAuto. +func (s *Service) maybeNudgePromise(ctx context.Context, chatID ChatID, runPrompt, finalText string) { + if s.postrun.Followups == nil || !s.postrun.PromiseNudge { + return + } + if strings.Contains(runPrompt, promiseNudgeMarker) { + return // the nudge itself may not chain + } + matched := promiseRe.FindString(finalText) + if matched == "" { + return + } + s.log.Info("final answer promised an unprompted return; nudging", "chat_id", chatID, "matched", matched) + s.InjectAuto(ctx, chatID, fmt.Sprintf( + promiseNudgeMarker+"\n\n"+ + "Your previous reply promised to come back on your own (matched: %q). Nothing wakes you "+ + "automatically — that promise strands the user. Do ONE of the following NOW:\n"+ + "1. Complete the promised work in the FOREGROUND and report the real result.\n"+ + "2. If it genuinely must wait, schedule a real follow-up: write followup/.md "+ + "(e.g. followup/15m.md) whose content is the prompt to run then, and tell the user when "+ + "you will be back.\n"+ + "3. Send a short correction telling the user you will NOT return on your own and what "+ + "they should do instead.\n"+ + "Reply in the user's language.", matched)) +} diff --git a/core/chat/postrun.go b/core/chat/postrun.go new file mode 100644 index 0000000..d276645 --- /dev/null +++ b/core/chat/postrun.go @@ -0,0 +1,370 @@ +package chat + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/duckbugio/flock/core/agent" + "github.com/duckbugio/flock/core/autobudget" + "github.com/duckbugio/flock/core/followup" + "github.com/duckbugio/flock/core/goal" + "github.com/duckbugio/flock/core/verify" +) + +// AutoUser is the sentinel user id carried by AUTONOMOUS runs — runs no human +// triggered and no stored creator owns: CI-watch fix-ups, post-run verification +// fix-ups, and goal-evaluator follow-ups. No real platform user id is negative, +// so the cost recorder can attribute these runs to the per-chat autonomy budget +// instead of a user. The pre-existing 0 sentinel (poller relays, startup +// resumes) and scheduled fires (attributed to their creator) keep their +// meanings. +const AutoUser int64 = -1 + +// notifyTimeout bounds one best-effort post-run notice delivery. +const notifyTimeout = 30 * time.Second + +// budgetDayFormat renders the UTC day used to de-duplicate the "budget +// reached" notice (one per chat per day). +const budgetDayFormat = "2006-01-02" + +// PostRunConfig bundles the autonomous post-run loop dependencies. Every field +// is optional: the zero value disables the whole feature set, keeping existing +// deployments byte-identical. +type PostRunConfig struct { + // Verifier re-runs changed repos' own check gates after a clean run (the + // deterministic "trust but verify" pass). Nil disables verification. + Verifier *verify.Verifier + // VerifyMaxFixes caps CONSECUTIVE auto-fix injections after failed + // verification, per chat; the counter resets on a passing round or a real + // user message. Non-positive disables the fix loop (failures only notify). + VerifyMaxFixes int + // Goals persists each chat's /goal objective. Nil disables the evaluator. + Goals goal.Store + // GoalMaxAttempts is the default evaluation-round cap for a newly armed goal. + GoalMaxAttempts int + // EvalModel overrides the evaluator's --model; empty inherits the run model. + EvalModel string + // EvalMaxTurns bounds the evaluator session; non-positive inherits the run cap. + EvalMaxTurns int + // EvalTimeout bounds one evaluator session; non-positive means no deadline. + EvalTimeout time.Duration + // Budget accumulates autonomous runs' cost per chat per UTC day; nil disables + // the autonomy budget (autonomous runs are then unmetered). + Budget *autobudget.Store + // BudgetCapUSD is the per-chat per-day autonomy ceiling; non-positive + // disables the gate. + BudgetCapUSD float64 + // Followups persists the one-shot "come back later" items the workspace + // followup/ convention schedules (see core/followup). Nil disables the sweep + // and the promise nudge. + Followups *followup.FileStore + // PromiseNudge fires one corrective run when a final answer promises to + // come back on its own without scheduling anything that would. + PromiseNudge bool +} + +// InjectAuto submits an AUTONOMY-originated prompt (a CI event, a verification +// or goal fix-up) into chatID. It differs from Inject in exactly two ways: the +// run is attributed to AutoUser so its cost lands on the chat's per-day +// autonomy budget, and the injection itself is gated by that budget — once the +// day's budget is spent, autonomous work pauses (with a single daily notice) +// while direct user messages keep working. A fix-up for work already paid for +// should neither be dropped on a busy lane nor lost to a deploy, so it +// enqueues an auto-resume marker and never abandons the job — but it must NOT +// block either: InjectAuto is called from postRun, which executes ON the +// lane's own worker goroutine, and a blocking Submit against that lane's full +// queue would deadlock the chat forever (the worker is the only goroutine that +// drains the queue). Hence TrySubmit first, and on a full lane the blocking +// wait is handed to a detached goroutine — Submit itself is shutdown-safe (it +// drops on the dispatcher's root context), and the marker enqueued above +// resumes the job if the process dies mid-wait. ctx scopes only the budget +// notice — the run itself executes under the Dispatcher's per-chat context. +func (s *Service) InjectAuto(ctx context.Context, chatID ChatID, prompt string) { + if !s.autoAllowed(chatID) { + s.log.Info("autonomy budget reached; dropping injected run", "chat_id", chatID) + s.notifyBudgetOnce(ctx, chatID) + return + } + id := s.enqueuePending(chatID, prompt) + job := func(runCtx context.Context) { + s.run(runCtx, chatID, AutoUser, prompt, nil, id) + } + if s.dispatch.TrySubmit(chatID, job) { + return + } + s.log.Info("lane full; queuing autonomous run in the background", "chat_id", chatID) + go s.dispatch.Submit(chatID, job) +} + +// postRun is the autonomy tail of a CLEANLY successful run: first the +// deterministic gate re-run (verify), then — only on a clean tree — the goal +// evaluator. Each stage may inject a follow-up run; the injected run repeats +// this tail on ITS clean terminal, which is what closes the loop. prompt is +// the prompt that STARTED this run — a verification fix-up carries its loop +// round inside it (see verify.FixPrompt), which is what makes the +// consecutive-round cap survive restarts. ctx is the dispatcher lane context +// (NOT the per-run timeout), so Stop/shutdown still aborts post-run work +// promptly. +func (s *Service) postRun(ctx context.Context, chatID ChatID, workdir, prompt string, snap verify.Snapshot) { + if ctx.Err() != nil { + return + } + if !s.verifyGates(ctx, chatID, workdir, prompt, snap) { + return + } + s.evaluateGoal(ctx, chatID, workdir) +} + +// notify posts a short post-run notice. Delivery must survive the (possibly +// already-cancelled) triggering context, so it runs on a detached, bounded +// derivative. Best-effort: failures are only logged. +func (s *Service) notify(ctx context.Context, chatID ChatID, text string) { + sendCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), notifyTimeout) + defer cancel() + if _, err := s.chat.Send(sendCtx, chatID, text, "", true); err != nil { + s.log.Debug("send post-run notice", "chat_id", chatID, "error", err) + } +} + +// verifyGates re-runs the check gates of repos this run changed and reports +// whether the tree is clean (true also when verification is disabled or +// nothing changed). On failure it notifies the chat and — within the +// consecutive-round cap — injects a fix-up run. The round this run represents +// is parsed from ITS OWN prompt (a fix-up prompt carries the marker; anything +// else is round 0), so the cap is durable: a deploy mid-loop resumes the +// fix-up run from its persisted prompt at the same round. +func (s *Service) verifyGates(ctx context.Context, chatID ChatID, workdir, prompt string, snap verify.Snapshot) bool { + if s.postrun.Verifier == nil { + return true + } + // Re-gate last round's failures even when this run changed nothing — + // otherwise a no-op "fix" run would silently exit the loop with a red gate. + s.mu.Lock() + retry := s.verifyRetry[chatID] + s.mu.Unlock() + results := s.postrun.Verifier.Verify(ctx, workdir, snap, retry) + failed := verify.Failed(results) + if len(failed) == 0 { + if len(results) > 0 { + // A verified-clean round closes any ongoing fix loop. + s.mu.Lock() + delete(s.verifyRetry, chatID) + s.mu.Unlock() + } + return true + } + + var cmds []string + repos := make([]string, 0, len(failed)) + for _, f := range failed { + cmds = append(cmds, fmt.Sprintf("%s (`%s`)", f.Repo, f.Command)) + repos = append(repos, f.Repo) + } + summary := strings.Join(cmds, ", ") + + round, _ := verify.ParseFixRound(prompt) // 0 for a non-fix-up run + s.mu.Lock() + s.verifyRetry[chatID] = repos + s.mu.Unlock() + if round >= s.postrun.VerifyMaxFixes { + s.notify(ctx, chatID, fmt.Sprintf( + "⚠️ Post-run verification still red after %d auto-fix round(s): %s. Stopping the auto-fix loop — please review.", + round, summary)) + return false + } + + s.notify(ctx, chatID, fmt.Sprintf( + "⚠️ Post-run verification failed: %s. Sending the team back to fix it (round %d/%d).", + summary, round+1, s.postrun.VerifyMaxFixes)) + s.InjectAuto(ctx, chatID, verify.FixPrompt(failed, round+1, s.postrun.VerifyMaxFixes)) + return false +} + +// evaluateGoal runs the independent goal evaluator when the chat has an armed +// /goal, then either celebrates, loops a fix-up run, or stops at the attempt +// cap. Inconclusive rounds (no parsable verdict) never loop and never consume +// an attempt. +func (s *Service) evaluateGoal(ctx context.Context, chatID ChatID, workdir string) { + if s.postrun.Goals == nil { + return + } + g, ok := s.postrun.Goals.Get(chatID) + if !ok { + return + } + if !s.autoAllowed(chatID) { + s.notifyBudgetOnce(ctx, chatID) + return + } + verdict, ok := s.runEvaluator(ctx, chatID, workdir, g.Criterion) + if !ok { + s.notify(ctx, chatID, "⚖️ Goal evaluation was inconclusive this round; it will re-run after the next completed run.") + return + } + g, ok, err := s.postrun.Goals.Bump(chatID) + if err != nil { + s.log.Error("bump goal attempts", "chat_id", chatID, "error", err) + } + if !ok { + return // goal disarmed concurrently (user ran /goal off) + } + switch { + case verdict.Achieved: + s.notify(ctx, chatID, fmt.Sprintf( + "🎯 Goal achieved — confirmed by an independent evaluation (round %d/%d): %s", + g.Attempts, g.MaxAttempts, verdict.Summary)) + s.deleteGoal(chatID) + case g.Attempts >= g.MaxAttempts: + s.notify(ctx, chatID, fmt.Sprintf( + "⚠️ Goal NOT achieved after %d evaluation round(s) — stopping the loop. Last verdict: %s\nRe-arm with /goal when ready.", + g.Attempts, verdict.Summary)) + s.deleteGoal(chatID) + default: + s.notify(ctx, chatID, fmt.Sprintf( + "⚖️ Evaluation round %d/%d: goal not met yet — sending the team back to work.", + g.Attempts, g.MaxAttempts)) + s.InjectAuto(ctx, chatID, goal.FixupPrompt(g.Criterion, verdict)) + } +} + +// deleteGoal disarms the chat's goal, logging (never surfacing) a store error. +func (s *Service) deleteGoal(chatID ChatID) { + if err := s.postrun.Goals.Delete(chatID); err != nil { + s.log.Error("delete goal", "chat_id", chatID, "error", err) + } +} + +// runEvaluator spawns the evaluator — a FRESH provider session in the same +// workspace (deliberately no session resume: an independent context cannot +// inherit the working session's blind spots) — and parses its verdict. Its cost +// is recorded against the chat's autonomy budget. ok=false means the round was +// inconclusive (spawn/stream error, error result, or unparsable verdict). +func (s *Service) runEvaluator(ctx context.Context, chatID ChatID, workdir, criterion string) (goal.Verdict, bool) { + opts := s.opts + opts.SessionID = "" // fresh, independent session + opts.Workdir = workdir + opts.Images = nil + opts.MCPConfig = "" + opts.Effort = "" // judging needs no ultracode orchestration + if s.postrun.EvalModel != "" { + opts.Model = s.postrun.EvalModel + } + if s.postrun.EvalMaxTurns > 0 { + opts.MaxTurns = s.postrun.EvalMaxTurns + } + if s.postrun.EvalTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, s.postrun.EvalTimeout) + defer cancel() + } + + events, err := s.runner.Run(ctx, goal.EvalPrompt(criterion), opts) + if err != nil { + s.log.Error("start goal evaluator", "chat_id", chatID, "error", err) + return goal.Verdict{}, false + } + var res *agent.RunResult + for ev := range events { + switch ev.Type { + case agent.Result: + res = ev.Result + case agent.RunError: + s.log.Error("goal evaluator run", "chat_id", chatID, "error", ev.Err) + default: + // Progress events are irrelevant here — the evaluator runs silently. + } + } + if res == nil { + return goal.Verdict{}, false + } + s.recordAutoCost(chatID, res) + if res.IsError { + s.log.Warn("goal evaluator returned an error result", "chat_id", chatID, "subtype", res.Subtype) + return goal.Verdict{}, false + } + return goal.ParseVerdict(res.Text) +} + +// autoAllowed reports whether autonomous work may still run today for chatID +// under the per-day autonomy budget. Unconfigured budget → always allowed. +func (s *Service) autoAllowed(chatID ChatID) bool { + return s.postrun.Budget.Allowed(chatID, s.nowFunc(), s.postrun.BudgetCapUSD) +} + +// recordAutoCost accumulates a completed autonomous run's cost onto the chat's +// per-day autonomy budget. Nil store / nil result / non-positive cost are +// no-ops; a write failure only weakens the cap, never breaks delivery. +func (s *Service) recordAutoCost(chatID ChatID, res *agent.RunResult) { + if res == nil { + return + } + if err := s.postrun.Budget.Add(chatID, s.nowFunc(), res.CostUSD); err != nil { + s.log.Error("record autonomy cost", "chat_id", chatID, "error", err) + } +} + +// notifyBudgetOnce tells the chat its autonomy budget is exhausted — once per +// chat per UTC day, so a busy CI watch or fix loop can't spam the notice. +func (s *Service) notifyBudgetOnce(ctx context.Context, chatID ChatID) { + day := s.nowFunc().UTC().Format(budgetDayFormat) + s.mu.Lock() + already := s.budgetNotified[chatID] == day + if !already { + s.budgetNotified[chatID] = day + } + s.mu.Unlock() + if already { + return + } + s.notify(ctx, chatID, fmt.Sprintf( + "💤 Daily autonomy budget ($%.2f) reached — automated runs (CI reactions, verification and "+ + "goal rounds) are paused until tomorrow. Your direct messages still work.", + s.postrun.BudgetCapUSD)) +} + +// ArmGoal arms (or replaces) chatID's goal with the given criterion, using the +// configured default attempt cap. It reports the effective goal for the +// adapter's confirmation reply. ok=false when the goal feature is disabled. +func (s *Service) ArmGoal(chatID ChatID, criterion string) (goal.Goal, bool) { + if s.postrun.Goals == nil { + return goal.Goal{}, false + } + attempts := s.postrun.GoalMaxAttempts + if attempts < 1 { + // Defense in depth behind config.EffectiveGoalMaxAttempts: a non-positive + // cap would end the loop after its first evaluation round. + attempts = 1 + } + g := goal.Goal{ + Criterion: criterion, + MaxAttempts: attempts, + CreatedAt: s.nowFunc().UnixMilli(), + } + if err := s.postrun.Goals.Set(chatID, g); err != nil { + s.log.Error("arm goal", "chat_id", chatID, "error", err) + return goal.Goal{}, false + } + return g, true +} + +// GoalStatus returns chatID's armed goal, ok=false when none (or disabled). +func (s *Service) GoalStatus(chatID ChatID) (goal.Goal, bool) { + if s.postrun.Goals == nil { + return goal.Goal{}, false + } + return s.postrun.Goals.Get(chatID) +} + +// DisarmGoal clears chatID's goal, reporting whether one was armed. +func (s *Service) DisarmGoal(chatID ChatID) bool { + if s.postrun.Goals == nil { + return false + } + _, ok := s.postrun.Goals.Get(chatID) + if ok { + s.deleteGoal(chatID) + } + return ok +} diff --git a/core/chat/postrun_test.go b/core/chat/postrun_test.go new file mode 100644 index 0000000..90d2863 --- /dev/null +++ b/core/chat/postrun_test.go @@ -0,0 +1,425 @@ +//nolint:testpackage // intentionally whitebox to test unexported post-run loop internals +package chat + +import ( + "context" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/duckbugio/flock/core/agent" + "github.com/duckbugio/flock/core/autobudget" + "github.com/duckbugio/flock/core/dispatch" + "github.com/duckbugio/flock/core/followup" + "github.com/duckbugio/flock/core/goal" + "github.com/duckbugio/flock/core/verify" +) + +// scriptedRunner returns a fresh scripted event slice per Run call (call 1 = +// script[0], …), recording every prompt. onRun, when non-nil, fires at the +// START of each call — a test hook to mutate the workspace "during" a run. +type scriptedRunner struct { + mu sync.Mutex + script [][]agent.Event + prompts []string + onRun func(call int) +} + +func (r *scriptedRunner) Run(ctx context.Context, prompt string, _ agent.Options) (<-chan agent.Event, error) { + r.mu.Lock() + call := len(r.prompts) + r.prompts = append(r.prompts, prompt) + var events []agent.Event + if call < len(r.script) { + events = r.script[call] + } + hook := r.onRun + r.mu.Unlock() + if hook != nil { + hook(call) + } + out := make(chan agent.Event) + go func() { + defer close(out) + for _, e := range events { + select { + case out <- e: + case <-ctx.Done(): + return + } + } + }() + return out, nil +} + +func (r *scriptedRunner) recorded() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.prompts...) +} + +// cleanResult builds a successful terminal Result event with the given cost. +func cleanResult(text string, cost float64) []agent.Event { + return []agent.Event{{Type: agent.Result, Result: &agent.RunResult{Text: text, CostUSD: cost}}} +} + +// dirWorkspace resolves every chat to one fixed real directory (unlike +// fakeWorkspace's synthetic path), so verifier tests can plant repos in it. +type dirWorkspace struct{ dir string } + +func (w *dirWorkspace) Ensure(ChatID) (string, error) { return w.dir, nil } + +// newPostRunService wires a Service with the given runner + post-run config. +func newPostRunService( + t *testing.T, r agent.Runner, c Transport, ws workspaceEnsurer, pr PostRunConfig, +) (*Service, *dispatch.Dispatcher) { + t.Helper() + d := dispatch.New(4) + s := New(Config{ + Runner: r, + Transport: c, + Dispatcher: d, + Workspace: ws, + PostRun: pr, + Logger: slog.New(slog.DiscardHandler), + }) + s.tick = 5 * time.Millisecond + return s, d +} + +// openGoals opens a real goal store in a temp dir. +func openGoals(t *testing.T) *goal.FileStore { + t.Helper() + s, err := goal.Open(filepath.Join(t.TempDir(), "goals.json")) + if err != nil { + t.Fatal(err) + } + return s +} + +// TestGoalLoopFixupThenAchieved drives the full /goal loop: run → evaluator +// says "not met" → fix-up injected → evaluator says "met" → goal disarmed. +func TestGoalLoopFixupThenAchieved(t *testing.T) { + goals := openGoals(t) + if err := goals.Set("100", goal.Goal{Criterion: "the dashboard loads", MaxAttempts: 3}); err != nil { + t.Fatal(err) + } + fr := &scriptedRunner{script: [][]agent.Event{ + cleanResult("did some work", 0), + cleanResult(`{"achieved": false, "summary": "endpoint missing", "unmet": ["GET /dash 404s"]}`, 0), + cleanResult("fixed the endpoint", 0), + cleanResult(`{"achieved": true, "summary": "dashboard loads fine", "unmet": []}`, 0), + }} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &fakeWorkspace{}, PostRunConfig{Goals: goals, GoalMaxAttempts: 3}) + defer d.Close() + + svc.Handle(context.Background(), "100", 42, "1", "build the dashboard") + + waitUntil(t, func() bool { _, armed := goals.Get("100"); return !armed }) + prompts := fr.recorded() + if len(prompts) != 4 { + t.Fatalf("want 4 runs (work, eval, fixup, eval), got %d: %q", len(prompts), prompts) + } + if !strings.Contains(prompts[1], "the dashboard loads") || !strings.Contains(prompts[1], "acceptance evaluator") { + t.Fatalf("run 2 must be the evaluator, got: %s", prompts[1]) + } + if !strings.Contains(prompts[2], "GET /dash 404s") { + t.Fatalf("run 3 must be the fix-up carrying the unmet point, got: %s", prompts[2]) + } + joined := strings.Join(allSent(fc), "\n") + if !strings.Contains(joined, "🎯") { + t.Fatalf("achievement notice missing; sent:\n%s", joined) + } +} + +// TestGoalLoopStopsAtAttemptCap asserts the loop ends (goal disarmed, no +// fix-up injected) once the evaluation-round cap is reached. +func TestGoalLoopStopsAtAttemptCap(t *testing.T) { + goals := openGoals(t) + if err := goals.Set("100", goal.Goal{Criterion: "impossible", MaxAttempts: 1}); err != nil { + t.Fatal(err) + } + fr := &scriptedRunner{script: [][]agent.Event{ + cleanResult("did some work", 0), + cleanResult(`{"achieved": false, "summary": "still impossible", "unmet": ["x"]}`, 0), + }} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &fakeWorkspace{}, PostRunConfig{Goals: goals, GoalMaxAttempts: 1}) + defer d.Close() + + svc.Handle(context.Background(), "100", 42, "1", "try") + + waitUntil(t, func() bool { _, armed := goals.Get("100"); return !armed }) + // Give a would-be fix-up a moment to (wrongly) appear, then assert none did. + time.Sleep(50 * time.Millisecond) + if got := len(fr.recorded()); got != 2 { + t.Fatalf("want exactly 2 runs (work + eval), got %d: %q", got, fr.recorded()) + } + if joined := strings.Join(allSent(fc), "\n"); !strings.Contains(joined, "NOT achieved") { + t.Fatalf("cap notice missing; sent:\n%s", joined) + } +} + +// TestInjectBudgetGate asserts a spent autonomy budget drops injected runs with +// a single daily notice, while leaving them unmetered when no budget is wired. +func TestInjectBudgetGate(t *testing.T) { + budget, err := autobudget.Open(filepath.Join(t.TempDir(), "auto.json")) + if err != nil { + t.Fatal(err) + } + if err := budget.Add("100", time.Now(), 5); err != nil { // cap below spend + t.Fatal(err) + } + fr := &scriptedRunner{script: [][]agent.Event{cleanResult("hi", 0)}} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &fakeWorkspace{}, PostRunConfig{Budget: budget, BudgetCapUSD: 2}) + defer d.Close() + + svc.InjectAuto(context.Background(), "100", "ci fix-up") + svc.InjectAuto(context.Background(), "100", "another ci fix-up") + + waitUntil(t, func() bool { return len(allSent(fc)) >= 1 }) + time.Sleep(50 * time.Millisecond) + if got := fr.recorded(); len(got) != 0 { + t.Fatalf("budget-gated Inject must not run, got %q", got) + } + notices := 0 + for _, s := range allSent(fc) { + if strings.Contains(s, "autonomy budget") { + notices++ + } + } + if notices != 1 { + t.Fatalf("want exactly one budget notice per day, got %d", notices) + } +} + +// TestAutoRunCostAccruesToBudget asserts an injected run's cost lands on the +// chat's autonomy budget, not on any user's cost total. +func TestAutoRunCostAccruesToBudget(t *testing.T) { + budget, err := autobudget.Open(filepath.Join(t.TempDir(), "auto.json")) + if err != nil { + t.Fatal(err) + } + fr := &scriptedRunner{script: [][]agent.Event{cleanResult("done", 1.5)}} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &fakeWorkspace{}, PostRunConfig{Budget: budget, BudgetCapUSD: 10}) + defer d.Close() + + svc.InjectAuto(context.Background(), "100", "ci fix-up") + + waitUntil(t, func() bool { return budget.SpentToday("100", time.Now()) > 1.4 }) + if got := budget.SpentToday("100", time.Now()); got != 1.5 { + t.Fatalf("SpentToday = %v, want 1.5", got) + } +} + +// TestVerifyLoopInjectsFixupAndStopsAtCap exercises the deterministic gate +// loop end to end with a real repo whose gate always fails: run 1 changes the +// repo → verification fails → fix-up round 1 → (no-op fix, retry list re-gates) +// → fix-up round 2 → cap reached → loop stops with a final notice. +func TestVerifyLoopInjectsFixupAndStopsAtCap(t *testing.T) { + for _, tool := range []string{"git", "make"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not available", tool) + } + } + ws := t.TempDir() + repoDir := filepath.Join(ws, "app") + if err := os.MkdirAll(repoDir, 0o750); err != nil { + t.Fatal(err) + } + gitRun := func(args ...string) { + t.Helper() + //nolint:gosec,noctx // test helper drives git with fixed args; no ctx needed + cmd := exec.Command("git", args...) + cmd.Dir = repoDir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + if err := os.WriteFile(filepath.Join(repoDir, "Makefile"), []byte("check:\n\t@echo red gate; exit 1\n"), 0o600); err != nil { + t.Fatal(err) + } + gitRun("init", "-q") + gitRun("add", ".") + gitRun("-c", "commit.gpgsign=false", "commit", "-q", "-m", "init") + + fr := &scriptedRunner{ + script: [][]agent.Event{ + cleanResult("changed the repo", 0), // user run + cleanResult("claimed a fix", 0), // fix-up 1 (touches nothing) + cleanResult("claimed again", 0), // fix-up 2 (touches nothing) + }, + onRun: func(call int) { + if call == 0 { // mutate the repo DURING the user run so it registers as changed + _ = os.WriteFile(filepath.Join(repoDir, "changed.txt"), []byte("x\n"), 0o600) + } + }, + } + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &dirWorkspace{dir: ws}, PostRunConfig{ + Verifier: &verify.Verifier{Timeout: time.Minute, Log: slog.New(slog.DiscardHandler)}, + VerifyMaxFixes: 2, + }) + defer d.Close() + + svc.Handle(context.Background(), "100", 42, "1", "change the repo") + + // The loop ends with the "still red … stopping" notice after 2 fix rounds. + waitUntil(t, func() bool { + return strings.Contains(strings.Join(allSent(fc), "\n"), "Stopping the auto-fix loop") + }) + prompts := fr.recorded() + if len(prompts) != 3 { + t.Fatalf("want 3 runs (user + 2 capped fix-ups), got %d: %q", len(prompts), prompts) + } + for _, p := range prompts[1:] { + if !strings.Contains(p, "Post-run verification") || !strings.Contains(p, "make check") { + t.Fatalf("fix-up prompt must carry the failing gate, got: %s", p) + } + } +} + +// allSent snapshots every text the fake chat delivered (sends only). +func allSent(f *fakeChat) []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.sent...) +} + +// TestInjectAutoNeverBlocksOnFullLane guards the deadlock fix: InjectAuto is +// called from postRun, which executes ON the lane's own worker goroutine, so +// it must return promptly even when the chat's queue is completely full — a +// blocking submit there would deadlock the chat forever (the worker is the +// only goroutine that drains the queue). +func TestInjectAutoNeverBlocksOnFullLane(t *testing.T) { + fr := &scriptedRunner{} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &fakeWorkspace{}, PostRunConfig{}) + defer d.Close() + + // Occupy the lane worker, then fill the queue buffer completely. + gate := make(chan struct{}) + svc.dispatch.Submit("100", func(context.Context) { <-gate }) + for i := 0; i < 64; i++ { + if !svc.dispatch.(*dispatch.Dispatcher).TrySubmit("100", func(context.Context) {}) { + break // buffer full — exactly the state under test + } + } + + done := make(chan struct{}) + go func() { + svc.InjectAuto(context.Background(), "100", "ci fix-up on a saturated lane") + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("InjectAuto blocked on a full lane — lane-deadlock regression") + } + close(gate) +} + +// TestFollowupSweepSchedules asserts a followup/.md file written during +// a run is scheduled into the durable store, the file is consumed, and the +// chat gets the confirmation notice. +func TestFollowupSweepSchedules(t *testing.T) { + ws := t.TempDir() + if err := os.MkdirAll(filepath.Join(ws, "followup"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "followup", "15m.md"), []byte("re-check the deploy\n"), 0o600); err != nil { + t.Fatal(err) + } + store, err := followup.Open(filepath.Join(t.TempDir(), "followups.json")) + if err != nil { + t.Fatal(err) + } + fr := &scriptedRunner{script: [][]agent.Event{cleanResult("done", 0)}} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &dirWorkspace{dir: ws}, PostRunConfig{ + Followups: store, PromiseNudge: true, + }) + defer d.Close() + + svc.Handle(context.Background(), "100", 42, "1", "deploy it") + + waitUntil(t, func() bool { + _, statErr := os.Stat(filepath.Join(ws, "followup", "15m.md")) + return store.Count("100") == 1 && os.IsNotExist(statErr) + }) + if joined := strings.Join(allSent(fc), "\n"); !strings.Contains(joined, "Follow-up scheduled") { + t.Fatalf("confirmation notice missing; sent:\n%s", joined) + } + // A scheduled follow-up suppresses the promise nudge even if the answer + // promised — exactly one run happened. + time.Sleep(50 * time.Millisecond) + if got := len(fr.recorded()); got != 1 { + t.Fatalf("want exactly 1 run, got %d: %q", got, fr.recorded()) + } +} + +// TestPromiseNudgeFiresOnceAndNeverChains asserts a final answer that promises +// an unprompted return triggers exactly ONE corrective run — even when that +// corrective run's own answer promises again. +func TestPromiseNudgeFiresOnceAndNeverChains(t *testing.T) { + store, err := followup.Open(filepath.Join(t.TempDir(), "followups.json")) + if err != nil { + t.Fatal(err) + } + fr := &scriptedRunner{script: [][]agent.Event{ + cleanResult("Deploy started. I'll report back once it completes.", 0), + cleanResult("Понял. Доложу, как только процесс завершится.", 0), // the nudge run promises AGAIN + }} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &fakeWorkspace{}, PostRunConfig{ + Followups: store, PromiseNudge: true, + }) + defer d.Close() + + svc.Handle(context.Background(), "100", 42, "1", "deploy it and tell me") + + waitUntil(t, func() bool { return len(fr.recorded()) == 2 }) + time.Sleep(100 * time.Millisecond) + prompts := fr.recorded() + if len(prompts) != 2 { + t.Fatalf("want exactly 2 runs (work + one nudge), got %d: %q", len(prompts), prompts) + } + if !strings.Contains(prompts[1], "Self-follow-up check.") { + t.Fatalf("second run must be the corrective nudge, got: %s", prompts[1]) + } +} + +// TestNoNudgeOnPlainAnswer asserts ordinary answers (including ones asking the +// USER to follow up) do not trigger the corrective run. +func TestNoNudgeOnPlainAnswer(t *testing.T) { + store, err := followup.Open(filepath.Join(t.TempDir(), "followups.json")) + if err != nil { + t.Fatal(err) + } + fr := &scriptedRunner{script: [][]agent.Event{ + cleanResult("Готово: PR открыт. Напиши мне, если CI упадёт — сам я его не увижу.", 0), + }} + fc := newFakeChat() + svc, d := newPostRunService(t, fr, fc, &fakeWorkspace{}, PostRunConfig{ + Followups: store, PromiseNudge: true, + }) + defer d.Close() + + svc.Handle(context.Background(), "100", 42, "1", "open the pr") + + waitUntil(t, func() bool { return len(fr.recorded()) == 1 }) + time.Sleep(100 * time.Millisecond) + if got := len(fr.recorded()); got != 1 { + t.Fatalf("plain answer must not nudge, got %d runs: %q", got, fr.recorded()) + } +} diff --git a/core/chat/reserved.go b/core/chat/reserved.go index 22f7a48..40070f0 100644 --- a/core/chat/reserved.go +++ b/core/chat/reserved.go @@ -13,17 +13,18 @@ type ReservedCommand struct { // ReservedCommands is the single source of truth for the slash commands the bot // intercepts. A reserved command is handled by the bot itself and is NEVER -// forwarded to the provider Runner; every OTHER slash command (e.g. /loop, /goal, +// forwarded to the provider Runner; every OTHER slash command (e.g. /loop, // /security-review) is forwarded verbatim so the provider's own slash-command and // skill system runs it. Both adapters (Telegram, VK) decide what to intercept // from this list, and Telegram builds its command menu from it. The order here is -// the order the menu presents: start, help, new, stop, schedule. +// the order the menu presents: start, help, new, stop, schedule, goal. var ReservedCommands = []ReservedCommand{ {Name: "start", Description: "Show a short welcome and usage"}, {Name: "help", Description: "List the bot's own commands"}, {Name: "new", Description: "Start a fresh session (forget the current conversation)"}, {Name: "stop", Description: "Stop the run currently in progress"}, {Name: "schedule", Description: "Manage scheduled jobs"}, + {Name: "goal", Description: "Arm a goal an independent evaluator re-checks after every run"}, } // IsReservedCommand reports whether name is one of the bot's reserved commands. diff --git a/core/chat/reserved_test.go b/core/chat/reserved_test.go index d25527a..82e639a 100644 --- a/core/chat/reserved_test.go +++ b/core/chat/reserved_test.go @@ -26,8 +26,9 @@ func TestIsReservedCommand(t *testing.T) { {"NeW", true}, {"STOP", true}, {"Schedule", true}, + {"goal", true}, + {"GOAL", true}, {"loop", false}, - {"goal", false}, {"news", false}, {"", false}, {"new ", false}, @@ -45,9 +46,9 @@ func TestIsReservedCommand(t *testing.T) { // TestReservedCommandsShape guards the menu source: names are unique, lowercase, // non-empty, every command has a description, and the order is the documented -// start, help, new, stop, schedule. +// start, help, new, stop, schedule, goal. func TestReservedCommandsShape(t *testing.T) { - wantOrder := []string{"start", "help", "new", "stop", "schedule"} + wantOrder := []string{"start", "help", "new", "stop", "schedule", "goal"} if len(chat.ReservedCommands) != len(wantOrder) { t.Fatalf("ReservedCommands has %d entries, want %d", len(chat.ReservedCommands), len(wantOrder)) } diff --git a/core/chat/service.go b/core/chat/service.go index e3fb3eb..e07780e 100644 --- a/core/chat/service.go +++ b/core/chat/service.go @@ -21,6 +21,7 @@ import ( "github.com/duckbugio/flock/core/cost" "github.com/duckbugio/flock/core/dispatch" "github.com/duckbugio/flock/core/pending" + "github.com/duckbugio/flock/core/verify" ) // tickInterval is how often the wall-clock ticker re-renders the progress frame — @@ -121,16 +122,20 @@ type Service struct { costCapUSD float64 outbox *Sweeper nudge *starNudge + postrun PostRunConfig opts agent.Options timeout time.Duration log *slog.Logger tick time.Duration nowFunc func() time.Time - mu sync.Mutex // guards runChat and lastMsg - runChat map[string]ChatID // active runID -> chatID, for mapping Stop back to a chat - lastMsg map[ChatID]MessageID // chatID -> the source message id of its latest submitted run - runSeq atomic.Uint64 + mu sync.Mutex // guards runChat, lastMsg, verifyRetry, budgetNotified and snapCache + runChat map[string]ChatID // active runID -> chatID, for mapping Stop back to a chat + lastMsg map[ChatID]MessageID // chatID -> the source message id of its latest submitted run + verifyRetry map[ChatID][]string // repos whose gate failed last round (re-gated even if unchanged) + budgetNotified map[ChatID]string // chatID -> UTC day the budget-reached notice was last sent + snapCache map[ChatID]verify.Snapshot // post-run repo fingerprints, reused as the next run's "before" + runSeq atomic.Uint64 } // Config bundles the dependencies of a Service. @@ -162,7 +167,11 @@ type Config struct { // StarNudge configures the post-task GitHub star nudge. When its Enabled flag // is false (the default for non-GitHub deploys) the whole nudge path is inert. StarNudge StarNudgeConfig - Opts agent.Options // Model/MaxTurns/Env for every run; Workdir is set per chat + // PostRun configures the autonomous post-run loop (deterministic gate + // verification, the /goal evaluator, and the per-chat autonomy budget). The + // zero value disables all of it (see PostRunConfig). + PostRun PostRunConfig + Opts agent.Options // Model/MaxTurns/Env for every run; Workdir is set per chat // Timeout bounds a single run's delivery; 0 means no deadline. On timeout the // run is cancelled but the captured session_id is still stored (plan §7.3). Timeout time.Duration @@ -206,6 +215,7 @@ func New(cfg Config) *Service { costCapUSD: cfg.CostCapUSD, outbox: cfg.Outbox, nudge: newStarNudge(cfg.StarNudge, cfg.Transport, log), + postrun: cfg.PostRun, opts: cfg.Opts, timeout: cfg.Timeout, log: log, @@ -213,6 +223,10 @@ func New(cfg Config) *Service { nowFunc: time.Now, runChat: map[string]ChatID{}, lastMsg: map[ChatID]MessageID{}, + + verifyRetry: map[ChatID][]string{}, + budgetNotified: map[ChatID]string{}, + snapCache: map[ChatID]verify.Snapshot{}, } } @@ -243,6 +257,10 @@ func (s *Service) HandleMedia( ) { s.mu.Lock() s.lastMsg[chatID] = msgID + // A real user message opens a fresh chapter: the post-run verification retry + // list starts over (the fix-round counter rides inside fix-up prompts, so a + // user message naturally resets it to zero). + delete(s.verifyRetry, chatID) s.mu.Unlock() // Enqueue the interrupted-run marker BEFORE Submit so a run that is only queued // (behind a still-running run in the same chat) when the process is killed on a @@ -415,6 +433,26 @@ func (s *Service) run( } opts.Workdir = workdir + // The post-run verifier needs each repo's state as of BEFORE this run. Reuse + // the fingerprint cached at the END of the previous run (refreshSnapCache) so + // the git calls stay off the per-message critical path; only the first run + // after boot pays a synchronous Take here. Nil verifier → nil snapshot. + var preSnap verify.Snapshot + if s.postrun.Verifier != nil { + s.mu.Lock() + cached, ok := s.snapCache[chatID] + s.mu.Unlock() + if ok { + preSnap = cached + } else { + preSnap = s.postrun.Verifier.Take(ctx, workdir) + } + } + // The dispatcher lane context, captured before the per-run timeout is layered + // on: post-run work (verify/evaluate) must not inherit a deadline the main run + // already spent, but must still die on Stop/shutdown. + laneCtx := ctx + // The interrupted-run marker already exists: it was enqueued at SUBMIT time (so // a still-queued run is captured too), not at run start. This run clears that // exact marker by id on its clean terminal, and enriches it with the anchor id @@ -563,12 +601,13 @@ loop: } } - // Record this run's cost against the sending user for the cumulative cost cap. - // Only a run that reached its terminal Result carries a cost; an error, - // timeout, or Stop with no Result records ZERO (finalResult is nil) and is a - // no-op. The cap is reactive: this Add may push the user over the cap, in which - // case their NEXT request is denied (the crossing request still ran). - s.recordCost(userID, finalResult) + // Record this run's cost: against the sending user for the cumulative cost + // cap, or — for an autonomous run (AutoUser) — against the chat's per-day + // autonomy budget. Only a run that reached its terminal Result carries a cost; + // an error, timeout, or Stop with no Result records ZERO (finalResult is nil) + // and is a no-op. Both caps are reactive: this Add may push over the cap, in + // which case the NEXT request is denied (the crossing request still ran). + s.recordCost(chatID, userID, finalResult) s.finish(ctx, chatID, progressMsgID, markerID, finalResult, finalErr, ctx.Err(), s.nowFunc().Sub(start)) @@ -596,15 +635,58 @@ loop: //nolint:contextcheck // intentionally detached; the nudge runs post-completion. s.nudge.maybeNudge(chatID) } + + // The autonomy tail (deterministic gate verification, then the /goal + // evaluator) fires ONLY on a cleanly successful run — never on an error + // result, a RunError, a Stop, or a timeout. It runs on the lane context + // (pre-timeout) so it is not starved by a deadline the main run already + // spent, yet still dies on Stop/shutdown. It runs synchronously on the lane + // so its injected follow-up is ordered before any queued user message. + if finalResult != nil && finalErr == nil && ctx.Err() == nil && !finalResult.IsError { + s.postRun(laneCtx, chatID, workdir, prompt, preSnap) + } + + // Cache the workspace's post-run fingerprints as the NEXT run's "before" + // state. This runs on EVERY terminal (an errored or stopped run may still + // have mutated repos) and after the answer is already delivered, so the git + // calls never add to the user's perceived latency. + s.refreshSnapCache(laneCtx, chatID, workdir) + + // Schedule any follow-up files the run wrote (the legal "come back later"), + // then — only on a clean success that scheduled nothing — check the final + // answer for an unbacked "I'll report back" promise and nudge once. + scheduled := s.sweepFollowups(laneCtx, chatID, workdir) + if scheduled == 0 && finalResult != nil && finalErr == nil && ctx.Err() == nil && !finalResult.IsError { + s.maybeNudgePromise(laneCtx, chatID, prompt, finalResult.Text) + } } -// recordCost accumulates a completed run's USD cost onto userID's running total -// for the cumulative cost cap. A nil result (error/timeout/Stop with no Result) -// records nothing, and a nil store / non-positive cost is a no-op inside Add. A -// store write failure is logged but never aborts the run — a lost accounting -// write only weakens the cap slightly, it does not break delivery. -func (s *Service) recordCost(userID int64, res *agent.RunResult) { - if s.costs == nil || res == nil { +// refreshSnapCache fingerprints the workspace and stores the result as +// chatID's cached pre-run snapshot. A nil verifier is a no-op. +func (s *Service) refreshSnapCache(ctx context.Context, chatID ChatID, workdir string) { + if s.postrun.Verifier == nil { + return + } + snap := s.postrun.Verifier.Take(ctx, workdir) + s.mu.Lock() + s.snapCache[chatID] = snap + s.mu.Unlock() +} + +// recordCost accumulates a completed run's USD cost: a real user's run onto +// their cumulative cost-cap total, an autonomous run (AutoUser) onto the chat's +// per-day autonomy budget. The 0 sentinel (poller relays and startup resumes, +// whose original sender is unknown) records nowhere, preserving its old +// behavior. A nil result (error/timeout/Stop with no Result) records nothing, +// and a nil store / non-positive cost is a no-op inside Add. A store write +// failure is logged but never aborts the run — a lost accounting write only +// weakens the cap slightly, it does not break delivery. +func (s *Service) recordCost(chatID ChatID, userID int64, res *agent.RunResult) { + if userID == AutoUser { + s.recordAutoCost(chatID, res) + return + } + if s.costs == nil || res == nil || userID <= 0 { return } if err := s.costs.Add(userID, res.CostUSD); err != nil { diff --git a/core/ciwatch/ciwatch.go b/core/ciwatch/ciwatch.go new file mode 100644 index 0000000..e9c6b80 --- /dev/null +++ b/core/ciwatch/ciwatch.go @@ -0,0 +1,363 @@ +// Package ciwatch closes the CI feedback loop without a human relay: a +// background loop discovers every duck//... branch checked out in the +// per-chat workspaces, polls the git host for that branch's CI state, and — on +// a red build — emits an event the adapter injects into the owning chat as a +// fix-up prompt ("CI failed, here are the failing checks — fix and push"). +// Optionally (ENABLE_AUTO_MERGE) it also merges a branch's open PR once CI is +// green, removing the last human touchpoint for deployments that want it. +// +// Like core/poller it POLLS (the bot can reach the host; inbound webhooks often +// can't reach the bot) and routes events by the chat id embedded in the branch +// name. Handled failures are deduplicated per repo+branch+SHA in a small +// durable store so one red build injects exactly one fix-up, across restarts. +package ciwatch + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/duckbugio/flock/core/teambranch" +) + +// minInterval floors the poll period, mirroring core/poller. +const minInterval = 30 * time.Second + +// ownerRepoParts is the exact "/"-separated segments of an owner/repo name. +const ownerRepoParts = 2 + +// EventKind discriminates watch events. +type EventKind int + +const ( + // CIFailed is emitted once per (repo, branch, SHA) when CI turns red. + CIFailed EventKind = iota + // PRMerged is emitted after auto-merge lands a green PR. + PRMerged + // CIGreen is emitted once per (repo, branch, SHA) when CI completes + // successfully and auto-merge did not consume the event. It exists to WAKE + // the owning chat: nothing else notifies an agent that promised to report a + // CI verdict — the poller only relays new PR comments, so without this a + // green build ends every conversation until the user pings. + CIGreen +) + +// Event is one actionable CI observation, routed by ChatID. +type Event struct { + Kind EventKind + ChatID string + Repo string // owner/repo + Branch string + SHA string + Detail string // short failing-checks summary (CIFailed) + PRIndex int // PRMerged +} + +// Status is a host-neutral CI summary for one branch head. +type Status struct { + // State is one of "success", "failure", "pending", "none" (no CI configured). + State string + SHA string + Detail string +} + +// Host abstracts the git host API (Gitea or GitHub — see hosts.go). +type Host interface { + // Status resolves branch's head and summarizes its CI state. + Status(ctx context.Context, repo, branch string) (Status, error) + // OpenPR finds the open PR whose head is branch; ok=false when none. + OpenPR(ctx context.Context, repo, branch string) (index int, ok bool, err error) + // Merge merges the PR (the host's default merge style). + Merge(ctx context.Context, repo string, index int) error +} + +// Config configures the watch loop. +type Config struct { + // BaseDir is the workspace root scanned for chat_/ checkouts + // (APPROVED_DIRECTORY). + BaseDir string + Host Host + Interval time.Duration // poll period; floored to 30s + AutoMerge bool + State *State // dedupe store; nil disables dedupe (every scan re-emits) + Logger *slog.Logger +} + +// Run polls until ctx is cancelled, emitting an Event on out for each fresh CI +// failure (and each auto-merge). Never closes out; logs and continues on +// transient errors. +func Run(ctx context.Context, cfg Config, out chan<- Event) error { + interval := cfg.Interval + if interval < minInterval { + interval = minInterval + } + log := cfg.Logger + if log == nil { + log = slog.Default() + } + log.Info("ci watch started", "base", cfg.BaseDir, "interval", interval, "auto_merge", cfg.AutoMerge) + for { + scanOnce(ctx, cfg, log, out) + select { + case <-time.After(interval): + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// scanOnce walks the current checkouts and reconciles each against the host, +// then prunes dedupe state whose branch is no longer checked out anywhere so +// the store tracks live branches instead of growing toward the wipe backstop. +func scanOnce(ctx context.Context, cfg Config, log *slog.Logger, out chan<- Event) { + checkouts := Discover(cfg.BaseDir) + live := make(map[string]struct{}, len(checkouts)) + for _, co := range checkouts { + live[co.Repo+"#"+co.Branch] = struct{}{} + if ctx.Err() != nil { + return + } + st, err := cfg.Host.Status(ctx, co.Repo, co.Branch) + if err != nil { + log.Warn("ci watch: status failed", "repo", co.Repo, "branch", co.Branch, "error", err) + continue + } + switch st.State { + case stateFailure: + handleFailure(ctx, cfg, log, out, co, st) + case stateSuccess: + handleSuccess(ctx, cfg, log, out, co, st) + default: + // pending / none — nothing to do this cycle. + } + } + if err := cfg.State.Prune(live); err != nil { + log.Warn("ci watch: prune state", "error", err) + } +} + +// handleFailure emits one fix-up event per fresh (repo, branch, SHA) failure. +func handleFailure(ctx context.Context, cfg Config, log *slog.Logger, out chan<- Event, co Checkout, st Status) { + key := co.Repo + "#" + co.Branch + mark := st.SHA + "#failure" + if cfg.State.Handled(key, mark) { + return + } + ev := Event{ + Kind: CIFailed, ChatID: co.ChatID, Repo: co.Repo, Branch: co.Branch, SHA: st.SHA, Detail: st.Detail, + } + if !emit(ctx, out, ev) { + return + } + log.Info("ci watch: failure relayed", "repo", co.Repo, "branch", co.Branch, "sha", st.SHA) + if err := cfg.State.Mark(key, mark); err != nil { + log.Warn("ci watch: persist state", "error", err) + } +} + +// handleSuccess reacts to a green build exactly once per (repo, branch, SHA): +// with auto-merge on it merges the branch's open PR (emitting PRMerged); in +// every other case it emits CIGreen so the owning chat WAKES UP — an agent +// that told its user "I'll report when CI finishes" has no other wake-up +// signal for a green build. +func handleSuccess(ctx context.Context, cfg Config, log *slog.Logger, out chan<- Event, co Checkout, st Status) { + key := co.Repo + "#" + co.Branch + greenMark := st.SHA + "#green" + mergedMark := st.SHA + "#merged" + if cfg.State.Handled(key, greenMark) || cfg.State.Handled(key, mergedMark) { + return + } + if cfg.AutoMerge { + if consumed := tryAutoMerge(ctx, cfg, log, out, co, st, key, mergedMark); consumed { + return + } + // No open PR: the merge path has nothing to do — still announce the green + // build below exactly once. + } + ev := Event{Kind: CIGreen, ChatID: co.ChatID, Repo: co.Repo, Branch: co.Branch, SHA: st.SHA} + if !emit(ctx, out, ev) { + return + } + log.Info("ci watch: green relayed", "repo", co.Repo, "branch", co.Branch, "sha", st.SHA) + if err := cfg.State.Mark(key, greenMark); err != nil { + log.Warn("ci watch: persist state", "error", err) + } +} + +// tryAutoMerge merges the branch's open PR, reporting whether the green event +// was consumed (merged, or a transient lookup/merge failure to retry next +// cycle). false means "no PR" — the caller falls through to the CIGreen path. +func tryAutoMerge( + ctx context.Context, cfg Config, log *slog.Logger, out chan<- Event, co Checkout, st Status, key, mark string, +) bool { + idx, ok, err := cfg.Host.OpenPR(ctx, co.Repo, co.Branch) + if err != nil { + log.Warn("ci watch: open-pr lookup failed", "repo", co.Repo, "branch", co.Branch, "error", err) + return true // transient: retry the whole green handling next cycle + } + if !ok { + return false + } + if err := cfg.Host.Merge(ctx, co.Repo, idx); err != nil { + // Not mergeable yet (conflicts, required reviews) — retried next cycle. + log.Warn("ci watch: merge failed", "repo", co.Repo, "pr", idx, "error", err) + return true + } + ev := Event{Kind: PRMerged, ChatID: co.ChatID, Repo: co.Repo, Branch: co.Branch, SHA: st.SHA, PRIndex: idx} + if !emit(ctx, out, ev) { + return true + } + log.Info("ci watch: auto-merged", "repo", co.Repo, "pr", idx) + if err := cfg.State.Mark(key, mark); err != nil { + log.Warn("ci watch: persist state", "error", err) + } + return true +} + +// emit sends ev on out unless ctx is cancelled. +func emit(ctx context.Context, out chan<- Event, ev Event) bool { + select { + case out <- ev: + return true + case <-ctx.Done(): + return false + } +} + +// Checkout is one duck/* branch checked out in some chat's workspace. +type Checkout struct { + ChatID string + Repo string // owner/repo derived from the origin remote + Branch string +} + +// Discover scans base for chat_/ git checkouts currently on a +// duck//... branch and returns one Checkout per (repo, branch), +// deduplicated. It reads .git/HEAD and .git/config directly (no git exec), so a +// scan is a handful of small file reads. The chat id is taken from the BRANCH +// (authoritative for routing, mirroring core/poller), not the directory name. +func Discover(base string) []Checkout { + chats, err := os.ReadDir(base) + if err != nil { + return nil + } + seen := map[string]struct{}{} + var out []Checkout + for _, chatDir := range chats { + if !chatDir.IsDir() || !strings.HasPrefix(chatDir.Name(), "chat_") { + continue + } + repos, err := os.ReadDir(filepath.Join(base, chatDir.Name())) + if err != nil { + continue + } + for _, repoDir := range repos { + if !repoDir.IsDir() { + continue + } + gitDir := filepath.Join(base, chatDir.Name(), repoDir.Name(), ".git") + co, ok := checkoutFromGitDir(gitDir) + if !ok { + continue + } + key := co.Repo + "#" + co.Branch + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, co) + } + } + return out +} + +// checkoutFromGitDir builds a Checkout from one .git directory, requiring a +// routable team branch (see core/teambranch) and a parsable origin remote. +func checkoutFromGitDir(gitDir string) (Checkout, bool) { + branch := currentBranch(gitDir) + chatID, ok := teambranch.ChatID(branch) + if !ok { + return Checkout{}, false + } + repo, ok := ownerRepo(originURL(gitDir)) + if !ok { + return Checkout{}, false + } + return Checkout{ChatID: chatID, Repo: repo, Branch: branch}, true +} + +// currentBranch reads .git/HEAD and returns the checked-out branch name, or "" +// for a detached HEAD / unreadable file / worktree-style .git file. +func currentBranch(gitDir string) string { + data, err := os.ReadFile(filepath.Join(gitDir, "HEAD")) //nolint:gosec // internal workspace path + if err != nil { + return "" + } + const refPrefix = "ref: refs/heads/" + line := strings.TrimSpace(string(data)) + if !strings.HasPrefix(line, refPrefix) { + return "" + } + return strings.TrimPrefix(line, refPrefix) +} + +// originURL scans .git/config for the [remote "origin"] url line. +func originURL(gitDir string) string { + data, err := os.ReadFile(filepath.Join(gitDir, "config")) //nolint:gosec // internal workspace path + if err != nil { + return "" + } + inOrigin := false + for _, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "[") { + inOrigin = line == `[remote "origin"]` + continue + } + if !inOrigin { + continue + } + if rest, ok := strings.CutPrefix(line, "url"); ok { + if v, ok := strings.CutPrefix(strings.TrimSpace(rest), "="); ok { + return strings.TrimSpace(v) + } + } + } + return "" +} + +// ownerRepo extracts "owner/repo" from an https/ssh/scp-style remote URL. +func ownerRepo(url string) (string, bool) { + if url == "" { + return "", false + } + s := strings.TrimSuffix(strings.TrimSpace(url), ".git") + // scp-style: git@host:owner/repo + if at := strings.Index(s, "@"); at >= 0 && !strings.Contains(s, "://") { + if colon := strings.Index(s[at:], ":"); colon >= 0 { + s = s[at+colon+1:] + return validOwnerRepo(s) + } + } + // URL-style: scheme://[user@]host/owner/repo + if i := strings.Index(s, "://"); i >= 0 { + s = s[i+3:] + if slash := strings.IndexByte(s, '/'); slash >= 0 { + return validOwnerRepo(s[slash+1:]) + } + return "", false + } + return validOwnerRepo(s) +} + +// validOwnerRepo accepts exactly "owner/repo" with non-empty segments. +func validOwnerRepo(s string) (string, bool) { + parts := strings.Split(strings.Trim(s, "/"), "/") + if len(parts) != ownerRepoParts || parts[0] == "" || parts[1] == "" { + return "", false + } + return parts[0] + "/" + parts[1], true +} diff --git a/core/ciwatch/ciwatch_test.go b/core/ciwatch/ciwatch_test.go new file mode 100644 index 0000000..46bcf78 --- /dev/null +++ b/core/ciwatch/ciwatch_test.go @@ -0,0 +1,275 @@ +//nolint:testpackage // intentionally whitebox to test unexported ciwatch internals +package ciwatch + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// fakeCheckout materializes a minimal .git dir (HEAD + config) so Discover can +// parse it without a real git binary. +func fakeCheckout(t *testing.T, base, chat, repo, branch, remote string) { + t.Helper() + gitDir := filepath.Join(base, chat, repo, ".git") + if err := os.MkdirAll(gitDir, 0o750); err != nil { + t.Fatal(err) + } + head := "ref: refs/heads/" + branch + "\n" + if branch == "" { + head = "0123456789abcdef0123456789abcdef01234567\n" // detached + } + if err := os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte(head), 0o600); err != nil { + t.Fatal(err) + } + config := "[core]\n\tbare = false\n[remote \"origin\"]\n\turl = " + remote + "\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n" + if err := os.WriteFile(filepath.Join(gitDir, "config"), []byte(config), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestDiscover(t *testing.T) { + base := t.TempDir() + fakeCheckout(t, base, "chat_100", "svc", "duck/100/feature-x", "https://git.example.com/acme/svc.git") + fakeCheckout(t, base, "chat_100", "lib", "main", "https://git.example.com/acme/lib.git") // not a duck branch + fakeCheckout(t, base, "chat_200", "svc2", "duck/200/fix", "git@github.com:acme/svc2.git") // scp remote + fakeCheckout(t, base, "chat_300", "bad", "duck/300/x", "not-a-remote-at-all/with/many/segments") // bad remote + fakeCheckout(t, base, "chat_400", "det", "", "https://git.example.com/acme/det.git") // detached HEAD + // Same repo+branch in a second dir must be deduplicated. + fakeCheckout(t, base, "chat_500", "svc", "duck/100/feature-x", "https://git.example.com/acme/svc.git") + // Non-chat dirs and plain files are ignored. + if err := os.MkdirAll(filepath.Join(base, "not_a_chat", "svc"), 0o750); err != nil { + t.Fatal(err) + } + + got := Discover(base) + want := map[string]Checkout{ + "acme/svc#duck/100/feature-x": {ChatID: "100", Repo: "acme/svc", Branch: "duck/100/feature-x"}, + "acme/svc2#duck/200/fix": {ChatID: "200", Repo: "acme/svc2", Branch: "duck/200/fix"}, + } + if len(got) != len(want) { + t.Fatalf("Discover = %+v, want %d checkouts", got, len(want)) + } + for _, co := range got { + w, ok := want[co.Repo+"#"+co.Branch] + if !ok || w != co { + t.Fatalf("unexpected checkout %+v", co) + } + } +} + +func TestOwnerRepo(t *testing.T) { + cases := []struct { + in string + want string + ok bool + }{ + {"https://github.com/acme/svc.git", "acme/svc", true}, + {"https://git.example.com/acme/svc", "acme/svc", true}, + {"http://user@git.example.com/acme/svc.git", "acme/svc", true}, + {"git@github.com:acme/svc.git", "acme/svc", true}, + {"ssh://git@git.example.com/acme/svc.git", "acme/svc", true}, + {"https://git.example.com/acme/group/svc.git", "", false}, // nested groups unsupported + {"", "", false}, + {"garbage", "", false}, + } + for _, tc := range cases { + got, ok := ownerRepo(tc.in) + if ok != tc.ok || got != tc.want { + t.Errorf("ownerRepo(%q) = %q, %v; want %q, %v", tc.in, got, ok, tc.want, tc.ok) + } + } +} + +func TestStateDedupe(t *testing.T) { + path := filepath.Join(t.TempDir(), "ci.json") + st, err := OpenState(path) + if err != nil { + t.Fatal(err) + } + if st.Handled("r#b", "sha1#failure") { + t.Fatal("fresh state must not report handled") + } + if err := st.Mark("r#b", "sha1#failure"); err != nil { + t.Fatal(err) + } + if !st.Handled("r#b", "sha1#failure") { + t.Fatal("marked observation must be handled") + } + if st.Handled("r#b", "sha2#failure") { + t.Fatal("a new sha must not be handled") + } + // Survives reopen. + re, err := OpenState(path) + if err != nil { + t.Fatal(err) + } + if !re.Handled("r#b", "sha1#failure") { + t.Fatal("state must persist across reopen") + } + // Nil state is a safe no-op. + var nilState *State + if nilState.Handled("x", "y") { + t.Fatal("nil state must report unhandled") + } + if err := nilState.Mark("x", "y"); err != nil { + t.Fatal(err) + } +} + +// fakeHost scripts Status/OpenPR/Merge for loop tests. +type fakeHost struct { + status Status + prIdx int + prOK bool + merged []int +} + +func (f *fakeHost) Status(context.Context, string, string) (Status, error) { return f.status, nil } +func (f *fakeHost) OpenPR(context.Context, string, string) (int, bool, error) { + return f.prIdx, f.prOK, nil +} + +func (f *fakeHost) Merge(_ context.Context, _ string, idx int) error { + f.merged = append(f.merged, idx) + return nil +} + +func TestScanOnceEmitsFailureOncePerSHA(t *testing.T) { + base := t.TempDir() + fakeCheckout(t, base, "chat_100", "svc", "duck/100/x", "https://git.example.com/acme/svc.git") + st, err := OpenState(filepath.Join(t.TempDir(), "ci.json")) + if err != nil { + t.Fatal(err) + } + host := &fakeHost{status: Status{State: stateFailure, SHA: "abc", Detail: "failing: build"}} + cfg := Config{BaseDir: base, Host: host, State: st} + out := make(chan Event, 4) + + scanOnce(context.Background(), cfg, testLogger(), out) + scanOnce(context.Background(), cfg, testLogger(), out) // same sha — deduped + + select { + case ev := <-out: + if ev.Kind != CIFailed || ev.ChatID != "100" || ev.SHA != "abc" { + t.Fatalf("event = %+v", ev) + } + default: + t.Fatal("expected one failure event") + } + select { + case ev := <-out: + t.Fatalf("duplicate event emitted: %+v", ev) + default: + } + + // A NEW sha fails → a fresh event. + host.status = Status{State: stateFailure, SHA: "def"} + scanOnce(context.Background(), cfg, testLogger(), out) + select { + case ev := <-out: + if ev.SHA != "def" { + t.Fatalf("event = %+v", ev) + } + default: + t.Fatal("expected event for the new sha") + } +} + +func TestScanOnceGreenWakesChatOnce(t *testing.T) { + base := t.TempDir() + fakeCheckout(t, base, "chat_100", "svc", "duck/100/x", "https://git.example.com/acme/svc.git") + st, err := OpenState(filepath.Join(t.TempDir(), "ci.json")) + if err != nil { + t.Fatal(err) + } + host := &fakeHost{status: Status{State: stateSuccess, SHA: "abc"}, prIdx: 7, prOK: true} + out := make(chan Event, 4) + + // AutoMerge off → the green build is RELAYED (it wakes the owning chat so a + // promised "I'll report when CI finishes" actually happens) — exactly once. + cfg := Config{BaseDir: base, Host: host, State: st} + scanOnce(context.Background(), cfg, testLogger(), out) + scanOnce(context.Background(), cfg, testLogger(), out) // same sha — deduped + if len(host.merged) != 0 { + t.Fatal("must not merge with AutoMerge off") + } + ev := <-out + if ev.Kind != CIGreen || ev.ChatID != "100" || ev.SHA != "abc" { + t.Fatalf("event = %+v", ev) + } + select { + case dup := <-out: + t.Fatalf("duplicate green event: %+v", dup) + default: + } +} + +func TestScanOnceAutoMerge(t *testing.T) { + base := t.TempDir() + fakeCheckout(t, base, "chat_100", "svc", "duck/100/x", "https://git.example.com/acme/svc.git") + st, err := OpenState(filepath.Join(t.TempDir(), "ci.json")) + if err != nil { + t.Fatal(err) + } + host := &fakeHost{status: Status{State: stateSuccess, SHA: "abc"}, prIdx: 7, prOK: true} + out := make(chan Event, 4) + + // AutoMerge on → merge once, dedupe the second scan, no CIGreen (the merge + // notice covers the wake-up). + cfg := Config{BaseDir: base, Host: host, State: st, AutoMerge: true} + scanOnce(context.Background(), cfg, testLogger(), out) + scanOnce(context.Background(), cfg, testLogger(), out) + if len(host.merged) != 1 || host.merged[0] != 7 { + t.Fatalf("merged = %v, want exactly one merge of #7", host.merged) + } + ev := <-out + if ev.Kind != PRMerged || ev.PRIndex != 7 { + t.Fatalf("event = %+v", ev) + } + select { + case dup := <-out: + t.Fatalf("unexpected extra event: %+v", dup) + default: + } +} + +func TestScanOncePrunesStaleState(t *testing.T) { + base := t.TempDir() + fakeCheckout(t, base, "chat_100", "svc", "duck/100/x", "https://git.example.com/acme/svc.git") + st, err := OpenState(filepath.Join(t.TempDir(), "ci.json")) + if err != nil { + t.Fatal(err) + } + // A leftover entry for a branch nobody has checked out anymore. + if err := st.Mark("acme/old#duck/100/gone", "dead#failure"); err != nil { + t.Fatal(err) + } + host := &fakeHost{status: Status{State: stateFailure, SHA: "abc"}} + scanOnce(context.Background(), Config{BaseDir: base, Host: host, State: st}, testLogger(), make(chan Event, 4)) + if st.Handled("acme/old#duck/100/gone", "dead#failure") { + t.Fatal("stale entry must be pruned after a scan") + } + if !st.Handled("acme/svc#duck/100/x", "abc#failure") { + t.Fatal("live entry must survive the prune") + } +} + +func TestRunStopsOnCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, Config{BaseDir: t.TempDir(), Host: &fakeHost{}, Interval: time.Hour}, make(chan Event)) + }() + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("Run must return ctx error") + } + case <-time.After(5 * time.Second): + t.Fatal("Run did not stop on cancel") + } +} diff --git a/core/ciwatch/hosts.go b/core/ciwatch/hosts.go new file mode 100644 index 0000000..56b37dd --- /dev/null +++ b/core/ciwatch/hosts.go @@ -0,0 +1,309 @@ +package ciwatch + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// defaultClientTimeout is the per-request budget of the default HTTP client. +const defaultClientTimeout = 20 * time.Second + +// maxDetailChecks caps how many failing check names are listed in an event's +// Detail (the fix-up prompt needs the gist, not the full matrix). +const maxDetailChecks = 5 + +// errBodySnippet is how much of a non-2xx response body is folded into the error. +const errBodySnippet = 256 + +// Host-neutral Status.State values (Gitea/GitHub raw states map onto these). +const ( + stateSuccess = "success" + stateFailure = "failure" + statePending = "pending" + stateNone = "none" + // stateError is Gitea's/legacy-GitHub's "error" raw state, folded into failure. + stateError = "error" +) + +// NewGitea returns a Host backed by a Gitea API base URL (…/api/v1), using the +// same token header as core/poller. client may be nil. +func NewGitea(baseURL, token string, client *http.Client) Host { + return &giteaHost{api: api{base: strings.TrimRight(baseURL, "/"), token: token, scheme: "token", client: client}} +} + +// NewGitHub returns a Host backed by the GitHub REST API. client may be nil. +func NewGitHub(token string, client *http.Client) Host { + return &githubHost{api: api{base: "https://api.github.com", token: token, scheme: "Bearer", client: client}} +} + +// api is the tiny shared JSON-over-HTTP helper for both hosts. +type api struct { + base string + token string + scheme string // Authorization scheme: "token" (Gitea) / "Bearer" (GitHub) + client *http.Client +} + +func (a *api) httpClient() *http.Client { + if a.client != nil { + return a.client + } + return &http.Client{Timeout: defaultClientTimeout} +} + +// do performs one JSON request; v may be nil to discard the body. A non-2xx +// status is an error carrying the status and a body snippet. +func (a *api) do(ctx context.Context, method, path string, body, v any) error { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, a.base+path, reader) + if err != nil { + return err + } + if a.token != "" { + req.Header.Set("Authorization", a.scheme+" "+a.token) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := a.httpClient().Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= http.StatusMultipleChoices { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodySnippet)) + return fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, bytes.TrimSpace(snippet)) + } + if v == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(v) +} + +// headSHA resolves a branch's head commit via the slash-safe query form +// (…/commits?sha=&limit=1 — a branch name with slashes cannot be a path +// segment). Both hosts serve this shape. +func (a *api) headSHA(ctx context.Context, repo, branch string) (string, error) { + var commits []struct { + SHA string `json:"sha"` + } + path := "/repos/" + repo + "/commits?limit=1&per_page=1&sha=" + url.QueryEscape(branch) + if err := a.do(ctx, http.MethodGet, path, nil, &commits); err != nil { + return "", err + } + if len(commits) == 0 || commits[0].SHA == "" { + return "", fmt.Errorf("branch %s not found in %s", branch, repo) + } + return commits[0].SHA, nil +} + +// --------------------------------------------------------------------------- +// Gitea + +type giteaHost struct{ api api } + +// giteaCombined is the subset of Gitea's combined commit status. +type giteaCombined struct { + State string `json:"state"` // pending | success | error | failure | warning + Statuses []struct { + Status string `json:"status"` + Context string `json:"context"` + Description string `json:"description"` + } `json:"statuses"` +} + +func (g *giteaHost) Status(ctx context.Context, repo, branch string) (Status, error) { + sha, err := g.api.headSHA(ctx, repo, branch) + if err != nil { + return Status{}, err + } + var combined giteaCombined + if err := g.api.do(ctx, http.MethodGet, "/repos/"+repo+"/commits/"+sha+"/status", nil, &combined); err != nil { + return Status{}, err + } + if len(combined.Statuses) == 0 { + return Status{State: stateNone, SHA: sha}, nil + } + st := Status{SHA: sha} + var failing []string + for _, s := range combined.Statuses { + if s.Status == stateFailure || s.Status == stateError { + failing = append(failing, s.Context) + } + } + switch combined.State { + case stateSuccess: + st.State = stateSuccess + case stateFailure, stateError: + st.State = stateFailure + st.Detail = detail(failing) + default: + st.State = statePending + } + return st, nil +} + +// giteaPull is the subset of a Gitea PR needed to find/merge by head branch. +type giteaPull struct { + Number int `json:"number"` + Merged bool `json:"merged"` + Head struct { + Ref string `json:"ref"` + } `json:"head"` +} + +func (g *giteaHost) OpenPR(ctx context.Context, repo, branch string) (int, bool, error) { + var pulls []giteaPull + if err := g.api.do(ctx, http.MethodGet, "/repos/"+repo+"/pulls?state=open&limit=50", nil, &pulls); err != nil { + return 0, false, err + } + for _, p := range pulls { + if p.Head.Ref == branch && !p.Merged { + return p.Number, true, nil + } + } + return 0, false, nil +} + +func (g *giteaHost) Merge(ctx context.Context, repo string, index int) error { + body := map[string]string{"Do": "merge"} + return g.api.do(ctx, http.MethodPost, fmt.Sprintf("/repos/%s/pulls/%d/merge", repo, index), body, nil) +} + +// --------------------------------------------------------------------------- +// GitHub + +type githubHost struct{ api api } + +// githubCheckRuns is the subset of the check-runs listing (GitHub Actions and +// most modern CI report here, not on the legacy statuses API). +type githubCheckRuns struct { + TotalCount int `json:"total_count"` //nolint:tagliatelle // GitHub API uses snake_case. + CheckRuns []struct { + Name string `json:"name"` + Status string `json:"status"` // queued | in_progress | completed + // Conclusion: success | failure | neutral | cancelled | timed_out | + // action_required | skipped | stale | null. + Conclusion string `json:"conclusion"` + } `json:"check_runs"` //nolint:tagliatelle // GitHub API uses snake_case. +} + +func (g *githubHost) Status(ctx context.Context, repo, branch string) (Status, error) { + sha, err := g.api.headSHA(ctx, repo, branch) + if err != nil { + return Status{}, err + } + var runs githubCheckRuns + if err := g.api.do(ctx, http.MethodGet, "/repos/"+repo+"/commits/"+sha+"/check-runs", nil, &runs); err != nil { + return Status{}, err + } + if runs.TotalCount == 0 { + // No check runs — fall back to the legacy combined status (external CI). + return g.legacyStatus(ctx, repo, sha) + } + st := Status{SHA: sha} + var failing []string + pending := false + for _, r := range runs.CheckRuns { + if r.Status != "completed" { + pending = true + continue + } + switch r.Conclusion { + case stateFailure, "timed_out", "action_required": + failing = append(failing, r.Name) + default: + // success / neutral / skipped / cancelled / stale — not a failure signal. + } + } + switch { + case len(failing) > 0: + // Failures are terminal even while other runs are still going: react early. + st.State = stateFailure + st.Detail = detail(failing) + case pending: + st.State = statePending + default: + st.State = stateSuccess + } + return st, nil +} + +// legacyStatus reads GitHub's combined commit status (pre-check-runs CI). +func (g *githubHost) legacyStatus(ctx context.Context, repo, sha string) (Status, error) { + var combined struct { + State string `json:"state"` // failure | pending | success + Statuses []struct { + State string `json:"state"` + Context string `json:"context"` + } `json:"statuses"` + } + if err := g.api.do(ctx, http.MethodGet, "/repos/"+repo+"/commits/"+sha+"/status", nil, &combined); err != nil { + return Status{}, err + } + if len(combined.Statuses) == 0 { + return Status{State: stateNone, SHA: sha}, nil + } + st := Status{SHA: sha} + switch combined.State { + case stateSuccess: + st.State = stateSuccess + case stateFailure, stateError: + st.State = stateFailure + var failing []string + for _, s := range combined.Statuses { + if s.State == stateFailure || s.State == stateError { + failing = append(failing, s.Context) + } + } + st.Detail = detail(failing) + default: + st.State = statePending + } + return st, nil +} + +func (g *githubHost) OpenPR(ctx context.Context, repo, branch string) (int, bool, error) { + owner := repo[:strings.IndexByte(repo, '/')] + var pulls []struct { + Number int `json:"number"` + } + path := "/repos/" + repo + "/pulls?state=open&head=" + url.QueryEscape(owner+":"+branch) + if err := g.api.do(ctx, http.MethodGet, path, nil, &pulls); err != nil { + return 0, false, err + } + if len(pulls) == 0 { + return 0, false, nil + } + return pulls[0].Number, true, nil +} + +func (g *githubHost) Merge(ctx context.Context, repo string, index int) error { + return g.api.do(ctx, http.MethodPut, fmt.Sprintf("/repos/%s/pulls/%d/merge", repo, index), map[string]string{}, nil) +} + +// detail renders a short failing-checks summary, capped. +func detail(failing []string) string { + if len(failing) == 0 { + return "" + } + if len(failing) > maxDetailChecks { + failing = append(failing[:maxDetailChecks:maxDetailChecks], "…") + } + return "failing: " + strings.Join(failing, ", ") +} diff --git a/core/ciwatch/hosts_test.go b/core/ciwatch/hosts_test.go new file mode 100644 index 0000000..022fe44 --- /dev/null +++ b/core/ciwatch/hosts_test.go @@ -0,0 +1,184 @@ +//nolint:testpackage // intentionally whitebox to test unexported ciwatch internals +package ciwatch + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testLogger() *slog.Logger { + return slog.New(slog.DiscardHandler) +} + +// giteaServer serves the three Gitea endpoints the host client touches. +func giteaServer(t *testing.T, combinedState string, contexts []string) (*httptest.Server, *[]string) { + t.Helper() + var merges []string + mux := http.NewServeMux() + mux.HandleFunc("/repos/acme/svc/commits", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("sha"); got != "duck/100/x" { + t.Errorf("headSHA branch = %q", got) + } + _ = json.NewEncoder(w).Encode([]map[string]string{{"sha": "abc123"}}) + }) + mux.HandleFunc("/repos/acme/svc/commits/abc123/status", func(w http.ResponseWriter, _ *http.Request) { + statuses := make([]map[string]string, 0, len(contexts)) + for _, c := range contexts { + statuses = append(statuses, map[string]string{"status": "failure", "context": c}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"state": combinedState, "statuses": statuses}) + }) + mux.HandleFunc("/repos/acme/svc/pulls", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"number": 3, "merged": false, "head": map[string]string{"ref": "duck/100/x"}}, + {"number": 4, "merged": false, "head": map[string]string{"ref": "other"}}, + }) + }) + mux.HandleFunc("/repos/acme/svc/pulls/3/merge", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + merges = append(merges, r.Method+" "+strings.TrimSpace(string(body))) + w.WriteHeader(http.StatusOK) + }) + return httptest.NewServer(mux), &merges +} + +func TestGiteaHost(t *testing.T) { + srv, merges := giteaServer(t, "failure", []string{"build", "lint"}) + defer srv.Close() + h := NewGitea(srv.URL, "tok", srv.Client()) + + st, err := h.Status(context.Background(), "acme/svc", "duck/100/x") + if err != nil { + t.Fatal(err) + } + if st.State != stateFailure || st.SHA != "abc123" || !strings.Contains(st.Detail, "build") { + t.Fatalf("Status = %+v", st) + } + + idx, ok, err := h.OpenPR(context.Background(), "acme/svc", "duck/100/x") + if err != nil || !ok || idx != 3 { + t.Fatalf("OpenPR = %d, %v, %v", idx, ok, err) + } + if _, ok, _ := h.OpenPR(context.Background(), "acme/svc", "duck/999/none"); ok { + t.Fatal("OpenPR must miss an unknown branch") + } + + if err := h.Merge(context.Background(), "acme/svc", 3); err != nil { + t.Fatal(err) + } + if len(*merges) != 1 || !strings.Contains((*merges)[0], "POST") || !strings.Contains((*merges)[0], "merge") { + t.Fatalf("merge calls = %v", *merges) + } +} + +func TestGiteaHostNoCI(t *testing.T) { + srv, _ := giteaServer(t, "", nil) + defer srv.Close() + h := NewGitea(srv.URL, "tok", srv.Client()) + st, err := h.Status(context.Background(), "acme/svc", "duck/100/x") + if err != nil { + t.Fatal(err) + } + if st.State != stateNone { + t.Fatalf("no statuses must map to none, got %+v", st) + } +} + +// githubServer serves the GitHub endpoints the host client touches. +func githubServer(t *testing.T, runs []map[string]string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/acme/svc/commits", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("sha"); got != "duck/100/x" { + t.Errorf("headSHA branch = %q", got) + } + _ = json.NewEncoder(w).Encode([]map[string]string{{"sha": "abc123"}}) + }) + mux.HandleFunc("/repos/acme/svc/commits/abc123/check-runs", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"total_count": len(runs), "check_runs": runs}) + }) + mux.HandleFunc("/repos/acme/svc/commits/abc123/status", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"state": "success", "statuses": []map[string]string{ + {"state": "success", "context": "legacy-ci"}, + }}) + }) + mux.HandleFunc("/repos/acme/svc/pulls", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("head"); got != "acme:duck/100/x" { + t.Errorf("OpenPR head = %q", got) + } + _ = json.NewEncoder(w).Encode([]map[string]any{{"number": 9}}) + }) + mux.HandleFunc("/repos/acme/svc/pulls/9/merge", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("merge method = %s", r.Method) + } + w.WriteHeader(http.StatusOK) + }) + return httptest.NewServer(mux) +} + +// githubHostFor builds the GitHub host pointed at the test server (the real +// constructor pins api.github.com). +func githubHostFor(srv *httptest.Server) Host { + return &githubHost{api: api{base: srv.URL, token: "tok", scheme: "Bearer", client: srv.Client()}} +} + +func TestGitHubHostFailureWins(t *testing.T) { + srv := githubServer(t, []map[string]string{ + {"name": "build", "status": "completed", "conclusion": "success"}, + {"name": "test", "status": "completed", "conclusion": "failure"}, + {"name": "lint", "status": "in_progress", "conclusion": ""}, + }) + defer srv.Close() + st, err := githubHostFor(srv).Status(context.Background(), "acme/svc", "duck/100/x") + if err != nil { + t.Fatal(err) + } + if st.State != stateFailure || !strings.Contains(st.Detail, "test") { + t.Fatalf("Status = %+v", st) + } +} + +func TestGitHubHostPendingAndSuccess(t *testing.T) { + pending := githubServer(t, []map[string]string{ + {"name": "build", "status": "in_progress", "conclusion": ""}, + }) + defer pending.Close() + st, err := githubHostFor(pending).Status(context.Background(), "acme/svc", "duck/100/x") + if err != nil || st.State != statePending { + t.Fatalf("Status = %+v, err %v", st, err) + } + + success := githubServer(t, []map[string]string{ + {"name": "build", "status": "completed", "conclusion": "success"}, + {"name": "meta", "status": "completed", "conclusion": "skipped"}, + }) + defer success.Close() + st, err = githubHostFor(success).Status(context.Background(), "acme/svc", "duck/100/x") + if err != nil || st.State != stateSuccess { + t.Fatalf("Status = %+v, err %v", st, err) + } +} + +func TestGitHubHostLegacyFallbackAndPR(t *testing.T) { + srv := githubServer(t, nil) // zero check runs → legacy combined status (success) + defer srv.Close() + h := githubHostFor(srv) + st, err := h.Status(context.Background(), "acme/svc", "duck/100/x") + if err != nil || st.State != stateSuccess { + t.Fatalf("Status = %+v, err %v", st, err) + } + idx, ok, err := h.OpenPR(context.Background(), "acme/svc", "duck/100/x") + if err != nil || !ok || idx != 9 { + t.Fatalf("OpenPR = %d, %v, %v", idx, ok, err) + } + if err := h.Merge(context.Background(), "acme/svc", 9); err != nil { + t.Fatal(err) + } +} diff --git a/core/ciwatch/state.go b/core/ciwatch/state.go new file mode 100644 index 0000000..2c9402f --- /dev/null +++ b/core/ciwatch/state.go @@ -0,0 +1,117 @@ +package ciwatch + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/duckbugio/flock/internal/atomicfile" +) + +// dirPerm is the owner-only permission for bot-created directories. +const dirPerm os.FileMode = 0o750 + +// maxStateEntries bounds the dedupe map; when exceeded the whole map is reset +// (the worst case after a reset is one duplicate fix-up per live branch, which +// is harmless — the store is dedupe, not authoritative data). +const maxStateEntries = 500 + +// State is a small durable dedupe map: "#" -> "#" +// of the last handled observation, shaped like the other JSON file stores. A +// nil *State disables dedupe: Handled always reports false and Mark does +// nothing (every scan would re-emit, acceptable for tests). +type State struct { + path string + + mu sync.Mutex + handled map[string]string +} + +// OpenState loads (or creates) a State store at path. +func OpenState(path string) (*State, error) { + if path == "" { + return nil, errors.New("ciwatch: state path is empty") + } + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return nil, fmt.Errorf("ciwatch: create state dir: %w", err) + } + s := &State{path: path, handled: map[string]string{}} + data, err := os.ReadFile(path) //nolint:gosec // operator-configured store path + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, fmt.Errorf("ciwatch: read state: %w", err) + } + if len(data) == 0 { + return s, nil + } + if err := json.Unmarshal(data, &s.handled); err != nil { + return nil, fmt.Errorf("ciwatch: parse state %s: %w", path, err) + } + return s, nil +} + +// Handled reports whether key's last handled mark equals mark. +func (s *State) Handled(key, mark string) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.handled[key] == mark +} + +// Mark records mark as key's last handled observation and persists atomically. +func (s *State) Mark(key, mark string) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.handled) >= maxStateEntries { + s.handled = map[string]string{} + } + s.handled[key] = mark + return s.persistLocked() +} + +// Prune drops every entry whose key is not in live — the branches currently +// checked out somewhere. Called once per scan so the map tracks live branches +// (a deleted/switched-away branch's entry has no future observation to dedupe) +// instead of growing toward the wholesale-wipe backstop, whose reset would +// re-emit one duplicate fix-up per still-red branch. A no-op (nothing persisted) +// when nothing is stale; nil-safe. +func (s *State) Prune(live map[string]struct{}) error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + changed := false + for k := range s.handled { + if _, ok := live[k]; !ok { + delete(s.handled, k) + changed = true + } + } + if !changed { + return nil + } + return s.persistLocked() +} + +// persistLocked writes the current map atomically. The caller must hold s.mu. +func (s *State) persistLocked() error { + data, err := json.Marshal(s.handled) + if err != nil { + return fmt.Errorf("ciwatch: encode state: %w", err) + } + if err := atomicfile.Write(s.path, data, ".ciwatch-*.tmp"); err != nil { + return fmt.Errorf("ciwatch: %w", err) + } + return nil +} diff --git a/core/claude/mcp.go b/core/claude/mcp.go index 1b43bc2..33bdabb 100644 --- a/core/claude/mcp.go +++ b/core/claude/mcp.go @@ -6,38 +6,55 @@ import ( "os" ) -// context7URL is the context7 MCP HTTP endpoint. context7 serves up-to-date, +// Context7URL is the context7 MCP HTTP endpoint. context7 serves up-to-date, // version-specific library/API documentation as tools (resolve-library-id, // get-library-docs), so the agent looks real docs up instead of guessing from // stale training data — the single highest-leverage accuracy boost for a coding // agent. The free tier needs no auth; an unreachable endpoint never breaks a run // (the CLI just runs without these tools). -const context7URL = "https://mcp.context7.com/mcp" +const Context7URL = "https://mcp.context7.com/mcp" -// mcpConfigPerm is the owner-only permission for the written MCP config file. +// mcpConfigPerm is the owner-only permission for the written MCP config file — +// it may carry a bearer token, so it must never be group/world readable. const mcpConfigPerm os.FileMode = 0o600 -// WriteContext7MCPConfig writes an MCP servers config enabling the context7 docs -// server to path (overwriting any existing file) and returns an error only on a -// write/marshal failure. The path is then passed to each run as Options.MCPConfig -// (--mcp-config). The config matches the Claude Code .mcp.json schema: +// MCPServer is one streamable-HTTP entry of the .mcp.json mcpServers map. +type MCPServer struct { + // URL is the server's HTTP endpoint. + URL string + // BearerToken, when non-empty, is sent as "Authorization: Bearer ". + BearerToken string +} + +// WriteMCPConfig writes a Claude Code .mcp.json enabling the given servers to +// path (overwriting any existing file). It reports ok=false — writing nothing — +// when servers is empty, so callers wire Options.MCPConfig only when at least +// one server is actually enabled. The config matches the Claude Code .mcp.json +// schema: // -// {"mcpServers":{"context7":{"type":"http","url":"https://mcp.context7.com/mcp"}}} -func WriteContext7MCPConfig(path string) error { - cfg := map[string]any{ - "mcpServers": map[string]any{ - "context7": map[string]any{ - "type": "http", - "url": context7URL, - }, - }, +// {"mcpServers":{"":{"type":"http","url":"…","headers":{"Authorization":"Bearer …"}}}} +func WriteMCPConfig(path string, servers map[string]MCPServer) (bool, error) { + if len(servers) == 0 { + return false, nil + } + entries := make(map[string]any, len(servers)) + for name, s := range servers { + entry := map[string]any{ + "type": "http", + "url": s.URL, + } + if s.BearerToken != "" { + entry["headers"] = map[string]string{"Authorization": "Bearer " + s.BearerToken} + } + entries[name] = entry } + cfg := map[string]any{"mcpServers": entries} data, err := json.MarshalIndent(cfg, "", " ") if err != nil { - return fmt.Errorf("marshal mcp config: %w", err) + return false, fmt.Errorf("marshal mcp config: %w", err) } if err := os.WriteFile(path, data, mcpConfigPerm); err != nil { - return fmt.Errorf("write mcp config %s: %w", path, err) + return false, fmt.Errorf("write mcp config %s: %w", path, err) } - return nil + return true, nil } diff --git a/core/claude/mcp_test.go b/core/claude/mcp_test.go index c7d1163..97ea2d8 100644 --- a/core/claude/mcp_test.go +++ b/core/claude/mcp_test.go @@ -10,33 +10,73 @@ import ( "github.com/duckbugio/flock/core/claude" ) -// TestWriteContext7MCPConfig asserts the written file is valid JSON matching the -// Claude Code .mcp.json schema with a context7 HTTP docs server. -func TestWriteContext7MCPConfig(t *testing.T) { - path := filepath.Join(t.TempDir(), "mcp.json") - if err := claude.WriteContext7MCPConfig(path); err != nil { - t.Fatalf("WriteContext7MCPConfig: %v", err) - } - //nolint:gosec // G304: path is a test-controlled temp file. - data, err := os.ReadFile(path) +// TestWriteMCPConfig asserts the written file is valid JSON in the Claude Code +// .mcp.json schema, carrying each enabled server and its bearer header. +func TestWriteMCPConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), ".flock-mcp.json") + ok, err := claude.WriteMCPConfig(path, map[string]claude.MCPServer{ + "context7": {URL: claude.Context7URL}, + //nolint:gosec // test fixture, not a credential + "duckbug": {URL: "https://duckbug.io/api/mcp", BearerToken: "sk-duck-api01-secret"}, + }) + if err != nil || !ok { + t.Fatalf("WriteMCPConfig = %v, %v", ok, err) + } + data, err := os.ReadFile(path) //nolint:gosec // test path under t.TempDir if err != nil { - t.Fatalf("read written config: %v", err) + t.Fatal(err) + } + var cfg struct { + Servers map[string]struct { + Type string `json:"type"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + } `json:"mcpServers"` } - // Decode into maps (not a tagged struct) so the test carries no snake/camel json - // tags of its own — it just checks the on-disk shape. - var cfg map[string]map[string]map[string]any if err := json.Unmarshal(data, &cfg); err != nil { - t.Fatalf("written config is not valid JSON: %v\n%s", err, data) + t.Fatalf("written config is not valid JSON: %v", err) + } + c7 := cfg.Servers["context7"] + if c7.Type != "http" || c7.URL != claude.Context7URL || len(c7.Headers) != 0 { + t.Fatalf("context7 entry = %+v", c7) + } + db := cfg.Servers["duckbug"] + if db.URL != "https://duckbug.io/api/mcp" || db.Headers["Authorization"] != "Bearer sk-duck-api01-secret" { + t.Fatalf("duckbug entry = %+v", db) + } + // The file may carry a token — owner-only permissions. + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) } - c7, ok := cfg["mcpServers"]["context7"] - if !ok { - t.Fatalf("context7 server missing from config: %s", data) + if info.Mode().Perm() != 0o600 { + t.Fatalf("config perms = %v, want 0600", info.Mode().Perm()) } - if c7["type"] != "http" { - t.Errorf("context7 type = %v, want http", c7["type"]) +} + +// TestWriteMCPConfigEmptyWritesNothing asserts no servers → no file, ok=false. +func TestWriteMCPConfigEmptyWritesNothing(t *testing.T) { + path := filepath.Join(t.TempDir(), ".flock-mcp.json") + ok, err := claude.WriteMCPConfig(path, nil) + if err != nil || ok { + t.Fatalf("WriteMCPConfig(empty) = %v, %v; want false, nil", ok, err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("no file must be written for an empty server set") + } +} + +// TestWriteMCPConfigTokenNotLoggedShape sanity-checks the header value shape so +// a future refactor cannot silently drop the Bearer prefix. +func TestWriteMCPConfigTokenNotLoggedShape(t *testing.T) { + path := filepath.Join(t.TempDir(), ".flock-mcp.json") + if _, err := claude.WriteMCPConfig(path, map[string]claude.MCPServer{ + "duckbug": {URL: "https://example.test/api/mcp", BearerToken: "tok"}, + }); err != nil { + t.Fatal(err) } - url, _ := c7["url"].(string) - if !strings.HasPrefix(url, "https://") || !strings.Contains(url, "context7") { - t.Errorf("context7 url = %q, want an https context7 endpoint", url) + data, _ := os.ReadFile(path) //nolint:gosec // test path under t.TempDir + if !strings.Contains(string(data), `"Bearer tok"`) { + t.Fatalf("Authorization header malformed:\n%s", data) } } diff --git a/core/followup/followup.go b/core/followup/followup.go new file mode 100644 index 0000000..deab1d9 --- /dev/null +++ b/core/followup/followup.go @@ -0,0 +1,239 @@ +// Package followup gives a run a LEGAL way to come back later. The failure +// mode it exists for: the agent ends a turn with "I'll report back when X +// finishes" — but nothing ever wakes it (each turn is a one-shot process), so +// the promise silently strands the user. Instead of promising, a run writes a +// file into the workspace's followup/ directory (see the convention appended +// to every workspace CLAUDE.md): the filename is the delay ("15m.md", "2h.md"), +// the content is the prompt to run then. The service sweeps the directory +// after every run into this durable store, and a background loop fires each +// item once at its due time through the same autonomy-budgeted injection path +// the other loops use. +// +// The store mirrors the other JSON file stores (single file, atomic rewrite, +// mutex, monotonic ids seeded above the max persisted id). +package followup + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/duckbugio/flock/internal/atomicfile" +) + +// dirPerm is the owner-only permission for bot-created directories. +const dirPerm os.FileMode = 0o750 + +// MinDelay floors a follow-up delay: anything shorter belongs in the +// foreground of the turn that wanted it. +const MinDelay = time.Minute + +// MaxDelay caps a follow-up delay; a longer horizon belongs in /schedule. +const MaxDelay = 48 * time.Hour + +// MaxPerChat bounds pending follow-ups per chat — each is an unattended, +// model-cost-bearing run, so an unbounded pile is an autonomy amplifier. +const MaxPerChat = 10 + +// ErrTooMany is returned by Add when a chat is at MaxPerChat, so the sweeper +// can surface a clear notice instead of a generic failure. +var ErrTooMany = errors.New("followup: chat has reached the maximum number of pending follow-ups") + +// Item is one scheduled one-shot follow-up. +type Item struct { + ID string `json:"id"` + ChatID string `json:"chatId"` + Prompt string `json:"prompt"` + DueAt int64 `json:"dueAt"` // Unix ms +} + +// FileStore is a JSON-file-backed store of pending follow-ups, safe for +// concurrent use. +type FileStore struct { + path string + + mu sync.Mutex + items map[string][]Item // chatID -> pending items + nextID uint64 +} + +// Open loads (or creates) a FileStore at path. A missing file yields an empty +// store; the parent directory is created if needed. +func Open(path string) (*FileStore, error) { + if path == "" { + return nil, errors.New("followup: store path is empty") + } + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return nil, fmt.Errorf("followup: create store dir: %w", err) + } + s := &FileStore{path: path, items: map[string][]Item{}, nextID: 1} + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +// load reads and decodes the backing file, seeding the id counter above the +// max persisted id so a new Add after a restart never collides. +func (s *FileStore) load() error { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("followup: read store: %w", err) + } + if len(data) == 0 { + return nil + } + if err := json.Unmarshal(data, &s.items); err != nil { + return fmt.Errorf("followup: parse store %s: %w", s.path, err) + } + for _, q := range s.items { + for _, it := range q { + if n, err := strconv.ParseUint(it.ID, 10, 64); err == nil && n >= s.nextID { + s.nextID = n + 1 + } + } + } + return nil +} + +// Add schedules prompt to fire at due, enforcing the per-chat cap. +func (s *FileStore) Add(chatID, prompt string, due time.Time) (Item, error) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.items[chatID]) >= MaxPerChat { + return Item{}, ErrTooMany + } + it := Item{ + ID: strconv.FormatUint(s.nextID, 10), + ChatID: chatID, + Prompt: prompt, + DueAt: due.UnixMilli(), + } + s.nextID++ + s.items[chatID] = append(s.items[chatID], it) + if err := s.persistLocked(); err != nil { + return Item{}, err + } + return it, nil +} + +// Due returns every item due at or before now, REMOVING them from the store +// (take-then-fire: a crash between take and fire drops at most one firing — +// for unattended work a miss is cheaper than a duplicate). +func (s *FileStore) Due(now time.Time) []Item { + s.mu.Lock() + defer s.mu.Unlock() + nowMs := now.UnixMilli() + var due []Item + changed := false + for chatID, q := range s.items { + var keep []Item + for _, it := range q { + if it.DueAt <= nowMs { + due = append(due, it) + changed = true + continue + } + keep = append(keep, it) + } + if len(keep) == 0 { + delete(s.items, chatID) + } else { + s.items[chatID] = keep + } + } + if changed { + if err := s.persistLocked(); err != nil { + // The items are already detached in memory; a persist failure only risks + // a duplicate fire after a crash, which the caller tolerates. + return due + } + } + return due +} + +// Count returns how many follow-ups chatID has pending. +func (s *FileStore) Count(chatID string) int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.items[chatID]) +} + +// persistLocked writes the current map atomically. The caller must hold s.mu. +func (s *FileStore) persistLocked() error { + data, err := json.Marshal(s.items) + if err != nil { + return fmt.Errorf("followup: encode store: %w", err) + } + if err := atomicfile.Write(s.path, data, ".followup-*.tmp"); err != nil { + return fmt.Errorf("followup: %w", err) + } + return nil +} + +// tick is the firing loop's poll period — coarse on purpose (delays are +// minutes to hours) and identical to the cron scheduler's cadence. +const tick = 30 * time.Second + +// Run fires due follow-ups until ctx is cancelled, handing each to fire (in +// production a closure over chat.Service.InjectAuto). It returns ctx.Err() on +// cancellation. +func Run(ctx context.Context, store *FileStore, now func() time.Time, fire func(Item)) error { + if now == nil { + now = time.Now + } + t := time.NewTicker(tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + for _, it := range store.Due(now()) { + fire(it) + } + } + } +} + +// ParseDelay parses a follow-up filename stem ("15m", "2h", "90s", "1d") into +// a delay clamped to [MinDelay, MaxDelay]. ok=false for anything that does not +// parse as a positive Go-style duration (with "d" accepted as 24h). +func ParseDelay(stem string) (time.Duration, bool) { + if stem == "" { + return 0, false + } + // Accept a trailing 'd' (days) which time.ParseDuration lacks. + if last := stem[len(stem)-1]; last == 'd' { + n, err := strconv.Atoi(stem[:len(stem)-1]) + if err != nil || n <= 0 { + return 0, false + } + return clampDelay(time.Duration(n) * 24 * time.Hour), true + } + d, err := time.ParseDuration(stem) + if err != nil || d <= 0 { + return 0, false + } + return clampDelay(d), true +} + +// clampDelay bounds a delay to [MinDelay, MaxDelay]. +func clampDelay(d time.Duration) time.Duration { + if d < MinDelay { + return MinDelay + } + if d > MaxDelay { + return MaxDelay + } + return d +} diff --git a/core/followup/followup_test.go b/core/followup/followup_test.go new file mode 100644 index 0000000..8a1f8bd --- /dev/null +++ b/core/followup/followup_test.go @@ -0,0 +1,113 @@ +package followup_test + +import ( + "path/filepath" + "testing" + "time" + + "github.com/duckbugio/flock/core/followup" +) + +var base = time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC) + +func openTemp(t *testing.T) *followup.FileStore { + t.Helper() + s, err := followup.Open(filepath.Join(t.TempDir(), "followups.json")) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestAddDueRemoves(t *testing.T) { + s := openTemp(t) + it, err := s.Add("100", "check the deploy", base.Add(15*time.Minute)) + if err != nil { + t.Fatal(err) + } + if it.ID == "" || s.Count("100") != 1 { + t.Fatalf("Add = %+v, count %d", it, s.Count("100")) + } + if due := s.Due(base.Add(10 * time.Minute)); len(due) != 0 { + t.Fatalf("not yet due, got %+v", due) + } + due := s.Due(base.Add(16 * time.Minute)) + if len(due) != 1 || due[0].Prompt != "check the deploy" { + t.Fatalf("Due = %+v", due) + } + // Take-then-fire: taken items are gone. + if s.Count("100") != 0 { + t.Fatalf("taken item must be removed, count %d", s.Count("100")) + } + if again := s.Due(base.Add(time.Hour)); len(again) != 0 { + t.Fatalf("no double fire, got %+v", again) + } +} + +func TestPerChatCap(t *testing.T) { + s := openTemp(t) + for i := 0; i < followup.MaxPerChat; i++ { + if _, err := s.Add("100", "p", base.Add(time.Hour)); err != nil { + t.Fatal(err) + } + } + if _, err := s.Add("100", "one too many", base.Add(time.Hour)); err == nil { + t.Fatal("cap must reject") + } + // Other chats are unaffected. + if _, err := s.Add("200", "p", base.Add(time.Hour)); err != nil { + t.Fatal(err) + } +} + +func TestPersistAcrossReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "followups.json") + s, err := followup.Open(path) + if err != nil { + t.Fatal(err) + } + first, err := s.Add("100", "p", base.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + re, err := followup.Open(path) + if err != nil { + t.Fatal(err) + } + if re.Count("100") != 1 { + t.Fatalf("reopened count = %d", re.Count("100")) + } + second, err := re.Add("100", "q", base.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if second.ID == first.ID { + t.Fatalf("id collision after reopen: %s", second.ID) + } +} + +func TestParseDelay(t *testing.T) { + cases := []struct { + in string + want time.Duration + ok bool + }{ + {"15m", 15 * time.Minute, true}, + {"2h", 2 * time.Hour, true}, + {"90s", 90 * time.Second, true}, + {"1d", 24 * time.Hour, true}, + {"30s", time.Minute, true}, // floored to MinDelay + {"100h", followup.MaxDelay, true}, // capped + {"3d", followup.MaxDelay, true}, // capped via the d-branch + {"0m", 0, false}, + {"-5m", 0, false}, + {"soon", 0, false}, + {"", 0, false}, + } + for _, tc := range cases { + got, ok := followup.ParseDelay(tc.in) + if ok != tc.ok || (ok && got != tc.want) { + t.Errorf("ParseDelay(%q) = %v, %v; want %v, %v", tc.in, got, ok, tc.want, tc.ok) + } + } +} diff --git a/core/goal/goal.go b/core/goal/goal.go new file mode 100644 index 0000000..fbc0e11 --- /dev/null +++ b/core/goal/goal.go @@ -0,0 +1,278 @@ +// Package goal implements the /goal evaluator loop: the user states a "done" +// criterion once, and after every clean run an INDEPENDENT evaluator — a fresh +// Claude session with no shared context, so it cannot inherit the working +// session's blind spots — judges the workspace against that criterion. Not met → +// the verdict's unmet points are injected back as a fix-up prompt; met → the +// goal is cleared and the user notified. The attempt counter bounds the loop. +// +// This package holds the durable goal store (one active goal per chat, mirroring +// core/session.FileStore: single JSON file, atomic rewrite, mutex) plus the +// evaluator prompt builder and the verdict parser. Actually RUNNING the +// evaluator (spawning the CLI) lives in core/chat, which owns the Runner. +package goal + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/duckbugio/flock/internal/atomicfile" +) + +// dirPerm is the owner-only permission for bot-created directories. +const dirPerm os.FileMode = 0o750 + +// Goal is one chat's active objective. Attempts counts completed evaluation +// rounds (bumped when a verdict is parsed, not when a run merely finishes), so +// MaxAttempts bounds the evaluate→fix loop regardless of how many messages the +// user sends while the goal is armed. +type Goal struct { + Criterion string `json:"criterion"` + MaxAttempts int `json:"maxAttempts"` + Attempts int `json:"attempts"` + CreatedAt int64 `json:"createdAt"` // Unix ms +} + +// Store persists at most one active goal per chat. A nil *FileStore behind this +// interface is NOT expected — core/chat treats a nil Store interface as "feature +// disabled" instead. +type Store interface { + // Get returns the chat's active goal, ok=false when none is armed. + Get(chatID string) (Goal, bool) + // Set arms (or replaces) the chat's goal and persists the change. + Set(chatID string, g Goal) error + // Bump increments the chat's attempt counter and persists it, returning the + // updated goal. ok=false when no goal is armed (nothing was bumped). + Bump(chatID string) (Goal, bool, error) + // Delete disarms the chat's goal. Deleting an absent goal is a no-op. + Delete(chatID string) error +} + +// FileStore is a JSON-file-backed Store, shaped like core/session.FileStore. +type FileStore struct { + path string + + mu sync.Mutex + goals map[string]Goal +} + +// Open loads (or creates) a FileStore at path. A missing file yields an empty +// store. The parent directory is created if needed. +func Open(path string) (*FileStore, error) { + if path == "" { + return nil, errors.New("goal: store path is empty") + } + if err := os.MkdirAll(filepath.Dir(path), dirPerm); err != nil { + return nil, fmt.Errorf("goal: create store dir: %w", err) + } + s := &FileStore{path: path, goals: map[string]Goal{}} + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +// load reads and decodes the backing file. A non-existent or empty file is not +// an error (a fresh store). +func (s *FileStore) load() error { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("goal: read store: %w", err) + } + if len(data) == 0 { + return nil + } + if err := json.Unmarshal(data, &s.goals); err != nil { + return fmt.Errorf("goal: parse store %s: %w", s.path, err) + } + return nil +} + +// Get returns the chat's active goal. +func (s *FileStore) Get(chatID string) (Goal, bool) { + s.mu.Lock() + defer s.mu.Unlock() + g, ok := s.goals[chatID] + return g, ok +} + +// Set arms (or replaces) the chat's goal and rewrites the file atomically. +func (s *FileStore) Set(chatID string, g Goal) error { + s.mu.Lock() + defer s.mu.Unlock() + s.goals[chatID] = g + return s.persistLocked() +} + +// Bump increments the chat's attempt counter, persists, and returns the updated +// goal. A chat with no armed goal reports ok=false and persists nothing. +func (s *FileStore) Bump(chatID string) (Goal, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + g, ok := s.goals[chatID] + if !ok { + return Goal{}, false, nil + } + g.Attempts++ + s.goals[chatID] = g + return g, true, s.persistLocked() +} + +// Delete disarms the chat's goal and rewrites the file. Deleting an absent goal +// is a no-op that persists nothing. +func (s *FileStore) Delete(chatID string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.goals[chatID]; !ok { + return nil + } + delete(s.goals, chatID) + return s.persistLocked() +} + +// persistLocked writes the current map atomically. The caller must hold s.mu. +func (s *FileStore) persistLocked() error { + data, err := json.Marshal(s.goals) + if err != nil { + return fmt.Errorf("goal: encode store: %w", err) + } + if err := atomicfile.Write(s.path, data, ".goals-*.tmp"); err != nil { + return fmt.Errorf("goal: %w", err) + } + return nil +} + +// Verdict is the evaluator's structured judgment. Achieved is authoritative; +// Summary is a one-paragraph justification; Unmet lists the concrete gaps the +// fix-up prompt relays back to the working session. +type Verdict struct { + Achieved bool `json:"achieved"` + Summary string `json:"summary"` + Unmet []string `json:"unmet"` +} + +// EvalPrompt builds the evaluator's instruction. The evaluator runs as a FRESH +// session in the same workspace (bypass permissions, like every bot run), so the +// prompt must (a) scope it to read-only inspection plus running checks, and +// (b) pin the output to a single JSON object the caller can parse. +func EvalPrompt(criterion string) string { + return `You are an INDEPENDENT acceptance evaluator. A development session in this +workspace claims to be progressing toward the goal below. Judge, strictly and +adversarially, whether the goal is FULLY met RIGHT NOW. + +GOAL (the user's own acceptance criterion): +` + criterion + ` + +Rules: +- Inspect the workspace yourself: read code, diffs (git log/diff on the duck/* + branches), and run the repos' own checks (task/make/npm test, linters) when + they bear on the goal. Evidence over claims: a session's report that "tests + pass" counts for nothing — re-run them. +- Do NOT modify, commit, or push anything. You are a judge, not a fixer. +- Unverifiable ≠ met. If you cannot demonstrate a criterion holds, it is unmet. +- Your ENTIRE final reply must be a single JSON object, no prose around it: + {"achieved": true|false, "summary": "", + "unmet": ["", ...]} + "unmet" must be empty when achieved is true, and non-empty when false.` +} + +// FixupPrompt builds the prompt injected back into the WORKING session when the +// evaluator judged the goal unmet. It is an engineering artifact (professional +// English, no duck flavor) and carries the verdict's concrete gaps. +func FixupPrompt(criterion string, v Verdict) string { + var b strings.Builder + _, _ = b.WriteString("An independent evaluation judged the goal NOT yet achieved.\n\nGoal:\n") + _, _ = b.WriteString(criterion) + _, _ = b.WriteString("\n\nEvaluator's summary:\n") + _, _ = b.WriteString(v.Summary) + if len(v.Unmet) > 0 { + _, _ = b.WriteString("\n\nUnmet points:\n") + for _, u := range v.Unmet { + _, _ = b.WriteString("- ") + _, _ = b.WriteString(u) + _, _ = b.WriteString("\n") + } + } + _, _ = b.WriteString("\nAddress every unmet point, then finish as usual. The goal will be re-evaluated.") + return b.String() +} + +// ParseVerdict extracts the evaluator's JSON verdict from its final text. The +// model is told to reply with ONLY the JSON object, but wrappers happen (a code +// fence, a stray closing sentence), so the parser scans balanced {...} spans and +// unmarshals candidates from the LAST one backwards, accepting the first that +// carries an "achieved" key. ok=false means no verdict could be parsed — the +// caller treats the round as inconclusive rather than looping on garbage. +func ParseVerdict(text string) (Verdict, bool) { + spans := jsonSpans(text) + for i := len(spans) - 1; i >= 0; i-- { + // Require the "achieved" key to be present (not merely defaulted) so an + // unrelated JSON object in the reply can't masquerade as a verdict. + var probe map[string]json.RawMessage + if err := json.Unmarshal([]byte(spans[i]), &probe); err != nil { + continue + } + if _, has := probe["achieved"]; !has { + continue + } + var v Verdict + if err := json.Unmarshal([]byte(spans[i]), &v); err != nil { + continue + } + return v, true + } + return Verdict{}, false +} + +// jsonSpans returns every top-level balanced {...} span in text, tracking JSON +// string literals so braces inside strings don't unbalance the scan. +func jsonSpans(text string) []string { + var ( + spans []string + depth int + start int + inString bool + escaped bool + ) + for i := 0; i < len(text); i++ { + c := text[i] + if inString { + switch { + case escaped: + escaped = false + case c == '\\': + escaped = true + case c == '"': + inString = false + } + continue + } + switch c { + case '"': + if depth > 0 { + inString = true + } + case '{': + if depth == 0 { + start = i + } + depth++ + case '}': + if depth == 0 { + continue + } + depth-- + if depth == 0 { + spans = append(spans, text[start:i+1]) + } + } + } + return spans +} diff --git a/core/goal/goal_test.go b/core/goal/goal_test.go new file mode 100644 index 0000000..eba67ec --- /dev/null +++ b/core/goal/goal_test.go @@ -0,0 +1,146 @@ +//nolint:testpackage // intentionally whitebox to test unexported goal internals +package goal + +import ( + "path/filepath" + "strings" + "testing" +) + +func openTemp(t *testing.T) *FileStore { + t.Helper() + s, err := Open(filepath.Join(t.TempDir(), "goals.json")) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestStoreLifecycle(t *testing.T) { + s := openTemp(t) + if _, ok := s.Get("1"); ok { + t.Fatal("fresh store must have no goal") + } + if err := s.Set("1", Goal{Criterion: "all tests green", MaxAttempts: 3}); err != nil { + t.Fatal(err) + } + g, ok := s.Get("1") + if !ok || g.Criterion != "all tests green" || g.Attempts != 0 { + t.Fatalf("Get = %+v ok=%v", g, ok) + } + g, ok, err := s.Bump("1") + if err != nil || !ok || g.Attempts != 1 { + t.Fatalf("Bump = %+v ok=%v err=%v", g, ok, err) + } + if _, ok, _ := s.Bump("absent"); ok { + t.Fatal("Bump on absent chat must report ok=false") + } + if err := s.Delete("1"); err != nil { + t.Fatal(err) + } + if _, ok := s.Get("1"); ok { + t.Fatal("deleted goal must be gone") + } + if err := s.Delete("1"); err != nil { + t.Fatal("double delete must be a no-op") + } +} + +func TestStorePersistsAcrossReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "goals.json") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := s.Set("7", Goal{Criterion: "c", MaxAttempts: 5, Attempts: 2}); err != nil { + t.Fatal(err) + } + re, err := Open(path) + if err != nil { + t.Fatal(err) + } + g, ok := re.Get("7") + if !ok || g.Attempts != 2 || g.MaxAttempts != 5 { + t.Fatalf("reopened Get = %+v ok=%v", g, ok) + } +} + +func TestParseVerdict(t *testing.T) { + cases := []struct { + name string + text string + wantOK bool + achieved bool + unmet int + }{ + { + name: "bare object", + text: `{"achieved": true, "summary": "done", "unmet": []}`, + wantOK: true, + achieved: true, + }, + { + name: "fenced with prose", + text: "Here is my verdict:\n```json\n" + + `{"achieved": false, "summary": "nope", "unmet": ["AC2 not covered"]}` + + "\n```\nDone.", + wantOK: true, + achieved: false, + unmet: 1, + }, + { + name: "last object wins", + text: `{"achieved": true, "summary": "early draft", "unmet": []} +some reasoning... +{"achieved": false, "summary": "final", "unmet": ["x", "y"]}`, + wantOK: true, + achieved: false, + unmet: 2, + }, + { + name: "unrelated json is not a verdict", + text: `{"status": "ok"} no verdict here`, + wantOK: false, + }, + { + name: "braces inside strings", + text: `{"achieved": false, "summary": "code has {braces} and \"quotes\"", "unmet": ["fix {}"]}`, + wantOK: true, + achieved: false, + unmet: 1, + }, + { + name: "no json at all", + text: "I think it's fine.", + wantOK: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, ok := ParseVerdict(tc.text) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v (verdict %+v)", ok, tc.wantOK, v) + } + if !ok { + return + } + if v.Achieved != tc.achieved { + t.Fatalf("achieved = %v, want %v", v.Achieved, tc.achieved) + } + if len(v.Unmet) != tc.unmet { + t.Fatalf("unmet = %d, want %d", len(v.Unmet), tc.unmet) + } + }) + } +} + +func TestPromptsCarryCriterionAndGaps(t *testing.T) { + p := EvalPrompt("all handlers covered by tests") + if !strings.Contains(p, "all handlers covered by tests") { + t.Fatal("EvalPrompt must embed the criterion") + } + f := FixupPrompt("crit", Verdict{Summary: "gaps remain", Unmet: []string{"handler X untested"}}) + if !strings.Contains(f, "crit") || !strings.Contains(f, "handler X untested") { + t.Fatalf("FixupPrompt must carry criterion and unmet points, got:\n%s", f) + } +} diff --git a/core/poller/poller.go b/core/poller/poller.go index e5b92cf..bb0b284 100644 --- a/core/poller/poller.go +++ b/core/poller/poller.go @@ -13,6 +13,8 @@ import ( "net/http" "strings" "time" + + "github.com/duckbugio/flock/core/teambranch" ) // PRComment is a new, non-self comment observed on an OPEN team PR whose head @@ -43,10 +45,6 @@ const minInterval = 30 * time.Second // when Config.Client is nil. const defaultClientTimeout = 20 * time.Second -// minRefParts is the minimum number of "/"-separated segments a team branch ref -// must have to carry a chatID: duck/[/]. -const minRefParts = 2 - // poller holds the resolved runtime state for one Run. type poller struct { base string @@ -174,8 +172,9 @@ func (p *poller) handleThread(ctx context.Context, t *notification, out chan<- P return nil } ref := pr.Head.Ref - if !strings.HasPrefix(ref, "duck/") { - p.markRead(ctx, threadID) // not a team branch — clear it + chatID, ok := teambranch.ChatID(ref) + if !ok { + p.markRead(ctx, threadID) // not a (routable) team branch — clear it return nil } @@ -202,14 +201,6 @@ func (p *poller) handleThread(ctx context.Context, t *notification, out chan<- P repo = t.Repository.FullName } - // Parse chatID from duck//. - parts := strings.Split(ref, "/") - if len(parts) < minRefParts { - p.markRead(ctx, threadID) - return nil - } - chatID := parts[1] - select { case out <- PRComment{ ChatID: chatID, diff --git a/core/teambranch/teambranch.go b/core/teambranch/teambranch.go new file mode 100644 index 0000000..6ca0833 --- /dev/null +++ b/core/teambranch/teambranch.go @@ -0,0 +1,32 @@ +// Package teambranch owns the team-branch naming contract: every branch the +// dev team pushes is named duck/[/], and the segment is +// what routes host-side events (PR comments, CI state) back to the owning +// chat. The convention is load-bearing in several independent places — the +// Gitea notification poller, the CI watch, and the workspace prompt that tells +// the agent how to name branches — so the parser lives here once instead of +// drifting per consumer (the pre-extraction copies already disagreed on +// whether an empty chat id was acceptable). +package teambranch + +import "strings" + +// Prefix marks team branches; refs without it are not the team's. +const Prefix = "duck/" + +// minSegments is the fewest "/"-separated segments a team branch carries: +// duck/. A trailing slug is optional. +const minSegments = 2 + +// ChatID extracts the owning chat id from a team branch ref. ok=false when ref +// is not a team branch: no duck/ prefix, too few segments, or an EMPTY chat id +// (a "duck//slug" ref routes nowhere and must be skipped, not emitted). +func ChatID(ref string) (string, bool) { + if !strings.HasPrefix(ref, Prefix) { + return "", false + } + parts := strings.Split(ref, "/") + if len(parts) < minSegments || parts[1] == "" { + return "", false + } + return parts[1], true +} diff --git a/core/teambranch/teambranch_test.go b/core/teambranch/teambranch_test.go new file mode 100644 index 0000000..1dda79e --- /dev/null +++ b/core/teambranch/teambranch_test.go @@ -0,0 +1,30 @@ +package teambranch_test + +import ( + "testing" + + "github.com/duckbugio/flock/core/teambranch" +) + +func TestChatID(t *testing.T) { + cases := []struct { + ref string + want string + ok bool + }{ + {"duck/100/feature-x", "100", true}, + {"duck/-5164159101/go-stage-6", "-5164159101", true}, // negative group ids are real + {"duck/100", "100", true}, // slug is optional + {"duck//slug", "", false}, // empty chat id routes nowhere + {"duck/", "", false}, + {"main", "", false}, + {"ducks/100/x", "", false}, // prefix is exact + {"", "", false}, + } + for _, tc := range cases { + got, ok := teambranch.ChatID(tc.ref) + if ok != tc.ok || got != tc.want { + t.Errorf("ChatID(%q) = %q, %v; want %q, %v", tc.ref, got, ok, tc.want, tc.ok) + } + } +} diff --git a/core/verify/verify.go b/core/verify/verify.go new file mode 100644 index 0000000..954a651 --- /dev/null +++ b/core/verify/verify.go @@ -0,0 +1,347 @@ +// Package verify is the deterministic post-run quality gate: after a run +// finishes cleanly, the bot re-runs the affected repos' OWN check runner +// (task/make/npm — the same entrypoint CI uses) from the Go side and compares +// reality against the agent's claim. The agent reporting "tests pass" counts +// for nothing here — this package re-executes the gate itself, so a run that +// left a repo red can be bounced back for a fix instead of being reported to +// the user as done. +// +// Scope discipline: only repos the RUN actually changed are verified. The +// service snapshots each repo's state (HEAD + porcelain status) before the run +// and hands the snapshot back after it; repos whose state is unchanged (a Q&A +// message, a read-only investigation) are skipped, so idle chatter never pays a +// test-suite run. +package verify + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +// gitCmdTimeout bounds the tiny git state commands (rev-parse/status) so a hung +// git (e.g. a dead network FS) can't stall the post-run path. +const gitCmdTimeout = 15 * time.Second + +// defaultCheckTimeout bounds one repo's check command when the Verifier is +// constructed without an explicit timeout. +const defaultCheckTimeout = 30 * time.Minute + +// termGrace is how long a timed-out gate command gets between SIGTERM and +// SIGKILL — enough for test runners to tear down containers and temp state. +const termGrace = 2 * time.Second + +// outputTailBytes is how much trailing combined output is kept per check — the +// end of a failing run (the actual failures + summary) is what the fix-up +// prompt and the user notice need. +const outputTailBytes = 3500 + +// targetCandidates is the runner-target preference order. The first target the +// repo's runner actually defines is the one executed — `check`-style targets +// typically bundle lint+tests, matching the template's "gate" definition. +var targetCandidates = []string{"check", "ci", "test", "tests", "lint"} + +// Verifier re-runs changed repos' check gates. A nil *Verifier disables the +// feature (both methods are safe no-ops on nil). +type Verifier struct { + // Timeout bounds ONE repo's check command; non-positive falls back to + // defaultCheckTimeout. + Timeout time.Duration + Log *slog.Logger +} + +// Snapshot maps a repo directory name to its pre-run state fingerprint. +type Snapshot map[string]string + +// Result is the outcome of verifying one changed repo. Ran=false means no +// runner/target was detected (or the runner binary is absent) — that is a skip, +// not a failure. Command is the exact gate command for the user notice; Output +// is the trailing combined output (populated on failure). +type Result struct { + Repo string + Command string + Output string + Ran bool + Passed bool +} + +// log returns the configured logger or the default one. +func (v *Verifier) log() *slog.Logger { + if v != nil && v.Log != nil { + return v.Log + } + return slog.Default() +} + +// timeout returns the per-check timeout, defaulted. +func (v *Verifier) timeout() time.Duration { + if v == nil || v.Timeout <= 0 { + return defaultCheckTimeout + } + return v.Timeout +} + +// Take fingerprints every git repo directly under workspace dir. A nil +// Verifier, a missing git binary, or an unreadable dir yields an empty snapshot +// — Verify then sees no "changed" repos and does nothing, so a broken +// environment degrades to the old behavior instead of failing runs. +func (v *Verifier) Take(ctx context.Context, dir string) Snapshot { + if v == nil { + return nil + } + snap := Snapshot{} + for _, repo := range discoverRepos(dir) { + snap[repo] = repoFingerprint(ctx, filepath.Join(dir, repo)) + } + return snap +} + +// Verify re-runs the check gate of every repo whose fingerprint differs from +// the pre-run snapshot (including repos cloned DURING the run), plus every repo +// named in retry — the previous round's failures, which must be re-gated even +// when the fix run touched nothing (otherwise a no-op "fix" would silently +// exit the loop with the gate still red). It returns one Result per checked +// repo; an empty slice means nothing needed checking. A nil Verifier returns +// nil. +func (v *Verifier) Verify(ctx context.Context, dir string, before Snapshot, retry []string) []Result { + if v == nil { + return nil + } + retrySet := map[string]struct{}{} + for _, r := range retry { + retrySet[r] = struct{}{} + } + var results []Result + for _, repo := range discoverRepos(dir) { + path := filepath.Join(dir, repo) + _, mustRetry := retrySet[repo] + if !mustRetry && repoFingerprint(ctx, path) == before[repo] { + continue + } + results = append(results, v.checkRepo(ctx, path, repo)) + } + return results +} + +// Failed filters results down to genuine gate failures (Ran && !Passed). +func Failed(results []Result) []Result { + var out []Result + for _, r := range results { + if r.Ran && !r.Passed { + out = append(out, r) + } + } + return out +} + +// fixRoundLine renders (and fixRoundRe parses back) the loop-round marker +// embedded in every fix-up prompt. Carrying the round INSIDE the prompt makes +// the consecutive-round cap durable for free: the prompt is persisted in the +// auto-resume marker, so a deploy mid-loop resumes at the same round instead +// of resetting an in-memory counter to zero. +const fixRoundLine = "Auto-fix round %d of %d." + +// fixRoundRe matches the marker at the start of a line, anchored so ordinary +// prose cannot fake it accidentally. +var fixRoundRe = regexp.MustCompile(`(?m)^Auto-fix round (\d+) of \d+\.$`) + +// FixPrompt builds the prompt injected back into the working session when one +// or more gates failed. round is the 1-based fix-up round this prompt starts, +// out of maxRounds. It is an engineering artifact (professional English) and +// carries each failing command plus its output tail. +func FixPrompt(failed []Result, round, maxRounds int) string { + var b strings.Builder + _, _ = fmt.Fprintf(&b, fixRoundLine+"\n\n", round, maxRounds) + _, _ = b.WriteString("Post-run verification re-ran the repos' own check gates and they FAILED. " + + "The previous report of success does not hold.\n") + for _, r := range failed { + _, _ = fmt.Fprintf(&b, "\nRepo %s — `%s` exited non-zero. Output tail:\n```\n%s\n```\n", + r.Repo, r.Command, r.Output) + } + _, _ = b.WriteString("\nFix the failures (never weaken or skip tests to get green), " + + "re-run the same gate to confirm, and push the fix to the same branch if one was pushed.") + return b.String() +} + +// ParseFixRound extracts the round marker from a run's prompt: the fix-up +// round that run represents, or ok=false for a prompt that is not a +// verification fix-up (a user message, a poller relay, …). +func ParseFixRound(prompt string) (round int, ok bool) { + m := fixRoundRe.FindStringSubmatch(prompt) + if m == nil { + return 0, false + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0, false + } + return n, true +} + +// checkRepo detects the repo's runner + target and executes the gate. +func (v *Verifier) checkRepo(ctx context.Context, path, repo string) Result { + name, args, ok := detectGate(ctx, path) + if !ok { + v.log().Info("post-verify: no check runner detected; skipping", "repo", repo) + return Result{Repo: repo, Ran: false} + } + cmdline := strings.Join(append([]string{name}, args...), " ") + v.log().Info("post-verify: running gate", "repo", repo, "cmd", cmdline) + + out, err := runCmd(ctx, v.timeout(), path, name, args...) + res := Result{Repo: repo, Command: cmdline, Ran: true, Passed: err == nil, Output: tail(out)} + if err != nil { + v.log().Warn("post-verify: gate failed", "repo", repo, "cmd", cmdline, "error", err) + } + return res +} + +// detectGate picks the repo's check command: the first targetCandidates entry +// its runner defines, probing Taskfile → Makefile → package.json in that order. +// A runner file whose binary is missing falls through to the next runner. +func detectGate(ctx context.Context, path string) (name string, args []string, ok bool) { + if hasFile(path, "Taskfile.yml") || hasFile(path, "Taskfile.yaml") { + if _, err := exec.LookPath("task"); err == nil { + for _, t := range targetCandidates { + // `task --dry` resolves the task without executing; unknown → non-zero. + if _, err := runCmd(ctx, gitCmdTimeout, path, "task", "--dry", t); err == nil { + return "task", []string{t}, true + } + } + } + } + if hasFile(path, "Makefile") { + if _, err := exec.LookPath("make"); err == nil { + for _, t := range targetCandidates { + // `make -n` resolves the target without executing; no rule → non-zero. + if _, err := runCmd(ctx, gitCmdTimeout, path, "make", "-n", t); err == nil { + return "make", []string{t}, true + } + } + } + } + if scripts := packageScripts(filepath.Join(path, "package.json")); len(scripts) > 0 { + if _, err := exec.LookPath("npm"); err == nil { + for _, t := range targetCandidates { + if _, defined := scripts[t]; defined { + return "npm", []string{"run", t}, true + } + } + } + } + return "", nil, false +} + +// hasFile reports whether dir contains the named regular file. +func hasFile(dir, name string) bool { + info, err := os.Stat(filepath.Join(dir, name)) + return err == nil && !info.IsDir() +} + +// packageScripts parses package.json's "scripts" map; any failure yields nil. +func packageScripts(path string) map[string]string { + data, err := os.ReadFile(path) //nolint:gosec // path is a join of internal workspace dirs. + if err != nil { + return nil + } + var pkg struct { + Scripts map[string]string `json:"scripts"` + } + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + return pkg.Scripts +} + +// discoverRepos lists the git repos directly under dir (entries containing a +// .git directory or file), sorted by ReadDir's lexical order for determinism. +func discoverRepos(dir string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var repos []string + for _, e := range entries { + if !e.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(dir, e.Name(), ".git")); err == nil { + repos = append(repos, e.Name()) + } + } + return repos +} + +// repoFingerprint captures a repo's HEAD plus its porcelain status. Any git +// failure yields a fingerprint that still compares stably ("" or partial), so a +// repo git cannot read is simply never reported as changed. +func repoFingerprint(ctx context.Context, path string) string { + if _, err := exec.LookPath("git"); err != nil { + return "" + } + head, _ := runCmd(ctx, gitCmdTimeout, path, "git", "rev-parse", "HEAD") + status, _ := runCmd(ctx, gitCmdTimeout, path, "git", "status", "--porcelain") + return strings.TrimSpace(head) + "\n" + status +} + +// runCmd executes name+args in dir with the process in its own group, killing +// the whole group when ctx is cancelled or timeout elapses (check runners spawn +// tool subprocesses). It returns the combined output and the wait error (nil == +// exit 0). +func runCmd(ctx context.Context, timeout time.Duration, dir, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + // exec.Command (not CommandContext) is deliberate, mirroring core/claude: + // cancellation must kill the whole process GROUP (check runners spawn tool + // subprocesses), which the select below does explicitly. + //nolint:gosec,noctx // name/args come from the fixed runner tables above; ctx drives the group kill below. + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + var buf strings.Builder + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Start(); err != nil { + return "", err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return buf.String(), err + case <-ctx.Done(): + // Mirror core/claude's kill strategy: SIGTERM first so the runner (and the + // tool subprocesses it spawned — docker, builders) can tear down temp + // state, escalating to SIGKILL only after a short grace. + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) + } + select { + case <-done: + case <-time.After(termGrace): + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + <-done + } + return buf.String(), ctx.Err() + } +} + +// tail returns the trailing outputTailBytes of s, trimmed. +func tail(s string) string { + s = strings.TrimSpace(s) + if len(s) <= outputTailBytes { + return s + } + return s[len(s)-outputTailBytes:] +} diff --git a/core/verify/verify_test.go b/core/verify/verify_test.go new file mode 100644 index 0000000..7553418 --- /dev/null +++ b/core/verify/verify_test.go @@ -0,0 +1,202 @@ +//nolint:testpackage // intentionally whitebox to test unexported verify internals +package verify + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// requireTools skips the test when the binaries it drives are absent (the +// dev-tools image ships git and make; a bare host might not). +func requireTools(t *testing.T, names ...string) { + t.Helper() + for _, n := range names { + if _, err := exec.LookPath(n); err != nil { + t.Skipf("%s not available: %v", n, err) + } + } +} + +// gitRepo initializes a real git repo with one committed file and returns the +// workspace dir + repo name. +func gitRepo(t *testing.T, makefile string) (ws, repo string) { + t.Helper() + ws = t.TempDir() + repo = "app" + dir := filepath.Join(ws, repo) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + run := func(args ...string) { + t.Helper() + //nolint:gosec,noctx // test helper drives git with fixed args; no ctx needed + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + if makefile != "" { + if err := os.WriteFile(filepath.Join(dir, "Makefile"), []byte(makefile), 0o600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "main.txt"), []byte("v1\n"), 0o600); err != nil { + t.Fatal(err) + } + run("init", "-q") + run("add", ".") + run("-c", "commit.gpgsign=false", "commit", "-q", "-m", "init") + return ws, repo +} + +func TestUnchangedRepoIsSkipped(t *testing.T) { + requireTools(t, "git") + v := &Verifier{} + ws, _ := gitRepo(t, "check:\n\ttrue\n") + ctx := context.Background() + snap := v.Take(ctx, ws) + if results := v.Verify(ctx, ws, snap, nil); len(results) != 0 { + t.Fatalf("unchanged repo must produce no results, got %+v", results) + } +} + +func TestChangedRepoGatePasses(t *testing.T) { + requireTools(t, "git", "make") + v := &Verifier{Timeout: time.Minute} + ws, repo := gitRepo(t, "check:\n\t@echo gate ok\n") + ctx := context.Background() + snap := v.Take(ctx, ws) + // Dirty the working tree — the fingerprint changes. + if err := os.WriteFile(filepath.Join(ws, repo, "main.txt"), []byte("v2\n"), 0o600); err != nil { + t.Fatal(err) + } + results := v.Verify(ctx, ws, snap, nil) + if len(results) != 1 { + t.Fatalf("want 1 result, got %+v", results) + } + r := results[0] + if !r.Ran || !r.Passed || r.Command != "make check" { + t.Fatalf("result = %+v", r) + } + if len(Failed(results)) != 0 { + t.Fatal("passing gate must not be reported failed") + } +} + +func TestChangedRepoGateFails(t *testing.T) { + requireTools(t, "git", "make") + v := &Verifier{Timeout: time.Minute} + ws, repo := gitRepo(t, "check:\n\t@echo boom failure detail; exit 1\n") + ctx := context.Background() + snap := v.Take(ctx, ws) + if err := os.WriteFile(filepath.Join(ws, repo, "extra.txt"), []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + results := v.Verify(ctx, ws, snap, nil) + failed := Failed(results) + if len(failed) != 1 { + t.Fatalf("want 1 failed, got %+v", results) + } + if !strings.Contains(failed[0].Output, "boom failure detail") { + t.Fatalf("failure output not captured: %+v", failed[0]) + } + prompt := FixPrompt(failed, 1, 2) + if !strings.Contains(prompt, "make check") || !strings.Contains(prompt, "boom failure detail") { + t.Fatalf("FixPrompt must carry command and output:\n%s", prompt) + } + // The loop round rides inside the prompt (that is what makes the cap durable + // across restarts) and parses back exactly. + if round, ok := ParseFixRound(prompt); !ok || round != 1 { + t.Fatalf("ParseFixRound(FixPrompt) = %d, %v; want 1, true", round, ok) + } + if _, ok := ParseFixRound("an ordinary user message about Auto-fix round things"); ok { + t.Fatal("prose must not parse as a fix-round marker") + } +} + +func TestRepoWithoutRunnerIsSkipResult(t *testing.T) { + requireTools(t, "git") + v := &Verifier{} + ws, repo := gitRepo(t, "") + ctx := context.Background() + snap := v.Take(ctx, ws) + if err := os.WriteFile(filepath.Join(ws, repo, "extra.txt"), []byte("x\n"), 0o600); err != nil { + t.Fatal(err) + } + results := v.Verify(ctx, ws, snap, nil) + if len(results) != 1 || results[0].Ran { + t.Fatalf("runnerless repo must yield a skip result, got %+v", results) + } + if len(Failed(results)) != 0 { + t.Fatal("a skip is not a failure") + } +} + +func TestNewRepoClonedDuringRunIsVerified(t *testing.T) { + requireTools(t, "git", "make") + v := &Verifier{Timeout: time.Minute} + ws := t.TempDir() + ctx := context.Background() + snap := v.Take(ctx, ws) // empty workspace + // A repo appears mid-run (as if cloned by the agent). + ws2, repo := gitRepo(t, "check:\n\t@echo ok\n") + if err := os.Rename(filepath.Join(ws2, repo), filepath.Join(ws, repo)); err != nil { + t.Fatal(err) + } + results := v.Verify(ctx, ws, snap, nil) + if len(results) != 1 || !results[0].Passed { + t.Fatalf("new repo must be verified, got %+v", results) + } +} + +func TestRetryRepoIsRecheckedEvenWhenUnchanged(t *testing.T) { + requireTools(t, "git", "make") + v := &Verifier{Timeout: time.Minute} + ws, repo := gitRepo(t, "check:\n\t@exit 1\n") + ctx := context.Background() + snap := v.Take(ctx, ws) + // Nothing changed since the snapshot, but the repo is on the retry list (its + // gate failed last round) — it must be re-gated anyway. + results := v.Verify(ctx, ws, snap, []string{repo}) + if len(Failed(results)) != 1 { + t.Fatalf("retry repo must be re-gated, got %+v", results) + } + // An unknown retry name matches no checkout and is ignored. + if res := v.Verify(ctx, ws, snap, []string{"ghost"}); len(res) != 0 { + t.Fatalf("unknown retry repo must be ignored, got %+v", res) + } +} + +func TestNilVerifierIsNoop(t *testing.T) { + var v *Verifier + ctx := context.Background() + if snap := v.Take(ctx, t.TempDir()); snap != nil { + t.Fatal("nil Take must return nil") + } + if res := v.Verify(ctx, t.TempDir(), nil, nil); res != nil { + t.Fatal("nil Verify must return nil") + } +} + +func TestPackageScriptsAndPick(t *testing.T) { + dir := t.TempDir() + pkg := `{"name":"x","scripts":{"lint":"eslint .","test":"jest"}}` + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(pkg), 0o600); err != nil { + t.Fatal(err) + } + scripts := packageScripts(filepath.Join(dir, "package.json")) + if len(scripts) != 2 || scripts["test"] != "jest" { + t.Fatalf("scripts = %+v", scripts) + } + if s := packageScripts(filepath.Join(dir, "absent.json")); s != nil { + t.Fatal("missing file must yield nil") + } +} diff --git a/core/workspace/workspace.go b/core/workspace/workspace.go index a721a13..b67bdfa 100644 --- a/core/workspace/workspace.go +++ b/core/workspace/workspace.go @@ -35,17 +35,22 @@ type Renderer struct { // /SKILL.md subdirectory). Empty or a missing dir disables skills. SkillsDir string - // PrePRCycles, PrReviewCycles, EnablePRReview and GitHost are substituted into - // the template for the ${PRE_PR_CYCLES} ${PR_REVIEW_CYCLES} ${ENABLE_PR_REVIEW} - // ${GIT_HOST} placeholders respectively. Only these placeholders are - // substituted; any other ${...} in the template is left untouched (matching - // envsubst with an explicit variable list). + // PrePRCycles, PrReviewCycles, EnablePRReview, GitHost and AutoApproveScope + // are substituted into the template for the ${PRE_PR_CYCLES} + // ${PR_REVIEW_CYCLES} ${ENABLE_PR_REVIEW} ${GIT_HOST} ${AUTO_APPROVE_SCOPE} + // placeholders respectively. Only these placeholders are substituted; any + // other ${...} in the template is left untouched (matching envsubst with an + // explicit variable list). PrePRCycles string PrReviewCycles string EnablePRReview string // GitHost names the real git host (github.com / a Gitea host / …) so the prompt // states it instead of assuming "Gitea". Empty when git is not configured. GitHost string + // AutoApproveScope is the highest planner COMPLEXITY that skips the Phase-1 + // "confirm scope and wait" gate: off | trivial | standard | all. Normalized + // by config.AutoApproveScopeLevel (an unknown value renders as "off"). + AutoApproveScope string } // Ensure creates (or refreshes) the workspace for chatID and returns its path. @@ -137,6 +142,24 @@ content). Writing the PNG into ` + "`outbox/`" + ` delivers it to the user in ch e.g. a Telegram Mini App that needs ` + "`initData`" + ` — will not render. ` +// followupConvention is appended to every rendered CLAUDE.md so the agents +// know the LEGAL way to come back later — instead of ending a turn with an +// "I'll report back" promise that nothing ever wakes. Like outboxConvention it +// lives here so the template file on disk stays byte-identical. +const followupConvention = ` + +## Coming back later (follow-ups) + +You cannot promise "I'll report back when it's done": nothing wakes you when a turn ends, so +that promise strands the user. When work genuinely must wait on something external (a deploy +propagating, a long remote job), schedule a follow-up instead: write a file +` + "`followup/.md`" + ` at the workspace root — a sibling of the repos, e.g. +` + "`followup/15m.md`" + `, ` + "`followup/2h.md`" + ` (min 1m, max 48h) — whose CONTENT is the +prompt you want run at that time. After this run ends the bot schedules it durably and starts a +new turn with that prompt when due; tell the user when you'll be back. Anything under a few +minutes: just wait in the foreground of THIS turn. Recurring needs belong in /schedule. +` + // renderClaudeMD reads the template, substitutes the three configured // placeholders, appends the outbox convention, drops any stale CLAUDE.md, and // writes the fresh file. @@ -153,12 +176,15 @@ func (r *Renderer) renderClaudeMD(ws string) error { "${PR_REVIEW_CYCLES}", r.PrReviewCycles, "${ENABLE_PR_REVIEW}", r.EnablePRReview, "${GIT_HOST}", r.GitHost, + "${AUTO_APPROVE_SCOPE}", r.autoApproveScope(), ).Replace(string(raw)) - // Append the outbox + screenshot conventions to the RENDERED output (after - // substitution), keeping the protected template file on disk byte-identical. + // Append the outbox + screenshot + follow-up conventions to the RENDERED + // output (after substitution), keeping the protected template file on disk + // byte-identical. rendered += outboxConvention rendered += shotConvention + rendered += followupConvention dst := filepath.Join(ws, "CLAUDE.md") // Drop a possibly stale/root-owned stub so the write recreates it fresh, @@ -173,6 +199,16 @@ func (r *Renderer) renderClaudeMD(ws string) error { return nil } +// autoApproveScope returns the substitution value for ${AUTO_APPROVE_SCOPE}, +// defaulting an empty field to the fail-safe "off" so a caller that never sets +// it (tests, the VK adapter) renders the always-wait behavior. +func (r *Renderer) autoApproveScope() string { + if r.AutoApproveScope == "" { + return "off" + } + return r.AutoApproveScope +} + // copyAgents copies every *.md under AgentsDir into agentsDst, overwriting any // existing copies so each Ensure refreshes them (matching the entrypoint's // `cp -f`). diff --git a/core/workspace/workspace_test.go b/core/workspace/workspace_test.go index 694617e..ec37935 100644 --- a/core/workspace/workspace_test.go +++ b/core/workspace/workspace_test.go @@ -310,3 +310,38 @@ func TestEnsureIdempotent(t *testing.T) { t.Fatalf("CLAUDE.md content lost after second Ensure: %q", string(body)) } } + +func TestAutoApproveScopeSubstitution(t *testing.T) { + r := newTestRenderer(t) + tmpl := filepath.Join(t.TempDir(), "tmpl.md") + if err := os.WriteFile(tmpl, []byte("auto=${AUTO_APPROVE_SCOPE}\n"), 0o600); err != nil { + t.Fatalf("write template: %v", err) + } + r.TemplatePath = tmpl + + // Unset renders the fail-safe "off". + ws, err := r.Ensure("900") + if err != nil { + t.Fatalf("Ensure: %v", err) + } + data, err := os.ReadFile(filepath.Join(ws, "CLAUDE.md")) //nolint:gosec // test path under t.TempDir + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + if !strings.Contains(string(data), "auto=off") { + t.Fatalf("unset AutoApproveScope must render off, got: %s", data) + } + + // A configured level renders verbatim. + r.AutoApproveScope = "trivial" + if _, err := r.Ensure("900"); err != nil { + t.Fatalf("Ensure: %v", err) + } + data, err = os.ReadFile(filepath.Join(ws, "CLAUDE.md")) //nolint:gosec // test path under t.TempDir + if err != nil { + t.Fatalf("read CLAUDE.md: %v", err) + } + if !strings.Contains(string(data), "auto=trivial") { + t.Fatalf("AutoApproveScope must substitute, got: %s", data) + } +} diff --git a/go.mod b/go.mod index adcf9c0..73fe26f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/duckbugio/flock -go 1.26.4 +go 1.26.5 require ( github.com/caarlos0/env/v11 v11.4.1 diff --git a/internal/autonomy/autonomy.go b/internal/autonomy/autonomy.go new file mode 100644 index 0000000..4a39cf3 --- /dev/null +++ b/internal/autonomy/autonomy.go @@ -0,0 +1,83 @@ +// Package autonomy assembles the post-run autonomy-loop configuration +// (deterministic gate verification, the /goal evaluator, the per-chat autonomy +// budget) shared by every adapter binary — the Telegram and VK mains wire the +// SAME loops, so the builder lives here once instead of drifting per cmd. +package autonomy + +import ( + "context" + "log/slog" + + "github.com/duckbugio/flock/core/autobudget" + "github.com/duckbugio/flock/core/chat" + "github.com/duckbugio/flock/core/followup" + "github.com/duckbugio/flock/core/goal" + "github.com/duckbugio/flock/core/verify" + "github.com/duckbugio/flock/internal/config" +) + +// Build opens the autonomy-loop stores and assembles the post-run config. +// Every store-open failure is non-fatal (log + run with that single feature +// disabled), matching the rest of the adapters' best-effort startup wiring. +func Build(cfg config.Config, logger *slog.Logger) chat.PostRunConfig { + goals, err := goal.Open(cfg.GoalStoreFile()) + if err != nil { + logger.Error("open goal store; /goal disabled", "path", cfg.GoalStoreFile(), "error", err) + goals = nil + } + budget, err := autobudget.Open(cfg.AutoBudgetStoreFile()) + if err != nil { + logger.Error("open autonomy budget store; auto budget disabled", + "path", cfg.AutoBudgetStoreFile(), "error", err) + budget = nil + } + followups, err := followup.Open(cfg.FollowupStoreFile()) + if err != nil { + logger.Error("open follow-up store; follow-ups disabled", "path", cfg.FollowupStoreFile(), "error", err) + followups = nil + } + postRun := chat.PostRunConfig{ + VerifyMaxFixes: cfg.PostVerifyMaxFixes, + GoalMaxAttempts: cfg.EffectiveGoalMaxAttempts(), + EvalModel: cfg.EvaluatorModel, + EvalMaxTurns: cfg.GoalEvalMaxTurns, + EvalTimeout: cfg.GoalEvalTimeout(), + Budget: budget, + BudgetCapUSD: cfg.AutoTaskMaxCostPerDay, + Followups: followups, + PromiseNudge: cfg.EnablePromiseNudge, + } + if cfg.EnablePostVerify { + postRun.Verifier = &verify.Verifier{Timeout: cfg.PostVerifyTimeout(), Log: logger} + } + if goals != nil { + postRun.Goals = goals + } + logger.Info("autonomy loops configured", + "post_verify", cfg.EnablePostVerify, + "goal_evaluator", goals != nil, + "ci_watch", cfg.CIWatchEnabled(), + "auto_merge", cfg.CIWatchEnabled() && cfg.EnableAutoMerge, + "auto_budget_usd_per_day", cfg.AutoTaskMaxCostPerDay, + "auto_approve_scope", cfg.AutoApproveScopeLevel(), + "followups", followups != nil, + "promise_nudge", cfg.EnablePromiseNudge && followups != nil, + ) + return postRun +} + +// StartFollowups runs the one-shot follow-up firing loop for the store wired +// into pr, injecting each due item into its chat through the autonomy-budgeted +// path. A nil store (open failed / feature off) is a no-op. +func StartFollowups(ctx context.Context, svc *chat.Service, pr chat.PostRunConfig, logger *slog.Logger) { + if pr.Followups == nil { + return + } + go func() { + if err := followup.Run(ctx, pr.Followups, nil, func(it followup.Item) { + svc.InjectAuto(ctx, it.ChatID, chat.FollowupPrompt(it)) + }); err != nil && ctx.Err() == nil { + logger.Error("follow-up loop stopped", "error", err) + } + }() +} diff --git a/internal/config/config.go b/internal/config/config.go index edee424..4482255 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -245,6 +245,66 @@ type Config struct { PrReviewCycles string `env:"PR_REVIEW_CYCLES" envDefault:"10"` EnablePRReview string `env:"ENABLE_PR_REVIEW" envDefault:"false"` + // AutoApproveScope controls the Phase-1 "confirm scope with the user" wait, + // rendered into the workspace CLAUDE.md: off (always wait, the default) | + // trivial | standard | all — the highest planner COMPLEXITY that proceeds + // WITHOUT waiting for approval (the spec summary is still posted). Kept as a + // string for verbatim template substitution; see AutoApproveScopeLevel. + AutoApproveScope string `env:"AUTO_APPROVE_SCOPE" envDefault:"off"` + + // Post-run verification (core/verify): after a cleanly successful run the bot + // re-runs the changed repos' OWN check gates (task/make/npm) from the Go side + // and, on red, injects a fix-up run — bounded by POST_VERIFY_MAX_FIXES + // consecutive rounds per chat. ENABLE_POST_VERIFY=false turns the whole pass + // off; the timeout bounds ONE repo's gate command. + EnablePostVerify bool `env:"ENABLE_POST_VERIFY" envDefault:"true"` + PostVerifyMaxFixes int `env:"POST_VERIFY_MAX_FIXES" envDefault:"2"` + PostVerifyTimeoutSeconds int `env:"POST_VERIFY_TIMEOUT_SECONDS" envDefault:"1800"` + + // /goal evaluator (core/goal): an INDEPENDENT fresh-session judge re-checks + // the user's stated "done" criterion after every clean run, looping fix-ups + // until achieved or GOAL_MAX_ATTEMPTS evaluation rounds. EVALUATOR_MODEL + // overrides the judge's model (empty = the run model); the timeout / turn cap + // bound one evaluator session. The store persists armed goals across restarts. + GoalMaxAttempts int `env:"GOAL_MAX_ATTEMPTS" envDefault:"5"` + EvaluatorModel string `env:"EVALUATOR_MODEL"` + GoalEvalTimeoutSeconds int `env:"GOAL_EVAL_TIMEOUT_SECONDS" envDefault:"1800"` + GoalEvalMaxTurns int `env:"GOAL_EVAL_MAX_TURNS" envDefault:"25"` + GoalStorePath string `env:"GOAL_STORE_PATH"` + + // CI watch (core/ciwatch): poll the git host for CI state on the duck/* + // branches checked out in the workspaces; a red build injects a fix-up into + // the owning chat, and (opt-in) a green PR is auto-merged. Needs GIT_TOKEN + // plus either GITEA_API_URL or GIT_HOST=github.com (see CIWatchEnabled). + EnableCIWatch bool `env:"ENABLE_CI_WATCH" envDefault:"false"` + CIPollInterval int `env:"CI_POLL_INTERVAL" envDefault:"120"` + EnableAutoMerge bool `env:"ENABLE_AUTO_MERGE" envDefault:"false"` + CIStatePath string `env:"CI_STATE_PATH"` + + // AutoTaskMaxCostPerDay is the per-chat per-UTC-day USD ceiling for the + // AUTONOMY-originated runs (CI fix-ups, verification fix-ups, goal-evaluator + // rounds and their follow-ups). Reactive like the per-user cap; non-positive + // disables the gate. Deliberately much smaller than CLAUDE_MAX_COST_PER_USER: + // nobody is watching an autonomous loop overnight. Scheduled jobs are gated + // separately (their creator's cost cap, see InjectScheduled). + AutoTaskMaxCostPerDay float64 `env:"AUTO_TASK_MAX_COST_PER_DAY" envDefault:"20.0"` + AutoBudgetStorePath string `env:"AUTO_BUDGET_STORE_PATH"` + + // Follow-ups (core/followup): the workspace followup/.md convention + // that lets a run legally come back later, plus the promise nudge — one + // corrective run fired when a final answer promises to return on its own + // without scheduling anything that would. + FollowupStorePath string `env:"FOLLOWUP_STORE_PATH"` + EnablePromiseNudge bool `env:"ENABLE_PROMISE_NUDGE" envDefault:"true"` + + // DuckBug MCP: wire the DuckBug (error & log monitoring) MCP server into + // every Claude run so the team can read the project's live errors/logs. The + // TOKEN is the on/off switch (a sk-duck-api01-… API token from the DuckBug + // UI); the URL defaults to the cloud endpoint and self-hosted deployments + // override it with /api/mcp. + DuckBugMCPToken string `env:"DUCKBUG_MCP_TOKEN"` + DuckBugMCPURL string `env:"DUCKBUG_MCP_URL" envDefault:"https://duckbug.io/api/mcp"` + // Source paths for the shared team config baked into the image (see the // Dockerfile's /opt/duck layout). Rendered per chat by core/workspace. TeamTemplatePath string `env:"TEAM_TEMPLATE_PATH" envDefault:"/opt/duck/CLAUDE.workspace.md.tmpl"` @@ -764,3 +824,126 @@ func (c Config) ValidateVK() error { } return nil } + +// autoApproveLevels is the set of accepted AUTO_APPROVE_SCOPE values. +var autoApproveLevels = map[string]struct{}{ + "off": {}, "trivial": {}, "standard": {}, "all": {}, +} + +// AutoApproveScopeLevel normalizes AUTO_APPROVE_SCOPE for template rendering: +// lowercased, and any unrecognized value falls back to "off" (fail-safe: a typo +// must never silently skip the human approval gate). +func (c Config) AutoApproveScopeLevel() string { + v := strings.ToLower(strings.TrimSpace(c.AutoApproveScope)) + if _, ok := autoApproveLevels[v]; ok { + return v + } + return "off" +} + +// PostVerifyTimeout returns the per-repo gate-command timeout as a Duration; a +// non-positive value falls back to the verifier's built-in default. +func (c Config) PostVerifyTimeout() time.Duration { + if c.PostVerifyTimeoutSeconds <= 0 { + return 0 + } + return time.Duration(c.PostVerifyTimeoutSeconds) * time.Second +} + +// EffectiveGoalMaxAttempts floors GOAL_MAX_ATTEMPTS to 1: with a non-positive +// cap the /goal loop would stop after its very first evaluation round (the +// bump makes Attempts 1 >= 0) without ever injecting a fix-up — a misconfig +// must degrade to "one round", never to a broken loop. +func (c Config) EffectiveGoalMaxAttempts() int { + if c.GoalMaxAttempts < 1 { + return 1 + } + return c.GoalMaxAttempts +} + +// GoalEvalTimeout returns the evaluator-session deadline as a Duration, or 0 +// when no timeout is configured. +func (c Config) GoalEvalTimeout() time.Duration { + if c.GoalEvalTimeoutSeconds <= 0 { + return 0 + } + return time.Duration(c.GoalEvalTimeoutSeconds) * time.Second +} + +// GoalStoreFile returns the path to the JSON goal store, defaulting to +// /goals.json so it lives under the workspace data dir +// alongside the session/cost stores, never inside a repo. +func (c Config) GoalStoreFile() string { + if strings.TrimSpace(c.GoalStorePath) != "" { + return c.GoalStorePath + } + return filepath.Join(c.ApprovedDirectory, "goals.json") +} + +// CIStateFile returns the path to the JSON CI-watch dedupe store, defaulting to +// /ci_state.json. +func (c Config) CIStateFile() string { + if strings.TrimSpace(c.CIStatePath) != "" { + return c.CIStatePath + } + return filepath.Join(c.ApprovedDirectory, "ci_state.json") +} + +// AutoBudgetStoreFile returns the path to the JSON autonomy-budget store, +// defaulting to /auto_budget.json. +func (c Config) AutoBudgetStoreFile() string { + if strings.TrimSpace(c.AutoBudgetStorePath) != "" { + return c.AutoBudgetStorePath + } + return filepath.Join(c.ApprovedDirectory, "auto_budget.json") +} + +// FollowupStoreFile returns the path to the JSON follow-up store, defaulting +// to /followups.json. +func (c Config) FollowupStoreFile() string { + if strings.TrimSpace(c.FollowupStorePath) != "" { + return c.FollowupStorePath + } + return filepath.Join(c.ApprovedDirectory, "followups.json") +} + +// DuckBugMCPEnabled reports whether the DuckBug MCP server should be wired +// into runs: the token is the on/off switch (no separate flag), and a usable +// URL must remain (the default cloud endpoint unless overridden). +func (c Config) DuckBugMCPEnabled() bool { + return strings.TrimSpace(c.DuckBugMCPToken) != "" && strings.TrimSpace(c.DuckBugMCPURL) != "" +} + +// minCIPollInterval floors the CI poll period, mirroring the Gitea poller. +const minCIPollInterval = 30 * time.Second + +// CIPollDuration returns the CI poll period as a Duration, floored to 30s, or 0 +// when the configured interval is not positive. +func (c Config) CIPollDuration() time.Duration { + if c.CIPollInterval <= 0 { + return 0 + } + d := time.Duration(c.CIPollInterval) * time.Second + if d < minCIPollInterval { + return minCIPollInterval + } + return d +} + +// CIWatchGitHub reports whether the CI watch should speak the GitHub API (the +// deployment's git host is github.com); otherwise it uses the Gitea API URL. +func (c Config) CIWatchGitHub() bool { + return strings.EqualFold(strings.TrimSpace(c.GitHost), gitHubHost) +} + +// CIWatchEnabled reports whether the CI watch loop should run: the feature flag +// is on, a token is set, a positive interval is configured, and the deployment +// has a host API to poll (github.com, or a Gitea API URL). Unlike the PR-comment +// poller it does NOT require ENABLE_PR_REVIEW — reacting to a red build is +// useful even when the on-PR review loop is off. +func (c Config) CIWatchEnabled() bool { + if !c.EnableCIWatch || c.GitToken == "" || c.CIPollInterval <= 0 { + return false + } + return c.CIWatchGitHub() || c.GiteaAPIURL != "" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0e6aba5..565aba0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "testing" "time" ) @@ -917,3 +918,100 @@ func TestValidateOpenAICompatAcceptsAPIKeyFromNamedEnv(t *testing.T) { t.Fatalf("ValidateOpenAICompat() with env key = %v, want nil", err) } } + +func TestAutoApproveScopeLevel(t *testing.T) { + for in, want := range map[string]string{ + "": "off", + "off": "off", + "trivial": "trivial", + "Standard": "standard", + "ALL": "all", + "typo": "off", // fail-safe: never silently skip the approval gate + } { + c := Config{AutoApproveScope: in} + if got := c.AutoApproveScopeLevel(); got != want { + t.Errorf("AutoApproveScopeLevel(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCIWatchEnabled(t *testing.T) { + const giteaHostName = "git.example.com" + base := Config{EnableCIWatch: true, GitToken: "tok", CIPollInterval: 120, GitHost: "github.com"} + if !base.CIWatchEnabled() { + t.Error("fully configured GitHub watch must be enabled") + } + gitea := base + gitea.GitHost = giteaHostName + gitea.GiteaAPIURL = "https://git.example.com/api/v1" + if !gitea.CIWatchEnabled() { + t.Error("fully configured Gitea watch must be enabled") + } + for _, tt := range []struct { + name string + mutate func(*Config) + }{ + {"flag off", func(c *Config) { c.EnableCIWatch = false }}, + {"no token", func(c *Config) { c.GitToken = "" }}, + {"zero interval", func(c *Config) { c.CIPollInterval = 0 }}, + {"no host api", func(c *Config) { c.GitHost = giteaHostName; c.GiteaAPIURL = "" }}, + } { + c := base + tt.mutate(&c) + if c.CIWatchEnabled() { + t.Errorf("CIWatchEnabled() = true for %q, want false", tt.name) + } + } +} + +func TestCIPollDuration(t *testing.T) { + if got := (Config{CIPollInterval: 0}).CIPollDuration(); got != 0 { + t.Errorf("zero interval = %v, want 0", got) + } + if got := (Config{CIPollInterval: 5}).CIPollDuration(); got != 30*time.Second { + t.Errorf("tiny interval = %v, want floored 30s", got) + } + if got := (Config{CIPollInterval: 120}).CIPollDuration(); got != 120*time.Second { + t.Errorf("interval = %v, want 120s", got) + } +} + +func TestAutonomyStoreFileDefaults(t *testing.T) { + c := Config{ApprovedDirectory: "/workspace"} + for name, got := range map[string]string{ + "goals": c.GoalStoreFile(), + "ci_state": c.CIStateFile(), + "budget": c.AutoBudgetStoreFile(), + } { + if !strings.HasPrefix(got, "/workspace/") { + t.Errorf("%s store default = %q, want under /workspace", name, got) + } + } + override := Config{ApprovedDirectory: "/workspace", GoalStorePath: "/data/g.json"} + if got := override.GoalStoreFile(); got != "/data/g.json" { + t.Errorf("GoalStoreFile override = %q", got) + } +} + +func TestEffectiveGoalMaxAttempts(t *testing.T) { + for in, want := range map[int]int{0: 1, -3: 1, 1: 1, 5: 5} { + c := Config{GoalMaxAttempts: in} + if got := c.EffectiveGoalMaxAttempts(); got != want { + t.Errorf("EffectiveGoalMaxAttempts(%d) = %d, want %d", in, got, want) + } + } +} + +func TestDuckBugMCPEnabled(t *testing.T) { + if (Config{}).DuckBugMCPEnabled() { + t.Error("no token must disable the DuckBug MCP") + } + on := Config{DuckBugMCPToken: "sk-duck-api01-x", DuckBugMCPURL: "https://duckbug.io/api/mcp"} //nolint:gosec // test fixture + if !on.DuckBugMCPEnabled() { + t.Error("token + url must enable the DuckBug MCP") + } + noURL := Config{DuckBugMCPToken: "sk-duck-api01-x", DuckBugMCPURL: " "} //nolint:gosec // test fixture + if noURL.DuckBugMCPEnabled() { + t.Error("a blank URL must disable the DuckBug MCP") + } +} diff --git a/tools/Dockerfile b/tools/Dockerfile index f20bdce..8eec4f5 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.4 +FROM golang:1.26.5 RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates git curl && \