From 264294d1cc4948476249c74f40004fe1b6cc812d Mon Sep 17 00:00:00 2001 From: sprooty Date: Wed, 5 Aug 2026 11:03:30 +0000 Subject: [PATCH 01/12] feat: add self-contained browser control plane --- AGENTS.md | 26 +- GUI_PLAN.md | 708 ++++++++++++++++++ README.md | 24 +- docs/ARCHITECTURE.md | 37 +- docs/COORDINATION-PLANE.md | 8 +- docs/DEPLOYMENT.md | 8 +- docs/MULTI-PROJECT-PLAN.md | 7 +- docs/USAGE.md | 13 +- docs/evidence/2026-08-05-gui-milestone-0-1.md | 63 ++ pyproject.toml | 4 +- src/agent_harness/__main__.py | 17 +- src/agent_harness/api.py | 115 ++- src/agent_harness/audit.py | 12 + src/agent_harness/browser_session.py | 107 +++ src/agent_harness/holds.py | 1 + src/agent_harness/query_service.py | 301 ++++++++ src/agent_harness/schemas.py | 44 ++ src/agent_harness/static/app.css | 91 +++ src/agent_harness/static/app.js | 28 + src/agent_harness/static/htmx.min.js | 1 + src/agent_harness/store.py | 17 + src/agent_harness/templates/analytics.html | 6 + src/agent_harness/templates/base.html | 42 ++ src/agent_harness/templates/events.html | 6 + .../templates/fragments/project_cards.html | 12 + .../templates/fragments/work_rows.html | 5 + src/agent_harness/templates/holds.html | 6 + src/agent_harness/templates/login.html | 15 + src/agent_harness/templates/placeholder.html | 2 + src/agent_harness/templates/projects.html | 12 + src/agent_harness/templates/settings.html | 8 + src/agent_harness/templates/work.html | 6 + src/agent_harness/templates/work_item.html | 7 + src/agent_harness/ui.py | 299 ++++++++ tests/test_api.py | 21 +- tests/test_ui.py | 148 ++++ tests/test_ui_packaging.py | 38 + 37 files changed, 2181 insertions(+), 84 deletions(-) create mode 100644 GUI_PLAN.md create mode 100644 docs/evidence/2026-08-05-gui-milestone-0-1.md create mode 100644 src/agent_harness/browser_session.py create mode 100644 src/agent_harness/query_service.py create mode 100644 src/agent_harness/static/app.css create mode 100644 src/agent_harness/static/app.js create mode 100644 src/agent_harness/static/htmx.min.js create mode 100644 src/agent_harness/templates/analytics.html create mode 100644 src/agent_harness/templates/base.html create mode 100644 src/agent_harness/templates/events.html create mode 100644 src/agent_harness/templates/fragments/project_cards.html create mode 100644 src/agent_harness/templates/fragments/work_rows.html create mode 100644 src/agent_harness/templates/holds.html create mode 100644 src/agent_harness/templates/login.html create mode 100644 src/agent_harness/templates/placeholder.html create mode 100644 src/agent_harness/templates/projects.html create mode 100644 src/agent_harness/templates/settings.html create mode 100644 src/agent_harness/templates/work.html create mode 100644 src/agent_harness/templates/work_item.html create mode 100644 src/agent_harness/ui.py create mode 100644 tests/test_ui.py create mode 100644 tests/test_ui_packaging.py diff --git a/AGENTS.md b/AGENTS.md index 89aa640..36d99e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ trade and it is rejected. | Telemetry export | `src/agent_harness/adapters/otlp.py` — opt-in, lazily loaded, **export only**; the event store stays the source of truth | | Queue schema migration | `docs/MIGRATION-graph.md` — backup, export, rebuild, rollback | | Log readers | `src/agent_harness/ingest.py` | -| JSON API (no GUI) | `src/agent_harness/api.py` | +| JSON API and in-process browser GUI | `src/agent_harness/api.py`; `src/agent_harness/ui.py` | | Session host client | `src/agent_harness/session_host.py` | | Agent loop | `src/agent_harness/session_executor.py` | | The worker and its 13 gates | `swack-tools/oxidex` — `scripts/model_fix_loop.py` | @@ -149,14 +149,24 @@ with Swagger UI. Treat it as a contract: Tests assert these properties, not just status codes — see `tests/test_api.py`. -## Do not add a GUI here +## The GUI belongs here -The GUI belongs to the session host — AIDevEnv is the reference one. It -already has tabs, token auth, push -notifications, an Android PWA, and the PTY sessions the agents run in. A web -UI in this repo means a second URL, a second login, no notifications and no -phone story — worse, for the same work. This service serves JSON; the host -renders it as a Work tab. +`agent-harness serve` owns and serves the browser GUI from the same process and +origin as its public JSON API. Templates, static assets, browser authentication, +tests and documentation live in this repository and ship in its distribution. + +The GUI has no dependency on MyDevEnv, AIDevEnv or another session host: it must +not import their code, consume their assets or authentication, require their +proxy, or assume their session and terminal model. An optional session host may +still execute agents through the generic `session_host` protocol; that execution +adapter is not the owner or host of the GUI. + +HTML controllers delegate to the same typed query and command services as JSON +routes. They never read SQLite directly or duplicate a gate. Browser actions +require authenticated operator identity, CSRF validation and explicit review; +navigation and drag-and-drop never imply permission for a state transition. +The JSON API remains public, typed and documented, and normal GUI operation +must not require a CDN or a separately deployed frontend. ## Two invariants the store must keep diff --git a/GUI_PLAN.md b/GUI_PLAN.md new file mode 100644 index 0000000..94130dc --- /dev/null +++ b/GUI_PLAN.md @@ -0,0 +1,708 @@ +# Agent Harness GUI Implementation Plan + +**Status:** Accepted product direction; implementation not started +**Plan date:** 2026-08-05 +**Product boundary:** The GUI is built, packaged, served, tested, and documented entirely +inside `agent-harness`. + +## 1. Product decision + +### 1.1 Owner ruling + +`agent-harness` owns its GUI. Running `agent-harness serve` will expose both the existing +JSON API and the browser application from the same process and origin. + +The GUI has **no dependency on MyDevEnv**. It will not import MyDevEnv code, consume its +assets, use its authentication, require its proxy, depend on its routes, or assume its +session or terminal model. Existing documentation names AIDevEnv as a reference session +host; that host is not a GUI dependency either. + +All GUI source, templates, static assets, migrations, tests, and documentation live in this +repository and ship in the `agent-harness` distribution. There is no separate frontend +repository or required frontend service. + +This is a new owner ruling. It supersedes only the earlier placement decision that said the +GUI must live in a session host. It does **not** supersede the generic-core rule, the gate +invariants, append-only event history, honest reporting, or settled decisions D1-D14. + +### 1.2 Consequence for the repository + +The current tree still enforces the old ruling in `AGENTS.md`, `README.md`, +`docs/ARCHITECTURE.md`, `docs/MULTI-PROJECT-PLAN.md`, `docs/USAGE.md`, CLI help, the +`api.py` module documentation, and `tests/test_api.py`. Implementation must begin by +changing those statements and replacing the test that asserts `/` is a 404. + +Until that policy-alignment change lands, GUI implementation is blocked by contradictory +repository instructions. The first milestone resolves the contradiction explicitly rather +than allowing code and policy to drift. + +### 1.3 Reference products + +Kiro Crew is the behavioral reference for a persistent multi-session workspace: history, +memory, skills, task execution, schedules, subagents, approvals, notifications, and live +terminal activity. See its [README](https://github.com/kirodotdev/KiroCrew), +[dashboard documentation](https://github.com/kirodotdev/KiroCrew/blob/main/src/kiro_crew/docs/dashboard.md), +and [feature index](https://github.com/kirodotdev/KiroCrew/blob/main/src/kiro_crew/docs/index.md). + +Agno is the behavioral reference for a production control plane: persistent sessions, +agent/team selection, streaming, tool-call visualization, reasoning, references, multimodal +output, human approval, traces, audit logs, RBAC, multi-tenancy, scheduling, and +integrations. See the [Agno README](https://github.com/agno-agi/agno) and +[Agno Agent UI](https://github.com/agno-agi/agent-ui). + +These are references, not dependencies. Their names, data models, and conventions must not +be hardcoded into generic execution code. + +## 2. Outcome and definition of done + +The completed product is a responsive control plane for one or many agent-harness projects. +An operator can understand state, act on work, answer holds, inspect evidence, configure +routing, plan projects, and diagnose failures without opening SQLite or reading a log file. + +The accepted GUI program is done only when all of the following are true: + +1. `agent-harness serve` presents the GUI directly at its own URL; MyDevEnv and AIDevEnv are + absent from the build, runtime, authentication, routing, and deployment requirements. +2. A fresh wheel or container includes every required template and static asset and makes + no CDN request for normal operation. +3. The GUI works in monitoring-only mode. Controls that require a supervised executor are + disabled with the readiness reason shown, not hidden and not allowed to fail later. +4. Projects, work, holds, readiness, dependencies, events, attempts, costs, delivery, + routes, roles, and audit health can be understood from the GUI. +5. Every state-changing action preserves the same validation and gate behavior as the JSON + API and records operator identity, intent, outcome, and time. +6. Retry, force start, dependency override, plan sync, GitHub reconciliation, repository + writes, and destructive maintenance cannot occur through an implicit UI transition. +7. Cost-cap failures remain terminal, unclassified historical rate limits remain separate, + and unpriced calls remain visibly excluded from known spend. +8. The interface is usable at desktop and phone widths with keyboard-only navigation, + visible focus, semantic labels, and reduced-motion support. +9. Lost connectivity is visible. Event delivery resumes from the last monotonic cursor + without silently dropping or duplicating an operator-visible transition. +10. The repository's four gates pass from the root with `TMPDIR` set to a fast volume. +11. A release evidence document records the build under test, checks run, exercised user + journeys, failures found, and unmet criteria. Unexercised behavior is not called proven. + +The session, memory, skills, scheduling, channels, and multi-user capabilities accepted in +the feature review remain in scope. They are sequenced after the operator control plane +because removing the session-host dependency turns those features from UI integration into +new agent-harness subsystems. No feature is dropped; the dependency-aware order changes. + +## 3. Scope and non-negotiable constraints + +### 3.1 In scope + +1. A server-rendered browser application served by the existing FastAPI process. +2. Responsive navigation, project overview, work board, detail views, holds, and events. +3. Safe project, work-item, dependency, plan, routing, and maintenance controls. +4. Inception, plan parsing, dry-run, synchronization, adoption, and dependency views. +5. Operational telemetry, rate-limit classification, cost, delivery, and baselines. +6. A later in-repository session/chat/terminal subsystem with persistent correlation to + projects, items, and attempts. +7. Later generic extension layers for memory, knowledge, skills, tools, automation, + channels, RBAC, and recovery. + +### 3.2 Out of scope + +1. A GUI hosted by or embedded in MyDevEnv, AIDevEnv, or another session host. +2. A second deployable frontend service or a separately versioned frontend repository. +3. Vendor-specific agent, model, log, memory, tool, or session behavior in core modules. +4. Replacing the existing JSON API. It remains a public, typed, documented surface. +5. Rewriting the queue, executor, stores, gates, or 13-stage attempt model for UI + convenience. +6. Turning `attempts.py` into a workflow engine or adding a third-party gate registry while + D8 remains open. +7. Fabricating missing history, cost, outcome, or rate-limit classifications to complete a + chart. + +### 3.3 Gate behavior + +The gates are the product. The GUI may explain and invoke a gate but may not weaken it, +skip it, make it optional, or infer approval from a visual interaction. Drag-and-drop is +never sufficient authorization for a gate-controlled state change. + +Each expensive or externally visible operation must have: + +1. a dedicated action, not an incidental navigation gesture; +2. a review screen showing the resolved target and consequences; +3. any required reason, confirmation phrase, resume token, or override token; +4. server-side validation at execution time; and +5. an append-only audit record for success or refusal. + +## 4. Current baseline and gaps + +The repository already contains most of the control-plane JSON contracts, but no browser +application. The implementation should reuse these contracts and add only the missing +capabilities. + +| Capability | Current state | Required work | +|---|---|---| +| Application shell | `/` deliberately returns 404 | Templates, packaged assets, navigation, login, error and reconnect states | +| Work | List, detail, retry, block, answer, readiness, graph, and dependency override APIs exist | Board/detail views, filters, action forms, item-scoped event and attempt evidence | +| Projects | List, create/update, detail, start, stop, preflight, base checks, readiness, and control APIs exist | Overview/configuration views; explicit per-project pause and drain contracts | +| Holds | List and answer APIs exist | Inbox, structured answer form, expiry handling, notifications, verified operator attribution | +| Events | Monotonic cursor APIs exist | SSE transport preserving cursor semantics, filtering, reconnect and polling fallback | +| Audit | Health, events, cost, delivery, rollups, baselines, maintenance, and reconcile APIs exist | Dashboards, confirmations, reason/operator audit for actions, missing breakdowns | +| Plans | Inception and plan parse/sync APIs exist | Wizards, revision UI, dry-run review, adoption HTTP API | +| Routing | Role map and route-health APIs exist | Editor, used/unused explanation, independence warnings, secret-safe validation | +| Workers | Project summaries expose counts and failures | Worker/claim/lease/heartbeat/session inventory API | +| Attempts and artifacts | Durable data exists in internal modules | Typed item-scoped API for attempts, stages, patches, diffs, and evidence links | +| Sessions/chat/terminal | Agent-harness can use an optional external session-host protocol | New in-repository generic session subsystem; external-host behavior does not satisfy this plan | +| Memory/skills/tools | No generic product APIs | New extension contracts and installed-metadata boundaries | +| Scheduling/channels | No generic product APIs | New scheduler, trigger, notification, and channel contracts | +| Identity/RBAC | Bearer token only; several actions accept caller-supplied `who` | Browser session auth, CSRF defense, authenticated operator identity, later RBAC | +| Backup/restore | Operational documentation only | WAL-aware snapshot, validation, restore plan, and guarded UI workflow | +| Policy | Repository forbids local HTML | Explicit policy and test reversal in Milestone 0 | + +## 5. Technical architecture + +### 5.1 Deployment shape + +```mermaid +flowchart LR + B[Browser] -->|HTML and fragments| UI[FastAPI UI routes] + B -->|SSE with cursor resume| STREAM[Event stream] + C[CLI and API clients] -->|Bearer token and JSON| API[Existing JSON API] + UI --> APP[Typed query and command services] + API --> APP + STREAM --> EVENTS[Append-only event stores] + APP --> Q[Work queue and project state] + APP --> EVENTS + APP --> R[Runtime, gates, and adapters] +``` + +The browser, HTML routes, JSON API, and event stream are one `agent-harness serve` +deployment. A reverse proxy may provide TLS, but no host application is required. + +### 5.2 Route layout + +1. `/` redirects an authenticated operator to `/projects` and otherwise to `/login`. +2. `/login` and `/logout` own browser-session establishment and revocation. +3. `/projects`, `/work`, `/holds`, `/plans`, `/events`, `/analytics`, `/sessions`, and + `/settings` are full-page routes. +4. `/ui/fragments/*` returns HTMX fragments; fragments are never treated as public API. +5. `/ui/actions/*` handles browser form submissions with CSRF validation and delegates to + the same command services as the JSON API. +6. `/api/*`, `/openapi.json`, `/docs`, `/redoc`, and `/healthz` keep their existing + contracts and purposes. +7. `/api/events/stream` adds SSE without changing `/api/events` cursor semantics. +8. `/assets/*` serves versioned, immutable files packaged with the Python distribution. + +All URL construction must honor FastAPI's `root_path`; templates must use named routes +rather than concatenate deployment prefixes. + +### 5.3 Frontend stack + +The settled D5 stack remains the baseline: Jinja templates, HTMX interactions, and SSE +updates. There is no Node build step in the normal development loop. + +1. HTML is rendered server-side with semantic landmarks and progressively enhanced forms. +2. HTMX and any small supporting libraries are vendored, version-pinned assets. The GUI + must not depend on a CDN or execute unpinned remote code. +3. CSS uses repository-owned design tokens for color, spacing, typography, state, focus, + and responsive layout. Dark and light themes honor system preference and operator choice. +4. Small behavior that HTMX does not cover is plain, typed/documented JavaScript kept at a + narrow boundary. +5. Charts and graphs must retain accessible table/list equivalents. +6. The later terminal view may use a vendored terminal renderer and WebSocket transport; + that exception does not introduce a separate frontend build or service. + +### 5.4 Application-service boundary + +HTML controllers must not reproduce business rules and must not read SQLite directly. The +current API handlers contain some inline query and command logic, so each GUI slice first +extracts only the relevant logic into a typed application service, leaving the public API +response unchanged. This is module movement and reuse, not a rewrite. + +Both JSON and HTML paths must therefore reach the same: + +1. authorization decision; +2. resolved project/item target; +3. gate or readiness check; +4. state transition; +5. exception-to-user-error mapping; and +6. audit event. + +Pydantic API schemas remain the external contract. UI-specific view models may add labels, +URLs, and presentation state, but they cannot become another source of truth. + +### 5.5 Browser authentication and action security + +The JSON API keeps bearer-token authentication. The browser must never place that bearer +token in a URL, rendered page, JavaScript bundle, browser storage, or log. + +Milestone 1 will add a browser login exchange: + +1. `POST /login` validates the configured harness token over TLS or loopback. +2. A successful login creates a bounded, opaque server-side session and sets an `HttpOnly`, + `SameSite=Strict` cookie. Restart, logout, expiry, or harness-token rotation revokes it. +3. Each browser session has a CSRF token. Every state-changing browser request requires it; + `Origin`/`Referer` checks provide defense in depth. +4. Failed login attempts are rate-limited without recording the supplied credential. +5. Single-operator mode uses the authenticated session's configured identity, not a free + text `who` field, for audit attribution. +6. The later RBAC milestone replaces the single identity with authenticated users and + project-scoped permissions without weakening these protections. + +OpenAPI and documentation routes remain public as currently required. Data, HTML views, +fragments, streams, and actions fail closed when no harness token is configured. + +### 5.6 Live updates + +SSE is a delivery optimization over the append-only event cursor, not a new state store. + +1. A client reconnects with `Last-Event-ID` or an explicit cursor. +2. The server replays events after that cursor, then streams new events and heartbeats. +3. Duplicate delivery is tolerated by event ID; a cursor gap is surfaced as degraded + history, not hidden. +4. Exponential reconnect is client-local and never pauses a worker or another browser. +5. Polling `/api/events` remains the fallback when streaming is unavailable. +6. HTML actions return their authoritative result immediately; the UI does not wait for an + eventually delivered event to decide whether the action succeeded. + +### 5.7 Packaging and content security + +Templates and assets are package data covered by a wheel-install smoke test. Production +responses set a restrictive Content Security Policy, `frame-ancestors`, MIME-sniffing, +referrer, and cache headers. User/model text is escaped by default; Markdown is sanitized; +tool output, patches, logs, and ANSI terminal content are treated as untrusted input. + +## 6. Delivery milestones + +Milestones are ordered by dependency. A milestone is complete only when its acceptance +criteria and repository gates pass; partially implemented screens do not make it complete. + +### Milestone 0 — Align policy and preserve the baseline + +**Goal:** Make the new product ruling unambiguous before adding HTML. + +0.1. Replace the `AGENTS.md` "Do not add a GUI here" section with the in-repository GUI +boundary and the no-MyDevEnv rule. + +0.2. Update `README.md`, `docs/ARCHITECTURE.md`, `docs/MULTI-PROJECT-PLAN.md`, +`docs/USAGE.md`, `docs/DEPLOYMENT.md`, CLI help, `api.py` documentation, and package +description where they claim the service is headless or host-rendered. + +0.3. Replace `test_there_is_no_html_anywhere` with tests that require `/` or `/login`, keep +all `/api/*` responses JSON, and keep OpenAPI/docs discoverable. + +0.4. Record the route, auth, service-layer, asset, CSP, and root-path decisions in the +architecture documentation. + +0.5. Run and record all four gates before feature work to establish an honest baseline. + +**Acceptance:** No active repository instruction or test forbids an agent-harness GUI; no +runtime behavior other than the deliberately updated route expectation has changed; the +baseline results are recorded. + +### Milestone 1 — Secure application shell and read-only operator slice + +**Goal:** Deliver a useful, self-contained GUI that cannot mutate execution state. + +1.1. Add packaged Jinja templates, CSS, vendored HTMX, icons, and asset manifest. + +1.2. Implement login, logout, opaque browser sessions, CSRF tokens, security headers, and +root-path-safe URL generation. + +1.3. Build the responsive shell with Projects, Work, Holds, Events, Analytics, Plans, +Sessions, and Settings navigation; project switcher; command palette; theme; focus +management; reconnect status; and global state badges. + +1.4. Build project cards showing queue counts, control/previous state, reason, workers, +worker failures, stale claims, draining items, readiness, and audit degradation. + +1.5. Build the work board as a grouped list first. Group pending, claimed, held, blocked, +failed, exhausted, and done; put holds first; add URL-backed search, filters, and sorting. +Kanban can be a later presentation of the same query. + +1.6. Build work-item detail with specification, issue, dependencies, branch, PR, lease, +attempt count, budgets, known spend, unpriced calls, latest session evidence, disposition, +reason, and readiness explanation. + +1.7. Add item-scoped typed APIs for event timeline, durable attempt/stage history, outcomes, +and retained patch/artifact metadata. Render them without inventing unavailable evidence. + +1.8. Build the holds inbox with question, reason, age, expiry, allowed answerer, item link, +and optional session evidence. Answering remains disabled until Milestone 2. + +1.9. Add SSE cursor resume, live status updates, and polling fallback. A disconnected banner +must distinguish stale displayed data from current state. + +1.10. Add empty, unconfigured, monitoring-only, degraded-audit, loading, 401/403, 404, 409, +422, and 5xx states with actionable explanations. + +**Acceptance:** From a wheel-installed `agent-harness serve`, an authenticated operator can +inspect every project and item, find every open hold, follow live events through a forced +disconnect/reconnect, and diagnose fixture failures without MyDevEnv, a CDN, or direct +database access. No GUI route can yet change harness state. + +### Milestone 2 — Explicit controls and human-in-the-loop actions + +**Goal:** Make the read-only control plane safely operable. + +2.1. Add per-project Continue, Pause, Drain, Stop, and Resume command contracts. Preserve +the explicit human-resume rule after every process restart; never auto-start a project. + +2.2. Build project preflight and base-check views. Force start, base checks, and readiness +overrides require a review dialog that shows every blocker and warning. + +2.3. Build the project configuration editor for repository, checkout, base branch, checks, +fixes, role routes, worker limit, attempt limit, wall-clock budget, spend ceiling, disk +floor, plan path, and durability. Secret values are never echoed. + +2.4. Implement Retry, Block, Answer, and revision-scoped Dependency override forms. Add any +missing reason/operator fields to typed API requests and record their audit outcomes. + +2.5. Answer forms support text and structured JSON. Resume-token expiry and mismatch return +the server's precise error without discarding the operator's unsent draft. + +2.6. Add safe bulk selection for actions whose server contract can validate every target. +Show a dry-run result and refuse the whole batch when atomic safety cannot be guaranteed. + +2.7. Add browser notifications for holds, failures, and completion after explicit +permission. Notification delivery is a convenience; the holds store and event stores remain +authoritative. + +2.8. Append audit events for operator identity, target, submitted reason, command outcome, +and timestamp. Do not log bearer tokens, resume tokens, CSRF tokens, or secret fields. + +**Acceptance:** Every supported mutation can be completed from the GUI and produces the same +result and refusal behavior as the JSON API. Tests prove that live claims cannot be raced, +cost caps are never retried, invalid resume tokens do not answer holds, blockers prevent +ordinary starts, and restart never auto-resumes a project. + +**MVP boundary:** Milestones 0-2 are the first releasable GUI. + +### Milestone 3 — Inception, plan lifecycle, adoption, and dependencies + +**Goal:** Let an operator bring work into the harness without hiding parsing or decision +loss. + +3.1. Build the "Describe a project" inception wizard using the existing draft, scope, +revision, question-resolution, approval, and generated-plan contracts. + +3.2. Show goal, assumptions, non-goals, risks, phases, work-item count, feedback revisions, +and open blocking/deferrable questions. Refuse approval while blocking questions remain. + +3.3. Render generated `PLAN.md` before initialization and allow download/copy without +creating queue rows. + +3.4. Build the plan workflow: Parse -> show skipped headings, duplicates, malformed and +unresolved dependencies, cycles, and external/cross-project edges -> dry-run -> explicit +Sync. + +3.5. Keep plan sync non-destructive. Real GitHub writes require a second confirmation that +shows repository and exact create/update/orphan counts. + +3.6. Add a typed HTTP adoption proposal API around `adoption.py`, then build the adoption +wizard. A proposal is never a decision: nothing is dropped unless the operator names it. + +3.7. Build an accessible dependency graph with zoom, pan, search, item focus, and a list +equivalent. Distinguish local work, external references, human decisions, cross-project +dependencies, advisory edges, satisfied edges, blocked edges, unresolved edges, and cycles. + +3.8. Show resolver status/evidence and the exact item-readiness explanation. Overrides are +revision-scoped and require authenticated identity and reason. + +**Acceptance:** A fixture project can be scoped, revised, approved, parsed, dry-run, synced, +and adopted without silent loss. Blocking questions and dependency cycles prevent the +corresponding gated action. External writes occur only after the confirmed non-destructive +preview. + +### Milestone 4 — Routing, workers, operations, and analytics + +**Goal:** Complete the production control-plane view. + +4.1. Build a generic role-routing editor for registered roles. Show model fallback order, +route preset, endpoint, provider identity, price reference, and whether the active executor +actually uses the role. + +4.2. Validate routes without exposing credentials. Warn when reviewer and implementer lack +model/vendor independence; do not silently rewrite the operator's routing choice. + +4.3. Add a typed worker-pool API and view for worker identity, project, item, claim, lease, +heartbeat, stage, start time, failure, and abandoned-session evidence. + +4.4. Build the event explorer with project, item, worker, endpoint, role, model, outcome, +error class, reason kind, and time filters while preserving cursor order. + +4.5. Build rate-limit panels that keep `rpm`, `window_cap`, `terminal_cap`, and +`unclassified` separate. Display the supplied baseline and denominator beside comparisons. + +4.6. Build cost panels by project, role, model, and window. Known spend and unpriced calls +must be visually and semantically separate. + +4.7. Build delivery panels for completed, failed, merged, closed-unmerged, and reverted +work; audit-health panels for missing, degraded, or partial history; and daily rollup and +baseline comparisons. + +4.8. Add confirmed GitHub reconciliation and audit maintenance actions. Show the resolved +repository, retention parameters, dry-run where supported, and returned errors. + +4.9. Add session-independent process metrics and agent-harness gateway logs through typed, +redacted APIs. Never make local filesystem log paths a core convention. + +**Acceptance:** An operator can explain fleet state, failure classes, route use, +reviewer-independence risk, cost caveats, delivery outcomes, worker leases, and audit health +from the GUI. Every chart has a raw/table route to the evidence behind it. + +**Control-plane v1 boundary:** Milestones 0-4 deliver the independent agent-harness GUI for +the capabilities the service substantially owns today. + +### Milestone 5 — Agent-harness-owned sessions, chat, and terminal + +**Goal:** Deliver the accepted workspace features without delegating the GUI to MyDevEnv or +another host. + +This milestone begins with a separate design review because the current session-host client +is an execution adapter, not an agent-harness session product. External session URLs may be +shown as evidence before this milestone, but they do not satisfy its acceptance criteria. + +5.1. Define generic session, message, attachment, tool-call, terminal-stream, and correlation +protocols. Core types must not name a vendor or import an adapter. + +5.2. Add an in-repository session registry and durable session metadata: project, item, +attempt, working directory, process state, timestamps, title, pin, folder, color, and parent +session. + +5.3. Add a guarded local process/PTY backend with explicit registered-work-directory +boundaries, resize, bounded scrollback, cooperative stop, exit reporting, and audit events. +Running a command uses the harness OS identity and is presented as a high-impact action. + +5.4. Add typed HTTP/SSE/WebSocket contracts for history, output, input, resize, stop, and +resume. Authenticate every connection; remote session links are scoped and short-lived. + +5.5. Build multiple session tabs, history, search, resume, rename, pin, folders, colors, and +fork-with-context. + +5.6. Render streaming Markdown, highlighted code, diagrams, file paths, tool calls/results, +reasoning, and references only where the provider supplies typed data. Sanitize all output. + +5.7. Add queued-message editing, reordering, cancellation, regeneration, and cooperative +stop with explicit server acknowledgements. + +5.8. Build the terminal pane with live output, working directory, resize, copy, and +send-selection-to-chat. + +5.9. Add file uploads and multimodal input/output with type, size, storage, scanning, +retention, and redaction policies before enabling them. + +5.10. Make item -> attempt -> session correlation durable and link both directions. The +work-item detail route points to the internal session workspace when one exists. + +**Acceptance:** All session acceptance journeys run against `agent-harness serve` alone. +No MyDevEnv/AIDevEnv process, route, token, asset, or proxy appears in the test or deployment +topology. Killing and restarting the GUI process preserves session metadata and honest +process-exit state; it never pretends a dead PTY is resumable. + +### Milestone 6 — Memory, knowledge, skills, tools, and extensions + +**Goal:** Add the accepted extension layer behind generic contracts. + +6.1. Define project-scoped context/preferences and inspectable lessons/corrections with +global versus project scope and explicit provenance. + +6.2. Add persistent, incognito, and temporary session policies with visible retention and +deletion behavior. + +6.3. Add knowledge-source upload, ingestion status, semantic search, and citations through +an adapter boundary. Source text and citation provenance remain inspectable. + +6.4. Add skills CRUD, version history, enable/disable, and project/agent assignment. + +6.5. Add an MCP/tool registry with capability discovery, health, and project/agent scope. +Tool registration must not become a gate-registration mechanism while D8 is unresolved. + +6.6. Define a host-independent GUI extension boundary using installed metadata. Extensions +may contribute declared pages/panels and typed data, but cannot bypass auth, CSP, audit, or +command services. + +6.7. Keep adapter modules lazy and opt-in. Extend `tests/test_generic.py` so the relevant +GUI execution path cannot import or contain dotted paths to shipped adapters. + +**Acceptance:** A third-party distribution can install one knowledge/tool/GUI extension by +metadata without editing core, and a clean installation without it behaves identically. +Extension failure is isolated and visibly degraded rather than taking down the control +plane. + +### Milestone 7 — Automation and external surfaces + +**Goal:** Add scheduled and reactive work without creating hidden execution. + +7.1. Add one-shot, interval, and cron schedules with timezone, skip dates, next-run preview, +pause/resume, owner, and project scope. + +7.2. Add background task specifications with step progress, checkpoint resume, refinement, +and cancellation. Reuse fixed attempt stages where applicable; do not turn them into a +general workflow language. + +7.3. Add authenticated, replay-protected webhook and CI triggers with explicit mapping to a +project/action and an immutable receipt. + +7.4. Add a generic notification/channel adapter contract for browser and optional external +surfaces. Shared continuity uses session IDs and typed messages, not a vendor-specific +channel model. + +7.5. Build Schedules, Triggers, Runs, Channels, and notification-routing views with preview, +pause, failure, and last/next-run state. + +7.6. Preserve explicit execution policy: a schedule or trigger is itself an approved source +of intent, recorded at creation and at every run. It cannot imply dependency override, +force start, or external write authority not present in its specification. + +**Acceptance:** Timezone/DST, missed-run, duplicate-webhook, pause, restart, checkpoint, and +cancellation tests prove at-most-once admission where promised and honest duplicate status +where external delivery cannot guarantee it. + +### Milestone 8 — RBAC, governance, and recovery + +**Goal:** Make the independent GUI safe for deployments with multiple operators and formal +recovery requirements. + +8.1. Add optional users, roles, and project isolation while keeping a simple single-operator +deployment supported. + +8.2. Define permissions for read, operate, answer, override, configure, synchronize, +administer, and open remote sessions. Enforce them in shared command services, not only in +templates. + +8.3. Display effective approval policy: allowed, denied, sensitive path, sandbox, and +redaction status. + +8.4. Build an immutable audit view for operator, agent, tool, schedule, webhook, and +external-effect actions. + +8.5. Add short-lived, scoped, revocable remote session links with explicit expiry and +single-use options. + +8.6. Add WAL-aware snapshot and restore workflows: resolve exact files, checkpoint safely, +create a recoverable snapshot, validate integrity, rehearse restore into a separate target, +and require an explicit final cutover. + +8.7. Add backup age, integrity, restore rehearsal, auth failure, denied action, and session +revocation panels without exposing sensitive values. + +**Acceptance:** Authorization tests prove horizontal and vertical isolation through both +HTML and JSON paths. A documented backup/restore exercise succeeds on a disposable fixture, +and a failed integrity check cannot overwrite the active stores. + +## 7. Feature-to-milestone traceability + +| Accepted feature group | Delivery | +|---|---| +| 1. Application shell and navigation | Milestone 1 | +| 2. Project overview and control center | Milestones 1-2 | +| 3. Work board | Milestones 1-2 | +| 4. Work-item detail | Milestones 1-2; session link completed in 5 | +| 5. Session and chat workspace | Milestone 5 | +| 6. Human-in-the-loop inbox | Milestones 1-2 | +| 7. Project inception and plan lifecycle | Milestone 3 | +| 8. Dependency and readiness visualization | Milestone 3 | +| 9. Agent, role, and worker orchestration | Milestone 4; subagent tree awaits a generic API | +| 10. Operations, telemetry, and analytics | Milestone 4 | +| 11. Memory, knowledge, skills, and tools | Milestone 6 | +| 12. Automation and external surfaces | Milestone 7 | +| 13. Security, governance, and recovery | Foundations in 1-2; full scope in 8 | + +## 8. Test and evidence strategy + +### 8.1 Tests required with each slice + +1. Unit tests for extracted query/command services and presentation view models. +2. In-process ASGI tests for HTML status, auth, CSRF, headers, root path, JSON isolation, + fragments, redirects, error mapping, and action parity. +3. Source-derived authorization tests so every new protected HTML, fragment, stream, and + action route is covered automatically, as API routes are today. +4. Browser journeys for login/logout, navigation, project switching, filters, reconnect, + keyboard operation, modal focus, form-error preservation, and each high-impact action. +5. Accessibility checks plus manual keyboard and screen-reader smoke tests at release gates. +6. Security tests for XSS through model/log/Markdown fields, CSRF, cookie flags, token + leakage, open redirects, path traversal, upload handling, WebSocket authorization, and + CSP. +7. Concurrency tests for stale page submissions, lease changes between review and confirm, + duplicate clicks, stream reconnect, and simultaneous operators. +8. Packaging tests that build/install the wheel in a clean environment and request every + template and hashed asset without repository files present. +9. Genericity tests over the GUI execution path using the authoritative `EXECUTION_PATH` in + `tests/test_generic.py`. +10. Regression tests proving the JSON API and OpenAPI remain typed, documented, and + backward-compatible. + +Browser tests may add a browser runtime to CI, but the application build itself remains +Node-free. The four repository gates remain mandatory; browser/a11y/security journeys are +additional release gates, not substitutes. + +### 8.2 Evidence per milestone + +Each milestone produces `docs/evidence/YYYY-MM-DD-gui-milestone-N.md` containing: + +1. commit and configuration under test; +2. commands run and whether `TMPDIR` was on a fast volume; +3. API/schema changes; +4. user journeys exercised and viewport/input methods used; +5. before/after measurements where the milestone changes an operational metric; +6. observed failures and fixes; +7. security and accessibility results; +8. screenshots only as supporting evidence, never as the sole assertion; and +9. unmet criteria and follow-up work stated plainly. + +## 9. Rollout plan + +1. Ship Milestone 1 first to a disposable demo database in monitoring-only mode. +2. Exercise read paths and reconnect behavior before enabling mutations. +3. Enable Milestone 2 controls against a fixture repository; verify every refusal and audit + event before using a real repository. +4. Run the MVP against a real but non-critical project and preserve evidence. Do not call + it fleet-proven; the harness has not run against a real fleet yet. +5. Deliver Milestones 3 and 4 after MVP evidence closes their API gaps. +6. Treat Milestone 5 as a subsystem release with a separate threat model and migration + plan; do not smuggle PTY ownership into an ordinary UI increment. +7. Deliver extension, automation, and multi-user milestones only after their generic + protocols and failure-isolation tests exist. + +Direct deployment is the supported shape: + +```console +HARNESS_TOKEN=replace-me uv run agent-harness --db harness.sqlite serve --port 8099 +``` + +The operator opens the agent-harness URL directly. A conventional reverse proxy may add +TLS and a path prefix, but MyDevEnv/AIDevEnv is never part of the topology. + +## 10. Risks and controls + +| Risk | Control | +|---|---| +| Old no-GUI guidance remains active | Milestone 0 changes policy, docs, help, and enforcement before code | +| HTML and JSON paths implement different rules | Shared typed query/command services and parity tests | +| Browser auth leaks the bearer token | Opaque server session, HttpOnly cookie, CSRF, no URL/storage/rendered token | +| UI hides a gate or turns it into a gesture | Review screens, server-side revalidation, reasons/tokens, append-only audit | +| SSE loses or duplicates events | Monotonic cursor replay, ID deduplication, gap warning, polling fallback | +| Templates or charts fabricate completeness | Preserve partial/unclassified/unpriced caveats and expose raw evidence | +| GUI introduces vendor coupling | Extend `EXECUTION_PATH` genericity tests; adapters only through metadata | +| Packaged install lacks frontend files | Clean-wheel asset and route smoke test | +| Session scope overwhelms the control plane | Deliver it as Milestone 5 after a protocol/threat-model review | +| Terminal or Markdown output executes hostile content | Sanitization, restrictive CSP, bounded PTY protocol, no raw HTML trust | +| Root-path or proxy deployment breaks links/streams | Named routes and explicit prefixed deployment tests | +| Multi-user permissions exist only in presentation | Enforce in shared services and test HTML and JSON paths | +| Backup action damages the active database | WAL-aware snapshot, separate-target rehearsal, integrity gate, explicit cutover | + +## 11. Decisions carried forward + +1. The GUI location is settled by this plan: inside `agent-harness`, independent of + MyDevEnv/AIDevEnv. +2. D5 remains the implementation stack: Jinja + HTMX + SSE, server-rendered, no Node build + in the iteration loop. +3. The JSON API remains public and typed; the GUI is an additional first-party client over + shared application services. +4. Browser authentication uses an opaque session derived from a successful bearer-token + login; it does not expose the bearer token to frontend code. +5. The first releasable GUI is Milestones 0-2. The control-plane v1 is Milestones 0-4. +6. Session/chat/terminal remains accepted but moves to Milestone 5 because it can no longer + reuse a host UI or host session subsystem. +7. D8 remains open. GUI extensions, tools, or MCP registration must not create a third-party + gate registry by accident. +8. D9 remains blocked on issue #84. GUI work must not move the review prompt into a stage + variable to make it editable. +9. Events remain append-only and the audit store remains the source of historical truth. +10. A restart never resumes execution without an explicit human action. diff --git a/README.md b/README.md index 6498a69..0856275 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ the record of what happened. ## Status: pre-alpha — deterministic paths are tested; real use is observed, not proven -It runs, it is deployed inside [AIDevEnv](https://github.com/TheDancingDeveloper-org/aidevenv), +It runs as a standalone service; [AIDevEnv](https://github.com/TheDancingDeveloper-org/aidevenv) +is an optional reference session host, and direct execution has been driven end to end against a real git repository with a scripted model. A first supervised NGMS attempt and later direct calls used real agents and providers, and exposed defects; the surviving evidence is reconstructed in @@ -67,7 +68,7 @@ blocked on runs this repository cannot perform on its own. | `protocols` | What a route is made of: wire protocol, auth, response reader, classifier — resolved by name | | `model_client` | Routes roles to models; per-worker jittered retry; per-endpoint parking | | `store` / `ingest` / `sources` | Append-only SQLite event store, idempotent ingest | -| `api` | Documented HTTP API + Swagger. No GUI — the session host renders it | +| `api` / `ui` | Typed JSON API, Swagger, and the self-contained browser control plane | | `adapters` | Opt-in readers for other tools' logs | ### Two ideas worth stealing @@ -296,10 +297,12 @@ name; no core module changes, and nothing in core imports it. See ### The API -There is **no GUI here on purpose.** The session host already owns tabs, auth, -push notifications, mobile and the terminal sessions agents run in; a second -web UI would mean a second URL and a second login to do the same job worse. -The harness serves JSON and the host renders it. +`agent-harness serve` exposes both this API and the responsive browser control +plane from one process and origin. The GUI is packaged here and needs no +MyDevEnv, AIDevEnv, host proxy, CDN or separate frontend service. An optional +session host remains one way to execute agents; it is not a browser dependency. +The harness serves HTML and JSON from one origin. The API remains independently +usable by CLI and generated clients. What it does own is a **documented API**: every route typed, every field described, and the schema served next to it. @@ -360,8 +363,9 @@ being kept. A fleet running unaudited looks exactly like one running audited. | `/redoc` | ReDoc | | `/openapi.json` | the schema — generate a client from it | -Auth is a bearer token, and inside a session host it is the **same token that -reaches the GUI**: one credential, one thing to rotate. +API clients use the bearer token. A browser submits it once at `/login` and +receives a bounded, opaque, HttpOnly server-side session; the bearer credential +is not exposed to frontend code or browser storage. ```bash curl -H "Authorization: Bearer $TOKEN" localhost:8099/api/work @@ -372,8 +376,8 @@ curl -H "Authorization: Bearer $TOKEN" -X POST localhost:8099/api/work/T4/retry Behind a proxy, pass `--root-path /api/harness` so the schema advertises URLs a client can actually call. -[AIDevEnv](https://github.com/TheDancingDeveloper-org/aidevenv) is the -reference host and ships a Work tab that consumes this API. +Open the same URL in a browser to use the packaged control plane. A reverse +proxy may add TLS and a path prefix, but no host application is required. ```bash uv sync --all-extras diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c650484..e17394a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,7 +1,8 @@ # Architecture -How AIDevEnv and agent-harness fit together, and what happens inside the -harness when work runs. +How the agent-harness JSON API, browser control plane and execution adapters fit +together, and what happens inside the harness when work runs. The GUI is owned, +packaged and served by this repository; MyDevEnv and AIDevEnv are not required. Every diagram here describes what the code does today. Where something is planned but not built, it says so. @@ -10,9 +11,10 @@ planned but not built, it says so. ## 1. The whole system -Two processes, one pod. AIDevEnv owns the browser, the auth and the terminal -sessions; the harness owns the queue and the record of what happened. Neither -reaches into the other's storage. +One service, one origin. agent-harness owns the browser, authentication, queue, +audit history and JSON API. An optional session host owns PTY processes only; +the harness reaches it through a generic execution protocol and remains usable +in monitoring-only mode without it. ```mermaid graph TB @@ -20,15 +22,13 @@ graph TB browser["Browser / PWA
phone, tablet, laptop"] end - subgraph pod["AIDevEnv pod (Node B)"] + subgraph pod["agent-harness process"] direction TB - server["AIDevEnv server
Rust + Axum
:8910"] + server["FastAPI UI + JSON API
:8099"] pty["PTY sessions
claude · codex · opencode"] harness["agent-harness
FastAPI
127.0.0.1:8099"] - server -->|"spawns, owns"| pty - server -->|"proxies /api/harness/*
adds the token"| harness - harness -->|"create session,
wait for exit"| server + server -->|"optional executor adapter"| pty end subgraph state["/var/lib/aidevenv — bind mount"] @@ -41,7 +41,7 @@ graph TB models["Model providers"] end - browser -->|"HTTPS + bearer token"| server + browser -->|"HTTPS + opaque browser session"| server harness --> queue harness --> audit harness -->|"sync plan, open PRs"| github @@ -56,10 +56,11 @@ graph TB class queue throwaway ``` -**Why the harness has no UI of its own.** The session host already owns tabs, -authentication, push notifications, mobile layout and the terminals agents run -in. A second web UI would mean a second URL and a second login to do the same -job worse, so the harness serves JSON and AIDevEnv renders it. +**Why the browser lives here.** The operator needs one URL, one source of truth +and one security boundary. Server-rendered templates and packaged assets are a +first-party client over shared typed query/command services; templates never +read SQLite or bypass gates. An optional session host can still provide a PTY, +but it does not provide the GUI, browser auth, proxy or deployment topology. **Why two databases.** The queue is mutable, gets migrated in place, and is a reasonable thing to delete and rebuild from the plan. Anything sharing that @@ -77,9 +78,9 @@ on. sequenceDiagram autonumber participant You - participant GUI as AIDevEnv Work tab + participant GUI as agent-harness browser GUI participant H as agent-harness - participant S as Session host + participant S as optional session host adapter participant A as claude / codex participant G as GitHub @@ -206,7 +207,7 @@ back from it. | `audit` | Append-only history, its own database, no mutation surface | | `maintenance` | Rolls up complete days, then thins what is covered | | `reconcile` | Merged / closed / reverted, fetched from GitHub | -| `api` | Documented HTTP API + Swagger. No GUI — the session host renders it | +| `api` / `ui` | Typed HTTP API + Swagger and packaged server-rendered GUI | --- diff --git a/docs/COORDINATION-PLANE.md b/docs/COORDINATION-PLANE.md index c2919a6..dbc7f5a 100644 --- a/docs/COORDINATION-PLANE.md +++ b/docs/COORDINATION-PLANE.md @@ -196,8 +196,9 @@ The API should support cursor-based reads and long polling initially. A later streaming transport can improve latency without changing the message model. All routes require explicit response models and documented fields. -The rendered room belongs in the session host, such as AIDevEnv. This -repository continues to serve JSON and does not add a second GUI. +The coordination data remains a typed JSON contract. The packaged browser GUI in this +repository may render that contract directly; a session host such as AIDevEnv remains an +optional execution adapter, not a GUI dependency. ### 5.1 Do not inject arbitrary text into terminals @@ -437,7 +438,8 @@ The coordination work is not complete until tests prove that: - the oversight model cannot directly mutate storage or write to GitHub; - corrections and access restrictions preserve the original message; - the API exposes named, described schemas for every route and field; -- the session host can render rooms without this repository adding a GUI. +- the browser GUI and JSON API can render the same coordination state without requiring a + session host; a host may still provide optional execution terminals. This framework has not run against a real fleet. The coordination plane must be described and measured accordingly rather than presented as proven before diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e2672c7..91f376d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,8 +1,8 @@ # Deploying `agent-harness serve` -The contract between whatever starts this process — systemd, a compose file, -a Kubernetes manifest, a session host's supervisor — and what the service can -then actually do. +The contract between whatever starts this process — systemd, a compose file or +a Kubernetes manifest — and what the service can then actually do. The JSON API +and browser GUI are served directly; no host application is required. There are **two supported modes**, and the difference is deliberate rather than a degraded state: @@ -15,7 +15,7 @@ than a degraded state: | Who it is for | a dashboard over someone else's harness | the deployment that does the work | The trap this document exists to close: **a monitoring-only process is -healthy.** `/healthz` returns `ok`, the API answers, the Work tab renders a +healthy.** `/healthz` returns `ok`, the API answers, the GUI renders a backlog — and nothing can execute a single item. A process manager started with only the monitoring arguments produces exactly that, and nothing about it looks wrong until someone presses start. diff --git a/docs/MULTI-PROJECT-PLAN.md b/docs/MULTI-PROJECT-PLAN.md index 9f53005..e8d7539 100644 --- a/docs/MULTI-PROJECT-PLAN.md +++ b/docs/MULTI-PROJECT-PLAN.md @@ -7,7 +7,7 @@ Status: written 2026-08-02. **Partly implemented — updated 2026-08-04.** | §2 Phase 0 — correctness, unattended running | **Largely built.** Leases, per-project control, preflight, the reaper and the audit layer are live. Unattended running itself is **unproven** — no long run has happened. | | §3 Phase 1 — project as a scope | **Built.** Projects are first-class, with their own checkout, checks, roles, budgets and control state. | | §4 Phase 2 — separate streams, explicitly resumed | **Built.** One worker pool per project; a project starts `stopped` and only a human starts it. | -| §5 Phase 3 — the GUI worth having | **Not built**, and not built *here* — the GUI belongs to the session host, per `AGENTS.md`. | +| §5 Phase 3 — the GUI worth having | **Superseded by `GUI_PLAN.md`.** The GUI is built and packaged here; delivery is in progress. | | §6 Phase 4 — project inception | **Built**, over the API only: `src/agent_harness/inception.py`, worked example in [`USAGE.md`](USAGE.md) §0b. There is no `agent-harness inception` subcommand. | | §7 Phase 5 — operational depth | **Partly built.** Per-item budgets, resumable attempts, a typed outcome taxonomy and durable holds landed in 2026-08; telemetry export is opt-in and has never reached a collector. | @@ -439,5 +439,6 @@ Per-project attempt and cost metrics, baseline comparison, failure triage. project the scoper has just finished initialising. - **No unattended repo creation.** Inception proposes; a human approves before anything is created. -- **No second web UI.** The session host keeps owning tabs, auth and - terminals. The harness serves JSON. +- **One deployable UI.** The harness owns its browser application and JSON API + in one process. A session host may own execution terminals but is not a GUI, + authentication, routing or deployment dependency. diff --git a/docs/USAGE.md b/docs/USAGE.md index 9a612d1..e293249 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -16,8 +16,8 @@ pip install git+https://github.com/TheDancingDeveloper-org/agent-harness agent-harness --help ``` -Inside [AIDevEnv](https://github.com/TheDancingDeveloper-org/aidevenv) it is -already there, already running, and already behind the Work tab. +When `agent-harness serve` is running, open its own URL for the packaged GUI. +No MyDevEnv, AIDevEnv or other host process is required for browser access. --- @@ -782,7 +782,7 @@ claim ──▶ git worktree on the item's base `AIDEVENV_TOKEN` authenticates to the session host; `HARNESS_API_KEY` authenticates the reviewer's model calls. -### Without one — headless +### Without one — direct execution Omit `--session-host` and the harness calls the model API directly, doing the implementing itself. Fully deterministic, and there is nothing to attach to: @@ -848,7 +848,7 @@ Commit or stash it, or pass --allow-dirty if it is genuinely disposable. overrides it — loudly, and recorded in the preflight report, because the whole point is that the loss is silent and irreversible. -One consequence of working in place: **one worker per checkout**. Two headless +One consequence of working in place: **one worker per checkout**. Two direct workers on one directory would check branches out over each other. ### The role flags are a seed, not a setting @@ -1142,8 +1142,9 @@ environment must hold and a non-destructive post-deploy smoke test, is in ## 5. Drive it from the API -The harness serves a full OpenAPI document with Swagger UI. Inside a session -host, the token that reaches the GUI reaches this too. +The harness serves a full OpenAPI document with Swagger UI and its browser +control plane. API clients use the bearer token; browsers exchange it at +`/login` for an opaque HttpOnly session. ```bash # Directly diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md new file mode 100644 index 0000000..1ceada1 --- /dev/null +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -0,0 +1,63 @@ +# GUI Milestones 0–1 evidence + +Status: implementation slice complete; the full GUI program is not complete. + +## Build under test + +- Branch: `codex/gui-plan` +- Worktree: `/home/sprooty/Working/Active/apps/agent-harness-worktrees/gui-plan` +- Plan: `GUI_PLAN.md`, SHA-256 `b8a5aa97c79dafdcb90502903df82445d7bdc274eb8c81a0deb6bccd7a68bb3d` +- Python: CPython 3.14.4; package installed with `uv sync --all-extras` +- Temporary test volume: `/tmp` (separate ext4 filesystem, 361 GiB free at baseline) + +## Commands and results + +The pre-change baseline was recorded before feature edits: + +| Command | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run pytest -q` | passed, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed | +| `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run mypy` | passed, 106 source files | + +An earlier concurrent setup attempt and an earlier `/dev/shm` pytest attempt are +not evidence: the former raced virtualenv creation and the latter filled the +64 MiB tmpfs. They are retained here only to explain why the valid baseline uses +`/tmp`. + +## Implemented and exercised + +- Policy/documentation alignment removes the old “GUI belongs to a host” ruling + and records the one-origin, no-host dependency boundary. +- Packaged Jinja templates, repository-owned CSS, vendored HTMX 2.0.9 + (`htmx.min.js` SHA-256 `57d9191515339922bd1356d7b2d80b1ee3b29f1b3a2c65a078bb8b2e8fd9ae5f`) + and plain browser JavaScript are served from `/assets`. +- `/` redirects to `/login` or `/projects`; `/projects`, `/work`, item detail, + `/holds`, `/events`, `/analytics`, `/plans`, `/sessions` and `/settings` are + authenticated HTML pages. +- Browser login exchanges the configured bearer token for an opaque bounded + `HttpOnly`, `SameSite=Strict` cookie. Login failures are rate-limited; restart + and logout revoke sessions. State-changing browser requests require CSRF and + same-origin checks. +- Monitoring-only mode is visible in the shell and does not expose controls that + would fail later. No UI route mutates queue or gate state in this slice. +- Typed item evidence exposes append-only events, durable attempt stages and + retained holds without fabricating absent history or cost. +- `/api/events/stream` resumes after a monotonic cursor and surfaces disconnects; + the existing `/api/events` cursor contract remains unchanged. +- Security headers include CSP, frame-ancestor denial, MIME sniffing and + referrer/permissions policy. User/model text is escaped by Jinja defaults. + +Focused browser/API journeys and the wheel packaging checks pass in-process via `TestClient`, including auth, +cookie flags, CSRF, no-token fail-closed behavior, XSS escaping, packaged asset +delivery, JSON/API isolation, evidence and cursor validation. + +## Not yet exercised or complete + +Milestone 1 is not a release claim for the full `GUI_PLAN`: browser automation, +screen-reader checks, forced reconnect with replayed events, and all additional +accessibility/security/concurrency journeys remain to run. Milestones 2–8 +(mutations, plan/adoption wizards, routing/operations panels, internal sessions, +extensions, automation, RBAC and recovery) remain explicitly incomplete. No +real fleet or external deployment was used. diff --git a/pyproject.toml b/pyproject.toml index 37e7caf..3b47a47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,8 @@ [project] name = "agent-harness" version = "0.0.0" -description = "Service-hosted, GUI-driven harness for role-routed coding-agent fleets" +description = "Service-hosted, self-contained browser GUI and JSON harness for role-routed coding-agent fleets" +license = {text = "MIT"} readme = "README.md" requires-python = ">=3.12" dependencies = [ @@ -51,6 +52,7 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/agent_harness"] +include = ["src/agent_harness/templates/**", "src/agent_harness/static/**"] [tool.ruff] line-length = 100 diff --git a/src/agent_harness/__main__.py b/src/agent_harness/__main__.py index 613dbd4..f1255ef 100644 --- a/src/agent_harness/__main__.py +++ b/src/agent_harness/__main__.py @@ -667,7 +667,7 @@ def live_routes() -> dict[str, Chain]: ) return 2 - # Before anything claims, and before the first model call: a headless run + # Before anything claims, and before the first model call: a direct run # works in place and begins each attempt by discarding the working tree, so # a dirty checkout is uncommitted work about to be destroyed. Refusing is # the only safe default; --allow-dirty is how you say it is disposable. @@ -1454,7 +1454,7 @@ def main(argv: list[str] | None = None) -> int: "--allow-dirty", action="store_true", help="run against a checkout that has uncommitted or untracked files. A " - "headless run works IN PLACE and discards the working tree before each " + "direct run works IN PLACE and discards the working tree before each " "attempt — tracked changes are reverted and untracked files are deleted, " "neither recoverably — so a dirty checkout is refused by default. Pass " "this only when the tree is genuinely disposable.", @@ -1488,7 +1488,7 @@ def main(argv: list[str] | None = None) -> int: ) p_serve = sub.add_parser( - "serve", help="serve the JSON API (headless — the GUI is the session host's)" + "serve", help="serve the JSON API and self-contained browser control plane" ) p_serve.add_argument( "--audit-db", @@ -1503,11 +1503,9 @@ def main(argv: list[str] | None = None) -> int: "--session-host", default=os.environ.get("AIDEVENV_URL", ""), metavar="URL", - help="base URL of a session host. WITH this, the API can start work: a " - "worker pool is attached and each agent runs as a terminal session you can " - "attach to. WITHOUT it the service is monitoring-only — every read works " - "and starting a project is refused, because starting would mark it running " - "with nothing able to claim.", + help="optional execution adapter for terminal sessions. WITH this, the " + "service can start work. WITHOUT it the packaged GUI and every read work " + "in monitoring-only mode, while starting is refused because nothing can claim.", ) p_serve.add_argument( "--agent", @@ -1560,7 +1558,8 @@ def main(argv: list[str] | None = None) -> int: default=os.environ.get("HARNESS_ROOT_PATH", ""), metavar="PREFIX", help="prefix this service is reached under when behind a proxy, e.g. " - "/api/harness. Without it, Swagger UI tells clients to call URLs that 404.", + "/harness. Without it, browser links and OpenAPI advertise URLs clients " + "cannot call.", ) args = parser.parse_args(argv) diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index 5b75b35..e15c7b6 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -1,20 +1,14 @@ -"""The harness's HTTP API. Headless — no HTML, no templates, no GUI. +"""The harness's JSON API and self-contained browser application. -The GUI lives in the session host (AIDevEnv is the reference one), which -already owns tabs, auth, push notifications, mobile and the terminal sessions -the agents run in. A second web UI here would mean a second URL and a second -login to do the same job worse. +The public API remains typed and documented. Every JSON route names a response +model, every field has a description, and OpenAPI is served alongside Swagger +UI. The browser application is an additional first-party client in this same +process and origin; it does not replace or weaken the JSON contract. -What this DOES own is a documented API. Every route is typed, every field has -a description, and the OpenAPI document is served alongside Swagger UI — so a -person with `curl`, an agent with a shell, or a generated client can all drive -the harness without reading its source. - -Auth is a bearer token. Deployed inside a session host it is the SAME token -that reaches the GUI: one credential, one thing to rotate, and no second -secret to keep track of. The service fails closed — with no token configured -every authenticated route refuses, because coming up open is not an acceptable -default for something reachable over a network. +API clients authenticate with the configured bearer token. A browser exchanges +that token once for a bounded opaque server-side session, so the credential is +never placed in a URL, rendered page, script, browser storage or log. With no +configured token, both surfaces fail closed. /docs Swagger UI, with an Authorize button /redoc ReDoc @@ -105,10 +99,12 @@ StopProjectRequest, Summary, WaitingItem, + WorkEvidence, WorkItem, WorkList, ) from .store import EventStore +from .ui import install_ui from .work import ( BLOCKED, CLAIMED, @@ -141,9 +137,10 @@ Plans work, claims it, runs it as an agent in a terminal session, and records what happened. -**Auth** — every route except `/healthz` needs `Authorization: Bearer `. -Deployed inside a session host this is the same token that reaches the GUI. -Use **Authorize** above to try these against a live instance. +**Auth** — every JSON data route needs `Authorization: Bearer `. +`/healthz`, `/docs`, `/redoc` and `/openapi.json` remain public. Use +**Authorize** above to try the API against a live instance. Browser sessions +are separate opaque credentials established at `/login`. **Reading the numbers.** Two things this API is careful about, because both are easy to get wrong and expensive when you do: @@ -336,6 +333,30 @@ def work_item( queue.now(), ) + @app.get( + "/api/work/{item_id}/evidence", + tags=["work"], + summary="Durable evidence for one item", + response_model=WorkEvidence, + responses={404: {"description": "No such item"}}, + ) + def work_evidence( + item_id: str = PathParam(description="Plan id, e.g. `T4`."), + project_id: str = Query("default", description="Which project the item is in."), + _: None = Depends(require_token), + ) -> WorkEvidence: + from .query_service import HarnessQueries + + evidence = HarnessQueries( + store, + app.state.queue, + audit=app.state.audit, + fleet=app.state.fleet, + ).evidence(project_id, item_id) + if evidence is None: + raise HTTPException(status_code=404, detail=f"no item {item_id!r}") + return evidence + @app.post( "/api/work", tags=["work"], @@ -1455,6 +1476,42 @@ def stop_project( queue.set_control(STOPPED, reason=reason, project_id=project_id) return _project_summary(queue, project_id, app.state.fleet) + @app.post( + "/api/projects/{project_id}/control", + tags=["control"], + summary="Pause or drain one project", + response_model=ProjectSummary, + responses={409: {"description": "Resume requires the explicit start contract"}}, + ) + def project_control( + request: SetFleetControl, + project_id: str = PathParam(description="Project id."), + _: None = Depends(require_token), + ) -> ProjectSummary: + """Change a project's claiming state without starting work implicitly. + + `running` is intentionally not accepted here: continuing execution is + the expensive, preflight-gated start contract above. Pause, drain and + stop remain safe next-boundary controls and are also available to a + monitoring deployment, where they only change durable operator intent. + """ + if request.state == "running": + raise HTTPException( + status_code=409, + detail="resume uses POST /api/projects/{project_id}/start so preflight is explicit", + ) + queue = need_queue() + if queue.get_project(project_id) is None: + raise HTTPException(status_code=404, detail=f"no project {project_id!r}") + fleet_ = app.state.fleet + if fleet_ is not None and request.state == STOPPED and hasattr(fleet_, "request_stop"): + fleet_.request_stop(project_id, reason=request.reason) + elif fleet_ is not None and request.state == STOPPED: + fleet_.stop(project_id, reason=request.reason) + else: + queue.set_control(request.state, request.reason, project_id=project_id) + return _project_summary(queue, project_id, app.state.fleet) + @app.post( "/api/control", tags=["control"], @@ -1758,6 +1815,27 @@ def summary(_: None = Depends(require_token)) -> Summary: ], ) + @app.middleware("http") + async def security_headers(request: Request, call_next: Any) -> Any: + response = await call_next(request) + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; script-src 'self'; " + "style-src 'self'; connect-src 'self'; img-src 'self' data:; " + "font-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; " + "form-action 'self'", + ) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault( + "Permissions-Policy", "camera=(), microphone=(), geolocation=()" + ) + if request.url.path.startswith("/assets/"): + response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable") + return response + + install_ui(app) return app @@ -2059,6 +2137,7 @@ def _item_model( ) -> WorkItem: now = time.time() if now is None else now return WorkItem( + project_id=record.project_id, hold=HoldView(**hold.as_dict(now)) if hold is not None else None, item_id=record.item_id, title=record.title, diff --git a/src/agent_harness/audit.py b/src/agent_harness/audit.py index da74c86..0314489 100644 --- a/src/agent_harness/audit.py +++ b/src/agent_harness/audit.py @@ -504,6 +504,18 @@ def since_id(self, event_id: int, limit: int = 200) -> list[dict[str, Any]]: ) ] + def item_events(self, project_id: str, item_id: str, limit: int = 1000) -> list[dict[str, Any]]: + """Retained history for one item, oldest first.""" + if self.degraded: + return [] + return [ + dict(row) + for row in self._connect().execute( + "SELECT * FROM events WHERE project_id = ? AND item_id = ? ORDER BY id LIMIT ?", + (project_id, item_id, limit), + ) + ] + def max_id(self) -> int: if self.degraded: return 0 diff --git a/src/agent_harness/browser_session.py b/src/agent_harness/browser_session.py new file mode 100644 index 0000000..4136615 --- /dev/null +++ b/src/agent_harness/browser_session.py @@ -0,0 +1,107 @@ +"""Opaque browser sessions and CSRF protection for the first-party GUI.""" + +from __future__ import annotations + +import secrets +import threading +import time +from collections import defaultdict, deque +from dataclasses import dataclass + +from fastapi import HTTPException, Request + + +@dataclass(frozen=True) +class BrowserSession: + session_id: str + csrf_token: str + operator: str + expires_at: float + + +class BrowserSessions: + """Process-local bounded sessions. + + The service's configured bearer token is the credential exchange input, not + a browser credential. Restarting this process revokes all sessions by + design; deployments needing continuity can add a protected server-side + store later without changing the browser contract. + """ + + def __init__(self, *, ttl_seconds: int = 8 * 60 * 60, max_sessions: int = 256) -> None: + if ttl_seconds <= 0 or max_sessions <= 0: + raise ValueError("session limits must be positive") + self.ttl_seconds = ttl_seconds + self.max_sessions = max_sessions + self._sessions: dict[str, BrowserSession] = {} + self._login_failures: dict[str, deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + + def allow_login(self, client_key: str, *, now: float | None = None) -> bool: + moment = time.time() if now is None else now + with self._lock: + failures = self._login_failures[client_key] + while failures and failures[0] <= moment - 60: + failures.popleft() + return len(failures) < 5 + + def record_login_failure(self, client_key: str, *, now: float | None = None) -> None: + moment = time.time() if now is None else now + with self._lock: + failures = self._login_failures[client_key] + while failures and failures[0] <= moment - 60: + failures.popleft() + failures.append(moment) + + def create(self, operator: str = "operator", *, now: float | None = None) -> BrowserSession: + moment = time.time() if now is None else now + session = BrowserSession( + session_id=secrets.token_urlsafe(32), + csrf_token=secrets.token_urlsafe(32), + operator=operator, + expires_at=moment + self.ttl_seconds, + ) + with self._lock: + self._purge(moment) + if len(self._sessions) >= self.max_sessions: + oldest = min(self._sessions.values(), key=lambda item: item.expires_at) + self._sessions.pop(oldest.session_id, None) + self._sessions[session.session_id] = session + return session + + def get(self, session_id: str | None, *, now: float | None = None) -> BrowserSession | None: + if not session_id: + return None + moment = time.time() if now is None else now + with self._lock: + self._purge(moment) + return self._sessions.get(session_id) + + def revoke(self, session_id: str | None) -> None: + if session_id: + with self._lock: + self._sessions.pop(session_id, None) + + def require(self, request: Request) -> BrowserSession: + session = self.get(request.cookies.get("harness_session")) + if session is None: + raise HTTPException(status_code=401, detail="browser session required") + return session + + def require_csrf( + self, request: Request, session: BrowserSession, submitted: str | None = None + ) -> None: + # Form parsing belongs to the action route; this method checks the + # header only so a missing token cannot be confused with an empty form. + token = request.headers.get("X-CSRF-Token", "") or (submitted or "") + origin = request.headers.get("Origin") or request.headers.get("Referer", "") + host = str(request.base_url).rstrip("/") + if not token or not secrets.compare_digest(token, session.csrf_token): + raise HTTPException(status_code=403, detail="invalid CSRF token") + if origin and not origin.startswith(host): + raise HTTPException(status_code=403, detail="request origin is not this service") + + def _purge(self, now: float) -> None: + expired = [key for key, value in self._sessions.items() if value.expires_at <= now] + for key in expired: + self._sessions.pop(key, None) diff --git a/src/agent_harness/holds.py b/src/agent_harness/holds.py index 56b8eb2..f4100ef 100644 --- a/src/agent_harness/holds.py +++ b/src/agent_harness/holds.py @@ -156,6 +156,7 @@ def remaining(self, now: float) -> float: def as_dict(self, now: float | None = None) -> dict[str, Any]: at = now if now is not None else time.time() return { + "project_id": self.project_id, "item_id": self.item_id, "attempt": self.attempt, "state": self.state, diff --git a/src/agent_harness/query_service.py b/src/agent_harness/query_service.py new file mode 100644 index 0000000..929e5bb --- /dev/null +++ b/src/agent_harness/query_service.py @@ -0,0 +1,301 @@ +"""Typed read services shared by JSON and browser controllers. + +Presentation code must not know which SQLite file owns a fact. This module is +the narrow read boundary over the event, audit and queue stores; it returns the +same Pydantic models the public API publishes, so HTML cannot quietly invent a +second interpretation of project or work state. +""" + +from __future__ import annotations + +import json +import time +from typing import Any + +from .schemas import ( + AttemptStageEvidence, + Event, + EventPage, + FleetControl, + HoldList, + HoldView, + LatestEvent, + ProjectList, + ProjectSpec, + ProjectSummary, + RoleRoute, + WorkEvidence, + WorkItem, + WorkList, +) +from .store import EventStore +from .work import BLOCKED, CLAIMED, DRAINING, HELD, WorkQueue, WorkRecord + + +class HarnessQueries: + """Read the control plane without exposing storage layout to controllers.""" + + def __init__( + self, + store: EventStore, + queue: WorkQueue | None, + *, + audit: Any | None = None, + fleet: Any | None = None, + ) -> None: + self.store = store + self.queue = queue + self.audit = audit + self.fleet = fleet + + def projects(self) -> ProjectList: + if self.queue is None: + return ProjectList(projects=[]) + return ProjectList( + projects=[ + summary + for project in self.queue.projects() + if (summary := self.project(project.project_id)) is not None + ] + ) + + def project(self, project_id: str) -> ProjectSummary | None: + queue = self.queue + if queue is None: + return None + project = queue.get_project(project_id) + if project is None: + return None + state, reason, previous = queue.control_detail(project_id) + worker_health = self._worker_health(project_id) + return ProjectSummary( + project=ProjectSpec( + project_id=project.project_id, + name=project.name, + repo=project.repo, + work_dir=project.work_dir, + base_branch=project.base_branch, + checks=list(project.checks), + fixes={k: list(v) for k, v in (project.fixes or {}).items()}, + durability=project.durability, + max_item_seconds=project.max_item_seconds, + max_item_spend_usd=project.max_item_spend_usd, + plan_path=project.plan_path, + roles=( + {name: RoleRoute(**route) for name, route in project.roles.items()} + if project.roles + else None + ), + max_workers=project.max_workers, + max_attempts=project.max_attempts, + min_free_disk_gb=project.min_free_disk_gb, + ), + counts=queue.counts(project_id=project_id), + control=FleetControl(state=state, reason=reason), + previous_state=previous, + stale=len(queue.stale(project_id=project_id)), + workers=(self.fleet.running().get(project_id, 0) if self.fleet is not None else 0), + draining_items=( + [ + item.item_id + for item in queue.items(project_id=project_id) + if item.state == CLAIMED + ] + if state == DRAINING + else [] + ), + **worker_health, + ) + + def work(self, project_id: str | None = None) -> WorkList: + queue = self.queue + if queue is None: + return WorkList( + configured=False, + reason="no work queue is attached to this harness", + ) + latest = self._latest_by_item(project_id) + return WorkList( + configured=True, + counts=queue.counts(project_id=project_id), + stale=[record.item_id for record in queue.stale(project_id=project_id)], + items=[ + self._item_model( + record, + latest.get(record.item_id), + queue.holds.current(record.project_id, record.item_id) + if record.state == HELD + else None, + queue.now(), + ) + for record in queue.items(project_id=project_id) + ], + ) + + def item(self, project_id: str, item_id: str) -> WorkItem | None: + queue = self.queue + if queue is None: + return None + record = queue.get(item_id, project_id=project_id) + if record is None: + return None + return self._item_model( + record, + self._latest_by_item(project_id).get(item_id), + queue.holds.current(project_id, item_id) if record.state == HELD else None, + queue.now(), + ) + + def holds(self, project_id: str | None = None) -> HoldList: + queue = self.queue + if queue is None: + return HoldList() + queue.expire_holds() + now = queue.now() + return HoldList( + open=[HoldView(**hold.as_dict(now)) for hold in queue.holds.open_holds(project_id)] + ) + + def events(self, since_id: int = 0, limit: int = 200) -> EventPage: + rows = self.store.since_id(since_id, limit=limit) + return EventPage( + events=[Event(**row) for row in rows], + cursor=rows[-1]["id"] if rows else since_id, + ) + + def live_events(self, since_id: int = 0, limit: int = 200) -> EventPage: + """The event source the running deployment actually writes. + + Under supervised `serve`, the audit store is the live sink. A plain + ingest-and-serve deployment has only the legacy event store. The + fallback is explicit and preserves each store's monotonic cursor. + """ + if self.audit is None: + return self.events(since_id, limit) + rows = self.audit.since_id(since_id, limit=limit) + return EventPage( + events=[Event(**self._audit_event_fields(row)) for row in rows], + cursor=rows[-1]["id"] if rows else since_id, + ) + + def evidence(self, project_id: str, item_id: str) -> WorkEvidence | None: + queue = self.queue + if queue is None or queue.get(item_id, project_id=project_id) is None: + return None + if self.audit is not None: + event_rows = self.audit.item_events(project_id, item_id) + events = [Event(**self._audit_event_fields(row)) for row in event_rows] + else: + events = [Event(**row) for row in self.store.item_events(project_id, item_id)] + stages = [ + AttemptStageEvidence( + attempt=attempt, + stage=stage.stage, + admitted_revision=stage.admitted_revision, + mode=stage.mode, + recorded_at=stage.recorded_at, + artefact=dict(stage.artefact), + ) + for attempt, stage in queue.attempts_log.history(project_id, item_id) + ] + holds = [ + HoldView(**hold.as_dict(queue.now())) + for hold in queue.holds.history(project_id, item_id) + ] + return WorkEvidence( + project_id=project_id, + item_id=item_id, + events=events, + stages=stages, + holds=holds, + ) + + def _latest_by_item(self, project_id: str | None = None) -> dict[str, dict[str, Any]]: + if self.audit is not None: + rows = self.audit.latest_by_item(project_id=project_id) + if rows: + return { + item_id: {**row, "data": json.loads(row.get("data") or "{}")} + for item_id, row in rows.items() + } + latest: dict[str, dict[str, Any]] = {} + for event in self.store.recent(kind="work", limit=2000): + data = event["data"] + item_id = data.get("item_id") + if project_id is not None and data.get("project_id") not in (None, project_id): + continue + if item_id and item_id not in latest: + latest[item_id] = event + return latest + + def _worker_health(self, project_id: str) -> dict[str, Any]: + if self.fleet is None or not hasattr(self.fleet, "failures"): + return {} + failures = self.fleet.failures(project_id) + return { + "worker_failures": len(failures), + "last_worker_error": failures[-1].error if failures else None, + } + + @staticmethod + def _audit_event_fields(row: dict[str, Any]) -> dict[str, Any]: + return { + "id": row["id"], + "ts": row["ts"], + "kind": row["kind"], + "source": row["source"], + "worker": row["worker"], + "role": row["role"], + "model": row["model"], + "endpoint": row["endpoint"], + "outcome": row["outcome"], + "error_class": row["error_class"], + "latency_s": row["latency_s"], + "data": json.loads(row["data"] or "{}"), + } + + @staticmethod + def _item_model( + record: WorkRecord, + event: dict[str, Any] | None, + hold: Any | None = None, + now: float | None = None, + ) -> WorkItem: + now = time.time() if now is None else now + return WorkItem( + project_id=record.project_id, + hold=HoldView(**hold.as_dict(now)) if hold is not None else None, + item_id=record.item_id, + title=record.title, + brief=record.brief, + issue=record.issue, + depends_on=list(record.depends_on), + state=record.state, + owner=record.owner, + lease_until=record.lease_until, + attempts=record.attempts, + last_error=record.last_error, + blocked_reason=record.last_error if record.state == BLOCKED else None, + budget_seconds=record.budget_seconds, + budget_spend_usd=record.budget_spend_usd, + spend_usd=record.spend_usd, + unpriced_calls=record.unpriced_calls, + first_started_at=record.first_started_at, + held_until=record.held_until, + disposition=record.disposition, + reason_kind=record.reason_kind, + branch=record.branch, + pr_url=record.pr_url, + updated_at=record.updated_at, + latest=( + LatestEvent( + outcome=event["outcome"] or "", + detail=event["data"].get("detail"), + ts=event["ts"], + session_id=event["data"].get("session_id"), + session_url=event["data"].get("session_url"), + ) + if event + else None + ), + ) diff --git a/src/agent_harness/schemas.py b/src/agent_harness/schemas.py index 7cb5cb0..da3c832 100644 --- a/src/agent_harness/schemas.py +++ b/src/agent_harness/schemas.py @@ -43,6 +43,7 @@ class LatestEvent(BaseModel): class WorkItem(BaseModel): + project_id: str = Field(description="Project scope of this item.") item_id: str = Field(description="Stable id from the plan, e.g. `T4`.") title: str brief: str = Field(description="The full specification given to the agent.") @@ -154,6 +155,12 @@ class WorkList(BaseModel): class HoldView(BaseModel): """A question an item is waiting on, and how long it has been waiting.""" + project_id: str = Field( + description="Project containing the held item. Together with `item_id`, this " + "is the stable identity used by detail and answer routes." + ) + item_id: str = Field(description="Plan id of the item waiting for this answer.") + attempt: int = Field(0, description="Attempt that asked the question.") state: str = Field(description="`open`, `answered`, `expired` or `cancelled`.") question: str = Field( description="What is being asked. Never empty — a hold with no " @@ -1300,6 +1307,43 @@ class EventPage(BaseModel): cursor: int = Field(description="Pass as `since_id` next time. Unchanged when empty.") +class AttemptStageEvidence(BaseModel): + """One durable boundary reached by one attempt.""" + + attempt: int = Field(description="One-based attempt number.") + stage: str = Field(description="A member of the executor's fixed stage list.") + admitted_revision: int = Field( + description="Dependency-graph revision against which the attempt was admitted." + ) + mode: str = Field(description="Durability mode that recorded this boundary.") + recorded_at: float = Field(description="Unix timestamp when this boundary was recorded.") + artefact: dict[str, Any] = Field( + default_factory=dict, + description="Retained typed stage data. Missing keys mean the evidence was not " + "recorded; clients must not infer them.", + ) + + +class WorkEvidence(BaseModel): + """Item-scoped history without fabricated gaps.""" + + project_id: str = Field(description="Project containing the item.") + item_id: str = Field(description="Stable plan id.") + events: list[Event] = Field( + default_factory=list, + description="Recorded events for this item, oldest first. Empty means no retained " + "event evidence is available, not that nothing happened.", + ) + stages: list[AttemptStageEvidence] = Field( + default_factory=list, + description="Durable attempt stages, oldest first.", + ) + holds: list[HoldView] = Field( + default_factory=list, + description="Every retained question for the item, including closed questions.", + ) + + # ------------------------------------------------------------------ summary diff --git a/src/agent_harness/static/app.css b/src/agent_harness/static/app.css new file mode 100644 index 0000000..21b5503 --- /dev/null +++ b/src/agent_harness/static/app.css @@ -0,0 +1,91 @@ +:root { + color-scheme: light dark; + --bg: #f5f7fb; + --surface: #fff; + --surface-alt: #edf1f8; + --text: #18202f; + --muted: #5e687a; + --line: #d5dce8; + --accent: #2457d6; + --accent-strong: #163d9d; + --danger: #ad2c38; + --warning: #8a5b00; + --success: #16734a; + --focus: #f29b38; + --radius: 12px; + --shadow: 0 8px 24px rgb(26 38 65 / 8%); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +@media (prefers-color-scheme: dark) { + :root { --bg: #101521; --surface: #182131; --surface-alt: #222d40; --text: #edf3ff; --muted: #aab6ca; --line: #354259; --accent: #91b4ff; --accent-strong: #c2d5ff; --danger: #ff8b94; --warning: #ffd27a; --success: #78deb0; --shadow: 0 8px 24px rgb(0 0 0 / 20%); } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text); line-height: 1.5; } +a { color: var(--accent); } +a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } +button, .button { display: inline-block; border: 1px solid var(--accent); border-radius: 8px; padding: .65rem 1rem; background: var(--accent); color: #fff; cursor: pointer; font: inherit; text-decoration: none; } +button:hover, .button:hover { background: var(--accent-strong); } +button.secondary, .button.secondary { background: transparent; color: var(--accent); } +button.quiet { border-color: transparent; background: transparent; color: var(--muted); padding: .35rem .6rem; } +input { width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: .7rem .8rem; background: var(--surface); color: var(--text); font: inherit; } +label { display: block; margin: .75rem 0 .3rem; font-weight: 650; } +.topbar { display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap; padding: .75rem clamp(1rem, 3vw, 3rem); background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; } +.brand { color: var(--text); font-weight: 800; font-size: 1.05rem; text-decoration: none; letter-spacing: -.02em; } +nav { display: flex; gap: .8rem; flex: 1; flex-wrap: wrap; } +nav a { color: var(--muted); text-decoration: none; font-size: .95rem; } +nav a:hover { color: var(--text); } +.statusline { display: flex; gap: .55rem; align-items: center; padding: .45rem clamp(1rem, 3vw, 3rem); color: var(--muted); background: var(--surface-alt); font-size: .83rem; } +.operator { margin-left: auto; } +.live-dot { width: .55rem; height: .55rem; border-radius: 50%; background: var(--success); } +.page { max-width: 1280px; margin: 0 auto; padding: clamp(1.2rem, 4vw, 3rem) clamp(1rem, 3vw, 3rem) 4rem; } +.page-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.5rem; } +h1, h2, h3 { line-height: 1.15; letter-spacing: -.025em; } +h1 { margin: .15rem 0; font-size: clamp(1.8rem, 4vw, 2.8rem); } +h2 { margin: .2rem 0 .8rem; font-size: 1.25rem; } +h3 { margin-top: 1.4rem; font-size: 1rem; } +.eyebrow { margin: 0; color: var(--muted); text-transform: uppercase; letter-spacing: .11em; font-size: .72rem; font-weight: 750; } +.muted { color: var(--muted); } +.small { font-size: .84rem; } +.mode-badge, .state, .attention { display: inline-block; border-radius: 999px; padding: .25rem .65rem; font-size: .76rem; font-weight: 750; white-space: nowrap; } +.mode-badge { color: var(--warning); background: color-mix(in srgb, var(--warning) 12%, transparent); } +.state { color: var(--muted); background: var(--surface-alt); } +.state-running, .state-done { color: var(--success); background: color-mix(in srgb, var(--success) 13%, transparent); } +.state-failed, .state-exhausted, .state-blocked { color: var(--danger); background: color-mix(in srgb, var(--danger) 13%, transparent); } +.attention { color: var(--warning); background: color-mix(in srgb, var(--warning) 15%, transparent); } +.project-grid, .analytics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 1rem; } +.card, .empty, .login-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: clamp(1rem, 2vw, 1.5rem); } +.empty { text-align: center; padding: 3rem 1.25rem; box-shadow: none; } +.card-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } +.facts, .detail-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .7rem; margin: 1rem 0; } +.facts div { background: var(--surface-alt); border-radius: 8px; padding: .7rem; } +dt { color: var(--muted); font-size: .78rem; } +dd { margin: .1rem 0 0; font-weight: 700; overflow-wrap: anywhere; } +.reason { border-left: 3px solid var(--warning); padding-left: .75rem; } +.alert { border-radius: 8px; padding: .75rem 1rem; } +.alert.error { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } +.filterbar { display: flex; align-items: end; gap: .7rem; margin-bottom: 1.5rem; } +.filterbar label { margin: 0; flex: 1; } +.work-group { margin-bottom: 2rem; } +.work-group h2 { border-bottom: 1px solid var(--line); padding-bottom: .55rem; } +.count { color: var(--muted); font-size: .85rem; } +.list { display: grid; gap: .7rem; } +.work-row { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 1rem; } +.row-main { display: flex; gap: .7rem; align-items: baseline; flex-wrap: wrap; } +.row-main a { text-decoration: none; color: var(--text); } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); font-size: .85rem; } +.row-meta { display: flex; gap: 1rem; flex-wrap: wrap; color: var(--muted); font-size: .82rem; } +.warning { color: var(--warning); } +.detail-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); gap: 1rem; } +.detail-facts { grid-template-columns: max-content 1fr; } +.detail-facts dd { font-weight: 500; } +.prewrap { white-space: pre-wrap; overflow-wrap: anywhere; } +.evidence { margin-top: 1rem; } +.timeline { margin: 0; padding-left: 1.4rem; } +.timeline li { padding: .55rem 0 .55rem .3rem; border-bottom: 1px solid var(--line); } +.timeline .muted { display: block; font-size: .8rem; } +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th, td { border-bottom: 1px solid var(--line); text-align: left; padding: .6rem .35rem; vertical-align: top; } +.login-card { max-width: 430px; margin: 10vh auto; } +.breadcrumb { margin-top: 0; } +@media (max-width: 720px) { .topbar { position: static; } nav { order: 3; flex-basis: 100%; gap: .6rem; } .operator { margin-left: 0; } .detail-grid { grid-template-columns: 1fr; } .filterbar { align-items: stretch; flex-direction: column; } .filterbar button { width: 100%; } .page-heading { flex-direction: column; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } } diff --git a/src/agent_harness/static/app.js b/src/agent_harness/static/app.js new file mode 100644 index 0000000..774cc73 --- /dev/null +++ b/src/agent_harness/static/app.js @@ -0,0 +1,28 @@ +/* First-party browser behavior. Server-rendered HTML remains usable without it. */ +(() => { + const status = (text) => { const node = document.querySelector('#connection-status'); if (node) node.textContent = text; }; + document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('[data-csrf-form]').forEach((form) => form.addEventListener('submit', (event) => { + event.preventDefault(); + const csrf = form.querySelector('input[name="csrf_token"]'); + fetch(form.action, {method: 'POST', headers: {'X-CSRF-Token': csrf ? csrf.value : '', 'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams(new FormData(form))}).then((response) => { + window.location.assign(response.redirected ? response.url : '/login'); + }).catch(() => status('Disconnected — action was not submitted')); + })); + document.querySelectorAll('[sse-connect]').forEach((node) => { + const endpoint = node.getAttribute('sse-connect'); let cursor = Number(new URL(endpoint, window.location.href).searchParams.get('since_id') || 0); let source; + const connect = () => { + source = new EventSource(endpoint + (endpoint.includes('?') ? '&' : '?') + 'since_id=' + cursor); + status('Connected to event stream'); + source.onmessage = (event) => { + if (event.lastEventId) cursor = Number(event.lastEventId); + try { + const data = JSON.parse(event.data); const li = document.createElement('li'); const strong = document.createElement('strong'); strong.textContent = data.outcome || data.kind; li.append(strong, ' ', document.createTextNode(new Date((data.ts || 0) * 1000).toISOString())); if (data.data && data.data.detail) { const p = document.createElement('p'); p.textContent = data.data.detail; li.append(p); } node.prepend(li); + } catch (_) { status('Received an event that could not be displayed'); } + }; + source.onerror = () => { source.close(); status('Disconnected — reconnecting from cursor ' + cursor); window.setTimeout(connect, 1000); }; + }; + connect(); + }); + }); +})(); diff --git a/src/agent_harness/static/htmx.min.js b/src/agent_harness/static/htmx.min.js new file mode 100644 index 0000000..37cd83c --- /dev/null +++ b/src/agent_harness/static/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.9"};Q.onLoad=j;Q.process=Ft;Q.on=xe;Q.off=be;Q.trigger=ae;Q.ajax=Nn;Q.find=f;Q.findAll=y;Q.closest=g;Q.remove=z;Q.addClass=G;Q.removeClass=b;Q.toggleClass=W;Q.takeClass=Z;Q.swap=ze;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=$;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:U,findThisElement:Se,filterValues:yn,swap:ze,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:A,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:le,makeSettleInfo:Sn,oobSwap:Te,querySelectorExt:ue,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;A(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function I(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function D(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);I(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);I(r,i.body);r.title=i.title}else{const i=L('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){D(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function M(e){return t(e,"Object")}function oe(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function se(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function le(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function v(e){try{return JSON.parse(e)}catch(e){H(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function V(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return On(te().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function f(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return f(te(),e)}}function y(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return y(te(),e)}}function x(){return window}function z(e,t){e=w(e);if(t){x().setTimeout(function(){z(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function G(e,t,n){e=ce(w(e));if(!e){return}if(n){x().setTimeout(function(){G(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function b(e,t,n){let r=ce(w(e));if(!r){return}if(n){x().setTimeout(function(){b(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=w(e);e.classList.toggle(t)}function Z(e,t){e=w(e);ie(e.parentElement.children,function(e){b(e,t)});G(ce(e),t)}function g(e,t){e=ce(w(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function pe(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=w(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=pe(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ce(t),pe(r.slice(8)))}else if(r.indexOf("find ")===0){e=f(p(t),pe(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=ce(t).nextElementSibling}else if(r.indexOf("next ")===0){e=ge(t,pe(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ce(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,pe(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(q(t,!!n));i.push(...F(c.querySelectorAll(e)))}return i}var ge=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ue(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(te().body,e)[0]}}function w(e,t){if(typeof e==="string"){return f(p(t)||document,e)}else{return e}}function ye(e,t,n,r){if(k(t)){return{target:te().body,event:K(e),listener:t,options:n}}else{return{target:w(e),event:K(t),listener:n,options:r}}}function xe(t,n,r,o){Gn(function(){const e=ye(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Gn(function(){const e=ye(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=te().createElement("output");function we(t,n){const e=ne(t,n);if(e){if(e==="this"){return[Se(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=ce(A(t,function(e){return e!==t&&s(ce(e),n)}));if(i){r.push(...we(i,n))}}if(r.length===0){H('The selector "'+e+'" on '+n+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ce(A(e,function(e){return a(ce(e),t)!=null}))}function Ee(e){const t=ne(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ue(e,t)}}else{const n=oe(e);if(n.boosted){return te().body}else{return e}}}function Ce(e){return Q.config.attributesToSettle.includes(e)}function Oe(t,n){ie(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});ie(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function He(t,e){const n=Jn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){ie(r,function(e){let t;const n=o.cloneNode(true);t=te().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);$e(s,e,e,t,i);Re()}ie(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(te().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function Re(){const e=f("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=f("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){ie(y(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=a(e,"id");const n=te().getElementById(t);if(n!=null){if(e.moveBefore){let e=f("#--htmx-preserve-pantry--");if(e==null){te().body.insertAdjacentHTML("afterend","
");e=f("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Ae(l,e,c){ie(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=p(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){b(e,Q.config.addedClass);Ft(ce(e));Le(p(e));ae(e,"htmx:load")}}function Le(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Ae(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;G(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0}function ze(h,d,p,g){if(!g){g={}}let m=null;let n=null;let e=function(){re(g.beforeSwapCallback);h=w(h);const r=g.contextElement?q(g.contextElement,false):te();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=Sn(h);if(p.swapStyle==="textContent"){h.textContent=d}else{let n=P(d);o.title=g.title||n.title;if(g.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(g.selectOOB){const i=g.selectOOB.split(",");for(let t=0;t0){x().setTimeout(n,p.settleDelay)}else{n()}};let t=Q.config.globalViewTransitions;if(p.hasOwnProperty("transition")){t=p.transition}const r=g.contextElement||te();if(t&&ae(r,"htmx:beforeTransition",g.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){m=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(p?.swapDelay&&p.swapDelay>0){x().setTimeout(e,p.swapDelay)}else{e()}}catch(e){fe(r,"htmx:swapError",g.eventInfo);re(n);throw e}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=On(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(te().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=O(e,Qe).trim();e.shift()}else{t=O(e,E)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{O(o,C);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,C);u.pollInterval=d(O(o,/[,\[\s]/));O(o,C);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const f={trigger:c};var i=nt(e,o,"event");if(i){f.eventFilter=i}O(o,C);while(o.length>0&&o[0]!==","){const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(O(o,E))}else if(a==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=O(o,E);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=rt(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(O(o,E))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=O(o,E)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=rt(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=O(o,E)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}r.push(f)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}O(o,C)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=a(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){oe(e).cancelled=true}function ct(e,t,n){const r=oe(e);r.timeout=x().setTimeout(function(){if(se(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function ft(e){return g(e,Q.config.disableSelector)}function at(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){gt(t,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)},n,e,true)})}}function ht(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function dt(e,t){return oe(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(te().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function gt(l,c,e,u,f){const a=oe(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in a)){a.lastValue=new WeakMap}t.forEach(function(e){if(!a.lastValue.has(u)){a.lastValue.set(u,new WeakMap)}a.lastValue.get(u).set(e,e.value)})}ie(t,function(i){const s=function(e){if(!se(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(f||ht(e,i)){e.preventDefault()}if(pt(u,l,e)){return}const t=oe(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ce(e.target),u.target)){return}}if(u.once){if(a.triggeredOnce){return}else{a.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=a.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(a.delayed){clearTimeout(a.delayed)}if(a.throttle){return}if(u.throttle>0){if(!a.throttle){ae(l,"htmx:trigger");c(l,e);a.throttle=x().setTimeout(function(){a.throttle=null},u.throttle)}}else if(u.delay>0){a.delayed=x().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let yt=null;function xt(){if(!yt){yt=function(){mt=true};window.addEventListener("scroll",yt);window.addEventListener("resize",yt);setInterval(function(){if(mt){mt=false;ie(te().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=oe(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){x().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;ie(de,function(r){if(s(t,"hx-"+r)){const o=a(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ce(e);if(ft(n)){S(n);return}he(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){xt();gt(r,n,t,e);bt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ue(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{gt(r,n,t,e)}}function Et(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Rt(e){const t=At(e.target);const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function At(e){return g(ce(e),"button, input[type='submit']")}function Nt(e){return e.form||g(e,"form")}function Lt(e){const t=At(e.target);if(!t){return}const n=Nt(t);if(!n){return}return oe(n)}function It(e){e.addEventListener("click",Rt);e.addEventListener("focusin",Rt);e.addEventListener("focusout",qt)}function Dt(t,e,n){const r=oe(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){On(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){Pe(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Jt(t){if(!U()){return null}t=V(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(te().body,"htmx:historyCacheMissLoad",r);ze(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});$t(r.path);ae(te().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{fe(te().body,"htmx:historyCacheMissLoadError",r)}};if(ae(te().body,"htmx:historyCacheMiss",r)){t.send()}}function en(e){Gt();e=e||location.pathname+location.search;const t=Jt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:_t(),swapSpec:n};if(ae(te().body,"htmx:historyCacheHit",r)){ze(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});$t(r.path);ae(te().body,"htmx:historyRestore",r)}}else{if(Q.config.refreshOnHistoryMiss){Q.location.reload(true)}else{Qt(e)}}}function tn(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function nn(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}ie(t,function(e){const t=oe(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function rn(e,t){ie(e.concat(t),function(e){const t=oe(e);t.requestCount=(t.requestCount||1)-1});ie(e,function(e){const t=oe(e);if(t.requestCount===0){b(e,Q.config.requestClass)}});ie(t,function(e){const t=oe(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function on(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);ie(e,e=>r.append(t,e))}}function un(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||on(t,e)){return}else{t.push(e)}if(sn(e)){const i=ee(e,"name");ln(i,un(e),n);if(o){an(e,r)}}if(e instanceof HTMLFormElement){ie(e.elements,function(e){if(t.indexOf(e)>=0){cn(e.name,un(e),n)}else{t.push(e)}if(o){an(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}ln(t,e,n)})}}function an(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Q.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function hn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function dn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=oe(e);if(s.lastButtonClicked&&!se(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||a(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,Nt(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const f=ee(u,"name");ln(f,u.value,o)}const c=we(e,"hx-include");ie(c,function(e){fn(n,r,i,ce(e),l);if(!h(e,"form")){ie(p(e).querySelectorAll(ot),function(e){fn(n,r,i,e,l)})}});hn(r,o);return{errors:i,formData:r,values:kn(r)}}function pn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function gn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=pn(n,t,e)});return n}function mn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":a(t,"id"),"HX-Current-URL":location.href};Cn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(oe(e).boosted){r["HX-Boosted"]="true"}return r}function yn(n,e){const t=ne(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){ie(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;ie(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function xn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function bn(e,t){const n=t||ne(e,"hx-swap");const r={swapStyle:oe(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&oe(e).boosted&&!xn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.slice(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{H("Unknown modifier in hx-swap: "+l)}}}}return r}function vn(e){return ne(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function wn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(vn(n)){return hn(new FormData,Dn(r))}else{return gn(r)}}}function Sn(e){return{tasks:[],elts:[e]}}function En(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(ue(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){x().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(ue(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function Cn(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=a(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=On(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c in n){if(n.hasOwnProperty(c)){if(i[c]==null){i[c]=n[c]}}}}return Cn(ce(u(r)),e,o,i,s)}function On(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return Cn(e,"hx-vars",true,n,t)}function Tn(e,t,n){return Cn(e,"hx-vals",false,n,t)}function Rn(e,t){return le(Hn(e,t),Tn(e,t))}function qn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function An(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function T(e,t){return t.test(e.getAllResponseHeaders())}function Nn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return he(t,n,null,null,{targetOverride:w(r)||ve,returnPromise:true})}else{let e=w(r.target);if(r.target&&!e||r.source&&!e&&!w(r.source)){e=ve}return he(t,n,w(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return he(t,n,null,null,{returnPromise:true})}}function Ln(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function In(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Q.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",le({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function kn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function he(t,n,r,o,i,k){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=te().body}const M=i.handler||Vn;const F=i.select||null;if(!se(r)){re(s);return e}const c=i.targetOverride||ce(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:ne(r,"hx-target")});re(l);return e}let u=oe(r);const f=u.lastButtonClicked;if(f){const A=ee(f,"formaction");if(A!=null){n=A}const N=ee(f,"formmethod");if(N!=null){if(de.includes(N.toLowerCase())){t=N}else{re(s);return e}}}const a=ne(r,"hx-confirm");if(k===undefined){const K=function(e){return he(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(ae(r,"htmx:confirm",G)===false){re(s);return e}}let h=r;let d=ne(r,"hx-sync");let p=null;let B=false;if(d){const L=d.split(":");const I=L[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ce(ue(r,I))}d=(L[1]||"drop").trim();u=oe(h);if(d==="drop"&&u.xhr&&u.abortable!==true){re(s);return e}else if(d==="abort"){if(u.xhr){re(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const D=oe(o);if(D&&D.triggerSpec&&D.triggerSpec.queue){p=D.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){he(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){he(t,n,r,o,i)})}re(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=B;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const X=ne(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){re(s);m();return e}}if(a&&!k){if(!confirm(a)){re(s);m();return e}}let x=mn(r,c,y);if(t!=="get"&&!vn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=le(x,i.headers)}const U=dn(r,t);let b=U.errors;const V=U.formData;if(i.values){hn(V,Dn(i.values))}const j=Dn(Rn(r,o));const v=hn(V,j);let w=yn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=Cn(r,"hx-request");const $=oe(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:kn(w),unfilteredFormData:v,unfilteredParameters:kn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){re(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);re(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let H=n;if(E){H=z;const Z=!w.keys().next().done;if(Z){if(H.indexOf("?")<0){H+="?"}else{H+="&"}H+=gn(w);if(O){H+="#"+O}}}if(!In(r,H,C)){fe(r,"htmx:invalidPath",C);re(l);m();return e}g.open(t.toUpperCase(),H,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const P in x){if(x.hasOwnProperty(P)){const Y=x[P];qn(g,P,Y)}}}const T={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:H,responsePath:null,anchor:O}};g.onload=function(){try{const t=Ln(r);T.pathInfo.responsePath=An(g);M(r,T);if(T.keepIndicators!==true){rn(R,q)}ae(r,"htmx:afterRequest",T);ae(r,"htmx:afterOnLoad",T);if(!se(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(se(n)){e=n}}if(e){ae(e,"htmx:afterRequest",T);ae(e,"htmx:afterOnLoad",T)}}re(s)}catch(e){fe(r,"htmx:onLoadError",le({error:e},T));throw e}finally{m()}};g.onerror=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendError",T);re(l);m()};g.onabort=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:sendAbort",T);re(l);m()};g.ontimeout=function(){rn(R,q);fe(r,"htmx:afterRequest",T);fe(r,"htmx:timeout",T);re(l);m()};if(!ae(r,"htmx:beforeRequest",T)){re(s);m();return e}var R=tn(r);var q=nn(r);ie(["loadstart","loadend","progress","abort"],function(t){ie([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",T);const J=E?null:wn(g,r,w);g.send(J);return e}function Mn(e,t){const n=t.xhr;let r=null;let o=null;if(T(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(T(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(T(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;let l=t.etc.push||ne(e,"hx-push-url");let c=t.etc.replace||ne(e,"hx-replace-url");if(l==="false")l=null;if(c==="false")c=null;const u=oe(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(c){f="replace";a=c}else if(u){f="push";a=s||i}if(a){if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Fn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Bn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Zn(){const e=te().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Yn(){const e=Zn();if(e){Q.config=le(Q.config,e)}}Gn(function(){Yn();Wn();let e=te().body;Ft(e);const t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=oe(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){en();ie(t,function(e){ae(e,"htmx:restored",{document:te(),triggerEvent:ae})})}else{if(n){n(e)}}};x().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/src/agent_harness/store.py b/src/agent_harness/store.py index b1c7b0b..8a2c100 100644 --- a/src/agent_harness/store.py +++ b/src/agent_harness/store.py @@ -247,6 +247,23 @@ def since_id(self, event_id: int, limit: int = 200) -> list[dict[str, Any]]: ) return [self._row_to_dict(r) for r in rows] + def item_events(self, project_id: str, item_id: str, limit: int = 1000) -> list[dict[str, Any]]: + """Retained history for one item, oldest first. + + The legacy ingest store predates indexed project/item columns, so the + identity lives in JSON. Keep the query here, beside that storage + knowledge, rather than teaching an API or template how rows happen to + be laid out. + """ + rows = self._connect().execute( + "SELECT * FROM events " + "WHERE json_extract(data, '$.item_id') = ? " + "AND COALESCE(json_extract(data, '$.project_id'), 'default') = ? " + "ORDER BY id LIMIT ?", + (item_id, project_id, limit), + ) + return [self._row_to_dict(row) for row in rows] + def max_id(self) -> int: row = self._connect().execute("SELECT MAX(id) FROM events").fetchone() return int(row[0] or 0) diff --git a/src/agent_harness/templates/analytics.html b/src/agent_harness/templates/analytics.html new file mode 100644 index 0000000..412adb6 --- /dev/null +++ b/src/agent_harness/templates/analytics.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

Operational telemetry

Analytics

{% if audit_health.degraded %}Audit degraded{% else %}Audit recording{% endif %}
+ {% if audit_health.degraded %}

History is degraded or unavailable. Counts below are not evidence of complete history.

{% endif %} +

Rate limits

Known classes only; historical unclassified limits remain separate.

{% for key, count in rate_limits.items() %}{% else %}{% endfor %}
ClassCount
{{ key }}{{ count }}
No classified rate limits

Cost

{% for row in costs %}{% else %}{% endfor %}
Project / role / modelKnown USDUnpriced
{{ row.project_id or '—' }} / {{ row.role or '—' }} / {{ row.model or '—' }}{{ row.cost_usd if row.cost_usd is not none else 'unknown' }}{{ row.unpriced }}
No cost evidence

Delivery

{% for row in delivery %}{% else %}{% endfor %}
Project / outcomeEventsItems
{{ row.project_id or '—' }} / {{ row.outcome or '—' }}{{ row.n }}{{ row.items }}
No delivery evidence
+{% endblock %} diff --git a/src/agent_harness/templates/base.html b/src/agent_harness/templates/base.html new file mode 100644 index 0000000..c4e35da --- /dev/null +++ b/src/agent_harness/templates/base.html @@ -0,0 +1,42 @@ + + + + + + + + + + + {{ title|default('agent-harness') }} · agent-harness + + + {% if session %} +
+ agent-harness + +
+ + +
+
+
+ + Connected to this harness + Signed in as {{ session.operator }} +
+ {% endif %} +
+ {% block content %}{% endblock %} +
+ + diff --git a/src/agent_harness/templates/events.html b/src/agent_harness/templates/events.html new file mode 100644 index 0000000..e875bf4 --- /dev/null +++ b/src/agent_harness/templates/events.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

Append-only history

Events

Cursor {{ events.cursor }}
+

The stream resumes after the last monotonic cursor. A reconnect is visible and never silently replaces the event store.

+
    {% for event in events.events %}
  1. {{ event.outcome or event.kind }}{{ event.ts|datetime }}

    {{ event.data.detail or event.source }}

  2. {% else %}
  3. No events have been recorded.
  4. {% endfor %}
+{% endblock %} diff --git a/src/agent_harness/templates/fragments/project_cards.html b/src/agent_harness/templates/fragments/project_cards.html new file mode 100644 index 0000000..62aad72 --- /dev/null +++ b/src/agent_harness/templates/fragments/project_cards.html @@ -0,0 +1,12 @@ +{% for entry in projects.projects %} +
+

{{ entry.project.project_id }}

{{ entry.project.name }}

{{ entry.control.state }}
+
+
Pending
{{ entry.counts.get('pending', 0) }}
Claimed
{{ entry.counts.get('claimed', 0) }}
Held
{{ entry.counts.get('held', 0) }}
Failed
{{ entry.counts.get('failed', 0) + entry.counts.get('exhausted', 0) }}
+
+

{{ entry.workers }} worker(s) active · {{ entry.stale }} stale lease(s) · {{ entry.worker_failures }} worker failure(s)

+ {% if entry.control.reason %}

{{ entry.control.reason }}

{% endif %} + {% if entry.last_worker_error %}

Latest worker failure: {{ entry.last_worker_error }}

{% endif %} +

Inspect work

+
+{% else %}

No projects configured

Create a project through the typed API, then return here to inspect it.

{% endfor %} diff --git a/src/agent_harness/templates/fragments/work_rows.html b/src/agent_harness/templates/fragments/work_rows.html new file mode 100644 index 0000000..b0d2fb3 --- /dev/null +++ b/src/agent_harness/templates/fragments/work_rows.html @@ -0,0 +1,5 @@ +{% set groups = ['held', 'blocked', 'failed', 'exhausted', 'claimed', 'pending', 'done'] %} +{% for state in groups %} + {% set rows = work.items|selectattr('state', 'equalto', state)|list %} + {% if rows %}

{{ state|capitalize }} {{ rows|length }}

{% for item in rows %}
{{ item.title }}{{ item.id }}{% if item.hold %}Waiting for a person{% endif %}

{{ item.brief|truncate(180) }}

{{ item.attempts }} attempt(s){% if item.latest %}{{ item.latest.outcome }}{% endif %}{% if item.unpriced_calls %}{{ item.unpriced_calls }} unpriced call(s){% endif %}
{% endfor %}
{% endif %} +{% else %}

No work items

{% endfor %} diff --git a/src/agent_harness/templates/holds.html b/src/agent_harness/templates/holds.html new file mode 100644 index 0000000..cf37f11 --- /dev/null +++ b/src/agent_harness/templates/holds.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

Human-in-the-loop

Holds

Answers arrive in Milestone 2
+

Questions are durable item state, not a projection over recent logs. They keep their claim and show age and expiry.

+ {% if not holds.open %}

No open holds

Nothing is waiting on an operator.

{% else %}
{% for hold in holds.open %}

{{ hold.project_id }} / {{ hold.item_id }}

{{ hold.question }}

{{ '%.0f'|format(hold.age_seconds) }}s waiting

{{ hold.reason or 'No additional reason supplied.' }}

Allowed answerer
{{ hold.who_may_answer }}
Expires
{{ hold.expires_at|datetime if hold.expires_at else 'Never' }}
Attempt
{{ hold.attempt }}

Answering is intentionally unavailable in this read-only milestone.

{% endfor %}
{% endif %} +{% endblock %} diff --git a/src/agent_harness/templates/login.html b/src/agent_harness/templates/login.html new file mode 100644 index 0000000..d9333fe --- /dev/null +++ b/src/agent_harness/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} + +{% endblock %} diff --git a/src/agent_harness/templates/placeholder.html b/src/agent_harness/templates/placeholder.html new file mode 100644 index 0000000..45574e6 --- /dev/null +++ b/src/agent_harness/templates/placeholder.html @@ -0,0 +1,2 @@ +{% extends "base.html" %} +{% block content %}

agent-harness

{{ title }}

Planned slice

{{ message }}

The control-plane shell stays available while this capability is delivered behind its generic typed boundary.

{% endblock %} diff --git a/src/agent_harness/templates/projects.html b/src/agent_harness/templates/projects.html new file mode 100644 index 0000000..a2f6476 --- /dev/null +++ b/src/agent_harness/templates/projects.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

Control plane

Projects

Read-only slice
+

A project starts stopped after restart. This view exposes queue state, leases, workers, readiness and audit health without starting work.

+ {% if not projects.projects %} +

No projects configured

Create a project through the typed API, then return here to inspect it.

+ {% else %} +
+ {% include "fragments/project_cards.html" %} +
+ {% endif %} +{% endblock %} diff --git a/src/agent_harness/templates/settings.html b/src/agent_harness/templates/settings.html new file mode 100644 index 0000000..4f2615e --- /dev/null +++ b/src/agent_harness/templates/settings.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +

Deployment

Settings

{{ mode }}
+ {% if mode == 'monitoring-only' %} +
Monitoring-only deployment. No worker pool is attached, so execution controls are disabled. This is a readiness condition, not a failed action.
+ {% endif %} +

Readiness

This page never probes or mutates execution. Use the typed readiness API for an explicit, potentially expensive check.

Queue
{{ 'configured' if queue_configured else 'not configured' }}
Browser identity
{{ session.operator }}
Audit
Append-only history is shown separately from queue state.

Open API documentation

+{% endblock %} diff --git a/src/agent_harness/templates/work.html b/src/agent_harness/templates/work.html new file mode 100644 index 0000000..c5a01ea --- /dev/null +++ b/src/agent_harness/templates/work.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block content %} +

Backlog

Work

Read-only slice
+
+ {% if not work.configured %}

Monitoring without a queue

{{ work.reason }}

{% elif not work.items %}

No work items

The queue is configured but contains no items.

{% else %}
{% include "fragments/work_rows.html" %}
{% endif %} +{% endblock %} diff --git a/src/agent_harness/templates/work_item.html b/src/agent_harness/templates/work_item.html new file mode 100644 index 0000000..a514923 --- /dev/null +++ b/src/agent_harness/templates/work_item.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% block content %} + +

{{ item.id }}

{{ item.title }}

{{ item.state }}
+

Specification

{{ item.brief }}

Issue
{{ item.issue or 'Not linked' }}
Dependencies
{{ item.depends_on|join(', ') or 'None declared' }}
Attempts
{{ item.attempts }}
Disposition
{{ item.disposition or 'No decision recorded' }}
Reason
{{ item.reason_kind or 'No reason recorded' }}

Lease and cost

Owner
{{ item.owner or 'None' }}
Branch
{{ item.branch or 'None' }}
Pull request
{% if item.pr_url %}{{ item.pr_url|e }}{% else %}None{% endif %}
Known spend
${{ '%.4f'|format(item.spend_usd) }}
Unpriced calls
{{ item.unpriced_calls }} (known spend is a lower bound)
{% if item.latest and item.latest.session_url %}

Open supplied session evidence

{% endif %}
+

Durable evidence

{% if not evidence.events and not evidence.stages and not evidence.holds %}

No retained evidence is available. This is not evidence that nothing happened.

{% else %}{% if evidence.stages %}

Attempt stages

    {% for stage in evidence.stages %}
  1. {{ stage.stage }} · attempt {{ stage.attempt }} · {{ stage.mode }}{{ stage.recorded_at|datetime }}
  2. {% endfor %}
{% endif %}{% if evidence.events %}

Events

    {% for event in evidence.events %}
  1. {{ event.outcome or event.kind }}{{ event.ts|datetime }}{% if event.data.detail %}

    {{ event.data.detail }}

    {% endif %}
  2. {% endfor %}
{% endif %}{% endif %}
+{% endblock %} diff --git a/src/agent_harness/ui.py b/src/agent_harness/ui.py new file mode 100644 index 0000000..64b0bc2 --- /dev/null +++ b/src/agent_harness/ui.py @@ -0,0 +1,299 @@ +"""Server-rendered browser control plane. + +This module contains only HTTP presentation and browser-session concerns. All +state reads come from :class:`HarnessQueries`; no route opens SQLite or applies +a queue transition. Mutating controls are deliberately introduced by a later +milestone after their shared command/audit services are available. +""" + +from __future__ import annotations + +import asyncio +import secrets +import time +from collections.abc import AsyncIterator +from pathlib import Path +from urllib.parse import parse_qs + +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from .browser_session import BrowserSession, BrowserSessions +from .query_service import HarnessQueries + +TEMPLATE_DIR = Path(__file__).with_name("templates") +STATIC_DIR = Path(__file__).with_name("static") + + +def install_ui(app: FastAPI) -> BrowserSessions: + """Mount packaged UI routes onto an existing API app.""" + sessions = BrowserSessions() + templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) + templates.env.filters["datetime"] = _datetime + app.state.browser_sessions = sessions + app.state.ui_templates = templates + + app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets") + + def queries(request: Request) -> HarnessQueries: + return HarnessQueries( + request.app.state.store, + request.app.state.queue, + audit=request.app.state.audit, + fleet=request.app.state.fleet, + ) + + def render(request: Request, template: str, **context: object) -> HTMLResponse: + session = sessions.get(request.cookies.get("harness_session")) + return templates.TemplateResponse( + request=request, + name=template, + context={ + "session": session, + "root_path": request.scope.get("root_path", ""), + **context, + }, + ) + + def require_session(request: Request) -> BrowserSession: + return sessions.require(request) + + @app.get("/", include_in_schema=False) + def root(request: Request) -> RedirectResponse: + target = "projects" if sessions.get(request.cookies.get("harness_session")) else "login" + return RedirectResponse(url=request.url_for(target), status_code=303) + + @app.get( + "/login", + name="login", + response_class=HTMLResponse, + response_model=None, + include_in_schema=False, + ) + def login_page(request: Request) -> HTMLResponse | RedirectResponse: + if sessions.get(request.cookies.get("harness_session")) is not None: + return RedirectResponse(url=request.url_for("projects"), status_code=303) + response = render( + request, + "login.html", + error=( + "No harness token is configured. Set HARNESS_TOKEN and restart the service." + if not request.app.state.token + else None + ), + ) + if not request.app.state.token: + response.status_code = 503 + return response + + @app.post("/login", response_class=HTMLResponse, response_model=None, include_in_schema=False) + async def login(request: Request) -> HTMLResponse | RedirectResponse: + if not request.app.state.token: + raise HTTPException(status_code=503, detail="no auth token configured") + client_key = request.client.host if request.client is not None else "unknown" + if not sessions.allow_login(client_key): + raise HTTPException( + status_code=429, detail="too many failed login attempts; try again later" + ) + body = parse_qs((await request.body()).decode("utf-8"), keep_blank_values=True) + supplied = body.get("token", [""])[0] + expected = request.app.state.token + if not expected or not supplied or not secrets.compare_digest(supplied, expected): + sessions.record_login_failure(client_key) + return render( + request, + "login.html", + error="That harness token was not accepted. Check the service URL and try again.", + ) + session = sessions.create(operator="operator") + response = RedirectResponse(url=request.url_for("projects"), status_code=303) + response.set_cookie( + "harness_session", + session.session_id, + httponly=True, + secure=request.url.scheme == "https", + samesite="strict", + max_age=sessions.ttl_seconds, + path=request.scope.get("root_path", "") or "/", + ) + return response + + @app.post("/logout", name="logout", include_in_schema=False) + async def logout(request: Request) -> RedirectResponse: + session = require_session(request) + body = parse_qs((await request.body()).decode("utf-8"), keep_blank_values=True) + sessions.require_csrf(request, session, body.get("csrf_token", [""])[0]) + sessions.revoke(session.session_id) + response = RedirectResponse(url=request.url_for("login"), status_code=303) + response.delete_cookie("harness_session", path=request.scope.get("root_path", "") or "/") + return response + + @app.get("/projects", name="projects", response_class=HTMLResponse, include_in_schema=False) + def projects_page(request: Request) -> HTMLResponse: + require_session(request) + return render( + request, "projects.html", title="Projects", projects=queries(request).projects() + ) + + @app.get("/work", name="work_page", response_class=HTMLResponse, include_in_schema=False) + def work_page(request: Request, project_id: str | None = None) -> HTMLResponse: + require_session(request) + return render( + request, + "work.html", + title="Work", + work=queries(request).work(project_id), + project_id=project_id, + ) + + @app.get( + "/work/{item_id}", + name="work_item_page", + response_class=HTMLResponse, + include_in_schema=False, + ) + def work_item_page(request: Request, item_id: str, project_id: str = "default") -> HTMLResponse: + require_session(request) + query = queries(request) + item = query.item(project_id, item_id) + if item is None: + raise HTTPException(status_code=404, detail="work item not found") + return render( + request, + "work_item.html", + title=item.title, + item=item, + evidence=query.evidence(project_id, item_id), + ) + + @app.get("/holds", name="holds", response_class=HTMLResponse, include_in_schema=False) + def holds_page(request: Request, project_id: str | None = None) -> HTMLResponse: + require_session(request) + return render( + request, "holds.html", title="Holds", holds=queries(request).holds(project_id) + ) + + @app.get("/events", name="events", response_class=HTMLResponse, include_in_schema=False) + def events_page(request: Request) -> HTMLResponse: + require_session(request) + return render( + request, + "events.html", + title="Events", + events=queries(request).live_events(), + ) + + @app.get("/analytics", name="analytics", response_class=HTMLResponse, include_in_schema=False) + def analytics_page(request: Request) -> HTMLResponse: + require_session(request) + audit = request.app.state.audit + return render( + request, + "analytics.html", + title="Analytics", + rate_limits=(audit.rate_limits_by_class() if audit is not None else {}), + costs=(audit.cost() if audit is not None else []), + delivery=(audit.delivery() if audit is not None else []), + audit_health=( + {"configured": True, "degraded": audit.degraded, "events": audit.count()} + if audit is not None + else {"configured": False, "degraded": True, "events": 0} + ), + ) + + @app.get("/plans", name="plans", response_class=HTMLResponse, include_in_schema=False) + def plans_page(request: Request) -> HTMLResponse: + require_session(request) + return render( + request, + "placeholder.html", + title="Plans", + message=( + "Plan review is available through the typed API while its review wizard " + "is being delivered." + ), + ) + + @app.get("/sessions", name="sessions", response_class=HTMLResponse, include_in_schema=False) + def sessions_page(request: Request) -> HTMLResponse: + require_session(request) + return render( + request, + "placeholder.html", + title="Sessions", + message="Agent-harness-owned sessions are scheduled for Milestone 5.", + ) + + @app.get("/settings", name="settings", response_class=HTMLResponse, include_in_schema=False) + def settings_page(request: Request) -> HTMLResponse: + require_session(request) + readiness = None + queue = request.app.state.queue + configured = queue is not None + return render( + request, + "settings.html", + title="Settings", + readiness=readiness, + mode=("supervised" if request.app.state.fleet is not None else "monitoring-only"), + queue_configured=configured, + ) + + @app.get("/ui/fragments/projects", response_class=HTMLResponse, include_in_schema=False) + def projects_fragment(request: Request) -> HTMLResponse: + require_session(request) + return render(request, "fragments/project_cards.html", projects=queries(request).projects()) + + @app.get("/ui/fragments/work", response_class=HTMLResponse, include_in_schema=False) + def work_fragment(request: Request, project_id: str | None = None) -> HTMLResponse: + require_session(request) + return render(request, "fragments/work_rows.html", work=queries(request).work(project_id)) + + @app.get("/api/events/stream", name="event_stream", include_in_schema=False) + async def event_stream(request: Request) -> StreamingResponse: + if sessions.get(request.cookies.get("harness_session")) is None: + credentials = request.headers.get("Authorization", "") + expected = request.app.state.token or "" + if not credentials.startswith("Bearer ") or not secrets.compare_digest( + credentials[7:], expected + ): + raise HTTPException( + status_code=401, + detail="browser session or bearer token required", + ) + try: + last_id = int( + request.headers.get("Last-Event-ID", request.query_params.get("since_id", "0")) + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail="event cursor must be an integer") from exc + + async def stream() -> AsyncIterator[str]: + nonlocal last_id + while True: + if await request.is_disconnected(): + return + page = queries(request).live_events(last_id, limit=200) + if page.events: + for event in page.events: + last_id = event.id + yield f"id: {event.id}\nevent: harness\ndata: {event.model_dump_json()}\n\n" + else: + yield ": heartbeat\n\n" + await asyncio.sleep(1.0) + + return StreamingResponse( + stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + return sessions + + +def _datetime(value: float | None) -> str: + if not value: + return "—" + return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(value)) diff --git a/tests/test_api.py b/tests/test_api.py index ddd7c42..f7a5bc9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -65,6 +65,9 @@ def test_healthz_is_open(client: TestClient) -> None: #: exactly what stopped anyone checking. A list that does not grow with the #: thing it describes is worse than no list, because it reads as coverage. OPEN_ROUTES = { + "/": "the browser entry point redirects to login or the authenticated app", + "/login": "the browser credential exchange", + "/assets": "packaged, immutable browser assets", "/healthz": "liveness, checked before a credential is available", "/docs": "the schema is not secret; the backlog is", "/docs/oauth2-redirect": "mounted by FastAPI for Swagger UI", @@ -125,12 +128,12 @@ def test_no_token_configured_fails_closed(store: EventStore) -> None: assert c.get("/healthz").status_code == 200 -def test_there_is_no_html_anywhere(client: TestClient) -> None: - """The GUI belongs to the session host. If HTML creeps back in here, so - does a second UI.""" +def test_browser_html_is_separate_from_json_api(client: TestClient) -> None: + """The first-party browser client never changes the JSON API contract.""" response = client.get("/api/work", headers=auth()) assert response.headers["content-type"].startswith("application/json") - assert client.get("/").status_code == 404 + assert client.get("/", follow_redirects=False).headers["location"].endswith("/login") + assert client.get("/login").headers["content-type"].startswith("text/html") # ------------------------------------------------------------------- work @@ -509,6 +512,16 @@ def test_one_item_can_be_fetched(client: TestClient) -> None: assert payload["title"] == "First" +def test_project_control_never_resumes_without_start_gate(client: TestClient) -> None: + response = client.post( + "/api/projects/p/control", + headers=auth(), + json={"state": "running", "reason": "clicked by operator"}, + ) + assert response.status_code == 409 + assert "preflight" in response.json()["detail"] + + def test_an_unknown_item_is_404(client: TestClient) -> None: assert client.get("/api/work/NOPE", headers=auth()).status_code == 404 diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..7240354 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,148 @@ +"""In-process journeys for the self-contained, read-only browser slice.""" + +from __future__ import annotations + +import time +from pathlib import Path + +from fastapi.testclient import TestClient + +from agent_harness.api import create_api +from agent_harness.events import WORK, Event +from agent_harness.store import EventStore +from agent_harness.work import Project, WorkQueue, WorkRecord + +TOKEN = "browser-test-token" # noqa: S105 - fixture value + + +def make_client(tmp_path: Path, *, token: str | None = TOKEN) -> TestClient: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + queue.add( + [WorkRecord(item_id="T1", title="First item", brief="A safe read path")], + project_id="p", + ) + return TestClient(create_api(store, queue=queue, token=token)) + + +def make_rooted_client(tmp_path: Path) -> TestClient: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + return TestClient(create_api(store, queue=queue, token=TOKEN, root_path="/harness")) + + +def login(client: TestClient) -> str: + response = client.post("/login", data={"token": TOKEN}, follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"].endswith("/projects") + cookie = response.cookies.get("harness_session") + assert cookie + assert "httponly" in response.headers["set-cookie"].lower() + assert "samesite=strict" in response.headers["set-cookie"].lower() + return cookie + + +def test_root_and_pages_fail_closed_until_login(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + assert client.get("/", follow_redirects=False).status_code == 303 + assert client.get("/projects").status_code == 401 + assert client.get("/work").status_code == 401 + assert client.get("/api/work").status_code == 401 + + +def test_login_cookie_is_opaque_and_pages_are_read_only(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + session = login(client) + assert TOKEN not in session + projects = client.get("/projects") + assert projects.status_code == 200 + assert "Project P" in projects.text + assert "read-only" in projects.text.lower() + assert client.get("/work?project_id=p").status_code == 200 + assert "First item" in client.get("/work?project_id=p").text + assert client.post("/api/projects/p/start").status_code == 401 + + +def test_ui_security_headers_and_packaged_assets(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + response = client.get("/login") + assert "default-src 'self'" in response.headers["content-security-policy"] + assert "X-Content-Type-Options" in response.headers + css = client.get("/assets/app.css") + js = client.get("/assets/htmx.min.js") + assert css.status_code == 200 and "--accent" in css.text + assert js.status_code == 200 and "cdn" not in js.text.lower() + assert "immutable" in css.headers["cache-control"] + + +def test_login_refuses_without_configured_token(tmp_path: Path) -> None: + with make_client(tmp_path, token=None) as client: + assert client.get("/login").status_code == 503 + assert client.post("/login", data={"token": TOKEN}).status_code == 503 + assert client.get("/api/work").status_code == 503 + + +def test_logout_requires_csrf_and_revokes_session(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + login(client) + assert client.post("/logout", follow_redirects=False).status_code == 403 + csrf = ( + client.get("/projects").text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + ) + response = client.post( + "/logout", + data={"csrf_token": csrf}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert client.get("/projects").status_code == 401 + + +def test_item_detail_renders_durable_evidence_without_xss(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + store = client.app.state.store # type: ignore[attr-defined] + store.append( + [ + Event( + ts=time.time(), + kind=WORK, + source="fixture", + outcome="agent_started", + data={ + "project_id": "p", + "item_id": "T1", + "detail": "", + }, + ) + ] + ) + login(client) + response = client.get("/work/T1?project_id=p") + assert response.status_code == 200 + assert "<script>alert(1)</script>" in response.text + assert "" not in response.text + evidence = client.get( + "/api/work/T1/evidence?project_id=p", + headers={"Authorization": f"Bearer {TOKEN}"}, + ) + assert evidence.status_code == 200 + assert evidence.json()["item_id"] == "T1" + + +def test_event_stream_accepts_cursor_and_requires_auth(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + assert client.get("/api/events/stream?since_id=0").status_code == 401 + login(client) + response = client.get("/api/events/stream?since_id=bad") + assert response.status_code == 400 + + +def test_ui_named_urls_honor_root_path(tmp_path: Path) -> None: + with make_rooted_client(tmp_path) as client: + response = client.get("/login") + assert response.status_code == 200 + assert "/harness/login" in response.text + assert "/harness/assets/app.css" in response.text diff --git a/tests/test_ui_packaging.py b/tests/test_ui_packaging.py new file mode 100644 index 0000000..de23fae --- /dev/null +++ b/tests/test_ui_packaging.py @@ -0,0 +1,38 @@ +"""Packaging checks for the first-party browser application.""" + +from __future__ import annotations + +import subprocess +import zipfile +from pathlib import Path + + +def test_ui_resources_are_in_the_installed_package() -> None: + """The distribution must carry the UI; runtime must not reach a CDN.""" + import importlib.resources + + package = importlib.resources.files("agent_harness") + assert package.joinpath("templates", "base.html").is_file() + assert package.joinpath("static", "app.css").is_file() + assert package.joinpath("static", "htmx.min.js").is_file() + + +def test_wheel_contains_templates_and_static_assets(tmp_path: Path) -> None: + """A built wheel remains self-contained after installation.""" + root = Path(__file__).resolve().parents[1] + dist = tmp_path / "dist" + result = subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(dist)], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + wheels = list(dist.glob("*.whl")) + assert len(wheels) == 1 + with zipfile.ZipFile(wheels[0]) as archive: + names = set(archive.namelist()) + assert "agent_harness/templates/base.html" in names + assert "agent_harness/static/app.css" in names + assert "agent_harness/static/htmx.min.js" in names From 8b202a964b910d243f375d03a1fea769ec75d895 Mon Sep 17 00:00:00 2001 From: sprooty Date: Wed, 5 Aug 2026 11:29:49 +0000 Subject: [PATCH 02/12] feat: add guarded browser control actions --- docs/evidence/2026-08-05-gui-milestone-0-1.md | 12 +- src/agent_harness/browser_session.py | 33 ++- .../templates/fragments/project_cards.html | 11 + src/agent_harness/templates/holds.html | 4 +- src/agent_harness/templates/projects.html | 2 +- src/agent_harness/templates/work_item.html | 4 + src/agent_harness/ui.py | 191 +++++++++++++++++- tests/test_ui.py | 77 ++++++- 8 files changed, 311 insertions(+), 23 deletions(-) diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index 1ceada1..b173a47 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -1,4 +1,4 @@ -# GUI Milestones 0–1 evidence +# GUI Milestones 0–1 evidence and Milestone 2 control bridge Status: implementation slice complete; the full GUI program is not complete. @@ -40,8 +40,10 @@ not evidence: the former raced virtualenv creation and the latter filled the `HttpOnly`, `SameSite=Strict` cookie. Login failures are rate-limited; restart and logout revoke sessions. State-changing browser requests require CSRF and same-origin checks. -- Monitoring-only mode is visible in the shell and does not expose controls that - would fail later. No UI route mutates queue or gate state in this slice. +- Monitoring-only mode is visible in the shell and disables controls that need a + supervised worker pool. The initial control bridge adds only explicit, + CSRF-protected pause/drain/stop, retry, block and hold-answer actions; each + delegates the existing queue validation and appends an operator audit event. - Typed item evidence exposes append-only events, durable attempt stages and retained holds without fabricating absent history or cost. - `/api/events/stream` resumes after a monotonic cursor and surfaces disconnects; @@ -55,9 +57,9 @@ delivery, JSON/API isolation, evidence and cursor validation. ## Not yet exercised or complete -Milestone 1 is not a release claim for the full `GUI_PLAN`: browser automation, +This is not a release claim for the full `GUI_PLAN`: browser automation, screen-reader checks, forced reconnect with replayed events, and all additional accessibility/security/concurrency journeys remain to run. Milestones 2–8 -(mutations, plan/adoption wizards, routing/operations panels, internal sessions, +(remaining mutations, plan/adoption wizards, routing/operations panels, internal sessions, extensions, automation, RBAC and recovery) remain explicitly incomplete. No real fleet or external deployment was used. diff --git a/src/agent_harness/browser_session.py b/src/agent_harness/browser_session.py index 4136615..ea90fb1 100644 --- a/src/agent_harness/browser_session.py +++ b/src/agent_harness/browser_session.py @@ -7,6 +7,7 @@ import time from collections import defaultdict, deque from dataclasses import dataclass +from urllib.parse import urlsplit from fastapi import HTTPException, Request @@ -17,6 +18,7 @@ class BrowserSession: csrf_token: str operator: str expires_at: float + token_fingerprint: str class BrowserSessions: @@ -53,13 +55,20 @@ def record_login_failure(self, client_key: str, *, now: float | None = None) -> failures.popleft() failures.append(moment) - def create(self, operator: str = "operator", *, now: float | None = None) -> BrowserSession: + def create( + self, + operator: str = "operator", + *, + token_fingerprint: str = "", + now: float | None = None, + ) -> BrowserSession: moment = time.time() if now is None else now session = BrowserSession( session_id=secrets.token_urlsafe(32), csrf_token=secrets.token_urlsafe(32), operator=operator, expires_at=moment + self.ttl_seconds, + token_fingerprint=token_fingerprint, ) with self._lock: self._purge(moment) @@ -86,6 +95,10 @@ def require(self, request: Request) -> BrowserSession: session = self.get(request.cookies.get("harness_session")) if session is None: raise HTTPException(status_code=401, detail="browser session required") + expected = request.app.state.token or "" + if not secrets.compare_digest(session.token_fingerprint, self.fingerprint(expected)): + self.revoke(session.session_id) + raise HTTPException(status_code=401, detail="browser session was revoked") return session def require_csrf( @@ -95,11 +108,23 @@ def require_csrf( # header only so a missing token cannot be confused with an empty form. token = request.headers.get("X-CSRF-Token", "") or (submitted or "") origin = request.headers.get("Origin") or request.headers.get("Referer", "") - host = str(request.base_url).rstrip("/") + request_url = urlsplit(str(request.base_url)) if not token or not secrets.compare_digest(token, session.csrf_token): raise HTTPException(status_code=403, detail="invalid CSRF token") - if origin and not origin.startswith(host): - raise HTTPException(status_code=403, detail="request origin is not this service") + if origin: + submitted_url = urlsplit(origin) + if ( + submitted_url.scheme != request_url.scheme + or submitted_url.netloc != request_url.netloc + ): + raise HTTPException(status_code=403, detail="request origin is not this service") + + @staticmethod + def fingerprint(token: str) -> str: + """Bind sessions to the configured token without retaining that token.""" + import hashlib + + return hashlib.sha256(token.encode()).hexdigest() def _purge(self, now: float) -> None: expired = [key for key, value in self._sessions.items() if value.expires_at <= now] diff --git a/src/agent_harness/templates/fragments/project_cards.html b/src/agent_harness/templates/fragments/project_cards.html index 62aad72..0d5ffcf 100644 --- a/src/agent_harness/templates/fragments/project_cards.html +++ b/src/agent_harness/templates/fragments/project_cards.html @@ -8,5 +8,16 @@ {% if entry.control.reason %}

{{ entry.control.reason }}

{% endif %} {% if entry.last_worker_error %}

Latest worker failure: {{ entry.last_worker_error }}

{% endif %}

Inspect work

+
+ {% for target in ['paused', 'draining', 'stopped'] %} +
+ + + + +
+ {% endfor %} +
+ {% if mode == 'monitoring-only' %}

Controls disabled: no supervised worker pool is attached.

{% endif %} {% else %}

No projects configured

Create a project through the typed API, then return here to inspect it.

{% endfor %} diff --git a/src/agent_harness/templates/holds.html b/src/agent_harness/templates/holds.html index cf37f11..302a9d6 100644 --- a/src/agent_harness/templates/holds.html +++ b/src/agent_harness/templates/holds.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -

Human-in-the-loop

Holds

Answers arrive in Milestone 2
+

Human-in-the-loop

Holds

Explicit answers

Questions are durable item state, not a projection over recent logs. They keep their claim and show age and expiry.

- {% if not holds.open %}

No open holds

Nothing is waiting on an operator.

{% else %}
{% for hold in holds.open %}

{{ hold.project_id }} / {{ hold.item_id }}

{{ hold.question }}

{{ '%.0f'|format(hold.age_seconds) }}s waiting

{{ hold.reason or 'No additional reason supplied.' }}

Allowed answerer
{{ hold.who_may_answer }}
Expires
{{ hold.expires_at|datetime if hold.expires_at else 'Never' }}
Attempt
{{ hold.attempt }}

Answering is intentionally unavailable in this read-only milestone.

{% endfor %}
{% endif %} + {% if not holds.open %}

No open holds

Nothing is waiting on an operator.

{% else %}
{% for hold in holds.open %}

{{ hold.project_id }} / {{ hold.item_id }}

{{ hold.question }}

{{ '%.0f'|format(hold.age_seconds) }}s waiting

{{ hold.reason or 'No additional reason supplied.' }}

Allowed answerer
{{ hold.who_may_answer }}
Expires
{{ hold.expires_at|datetime if hold.expires_at else 'Never' }}
Attempt
{{ hold.attempt }}
{% endfor %}
{% endif %} {% endblock %} diff --git a/src/agent_harness/templates/projects.html b/src/agent_harness/templates/projects.html index a2f6476..995f92d 100644 --- a/src/agent_harness/templates/projects.html +++ b/src/agent_harness/templates/projects.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -

Control plane

Projects

Read-only slice
+

Control plane

Projects

{{ mode }}

A project starts stopped after restart. This view exposes queue state, leases, workers, readiness and audit health without starting work.

{% if not projects.projects %}

No projects configured

Create a project through the typed API, then return here to inspect it.

diff --git a/src/agent_harness/templates/work_item.html b/src/agent_harness/templates/work_item.html index a514923..e15640b 100644 --- a/src/agent_harness/templates/work_item.html +++ b/src/agent_harness/templates/work_item.html @@ -3,5 +3,9 @@

{{ item.id }}

{{ item.title }}

{{ item.state }}

Specification

{{ item.brief }}

Issue
{{ item.issue or 'Not linked' }}
Dependencies
{{ item.depends_on|join(', ') or 'None declared' }}
Attempts
{{ item.attempts }}
Disposition
{{ item.disposition or 'No decision recorded' }}
Reason
{{ item.reason_kind or 'No reason recorded' }}

Lease and cost

Owner
{{ item.owner or 'None' }}
Branch
{{ item.branch or 'None' }}
Pull request
{% if item.pr_url %}{{ item.pr_url|e }}{% else %}None{% endif %}
Known spend
${{ '%.4f'|format(item.spend_usd) }}
Unpriced calls
{{ item.unpriced_calls }} (known spend is a lower bound)
{% if item.latest and item.latest.session_url %}

Open supplied session evidence

{% endif %}
+

Explicit actions

Every action is a deliberate form submission. A live claim and all queue gates are checked again by the server.

+ {% if item.state in ['failed', 'exhausted', 'blocked', 'done'] %}
{% endif %} + {% if item.state not in ['done', 'blocked'] %}
{% endif %} +

Durable evidence

{% if not evidence.events and not evidence.stages and not evidence.holds %}

No retained evidence is available. This is not evidence that nothing happened.

{% else %}{% if evidence.stages %}

Attempt stages

    {% for stage in evidence.stages %}
  1. {{ stage.stage }} · attempt {{ stage.attempt }} · {{ stage.mode }}{{ stage.recorded_at|datetime }}
  2. {% endfor %}
{% endif %}{% if evidence.events %}

Events

    {% for event in evidence.events %}
  1. {{ event.outcome or event.kind }}{{ event.ts|datetime }}{% if event.data.detail %}

    {{ event.data.detail }}

    {% endif %}
  2. {% endfor %}
{% endif %}{% endif %}
{% endblock %} diff --git a/src/agent_harness/ui.py b/src/agent_harness/ui.py index 64b0bc2..bc94a43 100644 --- a/src/agent_harness/ui.py +++ b/src/agent_harness/ui.py @@ -1,14 +1,15 @@ """Server-rendered browser control plane. -This module contains only HTTP presentation and browser-session concerns. All -state reads come from :class:`HarnessQueries`; no route opens SQLite or applies -a queue transition. Mutating controls are deliberately introduced by a later -milestone after their shared command/audit services are available. +This module contains HTTP presentation, browser-session concerns and narrow +action adapters. State reads come from :class:`HarnessQueries`; action routes +delegate to the same queue/fleet contracts as the JSON API and append an +operator event. No route opens SQLite or weakens a gate. """ from __future__ import annotations import asyncio +import json import secrets import time from collections.abc import AsyncIterator @@ -21,7 +22,9 @@ from fastapi.templating import Jinja2Templates from .browser_session import BrowserSession, BrowserSessions +from .events import WORK, Event from .query_service import HarnessQueries +from .work import BLOCKED, CLAIMED, DONE, PENDING TEMPLATE_DIR = Path(__file__).with_name("templates") STATIC_DIR = Path(__file__).with_name("static") @@ -47,6 +50,11 @@ def queries(request: Request) -> HarnessQueries: def render(request: Request, template: str, **context: object) -> HTMLResponse: session = sessions.get(request.cookies.get("harness_session")) + if session is not None and not secrets.compare_digest( + session.token_fingerprint, sessions.fingerprint(request.app.state.token or "") + ): + sessions.revoke(session.session_id) + session = None return templates.TemplateResponse( request=request, name=template, @@ -60,6 +68,24 @@ def render(request: Request, template: str, **context: object) -> HTMLResponse: def require_session(request: Request) -> BrowserSession: return sessions.require(request) + async def form(request: Request) -> dict[str, str]: + values = parse_qs((await request.body()).decode("utf-8"), keep_blank_values=True) + return {key: entries[0] for key, entries in values.items()} + + def action_audit( + request: Request, *, action: str, outcome: str, data: dict[str, object] + ) -> None: + """Record browser intent without ever retaining browser credentials.""" + event = Event( + ts=time.time(), + kind=WORK, + source="browser", + outcome=outcome, + data={"action": action, "operator": sessions.require(request).operator, **data}, + ) + sink = request.app.state.audit or request.app.state.store + sink.append([event]) + @app.get("/", include_in_schema=False) def root(request: Request) -> RedirectResponse: target = "projects" if sessions.get(request.cookies.get("harness_session")) else "login" @@ -107,7 +133,9 @@ async def login(request: Request) -> HTMLResponse | RedirectResponse: "login.html", error="That harness token was not accepted. Check the service URL and try again.", ) - session = sessions.create(operator="operator") + session = sessions.create( + operator="operator", token_fingerprint=sessions.fingerprint(expected) + ) response = RedirectResponse(url=request.url_for("projects"), status_code=303) response.set_cookie( "harness_session", @@ -123,8 +151,8 @@ async def login(request: Request) -> HTMLResponse | RedirectResponse: @app.post("/logout", name="logout", include_in_schema=False) async def logout(request: Request) -> RedirectResponse: session = require_session(request) - body = parse_qs((await request.body()).decode("utf-8"), keep_blank_values=True) - sessions.require_csrf(request, session, body.get("csrf_token", [""])[0]) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) sessions.revoke(session.session_id) response = RedirectResponse(url=request.url_for("login"), status_code=303) response.delete_cookie("harness_session", path=request.scope.get("root_path", "") or "/") @@ -134,7 +162,11 @@ async def logout(request: Request) -> RedirectResponse: def projects_page(request: Request) -> HTMLResponse: require_session(request) return render( - request, "projects.html", title="Projects", projects=queries(request).projects() + request, + "projects.html", + title="Projects", + projects=queries(request).projects(), + mode=("supervised" if request.app.state.fleet is not None else "monitoring-only"), ) @app.get("/work", name="work_page", response_class=HTMLResponse, include_in_schema=False) @@ -168,11 +200,145 @@ def work_item_page(request: Request, item_id: str, project_id: str = "default") evidence=query.evidence(project_id, item_id), ) + @app.post("/ui/actions/project-control", name="project_control_action", include_in_schema=False) + async def project_control_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "") + state = body.get("state", "") + reason = body.get("reason", "").strip() or None + if state not in {"paused", "draining", "stopped"}: + raise HTTPException(status_code=409, detail="resume requires the explicit start gate") + queue = request.app.state.queue + if queue is None or queue.get_project(project_id) is None: + raise HTTPException(status_code=404, detail="project not found") + fleet = request.app.state.fleet + if state == "stopped" and fleet is not None: + if hasattr(fleet, "request_stop"): + fleet.request_stop(project_id, reason=reason) + else: + fleet.stop(project_id, reason=reason) + else: + queue.set_control(state, reason, project_id=project_id) + action_audit( + request, + action="project_control", + outcome="operator_control_changed", + data={"project_id": project_id, "state": state, "reason": reason or ""}, + ) + return RedirectResponse(url=request.url_for("projects"), status_code=303) + + @app.post("/ui/actions/work/retry", name="retry_action", include_in_schema=False) + async def retry_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id, item_id = body.get("project_id", "default"), body.get("item_id", "") + queue = request.app.state.queue + record = queue.get(item_id, project_id=project_id) if queue is not None else None + if record is None: + raise HTTPException(status_code=404, detail="work item not found") + if record.state == CLAIMED and record.lease_until > time.time(): + raise HTTPException(status_code=409, detail="the item's claim is still live") + queue.release(item_id, PENDING, error=None, project_id=project_id) + action_audit( + request, + action="retry", + outcome="operator_retry", + data={"project_id": project_id, "item_id": item_id}, + ) + return RedirectResponse( + url=str(request.url_for("work_page")) + f"?project_id={project_id}", status_code=303 + ) + + @app.post("/ui/actions/work/block", name="block_action", include_in_schema=False) + async def block_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id, item_id = body.get("project_id", "default"), body.get("item_id", "") + reason = body.get("reason", "").strip() + if not reason: + raise HTTPException(status_code=422, detail="a block reason is required") + queue = request.app.state.queue + record = queue.get(item_id, project_id=project_id) if queue is not None else None + if record is None: + raise HTTPException(status_code=404, detail="work item not found") + override = body.get("override", "") == "true" + if record.state == CLAIMED and record.lease_until > time.time() and not override: + raise HTTPException(status_code=409, detail="the item's claim is still live") + if record.state == DONE and not override: + raise HTTPException(status_code=409, detail="the item is already done") + queue.release(item_id, BLOCKED, error=reason, project_id=project_id) + action_audit( + request, + action="block", + outcome="operator_block", + data={"project_id": project_id, "item_id": item_id, "reason": reason}, + ) + return RedirectResponse( + url=str(request.url_for("work_page")) + f"?project_id={project_id}", status_code=303 + ) + + @app.post("/ui/actions/hold/answer", name="answer_hold_action", include_in_schema=False) + async def answer_hold_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id, item_id = body.get("project_id", "default"), body.get("item_id", "") + text = body.get("text", "") + raw_data = body.get("data", "{}") or "{}" + try: + data = json.loads(raw_data) + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=422, detail="structured answer must be valid JSON" + ) from exc + if not isinstance(data, dict): + raise HTTPException(status_code=422, detail="structured answer must be a JSON object") + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + try: + from .holds import Answer as HoldAnswer + + queue.answer_hold( + item_id, + body.get("resume_token", ""), + HoldAnswer(text=text, data=data, who=session.operator), + project_id=project_id, + ) + except Exception as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + action_audit( + request, + action="answer_hold", + outcome="operator_answered_hold", + data={ + "project_id": project_id, + "item_id": item_id, + "has_text": bool(text), + "data_keys": sorted(data), + }, + ) + return RedirectResponse(url=request.url_for("holds"), status_code=303) + @app.get("/holds", name="holds", response_class=HTMLResponse, include_in_schema=False) def holds_page(request: Request, project_id: str | None = None) -> HTMLResponse: require_session(request) + hold_list = queries(request).holds(project_id) + queue = request.app.state.queue + resume_tokens = { + (hold.project_id, hold.item_id): hold.resume_token + for hold in (queue.holds.open_holds(project_id) if queue is not None else []) + } return render( - request, "holds.html", title="Holds", holds=queries(request).holds(project_id) + request, + "holds.html", + title="Holds", + holds=hold_list, + resume_tokens=resume_tokens, ) @app.get("/events", name="events", response_class=HTMLResponse, include_in_schema=False) @@ -244,7 +410,12 @@ def settings_page(request: Request) -> HTMLResponse: @app.get("/ui/fragments/projects", response_class=HTMLResponse, include_in_schema=False) def projects_fragment(request: Request) -> HTMLResponse: require_session(request) - return render(request, "fragments/project_cards.html", projects=queries(request).projects()) + return render( + request, + "fragments/project_cards.html", + projects=queries(request).projects(), + mode=("supervised" if request.app.state.fleet is not None else "monitoring-only"), + ) @app.get("/ui/fragments/work", response_class=HTMLResponse, include_in_schema=False) def work_fragment(request: Request, project_id: str | None = None) -> HTMLResponse: diff --git a/tests/test_ui.py b/tests/test_ui.py index 7240354..997062a 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -59,12 +59,80 @@ def test_login_cookie_is_opaque_and_pages_are_read_only(tmp_path: Path) -> None: projects = client.get("/projects") assert projects.status_code == 200 assert "Project P" in projects.text - assert "read-only" in projects.text.lower() + assert "monitoring-only" in projects.text.lower() assert client.get("/work?project_id=p").status_code == 200 assert "First item" in client.get("/work?project_id=p").text assert client.post("/api/projects/p/start").status_code == 401 +def test_browser_controls_require_csrf_and_delegate_queue_rules(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + login(client) + page = client.get("/work/T1?project_id=p") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + assert ( + client.post( + "/ui/actions/work/block", + data={"project_id": "p", "item_id": "T1", "reason": "needs a decision"}, + ).status_code + == 403 + ) + response = client.post( + "/ui/actions/work/block", + data={ + "csrf_token": csrf, + "project_id": "p", + "item_id": "T1", + "reason": "needs a decision", + }, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert client.get("/work/T1?project_id=p").text.count("blocked") >= 1 + audit = client.app.state.store.recent(limit=10) # type: ignore[attr-defined] + assert any(event["data"].get("operator") == "operator" for event in audit) + + +def test_monitoring_only_disables_project_controls(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + login(client) + html = client.get("/projects").text + assert "monitoring-only" in html + assert "no supervised worker pool" in html + assert "disabled" in html + + +def test_hold_answer_form_uses_opaque_resume_token_and_csrf(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + queue = client.app.state.queue # type: ignore[attr-defined] + queue.set_control("running", project_id="p") + claimed = queue.claim(owner="worker", project_id="p") + assert claimed is not None + hold = queue.hold( + "T1", project_id="p", question="Choose a path", owner="worker", max_seconds=60 + ) + login(client) + html = client.get("/holds?project_id=p").text + assert hold.resume_token in html + csrf = html.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + response = client.post( + "/ui/actions/hold/answer", + data={ + "csrf_token": csrf, + "project_id": "p", + "item_id": "T1", + "resume_token": hold.resume_token, + "text": "the safe path", + "data": '{"choice":"safe"}', + }, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert queue.holds.current("p", "T1") is None + + def test_ui_security_headers_and_packaged_assets(tmp_path: Path) -> None: with make_client(tmp_path) as client: response = client.get("/login") @@ -101,6 +169,13 @@ def test_logout_requires_csrf_and_revokes_session(tmp_path: Path) -> None: assert client.get("/projects").status_code == 401 +def test_token_rotation_revokes_existing_browser_session(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + login(client) + client.app.state.token = "rotated-token" # type: ignore[attr-defined] + assert client.get("/projects").status_code == 401 + + def test_item_detail_renders_durable_evidence_without_xss(tmp_path: Path) -> None: with make_client(tmp_path) as client: store = client.app.state.store # type: ignore[attr-defined] From b7593255d77f6e9c0a57067b6576a21ed00147e1 Mon Sep 17 00:00:00 2001 From: sprooty Date: Wed, 5 Aug 2026 11:31:47 +0000 Subject: [PATCH 03/12] docs: record GUI gate evidence --- docs/evidence/2026-08-05-gui-milestone-0-1.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index b173a47..8a18bd8 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -21,6 +21,15 @@ The pre-change baseline was recorded before feature edits: | `uv run ruff format --check .` | passed | | `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run mypy` | passed, 106 source files | +The same gates were run after the browser shell and guarded action bridge: + +| Command | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 116 files formatted | +| `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run mypy` | passed, 111 source files | + An earlier concurrent setup attempt and an earlier `/dev/shm` pytest attempt are not evidence: the former raced virtualenv creation and the latter filled the 64 MiB tmpfs. They are retained here only to explain why the valid baseline uses From a2456797d25e40170f905c0c55fff611c1601f69 Mon Sep 17 00:00:00 2001 From: sprooty Date: Wed, 5 Aug 2026 12:36:22 +0000 Subject: [PATCH 04/12] feat: add browser inception and graph reviews --- docs/evidence/2026-08-05-gui-milestone-0-1.md | 28 +++- src/agent_harness/query_service.py | 138 +++++++++++++++ src/agent_harness/templates/base.html | 1 + src/agent_harness/templates/graph.html | 12 ++ src/agent_harness/templates/plans.html | 92 ++++++++++ src/agent_harness/ui.py | 158 +++++++++++++++++- tests/test_ui.py | 114 +++++++++++++ 7 files changed, 533 insertions(+), 10 deletions(-) create mode 100644 src/agent_harness/templates/graph.html create mode 100644 src/agent_harness/templates/plans.html diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index 8a18bd8..aecb20c 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -1,4 +1,4 @@ -# GUI Milestones 0–1 evidence and Milestone 2 control bridge +# GUI Milestones 0–1 evidence and Milestones 2–3 slices Status: implementation slice complete; the full GUI program is not complete. @@ -30,6 +30,15 @@ The same gates were run after the browser shell and guarded action bridge: | `uv run ruff format --check .` | passed, 116 files formatted | | `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run mypy` | passed, 111 source files | +After the inception, plan-parse review and dependency-graph slices: + +| Command | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run pytest -q` | passed at 100%, 1 skipped (`/tmp/gui-pytest-m3-final.log`) | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed | +| `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run mypy` | passed, 111 source files | + An earlier concurrent setup attempt and an earlier `/dev/shm` pytest attempt are not evidence: the former raced virtualenv creation and the latter filled the 64 MiB tmpfs. They are retained here only to explain why the valid baseline uses @@ -53,6 +62,15 @@ not evidence: the former raced virtualenv creation and the latter filled the supervised worker pool. The initial control bridge adds only explicit, CSRF-protected pause/drain/stop, retry, block and hold-answer actions; each delegates the existing queue validation and appends an operator audit event. +- The Plans page now runs the typed inception draft, scope, question-resolution and + approval gates with CSRF and authenticated operator attribution. It renders a generated + `PLAN.md` preview/download without creating queue rows. +- Configured plans have a read-only parser review listing recognized items, skipped + headings, duplicate IDs, malformed/unresolved/external/decision/cross-project + dependencies, cycles and unattached arrows. +- The Dependency graph page renders the typed graph revision, edge state/evidence, + ready items, cycles and per-item readiness explanations from the same graph report + used by admission and the JSON API. - Typed item evidence exposes append-only events, durable attempt stages and retained holds without fabricating absent history or cost. - `/api/events/stream` resumes after a monotonic cursor and surfaces disconnects; @@ -68,7 +86,9 @@ delivery, JSON/API isolation, evidence and cursor validation. This is not a release claim for the full `GUI_PLAN`: browser automation, screen-reader checks, forced reconnect with replayed events, and all additional -accessibility/security/concurrency journeys remain to run. Milestones 2–8 -(remaining mutations, plan/adoption wizards, routing/operations panels, internal sessions, -extensions, automation, RBAC and recovery) remain explicitly incomplete. No +accessibility/security/concurrency journeys remain to run. Milestone 2 remains partial +(preflight/configuration/bulk actions/notifications and dependency-override controls are +not yet wired). Milestone 3 remains partial (adoption, plan dry-run/sync and richer graph +interactions are not yet wired). Milestones 4–8 (routing/operations panels, internal +sessions, extensions, automation, RBAC and recovery) remain explicitly incomplete. No real fleet or external deployment was used. diff --git a/src/agent_harness/query_service.py b/src/agent_harness/query_service.py index 929e5bb..9d9fd4d 100644 --- a/src/agent_harness/query_service.py +++ b/src/agent_harness/query_service.py @@ -10,19 +10,28 @@ import json import time +from pathlib import Path from typing import Any from .schemas import ( AttemptStageEvidence, + DependencyEdgeModel, + DependencyGraphReport, Event, EventPage, FleetControl, HoldList, HoldView, + ItemReadiness, LatestEvent, + OpenQuestion, + PlanItem, + PlanParseResult, ProjectList, ProjectSpec, ProjectSummary, + ProposalModel, + ReadinessReasonModel, RoleRoute, WorkEvidence, WorkItem, @@ -156,6 +165,135 @@ def holds(self, project_id: str | None = None) -> HoldList: open=[HoldView(**hold.as_dict(now)) for hold in queue.holds.open_holds(project_id)] ) + def inception(self, project_id: str) -> ProposalModel | None: + """Return the current inception proposal through the typed read boundary.""" + if self.queue is None: + return None + from .inception import Inception + + proposal = Inception(self.queue).current(project_id) + if proposal is None: + return None + return ProposalModel( + revision=proposal.revision, + created_at=proposal.created_at, + goal=proposal.goal, + assumptions=proposal.assumptions, + non_goals=proposal.non_goals, + risks=proposal.risks, + phases=proposal.phases, + questions=[ + OpenQuestion( + id=question.id, + question=question.question, + severity=question.severity, + why_it_matters=question.why_it_matters, + answer=question.answer, + deferred_reason=question.deferred_reason, + resolved_by=question.resolved_by, + ) + for question in proposal.questions + ], + feedback=proposal.feedback, + item_count=proposal.item_count(), + blocking_open=len(proposal.blocking_open()), + ) + + def inception_plan(self, project_id: str, name: str | None = None) -> str | None: + """Render the current proposal without creating queue rows or files.""" + if self.queue is None: + return None + from .inception import Inception + + try: + return Inception(self.queue).plan_markdown(project_id, name) + except ValueError: + return None + + @staticmethod + def plan_parse_markdown(markdown: str) -> PlanParseResult: + """Parse a document using the same loss-reporting parser as the API.""" + from .plan import parse_plan + + parsed = parse_plan(markdown) + report = parsed.dependency_report() + return PlanParseResult( + items=[ + PlanItem( + id=item.id, + title=item.title, + body=item.body, + labels=item.labels, + milestone=item.milestone, + depends_on=item.depends_on, + done=item.done, + line=item.line, + ) + for item in parsed.items + ], + skipped=[f"line {line}: {title}" for line, title in parsed.skipped], + duplicate_ids=parsed.duplicate_ids(), + unresolved_dependencies=report.unresolved, + external_dependencies=report.external, + decision_dependencies=report.decisions, + cross_project_dependencies=report.cross_project, + malformed_dependencies=report.malformed, + dependency_cycles=[list(cycle) for cycle in report.cycles], + unattached_arrows=[f"line {line}: {text}" for line, text in report.unattached_arrows], + ) + + def plan_parse(self, path: str) -> PlanParseResult | None: + """Read and parse a configured plan path, without writing anything.""" + target = Path(path) + if not target.is_file(): + return None + return self.plan_parse_markdown(target.read_text(encoding="utf-8")) + + def graph(self, project_id: str) -> DependencyGraphReport | None: + if self.queue is None or self.queue.get_project(project_id) is None: + return None + report = self.queue.graph.report(project_id) + records = {record.item_id: record for record in self.queue.items(project_id=project_id)} + return DependencyGraphReport( + project_id=report.project_id, + revision=report.revision, + edges=[ + DependencyEdgeModel( + source_item=edge.source_item, + target_kind=edge.target_kind, + target_id=edge.target_id, + required=edge.required, + resolver=edge.resolver, + state=edge.state, + evidence=edge.evidence, + provenance=edge.provenance, + revision=edge.revision, + ) + for edge in report.edges + ], + cycles=[list(cycle) for cycle in report.cycles], + ready=list(report.ready), + not_ready=[ + self._readiness_model(state, records.get(state.item_id)) + for state in report.not_ready + ], + ) + + @staticmethod + def _readiness_model(state: Any, record: WorkRecord | None) -> ItemReadiness: + return ItemReadiness( + project_id=state.project_id, + item_id=state.item_id, + ready=state.ready, + graph_revision=state.revision, + admitted_revision=record.admitted_revision if record is not None else None, + reasons=[ReadinessReasonModel(**reason.__dict__) for reason in state.reasons], + advisory=[ReadinessReasonModel(**reason.__dict__) for reason in state.advisory], + overridden=state.overridden, + override_reason=state.override_reason, + explanation=state.explain(), + ) + def events(self, since_id: int = 0, limit: int = 200) -> EventPage: rows = self.store.since_id(since_id, limit=limit) return EventPage( diff --git a/src/agent_harness/templates/base.html b/src/agent_harness/templates/base.html index c4e35da..dded751 100644 --- a/src/agent_harness/templates/base.html +++ b/src/agent_harness/templates/base.html @@ -21,6 +21,7 @@ Events Analytics Plans + Graph Sessions Settings diff --git a/src/agent_harness/templates/graph.html b/src/agent_harness/templates/graph.html new file mode 100644 index 0000000..7d5c252 --- /dev/null +++ b/src/agent_harness/templates/graph.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

Coordination plane

Dependency graph

Revision {{ graph.revision if graph else '—' }}
+
+ {% if not graph %}

No graph available

Choose a configured project.

{% else %} +

Admission

Readiness

{{ graph.ready|length }} ready
+ {% if graph.cycles %}
Cycles block work.
    {% for cycle in graph.cycles %}
  • {{ cycle|join(' → ') }}
  • {% endfor %}
{% endif %} + {% if graph.not_ready %}
{% for item in graph.not_ready %}

{{ item.item_id }}

{{ item.explanation }}

{% if item.advisory %}

Advisory: {{ item.advisory|length }} edge(s)

{% endif %}
{% endfor %}
{% else %}

Every item is ready at this revision.

{% endif %} +
+

Edges

{% if graph.edges %}
{% for edge in graph.edges %}{% endfor %}
Waiting itemTargetKindStateEvidence
{{ edge.source_item }}{{ edge.target_id }}{{ edge.target_kind }}{{ edge.state }}{% if not edge.required %} (advisory){% endif %}{{ edge.evidence }}
{% else %}

No dependency edges are declared.

{% endif %}
+ {% endif %} +{% endblock %} diff --git a/src/agent_harness/templates/plans.html b/src/agent_harness/templates/plans.html new file mode 100644 index 0000000..c3d9c8f --- /dev/null +++ b/src/agent_harness/templates/plans.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block content %} +
+

Inception and plan lifecycle

Plans

+ Human gates +
+
+
+ + +
+
+ {% if not project_id %} +

No projects configured

Describe a project below to open an inception draft.

+ {% else %} +
+

Describe a project

+

Starting a draft writes only the queue setting. It creates no repository, issue, branch, or queue row.

+
+ + + + + + +
+
+ {% if plan_path %} +
+

Configured plan

Parse review

Read-only
+

{{ plan_path }} is parsed without creating or updating issues.

+ {% if parse_result %} +
Recognized items
{{ parse_result.items|length }}
Skipped headings
{{ parse_result.skipped|length }}
Duplicate ids
{{ parse_result.duplicate_ids|length }}
Dependency cycles
{{ parse_result.dependency_cycles|length }}
+ {% if parse_result.skipped %}

Skipped headings

    {% for finding in parse_result.skipped %}
  • {{ finding }}
  • {% endfor %}
{% endif %} + {% if parse_result.duplicate_ids %}

Duplicate ids

    {% for item_id, lines in parse_result.duplicate_ids.items() %}
  • {{ item_id }}: lines {{ lines|join(', ') }}
  • {% endfor %}
{% endif %} + {% if parse_result.unresolved_dependencies %}

Unresolved dependencies

    {% for item_id, deps in parse_result.unresolved_dependencies.items() %}
  • {{ item_id }}: {{ deps|join(', ') }}
  • {% endfor %}
{% endif %} + {% if parse_result.malformed_dependencies %}

Malformed dependencies

    {% for item_id, deps in parse_result.malformed_dependencies.items() %}
  • {{ item_id }}: {{ deps|join(', ') }}
  • {% endfor %}
{% endif %} + {% if parse_result.external_dependencies %}

External dependencies

    {% for item_id, deps in parse_result.external_dependencies.items() %}
  • {{ item_id }}: {{ deps|join(', ') }}
  • {% endfor %}
{% endif %} + {% if parse_result.decision_dependencies %}

Human decisions

    {% for item_id, deps in parse_result.decision_dependencies.items() %}
  • {{ item_id }}: {{ deps|join(', ') }}
  • {% endfor %}
{% endif %} + {% if parse_result.cross_project_dependencies %}

Cross-project dependencies

    {% for item_id, deps in parse_result.cross_project_dependencies.items() %}
  • {{ item_id }}: {{ deps|join(', ') }}
  • {% endfor %}
{% endif %} + {% if parse_result.dependency_cycles %}

Cycles

    {% for cycle in parse_result.dependency_cycles %}
  • {{ cycle|join(' → ') }}
  • {% endfor %}
{% endif %} + {% if parse_result.unattached_arrows %}

Unattached dependency arrows

    {% for finding in parse_result.unattached_arrows %}
  • {{ finding }}
  • {% endfor %}
{% endif %} + {% else %}

The configured plan file is missing or unreadable. Nothing was silently dropped.

{% endif %} +
+ {% endif %} + {% if proposal %} +
+

Revision {{ proposal.revision }}

{{ proposal.goal or 'Proposal' }}

{{ proposal.item_count }} items
+ {% if proposal.assumptions %}

Assumptions

    {% for value in proposal.assumptions %}
  • {{ value }}
  • {% endfor %}
{% endif %} + {% if proposal.non_goals %}

Not doing

    {% for value in proposal.non_goals %}
  • {{ value }}
  • {% endfor %}
{% endif %} + {% if proposal.risks %}

Risks

    {% for value in proposal.risks %}
  • {{ value }}
  • {% endfor %}
{% endif %} +
+ + + +
+
+
+

Approval gate

Open questions

{{ proposal.blocking_open }} blocking
+ {% if proposal.questions %} + {% for question in proposal.questions %} +
+

{{ question.id }} · {{ question.question }}

{{ question.why_it_matters }}

+ {% if question.answer %}

Answered by {{ question.resolved_by or 'operator' }}: {{ question.answer }}

{% elif question.deferred_reason %}

Deferred: {{ question.deferred_reason }}

{% else %} +
+ + + + + +
+ {% endif %} +
+ {% endfor %} + {% else %}

The proposal has no open questions.

{% endif %} +
+ + +
+
+ {% if plan_markdown %} +

Generated document

PLAN.md preview

Download
{{ plan_markdown }}
+ {% endif %} + {% else %} +

No proposal yet

Open a draft, then generate a proposal through the scoping role.

+ {% endif %} + {% endif %} +{% endblock %} diff --git a/src/agent_harness/ui.py b/src/agent_harness/ui.py index bc94a43..d2286a5 100644 --- a/src/agent_harness/ui.py +++ b/src/agent_harness/ui.py @@ -23,6 +23,7 @@ from .browser_session import BrowserSession, BrowserSessions from .events import WORK, Event +from .inception import Inception from .query_service import HarnessQueries from .work import BLOCKED, CLAIMED, DONE, PENDING @@ -86,6 +87,17 @@ def action_audit( sink = request.app.state.audit or request.app.state.store sink.append([event]) + def inception_for(request: Request) -> Inception: + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + return Inception(queue, model_client=request.app.state.model_client) + + def project_redirect(request: Request, project_id: str) -> RedirectResponse: + return RedirectResponse( + url=str(request.url_for("plans")) + f"?project_id={project_id}", status_code=303 + ) + @app.get("/", include_in_schema=False) def root(request: Request) -> RedirectResponse: target = "projects" if sessions.get(request.cookies.get("harness_session")) else "login" @@ -324,6 +336,113 @@ async def answer_hold_action(request: Request) -> RedirectResponse: ) return RedirectResponse(url=request.url_for("holds"), status_code=303) + @app.post("/ui/actions/inception/start", name="inception_start_action", include_in_schema=False) + async def inception_start_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + overview = body.get("overview", "").strip() + if not project_id or not overview: + raise HTTPException(status_code=422, detail="project id and overview are required") + try: + inception_for(request).start(project_id, overview) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + action_audit( + request, + action="inception_start", + outcome="operator_started_inception", + data={"project_id": project_id}, + ) + return project_redirect(request, project_id) + + @app.post("/ui/actions/inception/scope", name="inception_scope_action", include_in_schema=False) + async def inception_scope_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + if not project_id: + raise HTTPException(status_code=422, detail="project id is required") + try: + inception_for(request).scope(project_id, body.get("feedback", "").strip() or None) + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + action_audit( + request, + action="inception_scope", + outcome="operator_requested_scope", + data={"project_id": project_id}, + ) + return project_redirect(request, project_id) + + @app.post( + "/ui/actions/inception/question", + name="inception_question_action", + include_in_schema=False, + ) + async def inception_question_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + question_id = body.get("question_id", "").strip() + answer = body.get("answer", "").strip() or None + defer_reason = body.get("defer_reason", "").strip() or None + severity = body.get("severity", "").strip() or None + if not project_id or not question_id or not (answer or defer_reason or severity): + raise HTTPException( + status_code=422, + detail="project, question, and an answer, deferral, or severity are required", + ) + try: + inception_for(request).resolve( + project_id, + question_id, + answer=answer, + defer_reason=defer_reason, + severity=severity, + who=session.operator, + ) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + action_audit( + request, + action="inception_question", + outcome="operator_resolved_question", + data={"project_id": project_id, "question_id": question_id}, + ) + return project_redirect(request, project_id) + + @app.post( + "/ui/actions/inception/approve", + name="inception_approve_action", + include_in_schema=False, + ) + async def inception_approve_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + if not project_id: + raise HTTPException(status_code=422, detail="project id is required") + try: + inception_for(request).approve(project_id) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + action_audit( + request, + action="inception_approve", + outcome="operator_approved_scope", + data={"project_id": project_id}, + ) + return project_redirect(request, project_id) + @app.get("/holds", name="holds", response_class=HTMLResponse, include_in_schema=False) def holds_page(request: Request, project_id: str | None = None) -> HTMLResponse: require_session(request) @@ -370,16 +489,43 @@ def analytics_page(request: Request) -> HTMLResponse: ) @app.get("/plans", name="plans", response_class=HTMLResponse, include_in_schema=False) - def plans_page(request: Request) -> HTMLResponse: + def plans_page(request: Request, project_id: str | None = None) -> HTMLResponse: require_session(request) + query = queries(request) + projects = query.projects() + selected = project_id or ( + projects.projects[0].project.project_id if projects.projects else None + ) + proposal = query.inception(selected) if selected else None + selected_summary = query.project(selected) if selected else None + plan_path = selected_summary.project.plan_path if selected_summary else None return render( request, - "placeholder.html", + "plans.html", title="Plans", - message=( - "Plan review is available through the typed API while its review wizard " - "is being delivered." - ), + projects=projects, + project_id=selected, + proposal=proposal, + plan_markdown=query.inception_plan(selected, selected) if selected else None, + plan_path=plan_path, + parse_result=query.plan_parse(plan_path) if plan_path else None, + ) + + @app.get("/graph", name="graph", response_class=HTMLResponse, include_in_schema=False) + def graph_page(request: Request, project_id: str | None = None) -> HTMLResponse: + require_session(request) + query = queries(request) + projects = query.projects() + selected = project_id or ( + projects.projects[0].project.project_id if projects.projects else None + ) + return render( + request, + "graph.html", + title="Dependency graph", + projects=projects, + project_id=selected, + graph=query.graph(selected) if selected else None, ) @app.get("/sessions", name="sessions", response_class=HTMLResponse, include_in_schema=False) diff --git a/tests/test_ui.py b/tests/test_ui.py index 997062a..0156bb5 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -2,8 +2,10 @@ from __future__ import annotations +import json import time from pathlib import Path +from typing import Any from fastapi.testclient import TestClient @@ -14,6 +16,27 @@ TOKEN = "browser-test-token" # noqa: S105 - fixture value +PROPOSAL = { + "goal": "A safe browser plan.", + "assumptions": ["The queue is durable"], + "non_goals": ["Automatic execution"], + "risks": ["Scope may change"], + "phases": [{"id": "P0", "title": "Foundation", "items": []}], + "open_questions": [ + { + "id": "Q1", + "question": "Which branch?", + "severity": "blocking", + "why_it_matters": "It changes the checkout.", + } + ], +} + + +class FakeScoper: + def call(self, _role: str, _messages: list[dict[str, Any]]) -> str: + return json.dumps(PROPOSAL) + def make_client(tmp_path: Path, *, token: str | None = TOKEN) -> TestClient: store = EventStore(tmp_path / "events.sqlite") @@ -221,3 +244,94 @@ def test_ui_named_urls_honor_root_path(tmp_path: Path) -> None: assert response.status_code == 200 assert "/harness/login" in response.text assert "/harness/assets/app.css" in response.text + + +def test_plans_surface_scoping_gate_and_plan_preview(tmp_path: Path) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + app = create_api(store, queue=queue, token=TOKEN, model_client=FakeScoper()) + with TestClient(app) as client: + login(client) + csrf = ( + client.get("/plans?project_id=p") + .text.split('name="csrf_token" value="', 1)[1] + .split('"', 1)[0] + ) + assert "Describe a project" in client.get("/plans?project_id=p").text + started = client.post( + "/ui/actions/inception/start", + data={"csrf_token": csrf, "project_id": "p", "overview": "A browser plan"}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert started.status_code == 303 + scoped = client.post( + "/ui/actions/inception/scope", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert scoped.status_code == 303 + html = client.get("/plans?project_id=p").text + assert "Which branch?" in html and "Approve scope" in html + assert "disabled" in html + question = client.post( + "/ui/actions/inception/question", + data={ + "csrf_token": csrf, + "project_id": "p", + "question_id": "Q1", + "answer": "main", + }, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert question.status_code == 303 + approved = client.post( + "/ui/actions/inception/approve", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert approved.status_code == 303 + html = client.get("/plans?project_id=p").text + assert "PLAN.md preview" in html + assert "A safe browser plan." in html + + +def test_plans_show_loss_report_for_configured_plan(tmp_path: Path) -> None: + plan_path = tmp_path / "PLAN.md" + plan_path.write_text( + "# Plan\n\n## Narrative\n\n### T1 — One\n\nDo one.\n\n" + "### T1 — Duplicate\n\nDo two.\n\n### T2 — Two\n\ndepends on: T9\n", + encoding="utf-8", + ) + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P", plan_path=str(plan_path))) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + html = client.get("/plans?project_id=p").text + assert "Parse review" in html + assert "Duplicate ids" in html + assert "unresolved" in html.lower() + + +def test_graph_page_shows_typed_edges_and_readiness(tmp_path: Path) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + queue.add( + [ + WorkRecord(item_id="T1", title="First", brief="first"), + WorkRecord(item_id="T2", title="Second", brief="second", depends_on=["T1"]), + ], + project_id="p", + ) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + html = client.get("/graph?project_id=p").text + assert "Dependency graph" in html + assert "T2" in html and "T1" in html + assert "blocked" in html.lower() From 4059d22dcdcf77200870273638b728601661de81 Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 02:04:31 +0000 Subject: [PATCH 05/12] feat: continue GUI control-plane milestones --- GUI_PLAN.md | 154 +++- docs/evidence/2026-08-05-gui-milestone-0-1.md | 106 ++- src/agent_harness/api.py | 246 +++--- src/agent_harness/audit.py | 27 + src/agent_harness/browser_session.py | 92 ++- src/agent_harness/fleet.py | 58 +- src/agent_harness/plan_service.py | 180 ++++ src/agent_harness/project_service.py | 90 ++ src/agent_harness/query_service.py | 350 ++++++-- src/agent_harness/routing_service.py | 93 +++ src/agent_harness/schemas.py | 139 +++- src/agent_harness/session_executor.py | 2 + src/agent_harness/static/app.css | 9 +- src/agent_harness/static/app.js | 4 +- src/agent_harness/store.py | 9 + src/agent_harness/templates/analytics.html | 61 +- src/agent_harness/templates/base.html | 1 + src/agent_harness/templates/events.html | 16 +- .../templates/fragments/project_cards.html | 2 +- src/agent_harness/templates/graph.html | 10 +- .../templates/plan_sync_review.html | 18 + src/agent_harness/templates/plans.html | 6 + src/agent_harness/templates/preflight.html | 12 + .../templates/project_configuration.html | 33 + .../project_configuration_review.html | 9 + src/agent_harness/templates/roles_review.html | 15 + src/agent_harness/templates/settings.html | 16 + src/agent_harness/templates/workers.html | 25 + src/agent_harness/ui.py | 775 +++++++++++++++++- src/agent_harness/work.py | 81 +- tests/test_api.py | 117 ++- tests/test_audit.py | 60 ++ tests/test_ui.py | 552 ++++++++++++- tests/test_work.py | 14 + 34 files changed, 3109 insertions(+), 273 deletions(-) create mode 100644 src/agent_harness/plan_service.py create mode 100644 src/agent_harness/project_service.py create mode 100644 src/agent_harness/routing_service.py create mode 100644 src/agent_harness/templates/plan_sync_review.html create mode 100644 src/agent_harness/templates/preflight.html create mode 100644 src/agent_harness/templates/project_configuration.html create mode 100644 src/agent_harness/templates/project_configuration_review.html create mode 100644 src/agent_harness/templates/roles_review.html create mode 100644 src/agent_harness/templates/workers.html diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 94130dc..21fd5c1 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -1,10 +1,116 @@ # Agent Harness GUI Implementation Plan -**Status:** Accepted product direction; implementation not started +**Status:** Accepted product direction; implementation in progress. Milestones 0–1 and +substantial Milestones 2–3 slices are implemented, as are the Milestone 4 routing, +worker-inventory, event-explorer and typed-analytics slices. Milestones 2–4 remain +partial and Milestones 5–8 remain incomplete. **Plan date:** 2026-08-05 **Product boundary:** The GUI is built, packaged, served, tested, and documented entirely inside `agent-harness`. +**Implementation tree:** branch `codex/gui-plan` at +`/home/sprooty/Working/Active/apps/agent-harness-worktrees/gui-plan`. The browser control +plane is implemented in `src/agent_harness/ui.py`, `query_service.py`, +`browser_session.py`, `templates/`, and `static/`; its in-process journeys are in +`tests/test_ui.py` and `tests/test_ui_packaging.py`. The current tree includes the GUI +foundation commits `264294d` through `a245679` plus an uncommitted continuation containing +the subsequent slices described below. Build and gate results are recorded in +[`docs/evidence/2026-08-05-gui-milestone-0-1.md`](docs/evidence/2026-08-05-gui-milestone-0-1.md). + +This worktree is the implementation source of truth for this plan. The repository's +default `main` checkout is not the GUI implementation tree. + +## 0. Current implementation status + +This section is the resume point as of 2026-08-06. Continue in this worktree; the default +`main` checkout is not the implementation source of truth. + +At the start of this continuation, the branch head is `a245679`: four GUI commits ahead of +the merge base and 18 commits behind `main` at `be7abe1`. The Milestone 2–4 continuation is +still a dirty worktree, not a commit. Preserve and verify that continuation, checkpoint it, +then integrate current `main` before treating later implementation or gate results as +release evidence. + +### 0.1 Landed foundation + +The branch currently points at `a245679` and contains these GUI commits: + +1. `264294d feat: add self-contained browser control plane` +2. `8b202a9 feat: add guarded browser control actions` +3. `b759325 docs: record GUI gate evidence` +4. `a245679 feat: add browser inception and graph reviews` + +Together they establish the in-repository GUI policy, packaged/authenticated shell, +monitoring views, browser action bridge, item evidence, SSE stream, inception flow, plan +parse review, and typed dependency graph. + +### 0.2 Implemented Milestones 2–4 slices + +The implementation now includes: + +1. project preflight and explicit base-check views; +2. a project configuration editor using the public `ProjectSpec`, secret-safe rendering, + a server-held one-time review, and an atomic `updated_at` compare-and-set on apply; +3. explicit, revision-scoped dependency overrides with authenticated reason and audit; +4. shared project, plan and routing application services instead of duplicate HTML/API + behavior; +5. a two-step plan-sync flow: parse and read-only GitHub preview, followed by a separate + apply bound to the exact plan bytes, persisted project target, and reviewed remote + counts, with refusal audit for local, project, remote-preview and GitHub failures; +6. shared plan-sync finding gates for JSON and browser clients, including unresolved, + malformed, cyclic and unattached dependencies; +7. a generic global role-routing editor using a one-time review and atomic setting + compare-and-set, complete `RoleRoute` field persistence, credential-safe endpoint + rendering, used/unused explanations and reviewer-independence warnings; +8. a typed worker-pool inventory joining live runtime identities to durable claims, + leases, heartbeats, stage evidence, failures and project-scoped abandoned sessions, + while reporting monitoring-only deployments without inventing a registry; +9. URL-backed event filters for project, item, worker, endpoint, role, model, outcome, + error class, reason kind and time, including filtered SSE resume on the same monotonic + cursor; and +10. focused in-process journeys for replay, stale project/routing configuration, CSRF, + preview-without-write, plan mutation, remote preview drift and refused external writes. + +### 0.3 Current verification + +The complete implementation tree has the following evidence: + +| Check | Most recent result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-resume.vVPDQ5 uv run pytest -q` | Passed at 100%, 1 skipped | +| `uv run ruff check .` | Passed | +| `uv run ruff format --check .` | Passed, 119 files checked | +| `TMPDIR=/tmp/agent-harness-gui-resume-mypy.KCzFMT uv run mypy` | Passed, 114 source files | + +The full suite includes the wheel packaging and in-process browser journeys. Browser +automation, accessibility tooling, a forced browser SSE reconnect, real GitHub concurrency, +and real fleet/deployment journeys remain open; the in-process stateful GitHub double does +not prove a transaction across remote preview and writes. + +### 0.4 Known limits + +1. GitHub has no transaction spanning the second preview and subsequent issue writes. The + service re-previews immediately before applying and refuses visible drift, but a remote + actor can still change the backlog between those operations. +2. The role editor is global. Per-project route overrides remain available through project + configuration but do not yet have a specialized routing comparison view. +3. Milestone 2 still lacks bulk-action review and notification delivery. Milestone 3 still + lacks the typed adoption HTTP/wizard flow and richer interactive graph controls. +4. Milestone 4 rate-limit, cost, delivery, audit-health, worker inventory and filtered + event exploration are implemented as read-only typed views. Confirmed GitHub + reconciliation, audit-maintenance controls and process-log metrics remain incomplete. + +### 0.5 Exact next work + +First revalidate and checkpoint the uncommitted Milestone 2–4 continuation, then integrate +the 18 newer `main` commits and rerun proportionate regression gates. Continue with +Milestone 4.8–4.9 only from that reconciled base: add confirmed GitHub reconciliation and +audit-maintenance reviews/actions, then expose session-independent process and gateway-log +metrics through typed, redacted APIs. The analytics views now keep `rpm`, `window_cap`, +`terminal_cap` and `unclassified` separate; show supplied baselines and denominators; keep +known spend distinct from unpriced calls; and retain table evidence behind every summary. +Do not mark Milestone 4 complete until every 4.1–4.9 acceptance requirement is evidenced. + ## 1. Product decision ### 1.1 Owner ruling @@ -27,14 +133,16 @@ invariants, append-only event history, honest reporting, or settled decisions D1 ### 1.2 Consequence for the repository -The current tree still enforces the old ruling in `AGENTS.md`, `README.md`, -`docs/ARCHITECTURE.md`, `docs/MULTI-PROJECT-PLAN.md`, `docs/USAGE.md`, CLI help, the -`api.py` module documentation, and `tests/test_api.py`. Implementation must begin by -changing those statements and replacing the test that asserts `/` is a 404. +The policy-alignment work described here has landed in the implementation tree. The old +host-owned GUI statements were replaced in `AGENTS.md`, `README.md`, +`docs/ARCHITECTURE.md`, `docs/MULTI-PROJECT-PLAN.md`, `docs/USAGE.md`, deployment guidance, +CLI help, the `api.py` module documentation, and API tests. The existing JSON API remains +public and typed; the browser routes are an additional first-party client over the same +FastAPI process and shared services. -Until that policy-alignment change lands, GUI implementation is blocked by contradictory -repository instructions. The first milestone resolves the contradiction explicitly rather -than allowing code and policy to drift. +The remaining milestones below are implementation work, not a prerequisite policy +rewrite. Acceptance claims must continue to follow the evidence document and must not +promote an unexercised slice to proven behavior. ### 1.3 Reference products @@ -131,28 +239,28 @@ Each expensive or externally visible operation must have: ## 4. Current baseline and gaps -The repository already contains most of the control-plane JSON contracts, but no browser -application. The implementation should reuse these contracts and add only the missing -capabilities. +The implementation branch now contains the browser foundation and several guarded control +slices. The remaining work must continue to reuse the typed JSON contracts and shared +services rather than growing a second interpretation in HTML controllers. | Capability | Current state | Required work | |---|---|---| -| Application shell | `/` deliberately returns 404 | Templates, packaged assets, navigation, login, error and reconnect states | -| Work | List, detail, retry, block, answer, readiness, graph, and dependency override APIs exist | Board/detail views, filters, action forms, item-scoped event and attempt evidence | -| Projects | List, create/update, detail, start, stop, preflight, base checks, readiness, and control APIs exist | Overview/configuration views; explicit per-project pause and drain contracts | -| Holds | List and answer APIs exist | Inbox, structured answer form, expiry handling, notifications, verified operator attribution | -| Events | Monotonic cursor APIs exist | SSE transport preserving cursor semantics, filtering, reconnect and polling fallback | +| Application shell | Packaged templates/assets, browser sessions, CSRF, navigation and security headers are implemented | Rich error states, reconnect proof, browser/accessibility journeys | +| Work | Board/detail, retry, block, hold answer, typed evidence, readiness, graph and dependency override paths exist | URL-backed filters/sorting, bulk review and remaining refusal journeys | +| Projects | Overview, control actions, preflight/base-check view and reviewed configuration editor exist | Continue/force-start review and complete mutation/refusal parity | +| Holds | Authenticated inbox and structured answer form exist | Draft preservation on expiry/mismatch and notifications | +| Events | SSE over the monotonic cursor and event views exist | Forced reconnect/replay proof, richer filtering and polling fallback evidence | | Audit | Health, events, cost, delivery, rollups, baselines, maintenance, and reconcile APIs exist | Dashboards, confirmations, reason/operator audit for actions, missing breakdowns | -| Plans | Inception and plan parse/sync APIs exist | Wizards, revision UI, dry-run review, adoption HTTP API | +| Plans | Inception, question gates, generated preview, parse-loss report and uncommitted reviewed plan sync exist | Finish plan-sync review items above; adoption HTTP API and wizard | | Routing | Role map and route-health APIs exist | Editor, used/unused explanation, independence warnings, secret-safe validation | | Workers | Project summaries expose counts and failures | Worker/claim/lease/heartbeat/session inventory API | | Attempts and artifacts | Durable data exists in internal modules | Typed item-scoped API for attempts, stages, patches, diffs, and evidence links | | Sessions/chat/terminal | Agent-harness can use an optional external session-host protocol | New in-repository generic session subsystem; external-host behavior does not satisfy this plan | | Memory/skills/tools | No generic product APIs | New extension contracts and installed-metadata boundaries | | Scheduling/channels | No generic product APIs | New scheduler, trigger, notification, and channel contracts | -| Identity/RBAC | Bearer token only; several actions accept caller-supplied `who` | Browser session auth, CSRF defense, authenticated operator identity, later RBAC | +| Identity/RBAC | Bearer API plus bounded opaque browser sessions, CSRF and authenticated operator attribution | Later multi-user identity and RBAC | | Backup/restore | Operational documentation only | WAL-aware snapshot, validation, restore plan, and guarded UI workflow | -| Policy | Repository forbids local HTML | Explicit policy and test reversal in Milestone 0 | +| Policy | In-repository GUI ruling and route/test reversal landed | Keep generic-core, gate and append-only invariants enforced | ## 5. Technical architecture @@ -334,7 +442,8 @@ must distinguish stale displayed data from current state. **Acceptance:** From a wheel-installed `agent-harness serve`, an authenticated operator can inspect every project and item, find every open hold, follow live events through a forced disconnect/reconnect, and diagnose fixture failures without MyDevEnv, a CDN, or direct -database access. No GUI route can yet change harness state. +database access. The current implementation has exercised these read paths; later browser, +screen-reader, reconnect, and release journeys remain open. ### Milestone 2 — Explicit controls and human-in-the-loop actions @@ -348,7 +457,10 @@ overrides require a review dialog that shows every blocker and warning. 2.3. Build the project configuration editor for repository, checkout, base branch, checks, fixes, role routes, worker limit, attempt limit, wall-clock budget, spend ceiling, disk -floor, plan path, and durability. Secret values are never echoed. +floor, plan path, hold expiry, and durability. Secret values are never echoed. The current +slice validates the public `ProjectSpec`, holds a one-time review server-side, audits the +apply, and refuses replay or stale persisted versions; bulk editing and richer route review +remain open. 2.4. Implement Retry, Block, Answer, and revision-scoped Dependency override forms. Add any missing reason/operator fields to typed API requests and record their audit outcomes. diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index aecb20c..f462265 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -1,4 +1,4 @@ -# GUI Milestones 0–1 evidence and Milestones 2–3 slices +# GUI Milestones 0–1 evidence and Milestones 2–4 slices Status: implementation slice complete; the full GUI program is not complete. @@ -6,10 +6,21 @@ Status: implementation slice complete; the full GUI program is not complete. - Branch: `codex/gui-plan` - Worktree: `/home/sprooty/Working/Active/apps/agent-harness-worktrees/gui-plan` -- Plan: `GUI_PLAN.md`, SHA-256 `b8a5aa97c79dafdcb90502903df82445d7bdc274eb8c81a0deb6bccd7a68bb3d` +- Committed branch head: `a245679`; the slices after that commit are currently an + uncommitted continuation and therefore are not yet commit-addressable evidence +- Branch relationship at the 2026-08-06 resume check: four commits ahead of the merge base + and 18 commits behind `main` at `be7abe1` +- Plan: `GUI_PLAN.md`; its exact post-checkpoint blob/commit identifier must replace the + transient working-tree hash when this continuation is checkpointed - Python: CPython 3.14.4; package installed with `uv sync --all-extras` - Temporary test volume: `/tmp` (separate ext4 filesystem, 361 GiB free at baseline) +This evidence file was refreshed on 2026-08-06 after the worker-inventory, filtered-event +and typed-analytics slices. The complete four-gate result below predates this +documentation-only resume edit. It verifies the implementation continuation as it then +stood, but does not remove the need to rerun the gates after checkpointing and integrating +current `main`. + ## Commands and results The pre-change baseline was recorded before feature edits: @@ -39,6 +50,43 @@ After the inception, plan-parse review and dependency-graph slices: | `uv run ruff format --check .` | passed | | `TMPDIR=/tmp/agent-harness-gui-baseline.HoeEBo uv run mypy` | passed, 111 source files | +After the project configuration, plan synchronization and global role-routing slices: + +| Command | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-full.zPtjTH uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 119 files checked | +| `TMPDIR=/tmp/agent-harness-gui-full.zPtjTH uv run mypy` | passed, 114 source files | + +After the worker-pool inventory and URL-backed event-filter slices: + +| Check | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-workers.XXXXXX uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 119 files already formatted | +| `TMPDIR=/tmp/agent-harness-gui-workers-mypy.XXXXXX uv run mypy` | passed, 114 source files | + +After the typed analytics projection and browser panels: + +| Check | Result | +|---|---| +| `TMPDIR= uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 119 files already formatted | +| `TMPDIR= uv run mypy` | passed, 114 source files | + +At the 2026-08-06 documentation-first resume checkpoint, against the complete dirty +continuation: + +| Check | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-resume.vVPDQ5 uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 119 files already formatted | +| `TMPDIR=/tmp/agent-harness-gui-resume-mypy.KCzFMT uv run mypy` | passed, 114 source files | + An earlier concurrent setup attempt and an earlier `/dev/shm` pytest attempt are not evidence: the former raced virtualenv creation and the latter filled the 64 MiB tmpfs. They are retained here only to explain why the valid baseline uses @@ -71,6 +119,41 @@ not evidence: the former raced virtualenv creation and the latter filled the - The Dependency graph page renders the typed graph revision, edge state/evidence, ready items, cycles and per-item readiness explanations from the same graph report used by admission and the JSON API. +- Dependency overrides now have an explicit browser review/action path. The form + requires a reason, records the authenticated operator and graph revision, delegates + to the same revision-scoped graph override as the JSON API, and displays the + resulting audit row without hiding the real edge state. +- Project configuration now has a typed, secret-safe editor. Review renders the changed + fields and consequences without applying them; apply consumes a one-time server-held + payload, records the authenticated operator, and uses an atomic `updated_at` predicate so + a concurrent API/operator edit cannot be overwritten by a stale browser page. +- Plan sync now has a separate read-only remote preview and explicit apply. The apply is + bound to the reviewed plan bytes, project repository/path/version and remote counts; + local, project, remote-preview and GitHub refusals are audited without credentials. + A stateful remote-drift journey proves a changed second preview performs zero writes. + GitHub does not provide a transaction across the second preview and later issue writes, + so the residual remote race remains explicit. +- JSON and browser plan sync use the same finding gate for duplicate, unresolved, + malformed, cyclic and unattached dependencies. The browser control appears only when + both the plan path and repository are configured. +- Settings now includes a generic global role-routing editor. It preserves fallback order, + route preset and price reference as well as the original route fields, renders endpoints + without URL credentials/query strings, identifies routes unused by the active executor, + reports reviewer independence, and applies through a one-time atomic compare-and-set. +- `/api/workers` and the Workers page expose a typed read-only inventory. Live identities + come from the attached fleet; project/item claims, leases, heartbeat timestamps and stage + evidence come from durable queue/audit state; failures and project-scoped abandoned + sessions remain distinct evidence. Monitoring-only mode returns no invented workers. +- The event explorer now accepts URL-backed project, item, worker, endpoint, role, model, + outcome, error-class, reason-kind and Unix-time filters. Sparse filters scan ordered rows + without skipping a later match, and filtered SSE reconnect uses the same exclusive + monotonic cursor. +- `/api/analytics` and `/analytics` now share a typed read projection. Rate-limit panels + retain the three classified classes plus `unclassified` and an explicit denominator; + cost panels count model calls and keep known spend separate from unpriced calls; delivery + panels show event and distinct-item denominators; baselines and daily rollups remain + visible; and missing, degraded or partial audit history is called out rather than inferred + away. Focused API/audit/browser journeys cover these caveats. - Typed item evidence exposes append-only events, durable attempt stages and retained holds without fabricating absent history or cost. - `/api/events/stream` resumes after a monotonic cursor and surfaces disconnects; @@ -87,8 +170,17 @@ delivery, JSON/API isolation, evidence and cursor validation. This is not a release claim for the full `GUI_PLAN`: browser automation, screen-reader checks, forced reconnect with replayed events, and all additional accessibility/security/concurrency journeys remain to run. Milestone 2 remains partial -(preflight/configuration/bulk actions/notifications and dependency-override controls are -not yet wired). Milestone 3 remains partial (adoption, plan dry-run/sync and richer graph -interactions are not yet wired). Milestones 4–8 (routing/operations panels, internal -sessions, extensions, automation, RBAC and recovery) remain explicitly incomplete. No -real fleet or external deployment was used. +(bulk-action review, notifications and other controls are not yet wired). Milestone 3 +remains partial (adoption and richer graph interactions are not yet wired). Milestone 4 is +partial: global routing, worker inventory, filtered events and typed analytics are +implemented, while confirmed reconciliation, audit-maintenance controls and process-log +metrics are not. Milestones 5–8 (internal sessions, +extensions, automation, RBAC and recovery) remain explicitly incomplete. No real fleet, +GitHub repository or external deployment was used. + +## Current slice verification + +The resume-checkpoint four-gate table above is the latest complete implementation evidence. Earlier +transient or partial runs are historical context only and are not substituted for that +complete pass. Because the branch is still dirty and behind `main`, this is not yet a +commit-addressable or integration-current release result. diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index e15c7b6..7a6776d 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -32,12 +32,19 @@ from .audit import AuditStore from .events import RATE_LIMIT_CLASSES, UNCLASSIFIED from .maintenance import DEFAULT_RETENTION_DAYS, run_maintenance +from .plan_service import PlanSyncConflict, PlanSyncFailure +from .plan_service import execute as execute_plan_sync +from .plan_service import parse_result as plan_parse_result from .preflight import BaseChecks +from .project_service import configure_project, project_spec from .providers import MEANING from .reconcile import GitHubReconciler, items_by_pr +from .routing_service import ROLE_MAP_KEY as ROLE_MAP_KEY +from .routing_service import configure_roles, role_map_view from .schemas import ( AddItemsRequest, AddItemsResult, + AnalyticsDashboard, AnswerRequest, AnswerResult, AuditCost, @@ -57,6 +64,7 @@ DependencyOverrideRequest, DependencyOverrideResult, Event, + EventFilters, EventPage, ExecutionReadiness, FleetControl, @@ -71,7 +79,6 @@ MaintenanceResult, NewBaseline, OpenQuestion, - PlanItem, PlanParseResult, PlanSyncRequest, PlanSyncResult, @@ -90,8 +97,6 @@ RetryResult, RoleMap, RoleMapView, - RoleRoute, - RoutedRole, RouteReachability, RoutesHealthView, ScopeRequest, @@ -99,6 +104,7 @@ StopProjectRequest, Summary, WaitingItem, + WorkerInventory, WorkEvidence, WorkItem, WorkList, @@ -121,10 +127,6 @@ WINDOWS = {"1h": 3600, "24h": 86400, "72h": 3 * 86400, "7d": 7 * 86400, "all": None} -#: Where the live role map is stored. Shared through the queue's database -#: because the API and the worker are different processes. -ROLE_MAP_KEY = "role_map" - #: How long a healthy model has to answer preflight's one-token probe, and #: how long it has before it is treated as not answering at all. Anything in #: between is reported as slow and not refused — a late model is usable, and @@ -178,6 +180,7 @@ def create_api( probes: Mapping[str, Any] | None = None, executor_roles: Any | None = None, default_preset: str = "", + github_factory: Any | None = None, ) -> FastAPI: """Build the API. @@ -226,6 +229,7 @@ def create_api( app.state.probes = dict(probes or {}) app.state.executor_roles = executor_roles app.state.default_preset = default_preset + app.state.github_factory = github_factory app.state.ask_model = _model_asker(model_client) app.state.base_checks = BaseChecks() app.state.token = token @@ -933,6 +937,7 @@ def audit_cost( rows=[AuditCostRow(**r) for r in rows], total_cost_usd=sum(priced) if priced else None, total_unpriced=sum(r["unpriced"] or 0 for r in rows), + denominator=sum(r["calls"] or 0 for r in rows), partial=partial, ) @@ -952,9 +957,32 @@ def audit_delivery( return AuditDelivery( window=window, rows=[AuditDeliveryRow(**r) for r in rows], + denominator=audit_store().delivery_denominator(since=since, project_id=project_id), partial=partial, ) + @app.get( + "/api/analytics", + tags=["observability"], + summary="Complete typed analytics projection", + response_model=AnalyticsDashboard, + ) + def analytics( + window: str = Query("7d", description=f"One of {sorted(WINDOWS)}."), + project_id: str | None = Query( + None, description="Limit spend, delivery and baselines to one project." + ), + _: None = Depends(require_token), + ) -> AnalyticsDashboard: + from .query_service import HarnessQueries + + try: + return HarnessQueries( + store, app.state.queue, audit=app.state.audit, fleet=app.state.fleet + ).analytics(window=window, project_id=project_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.post( "/api/audit/reconcile", tags=["observability"], @@ -1141,30 +1169,7 @@ def create_project( start does. """ queue = need_queue() - queue.add_project( - Project( - project_id=spec.project_id, - name=spec.name, - repo=spec.repo, - work_dir=spec.work_dir, - base_branch=spec.base_branch, - checks=list(spec.checks), - fixes={k: list(v) for k, v in spec.fixes.items()}, - durability=spec.durability, - max_item_seconds=spec.max_item_seconds, - max_item_spend_usd=spec.max_item_spend_usd, - plan_path=spec.plan_path, - roles={k: v.model_dump() for k, v in spec.roles.items()} if spec.roles else None, - max_workers=spec.max_workers, - max_attempts=spec.max_attempts, - min_free_disk_gb=spec.min_free_disk_gb, - ) - ) - fleet_ = app.state.fleet - if fleet_ is not None and hasattr(fleet_, "resize"): - # A no-op for a stopped project: there is no pool to reconcile, - # and the persisted budget is what the next start reads. - fleet_.resize(spec.project_id) + configure_project(queue, spec, fleet=app.state.fleet) return _project_summary(queue, spec.project_id, app.state.fleet) @app.get( @@ -1615,16 +1620,7 @@ def set_roles(request: RoleMap, _: None = Depends(require_token)) -> RoleMapView it is your call, and it is worth making deliberately. """ queue = need_queue() - queue.set_setting( - ROLE_MAP_KEY, - # Only the routing fields. `used` is computed from the deployment, - # not configured, and storing it would let a stale answer be read - # back later as though an operator had set it. - { - name: route.model_dump(include={"model", "endpoint", "provider"}) - for name, route in request.roles.items() - }, - ) + configure_roles(queue, request) return _role_map_view(app.state, queue) # ---------------------------------------------------------------- plan @@ -1653,31 +1649,7 @@ def plan_parse( if not target.is_file(): raise HTTPException(status_code=404, detail=f"no plan at {path!r}") parsed = parse_plan_file(target) - report = parsed.dependency_report() - return PlanParseResult( - items=[ - PlanItem( - id=i.id, - title=i.title, - body=i.body, - labels=i.labels, - milestone=i.milestone, - depends_on=i.depends_on, - done=i.done, - line=i.line, - ) - for i in parsed.items - ], - skipped=[f"line {n}: {title}" for n, title in parsed.skipped], - duplicate_ids=parsed.duplicate_ids(), - unresolved_dependencies=report.unresolved, - external_dependencies=report.external, - decision_dependencies=report.decisions, - cross_project_dependencies=report.cross_project, - malformed_dependencies=report.malformed, - dependency_cycles=[list(cycle) for cycle in report.cycles], - unattached_arrows=[f"line {n}: {text}" for n, text in report.unattached_arrows], - ) + return plan_parse_result(parsed) @app.post( "/api/plan/sync", @@ -1701,35 +1673,27 @@ def plan_sync( usually an edit, sometimes a mistake, and never grounds for the harness to decide work stopped mattering. """ - from .github import GitHub, GitHubError, sync - from .plan import parse_plan_file + factory = app.state.github_factory + if factory is None: + from .github import GitHub - target = Path(request.path) - if not target.is_file(): - raise HTTPException(status_code=404, detail=f"no plan at {request.path!r}") - parsed = parse_plan_file(target) - duplicates = parsed.duplicate_ids() - if duplicates and not request.allow_duplicates: - raise HTTPException( - status_code=409, - detail={ - "reason": "the plan states these ids more than once; each becomes one issue", - "duplicate_ids": duplicates, - }, - ) + github = GitHub(request.repo) + else: + github = factory(request.repo) try: - report = sync(GitHub(request.repo), parsed.deduplicated(), dry_run=request.dry_run) - except GitHubError as exc: + return execute_plan_sync( + request.path, + github, + dry_run=request.dry_run, + allow_duplicates=request.allow_duplicates, + ) + except PlanSyncConflict as exc: + status_code = 404 if exc.reason_kind == "plan_missing" else 409 + detail: str | dict[str, Any] + detail = {"reason": str(exc), **exc.details} if exc.details else str(exc) + raise HTTPException(status_code=status_code, detail=detail) from exc + except PlanSyncFailure as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc - return PlanSyncResult( - created=report.created, - updated=report.updated, - unchanged=report.unchanged, - orphaned=report.orphaned, - labels_created=report.labels_created, - milestones_created=report.milestones_created, - dry_run=request.dry_run, - ) # ------------------------------------------------------- observability @@ -1756,6 +1720,7 @@ def errors( meaning={c: MEANING[c] for c in RATE_LIMIT_CLASSES}, unclassified=by_class.get(UNCLASSIFIED, 0), total=sum(classified.values()), + denominator=error_store.rate_limit_denominator(since), by_worker=error_store.group_counts("worker", since), by_endpoint=error_store.group_counts("endpoint", since), by_role=error_store.group_counts("role", since), @@ -1770,6 +1735,21 @@ def errors( def events( since_id: int = Query(0, description="Cursor from the previous page."), limit: int = Query(200, ge=1, le=1000), + project_id: str | None = Query(None, description="Limit to one project."), + item_id: str | None = Query(None, description="Limit to one work item."), + worker: str | None = Query(None, description="Limit to one worker identity."), + endpoint: str | None = Query(None, description="Limit to one endpoint."), + role: str | None = Query(None, description="Limit to one routed role."), + model: str | None = Query(None, description="Limit to one model identifier."), + outcome: str | None = Query(None, description="Limit to one outcome token."), + error_class: str | None = Query(None, description="Limit to one error class."), + reason_kind: str | None = Query(None, description="Limit to one reason kind."), + start_ts: float | None = Query( + None, description="Include events at or after this Unix time." + ), + end_ts: float | None = Query( + None, description="Include events at or before this Unix time." + ), _: None = Depends(require_token), ) -> EventPage: """Append-only history, oldest first. @@ -1778,11 +1758,44 @@ def events( millisecond must still have a total order, or a poll silently drops one. """ - rows = store.since_id(since_id, limit=limit) - return EventPage( - events=[Event(**row) for row in rows], - cursor=rows[-1]["id"] if rows else since_id, - ) + from .query_service import HarnessQueries + + try: + filters = EventFilters( + project_id=project_id, + item_id=item_id, + worker=worker, + endpoint=endpoint, + role=role, + model=model, + outcome=outcome, + error_class=error_class, + reason_kind=reason_kind, + start_ts=start_ts, + end_ts=end_ts, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return HarnessQueries( + store, app.state.queue, audit=app.state.audit, fleet=app.state.fleet + ).filtered_events(since_id, limit, filters) + + @app.get( + "/api/workers", + tags=["control", "observability"], + summary="Worker-pool inventory", + response_model=WorkerInventory, + ) + def workers( + project_id: str | None = Query(None, description="Limit to one project."), + _: None = Depends(require_token), + ) -> WorkerInventory: + """Read live workers alongside durable claims and failure evidence.""" + from .query_service import HarnessQueries + + return HarnessQueries( + store, app.state.queue, audit=app.state.audit, fleet=app.state.fleet + ).worker_inventory(project_id) @app.get( "/api/summary", @@ -1969,26 +1982,7 @@ def ask(route: Any) -> Any: def _role_map_view(state: Any, queue: WorkQueue) -> RoleMapView: """The global map, annotated with what this deployment will call.""" - from .model_client import reviewer_independence - - stored = queue.get_setting(ROLE_MAP_KEY) or {} - executor = _executor_roles(state) - independent, why = reviewer_independence( - _role_routes(queue, default_preset=getattr(state, "default_preset", "")), - implemented_by=executor.implemented_by, - ) - return RoleMapView( - reviewer_independent=independent, - reviewer_note=why, - roles={ - name: RoutedRole( - **RoleRoute(**route).model_dump(), - used=executor.calls_role(name), - unused_reason=executor.unused_reason(name), - ) - for name, route in stored.items() - }, - ) + return role_map_view(state, queue) def _preflight( @@ -2050,23 +2044,7 @@ def _preflight( def _project_spec(project: Project) -> ProjectSpec: - return ProjectSpec( - project_id=project.project_id, - name=project.name, - repo=project.repo, - work_dir=project.work_dir, - base_branch=project.base_branch, - checks=list(project.checks), - fixes={k: list(v) for k, v in (project.fixes or {}).items()}, - durability=project.durability, - max_item_seconds=project.max_item_seconds, - max_item_spend_usd=project.max_item_spend_usd, - plan_path=project.plan_path, - roles={k: RoleRoute(**v) for k, v in project.roles.items()} if project.roles else None, - max_workers=project.max_workers, - max_attempts=project.max_attempts, - min_free_disk_gb=project.min_free_disk_gb, - ) + return project_spec(project) def _project_summary(queue: WorkQueue, project_id: str, fleet: Any | None = None) -> ProjectSummary: diff --git a/src/agent_harness/audit.py b/src/agent_harness/audit.py index 0314489..5e8a4b6 100644 --- a/src/agent_harness/audit.py +++ b/src/agent_harness/audit.py @@ -533,6 +533,17 @@ def rate_limits_by_class(self, since: float | None = None) -> dict[str, int]: sql += " GROUP BY error_class ORDER BY n DESC" return {r["error_class"]: r["n"] for r in self._connect().execute(sql, params)} + def rate_limit_denominator(self, since: float | None = None) -> int: + """All observed classified and unclassified rate-limit rows.""" + if self.degraded: + return 0 + sql = "SELECT COUNT(*) FROM events WHERE error_class IS NOT NULL" + params: list[Any] = [] + if since is not None: + sql += " AND ts >= ?" + params.append(since) + return int(self._connect().execute(sql, params).fetchone()[0]) + def group_counts( self, field: str, since: float | None = None, rate_limits_only: bool = True ) -> list[dict[str, Any]]: @@ -606,6 +617,22 @@ def delivery( sql += " GROUP BY project_id, outcome ORDER BY n DESC" return [dict(r) for r in self._connect().execute(sql, params)] + def delivery_denominator( + self, since: float | None = None, project_id: str | None = None + ) -> int: + """Distinct work items represented by delivery rows in a window.""" + if self.degraded: + return 0 + sql = "SELECT COUNT(DISTINCT item_id) FROM events WHERE kind = 'work'" + params: list[Any] = [] + if since is not None: + sql += " AND ts >= ?" + params.append(since) + if project_id is not None: + sql += " AND project_id = ?" + params.append(project_id) + return int(self._connect().execute(sql, params).fetchone()[0]) + def baselines(self, project_id: str | None = None) -> list[dict[str, Any]]: if self.degraded: return [] diff --git a/src/agent_harness/browser_session.py b/src/agent_harness/browser_session.py index ea90fb1..9afc275 100644 --- a/src/agent_harness/browser_session.py +++ b/src/agent_harness/browser_session.py @@ -7,6 +7,7 @@ import time from collections import defaultdict, deque from dataclasses import dataclass +from typing import Any from urllib.parse import urlsplit from fastapi import HTTPException, Request @@ -21,6 +22,20 @@ class BrowserSession: token_fingerprint: str +@dataclass(frozen=True) +class BrowserReview: + """One exact, short-lived browser action awaiting explicit confirmation.""" + + review_id: str + session_id: str + kind: str + target_id: str + baseline_digest: str + baseline_version: float + payload: dict[str, Any] + expires_at: float + + class BrowserSessions: """Process-local bounded sessions. @@ -30,12 +45,22 @@ class BrowserSessions: store later without changing the browser contract. """ - def __init__(self, *, ttl_seconds: int = 8 * 60 * 60, max_sessions: int = 256) -> None: - if ttl_seconds <= 0 or max_sessions <= 0: + def __init__( + self, + *, + ttl_seconds: int = 8 * 60 * 60, + max_sessions: int = 256, + review_ttl_seconds: int = 10 * 60, + max_reviews: int = 512, + ) -> None: + if ttl_seconds <= 0 or max_sessions <= 0 or review_ttl_seconds <= 0 or max_reviews <= 0: raise ValueError("session limits must be positive") self.ttl_seconds = ttl_seconds self.max_sessions = max_sessions + self.review_ttl_seconds = review_ttl_seconds + self.max_reviews = max_reviews self._sessions: dict[str, BrowserSession] = {} + self._reviews: dict[str, BrowserReview] = {} self._login_failures: dict[str, deque[float]] = defaultdict(deque) self._lock = threading.Lock() @@ -90,6 +115,66 @@ def revoke(self, session_id: str | None) -> None: if session_id: with self._lock: self._sessions.pop(session_id, None) + for review_id in [ + key for key, review in self._reviews.items() if review.session_id == session_id + ]: + self._reviews.pop(review_id, None) + + def create_review( + self, + session: BrowserSession, + *, + kind: str, + target_id: str, + baseline_digest: str, + baseline_version: float, + payload: dict[str, Any], + now: float | None = None, + ) -> BrowserReview: + moment = time.time() if now is None else now + review = BrowserReview( + review_id=secrets.token_urlsafe(32), + session_id=session.session_id, + kind=kind, + target_id=target_id, + baseline_digest=baseline_digest, + baseline_version=baseline_version, + payload=payload, + expires_at=moment + self.review_ttl_seconds, + ) + with self._lock: + self._purge(moment) + if len(self._reviews) >= self.max_reviews: + oldest = min(self._reviews.values(), key=lambda item: item.expires_at) + self._reviews.pop(oldest.review_id, None) + self._reviews[review.review_id] = review + return review + + def consume_review( + self, + session: BrowserSession, + review_id: str, + *, + kind: str, + target_id: str, + now: float | None = None, + ) -> BrowserReview: + """Consume one matching review; replay and cross-session use are refused.""" + moment = time.time() if now is None else now + with self._lock: + self._purge(moment) + review = self._reviews.get(review_id) + if ( + review is None + or review.session_id != session.session_id + or review.kind != kind + or review.target_id != target_id + ): + raise HTTPException( + status_code=409, detail="configuration review is invalid or expired" + ) + self._reviews.pop(review_id, None) + return review def require(self, request: Request) -> BrowserSession: session = self.get(request.cookies.get("harness_session")) @@ -130,3 +215,6 @@ def _purge(self, now: float) -> None: expired = [key for key, value in self._sessions.items() if value.expires_at <= now] for key in expired: self._sessions.pop(key, None) + expired_reviews = [key for key, value in self._reviews.items() if value.expires_at <= now] + for key in expired_reviews: + self._reviews.pop(key, None) diff --git a/src/agent_harness/fleet.py b/src/agent_harness/fleet.py index f8c2df9..9540b1e 100644 --- a/src/agent_harness/fleet.py +++ b/src/agent_harness/fleet.py @@ -63,6 +63,8 @@ class Worker: thread: threading.Thread stop: threading.Event + started_at: float = 0.0 + owner: str | None = None @dataclass @@ -102,6 +104,16 @@ def halt(self) -> None: worker.stop.set() +@dataclass(frozen=True) +class WorkerSnapshot: + """A read-only runtime identity exposed to control-plane projections.""" + + project_id: str + worker_id: str + claim_owner: str | None + started_at: float + + class Fleet: """Starts and stops per-project worker pools. @@ -165,9 +177,9 @@ def start(self, project_id: str) -> int: # the project still reads `stopped` claims nothing and sleeps a # full poll for no reason. self.queue.set_control(RUNNING, project_id=project_id) - for _ in range(max(1, project.max_workers)): - pool.workers.append(self._spawn(pool)) self._pools[project_id] = pool + for _ in range(max(1, project.max_workers)): + self._spawn(pool) log.info("started %d worker(s) for project %s", len(pool.workers), project_id) return len(pool.workers) @@ -216,7 +228,7 @@ def resize(self, project_id: str, size: int | None = None) -> int: shortfall = target - pool.wanted for _ in range(max(0, shortfall)): try: - pool.workers.append(self._spawn(pool)) + self._spawn(pool) except RuntimeError as exc: # the OS refused another thread # Reported after the lock: a sibling that started fine is # working, and must not be torn down over this. @@ -253,8 +265,13 @@ def _spawn(self, pool: ProjectPool) -> Worker: name=f"harness-{pool.project_id}-{pool.launched}", daemon=True, ) + worker = Worker(thread=thread, stop=stop, started_at=self.now()) + # Register before starting the thread. The executor may fail during + # construction immediately, and inventory should still be able to + # explain which runtime identity disappeared. + pool.workers.append(worker) thread.start() - return Worker(thread=thread, stop=stop) + return worker def _join_retired(self, project_id: str, pool: ProjectPool, retired: list[Worker]) -> None: """Wait for shrunk-away workers off the caller's thread. @@ -354,11 +371,44 @@ def failures(self, project_id: str | None = None) -> list[WorkerFailure]: with self._lock: return [f for f in self._failures if project_id is None or f.project_id == project_id] + def workers(self, project_id: str | None = None) -> list[WorkerSnapshot]: + """Live worker identities and their process claim owner. + + This is deliberately a snapshot, not a registry. The fleet remains + the owner of runtime threads; callers receive enough evidence to + correlate a live thread with durable queue claims without mutating or + scheduling anything. + """ + with self._lock: + snapshots: list[WorkerSnapshot] = [] + for pid, pool in self._pools.items(): + if project_id is not None and pid != project_id: + continue + for worker in pool.workers: + if worker.thread.is_alive(): + snapshots.append( + WorkerSnapshot( + project_id=pid, + worker_id=worker.thread.name, + claim_owner=worker.owner, + started_at=worker.started_at, + ) + ) + return snapshots + # ------------------------------------------------------------ internals def _worker(self, project_id: str, stop: threading.Event) -> None: try: executor = self.executor_factory(project_id) + with self._lock: + pool = self._pools.get(project_id) + if pool is not None: + current = threading.current_thread() + for worker in pool.workers: + if worker.thread is current: + worker.owner = getattr(executor, "owner", None) + break except Exception as exc: # noqa: BLE001 - one project must not kill the fleet self._died(project_id, None, f"could not build an executor: {exc}") return diff --git a/src/agent_harness/plan_service.py b/src/agent_harness/plan_service.py new file mode 100644 index 0000000..2d93d40 --- /dev/null +++ b/src/agent_harness/plan_service.py @@ -0,0 +1,180 @@ +"""Shared, reviewable plan-to-backlog operations for API and browser callers. + +The parser and GitHub adapter already provide the durable behavior. This module +keeps the browser's preview and apply steps on that same path while binding an +apply to the exact local plan bytes and the persisted project configuration it +was reviewed against. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +from .github import GitHubError, sync +from .plan import ParsedPlan, parse_plan_file +from .schemas import PlanParseResult, PlanSyncResult + + +class PlanSyncConflict(Exception): + """The plan is unsafe to sync or a reviewed preview is no longer current.""" + + def __init__( + self, + message: str, + *, + reason_kind: str = "plan_conflict", + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.reason_kind = reason_kind + self.details = details or {} + + +class PlanSyncFailure(Exception): + """The external backlog rejected a requested read or write.""" + + reason_kind = "github_refused" + + +def plan_digest(path: str | Path) -> str: + """Fingerprint the exact plan bytes, not its lossy parsed representation.""" + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def parse_result(parsed: ParsedPlan) -> PlanParseResult: + """The same loss-reporting parse projection published by the JSON API.""" + report = parsed.dependency_report() + return PlanParseResult( + items=[ + { + "id": item.id, + "title": item.title, + "body": item.body, + "labels": item.labels, + "milestone": item.milestone, + "depends_on": item.depends_on, + "done": item.done, + "line": item.line, + } + for item in parsed.items + ], + skipped=[f"line {line}: {title}" for line, title in parsed.skipped], + duplicate_ids=parsed.duplicate_ids(), + unresolved_dependencies=report.unresolved, + external_dependencies=report.external, + decision_dependencies=report.decisions, + cross_project_dependencies=report.cross_project, + malformed_dependencies=report.malformed, + dependency_cycles=[list(cycle) for cycle in report.cycles], + unattached_arrows=[f"line {line}: {text}" for line, text in report.unattached_arrows], + ) + + +def _sync_result(report: Any, *, dry_run: bool) -> PlanSyncResult: + return PlanSyncResult( + created=list(report.created), + updated=list(report.updated), + unchanged=list(report.unchanged), + orphaned=list(report.orphaned), + labels_created=list(report.labels_created), + milestones_created=list(report.milestones_created), + dry_run=dry_run, + ) + + +def _blocking_findings(parsed: ParsedPlan) -> bool: + report = parsed.dependency_report() + return bool(report.unresolved or report.malformed or report.cycles or report.unattached_arrows) + + +def _validated_plan(path: str | Path, *, allow_duplicates: bool = False) -> ParsedPlan: + target = Path(path) + if not target.is_file(): + raise PlanSyncConflict(f"configured plan is missing: {target}", reason_kind="plan_missing") + parsed = parse_plan_file(target) + if not parsed.items: + raise PlanSyncConflict( + "the configured plan contains no recognized work items", + reason_kind="no_plan_items", + ) + duplicates = parsed.duplicate_ids() + if duplicates and not allow_duplicates: + raise PlanSyncConflict( + "the plan states an id more than once; each id becomes one issue", + reason_kind="duplicate_ids", + details={"duplicate_ids": duplicates}, + ) + if _blocking_findings(parsed): + report = parsed.dependency_report() + raise PlanSyncConflict( + "the plan has malformed, unresolved, cyclic, or unattached dependencies", + reason_kind="dependency_findings", + details={ + "unresolved_dependencies": report.unresolved, + "malformed_dependencies": report.malformed, + "dependency_cycles": [list(cycle) for cycle in report.cycles], + "unattached_arrows": [ + f"line {line}: {text}" for line, text in report.unattached_arrows + ], + }, + ) + return parsed + + +def execute( + path: str | Path, + github: Any, + *, + dry_run: bool, + allow_duplicates: bool = False, +) -> PlanSyncResult: + """Validate and sync through the one contract shared by JSON and HTML.""" + parsed = _validated_plan(path, allow_duplicates=allow_duplicates) + try: + report = sync(github, parsed.deduplicated(), dry_run=dry_run) + except GitHubError as exc: + raise PlanSyncFailure(str(exc)) from exc + return _sync_result(report, dry_run=dry_run) + + +def preview(path: str | Path, github: Any) -> tuple[str, PlanParseResult, PlanSyncResult]: + """Parse and perform a read-only remote preview; never write anything.""" + target = Path(path) + parsed = _validated_plan(target) + parsed_view = parse_result(parsed) + return plan_digest(target), parsed_view, execute(target, github, dry_run=True) + + +def apply( + path: str | Path, + repo: str, + github: Any, + *, + expected_digest: str, + expected_preview: PlanSyncResult, +) -> PlanSyncResult: + """Re-preview, compare, then perform the one explicitly confirmed write.""" + target = Path(path) + if not target.is_file(): + raise PlanSyncConflict("the reviewed plan is no longer present", reason_kind="plan_missing") + actual_repo = getattr(github, "repo", repo) + if actual_repo != repo: + raise PlanSyncConflict( + "the resolved repository differs from the reviewed repository", + reason_kind="repository_changed", + ) + current_digest = plan_digest(target) + if current_digest != expected_digest: + raise PlanSyncConflict( + "the plan changed after review; preview it again", + reason_kind="plan_changed", + ) + current_preview = execute(target, github, dry_run=True) + if current_preview.model_dump(mode="json") != expected_preview.model_dump(mode="json"): + raise PlanSyncConflict( + "the remote backlog changed after review; preview it again", + reason_kind="remote_preview_changed", + ) + return execute(target, github, dry_run=False) diff --git a/src/agent_harness/project_service.py b/src/agent_harness/project_service.py new file mode 100644 index 0000000..68a8ea9 --- /dev/null +++ b/src/agent_harness/project_service.py @@ -0,0 +1,90 @@ +"""Shared application service for project configuration. + +The public JSON API and first-party browser both accept the typed +``ProjectSpec`` contract. Keeping persistence and live-pool reconciliation in +one service prevents the two controllers from assigning different behavior to +the same configuration. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from .schemas import ProjectSpec, RoleRoute +from .work import Project, WorkQueue + + +class ProjectConfigurationConflict(Exception): + """The project changed after the operator reviewed its replacement.""" + + +def project_spec(project: Project) -> ProjectSpec: + """The public contract view of one persisted project.""" + return ProjectSpec( + project_id=project.project_id, + name=project.name, + repo=project.repo, + work_dir=project.work_dir, + base_branch=project.base_branch, + checks=list(project.checks), + fixes={key: list(value) for key, value in (project.fixes or {}).items()}, + durability=project.durability, + max_item_seconds=project.max_item_seconds, + max_item_spend_usd=project.max_item_spend_usd, + max_hold_seconds=project.max_hold_seconds, + plan_path=project.plan_path, + roles=( + {name: RoleRoute(**route) for name, route in project.roles.items()} + if project.roles + else None + ), + max_workers=project.max_workers, + max_attempts=project.max_attempts, + min_free_disk_gb=project.min_free_disk_gb, + ) + + +def project_spec_digest(spec: ProjectSpec) -> str: + """Stable semantic fingerprint used to reject stale browser reviews.""" + encoded = json.dumps(spec.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode()).hexdigest() + + +def configure_project( + queue: WorkQueue, + spec: ProjectSpec, + *, + fleet: Any | None = None, + expected_updated_at: float | None = None, +) -> None: + """Persist one validated project specification and reconcile its live pool.""" + project = Project( + project_id=spec.project_id, + name=spec.name, + repo=spec.repo, + work_dir=spec.work_dir, + base_branch=spec.base_branch, + checks=list(spec.checks), + fixes={key: list(value) for key, value in spec.fixes.items()}, + durability=spec.durability, + max_item_seconds=spec.max_item_seconds, + max_item_spend_usd=spec.max_item_spend_usd, + max_hold_seconds=spec.max_hold_seconds, + plan_path=spec.plan_path, + roles=( + {name: route.model_dump() for name, route in spec.roles.items()} if spec.roles else None + ), + max_workers=spec.max_workers, + max_attempts=spec.max_attempts, + min_free_disk_gb=spec.min_free_disk_gb, + ) + if expected_updated_at is None: + queue.add_project(project) + elif not queue.update_project(project, expected_updated_at=expected_updated_at): + raise ProjectConfigurationConflict("project configuration changed after review") + if fleet is not None and hasattr(fleet, "resize"): + # A no-op for a stopped project: there is no pool to reconcile, and + # the persisted budget is what the next explicit start reads. + fleet.resize(spec.project_id) diff --git a/src/agent_harness/query_service.py b/src/agent_harness/query_service.py index 9d9fd4d..9249912 100644 --- a/src/agent_harness/query_service.py +++ b/src/agent_harness/query_service.py @@ -13,11 +13,26 @@ from pathlib import Path from typing import Any +from .events import RATE_LIMIT_CLASSES, UNCLASSIFIED +from .project_service import project_spec +from .providers import MEANING from .schemas import ( + AbandonedSessionEvidence, + AnalyticsDashboard, AttemptStageEvidence, + AuditCost, + AuditCostRow, + AuditDelivery, + AuditDeliveryRow, + AuditHealth, + AuditRollupRow, + AuditRollups, + Baseline, + BaselineList, DependencyEdgeModel, DependencyGraphReport, Event, + EventFilters, EventPage, FleetControl, HoldList, @@ -25,14 +40,14 @@ ItemReadiness, LatestEvent, OpenQuestion, - PlanItem, PlanParseResult, ProjectList, - ProjectSpec, ProjectSummary, ProposalModel, + RateLimits, ReadinessReasonModel, - RoleRoute, + WorkerInventory, + WorkerInventoryItem, WorkEvidence, WorkItem, WorkList, @@ -40,6 +55,14 @@ from .store import EventStore from .work import BLOCKED, CLAIMED, DRAINING, HELD, WorkQueue, WorkRecord +ANALYTICS_WINDOWS: dict[str, float | None] = { + "1h": 3600, + "24h": 86400, + "72h": 3 * 86400, + "7d": 7 * 86400, + "all": None, +} + class HarnessQueries: """Read the control plane without exposing storage layout to controllers.""" @@ -78,27 +101,7 @@ def project(self, project_id: str) -> ProjectSummary | None: state, reason, previous = queue.control_detail(project_id) worker_health = self._worker_health(project_id) return ProjectSummary( - project=ProjectSpec( - project_id=project.project_id, - name=project.name, - repo=project.repo, - work_dir=project.work_dir, - base_branch=project.base_branch, - checks=list(project.checks), - fixes={k: list(v) for k, v in (project.fixes or {}).items()}, - durability=project.durability, - max_item_seconds=project.max_item_seconds, - max_item_spend_usd=project.max_item_spend_usd, - plan_path=project.plan_path, - roles=( - {name: RoleRoute(**route) for name, route in project.roles.items()} - if project.roles - else None - ), - max_workers=project.max_workers, - max_attempts=project.max_attempts, - min_free_disk_gb=project.min_free_disk_gb, - ), + project=project_spec(project), counts=queue.counts(project_id=project_id), control=FleetControl(state=state, reason=reason), previous_state=previous, @@ -214,33 +217,9 @@ def inception_plan(self, project_id: str, name: str | None = None) -> str | None def plan_parse_markdown(markdown: str) -> PlanParseResult: """Parse a document using the same loss-reporting parser as the API.""" from .plan import parse_plan + from .plan_service import parse_result - parsed = parse_plan(markdown) - report = parsed.dependency_report() - return PlanParseResult( - items=[ - PlanItem( - id=item.id, - title=item.title, - body=item.body, - labels=item.labels, - milestone=item.milestone, - depends_on=item.depends_on, - done=item.done, - line=item.line, - ) - for item in parsed.items - ], - skipped=[f"line {line}: {title}" for line, title in parsed.skipped], - duplicate_ids=parsed.duplicate_ids(), - unresolved_dependencies=report.unresolved, - external_dependencies=report.external, - decision_dependencies=report.decisions, - cross_project_dependencies=report.cross_project, - malformed_dependencies=report.malformed, - dependency_cycles=[list(cycle) for cycle in report.cycles], - unattached_arrows=[f"line {line}: {text}" for line, text in report.unattached_arrows], - ) + return parse_result(parse_plan(markdown)) def plan_parse(self, path: str) -> PlanParseResult | None: """Read and parse a configured plan path, without writing anything.""" @@ -279,6 +258,12 @@ def graph(self, project_id: str) -> DependencyGraphReport | None: ], ) + def overrides(self, project_id: str) -> list[dict[str, Any]]: + """Revision-scoped dependency decisions for the graph audit panel.""" + if self.queue is None or self.queue.get_project(project_id) is None: + return [] + return self.queue.graph.overrides(project_id) + @staticmethod def _readiness_model(state: Any, record: WorkRecord | None) -> ItemReadiness: return ItemReadiness( @@ -295,10 +280,44 @@ def _readiness_model(state: Any, record: WorkRecord | None) -> ItemReadiness: ) def events(self, since_id: int = 0, limit: int = 200) -> EventPage: - rows = self.store.since_id(since_id, limit=limit) + return self.filtered_events(since_id=since_id, limit=limit) + + def filtered_events( + self, + since_id: int = 0, + limit: int = 200, + filters: EventFilters | None = None, + *, + live: bool = False, + ) -> EventPage: + """Read filtered history without changing monotonic cursor meaning. + + Filtering happens after the exclusive id cursor. The reader keeps + scanning ordered chunks until it has enough matches, so a sparse + filter cannot cause matching events to disappear at a page boundary. + """ + filters = filters or EventFilters() + source = self.audit if live and self.audit is not None else self.store + cursor = since_id + matched: list[dict[str, Any]] = [] + while len(matched) < limit: + rows = source.since_id(cursor, limit=min(1000, max(limit, 200))) + if not rows: + break + for row in rows: + cursor = int(row["id"]) + event = self._audit_event_fields(row) if source is self.audit else row + if self._event_matches(event, filters): + matched.append(event) + if len(matched) >= limit: + break + if len(rows) < min(1000, max(limit, 200)): + break return EventPage( - events=[Event(**row) for row in rows], - cursor=rows[-1]["id"] if rows else since_id, + events=[Event(**row) for row in matched], + # Advance over scanned non-matches. A cursor is a position in the + # append-only stream, not the id of the last displayed row. + cursor=cursor, ) def live_events(self, since_id: int = 0, limit: int = 200) -> EventPage: @@ -308,12 +327,225 @@ def live_events(self, since_id: int = 0, limit: int = 200) -> EventPage: ingest-and-serve deployment has only the legacy event store. The fallback is explicit and preserves each store's monotonic cursor. """ - if self.audit is None: - return self.events(since_id, limit) - rows = self.audit.since_id(since_id, limit=limit) - return EventPage( - events=[Event(**self._audit_event_fields(row)) for row in rows], - cursor=rows[-1]["id"] if rows else since_id, + return self.filtered_events(since_id, limit, live=True) + + @staticmethod + def _event_matches(event: dict[str, Any], filters: EventFilters) -> bool: + data = event.get("data") or {} + + def value(name: str) -> Any: + return event.get(name) if event.get(name) is not None else data.get(name) + + for name in ( + "project_id", + "item_id", + "worker", + "endpoint", + "role", + "model", + "outcome", + "error_class", + "reason_kind", + ): + expected = getattr(filters, name) + if expected is not None and str(value(name) or "") != expected: + return False + ts = float(event.get("ts", 0.0)) + if filters.start_ts is not None and ts < filters.start_ts: + return False + return not (filters.end_ts is not None and ts > filters.end_ts) + + def worker_inventory(self, project_id: str | None = None) -> WorkerInventory: + """Project runtime, durable claims, failures and session evidence. + + The queue remains authoritative for claims and the fleet remains + authoritative for live threads. This method only joins their read + projections; it never creates a worker record or infers supervision + from a claimed row in a monitoring-only deployment. + """ + fleet = self.fleet + if fleet is None: + return WorkerInventory( + configured=False, + mode="monitoring-only", + reason="no worker pool is attached; this deployment is monitoring-only", + ) + queue = self.queue + if queue is None: + return WorkerInventory( + configured=True, + mode="supervised", + reason="worker pool is attached but no work queue is configured", + ) + snapshots = list(fleet.workers(project_id)) if hasattr(fleet, "workers") else [] + claims = queue.claimed(project_id=project_id) + sessions = queue.abandoned_sessions() + by_item: dict[str, list[AbandonedSessionEvidence]] = {} + for row in sessions: + row_project = row.get("project_id") + if project_id is not None and row_project != project_id: + continue + by_item.setdefault(str(row["item_id"]), []).append(AbandonedSessionEvidence(**row)) + latest = self._latest_by_item(project_id) + used_claims: set[int] = set() + items: list[WorkerInventoryItem] = [] + now = queue.now() + + def row_for_claim( + record: WorkRecord, worker_id: str, started_at: float | None + ) -> WorkerInventoryItem: + event = latest.get(record.item_id) or {} + data = event.get("data") or {} + return WorkerInventoryItem( + worker_id=worker_id, + project_id=record.project_id, + state="stale_claim" if record.lease_until < now else "running", + claim_owner=record.owner, + item_id=record.item_id, + lease_until=record.lease_until, + heartbeat_at=record.updated_at, + stage=event.get("outcome") or data.get("stage"), + started_at=started_at, + item_started_at=record.first_started_at or None, + abandoned_sessions=by_item.get(record.item_id, []), + ) + + for snapshot in snapshots: + match = next( + ( + (index, record) + for index, record in enumerate(claims) + if index not in used_claims and record.owner == snapshot.claim_owner + ), + None, + ) + if match is None: + items.append( + WorkerInventoryItem( + worker_id=snapshot.worker_id, + project_id=snapshot.project_id, + state="running", + claim_owner=snapshot.claim_owner, + started_at=snapshot.started_at, + ) + ) + continue + index, record = match + used_claims.add(index) + items.append(row_for_claim(record, snapshot.worker_id, snapshot.started_at)) + + for index, record in enumerate(claims): + if index in used_claims: + continue + items.append(row_for_claim(record, record.owner or "unknown", None)) + + failures = fleet.failures(project_id) + for failure in failures: + items.append( + WorkerInventoryItem( + worker_id=failure.worker or "unknown", + project_id=failure.project_id, + state="failed", + claim_owner=failure.worker, + failure=failure.error, + failed_at=failure.at, + abandoned_sessions=[ + session + for item_id in failure.released + for session in by_item.get(item_id, []) + ], + ) + ) + return WorkerInventory(configured=True, mode="supervised", workers=items) + + def analytics(self, window: str = "7d", project_id: str | None = None) -> AnalyticsDashboard: + """Build the complete analytics projection for the browser client. + + Audit is optional by design. When it is absent, rate limits can still + use the live event store, while spend, delivery, baselines and rollups + remain explicitly empty and the health model tells the operator why. + """ + if window not in ANALYTICS_WINDOWS: + raise ValueError( + f"unknown window {window!r}; expected one of {sorted(ANALYTICS_WINDOWS)}" + ) + span = ANALYTICS_WINDOWS[window] + since = None if span is None else time.time() - span + audit = self.audit + source = audit if audit is not None else self.store + oldest, newest = source.span() + partial = bool(since is not None and oldest is not None and oldest > since) + by_class = source.rate_limits_by_class(since) + classified = {name: by_class.get(name, 0) for name in RATE_LIMIT_CLASSES} + rate_limits = RateLimits( + window=window, + classified=classified, + meaning={name: MEANING[name] for name in RATE_LIMIT_CLASSES}, + unclassified=by_class.get(UNCLASSIFIED, 0), + total=sum(classified.values()), + denominator=( + source.rate_limit_denominator(since) + if hasattr(source, "rate_limit_denominator") + else sum(by_class.values()) + ), + by_worker=source.group_counts("worker", since), + by_endpoint=source.group_counts("endpoint", since), + by_role=source.group_counts("role", since), + ) + if audit is None: + cost = AuditCost(window=window, partial=partial) + delivery = AuditDelivery(window=window, partial=partial) + baselines = BaselineList() + rollups = AuditRollups() + health = AuditHealth(configured=False, degraded=True, events=0) + else: + cost_rows = [ + AuditCostRow(**row) for row in audit.cost(since=since, project_id=project_id) + ] + priced = [row.cost_usd for row in cost_rows if row.cost_usd is not None] + cost = AuditCost( + window=window, + rows=cost_rows, + total_cost_usd=sum(priced) if priced else None, + total_unpriced=sum(row.unpriced for row in cost_rows), + denominator=sum(row.calls for row in cost_rows), + partial=partial, + ) + delivery_rows = [ + AuditDeliveryRow(**row) + for row in audit.delivery(since=since, project_id=project_id) + ] + delivery = AuditDelivery( + window=window, + rows=delivery_rows, + denominator=audit.delivery_denominator(since=since, project_id=project_id), + partial=partial, + ) + baselines = BaselineList( + baselines=[Baseline(**row) for row in audit.baselines(project_id=project_id)] + ) + rollups = AuditRollups( + rows=[AuditRollupRow(**row) for row in audit.rollups(project_id=project_id)], + rolled_up_through=audit.rolled_up_through(), + ) + health = AuditHealth( + configured=True, + degraded=audit.degraded, + path=str(audit.path), + events=audit.count(), + oldest=oldest, + newest=newest, + schema_version=getattr(audit, "SCHEMA_VERSION", None), + ) + return AnalyticsDashboard( + window=window, + project_id=project_id, + rate_limits=rate_limits, + cost=cost, + delivery=delivery, + audit_health=health, + baselines=baselines, + rollups=rollups, ) def evidence(self, project_id: str, item_id: str) -> WorkEvidence | None: diff --git a/src/agent_harness/routing_service.py b/src/agent_harness/routing_service.py new file mode 100644 index 0000000..1890ce9 --- /dev/null +++ b/src/agent_harness/routing_service.py @@ -0,0 +1,93 @@ +"""Shared global role-routing configuration for API and browser clients.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from .schemas import RoleMap, RoleMapView, RoleRoute, RoutedRole +from .work import WorkQueue + +ROLE_MAP_KEY = "role_map" + + +class RoleConfigurationConflict(Exception): + """The global role map changed after an operator reviewed a replacement.""" + + +def stored_role_map(queue: WorkQueue) -> dict[str, Any] | None: + """Return the exact persisted value used for an optimistic browser review.""" + stored = queue.get_setting(ROLE_MAP_KEY) + if stored is None: + return None + if not isinstance(stored, dict): + raise ValueError("the stored role map is not an object") + return stored + + +def role_map_payload(role_map: RoleMap) -> dict[str, dict[str, Any]]: + """Persist every public RoleRoute field, including fallback and pricing metadata.""" + return {name: route.model_dump(mode="json") for name, route in role_map.roles.items()} + + +def role_map_digest(stored: dict[str, Any] | None) -> str: + encoded = json.dumps(stored, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode()).hexdigest() + + +def configure_roles( + queue: WorkQueue, + role_map: RoleMap, + *, + expected: dict[str, Any] | None | object = ..., +) -> None: + """Persist a validated map, optionally as an atomic reviewed replacement.""" + payload = role_map_payload(role_map) + if expected is ...: + queue.set_setting(ROLE_MAP_KEY, payload) + return + if not queue.compare_and_set_setting(ROLE_MAP_KEY, expected, payload): + raise RoleConfigurationConflict("role routing changed after review") + + +def role_map_view(state: Any, queue: WorkQueue) -> RoleMapView: + return role_map_view_for(state, stored_role_map(queue) or {}) + + +def role_map_view_for(state: Any, stored: dict[str, Any]) -> RoleMapView: + """Annotate configured roles with actual executor use and independence.""" + from .model_client import reviewer_independence, routes_from_map + from .runtime import ExecutorRoles + + executor = getattr(state, "executor_roles", None) or ExecutorRoles() + routes = routes_from_map(stored, default_preset=getattr(state, "default_preset", "")) + independent, why = reviewer_independence(routes, implemented_by=executor.implemented_by) + return RoleMapView( + reviewer_independent=independent, + reviewer_note=why, + roles={ + name: RoutedRole( + **RoleRoute(**route).model_dump(), + used=executor.calls_role(name), + unused_reason=executor.unused_reason(name), + ) + for name, route in stored.items() + }, + ) + + +def safe_endpoint(endpoint: str) -> str: + """Render route identity without URL credentials, query strings, or fragments.""" + parsed = urlsplit(endpoint) + hostname = parsed.hostname or "" + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + try: + port_number = parsed.port + except ValueError: + port_number = None + port = f":{port_number}" if port_number is not None else "" + netloc = f"{hostname}{port}" if hostname else "redacted-host" + return urlunsplit((parsed.scheme, netloc, parsed.path, "", "")) diff --git a/src/agent_harness/schemas.py b/src/agent_harness/schemas.py index da3c832..fb97e19 100644 --- a/src/agent_harness/schemas.py +++ b/src/agent_harness/schemas.py @@ -488,11 +488,11 @@ class ProjectSpec(BaseModel): supplied again after a restart -- every field was previously a CLI flag with nowhere to be written down.""" - project_id: str = Field(description="Stable id, used to scope every other call.") - name: str + project_id: str = Field(min_length=1, description="Stable id, used to scope every other call.") + name: str = Field(min_length=1) repo: str | None = Field(None, description="GitHub repo as `owner/name`.") work_dir: str | None = Field(None, description="Checkout the worktrees branch from.") - base_branch: str = "main" + base_branch: str = Field("main", min_length=1) checks: list[str] = Field( default_factory=list, description=( @@ -537,6 +537,13 @@ class ProjectSpec(BaseModel): "reported as unenforceable — unknown cost is never treated as zero." ), ) + max_hold_seconds: float = Field( + 6 * 60 * 60, + ge=0, + description="Maximum time a held question keeps its claim. Zero means no expiry; " + "the safe default is six hours so one unanswered question cannot occupy a worker " + "forever.", + ) durability: str = Field( "", description=( @@ -555,6 +562,7 @@ class ProjectSpec(BaseModel): ) max_workers: int = Field( 1, + ge=1, description="Concurrency budget. Its purpose is that one project cannot " "starve another, so it is per project rather than per fleet. Each worker owns " "a worktree and its build output, so raising this also multiplies peak disk use.", @@ -1063,6 +1071,12 @@ class RateLimits(BaseModel): "these and cannot be recovered." ) total: int = Field(description="Classified rate limits only. Excludes `unclassified`.") + denominator: int = Field( + 0, + description="All observed rate-limit events in the window, including the " + "separately reported unclassified rows. This is the denominator for any " + "rate-limit comparison.", + ) by_worker: list[dict[str, Any]] = Field(default_factory=list) by_endpoint: list[dict[str, Any]] = Field(default_factory=list) by_role: list[dict[str, Any]] = Field(default_factory=list) @@ -1116,6 +1130,11 @@ class AuditCost(BaseModel): rows: list[AuditCostRow] = Field(default_factory=list) total_cost_usd: float | None = None total_unpriced: int = 0 + denominator: int = Field( + 0, + description="Model-call rows observed in the requested window, including " + "calls whose price was unknown.", + ) partial: bool = Field( False, description="True when the requested window starts before the earliest " @@ -1133,6 +1152,11 @@ class AuditDeliveryRow(BaseModel): class AuditDelivery(BaseModel): window: str rows: list[AuditDeliveryRow] = Field(default_factory=list) + denominator: int = Field( + 0, + description="Distinct work items observed in the requested window. Outcome " + "rows can overlap because one item may emit more than one outcome.", + ) partial: bool = False @@ -1271,6 +1295,21 @@ class BaselineList(BaseModel): baselines: list[Baseline] = Field(default_factory=list) +class AnalyticsDashboard(BaseModel): + """The typed, read-only projection rendered by the analytics page.""" + + window: str = Field(description="The requested audit window token.") + project_id: str | None = Field(None, description="Optional project scope.") + rate_limits: RateLimits = Field(description="Classified and unclassified rate limits.") + cost: AuditCost = Field(description="Known and unpriced model-call spend.") + delivery: AuditDelivery = Field(description="Outcome counts and item denominator.") + audit_health: AuditHealth = Field( + description="Whether the history is complete enough to trust." + ) + baselines: BaselineList = Field(description="Immutable supplied comparison baselines.") + rollups: AuditRollups = Field(description="Daily retained history for long windows.") + + class NewBaseline(BaseModel): baseline_id: str = Field(description="Stable id. Recording twice under one id is refused.") project_id: str @@ -1307,6 +1346,100 @@ class EventPage(BaseModel): cursor: int = Field(description="Pass as `since_id` next time. Unchanged when empty.") +class EventFilters(BaseModel): + """Typed, URL-safe filters for the append-only event explorer. + + The fields mirror the evidence dimensions recorded by the audit store. A + reason kind lives in event data because older event rows predate the + classified field; readers therefore treat its absence as no match rather + than inventing a classification. + """ + + project_id: str | None = Field(None, description="Limit events to one project.") + item_id: str | None = Field(None, description="Limit events to one work item.") + worker: str | None = Field(None, description="Limit events to one worker identity.") + endpoint: str | None = Field(None, description="Limit events to one endpoint.") + role: str | None = Field(None, description="Limit events to one routed role.") + model: str | None = Field(None, description="Limit events to one model identifier.") + outcome: str | None = Field(None, description="Limit events to one outcome token.") + error_class: str | None = Field(None, description="Limit events to one error class.") + reason_kind: str | None = Field(None, description="Limit events to a recorded reason kind.") + start_ts: float | None = Field(None, description="Include events at or after this Unix time.") + end_ts: float | None = Field(None, description="Include events at or before this Unix time.") + + @field_validator( + "project_id", + "item_id", + "worker", + "endpoint", + "role", + "model", + "outcome", + "error_class", + "reason_kind", + "start_ts", + "end_ts", + mode="before", + ) + @classmethod + def blank_strings_are_not_filters(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() or None + return value + + @model_validator(mode="after") + def valid_time_window(self) -> EventFilters: + if self.start_ts is not None and self.end_ts is not None and self.start_ts > self.end_ts: + raise ValueError("start_ts must not be after end_ts") + return self + + +class AbandonedSessionEvidence(BaseModel): + """A terminal session deliberately retained after an agent timeout.""" + + session_id: str = Field(description="Session retained for human recovery.") + project_id: str | None = Field( + None, description="Project scope, when recorded by a project-aware executor." + ) + item_id: str = Field(description="Item whose context the session still owns.") + reason: str | None = Field(None, description="Why the session was abandoned.") + session_url: str | None = Field(None, description="Optional deep link to the session.") + abandoned_at: float = Field(description="Unix time when the session was retained.") + + +class WorkerInventoryItem(BaseModel): + """One live worker, a durable claim, or a recorded worker failure.""" + + worker_id: str = Field(description="Runtime worker identity, normally its thread name.") + project_id: str | None = Field(None, description="Project served by this worker, if known.") + state: str = Field(description="`running`, `idle`, `failed`, or `stale_claim`.") + claim_owner: str | None = Field(None, description="Durable queue owner on a claimed item.") + item_id: str | None = Field(None, description="Work item currently held by the worker.") + lease_until: float | None = Field(None, description="Claim lease expiry, when an item is held.") + heartbeat_at: float | None = Field( + None, description="Most recent durable claim update, used as the heartbeat evidence." + ) + stage: str | None = Field(None, description="Most recent recorded stage or event outcome.") + started_at: float | None = Field(None, description="Runtime worker start time, when attached.") + item_started_at: float | None = Field(None, description="When the current item first started.") + failure: str | None = Field(None, description="Most recent recorded worker failure.") + failed_at: float | None = Field(None, description="Unix time of the worker failure.") + abandoned_sessions: list[AbandonedSessionEvidence] = Field( + default_factory=list, description="Retained session evidence associated with this item." + ) + + +class WorkerInventory(BaseModel): + """The read-only worker-pool projection used by the GUI and API.""" + + configured: bool = Field(description="Whether a supervised worker pool is attached.") + mode: Literal["supervised", "monitoring-only"] = Field( + description="Monitoring-only deployments have no worker identities to report." + ) + reason: str | None = Field(None, description="Why the inventory is unavailable, when absent.") + workers: list[WorkerInventoryItem] = Field(default_factory=list) + + class AttemptStageEvidence(BaseModel): """One durable boundary reached by one attempt.""" diff --git a/src/agent_harness/session_executor.py b/src/agent_harness/session_executor.py index b5131b5..b25fcea 100644 --- a/src/agent_harness/session_executor.py +++ b/src/agent_harness/session_executor.py @@ -487,6 +487,7 @@ def _orphan_session(self, record: WorkRecord, reason: str) -> None: self.queue.record_abandoned_session( partial.session_id, record.item_id, + project_id=self.project_id, reason=f"worker failed and left this session running: {reason}", session_url=None, ) @@ -675,6 +676,7 @@ def _execute(self, record: WorkRecord) -> Outcome: self.queue.record_abandoned_session( session.id, record.item_id, + project_id=self.project_id, reason=outcome.reason, session_url=session.tab_url(self.ui_base_url) if self.ui_base_url else None, ) diff --git a/src/agent_harness/static/app.css b/src/agent_harness/static/app.css index 21b5503..1fa9805 100644 --- a/src/agent_harness/static/app.css +++ b/src/agent_harness/static/app.css @@ -22,13 +22,16 @@ * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); line-height: 1.5; } a { color: var(--accent); } -a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } +a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } button, .button { display: inline-block; border: 1px solid var(--accent); border-radius: 8px; padding: .65rem 1rem; background: var(--accent); color: #fff; cursor: pointer; font: inherit; text-decoration: none; } button:hover, .button:hover { background: var(--accent-strong); } button.secondary, .button.secondary { background: transparent; color: var(--accent); } button.quiet { border-color: transparent; background: transparent; color: var(--muted); padding: .35rem .6rem; } -input { width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: .7rem .8rem; background: var(--surface); color: var(--text); font: inherit; } +input, textarea, select { width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: .7rem .8rem; background: var(--surface); color: var(--text); font: inherit; } +textarea { resize: vertical; } +input[type="checkbox"] { width: auto; } label { display: block; margin: .75rem 0 .3rem; font-weight: 650; } +.choice { display: flex; gap: .5rem; align-items: center; } .topbar { display: flex; align-items: center; gap: 1.2rem; flex-wrap: wrap; padding: .75rem clamp(1rem, 3vw, 3rem); background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; } .brand { color: var(--text); font-weight: 800; font-size: 1.05rem; text-decoration: none; letter-spacing: -.02em; } nav { display: flex; gap: .8rem; flex: 1; flex-wrap: wrap; } @@ -53,6 +56,7 @@ h3 { margin-top: 1.4rem; font-size: 1rem; } .state-failed, .state-exhausted, .state-blocked { color: var(--danger); background: color-mix(in srgb, var(--danger) 13%, transparent); } .attention { color: var(--warning); background: color-mix(in srgb, var(--warning) 15%, transparent); } .project-grid, .analytics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 1rem; } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 0 1rem; } .card, .empty, .login-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: clamp(1rem, 2vw, 1.5rem); } .empty { text-align: center; padding: 3rem 1.25rem; box-shadow: none; } .card-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } @@ -65,6 +69,7 @@ dd { margin: .1rem 0 0; font-weight: 700; overflow-wrap: anywhere; } .alert.error { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); } .filterbar { display: flex; align-items: end; gap: .7rem; margin-bottom: 1.5rem; } .filterbar label { margin: 0; flex: 1; } +.event-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); } .work-group { margin-bottom: 2rem; } .work-group h2 { border-bottom: 1px solid var(--line); padding-bottom: .55rem; } .count { color: var(--muted); font-size: .85rem; } diff --git a/src/agent_harness/static/app.js b/src/agent_harness/static/app.js index 774cc73..b069ab4 100644 --- a/src/agent_harness/static/app.js +++ b/src/agent_harness/static/app.js @@ -6,7 +6,9 @@ event.preventDefault(); const csrf = form.querySelector('input[name="csrf_token"]'); fetch(form.action, {method: 'POST', headers: {'X-CSRF-Token': csrf ? csrf.value : '', 'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams(new FormData(form))}).then((response) => { - window.location.assign(response.redirected ? response.url : '/login'); + if (response.redirected) window.location.assign(response.url); + else if (response.ok) response.text().then((html) => { document.open(); document.write(html); document.close(); }); + else status('Action refused (' + response.status + ') — review the server response'); }).catch(() => status('Disconnected — action was not submitted')); })); document.querySelectorAll('[sse-connect]').forEach((node) => { diff --git a/src/agent_harness/store.py b/src/agent_harness/store.py index 8a2c100..f676b28 100644 --- a/src/agent_harness/store.py +++ b/src/agent_harness/store.py @@ -196,6 +196,15 @@ def group_counts( sql += f" GROUP BY {field}, error_class ORDER BY n DESC" return [dict(r) for r in self._connect().execute(sql, args)] + def rate_limit_denominator(self, since: float | None = None) -> int: + """All observed classified and unclassified rate-limit rows.""" + sql = "SELECT COUNT(*) FROM events WHERE error_class IS NOT NULL" + args: list[Any] = [] + if since is not None: + sql += " AND ts >= ?" + args.append(since) + return int(self._connect().execute(sql, args).fetchone()[0]) + def outcome_counts(self, kind: str | None = None, since: float | None = None) -> dict[str, int]: sql = "SELECT outcome, COUNT(*) AS n FROM events WHERE outcome IS NOT NULL" args: list[Any] = [] diff --git a/src/agent_harness/templates/analytics.html b/src/agent_harness/templates/analytics.html index 412adb6..3194325 100644 --- a/src/agent_harness/templates/analytics.html +++ b/src/agent_harness/templates/analytics.html @@ -1,6 +1,61 @@ {% extends "base.html" %} {% block content %} -

Operational telemetry

Analytics

{% if audit_health.degraded %}Audit degraded{% else %}Audit recording{% endif %}
- {% if audit_health.degraded %}

History is degraded or unavailable. Counts below are not evidence of complete history.

{% endif %} -

Rate limits

Known classes only; historical unclassified limits remain separate.

{% for key, count in rate_limits.items() %}{% else %}{% endfor %}
ClassCount
{{ key }}{{ count }}
No classified rate limits

Cost

{% for row in costs %}{% else %}{% endfor %}
Project / role / modelKnown USDUnpriced
{{ row.project_id or '—' }} / {{ row.role or '—' }} / {{ row.model or '—' }}{{ row.cost_usd if row.cost_usd is not none else 'unknown' }}{{ row.unpriced }}
No cost evidence

Delivery

{% for row in delivery %}{% else %}{% endfor %}
Project / outcomeEventsItems
{{ row.project_id or '—' }} / {{ row.outcome or '—' }}{{ row.n }}{{ row.items }}
No delivery evidence
+
+

Operational telemetry

Analytics

+ {% if dashboard.audit_health.degraded %}Audit degraded{% else %}Audit recording{% endif %} +
+ +
+ + + +
+ + {% if dashboard.audit_health.degraded %} +

History is missing, degraded, or unavailable. Counts below are not evidence of complete history.

+ {% endif %} + {% if dashboard.cost.partial or dashboard.delivery.partial %} +

The selected {{ dashboard.window }} window begins before retained history (oldest event: {{ dashboard.audit_health.oldest|datetime if dashboard.audit_health.oldest else 'unknown' }}). Results are partial.

+ {% endif %} + +
+
+

Rate limits

+

Denominator: {{ dashboard.rate_limits.denominator }} observed rate-limit rows. Unclassified remains separate.

+ + {% for key in ["rpm", "window_cap", "terminal_cap"] %}{% endfor %} + +
ClassCountMeaning
{{ key }}{{ dashboard.rate_limits.classified[key] }}{{ dashboard.rate_limits.meaning[key] }}
unclassified{{ dashboard.rate_limits.unclassified }}Recorded before classification; excluded from known classes.
+ {% if dashboard.baselines.baselines %}

Supplied baselines: {{ dashboard.baselines.baselines|length }}. Compare only with the matching workload and window.

{% else %}

No supplied baseline is recorded.

{% endif %} +
+ +
+

Cost

+

Denominator: {{ dashboard.cost.denominator }} model calls. Known spend excludes {{ dashboard.cost.total_unpriced }} unpriced calls.

+

Known total: {% if dashboard.cost.total_cost_usd is none %}unknown{% else %}${{ '%.4f'|format(dashboard.cost.total_cost_usd) }}{% endif %}

+ + {% for row in dashboard.cost.rows %}{% else %}{% endfor %} +
Project / role / modelKnown USDCallsUnpriced
{{ row.project_id or '—' }} / {{ row.role or '—' }} / {{ row.model or '—' }}{{ row.cost_usd if row.cost_usd is not none else 'unknown' }}{{ row.calls }}{{ row.unpriced }}
No cost evidence
+
+ +
+

Delivery

+

Denominator: {{ dashboard.delivery.denominator }} distinct work items. Outcome event counts may overlap.

+ + {% for row in dashboard.delivery.rows %}{% else %}{% endfor %} +
Project / outcomeEventsItems
{{ row.project_id or '—' }} / {{ row.outcome or '—' }}{{ row.n }}{{ row.items }}
No delivery evidence
+

Merged, closed-unmerged, and reverted outcomes appear only after reconciliation records them.

+
+ +
+

Audit history

+
Stored events
{{ dashboard.audit_health.events }}
Schema
{{ dashboard.audit_health.schema_version or '—' }}
Oldest
{{ dashboard.audit_health.oldest|datetime if dashboard.audit_health.oldest else '—' }}
Newest
{{ dashboard.audit_health.newest|datetime if dashboard.audit_health.newest else '—' }}
+

Daily rollups through {{ dashboard.rollups.rolled_up_through or 'no published day' }}. {{ dashboard.rollups.rows|length }} retained rollup rows.

+ {% if dashboard.baselines.baselines %}{% for baseline in dashboard.baselines.baselines %}{% endfor %}
BaselineWindowItems doneCost
{{ baseline.label }} ({{ baseline.baseline_id }}){{ baseline.window_days }}d{{ baseline.items_done if baseline.items_done is not none else 'unknown' }}{{ baseline.cost_usd if baseline.cost_usd is not none else 'unknown' }}
{% endif %} +
+
{% endblock %} diff --git a/src/agent_harness/templates/base.html b/src/agent_harness/templates/base.html index dded751..ae0fb39 100644 --- a/src/agent_harness/templates/base.html +++ b/src/agent_harness/templates/base.html @@ -20,6 +20,7 @@ Holds Events Analytics + Workers Plans Graph Sessions diff --git a/src/agent_harness/templates/events.html b/src/agent_harness/templates/events.html index e875bf4..ad7f269 100644 --- a/src/agent_harness/templates/events.html +++ b/src/agent_harness/templates/events.html @@ -2,5 +2,19 @@ {% block content %}

Append-only history

Events

Cursor {{ events.cursor }}

The stream resumes after the last monotonic cursor. A reconnect is visible and never silently replaces the event store.

-
    {% for event in events.events %}
  1. {{ event.outcome or event.kind }}{{ event.ts|datetime }}

    {{ event.data.detail or event.source }}

  2. {% else %}
  3. No events have been recorded.
  4. {% endfor %}
+
+ + + + + + + + + + + + +
+
    {% for event in events.events %}
  1. {{ event.outcome or event.kind }}{{ event.ts|datetime }}

    {{ event.data.detail or event.source }}

  2. {% else %}
  3. No events have been recorded.
  4. {% endfor %}
{% endblock %} diff --git a/src/agent_harness/templates/fragments/project_cards.html b/src/agent_harness/templates/fragments/project_cards.html index 0d5ffcf..db7d275 100644 --- a/src/agent_harness/templates/fragments/project_cards.html +++ b/src/agent_harness/templates/fragments/project_cards.html @@ -7,7 +7,7 @@

{{ entry.workers }} worker(s) active · {{ entry.stale }} stale lease(s) · {{ entry.worker_failures }} worker failure(s)

{% if entry.control.reason %}

{{ entry.control.reason }}

{% endif %} {% if entry.last_worker_error %}

Latest worker failure: {{ entry.last_worker_error }}

{% endif %} -

Inspect work

+

Inspect work Review preflight Configure

{% for target in ['paused', 'draining', 'stopped'] %}
diff --git a/src/agent_harness/templates/graph.html b/src/agent_harness/templates/graph.html index 7d5c252..f547552 100644 --- a/src/agent_harness/templates/graph.html +++ b/src/agent_harness/templates/graph.html @@ -5,7 +5,15 @@ {% if not graph %}

No graph available

Choose a configured project.

{% else %}

Admission

Readiness

{{ graph.ready|length }} ready
{% if graph.cycles %}
Cycles block work.
    {% for cycle in graph.cycles %}
  • {{ cycle|join(' → ') }}
  • {% endfor %}
{% endif %} - {% if graph.not_ready %}
{% for item in graph.not_ready %}

{{ item.item_id }}

{{ item.explanation }}

{% if item.advisory %}

Advisory: {{ item.advisory|length }} edge(s)

{% endif %}
{% endfor %}
{% else %}

Every item is ready at this revision.

{% endif %} + {% if graph.ready %}

Ready at revision {{ graph.revision }}

{% endif %} + {% if graph.not_ready %}
{% for item in graph.not_ready %}

{{ item.item_id }}

{{ item.explanation }}

{% if item.advisory %}

Advisory: {{ item.advisory|length }} edge(s)

{% endif %} + + + + + +
{% endfor %}
{% else %}

Every item is ready at this revision.

{% endif %} + {% if overrides %}

Recorded overrides

{% for override in overrides %}{% endfor %}
ItemRevisionOperatorReason
{{ override.item_id }}{{ override.revision }}{{ override.who or '—' }}{{ override.reason }}
{% endif %}

Edges

{% if graph.edges %}
{% for edge in graph.edges %}{% endfor %}
Waiting itemTargetKindStateEvidence
{{ edge.source_item }}{{ edge.target_id }}{{ edge.target_kind }}{{ edge.state }}{% if not edge.required %} (advisory){% endif %}{{ edge.evidence }}
{% else %}

No dependency edges are declared.

{% endif %}
{% endif %} diff --git a/src/agent_harness/templates/plan_sync_review.html b/src/agent_harness/templates/plan_sync_review.html new file mode 100644 index 0000000..dca14b1 --- /dev/null +++ b/src/agent_harness/templates/plan_sync_review.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block content %} + +

Explicit confirmation

Review plan sync

No external writes yet
+
+

Resolved target

+
Project
{{ project_id }}
Repository
{{ repo }}
Plan
{{ plan_path }}
Recognized items
{{ parsed.items|length }}
Skipped headings
{{ parsed.skipped|length }}
Orphaned issues
{{ preview.orphaned|length }} (left untouched)
+ {% if parsed.duplicate_ids or parsed.unresolved_dependencies or parsed.malformed_dependencies or parsed.dependency_cycles or parsed.unattached_arrows %} +

The plan has parser or dependency findings. Sync is refused until the preview is clean.

+ {% endif %} +

Would create

{{ preview.created|length }}

new issues

Would update

{{ preview.updated|length }}

existing marked issues

Unchanged

{{ preview.unchanged|length }}

already matching

Metadata

{{ preview.labels_created|length + preview.milestones_created|length }}

labels/milestones requested

+ {% if preview.created %}

Creates

    {% for item_id in preview.created %}
  • {{ item_id }}
  • {% endfor %}
{% endif %} + {% if preview.updated %}

Updates

    {% for item_id in preview.updated %}
  • {{ item_id }}
  • {% endfor %}
{% endif %} + {% if preview.orphaned %}

Orphans left open

    {% for item_id in preview.orphaned %}
  • {{ item_id }}
  • {% endfor %}
{% endif %} +
Consequences: applying creates or updates only marked GitHub issues and requested labels/milestones. It never closes, reopens, deletes, or silently drops an issue. The server rechecks the exact plan bytes and remote preview immediately before writing.
+
Cancel
+
+{% endblock %} diff --git a/src/agent_harness/templates/plans.html b/src/agent_harness/templates/plans.html index c3d9c8f..ef37101 100644 --- a/src/agent_harness/templates/plans.html +++ b/src/agent_harness/templates/plans.html @@ -45,6 +45,12 @@

Describe a project

{% if parse_result.dependency_cycles %}

Cycles

    {% for cycle in parse_result.dependency_cycles %}
  • {{ cycle|join(' → ') }}
  • {% endfor %}
{% endif %} {% if parse_result.unattached_arrows %}

Unattached dependency arrows

    {% for finding in parse_result.unattached_arrows %}
  • {{ finding }}
  • {% endfor %}
{% endif %} {% else %}

The configured plan file is missing or unreadable. Nothing was silently dropped.

{% endif %} + {% if repo and plan_path and project_id and parse_result and not parse_result.duplicate_ids and not parse_result.unresolved_dependencies and not parse_result.malformed_dependencies and not parse_result.dependency_cycles and not parse_result.unattached_arrows %} +
+ {% elif plan_path and not repo %} +

Configure a repository before previewing an external plan sync.

+ {% endif %} + {% if sync_error %}{% endif %} {% endif %} {% if proposal %} diff --git a/src/agent_harness/templates/preflight.html b/src/agent_harness/templates/preflight.html new file mode 100644 index 0000000..94da6c1 --- /dev/null +++ b/src/agent_harness/templates/preflight.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block content %} +

Start gate

Project preflight

{{ 'Ready' if result.ready else 'Blocked' }}
+

Project {{ project_id }}

{{ result.summary }}

Back to projects
+

This report uses the same preflight checks as the JSON start contract. A blocked result refuses start; warnings remain visible and are not silently overridden.

+
{% for check in result.checks %}

{{ check.name }}

{{ 'passed' if check.ok else ('warning' if not check.blocking else 'blocked') }}

{{ check.detail }}

{% endfor %}
+
+

Expensive check

Base branch checks

{{ base.state }}
+ {% if base.detail %}

{{ base.detail }}

{% else %}

No base-check run has been started.

{% endif %} +
+
+{% endblock %} diff --git a/src/agent_harness/templates/project_configuration.html b/src/agent_harness/templates/project_configuration.html new file mode 100644 index 0000000..726b8ad --- /dev/null +++ b/src/agent_harness/templates/project_configuration.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block content %} + +

Project {{ project.project_id }}

Project configuration

Review required
+

Submitting this page changes nothing. The server validates the same typed project contract as the JSON API and shows a separate review before applying it.

+ {% if error %}{% endif %} +
+ +

Repository and checkout

+
+
+
+
+
+
+
+

Concurrency and ceilings

Zero means unlimited only where stated. These are harness item ceilings, not provider cost caps, and changing them never retries a provider cap.

+
+
+
+
+
+
+
+

Checks, fixes, and role routes

Stored command and route values are not rendered back into HTML because they can contain credentials. They remain unchanged unless the corresponding replacement is explicitly selected.

+

{{ project.checks|length }} check(s), {{ project.fixes|length }} fix mapping(s), {{ (project.roles or {})|length }} role override(s) are configured.

+ + + +
+ +
+{% endblock %} diff --git a/src/agent_harness/templates/project_configuration_review.html b/src/agent_harness/templates/project_configuration_review.html new file mode 100644 index 0000000..a6ba34b --- /dev/null +++ b/src/agent_harness/templates/project_configuration_review.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block content %} + +

Explicit confirmation

Review project configuration

No changes applied
+

Resolved target

Project
{{ project_id }}
Changed fields
{{ changed|join(', ') }}
Repository
{{ proposed.repo or 'Not configured' }}
Checkout
{{ proposed.work_dir or 'Not configured' }}
Base branch
{{ proposed.base_branch }}
Workers
{{ proposed.max_workers }}
Attempts
{{ proposed.max_attempts }}
Wall-clock ceiling
{{ proposed.max_item_seconds or 'Unlimited' }}
Spend ceiling
{{ proposed.max_item_spend_usd or 'Unlimited' }}
Hold expiry
{{ proposed.max_hold_seconds or 'No expiry' }}
Disk floor
{{ proposed.min_free_disk_gb }} GiB
Checks
{{ proposed.checks|length }} configured; values withheld
Fixes
{{ proposed.fixes|length }} configured; values withheld
Role overrides
{{ (proposed.roles or {})|length }} configured; values withheld
+
Consequences: this persists the project definition. A running supervised pool is resized if the worker limit changed. It does not start a stopped project, bypass preflight, answer a hold, override a dependency, or perform repository writes.
+
Cancel
+
+{% endblock %} diff --git a/src/agent_harness/templates/roles_review.html b/src/agent_harness/templates/roles_review.html new file mode 100644 index 0000000..b7fa670 --- /dev/null +++ b/src/agent_harness/templates/roles_review.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} + +

Explicit confirmation

Review role routing

No changes applied
+
This changes live routing. The replacement takes effect on the next model call. It does not pause workers or rewrite project overrides.
+
+

Resolved replacement

+

Reviewer independence: {{ routes.reviewer_note }}

+ {% if role_rows %}
{% for route in role_rows %}{% endfor %}
RoleFallback orderEndpointPreset / classifierPrice referenceExecutor use
{{ route.name }}{{ route.models|join(' → ') }}{{ route.endpoint }}{{ route.preset or 'deployment default' }} / {{ route.provider }}{{ route.price_ref or 'model id' }}{% if route.used %}Used{% else %}Unused: {{ route.unused_reason }}{% endif %}
{% else %}

This replacement removes every global role route.

{% endif %} +
+
+
Cancel
+

This review is one-time and expires. A concurrent API or CLI routing change makes it stale instead of being overwritten.

+
+{% endblock %} diff --git a/src/agent_harness/templates/settings.html b/src/agent_harness/templates/settings.html index 4f2615e..2d996c2 100644 --- a/src/agent_harness/templates/settings.html +++ b/src/agent_harness/templates/settings.html @@ -5,4 +5,20 @@
Monitoring-only deployment. No worker pool is attached, so execution controls are disabled. This is a readiness condition, not a failed action.
{% endif %}

Readiness

This page never probes or mutates execution. Use the typed readiness API for an explicit, potentially expensive check.

Queue
{{ 'configured' if queue_configured else 'not configured' }}
Browser identity
{{ session.operator }}
Audit
Append-only history is shown separately from queue state.

Open API documentation

+
+

Global configuration

Role routing

Explicit review
+

The active executor determines whether each registered role is used. Project routes may override this global map.

+ {% if routes %} +

Reviewer independence: {{ routes.reviewer_note }}

+ {% if role_rows %}
{% for route in role_rows %}{% endfor %}
RoleModelsEndpointPreset / classifierPricingUse
{{ route.name }}{{ route.models|join(' → ') }}{{ route.endpoint }}{{ route.preset or 'deployment default' }} / {{ route.provider }}{{ route.price_ref or 'model id' }}{% if route.used %}Used by this executor{% else %}Unused: {{ route.unused_reason }}{% endif %}
{% else %}

No global role routes are configured.

{% endif %} +
+ + + +

For credential safety the current map is not copied into the form. Submit the complete replacement; an empty object removes all global routes. Every public RoleRoute field is preserved.

+ {% if route_error %}{% endif %} + +
+ {% else %}

Configure the queue before editing role routes.

{% endif %} +
{% endblock %} diff --git a/src/agent_harness/templates/workers.html b/src/agent_harness/templates/workers.html new file mode 100644 index 0000000..334e2bc --- /dev/null +++ b/src/agent_harness/templates/workers.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +

Runtime inventory

Workers

{{ inventory.mode }}
+ {% if not inventory.configured %} +

Monitoring-only deployment

{{ inventory.reason }}

No worker identity is inferred from durable claims; the service is not supervising execution.

+ {% elif inventory.reason %} +
{{ inventory.reason }}
+ {% elif not inventory.workers %} +

No worker evidence

The supervised pool is attached, but no live worker, claim, or failure is currently recorded.

+ {% else %} +
+

Worker and claim evidence

+

Live identities come from the attached fleet. Claims, leases, stages, failures, and abandoned sessions come from durable state and are never fabricated.

+ + {% for worker in inventory.workers %} + + + + + + + {% endfor %}
WorkerProject / itemStateLease / heartbeatStageFailure / retained session
{{ worker.worker_id }}{{ worker.project_id or '—' }}{% if worker.item_id %} / {{ worker.item_id }}{% endif %}{{ worker.state }}{% if worker.lease_until %}{{ worker.lease_until|datetime }}{% else %}—{% endif %}
{{ worker.heartbeat_at|datetime if worker.heartbeat_at else 'no durable heartbeat' }}
{{ worker.stage or '—' }}{% if worker.failure %}{{ worker.failure }}{% endif %}{% for session in worker.abandoned_sessions %}{% endfor %}{% if not worker.failure and not worker.abandoned_sessions %}—{% endif %}
+
+ {% endif %} +{% endblock %} diff --git a/src/agent_harness/ui.py b/src/agent_harness/ui.py index d2286a5..b9cb1d7 100644 --- a/src/agent_harness/ui.py +++ b/src/agent_harness/ui.py @@ -14,17 +14,47 @@ import time from collections.abc import AsyncIterator from pathlib import Path -from urllib.parse import parse_qs +from typing import Any +from urllib.parse import parse_qs, urlencode from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from pydantic import ValidationError from .browser_session import BrowserSession, BrowserSessions from .events import WORK, Event from .inception import Inception +from .plan_service import PlanSyncConflict, PlanSyncFailure +from .plan_service import apply as apply_plan_sync +from .plan_service import preview as preview_plan_sync +from .project_service import ( + ProjectConfigurationConflict, + configure_project, + project_spec, + project_spec_digest, +) from .query_service import HarnessQueries +from .routing_service import ( + RoleConfigurationConflict, + configure_roles, + role_map_digest, + role_map_payload, + role_map_view, + role_map_view_for, + safe_endpoint, + stored_role_map, +) +from .schemas import ( + BaseCheckStatus, + PlanSyncResult, + PreflightCheck, + PreflightResult, + ProjectSpec, + RoleMap, + RoleMapView, +) from .work import BLOCKED, CLAIMED, DONE, PENDING TEMPLATE_DIR = Path(__file__).with_name("templates") @@ -49,7 +79,9 @@ def queries(request: Request) -> HarnessQueries: fleet=request.app.state.fleet, ) - def render(request: Request, template: str, **context: object) -> HTMLResponse: + def render( + request: Request, template: str, *, status_code: int = 200, **context: object + ) -> HTMLResponse: session = sessions.get(request.cookies.get("harness_session")) if session is not None and not secrets.compare_digest( session.token_fingerprint, sessions.fingerprint(request.app.state.token or "") @@ -59,6 +91,7 @@ def render(request: Request, template: str, **context: object) -> HTMLResponse: return templates.TemplateResponse( request=request, name=template, + status_code=status_code, context={ "session": session, "root_path": request.scope.get("root_path", ""), @@ -87,6 +120,20 @@ def action_audit( sink = request.app.state.audit or request.app.state.store sink.append([event]) + def refusal_audit( + request: Request, + *, + action: str, + reason_kind: str, + data: dict[str, object], + ) -> None: + action_audit( + request, + action=action, + outcome="operator_action_refused", + data={"reason_kind": reason_kind, **data}, + ) + def inception_for(request: Request) -> Inception: queue = request.app.state.queue if queue is None: @@ -98,6 +145,133 @@ def project_redirect(request: Request, project_id: str) -> RedirectResponse: url=str(request.url_for("plans")) + f"?project_id={project_id}", status_code=303 ) + def preflight_model(request: Request, project_id: str, *, check_base: bool) -> PreflightResult: + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + from .api import _preflight + + report = _preflight(request.app.state, queue, project, check_base=check_base) + return PreflightResult( + project_id=report.project_id, + ready=report.ready, + summary=report.summary(), + checks=[PreflightCheck(**check.as_dict()) for check in report.checks], + ) + + def configured_project(request: Request, project_id: str) -> ProjectSpec: + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + return project_spec(project) + + def configured_project_version(request: Request, project_id: str) -> float: + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + return float(project.updated_at) + + def github_for(request: Request, repo: str) -> Any: + factory = getattr(request.app.state, "github_factory", None) + if factory is not None: + return factory(repo) + from .github import GitHub + + return GitHub(repo) + + def plans_context( + request: Request, + project_id: str | None, + *, + sync_preview: PlanSyncResult | None = None, + sync_error: str | None = None, + ) -> dict[str, Any]: + query = queries(request) + projects = query.projects() + selected = project_id or ( + projects.projects[0].project.project_id if projects.projects else None + ) + proposal = query.inception(selected) if selected else None + selected_summary = query.project(selected) if selected else None + plan_path = selected_summary.project.plan_path if selected_summary else None + repo = selected_summary.project.repo if selected_summary else None + return { + "projects": projects, + "project_id": selected, + "proposal": proposal, + "plan_markdown": query.inception_plan(selected, selected) if selected else None, + "plan_path": plan_path, + "repo": repo, + "parse_result": query.plan_parse(plan_path) if plan_path else None, + "sync_preview": sync_preview, + "sync_error": sync_error, + } + + def optional(value: str) -> str | None: + return value.strip() or None + + def project_spec_from_form(current: ProjectSpec, body: dict[str, str]) -> ProjectSpec: + """Validate browser input through the public API's exact contract.""" + data = current.model_dump(mode="json") + data.update( + { + "project_id": current.project_id, + "name": body.get("name", "").strip(), + "repo": optional(body.get("repo", "")), + "work_dir": optional(body.get("work_dir", "")), + "base_branch": body.get("base_branch", "").strip(), + "durability": body.get("durability", "").strip(), + "plan_path": optional(body.get("plan_path", "")), + "max_workers": body.get("max_workers", ""), + "max_attempts": body.get("max_attempts", ""), + "max_item_seconds": body.get("max_item_seconds", ""), + "max_item_spend_usd": body.get("max_item_spend_usd", ""), + "max_hold_seconds": body.get("max_hold_seconds", ""), + "min_free_disk_gb": body.get("min_free_disk_gb", ""), + } + ) + if body.get("replace_checks") == "yes": + data["checks"] = [ + line.strip() for line in body.get("checks", "").splitlines() if line.strip() + ] + if body.get("replace_fixes") == "yes": + data["fixes"] = json.loads(body.get("fixes", "{}") or "{}") + if body.get("replace_roles") == "yes": + data["roles"] = json.loads(body.get("roles", "{}") or "{}") or None + return ProjectSpec.model_validate(data) + + def changed_project_fields(before: ProjectSpec, after: ProjectSpec) -> list[str]: + previous = before.model_dump(mode="json") + proposed = after.model_dump(mode="json") + return sorted(key for key, value in proposed.items() if previous.get(key) != value) + + def validation_message(exc: ValidationError | json.JSONDecodeError) -> str: + """Describe invalid input without reflecting submitted values or secrets.""" + if isinstance(exc, json.JSONDecodeError): + return "Replacement checks, fixes, and routes must use valid JSON where requested." + return "; ".join( + f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" + for error in exc.errors(include_input=False, include_url=False) + ) + + def role_rows(view: RoleMapView) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + for name, route in sorted(view.roles.items()): + data = route.model_dump(mode="json") + data["name"] = name + data["endpoint"] = safe_endpoint(route.endpoint) + rows.append(data) + return rows + @app.get("/", include_in_schema=False) def root(request: Request) -> RedirectResponse: target = "projects" if sessions.get(request.cookies.get("harness_session")) else "login" @@ -181,6 +355,149 @@ def projects_page(request: Request) -> HTMLResponse: mode=("supervised" if request.app.state.fleet is not None else "monitoring-only"), ) + @app.get( + "/projects/{project_id}/configuration", + name="project_configuration", + response_class=HTMLResponse, + include_in_schema=False, + ) + def project_configuration_page(request: Request, project_id: str) -> HTMLResponse: + require_session(request) + return render( + request, + "project_configuration.html", + title="Project configuration", + project=configured_project(request, project_id), + error=None, + ) + + @app.post( + "/ui/actions/project-configuration/review", + name="project_configuration_review", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def project_configuration_review(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + current = configured_project(request, project_id) + try: + proposed = project_spec_from_form(current, body) + except (ValidationError, json.JSONDecodeError) as exc: + return render( + request, + "project_configuration.html", + title="Project configuration", + project=current, + error=validation_message(exc), + status_code=422, + ) + changed = changed_project_fields(current, proposed) + if not changed: + raise HTTPException(status_code=409, detail="configuration is unchanged") + review = sessions.create_review( + session, + kind="project_configuration", + target_id=project_id, + baseline_digest=project_spec_digest(current), + baseline_version=configured_project_version(request, project_id), + payload=proposed.model_dump(mode="json"), + ) + return render( + request, + "project_configuration_review.html", + title="Review project configuration", + project_id=project_id, + changed=changed, + proposed=proposed, + review=review, + ) + + @app.post( + "/ui/actions/project-configuration/apply", + name="project_configuration_apply", + include_in_schema=False, + ) + async def project_configuration_apply(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + review = sessions.consume_review( + session, + body.get("review_id", ""), + kind="project_configuration", + target_id=project_id, + ) + current = configured_project(request, project_id) + if not secrets.compare_digest(review.baseline_digest, project_spec_digest(current)): + raise HTTPException( + status_code=409, + detail=( + "project configuration changed after review; review the current values again" + ), + ) + proposed = ProjectSpec.model_validate(review.payload) + queue = request.app.state.queue + assert queue is not None + changed = changed_project_fields(current, proposed) + try: + configure_project( + queue, + proposed, + fleet=request.app.state.fleet, + expected_updated_at=review.baseline_version, + ) + except ProjectConfigurationConflict as exc: + raise HTTPException( + status_code=409, + detail=( + "project configuration changed after review; review the current values again" + ), + ) from exc + action_audit( + request, + action="project_configuration", + outcome="operator_configured_project", + data={"project_id": project_id, "changed_fields": changed}, + ) + return RedirectResponse( + url=request.url_for("project_configuration", project_id=project_id), status_code=303 + ) + + @app.get( + "/projects/{project_id}/preflight", + name="preflight", + response_class=HTMLResponse, + include_in_schema=False, + ) + def preflight_page(request: Request, project_id: str, check_base: bool = False) -> HTMLResponse: + require_session(request) + status = request.app.state.base_checks.status(project_id) + base = ( + BaseCheckStatus( + project_id=project_id, + state=status.state, + ok=status.ok, + detail=status.detail, + started_at=status.started_at, + finished_at=status.finished_at, + ) + if status is not None + else BaseCheckStatus(project_id=project_id, state="not_run", ok=None, detail="") + ) + return render( + request, + "preflight.html", + title="Preflight", + project_id=project_id, + result=preflight_model(request, project_id, check_base=check_base), + base=base, + check_base=check_base, + ) + @app.get("/work", name="work_page", response_class=HTMLResponse, include_in_schema=False) def work_page(request: Request, project_id: str | None = None) -> HTMLResponse: require_session(request) @@ -357,6 +674,75 @@ async def inception_start_action(request: Request) -> RedirectResponse: ) return project_redirect(request, project_id) + @app.post("/ui/actions/preflight/base", name="base_check_action", include_in_schema=False) + async def base_check_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + run = request.app.state.base_checks.start(project) + action_audit( + request, + action="base_checks", + outcome="operator_started_or_joined_base_checks", + data={"project_id": project_id, "state": run.state}, + ) + return RedirectResponse( + url=str(request.url_for("preflight", project_id=project_id)) + "?check_base=true", + status_code=303, + ) + + @app.post( + "/ui/actions/work/dependency-override", + name="dependency_override_action", + include_in_schema=False, + ) + async def dependency_override_action(request: Request) -> RedirectResponse: + """Record the same revision-scoped admission decision as the JSON API. + + The browser supplies no free-text identity: the authenticated session is + the operator recorded in the graph. The reason remains mandatory, and + the graph keeps its real blocked edge state after the override. + """ + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + item_id = body.get("item_id", "").strip() + reason = body.get("reason", "").strip() + if not project_id or not item_id: + raise HTTPException(status_code=422, detail="project and item are required") + if not reason: + raise HTTPException(status_code=422, detail="an override reason is required") + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + if queue.get(item_id, project_id=project_id) is None: + raise HTTPException(status_code=404, detail="work item not found") + revision = queue.graph.record_override( + project_id, item_id, reason=reason, who=session.operator + ) + action_audit( + request, + action="dependency_override", + outcome="operator_overrode_dependency_gate", + data={ + "project_id": project_id, + "item_id": item_id, + "revision": revision, + "reason": reason, + }, + ) + return RedirectResponse( + url=str(request.url_for("graph")) + f"?project_id={project_id}", status_code=303 + ) + @app.post("/ui/actions/inception/scope", name="inception_scope_action", include_in_schema=False) async def inception_scope_action(request: Request) -> RedirectResponse: session = require_session(request) @@ -461,54 +847,255 @@ def holds_page(request: Request, project_id: str | None = None) -> HTMLResponse: ) @app.get("/events", name="events", response_class=HTMLResponse, include_in_schema=False) - def events_page(request: Request) -> HTMLResponse: + def events_page( + request: Request, + since_id: int = 0, + limit: int = 100, + project_id: str | None = None, + item_id: str | None = None, + worker: str | None = None, + endpoint: str | None = None, + role: str | None = None, + model: str | None = None, + outcome: str | None = None, + error_class: str | None = None, + reason_kind: str | None = None, + start_ts: str | None = None, + end_ts: str | None = None, + ) -> HTMLResponse: require_session(request) + from .schemas import EventFilters + + try: + filters = EventFilters( + project_id=project_id, + item_id=item_id, + worker=worker, + endpoint=endpoint, + role=role, + model=model, + outcome=outcome, + error_class=error_class, + reason_kind=reason_kind, + start_ts=start_ts, + end_ts=end_ts, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + event_page = queries(request).filtered_events( + since_id=since_id, limit=min(max(limit, 1), 1000), filters=filters, live=True + ) + stream_params = { + key: value for key, value in filters.model_dump().items() if value is not None + } + stream_params["since_id"] = event_page.cursor return render( request, "events.html", title="Events", - events=queries(request).live_events(), + events=event_page, + filters=filters, + stream_url=str(request.url_for("event_stream")) + "?" + urlencode(stream_params), + ) + + @app.get("/workers", name="workers", response_class=HTMLResponse, include_in_schema=False) + def workers_page(request: Request, project_id: str | None = None) -> HTMLResponse: + require_session(request) + return render( + request, + "workers.html", + title="Workers", + project_id=project_id, + inventory=queries(request).worker_inventory(project_id), ) @app.get("/analytics", name="analytics", response_class=HTMLResponse, include_in_schema=False) - def analytics_page(request: Request) -> HTMLResponse: + def analytics_page( + request: Request, + window: str = "7d", + project_id: str | None = None, + ) -> HTMLResponse: require_session(request) - audit = request.app.state.audit + try: + dashboard = queries(request).analytics(window=window, project_id=project_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc return render( request, "analytics.html", title="Analytics", - rate_limits=(audit.rate_limits_by_class() if audit is not None else {}), - costs=(audit.cost() if audit is not None else []), - delivery=(audit.delivery() if audit is not None else []), - audit_health=( - {"configured": True, "degraded": audit.degraded, "events": audit.count()} - if audit is not None - else {"configured": False, "degraded": True, "events": 0} - ), + dashboard=dashboard, ) @app.get("/plans", name="plans", response_class=HTMLResponse, include_in_schema=False) def plans_page(request: Request, project_id: str | None = None) -> HTMLResponse: require_session(request) - query = queries(request) - projects = query.projects() - selected = project_id or ( - projects.projects[0].project.project_id if projects.projects else None - ) - proposal = query.inception(selected) if selected else None - selected_summary = query.project(selected) if selected else None - plan_path = selected_summary.project.plan_path if selected_summary else None return render( request, "plans.html", title="Plans", - projects=projects, - project_id=selected, - proposal=proposal, - plan_markdown=query.inception_plan(selected, selected) if selected else None, - plan_path=plan_path, - parse_result=query.plan_parse(plan_path) if plan_path else None, + **plans_context(request, project_id), + ) + + @app.post( + "/ui/actions/plan-sync/review", + name="plan_sync_review", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def plan_sync_review(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + if not project.repo or not project.plan_path: + raise HTTPException( + status_code=422, + detail="configure both a repository and a plan path before syncing", + ) + try: + digest, parsed, preview = preview_plan_sync( + project.plan_path, github_for(request, project.repo) + ) + except PlanSyncConflict as exc: + return render( + request, + "plans.html", + title="Plans", + status_code=409, + **plans_context(request, project_id, sync_error=str(exc)), + ) + except PlanSyncFailure as exc: + return render( + request, + "plans.html", + title="Plans", + status_code=502, + **plans_context( + request, project_id, sync_error=f"GitHub refused the preview: {exc}" + ), + ) + review = sessions.create_review( + session, + kind="plan_sync", + target_id=project_id, + baseline_digest=digest, + baseline_version=float(project.updated_at), + payload={ + "repo": project.repo, + "plan_path": project.plan_path, + "parsed": parsed.model_dump(mode="json"), + "preview": preview.model_dump(mode="json"), + }, + ) + return render( + request, + "plan_sync_review.html", + title="Review plan sync", + project_id=project_id, + plan_path=project.plan_path, + repo=project.repo, + parsed=parsed, + preview=preview, + review=review, + ) + + @app.post( + "/ui/actions/plan-sync/apply", + name="plan_sync_apply", + include_in_schema=False, + ) + async def plan_sync_apply(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + review = sessions.consume_review( + session, + body.get("review_id", ""), + kind="plan_sync", + target_id=project_id, + ) + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + payload = review.payload + repo = payload.get("repo") + plan_path = payload.get("plan_path") + preview_payload = payload.get("preview") + if ( + not isinstance(repo, str) + or not isinstance(plan_path, str) + or not isinstance(preview_payload, dict) + ): + refusal_audit( + request, + action="plan_sync", + reason_kind="invalid_review", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=409, detail="plan review payload is invalid") + if ( + project.updated_at != review.baseline_version + or project.repo != repo + or project.plan_path != plan_path + ): + refusal_audit( + request, + action="plan_sync", + reason_kind="project_configuration_changed", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=409, detail="project changed after plan review") + expected_preview = PlanSyncResult.model_validate(preview_payload) + try: + result = apply_plan_sync( + plan_path, + repo, + github_for(request, repo), + expected_digest=review.baseline_digest, + expected_preview=expected_preview, + ) + except PlanSyncConflict as exc: + refusal_audit( + request, + action="plan_sync", + reason_kind=exc.reason_kind, + data={"project_id": project_id, "repo": repo, "plan_path": plan_path}, + ) + raise HTTPException(status_code=409, detail=str(exc)) from exc + except PlanSyncFailure as exc: + refusal_audit( + request, + action="plan_sync", + reason_kind=exc.reason_kind, + data={"project_id": project_id, "repo": repo, "plan_path": plan_path}, + ) + raise HTTPException(status_code=502, detail=f"GitHub refused the sync: {exc}") from exc + action_audit( + request, + action="plan_sync", + outcome="operator_synced_plan", + data={ + "project_id": project_id, + "repo": repo, + "plan_path": plan_path, + "created": result.created, + "updated": result.updated, + "orphaned": result.orphaned, + }, + ) + return RedirectResponse( + url=str(request.url_for("plans")) + f"?project_id={project_id}", status_code=303 ) @app.get("/graph", name="graph", response_class=HTMLResponse, include_in_schema=False) @@ -526,6 +1113,7 @@ def graph_page(request: Request, project_id: str | None = None) -> HTMLResponse: projects=projects, project_id=selected, graph=query.graph(selected) if selected else None, + overrides=(query.overrides(selected) if selected else []), ) @app.get("/sessions", name="sessions", response_class=HTMLResponse, include_in_schema=False) @@ -544,6 +1132,7 @@ def settings_page(request: Request) -> HTMLResponse: readiness = None queue = request.app.state.queue configured = queue is not None + routes = role_map_view(request.app.state, queue) if queue is not None else None return render( request, "settings.html", @@ -551,8 +1140,108 @@ def settings_page(request: Request) -> HTMLResponse: readiness=readiness, mode=("supervised" if request.app.state.fleet is not None else "monitoring-only"), queue_configured=configured, + routes=routes, + role_rows=(role_rows(routes) if routes is not None else []), + ) + + @app.post( + "/ui/actions/roles/review", + name="roles_review", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def roles_review(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + try: + submitted = json.loads(body.get("roles", "{}") or "{}") + proposed = RoleMap.model_validate({"roles": submitted}) + except (ValidationError, json.JSONDecodeError) as exc: + return render( + request, + "settings.html", + title="Settings", + status_code=422, + readiness=None, + mode=("supervised" if request.app.state.fleet is not None else "monitoring-only"), + queue_configured=True, + routes=role_map_view(request.app.state, queue), + role_rows=role_rows(role_map_view(request.app.state, queue)), + route_error=validation_message(exc), + ) + current = stored_role_map(queue) + payload = role_map_payload(proposed) + review = sessions.create_review( + session, + kind="role_map", + target_id="global", + baseline_digest=role_map_digest(current), + baseline_version=0, + payload={"expected": current, "proposed": payload}, + ) + proposed_view = role_map_view_for(request.app.state, payload) + return render( + request, + "roles_review.html", + title="Review role routing", + review=review, + routes=proposed_view, + role_rows=role_rows(proposed_view), ) + @app.post( + "/ui/actions/roles/apply", + name="roles_apply", + include_in_schema=False, + ) + async def roles_apply(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + review = sessions.consume_review( + session, + body.get("review_id", ""), + kind="role_map", + target_id="global", + ) + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + expected = review.payload.get("expected") + proposed_payload = review.payload.get("proposed") + if (expected is not None and not isinstance(expected, dict)) or not isinstance( + proposed_payload, dict + ): + refusal_audit( + request, + action="role_configuration", + reason_kind="invalid_review", + data={"scope": "global"}, + ) + raise HTTPException(status_code=409, detail="role review payload is invalid") + proposed = RoleMap.model_validate({"roles": proposed_payload}) + try: + configure_roles(queue, proposed, expected=expected) + except RoleConfigurationConflict as exc: + refusal_audit( + request, + action="role_configuration", + reason_kind="role_map_changed", + data={"scope": "global"}, + ) + raise HTTPException(status_code=409, detail=str(exc)) from exc + action_audit( + request, + action="role_configuration", + outcome="operator_configured_roles", + data={"scope": "global", "roles": sorted(proposed.roles)}, + ) + return RedirectResponse(url=request.url_for("settings"), status_code=303) + @app.get("/ui/fragments/projects", response_class=HTMLResponse, include_in_schema=False) def projects_fragment(request: Request) -> HTMLResponse: require_session(request) @@ -586,16 +1275,42 @@ async def event_stream(request: Request) -> StreamingResponse: ) except ValueError as exc: raise HTTPException(status_code=400, detail="event cursor must be an integer") from exc + from .schemas import EventFilters + + try: + filters = EventFilters( + **{ + key: request.query_params.get(key) + for key in ( + "project_id", + "item_id", + "worker", + "endpoint", + "role", + "model", + "outcome", + "error_class", + "reason_kind", + "start_ts", + "end_ts", + ) + if request.query_params.get(key) is not None + } + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc async def stream() -> AsyncIterator[str]: nonlocal last_id while True: if await request.is_disconnected(): return - page = queries(request).live_events(last_id, limit=200) + page = queries(request).filtered_events( + last_id, limit=200, filters=filters, live=True + ) + last_id = page.cursor if page.events: for event in page.events: - last_id = event.id yield f"id: {event.id}\nevent: harness\ndata: {event.model_dump_json()}\n\n" else: yield ": heartbeat\n\n" diff --git a/src/agent_harness/work.py b/src/agent_harness/work.py index f63dda5..f42965e 100644 --- a/src/agent_harness/work.py +++ b/src/agent_harness/work.py @@ -217,6 +217,7 @@ -- same thing after a week. CREATE TABLE IF NOT EXISTS abandoned_sessions ( session_id TEXT PRIMARY KEY, + project_id TEXT, item_id TEXT NOT NULL, reason TEXT, session_url TEXT, @@ -613,6 +614,9 @@ def _migrate(self) -> None: # what it always did. "deliverable": "TEXT NOT NULL DEFAULT 'code'", }, + "abandoned_sessions": { + "project_id": "TEXT", + }, } def _add_missing_columns(self, conn: sqlite3.Connection) -> None: @@ -761,6 +765,46 @@ def add_project(self, project: Project) -> None: finally: conn.close() + def update_project(self, project: Project, *, expected_updated_at: float) -> bool: + """Replace an existing project only if it is still the reviewed version. + + Browser review is a judgement about exact values. A compare followed + by an unconditional update leaves a race for another process between + those operations, so the version predicate belongs in the UPDATE. + """ + conn = self._connect() + try: + changed = conn.execute( + "UPDATE projects SET name = ?, repo = ?, work_dir = ?, base_branch = ?, " + "checks = ?, fixes = ?, durability = ?, max_item_seconds = ?, " + "max_item_spend_usd = ?, max_hold_seconds = ?, plan_path = ?, roles = ?, " + "max_workers = ?, max_attempts = ?, min_free_disk_gb = ?, updated_at = ? " + "WHERE project_id = ? AND updated_at = ?", + ( + project.name, + project.repo, + project.work_dir, + project.base_branch, + json.dumps(project.checks), + json.dumps(project.fixes), + project.durability, + project.max_item_seconds, + project.max_item_spend_usd, + project.max_hold_seconds, + project.plan_path, + json.dumps(project.roles) if project.roles else None, + project.max_workers, + project.max_attempts, + project.min_free_disk_gb, + self.now(), + project.project_id, + expected_updated_at, + ), + ).rowcount + return changed == 1 + finally: + conn.close() + def projects(self) -> list[Project]: conn = self._connect() try: @@ -993,6 +1037,37 @@ def set_setting(self, key: str, value: Any) -> None: finally: conn.close() + def compare_and_set_setting(self, key: str, expected: Any | None, value: Any) -> bool: + """Replace a setting only when its complete stored value is unchanged. + + Browser reviews use this instead of comparing a timestamp and then + writing in a second transaction. The immediate transaction makes + the comparison and replacement one operation, so an API or CLI edit + cannot be overwritten between those two steps. ``None`` means the + setting was absent when reviewed. + """ + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + current = json.loads(row["value"]) if row else None + if current != expected: + conn.rollback() + return False + conn.execute( + "INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, " + "updated_at = excluded.updated_at", + (key, json.dumps(value), self.now()), + ) + conn.commit() + return True + except Exception: + conn.rollback() + raise + finally: + conn.close() + # ------------------------------------------------------------- claims def claim( @@ -1633,6 +1708,7 @@ def record_abandoned_session( session_id: str, item_id: str, *, + project_id: str | None = None, reason: str | None = None, session_url: str | None = None, ) -> None: @@ -1646,8 +1722,9 @@ def record_abandoned_session( try: conn.execute( "INSERT OR REPLACE INTO abandoned_sessions " - "(session_id, item_id, reason, session_url, abandoned_at) VALUES (?, ?, ?, ?, ?)", - (session_id, item_id, reason, session_url, self.now()), + "(session_id, project_id, item_id, reason, session_url, abandoned_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (session_id, project_id, item_id, reason, session_url, self.now()), ) finally: conn.close() diff --git a/tests/test_api.py b/tests/test_api.py index f7a5bc9..ba1b8ab 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,8 +13,9 @@ from agent_harness.api import create_api from agent_harness.events import MODEL_CALL, UNCLASSIFIED, WORK, Event +from agent_harness.fleet import WorkerSnapshot from agent_harness.store import EventStore -from agent_harness.work import CLAIMED, DONE, PENDING, WorkQueue, WorkRecord +from agent_harness.work import CLAIMED, DONE, PENDING, Project, WorkQueue, WorkRecord from conftest import make_queue TOKEN = "test-token" # noqa: S105 - a fixture, not a credential @@ -404,6 +405,65 @@ def test_an_empty_poll_keeps_the_cursor(client: TestClient) -> None: assert payload["cursor"] == 99 +def test_event_filters_scan_past_non_matching_rows_and_keep_stream_cursor( + client: TestClient, store: EventStore +) -> None: + now = time.time() + store.append( + [ + Event(ts=now, kind=WORK, source="s", outcome="other", data={"project_id": "p"}), + Event( + ts=now, + kind=WORK, + source="s", + outcome="wanted", + data={"project_id": "p", "reason_kind": "checks_failed"}, + ), + Event(ts=now, kind=WORK, source="s", outcome="later", data={"project_id": "q"}), + ] + ) + payload = client.get( + "/api/events?since_id=0&limit=1&project_id=p&reason_kind=checks_failed", + headers=auth(), + ).json() + assert [event["outcome"] for event in payload["events"]] == ["wanted"] + assert payload["cursor"] == 2 + + +def test_worker_inventory_is_explicitly_monitoring_only_without_fleet( + client: TestClient, +) -> None: + payload = client.get("/api/workers", headers=auth()).json() + assert payload["configured"] is False + assert payload["mode"] == "monitoring-only" + assert payload["workers"] == [] + + +def test_worker_inventory_joins_live_identity_with_durable_claim(tmp_path: Path) -> None: + store = EventStore(tmp_path / "e.sqlite") + queue = WorkQueue(str(tmp_path / "w.sqlite"), lease_seconds=100.0) + queue.add_project(Project(project_id="p", name="P")) + queue.add([WorkRecord(item_id="W1", title="First")], project_id="p") + queue.set_control("running", project_id="p") + assert queue.claim("owner", project_id="p") is not None + + class FakeFleet: + def workers(self, project_id: str | None = None) -> list[WorkerSnapshot]: + assert project_id in (None, "p") + return [WorkerSnapshot("p", "harness-p-1", "owner", 100.0)] + + def failures(self, project_id: str | None = None) -> list[Any]: + return [] + + with TestClient(create_api(store, queue=queue, token=TOKEN, fleet=FakeFleet())) as client: + payload = client.get("/api/workers?project_id=p", headers=auth()).json() + assert payload["configured"] is True + assert payload["mode"] == "supervised" + assert payload["workers"][0]["worker_id"] == "harness-p-1" + assert payload["workers"][0]["item_id"] == "W1" + assert payload["workers"][0]["claim_owner"] == "owner" + + # ---------------------------------------------------------------- summary @@ -460,6 +520,8 @@ def test_the_schema_documents_response_shapes_not_empty_objects( ("/api/summary", "get"), ("/api/errors", "get"), ("/api/events", "get"), + ("/api/workers", "get"), + ("/api/analytics", "get"), ("/healthz", "get"), ]: content = schema["paths"][path][method]["responses"]["200"]["content"] @@ -651,6 +713,59 @@ def test_the_role_map_can_be_read_and_changed(client: TestClient) -> None: assert stored["reviewer"]["model"] == "other-vendor" +def test_the_role_map_preserves_fallback_preset_and_pricing_fields(client: TestClient) -> None: + route = { + "models": ["preferred", "fallback"], + "endpoint": "https://models.example/v1", + "provider": "generic", + "preset": "chat-completions", + "price_ref": "priced-as-this", + } + response = client.put("/api/roles", headers=auth(), json={"roles": {"reviewer": route}}) + assert response.status_code == 200 + stored = client.get("/api/roles", headers=auth()).json()["roles"]["reviewer"] + assert stored["model"] == "preferred" + assert stored["models"] == ["preferred", "fallback"] + assert stored["preset"] == "chat-completions" + assert stored["price_ref"] == "priced-as-this" + + +def test_json_and_browser_plan_sync_share_dependency_refusals( + tmp_path: Path, store: EventStore +) -> None: + plan = tmp_path / "PLAN.md" + plan.write_text( + "# Plan\n\n### T1 — Cannot start\n\ndepends on: T404\n", + encoding="utf-8", + ) + queue = WorkQueue(str(tmp_path / "sync.sqlite")) + queue.add_project(Project(project_id="p", name="P", repo="o/r", plan_path=str(plan))) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as app_client: + response = app_client.post( + "/api/plan/sync", + headers=auth(), + json={"path": str(plan), "repo": "o/r", "dry_run": True}, + ) + assert response.status_code == 409 + assert response.json()["detail"]["unresolved_dependencies"] == {"T1": ["T404"]} + + login = app_client.post( + "/login", + data={"token": TOKEN}, + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + assert login.status_code == 200 + page = app_client.get("/plans?project_id=p") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + browser = app_client.post( + "/ui/actions/plan-sync/review", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + ) + assert browser.status_code == 409 + assert "unresolved" in browser.text.lower() + + def test_the_role_map_persists_for_a_worker_in_another_process( client: TestClient, queue: WorkQueue ) -> None: diff --git a/tests/test_audit.py b/tests/test_audit.py index 190dd25..7527452 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -339,6 +339,66 @@ def test_cost_reports_unpriced_calls_separately(client) -> None: # type: ignore assert body["total_unpriced"] == 1, "an unpriced call was folded into the total" +def test_analytics_projection_keeps_classes_denominators_and_baselines(client) -> None: # type: ignore[no-untyped-def] + client.audit.append( + [ + ev( + kind=MODEL_CALL, + error_class="rpm", + data={"run_id": "r", "seq": 1, "project_id": "p", "tokens_in": 10}, + ), + ev( + kind=MODEL_CALL, + error_class="unclassified", + data={"run_id": "r", "seq": 2, "project_id": "p"}, + ), + ev( + kind=WORK, + outcome="done", + data={"run_id": "r", "seq": 3, "project_id": "p", "item_id": "T1"}, + ), + ] + ) + body = client.get("/api/analytics?window=all&project_id=p", headers=hdr()) + assert body.status_code == 200 + payload = body.json() + assert payload["rate_limits"]["classified"]["rpm"] == 1 + assert payload["rate_limits"]["unclassified"] == 1 + assert payload["rate_limits"]["total"] == 1 + assert payload["rate_limits"]["denominator"] == 2 + assert payload["cost"]["denominator"] == 2 + assert payload["delivery"]["denominator"] == 1 + assert payload["audit_health"]["events"] == 3 + + baseline = { + "baseline_id": "p-before", + "project_id": "p", + "label": "before", + "window_days": 7, + "items_done": 12, + } + assert client.post("/api/audit/baselines", headers=hdr(), json=baseline).status_code == 200 + refreshed = client.get("/api/analytics?window=all&project_id=p", headers=hdr()).json() + assert refreshed["baselines"]["baselines"][0]["items_done"] == 12 + + +def test_analytics_projection_marks_partial_history_and_degraded_store(tmp_path: Path) -> None: + from fastapi.testclient import TestClient + + from agent_harness.api import create_api + + blocker = tmp_path / "blocked" + blocker.write_text("not a directory") + audit = open_audit_store(blocker / "audit.sqlite", required=False) + with TestClient( + create_api(EventStore(tmp_path / "harness.sqlite"), token="tok", audit=audit) + ) as c: # noqa: S106 + payload = c.get("/api/analytics?window=7d", headers=hdr()).json() + assert payload["audit_health"]["configured"] is True + assert payload["audit_health"]["degraded"] is True + assert payload["cost"]["denominator"] == 0 + + def test_a_window_longer_than_the_history_is_flagged_partial(client) -> None: # type: ignore[no-untyped-def] """A chart labelled '7 days' drawn from one hour of history is not wrong about the data, it is wrong about the question -- and the numbers alone diff --git a/tests/test_ui.py b/tests/test_ui.py index 0156bb5..5bfdc62 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -4,13 +4,17 @@ import json import time +from collections.abc import Sequence from pathlib import Path -from typing import Any +from typing import Any, cast from fastapi.testclient import TestClient from agent_harness.api import create_api +from agent_harness.audit import AuditStore from agent_harness.events import WORK, Event +from agent_harness.github import GitHub, GitHubError +from agent_harness.runtime import ExecutorRoles from agent_harness.store import EventStore from agent_harness.work import Project, WorkQueue, WorkRecord @@ -38,6 +42,41 @@ def call(self, _role: str, _messages: list[dict[str, Any]]) -> str: return json.dumps(PROPOSAL) +class FakeGitHubRunner: + """Offline GitHub transport that exposes preview writes in argv.""" + + def __init__(self) -> None: + self.calls: list[list[str]] = [] + + def __call__(self, args: Sequence[str], stdin: str | None = None) -> str: + self.calls.append([*args, *([stdin] if stdin is not None else [])]) + if args[1:3] == ["issue", "list"]: + return "[]" + if args[1:3] == ["label", "list"]: + return "[]" + if args[1] == "api" and "milestones" in " ".join(args): + return "[]" + return "https://github.com/o/r/issues/1\n" + + +class StatefulGitHubRunner(FakeGitHubRunner): + """A remote backlog that can drift or refuse the confirmed write.""" + + def __init__(self) -> None: + super().__init__() + self.issues = "[]" + self.fail_writes = False + + def __call__(self, args: Sequence[str], stdin: str | None = None) -> str: + if args[1:3] == ["issue", "list"]: + self.calls.append([*args]) + return self.issues + if self.fail_writes and args[1:3] in (["issue", "create"], ["issue", "edit"]): + self.calls.append([*args]) + raise GitHubError("the remote rejected the write") + return super().__call__(args, stdin) + + def make_client(tmp_path: Path, *, token: str | None = TOKEN) -> TestClient: store = EventStore(tmp_path / "events.sqlite") queue = WorkQueue(str(tmp_path / "queue.sqlite")) @@ -126,6 +165,24 @@ def test_monitoring_only_disables_project_controls(tmp_path: Path) -> None: assert "disabled" in html +def test_preflight_page_uses_start_gate_and_base_check_contract(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + login(client) + html = client.get("/projects/p/preflight").text + assert "Project preflight" in html + assert "no worker pool is attached" in html + assert "Run base checks" in html + csrf = html.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + response = client.post( + "/ui/actions/preflight/base", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"].endswith("/projects/p/preflight?check_base=true") + + def test_hold_answer_form_uses_opaque_resume_token_and_csrf(tmp_path: Path) -> None: with make_client(tmp_path) as client: queue = client.app.state.queue # type: ignore[attr-defined] @@ -238,6 +295,71 @@ def test_event_stream_accepts_cursor_and_requires_auth(tmp_path: Path) -> None: assert response.status_code == 400 +def test_events_page_preserves_typed_filters_and_workers_page_explains_monitoring_only( + tmp_path: Path, +) -> None: + with make_client(tmp_path) as client: + store = client.app.state.store # type: ignore[attr-defined] + store.append( + [ + Event( + ts=time.time(), + kind=WORK, + source="fixture", + outcome="checks_failed", + data={"project_id": "p", "reason_kind": "checks_failed"}, + ) + ] + ) + login(client) + events = client.get("/events?project_id=p&reason_kind=checks_failed") + assert events.status_code == 200 + assert 'name="reason_kind" value="checks_failed"' in events.text + assert "checks_failed" in events.text + workers = client.get("/workers") + assert workers.status_code == 200 + assert "Monitoring-only deployment" in workers.text + + +def test_analytics_page_shows_denominators_and_unknown_costs(tmp_path: Path) -> None: + audit = AuditStore(tmp_path / "audit.sqlite") + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + audit.append( + [ + Event( + ts=time.time(), + kind="model_call", + source="fixture", + error_class="rpm", + data={ + "run_id": "r", + "seq": 1, + "project_id": "p", + "tokens_in": 1, + "price_in_per_mtok": 2.0, + "price_table": "fixture", + }, + ), + Event( + ts=time.time(), + kind="model_call", + source="fixture", + error_class="unclassified", + data={"run_id": "r", "seq": 2, "project_id": "p"}, + ), + ] + ) + with TestClient(create_api(store, queue=queue, audit=audit, token=TOKEN)) as client: + login(client) + page = client.get("/analytics?window=all&project_id=p") + assert page.status_code == 200 + assert "Denominator: 2 observed rate-limit rows" in page.text + assert "unclassified" in page.text + assert "Known spend excludes 1 unpriced calls" in page.text + + def test_ui_named_urls_honor_root_path(tmp_path: Path) -> None: with make_rooted_client(tmp_path) as client: response = client.get("/login") @@ -318,6 +440,194 @@ def test_plans_show_loss_report_for_configured_plan(tmp_path: Path) -> None: assert "unresolved" in html.lower() +def test_plan_sync_requires_preview_and_rechecks_exact_plan(tmp_path: Path) -> None: + plan_path = tmp_path / "PLAN.md" + plan_path.write_text("# Plan\n\n### T1 — Ship it\n\nA precise brief.\n", encoding="utf-8") + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project(project_id="p", name="Project P", repo="o/r", plan_path=str(plan_path)) + ) + runner = FakeGitHubRunner() + app = create_api( + store, + queue=queue, + token=TOKEN, + github_factory=lambda repo: GitHub(repo, runner), + ) + with TestClient(app) as client: + login(client) + html = client.get("/plans?project_id=p").text + assert "Preview GitHub sync" in html + csrf = html.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + review = client.post( + "/ui/actions/plan-sync/review", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + ) + assert review.status_code == 200 + assert "No external writes yet" in review.text + assert not any(call[1:3] == ["issue", "create"] for call in runner.calls) + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + + plan_path.write_text( + "# Plan\n\n### T1 — Changed after review\n\nA different brief.\n", + encoding="utf-8", + ) + stale = client.post( + "/ui/actions/plan-sync/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert stale.status_code == 409 + assert not any(call[1:3] == ["issue", "create"] for call in runner.calls) + + plan_path.write_text("# Plan\n\n### T1 — Ship it\n\nA precise brief.\n", encoding="utf-8") + fresh = client.post( + "/ui/actions/plan-sync/review", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + ) + fresh_id = fresh.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + applied = client.post( + "/ui/actions/plan-sync/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": fresh_id}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert applied.status_code == 303 + assert any(call[1:3] == ["issue", "create"] for call in runner.calls) + assert any(event["data"].get("action") == "plan_sync" for event in store.recent(limit=20)) + + +def test_plan_sync_remote_drift_and_refusal_write_nothing_unreviewed(tmp_path: Path) -> None: + plan_path = tmp_path / "PLAN.md" + plan_path.write_text("# Plan\n\n### T1 — Ship it\n\nA precise brief.\n", encoding="utf-8") + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project(project_id="p", name="Project P", repo="o/r", plan_path=str(plan_path)) + ) + runner = StatefulGitHubRunner() + app = create_api( + store, + queue=queue, + token=TOKEN, + github_factory=lambda repo: GitHub(repo, runner), + ) + with TestClient(app) as client: + login(client) + page = client.get("/plans?project_id=p") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + review = client.post( + "/ui/actions/plan-sync/review", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + ) + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + runner.issues = json.dumps( + [ + { + "number": 1, + "title": "Changed remotely", + "body": "", + "state": "OPEN", + "labels": [], + "milestone": None, + "assignees": [], + "url": "https://github.com/o/r/issues/1", + } + ] + ) + drifted = client.post( + "/ui/actions/plan-sync/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert drifted.status_code == 409 + assert not any( + call[1:3] in (["issue", "create"], ["issue", "edit"]) for call in runner.calls + ) + assert any( + event["data"].get("reason_kind") == "remote_preview_changed" + for event in store.recent(limit=20) + ) + + runner.issues = "[]" + fresh = client.post( + "/ui/actions/plan-sync/review", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + ) + fresh_id = fresh.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + runner.fail_writes = True + refused = client.post( + "/ui/actions/plan-sync/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": fresh_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert refused.status_code == 502 + assert any( + event["data"].get("reason_kind") == "github_refused" for event in store.recent(limit=20) + ) + + +def test_plan_sync_rejects_project_target_drift_and_audits_it(tmp_path: Path) -> None: + plan_path = tmp_path / "PLAN.md" + plan_path.write_text("# Plan\n\n### T1 — Ship it\n\nA precise brief.\n", encoding="utf-8") + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project(project_id="p", name="Project P", repo="o/r", plan_path=str(plan_path)) + ) + runner = FakeGitHubRunner() + with TestClient( + create_api( + store, + queue=queue, + token=TOKEN, + github_factory=lambda repo: GitHub(repo, runner), + ) + ) as client: + login(client) + page = client.get("/plans?project_id=p") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + review = client.post( + "/ui/actions/plan-sync/review", + data={"csrf_token": csrf, "project_id": "p"}, + headers={"X-CSRF-Token": csrf}, + ) + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + queue.add_project( + Project(project_id="p", name="Project P", repo="other/repo", plan_path=str(plan_path)) + ) + response = client.post( + "/ui/actions/plan-sync/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert response.status_code == 409 + assert not any(call[1:3] == ["issue", "create"] for call in runner.calls) + assert any( + event["data"].get("reason_kind") == "project_configuration_changed" + for event in store.recent(limit=20) + ) + + +def test_plan_sync_control_needs_both_plan_and_repository(tmp_path: Path) -> None: + plan_path = tmp_path / "PLAN.md" + plan_path.write_text("# Plan\n\n### T1 — Ship it\n\nA precise brief.\n", encoding="utf-8") + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P", plan_path=str(plan_path))) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + html = client.get("/plans?project_id=p").text + assert "Preview GitHub sync" not in html + assert "Configure a repository" in html + + def test_graph_page_shows_typed_edges_and_readiness(tmp_path: Path) -> None: store = EventStore(tmp_path / "events.sqlite") queue = WorkQueue(str(tmp_path / "queue.sqlite")) @@ -335,3 +645,243 @@ def test_graph_page_shows_typed_edges_and_readiness(tmp_path: Path) -> None: assert "Dependency graph" in html assert "T2" in html and "T1" in html assert "blocked" in html.lower() + + +def test_dependency_override_is_explicit_revision_scoped_and_audited(tmp_path: Path) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + queue.add( + [ + WorkRecord(item_id="T1", title="First", brief="first"), + WorkRecord(item_id="T2", title="Second", brief="second", depends_on=["T1"]), + ], + project_id="p", + ) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + html = client.get("/graph?project_id=p").text + csrf = html.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + missing_reason = client.post( + "/ui/actions/work/dependency-override", + data={ + "csrf_token": csrf, + "project_id": "p", + "item_id": "T2", + "reason": "", + }, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert missing_reason.status_code == 422 + response = client.post( + "/ui/actions/work/dependency-override", + data={ + "csrf_token": csrf, + "project_id": "p", + "item_id": "T2", + "reason": "the dependency is tracked in the external release board", + }, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"].endswith("/graph?project_id=p") + readiness = queue.readiness("T2", project_id="p") + assert readiness.ready is True + assert readiness.overridden is True + assert readiness.override_reason is not None + assert "external release board" in readiness.override_reason + html = client.get("/graph?project_id=p").text + assert "Recorded overrides" in html + assert "external release board" in html + assert "operator" in html + assert any( + event["data"].get("action") == "dependency_override" + and event["data"].get("operator") == "operator" + for event in store.recent(limit=20) + ) + + +def _configuration_form(csrf: str, *, name: str) -> dict[str, str]: + return { + "csrf_token": csrf, + "project_id": "p", + "name": name, + "base_branch": "main", + "repo": "", + "work_dir": "", + "plan_path": "", + "durability": "", + "max_workers": "1", + "max_attempts": "5", + "max_item_seconds": "0", + "max_item_spend_usd": "0", + "max_hold_seconds": "21600", + "min_free_disk_gb": "0", + } + + +def test_project_configuration_is_secret_safe_and_requires_review(tmp_path: Path) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P", checks=["echo SECRET"])) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + page = client.get("/projects/p/configuration") + assert page.status_code == 200 + assert "1 check(s)" in page.text + assert "SECRET" not in page.text + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + review = client.post( + "/ui/actions/project-configuration/review", + data=_configuration_form(csrf, name="Renamed project"), + headers={"X-CSRF-Token": csrf}, + ) + assert review.status_code == 200 + assert "No changes applied" in review.text + assert "name" in review.text + configured = queue.get_project("p") + assert configured is not None and configured.name == "Project P" + + +def test_project_configuration_apply_is_one_time_audited_and_stale_safe(tmp_path: Path) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + page = client.get("/projects/p/configuration") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + review = client.post( + "/ui/actions/project-configuration/review", + data=_configuration_form(csrf, name="Renamed project"), + headers={"X-CSRF-Token": csrf}, + ) + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + applied = client.post( + "/ui/actions/project-configuration/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert applied.status_code == 303 + configured = queue.get_project("p") + assert configured is not None and configured.name == "Renamed project" + assert any( + event["data"].get("action") == "project_configuration" + and event["data"].get("operator") == "operator" + for event in store.recent(limit=20) + ) + replay = client.post( + "/ui/actions/project-configuration/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert replay.status_code == 409 + + page = client.get("/projects/p/configuration") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + stale = client.post( + "/ui/actions/project-configuration/review", + data=_configuration_form(csrf, name="Stale browser value"), + headers={"X-CSRF-Token": csrf}, + ) + stale_id = stale.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + queue.add_project(Project(project_id="p", name="Concurrent API value")) + refused = client.post( + "/ui/actions/project-configuration/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": stale_id}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert refused.status_code == 409 + configured = queue.get_project("p") + assert configured is not None and configured.name == "Concurrent API value" + + +def test_global_role_routing_is_secret_safe_reviewed_complete_and_stale_safe( + tmp_path: Path, +) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + app = create_api( + store, + queue=queue, + token=TOKEN, + executor_roles=ExecutorRoles(calls=frozenset({"reviewer"}), implemented_by="agent"), + ) + route_map: dict[str, dict[str, object]] = { + "implementer": { + "models": ["writer", "writer-fallback"], + "endpoint": "https://user:password@models.example/v1?token=secret", + "provider": "generic", + "preset": "chat-completions", + "price_ref": "writer-price", + }, + "reviewer": { + "model": "reviewer", + "endpoint": "https://review.example/v1", + "provider": "generic", + "preset": "chat-completions", + "price_ref": "review-price", + }, + } + with TestClient(app) as client: + login(client) + page = client.get("/settings") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + review = client.post( + "/ui/actions/roles/review", + data={"csrf_token": csrf, "roles": json.dumps(route_map)}, + headers={"X-CSRF-Token": csrf}, + ) + assert review.status_code == 200 + assert "models.example/v1" in review.text + assert "password" not in review.text + assert "token=secret" not in review.text + assert "Unused:" in review.text + assert "writer → writer-fallback" in review.text + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + applied = client.post( + "/ui/actions/roles/apply", + data={"csrf_token": csrf, "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert applied.status_code == 303 + stored = cast(dict[str, dict[str, object]], queue.get_setting("role_map")) + assert stored["implementer"]["models"] == ["writer", "writer-fallback"] + assert stored["implementer"]["preset"] == "chat-completions" + assert stored["implementer"]["price_ref"] == "writer-price" + assert any( + event["data"].get("action") == "role_configuration" + and event["data"].get("operator") == "operator" + for event in store.recent(limit=20) + ) + + page = client.get("/settings") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + stale = client.post( + "/ui/actions/roles/review", + data={"csrf_token": csrf, "roles": json.dumps({"reviewer": route_map["reviewer"]})}, + headers={"X-CSRF-Token": csrf}, + ) + stale_id = stale.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + queue.set_setting( + "role_map", {"reviewer": {**route_map["reviewer"], "model": "concurrent"}} + ) + refused = client.post( + "/ui/actions/roles/apply", + data={"csrf_token": csrf, "review_id": stale_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert refused.status_code == 409 + current = cast(dict[str, dict[str, object]], queue.get_setting("role_map")) + assert current["reviewer"]["model"] == "concurrent" + assert any( + event["data"].get("reason_kind") == "role_map_changed" + for event in store.recent(limit=20) + ) diff --git a/tests/test_work.py b/tests/test_work.py index bd5feb3..551d196 100644 --- a/tests/test_work.py +++ b/tests/test_work.py @@ -333,6 +333,20 @@ def test_an_unset_setting_is_none_not_an_error(queue: WorkQueue) -> None: assert queue.get_setting("nope") is None +def test_a_reviewed_setting_replacement_is_atomic(tmp_path: Path) -> None: + queue = make_queue(str(tmp_path / "w.sqlite")) + original = {"reviewer": {"model": "old"}} + queue.set_setting("role_map", original) + + assert queue.compare_and_set_setting("role_map", original, {"reviewer": {"model": "reviewed"}}) + queue.set_setting("role_map", {"reviewer": {"model": "concurrent"}}) + + assert not queue.compare_and_set_setting( + "role_map", {"reviewer": {"model": "reviewed"}}, {"reviewer": {"model": "stale"}} + ) + assert queue.get_setting("role_map") == {"reviewer": {"model": "concurrent"}} + + # ------------------------------- a rewritten brief revives a stalled item From 82f28461097a9820c72889212152b5f42a8c61ab Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 03:13:54 +0000 Subject: [PATCH 06/12] feat: add reviewed audit operations to the GUI --- GUI_PLAN.md | 27 +- docs/evidence/2026-08-05-gui-milestone-0-1.md | 22 +- src/agent_harness/api.py | 20 +- src/agent_harness/audit_service.py | 46 ++++ src/agent_harness/templates/analytics.html | 21 ++ .../templates/audit_action_result.html | 15 + .../templates/audit_action_review.html | 25 ++ src/agent_harness/ui.py | 257 ++++++++++++++++++ tests/test_ui.py | 233 ++++++++++++++++ 9 files changed, 639 insertions(+), 27 deletions(-) create mode 100644 src/agent_harness/audit_service.py create mode 100644 src/agent_harness/templates/audit_action_result.html create mode 100644 src/agent_harness/templates/audit_action_review.html diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 2aa5ec4..ee202f8 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -79,10 +79,10 @@ The complete implementation tree has the following evidence: | Check | Most recent result | |---|---| -| `TMPDIR=/tmp/agent-harness-gui-merged-sync.jXkgCh uv run pytest -q` | Passed at 100%, 1 skipped | +| `TMPDIR=/tmp/agent-harness-gui-4-8-full.VVKT0C uv run pytest -q` | Passed at 100%, 1 skipped | | `uv run ruff check .` | Passed | -| `uv run ruff format --check .` | Passed, 131 files checked | -| `TMPDIR=/tmp/agent-harness-gui-merge-mypy.8cRBKW uv run mypy` | Passed, 126 source files | +| `uv run ruff format --check .` | Passed, 132 files checked | +| `TMPDIR=/tmp/agent-harness-gui-4-8-full-mypy.MdANch uv run mypy` | Passed, 127 source files | The full suite includes the wheel packaging and in-process browser journeys. Browser automation, accessibility tooling, a forced browser SSE reconnect, real GitHub concurrency, @@ -99,14 +99,17 @@ not prove a transaction across remote preview and writes. 3. Milestone 2 still lacks bulk-action review and notification delivery. Milestone 3 still lacks the typed adoption HTTP/wizard flow and richer interactive graph controls. 4. Milestone 4 rate-limit, cost, delivery, audit-health, worker inventory and filtered - event exploration are implemented as read-only typed views. Confirmed GitHub - reconciliation, audit-maintenance controls and process-log metrics remain incomplete. + event exploration are implemented as read-only typed views. GitHub reconciliation and + audit maintenance now have explicit reviewed browser actions. Session-independent + process and gateway-log metrics remain incomplete. ### 0.5 Exact next work -Continue from the reconciled base with Milestone 4.8–4.9: add confirmed GitHub -reconciliation and audit-maintenance reviews/actions, then expose session-independent -process and gateway-log metrics through typed, redacted APIs. The analytics views now keep +Continue from the reconciled base with Milestone 4.9: expose session-independent process +and gateway-log metrics through typed, redacted APIs. Milestone 4.8 is implemented with +one-time review/apply actions, persisted-project repository resolution and drift refusal, +validated retention parameters, required reasons, authenticated operator audit, and result +pages that retain returned errors. The analytics views now keep `rpm`, `window_cap`, `terminal_cap` and `unclassified` separate; show supplied baselines and denominators; keep known spend distinct from unpriced calls; and retain table evidence behind every summary. Do not mark Milestone 4 complete until every 4.1–4.9 acceptance @@ -550,7 +553,13 @@ work; audit-health panels for missing, degraded, or partial history; and daily r baseline comparisons. 4.8. Add confirmed GitHub reconciliation and audit maintenance actions. Show the resolved -repository, retention parameters, dry-run where supported, and returned errors. +repository, retention parameters, dry-run where supported, and returned errors. Implemented: +both operations share application services with the JSON API and use a non-mutating, +one-time browser review. Neither underlying operation supports a dry run, so the review +states that honestly. Reconciliation is project-scoped and refuses repository drift; +maintenance binds the validated retention window. Both require a reason and a healthy +append-only audit store, record success/refusal with authenticated identity, and display +all returned errors. 4.9. Add session-independent process metrics and agent-harness gateway logs through typed, redacted APIs. Never make local filesystem log paths a core convention. diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index 2713e4c..d1de3ec 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -91,6 +91,15 @@ declared optional dependencies with `uv sync --all-extras`: | `uv run ruff format --check .` | passed, 131 files already formatted | | `TMPDIR=/tmp/agent-harness-gui-merge-mypy.8cRBKW uv run mypy` | passed, 126 source files | +After the reviewed reconciliation and audit-maintenance controls: + +| Check | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-4-8-full.VVKT0C uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 132 files already formatted | +| `TMPDIR=/tmp/agent-harness-gui-4-8-full-mypy.MdANch uv run mypy` | passed, 127 source files | + The first full merged pytest attempt is not passing evidence. This older worktree had not yet installed `main`'s newly declared `agent-loop` extra, so 38 tests failed consistently with `ModuleNotFoundError: minisweagent`. `uv sync --all-extras` installed the declared @@ -164,6 +173,15 @@ not evidence: the former raced virtualenv creation and the latter filled the panels show event and distinct-item denominators; baselines and daily rollups remain visible; and missing, degraded or partial audit history is called out rather than inferred away. Focused API/audit/browser journeys cover these caveats. +- Analytics now exposes reviewed GitHub reconciliation and audit-maintenance actions. + Review performs no external request or audit mutation; apply consumes a one-time + server-held payload. Reconciliation resolves the repository from a persisted project, + scopes PR attribution to that project and refuses configuration drift before GitHub. + Maintenance validates and binds the raw-event retention window. Neither underlying + operation supports a dry run, which the review says explicitly. Success and replay/drift + refusals record the authenticated operator and required reason in the healthy append-only + audit store, while result pages retain returned counts and errors. Missing or degraded + audit stores refuse the controls because the required operator record could not be kept. - Typed item evidence exposes append-only events, durable attempt stages and retained holds without fabricating absent history or cost. - `/api/events/stream` resumes after a monotonic cursor and surfaces disconnects; @@ -183,13 +201,13 @@ accessibility/security/concurrency journeys remain to run. Milestone 2 remains p (bulk-action review, notifications and other controls are not yet wired). Milestone 3 remains partial (adoption and richer graph interactions are not yet wired). Milestone 4 is partial: global routing, worker inventory, filtered events and typed analytics are -implemented, while confirmed reconciliation, audit-maintenance controls and process-log +implemented, as are confirmed reconciliation and audit-maintenance controls; process-log metrics are not. Milestones 5–8 (internal sessions, extensions, automation, RBAC and recovery) remain explicitly incomplete. No real fleet, GitHub repository or external deployment was used. ## Current slice verification -The post-integration four-gate table above is the latest complete implementation evidence. +The Milestone 4.8 four-gate table above is the latest complete implementation evidence. Earlier transient or partial runs are historical context only and are not substituted for that complete pass. diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index 85ee567..f6da5ad 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -30,15 +30,15 @@ from . import __version__ from .audit import AuditStore +from .audit_service import maintain_audit, reconcile_repository from .events import RATE_LIMIT_CLASSES, UNCLASSIFIED -from .maintenance import DEFAULT_RETENTION_DAYS, run_maintenance +from .maintenance import DEFAULT_RETENTION_DAYS from .plan_service import PlanSyncConflict, PlanSyncFailure from .plan_service import execute as execute_plan_sync from .plan_service import parse_result as plan_parse_result from .preflight import BaseChecks from .project_service import configure_project, project_spec from .providers import MEANING -from .reconcile import GitHubReconciler, items_by_pr from .routing_service import ROLE_MAP_KEY as ROLE_MAP_KEY from .routing_service import configure_roles, role_map_view from .schemas import ( @@ -1013,16 +1013,7 @@ def audit_reconcile( produces two facts, in order, both true when recorded — not one fact that changes its mind. """ - queue = app.state.queue - mapping = items_by_pr(queue) if queue is not None else {} - report = GitHubReconciler(repo, audit_store()).reconcile(mapping) - return ReconcileResult( - merged=report.merged, - closed_unmerged=report.closed_unmerged, - reverted=report.reverted, - skipped=report.skipped, - errors=report.errors, - ) + return reconcile_repository(app.state.queue, audit_store(), repo) @app.post( "/api/audit/maintenance", @@ -1042,10 +1033,7 @@ def audit_maintenance( so an operator does not have to wait an hour to see whether retention is working, which is exactly when they are most likely to want to know. """ - report = run_maintenance(audit_store(), retention_days=retention_days) - return MaintenanceResult( - rolled_up=report.rolled_up, thinned=report.thinned, errors=report.errors - ) + return maintain_audit(audit_store(), retention_days) @app.get( "/api/audit/rollups", diff --git a/src/agent_harness/audit_service.py b/src/agent_harness/audit_service.py new file mode 100644 index 0000000..1696099 --- /dev/null +++ b/src/agent_harness/audit_service.py @@ -0,0 +1,46 @@ +"""Shared application services for operator-triggered audit operations. + +The public JSON API and the first-party browser both use these functions. The +browser adds an explicit review and operator attribution; neither controller +gets a second interpretation of reconciliation or retention behavior. +""" + +from __future__ import annotations + +from typing import Any + +from .audit import AuditStore +from .maintenance import run_maintenance +from .reconcile import GitHubReconciler, items_by_pr +from .schemas import MaintenanceResult, ReconcileResult + + +def reconcile_repository( + queue: Any, audit: AuditStore, repo: str, *, project_id: str | None = None +) -> ReconcileResult: + """Record merge/revert facts for one resolved repository.""" + mapping = items_by_pr(queue) if queue is not None else {} + if project_id is not None: + mapping = { + number: attribution + for number, attribution in mapping.items() + if attribution.get("project_id") == project_id + } + report = GitHubReconciler(repo, audit).reconcile(mapping) + return ReconcileResult( + merged=report.merged, + closed_unmerged=report.closed_unmerged, + reverted=report.reverted, + skipped=report.skipped, + errors=report.errors, + ) + + +def maintain_audit(audit: AuditStore, retention_days: int) -> MaintenanceResult: + """Close rollups, then thin covered raw rows under one retention policy.""" + report = run_maintenance(audit, retention_days=retention_days) + return MaintenanceResult( + rolled_up=report.rolled_up, + thinned=report.thinned, + errors=report.errors, + ) diff --git a/src/agent_harness/templates/analytics.html b/src/agent_harness/templates/analytics.html index 3194325..47d28cc 100644 --- a/src/agent_harness/templates/analytics.html +++ b/src/agent_harness/templates/analytics.html @@ -58,4 +58,25 @@

Audit history

{% if dashboard.baselines.baselines %}{% for baseline in dashboard.baselines.baselines %}{% endfor %}
BaselineWindowItems doneCost
{{ baseline.label }} ({{ baseline.baseline_id }}){{ baseline.window_days }}d{{ baseline.items_done if baseline.items_done is not none else 'unknown' }}{{ baseline.cost_usd if baseline.cost_usd is not none else 'unknown' }}
{% endif %}
+ +
+

Guarded operations

Reconciliation and maintenance

Explicit review
+

Each operation has a separate non-mutating review, a one-time confirmation, authenticated operator attribution, and a required reason.

+
+
+

GitHub reconciliation

+ + + + +
+
+

Audit maintenance

+ + + + +
+
+
{% endblock %} diff --git a/src/agent_harness/templates/audit_action_result.html b/src/agent_harness/templates/audit_action_result.html new file mode 100644 index 0000000..937336a --- /dev/null +++ b/src/agent_harness/templates/audit_action_result.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block content %} +

Operator action result

{{ title }}

Recorded
+
+ {% if action_kind == "reconcile" %} +

GitHub reconciliation completed for {{ repo }}.

+
Merged
{{ result.merged }}
Closed unmerged
{{ result.closed_unmerged }}
Reverted
{{ result.reverted }}
Skipped
{{ result.skipped }}
Reason
{{ reason }}
+ {% else %} +

Audit maintenance completed.

+
Rollup rows written
{{ result.rolled_up }}
Raw rows thinned
{{ result.thinned }}
Reason
{{ reason }}
+ {% endif %} + {% if result.errors %}{% else %}

The operation returned no errors.

{% endif %} +

Back to analytics

+
+{% endblock %} diff --git a/src/agent_harness/templates/audit_action_review.html b/src/agent_harness/templates/audit_action_review.html new file mode 100644 index 0000000..1ac9644 --- /dev/null +++ b/src/agent_harness/templates/audit_action_review.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +

Explicit confirmation

{{ title }}

No changes applied
+
+ {% if action_kind == "reconcile" %} +

No external request has been made. Confirming contacts GitHub and appends any merge, close, or revert facts returned for the resolved repository.

+
Project
{{ project_id }}
Resolved repository
{{ repo }}
Dry run
Dry run is not supported by reconciliation; this review is the non-mutating boundary.
Reason
{{ reason }}
+
+ + + + +
+ {% else %} +

No audit rows have been rolled up or thinned. Confirming first closes eligible daily rollups, then removes only covered raw rows older than the reviewed retention window.

+
Raw-event retention
{{ retention_days }} days
Order
Roll up, then thin covered rows
Dry run
Dry run is not supported by audit maintenance; this review is the non-mutating boundary.
Reason
{{ reason }}
+
+ + + +
+ {% endif %} +

Cancel

+
+{% endblock %} diff --git a/src/agent_harness/ui.py b/src/agent_harness/ui.py index b9cb1d7..311b888 100644 --- a/src/agent_harness/ui.py +++ b/src/agent_harness/ui.py @@ -23,9 +23,12 @@ from fastapi.templating import Jinja2Templates from pydantic import ValidationError +from .audit import AuditStore +from .audit_service import maintain_audit, reconcile_repository from .browser_session import BrowserSession, BrowserSessions from .events import WORK, Event from .inception import Inception +from .maintenance import DEFAULT_RETENTION_DAYS from .plan_service import PlanSyncConflict, PlanSyncFailure from .plan_service import apply as apply_plan_sync from .plan_service import preview as preview_plan_sync @@ -102,6 +105,17 @@ def render( def require_session(request: Request) -> BrowserSession: return sessions.require(request) + def require_audit(request: Request) -> AuditStore: + audit: AuditStore | None = request.app.state.audit + if audit is None: + raise HTTPException(status_code=409, detail="no audit store is attached") + if audit.degraded: + raise HTTPException( + status_code=409, + detail="audit store is degraded; the operator action cannot be recorded", + ) + return audit + async def form(request: Request) -> dict[str, str]: values = parse_qs((await request.body()).decode("utf-8"), keep_blank_values=True) return {key: entries[0] for key, entries in values.items()} @@ -925,6 +939,249 @@ def analytics_page( "analytics.html", title="Analytics", dashboard=dashboard, + projects=queries(request).projects(), + retention_days=DEFAULT_RETENTION_DAYS, + ) + + @app.post( + "/ui/actions/audit-reconcile/review", + name="audit_reconcile_review", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def audit_reconcile_review(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + reason = body.get("reason", "").strip() + if not project_id: + raise HTTPException(status_code=422, detail="a project is required") + if not reason: + raise HTTPException(status_code=422, detail="a reconciliation reason is required") + require_audit(request) + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + if not project.repo: + raise HTTPException(status_code=409, detail="project has no GitHub repository") + review = sessions.create_review( + session, + kind="audit_reconcile", + target_id=project_id, + baseline_digest=project_spec_digest(project_spec(project)), + baseline_version=float(project.updated_at), + payload={"repo": project.repo, "reason": reason}, + ) + return render( + request, + "audit_action_review.html", + title="Review GitHub reconciliation", + action_kind="reconcile", + review=review, + project_id=project_id, + repo=project.repo, + reason=reason, + retention_days=None, + ) + + @app.post( + "/ui/actions/audit-reconcile/apply", + name="audit_reconcile_apply", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def audit_reconcile_apply(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + try: + review = sessions.consume_review( + session, + body.get("review_id", ""), + kind="audit_reconcile", + target_id=project_id, + ) + except HTTPException: + refusal_audit( + request, + action="audit_reconcile", + reason_kind="invalid_or_expired_review", + data={"project_id": project_id}, + ) + raise + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + project = queue.get_project(project_id) + repo = review.payload.get("repo") + reason = review.payload.get("reason") + if project is None: + refusal_audit( + request, + action="audit_reconcile", + reason_kind="project_missing", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=409, detail="project was removed after review") + if not isinstance(repo, str) or not isinstance(reason, str) or not reason: + refusal_audit( + request, + action="audit_reconcile", + reason_kind="invalid_review", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=409, detail="reconciliation review is invalid") + if ( + project.updated_at != review.baseline_version + or project.repo != repo + or not secrets.compare_digest( + review.baseline_digest, project_spec_digest(project_spec(project)) + ) + ): + refusal_audit( + request, + action="audit_reconcile", + reason_kind="project_configuration_changed", + data={"project_id": project_id, "repo": repo}, + ) + raise HTTPException( + status_code=409, + detail="project configuration changed after reconciliation review", + ) + result = reconcile_repository(queue, require_audit(request), repo, project_id=project_id) + action_audit( + request, + action="audit_reconcile", + outcome="operator_reconciled_github", + data={ + "project_id": project_id, + "repo": repo, + "reason": reason, + "merged": result.merged, + "closed_unmerged": result.closed_unmerged, + "reverted": result.reverted, + "skipped": result.skipped, + "errors": result.errors, + }, + ) + return render( + request, + "audit_action_result.html", + title="GitHub reconciliation result", + action_kind="reconcile", + repo=repo, + reason=reason, + result=result, + ) + + @app.post( + "/ui/actions/audit-maintenance/review", + name="audit_maintenance_review", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def audit_maintenance_review(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + reason = body.get("reason", "").strip() + if not reason: + raise HTTPException(status_code=422, detail="a maintenance reason is required") + require_audit(request) + try: + retention_days = int(body.get("retention_days", "")) + except ValueError as exc: + raise HTTPException( + status_code=422, detail="retention days must be an integer" + ) from exc + if retention_days < 0: + raise HTTPException(status_code=422, detail="retention days must not be negative") + review = sessions.create_review( + session, + kind="audit_maintenance", + target_id="audit", + baseline_digest=str(retention_days), + baseline_version=0, + payload={"retention_days": retention_days, "reason": reason}, + ) + return render( + request, + "audit_action_review.html", + title="Review audit maintenance", + action_kind="maintenance", + review=review, + project_id=None, + repo=None, + reason=reason, + retention_days=retention_days, + ) + + @app.post( + "/ui/actions/audit-maintenance/apply", + name="audit_maintenance_apply", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def audit_maintenance_apply(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + try: + review = sessions.consume_review( + session, + body.get("review_id", ""), + kind="audit_maintenance", + target_id="audit", + ) + except HTTPException: + refusal_audit( + request, + action="audit_maintenance", + reason_kind="invalid_or_expired_review", + data={}, + ) + raise + retention_days = review.payload.get("retention_days") + reason = review.payload.get("reason") + if ( + not isinstance(retention_days, int) + or retention_days < 0 + or not isinstance(reason, str) + or not reason + ): + refusal_audit( + request, + action="audit_maintenance", + reason_kind="invalid_review", + data={}, + ) + raise HTTPException(status_code=409, detail="maintenance review is invalid") + result = maintain_audit(require_audit(request), retention_days) + action_audit( + request, + action="audit_maintenance", + outcome="operator_ran_audit_maintenance", + data={ + "reason": reason, + "retention_days": retention_days, + "rolled_up": result.rolled_up, + "thinned": result.thinned, + "errors": result.errors, + }, + ) + return render( + request, + "audit_action_result.html", + title="Audit maintenance result", + action_kind="maintenance", + repo=None, + reason=reason, + result=result, ) @app.get("/plans", name="plans", response_class=HTMLResponse, include_in_schema=False) diff --git a/tests/test_ui.py b/tests/test_ui.py index 5bfdc62..b2a262c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -14,6 +14,8 @@ from agent_harness.audit import AuditStore from agent_harness.events import WORK, Event from agent_harness.github import GitHub, GitHubError +from agent_harness.maintenance import MaintenanceReport +from agent_harness.reconcile import ReconcileReport from agent_harness.runtime import ExecutorRoles from agent_harness.store import EventStore from agent_harness.work import Project, WorkQueue, WorkRecord @@ -885,3 +887,234 @@ def test_global_role_routing_is_secret_safe_reviewed_complete_and_stale_safe( event["data"].get("reason_kind") == "role_map_changed" for event in store.recent(limit=20) ) + + +def test_github_reconciliation_is_reviewed_resolved_audited_and_one_time( + tmp_path: Path, monkeypatch: Any +) -> None: + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P", repo="owner/repository")) + queue.add( + [WorkRecord(item_id="T1", title="Known PR", brief="")], + project_id="p", + ) + assert queue.record_pr_url("T1", "https://x/pull/7", project_id="p") + queue.add_project(Project(project_id="other", name="Other", repo="elsewhere/repository")) + queue.add( + [WorkRecord(item_id="O1", title="Other PR", brief="")], + project_id="other", + ) + assert queue.record_pr_url("O1", "https://x/pull/8", project_id="other") + calls: list[tuple[str, dict[int, dict[str, str]]]] = [] + + class FakeReconciler: + def __init__(self, repo: str, _audit: AuditStore) -> None: + self.repo = repo + + def reconcile(self, mapping: dict[int, dict[str, str]]) -> ReconcileReport: + calls.append((self.repo, mapping)) + return ReconcileReport(merged=2, skipped=1, errors=["one remote row was invalid"]) + + monkeypatch.setattr("agent_harness.audit_service.GitHubReconciler", FakeReconciler) + with TestClient(create_api(store, queue=queue, audit=audit, token=TOKEN)) as client: + login(client) + page = client.get("/analytics") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + review = client.post( + "/ui/actions/audit-reconcile/review", + data={"csrf_token": csrf, "project_id": "p", "reason": "refresh delivery facts"}, + headers={"X-CSRF-Token": csrf}, + ) + assert review.status_code == 200 + assert "owner/repository" in review.text + assert "No external request has been made" in review.text + assert "Dry run is not supported" in review.text + assert calls == [] + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + + result = client.post( + "/ui/actions/audit-reconcile/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert result.status_code == 200 + assert calls == [("owner/repository", {7: {"project_id": "p", "item_id": "T1"}})] + assert "Merged" in result.text and ">2<" in result.text + assert "one remote row was invalid" in result.text + events = [json.loads(event["data"]) for event in audit.recent(limit=20)] + assert any( + event.get("action") == "audit_reconcile" + and event.get("operator") == "operator" + and event.get("reason") == "refresh delivery facts" + and event.get("repo") == "owner/repository" + for event in events + ) + replay = client.post( + "/ui/actions/audit-reconcile/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert replay.status_code == 409 + assert len(calls) == 1 + replay_events = [json.loads(event["data"]) for event in audit.recent(limit=20)] + assert any( + event.get("action") == "audit_reconcile" + and event.get("reason_kind") == "invalid_or_expired_review" + for event in replay_events + ) + + +def test_github_reconciliation_refuses_project_drift_and_missing_reason( + tmp_path: Path, monkeypatch: Any +) -> None: + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P", repo="owner/first")) + calls: list[str] = [] + + class FakeReconciler: + def __init__(self, repo: str, _audit: AuditStore) -> None: + calls.append(repo) + + def reconcile(self, _mapping: dict[int, dict[str, str]]) -> ReconcileReport: + return ReconcileReport() + + monkeypatch.setattr("agent_harness.audit_service.GitHubReconciler", FakeReconciler) + with TestClient(create_api(store, queue=queue, audit=audit, token=TOKEN)) as client: + login(client) + page = client.get("/analytics") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + missing = client.post( + "/ui/actions/audit-reconcile/review", + data={"csrf_token": csrf, "project_id": "p", "reason": ""}, + headers={"X-CSRF-Token": csrf}, + ) + assert missing.status_code == 422 + review = client.post( + "/ui/actions/audit-reconcile/review", + data={"csrf_token": csrf, "project_id": "p", "reason": "refresh facts"}, + headers={"X-CSRF-Token": csrf}, + ) + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + queue.add_project(Project(project_id="p", name="Project P", repo="owner/changed")) + refused = client.post( + "/ui/actions/audit-reconcile/apply", + data={"csrf_token": csrf, "project_id": "p", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert refused.status_code == 409 + assert calls == [] + events = [json.loads(event["data"]) for event in audit.recent(limit=20)] + assert any( + event.get("action") == "audit_reconcile" + and event.get("reason_kind") == "project_configuration_changed" + for event in events + ) + + +def test_audit_maintenance_is_reviewed_validated_audited_and_reports_errors( + tmp_path: Path, monkeypatch: Any +) -> None: + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + calls: list[int] = [] + + def fake_maintenance(_audit: AuditStore, *, retention_days: int) -> MaintenanceReport: + calls.append(retention_days) + return MaintenanceReport(rolled_up=3, thinned=7, errors=["thin: database busy"]) + + monkeypatch.setattr("agent_harness.audit_service.run_maintenance", fake_maintenance) + with TestClient(create_api(store, queue=queue, audit=audit, token=TOKEN)) as client: + login(client) + page = client.get("/analytics") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + invalid = client.post( + "/ui/actions/audit-maintenance/review", + data={"csrf_token": csrf, "retention_days": "-1", "reason": "close history"}, + headers={"X-CSRF-Token": csrf}, + ) + assert invalid.status_code == 422 + review = client.post( + "/ui/actions/audit-maintenance/review", + data={"csrf_token": csrf, "retention_days": "30", "reason": "close history"}, + headers={"X-CSRF-Token": csrf}, + ) + assert review.status_code == 200 + assert "30 days" in review.text + assert "No audit rows have been rolled up or thinned" in review.text + assert "Dry run is not supported" in review.text + assert calls == [] + review_id = review.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + result = client.post( + "/ui/actions/audit-maintenance/apply", + data={"csrf_token": csrf, "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert result.status_code == 200 + assert calls == [30] + assert "thin: database busy" in result.text + events = [json.loads(event["data"]) for event in audit.recent(limit=20)] + assert any( + event.get("action") == "audit_maintenance" + and event.get("operator") == "operator" + and event.get("reason") == "close history" + and event.get("retention_days") == 30 + for event in events + ) + replay = client.post( + "/ui/actions/audit-maintenance/apply", + data={"csrf_token": csrf, "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert replay.status_code == 409 + assert calls == [30] + replay_events = [json.loads(event["data"]) for event in audit.recent(limit=20)] + assert any( + event.get("action") == "audit_maintenance" + and event.get("reason_kind") == "invalid_or_expired_review" + for event in replay_events + ) + + +def test_audit_actions_refuse_when_no_audit_store_is_attached(tmp_path: Path) -> None: + with make_client(tmp_path) as client: + queue = client.app.state.queue # type: ignore[attr-defined] + queue.add_project(Project(project_id="p", name="Project P", repo="owner/repository")) + login(client) + html = client.get("/analytics").text + csrf = html.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + reconcile = client.post( + "/ui/actions/audit-reconcile/review", + data={"csrf_token": csrf, "project_id": "p", "reason": "refresh facts"}, + headers={"X-CSRF-Token": csrf}, + ) + maintenance = client.post( + "/ui/actions/audit-maintenance/review", + data={"csrf_token": csrf, "retention_days": "90", "reason": "close history"}, + headers={"X-CSRF-Token": csrf}, + ) + assert reconcile.status_code == 409 + assert maintenance.status_code == 409 + + +def test_audit_actions_refuse_when_operator_events_cannot_be_recorded(tmp_path: Path) -> None: + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite", degraded=True) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P", repo="owner/repository")) + with TestClient(create_api(store, queue=queue, audit=audit, token=TOKEN)) as client: + login(client) + html = client.get("/analytics").text + csrf = html.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + response = client.post( + "/ui/actions/audit-reconcile/review", + data={"csrf_token": csrf, "project_id": "p", "reason": "refresh facts"}, + headers={"X-CSRF-Token": csrf}, + ) + assert response.status_code == 409 + assert "cannot be recorded" in response.json()["detail"] From b18e4c9cb0ae78f89aaab3cef5387fce6bdae9e1 Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 03:53:30 +0000 Subject: [PATCH 07/12] feat: expose process and gateway telemetry --- GUI_PLAN.md | 48 +++-- docs/evidence/2026-08-05-gui-milestone-0-1.md | 19 +- src/agent_harness/api.py | 52 +++++ src/agent_harness/process_metrics.py | 72 +++++++ src/agent_harness/query_service.py | 123 ++++++++++- src/agent_harness/routing_service.py | 26 ++- src/agent_harness/schemas.py | 66 ++++++ src/agent_harness/static/app.css | 1 + src/agent_harness/templates/analytics.html | 24 +++ src/agent_harness/ui.py | 3 + tests/test_api.py | 201 ++++++++++++++++++ tests/test_process_metrics.py | 24 +++ tests/test_ui.py | 53 ++++- 13 files changed, 688 insertions(+), 24 deletions(-) create mode 100644 src/agent_harness/process_metrics.py create mode 100644 tests/test_process_metrics.py diff --git a/GUI_PLAN.md b/GUI_PLAN.md index ee202f8..88fea97 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -70,7 +70,11 @@ The implementation now includes: 9. URL-backed event filters for project, item, worker, endpoint, role, model, outcome, error class, reason kind and time, including filtered SSE resume on the same monotonic cursor; and -10. focused in-process journeys for replay, stale project/routing configuration, CSRF, +10. Milestone 4.9 portable service-process sampling and a + narrowed projection of already-redacted `model_call` events from the live append-only + event source. This uses no session-host state, exposes no arbitrary model payload, and + introduces no filesystem log-path convention; and +11. focused in-process journeys for replay, stale project/routing configuration, CSRF, preview-without-write, plan mutation, remote preview drift and refused external writes. ### 0.3 Current verification @@ -79,10 +83,10 @@ The complete implementation tree has the following evidence: | Check | Most recent result | |---|---| -| `TMPDIR=/tmp/agent-harness-gui-4-8-full.VVKT0C uv run pytest -q` | Passed at 100%, 1 skipped | +| `TMPDIR=/tmp/agent-harness-gui-4-9-full.7JtLkQ uv run pytest -q` | Passed at 100%, 1 skipped | | `uv run ruff check .` | Passed | -| `uv run ruff format --check .` | Passed, 132 files checked | -| `TMPDIR=/tmp/agent-harness-gui-4-8-full-mypy.MdANch uv run mypy` | Passed, 127 source files | +| `uv run ruff format --check .` | Passed, 134 files checked | +| `TMPDIR=/tmp/agent-harness-gui-4-9-mypy.Ce6jNh uv run mypy` | Passed, 129 source files | The full suite includes the wheel packaging and in-process browser journeys. Browser automation, accessibility tooling, a forced browser SSE reconnect, real GitHub concurrency, @@ -100,20 +104,26 @@ not prove a transaction across remote preview and writes. lacks the typed adoption HTTP/wizard flow and richer interactive graph controls. 4. Milestone 4 rate-limit, cost, delivery, audit-health, worker inventory and filtered event exploration are implemented as read-only typed views. GitHub reconciliation and - audit maintenance now have explicit reviewed browser actions. Session-independent - process and gateway-log metrics remain incomplete. + audit maintenance have explicit reviewed browser actions. Process metrics and gateway + call history are session-independent; the latter is deliberately structured harness + `model_call` evidence, not a claim that core can discover or parse an arbitrary gateway + daemon's filesystem logs. ### 0.5 Exact next work -Continue from the reconciled base with Milestone 4.9: expose session-independent process -and gateway-log metrics through typed, redacted APIs. Milestone 4.8 is implemented with -one-time review/apply actions, persisted-project repository resolution and drift refusal, -validated retention parameters, required reasons, authenticated operator audit, and result -pages that retain returned errors. The analytics views now keep -`rpm`, `window_cap`, `terminal_cap` and `unclassified` separate; show supplied baselines and -denominators; keep known spend distinct from unpriced calls; and retain table evidence -behind every summary. Do not mark Milestone 4 complete until every 4.1–4.9 acceptance -requirement is evidenced. +Milestone 4.9 is implemented through typed `GET /api/process` and +`GET /api/gateway-logs` contracts plus table evidence on the analytics page. The process +sampler is per-application and stdlib-only. Gateway pages prefer the live audit source, +fall back to the ingest event store, page sparse `model_call` rows without breaking cursor +meaning, allowlist fields, re-redact displayed text, remove URL userinfo/query/fragment, +bound detail, and report degraded history. Focused tests prove both reads avoid session-host +state, omit arbitrary model output, scope projects, and fail closed for malformed endpoints. + +Milestone 4's substantially owned control-plane surface is implemented. Continue with the +remaining earlier acceptance gaps rather than starting Milestone 5: first reconcile the +Milestone 2 bulk-action review/notification requirements and Milestone 3 typed adoption and +interactive-graph requirements against current code, then implement the smallest complete +missing contract and update this state section before and after it. ## 1. Product decision @@ -562,7 +572,13 @@ append-only audit store, record success/refusal with authenticated identity, and all returned errors. 4.9. Add session-independent process metrics and agent-harness gateway logs through typed, -redacted APIs. Never make local filesystem log paths a core convention. +redacted APIs. Never make local filesystem log paths a core convention. Implemented: +`/api/process` samples the serving process without querying session-host state, and +`/api/gateway-logs` provides a cursor-paged, project-filterable, allowlisted projection of +already-redacted harness `model_call` events. The analytics page supplies table evidence +for both. Live audit history is preferred, ingest history is the explicit fallback, +degradation is visible, arbitrary payloads are omitted, detail is bounded, and endpoint +userinfo/query/fragment are removed with malformed values failing closed. **Acceptance:** An operator can explain fleet state, failure classes, route use, reviewer-independence risk, cost caveats, delivery outcomes, worker leases, and audit health diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index d1de3ec..a2f64e0 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -182,6 +182,14 @@ not evidence: the former raced virtualenv creation and the latter filled the refusals record the authenticated operator and required reason in the healthy append-only audit store, while result pages retain returned counts and errors. Missing or degraded audit stores refuse the controls because the required operator record could not be kept. +- Milestone 4.9 is implemented against a fixed generic boundary: service-process + metrics come from a portable in-process sampler, and gateway logs are a narrowed + projection of `model_call` rows from the live append-only event source. Those rows have + already crossed the store redaction boundary. The projection does not expose arbitrary + model payloads, depend on session-host state, or establish a filesystem log-path + convention. It re-redacts allowlisted display fields, strips endpoint userinfo/query/ + fragment, bounds detail, filters by project without breaking sparse cursor paging, and + reports degraded live history instead of silently falling back to stale ingest data. - Typed item evidence exposes append-only events, durable attempt stages and retained holds without fabricating absent history or cost. - `/api/events/stream` resumes after a monotonic cursor and surfaces disconnects; @@ -199,15 +207,16 @@ This is not a release claim for the full `GUI_PLAN`: browser automation, screen-reader checks, forced reconnect with replayed events, and all additional accessibility/security/concurrency journeys remain to run. Milestone 2 remains partial (bulk-action review, notifications and other controls are not yet wired). Milestone 3 -remains partial (adoption and richer graph interactions are not yet wired). Milestone 4 is -partial: global routing, worker inventory, filtered events and typed analytics are -implemented, as are confirmed reconciliation and audit-maintenance controls; process-log -metrics are not. Milestones 5–8 (internal sessions, +remains partial (adoption and richer graph interactions are not yet wired). Milestone 4's +substantially owned control-plane surface is implemented: global routing, worker inventory, +filtered events, typed analytics, confirmed reconciliation and maintenance, portable +process metrics and structured gateway-call evidence. This is not a claim that core reads +an arbitrary gateway daemon's local log files. Milestones 5–8 (internal sessions, extensions, automation, RBAC and recovery) remain explicitly incomplete. No real fleet, GitHub repository or external deployment was used. ## Current slice verification -The Milestone 4.8 four-gate table above is the latest complete implementation evidence. +The Milestone 4.9 four-gate table above is the latest complete implementation evidence. Earlier transient or partial runs are historical context only and are not substituted for that complete pass. diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index f6da5ad..de2bea5 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -37,6 +37,7 @@ from .plan_service import execute as execute_plan_sync from .plan_service import parse_result as plan_parse_result from .preflight import BaseChecks +from .process_metrics import ProcessMetricsSampler, ProcessMetricsSource from .project_service import configure_project, project_spec from .providers import MEANING from .routing_service import ROLE_MAP_KEY as ROLE_MAP_KEY @@ -68,6 +69,7 @@ EventPage, ExecutionReadiness, FleetControl, + GatewayLogPage, Health, HoldList, HoldView, @@ -85,6 +87,7 @@ PlanSyncResult, PreflightCheck, PreflightResult, + ProcessMetrics, ProjectList, ProjectReadiness, ProjectSpec, @@ -182,6 +185,7 @@ def create_api( executor_roles: Any | None = None, default_preset: str = "", github_factory: Any | None = None, + process_metrics: ProcessMetricsSource | None = None, ) -> FastAPI: """Build the API. @@ -231,6 +235,7 @@ def create_api( app.state.executor_roles = executor_roles app.state.default_preset = default_preset app.state.github_factory = github_factory + app.state.process_metrics = process_metrics or ProcessMetricsSampler() app.state.ask_model = _model_asker(model_client) app.state.base_checks = BaseChecks() app.state.token = token @@ -1695,6 +1700,53 @@ def plan_sync( # ------------------------------------------------------- observability + @app.get( + "/api/process", + tags=["observability"], + summary="Session-independent service-process metrics", + response_model=ProcessMetrics, + ) + def process_metrics_api(_: None = Depends(require_token)) -> ProcessMetrics: + """Sample the process serving this API. + + This does not query a session host or infer a process tree. The start + timestamp is the moment this API application began tracking itself. + """ + from .query_service import HarnessQueries + + return HarnessQueries( + store, + app.state.queue, + audit=app.state.audit, + fleet=app.state.fleet, + process_metrics=app.state.process_metrics, + ).process_metrics() + + @app.get( + "/api/gateway-logs", + tags=["observability"], + summary="Redacted model gateway call log", + response_model=GatewayLogPage, + ) + def gateway_logs( + since_id: int = Query(0, ge=0, description="Exclusive source-event cursor."), + limit: int = Query(200, ge=1, le=1000, description="Maximum model calls to return."), + project_id: str | None = Query(None, description="Limit to one recorded project id."), + _: None = Depends(require_token), + ) -> GatewayLogPage: + """Allowlisted `model_call` evidence from the active event source. + + The live audit store is preferred and the ingest event store is the + monitoring-only fallback. Both redact before writing. Model answer + bodies and arbitrary payload fields are deliberately excluded, and + no filesystem log path is part of this contract. + """ + from .query_service import HarnessQueries + + return HarnessQueries(store, app.state.queue, audit=app.state.audit).gateway_logs( + since_id, limit, project_id=project_id + ) + @app.get( "/api/errors", tags=["observability"], diff --git a/src/agent_harness/process_metrics.py b/src/agent_harness/process_metrics.py new file mode 100644 index 0000000..e4ca605 --- /dev/null +++ b/src/agent_harness/process_metrics.py @@ -0,0 +1,72 @@ +"""Portable metrics for the process serving the control plane. + +This is intentionally a sampler, not a process registry. It reports the +current harness process without consulting a session host, walking a process +tree, or assuming a platform-specific metrics filesystem exists. +""" + +from __future__ import annotations + +import os +import threading +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(frozen=True) +class ProcessSample: + """One observation of the process that owns the API application.""" + + sampled_at: float + started_at: float + uptime_seconds: float + pid: int + thread_count: int + cpu_seconds: float + + +class ProcessMetricsSource(Protocol): + """Injected source contract used by API and browser read services.""" + + def sample(self) -> ProcessSample: + """Observe the current service process without mutating it.""" + + +class ProcessMetricsSampler: + """A stdlib-only, per-application process sampler. + + The start time is captured when the application is built, which is the + boundary the API can state honestly. It is not a guess at an ancestor + process's start time. + """ + + def __init__( + self, + *, + wall_time: Callable[[], float] = time.time, + monotonic: Callable[[], float] = time.monotonic, + cpu_time: Callable[[], float] = time.process_time, + pid: Callable[[], int] = os.getpid, + thread_count: Callable[[], int] = threading.active_count, + ) -> None: + self._wall_time = wall_time + self._monotonic = monotonic + self._cpu_time = cpu_time + self._pid = pid + self._thread_count = thread_count + self._started_at = wall_time() + self._started_monotonic = monotonic() + + def sample(self) -> ProcessSample: + """Return a portable snapshot; no session or filesystem access.""" + sampled_at = self._wall_time() + return ProcessSample( + sampled_at=sampled_at, + started_at=self._started_at, + uptime_seconds=max(0.0, self._monotonic() - self._started_monotonic), + pid=self._pid(), + thread_count=self._thread_count(), + cpu_seconds=max(0.0, self._cpu_time()), + ) diff --git a/src/agent_harness/query_service.py b/src/agent_harness/query_service.py index 9249912..ee0413c 100644 --- a/src/agent_harness/query_service.py +++ b/src/agent_harness/query_service.py @@ -13,7 +13,8 @@ from pathlib import Path from typing import Any -from .events import RATE_LIMIT_CLASSES, UNCLASSIFIED +from .events import MODEL_CALL, RATE_LIMIT_CLASSES, UNCLASSIFIED +from .process_metrics import ProcessMetricsSource from .project_service import project_spec from .providers import MEANING from .schemas import ( @@ -35,12 +36,15 @@ EventFilters, EventPage, FleetControl, + GatewayLog, + GatewayLogPage, HoldList, HoldView, ItemReadiness, LatestEvent, OpenQuestion, PlanParseResult, + ProcessMetrics, ProjectList, ProjectSummary, ProposalModel, @@ -64,6 +68,26 @@ } +def _optional_int(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _redacted_text(value: Any, redact: Any) -> str | None: + """Defence-in-depth at projection time; omit text if the filter fails.""" + if value is None: + return None + try: + clean = redact(str(value)) + except Exception: # noqa: BLE001 - returning the raw value would expose it + return None + return None if clean is None else str(clean) + + class HarnessQueries: """Read the control plane without exposing storage layout to controllers.""" @@ -74,11 +98,13 @@ def __init__( *, audit: Any | None = None, fleet: Any | None = None, + process_metrics: ProcessMetricsSource | None = None, ) -> None: self.store = store self.queue = queue self.audit = audit self.fleet = fleet + self.process_metrics_source = process_metrics def projects(self) -> ProjectList: if self.queue is None: @@ -329,6 +355,101 @@ def live_events(self, since_id: int = 0, limit: int = 200) -> EventPage: """ return self.filtered_events(since_id, limit, live=True) + def process_metrics(self) -> ProcessMetrics: + """Observe this service process without consulting session-host state.""" + source = self.process_metrics_source + if source is None: + raise RuntimeError("no process metrics source is attached") + sample = source.sample() + fleet = self.fleet + if fleet is None: + active_workers = 0 + elif hasattr(fleet, "workers"): + active_workers = len(fleet.workers()) + else: + active_workers = sum(fleet.running().values()) + return ProcessMetrics( + sampled_at=sample.sampled_at, + started_at=sample.started_at, + uptime_seconds=sample.uptime_seconds, + pid=sample.pid, + thread_count=sample.thread_count, + cpu_seconds=sample.cpu_seconds, + mode="supervised" if fleet is not None else "monitoring-only", + active_workers=active_workers, + ) + + def gateway_logs( + self, + since_id: int = 0, + limit: int = 200, + *, + project_id: str | None = None, + ) -> GatewayLogPage: + """Project model calls from the live redacted event source. + + Model answer bodies and arbitrary event data are intentionally not in + the schema. The audit store is the live source when attached; a plain + ingest-and-serve deployment falls back to its event store. Neither + route asks a session host where it writes files. + """ + from .routing_service import safe_endpoint + + source = self.audit if self.audit is not None else self.store + source_name = "live_audit" if self.audit is not None else "ingested_events" + degraded = bool(getattr(source, "degraded", False)) + cursor = since_id + matched: list[GatewayLog] = [] + chunk_size = min(1000, max(limit, 200)) + while not degraded and len(matched) < limit: + rows = source.since_id(cursor, limit=chunk_size) + if not rows: + break + for raw in rows: + cursor = int(raw["id"]) + row = self._audit_event_fields(raw) if source is self.audit else raw + if row.get("kind") != MODEL_CALL: + continue + data = row.get("data") or {} + if project_id is not None and str(data.get("project_id") or "") != project_id: + continue + endpoint = row.get("endpoint") + redact = source.redact + detail = _redacted_text(data.get("detail"), redact) + if detail is not None: + detail = detail[:2000] + clean_endpoint = _redacted_text(endpoint, redact) + matched.append( + GatewayLog( + id=cursor, + ts=float(row["ts"]), + project_id=_redacted_text(data.get("project_id"), redact), + item_id=_redacted_text(data.get("item_id"), redact), + worker=_redacted_text(row.get("worker"), redact), + role=_redacted_text(row.get("role"), redact), + model=_redacted_text(row.get("model"), redact), + endpoint=( + safe_endpoint(clean_endpoint) if clean_endpoint is not None else None + ), + outcome=_redacted_text(row.get("outcome"), redact), + error_class=_redacted_text(row.get("error_class"), redact), + latency_s=row.get("latency_s"), + attempt=_optional_int(data.get("attempt")), + detail=detail, + ) + ) + if len(matched) >= limit: + break + if len(rows) < chunk_size: + break + return GatewayLogPage( + configured=not degraded, + degraded=degraded, + source=source_name, + logs=matched, + cursor=cursor, + ) + @staticmethod def _event_matches(event: dict[str, Any], filters: EventFilters) -> bool: data = event.get("data") or {} diff --git a/src/agent_harness/routing_service.py b/src/agent_harness/routing_service.py index 1890ce9..2762c52 100644 --- a/src/agent_harness/routing_service.py +++ b/src/agent_harness/routing_service.py @@ -80,7 +80,31 @@ def role_map_view_for(state: Any, stored: dict[str, Any]) -> RoleMapView: def safe_endpoint(endpoint: str) -> str: """Render route identity without URL credentials, query strings, or fragments.""" - parsed = urlsplit(endpoint) + # Redaction runs before persistence and its visible marker contains square + # brackets. When that marker replaces a password in URL userinfo, + # `urlsplit` can mistake it for a bracketed IP literal and reject the + # otherwise useful endpoint. Userinfo is discarded anyway, so remove it + # from the raw authority before asking the URL parser for the safe fields. + scheme_end = endpoint.find("://") + candidate = endpoint if scheme_end >= 0 else f"//{endpoint}" + authority_start = scheme_end + 3 if scheme_end >= 0 else 2 + authority_end = len(candidate) + for marker in "/?#": + index = candidate.find(marker, authority_start) + if index >= 0: + authority_end = min(authority_end, index) + authority = candidate[authority_start:authority_end] + if "@" in authority: + candidate = ( + candidate[:authority_start] + authority.rsplit("@", 1)[1] + candidate[authority_end:] + ) + try: + parsed = urlsplit(candidate) + except ValueError: + # A malformed stored endpoint is still untrusted display text. Do not + # echo it as the fallback: it may be malformed precisely because it + # contains credential-bearing userinfo the parser cannot separate. + return "redacted-endpoint" hostname = parsed.hostname or "" if ":" in hostname and not hostname.startswith("["): hostname = f"[{hostname}]" diff --git a/src/agent_harness/schemas.py b/src/agent_harness/schemas.py index a2004eb..131257c 100644 --- a/src/agent_harness/schemas.py +++ b/src/agent_harness/schemas.py @@ -1382,6 +1382,72 @@ class EventPage(BaseModel): cursor: int = Field(description="Pass as `since_id` next time. Unchanged when empty.") +class ProcessMetrics(BaseModel): + """A session-independent observation of the serving process.""" + + sampled_at: float = Field(description="Unix time at which this snapshot was sampled.") + started_at: float = Field( + description="Unix time at which this API application began tracking its process." + ) + uptime_seconds: float = Field( + description="Monotonic seconds since this API application began tracking its process." + ) + pid: int = Field(description="Operating-system id of the process serving this API.") + thread_count: int = Field(description="Live Python threads in the serving process.") + cpu_seconds: float = Field(description="CPU seconds consumed by the serving process.") + mode: Literal["supervised", "monitoring-only"] = Field( + description="Whether an in-process worker fleet is attached; no session host is queried." + ) + active_workers: int = Field( + description="Live workers reported by the attached fleet, or zero in monitoring-only mode." + ) + + +class GatewayLog(BaseModel): + """Allowlisted fields from one redacted model gateway call.""" + + id: int = Field(description="Monotonic source-event id used for cursor paging.") + ts: float = Field(description="Unix time at which the gateway outcome was recorded.") + project_id: str | None = Field(None, description="Project attributed to the call, if recorded.") + item_id: str | None = Field(None, description="Work item attributed to the call, if recorded.") + worker: str | None = Field(None, description="Worker identity attributed to the call.") + role: str | None = Field(None, description="Routed model role used by the call.") + model: str | None = Field(None, description="Model identifier recorded for the call.") + endpoint: str | None = Field( + None, + description="Credential-safe endpoint identity with URL userinfo, query and fragment " + "removed.", + ) + outcome: str | None = Field(None, description="Recorded gateway outcome token.") + error_class: str | None = Field(None, description="Classified failure kind, when one occurred.") + latency_s: float | None = Field(None, description="Recorded call latency in seconds.") + attempt: int | None = Field(None, description="Attempt number within the model call ladder.") + detail: str | None = Field( + None, + description="Bounded redacted gateway detail; model answer payloads are never exposed " + "here.", + ) + + +class GatewayLogPage(BaseModel): + """Cursor-paged model gateway evidence from the active event source.""" + + configured: bool = Field(description="Whether the selected event source is readable.") + degraded: bool = Field( + description="Whether the selected live audit source reports degraded persistence." + ) + source: Literal["live_audit", "ingested_events"] = Field( + description="The append-only source projected; never a local filesystem log path." + ) + logs: list[GatewayLog] = Field( + default_factory=list, + description="Allowlisted, already-redacted model-call records in source cursor order.", + ) + cursor: int = Field( + description="Pass as `since_id` next time; advances across scanned non-model events." + ) + + class EventFilters(BaseModel): """Typed, URL-safe filters for the append-only event explorer. diff --git a/src/agent_harness/static/app.css b/src/agent_harness/static/app.css index 1fa9805..afb42d2 100644 --- a/src/agent_harness/static/app.css +++ b/src/agent_harness/static/app.css @@ -89,6 +89,7 @@ dd { margin: .1rem 0 0; font-weight: 700; overflow-wrap: anywhere; } .timeline li { padding: .55rem 0 .55rem .3rem; border-bottom: 1px solid var(--line); } .timeline .muted { display: block; font-size: .8rem; } table { width: 100%; border-collapse: collapse; font-size: .9rem; } +.table-scroll { overflow-x: auto; } th, td { border-bottom: 1px solid var(--line); text-align: left; padding: .6rem .35rem; vertical-align: top; } .login-card { max-width: 430px; margin: 10vh auto; } .breadcrumb { margin-top: 0; } diff --git a/src/agent_harness/templates/analytics.html b/src/agent_harness/templates/analytics.html index 47d28cc..e441634 100644 --- a/src/agent_harness/templates/analytics.html +++ b/src/agent_harness/templates/analytics.html @@ -23,6 +23,30 @@ {% endif %}
+
+

Service process

+

Sampled independently of session-host state. Tracking begins when this API application starts.

+
+
Mode
{{ process.mode }}
+
Uptime
{{ '%.1f'|format(process.uptime_seconds) }}s
+
CPU
{{ '%.3f'|format(process.cpu_seconds) }}s
+
Threads
{{ process.thread_count }}
+
Active workers
{{ process.active_workers }}
+
Process id
{{ process.pid }}
+
+

Typed JSON evidence: GET {{ root_path }}/api/process

+
+ +
+

Gateway calls

+

Allowlisted model_call fields from {{ gateway_logs.source }}. Arbitrary model payloads are excluded.

+ {% if gateway_logs.degraded %}

The live audit source is degraded; gateway history is unavailable.

{% endif %} +
+ {% for row in gateway_logs.logs %}{% else %}{% endfor %} +
TimeRouteOutcomeLatency
{{ row.ts|datetime }}{{ row.role or '—' }} / {{ row.model or '—' }}
{{ row.endpoint or '—' }}
{{ row.outcome or '—' }}{% if row.error_class %} / {{ row.error_class }}{% endif %}{{ row.latency_s if row.latency_s is not none else '—' }}
No gateway-call evidence
+

Typed JSON evidence: GET {{ root_path }}/api/gateway-logs

+
+

Rate limits

Denominator: {{ dashboard.rate_limits.denominator }} observed rate-limit rows. Unclassified remains separate.

diff --git a/src/agent_harness/ui.py b/src/agent_harness/ui.py index 311b888..e5898b8 100644 --- a/src/agent_harness/ui.py +++ b/src/agent_harness/ui.py @@ -80,6 +80,7 @@ def queries(request: Request) -> HarnessQueries: request.app.state.queue, audit=request.app.state.audit, fleet=request.app.state.fleet, + process_metrics=request.app.state.process_metrics, ) def render( @@ -939,6 +940,8 @@ def analytics_page( "analytics.html", title="Analytics", dashboard=dashboard, + process=queries(request).process_metrics(), + gateway_logs=queries(request).gateway_logs(limit=50, project_id=project_id), projects=queries(request).projects(), retention_days=DEFAULT_RETENTION_DAYS, ) diff --git a/tests/test_api.py b/tests/test_api.py index ba1b8ab..f27946b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,8 +12,11 @@ from fastapi.testclient import TestClient from agent_harness.api import create_api +from agent_harness.audit import AuditStore from agent_harness.events import MODEL_CALL, UNCLASSIFIED, WORK, Event from agent_harness.fleet import WorkerSnapshot +from agent_harness.process_metrics import ProcessSample +from agent_harness.redaction import Redactor from agent_harness.store import EventStore from agent_harness.work import CLAIMED, DONE, PENDING, Project, WorkQueue, WorkRecord from conftest import make_queue @@ -430,6 +433,202 @@ def test_event_filters_scan_past_non_matching_rows_and_keep_stream_cursor( assert payload["cursor"] == 2 +def test_process_metrics_are_typed_and_do_not_read_session_host(tmp_path: Path) -> None: + class FakeMetrics: + def sample(self) -> ProcessSample: + return ProcessSample( + sampled_at=120.0, + started_at=100.0, + uptime_seconds=20.0, + pid=42, + thread_count=3, + cpu_seconds=1.25, + ) + + class PoisonSessionHost: + def __getattribute__(self, name: str) -> Any: + raise AssertionError(f"process metrics read session-host state: {name}") + + store = EventStore(tmp_path / "process-events.sqlite") + with TestClient( + create_api( + store, + token=TOKEN, + session_host=PoisonSessionHost(), + process_metrics=FakeMetrics(), + ) + ) as client: + payload = client.get("/api/process", headers=auth()).json() + assert payload == { + "sampled_at": 120.0, + "started_at": 100.0, + "uptime_seconds": 20.0, + "pid": 42, + "thread_count": 3, + "cpu_seconds": 1.25, + "mode": "monitoring-only", + "active_workers": 0, + } + + +def test_gateway_logs_use_live_redacted_model_calls_and_omit_arbitrary_payload( + tmp_path: Path, +) -> None: + secret = "gateway-secret-value" # noqa: S105 - redaction fixture + redact = Redactor([secret]) + store = EventStore(tmp_path / "ingest.sqlite", redact=redact) + audit = AuditStore(tmp_path / "audit.sqlite", redact=redact) + store.append([Event(ts=1.0, kind=MODEL_CALL, source="ingest", model="not-live")]) + audit.append( + [ + Event(ts=2.0, kind=WORK, source="serve", outcome="claimed"), + Event( + ts=3.0, + kind=MODEL_CALL, + source="serve", + worker="worker-1", + role="reviewer", + model="model-a", + endpoint=f"https://user:{secret}@gateway.example/v1?token={secret}", + outcome="error", + error_class="rpm", + latency_s=0.75, + data={ + "project_id": "p", + "item_id": "T1", + "attempt": 2, + "detail": f"Bearer {secret}", + "answer": f"arbitrary model output {secret}", + }, + ), + ] + ) + with TestClient(create_api(store, audit=audit, token=TOKEN)) as client: + response = client.get("/api/gateway-logs", headers=auth()) + assert response.status_code == 200 + payload = response.json() + assert payload["source"] == "live_audit" + assert payload["configured"] is True + assert payload["degraded"] is False + assert len(payload["logs"]) == 1 + row = payload["logs"][0] + assert row == { + "id": 2, + "ts": 3.0, + "project_id": "p", + "item_id": "T1", + "worker": "worker-1", + "role": "reviewer", + "model": "model-a", + "endpoint": "https://gateway.example/v1", + "outcome": "error", + "error_class": "rpm", + "latency_s": 0.75, + "attempt": 2, + "detail": "Bearer [redacted]", + } + rendered = response.text + assert secret not in rendered + assert "arbitrary model output" not in rendered + assert "not-live" not in rendered + + +def test_gateway_log_cursor_pages_model_calls_and_falls_back_to_ingest_store( + client: TestClient, store: EventStore +) -> None: + store.append( + [ + Event(ts=1.0, kind=MODEL_CALL, source="ingest", model="first"), + Event(ts=2.0, kind=WORK, source="ingest", outcome="between"), + Event(ts=3.0, kind=MODEL_CALL, source="ingest", model="second"), + ] + ) + first = client.get("/api/gateway-logs?since_id=0&limit=1", headers=auth()).json() + assert first["source"] == "ingested_events" + assert [row["model"] for row in first["logs"]] == ["first"] + second = client.get( + f"/api/gateway-logs?since_id={first['cursor']}&limit=1", headers=auth() + ).json() + assert [row["model"] for row in second["logs"]] == ["second"] + assert second["cursor"] > first["cursor"] + + +def test_gateway_logs_scope_projects_and_report_degraded_live_history(tmp_path: Path) -> None: + store = EventStore(tmp_path / "ingest.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + audit.append( + [ + Event( + ts=1.0, + kind=MODEL_CALL, + source="serve", + model="project-a", + data={"project_id": "a"}, + ), + Event( + ts=2.0, + kind=MODEL_CALL, + source="serve", + model="project-b", + data={"project_id": "b"}, + ), + ] + ) + with TestClient(create_api(store, audit=audit, token=TOKEN)) as client: + scoped = client.get("/api/gateway-logs?project_id=b", headers=auth()).json() + assert [row["model"] for row in scoped["logs"]] == ["project-b"] + assert scoped["cursor"] == 2 + + degraded = AuditStore(tmp_path / "not-opened.sqlite", degraded=True) + with TestClient(create_api(store, audit=degraded, token=TOKEN)) as client: + unavailable = client.get("/api/gateway-logs?since_id=7", headers=auth()).json() + assert unavailable == { + "configured": False, + "degraded": True, + "source": "live_audit", + "logs": [], + "cursor": 7, + } + + +def test_gateway_logs_never_echo_a_malformed_endpoint( + client: TestClient, store: EventStore +) -> None: + store.append( + [ + Event( + ts=1.0, + kind=MODEL_CALL, + source="ingest", + endpoint="https://secret-user:secret-password@[not-an-ip/v1?token=secret-token", + ) + ] + ) + response = client.get("/api/gateway-logs", headers=auth()) + assert response.status_code == 200 + assert response.json()["logs"][0]["endpoint"] == "redacted-endpoint" + assert "secret" not in response.text + + +def test_gateway_logs_strip_userinfo_from_endpoints_without_a_scheme( + client: TestClient, store: EventStore +) -> None: + store.append( + [ + Event( + ts=1.0, + kind=MODEL_CALL, + source="ingest", + endpoint="secret-user:secret-password@gateway.example/v1?token=secret-token", + ) + ] + ) + response = client.get("/api/gateway-logs", headers=auth()) + assert response.status_code == 200 + assert response.json()["logs"][0]["endpoint"] == "//gateway.example/v1" + assert "secret" not in response.text + + def test_worker_inventory_is_explicitly_monitoring_only_without_fleet( client: TestClient, ) -> None: @@ -520,6 +719,8 @@ def test_the_schema_documents_response_shapes_not_empty_objects( ("/api/summary", "get"), ("/api/errors", "get"), ("/api/events", "get"), + ("/api/gateway-logs", "get"), + ("/api/process", "get"), ("/api/workers", "get"), ("/api/analytics", "get"), ("/healthz", "get"), diff --git a/tests/test_process_metrics.py b/tests/test_process_metrics.py new file mode 100644 index 0000000..bf8ba14 --- /dev/null +++ b/tests/test_process_metrics.py @@ -0,0 +1,24 @@ +"""Portable service-process sampling has no session or filesystem dependency.""" + +from agent_harness.process_metrics import ProcessMetricsSampler + + +def test_sampler_uses_monotonic_uptime_and_injected_process_observers() -> None: + wall = iter([100.0, 121.0]) + monotonic = iter([50.0, 70.5]) + sampler = ProcessMetricsSampler( + wall_time=lambda: next(wall), + monotonic=lambda: next(monotonic), + cpu_time=lambda: 3.25, + pid=lambda: 42, + thread_count=lambda: 7, + ) + + sample = sampler.sample() + + assert sample.started_at == 100.0 + assert sample.sampled_at == 121.0 + assert sample.uptime_seconds == 20.5 + assert sample.cpu_seconds == 3.25 + assert sample.pid == 42 + assert sample.thread_count == 7 diff --git a/tests/test_ui.py b/tests/test_ui.py index b2a262c..a5b04f4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -12,7 +12,7 @@ from agent_harness.api import create_api from agent_harness.audit import AuditStore -from agent_harness.events import WORK, Event +from agent_harness.events import MODEL_CALL, WORK, Event from agent_harness.github import GitHub, GitHubError from agent_harness.maintenance import MaintenanceReport from agent_harness.reconcile import ReconcileReport @@ -116,6 +116,57 @@ def test_root_and_pages_fail_closed_until_login(tmp_path: Path) -> None: assert client.get("/api/work").status_code == 401 +def test_analytics_shows_session_independent_process_and_gateway_evidence( + tmp_path: Path, +) -> None: + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="Project P")) + audit.append( + [ + Event( + ts=3.0, + kind=MODEL_CALL, + source="serve", + role="reviewer", + model="model-a", + endpoint="https://user:password123@gateway.example/v1?token=secret123", + outcome="ok", + latency_s=0.5, + data={"project_id": "p", "detail": "completed"}, + ) + ] + ) + + class PoisonSessionHost: + def __getattribute__(self, name: str) -> Any: + raise AssertionError(f"analytics read session-host state: {name}") + + with TestClient( + create_api( + store, + queue=queue, + audit=audit, + token=TOKEN, + session_host=PoisonSessionHost(), + ) + ) as client: + login(client) + response = client.get("/analytics") + assert response.status_code == 200 + html = response.text + assert "Service process" in html + assert "monitoring-only" in html + assert "Gateway calls" in html + assert "model-a" in html + assert "https://gateway.example/v1" in html + assert "password123" not in html + assert "secret123" not in html + assert "/api/process" in html + assert "/api/gateway-logs" in html + + def test_login_cookie_is_opaque_and_pages_are_read_only(tmp_path: Path) -> None: with make_client(tmp_path) as client: session = login(client) From 33bdcb8b9b54e6867c7f72eb8c433eb3259cc326 Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 04:49:40 +0000 Subject: [PATCH 08/12] feat: add reviewed project adoption workflow --- GUI_PLAN.md | 56 ++- docs/evidence/2026-08-05-gui-milestone-0-1.md | 35 +- src/agent_harness/adoption.py | 50 ++- src/agent_harness/adoption_service.py | 249 ++++++++++++ src/agent_harness/api.py | 151 ++++++++ src/agent_harness/schemas.py | 125 ++++++ .../templates/adoption_decision_review.html | 10 + .../templates/adoption_reconcile_review.html | 10 + src/agent_harness/templates/plans.html | 38 ++ src/agent_harness/ui.py | 365 ++++++++++++++++++ tests/test_adoption.py | 37 +- tests/test_api.py | 211 ++++++++++ tests/test_ui.py | 326 ++++++++++++++++ 13 files changed, 1635 insertions(+), 28 deletions(-) create mode 100644 src/agent_harness/adoption_service.py create mode 100644 src/agent_harness/templates/adoption_decision_review.html create mode 100644 src/agent_harness/templates/adoption_reconcile_review.html diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 88fea97..5f9fb2b 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -1,9 +1,9 @@ # Agent Harness GUI Implementation Plan -**Status:** Accepted product direction; implementation in progress. Milestones 0–1 and -substantial Milestones 2–3 slices are implemented, as are the Milestone 4 routing, -worker-inventory, event-explorer and typed-analytics slices. Milestones 2–4 remain -partial and Milestones 5–8 remain incomplete. +**Status:** Accepted product direction; implementation in progress. Milestones 0–1, +substantial Milestone 2, and the Milestone 3 inception, plan, adoption and dependency +contracts are implemented, as are the substantially owned Milestone 4 control-plane +slices. Milestones 2–3 remain partial and Milestones 5–8 remain incomplete. **Plan date:** 2026-08-05 **Product boundary:** The GUI is built, packaged, served, tested, and documented entirely inside `agent-harness`. @@ -13,8 +13,9 @@ inside `agent-harness`. plane is implemented in `src/agent_harness/ui.py`, `query_service.py`, `browser_session.py`, `templates/`, and `static/`; its in-process journeys are in `tests/test_ui.py` and `tests/test_ui_packaging.py`. The current tree includes the GUI -foundation commits `264294d` through `a245679` plus an uncommitted continuation containing -the subsequent slices described below. Build and gate results are recorded in +foundation commits `264294d` through `a245679`, the merged continuation through `b18e4c9`, +and the current Milestone 3.6 adoption slice described below. Build and gate results are +recorded in [`docs/evidence/2026-08-05-gui-milestone-0-1.md`](docs/evidence/2026-08-05-gui-milestone-0-1.md). This worktree is the implementation source of truth for this plan. The repository's @@ -35,7 +36,7 @@ pytest attempt was invalid because this older worktree had not installed the new ### 0.1 Landed foundation -The branch currently points at `a245679` and contains these GUI commits: +The original GUI foundation consists of these commits: 1. `264294d feat: add self-contained browser control plane` 2. `8b202a9 feat: add guarded browser control actions` @@ -73,8 +74,12 @@ The implementation now includes: 10. Milestone 4.9 portable service-process sampling and a narrowed projection of already-redacted `model_call` events from the live append-only event source. This uses no session-host state, exposes no arbitrary model payload, and - introduces no filesystem log-path convention; and -11. focused in-process journeys for replay, stale project/routing configuration, CSRF, + introduces no filesystem log-path convention; +11. a typed adoption lifecycle and reviewed browser wizard that resolve only persisted + project paths and repository scope, retain parse-loss and ranked evidence, require exact + named drop approval, re-inspect before apply, default JSON reconciliation to dry-run, + and preserve the existing project configuration; and +12. focused in-process journeys for replay, stale project/routing/adoption configuration, CSRF, preview-without-write, plan mutation, remote preview drift and refused external writes. ### 0.3 Current verification @@ -83,10 +88,10 @@ The complete implementation tree has the following evidence: | Check | Most recent result | |---|---| -| `TMPDIR=/tmp/agent-harness-gui-4-9-full.7JtLkQ uv run pytest -q` | Passed at 100%, 1 skipped | +| `TMPDIR=/tmp/agent-harness-gui-adoption-full.V0396Z uv run pytest -q` | Passed at 100%, 1 skipped | | `uv run ruff check .` | Passed | -| `uv run ruff format --check .` | Passed, 134 files checked | -| `TMPDIR=/tmp/agent-harness-gui-4-9-mypy.Ce6jNh uv run mypy` | Passed, 129 source files | +| `uv run ruff format --check .` | Passed, 135 files checked | +| `TMPDIR=/tmp/agent-harness-gui-adoption-mypy.foKNPD uv run mypy` | Passed, 130 source files | The full suite includes the wheel packaging and in-process browser journeys. Browser automation, accessibility tooling, a forced browser SSE reconnect, real GitHub concurrency, @@ -101,7 +106,8 @@ not prove a transaction across remote preview and writes. 2. The role editor is global. Per-project route overrides remain available through project configuration but do not yet have a specialized routing comparison view. 3. Milestone 2 still lacks bulk-action review and notification delivery. Milestone 3 still - lacks the typed adoption HTTP/wizard flow and richer interactive graph controls. + lacks richer interactive graph controls; the typed adoption HTTP/browser lifecycle is + implemented. 4. Milestone 4 rate-limit, cost, delivery, audit-health, worker inventory and filtered event exploration are implemented as read-only typed views. GitHub reconciliation and audit maintenance have explicit reviewed browser actions. Process metrics and gateway @@ -125,6 +131,23 @@ Milestone 2 bulk-action review/notification requirements and Milestone 3 typed a interactive-graph requirements against current code, then implement the smallest complete missing contract and update this state section before and after it. +Milestone 3.6 is now implemented. `POST /api/adoption/{project_id}/inspect`, +`GET /api/adoption/{project_id}`, and the decision/reconcile routes are typed. They resolve +the checkout, plan and optional remote repository only from persisted project +configuration, return the parse-loss report and ranked evidence, omit arbitrary remote +bodies, and redact/safely render candidate fields. Inspection creates no queue rows; +approval names exact drops but still creates no queue rows; JSON reconciliation defaults +to dry-run; real apply re-inspects the same plan/remote scope and refuses digest or drop-list +drift. The browser uses separate one-time decision and first-mutation reviews. Existing +project configuration is preserved during reconciliation. A failed remote write reports +that earlier queue/remote changes may be partial and records that fact instead of claiming +atomicity. + +The next remaining Milestone 3 contract is 3.7: add accessible search and item focus to the +dependency view while retaining its complete list equivalent and the typed graph's exact +edge/readiness semantics. Zoom and pan should enhance the visual representation, never +replace keyboard-readable evidence or become an authorization gesture. + ## 1. Product decision ### 1.1 Owner ruling @@ -265,7 +288,7 @@ services rather than growing a second interpretation in HTML controllers. | Holds | Authenticated inbox and structured answer form exist | Draft preservation on expiry/mismatch and notifications | | Events | SSE over the monotonic cursor and event views exist | Forced reconnect/replay proof, richer filtering and polling fallback evidence | | Audit | Health, events, cost, delivery, rollups, baselines, maintenance, and reconcile APIs exist | Dashboards, confirmations, reason/operator audit for actions, missing breakdowns | -| Plans | Inception, question gates, generated preview, parse-loss report and uncommitted reviewed plan sync exist | Finish plan-sync review items above; adoption HTTP API and wizard | +| Plans | Inception, question gates, generated preview, parse-loss report, reviewed plan sync, and typed/reviewed adoption lifecycle exist | Richer accessible dependency-graph interaction | | Routing | Role map and route-health APIs exist | Editor, used/unused explanation, independence warnings, secret-safe validation | | Workers | Project summaries expose counts and failures | Worker/claim/lease/heartbeat/session inventory API | | Attempts and artifacts | Durable data exists in internal modules | Typed item-scoped API for attempts, stages, patches, diffs, and evidence links | @@ -522,6 +545,11 @@ shows repository and exact create/update/orphan counts. 3.6. Add a typed HTTP adoption proposal API around `adoption.py`, then build the adoption wizard. A proposal is never a decision: nothing is dropped unless the operator names it. +Implemented: inspect/report/decision/reconcile resolve persisted inputs, expose redacted +typed evidence, default reconciliation to dry-run, and bind browser apply to one-time exact +report/drop-list review plus apply-time reinspection. Inspection and approval create no +queue rows. Reconciliation preserves existing project configuration; a failed external +write is reported and audited as potentially partial rather than represented as atomic. 3.7. Build an accessible dependency graph with zoom, pan, search, item focus, and a list equivalent. Distinguish local work, external references, human decisions, cross-project diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index a2f64e0..129093c 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -190,6 +190,17 @@ not evidence: the former raced virtualenv creation and the latter filled the convention. It re-redacts allowlisted display fields, strips endpoint userinfo/query/ fragment, bounds detail, filters by project without breaking sparse cursor paging, and reports degraded live history instead of silently falling back to stale ingest data. +- Milestone 3.6 now has typed inspect, current-report, decision and reconciliation API + contracts plus a reviewed browser wizard. Every path resolves its checkout, plan and + optional remote evidence source from persisted project configuration. Inspection and + approval create no queue rows; real reconciliation is dry-run by default in JSON and is + bound to the exact findings digest and human-named drop list. Apply re-inspects the same + input scope, preserves the existing project row, and refuses drift. The public report + includes parse-loss and ranked evidence but excludes arbitrary remote issue/PR bodies, + re-redacts free text and removes candidate-URL credentials, query strings and fragments. + A regression journey demonstrates the non-transactional failure boundary honestly: if a + confirmed marker write fails after queue insertion, the 502 and audit both say the result + may be partial, and the landed queue row remains observable. - Typed item evidence exposes append-only events, durable attempt stages and retained holds without fabricating absent history or cost. - `/api/events/stream` resumes after a monotonic cursor and surfaces disconnects; @@ -207,7 +218,7 @@ This is not a release claim for the full `GUI_PLAN`: browser automation, screen-reader checks, forced reconnect with replayed events, and all additional accessibility/security/concurrency journeys remain to run. Milestone 2 remains partial (bulk-action review, notifications and other controls are not yet wired). Milestone 3 -remains partial (adoption and richer graph interactions are not yet wired). Milestone 4's +remains partial (adoption is wired; richer graph interactions are not yet wired). Milestone 4's substantially owned control-plane surface is implemented: global routing, worker inventory, filtered events, typed analytics, confirmed reconciliation and maintenance, portable process metrics and structured gateway-call evidence. This is not a claim that core reads @@ -215,8 +226,24 @@ an arbitrary gateway daemon's local log files. Milestones 5–8 (internal sessio extensions, automation, RBAC and recovery) remain explicitly incomplete. No real fleet, GitHub repository or external deployment was used. +The post-Milestone-4 audit found adoption was CLI-only despite a mature engine; that gap is +now implemented and exercised. The engine correction prevents reconciliation from +replacing an existing project with a minimal row. The next Milestone 3 gap is accessible +dependency-graph search/focus and visual zoom/pan while keeping the current complete list +equivalent authoritative for edge and readiness evidence. + ## Current slice verification -The Milestone 4.9 four-gate table above is the latest complete implementation evidence. -Earlier transient or partial runs are historical context only and are not substituted for -that complete pass. +After the typed and reviewed Milestone 3.6 adoption slice: + +| Check | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-adoption-full.V0396Z uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 135 files already formatted | +| `TMPDIR=/tmp/agent-harness-gui-adoption-mypy.foKNPD uv run mypy` | passed, 130 source files | + +The full suite includes the adoption engine/API/browser journeys, generic-core guard, +API isolation, write-boundary redaction and wheel packaging tests. No real remote +repository was contacted: remote reads, drift and partial-write failure use an in-process +stateful transport double, so this is not evidence of atomicity or real GitHub behavior. diff --git a/src/agent_harness/adoption.py b/src/agent_harness/adoption.py index 54370f7..1067e81 100644 --- a/src/agent_harness/adoption.py +++ b/src/agent_harness/adoption.py @@ -243,6 +243,10 @@ class AdoptionReport: created_at: float dry_run: bool items: list[AdoptionItem] + plan_path: str = "" + configured_repo: str | None = None + input_digest: str = "" + inspect_remote: bool = False approved_drops: list[str] = field(default_factory=list) history: list[str] = field(default_factory=lambda: [DRAFT, INSPECTING, PROPOSED]) #: Why a human rejected or asked for a revision. Never inferred. @@ -575,6 +579,11 @@ def inspect( *, dry_run: bool = False, persist: bool = True, + plan_path: str = "", + configured_repo: str | None = None, + input_digest: str = "", + inspect_remote: bool = False, + emit: bool = True, ) -> AdoptionReport: """Build a proposal. Reads everything; changes nothing outside the report. @@ -603,6 +612,10 @@ def inspect( created_at=self.now(), dry_run=dry_run, items=proposed, + plan_path=plan_path, + configured_repo=configured_repo, + input_digest=input_digest, + inspect_remote=inspect_remote, history=[DRAFT, INSPECTING, PROPOSED], ) previous = self.queue.get_setting(self._key(project_id)) @@ -616,7 +629,7 @@ def inspect( report.created_at = earlier.created_at if persist: self._save(report) - for item in report.items: + for item in report.items if emit else []: outcome = ( "adoption_ambiguous" if item.ambiguity @@ -872,7 +885,13 @@ def _run_verification(self, argv: Sequence[str]) -> Evidence: # ------------------------------------------------------------ decision - def approve(self, project_id: str, *, approved_drops: Sequence[str] = ()) -> AdoptionReport: + def approve( + self, + project_id: str, + *, + approved_drops: Sequence[str] = (), + reason: str = "", + ) -> AdoptionReport: """Record the human's exact permission. Absence is never approval.""" report = self.load(project_id) if report.state not in (PROPOSED, APPROVED): @@ -887,6 +906,7 @@ def approve(self, project_id: str, *, approved_drops: Sequence[str] = ()) -> Ado raise ValueError(f"cannot approve unproposed drops: {', '.join(unknown)}") report.state = APPROVED report.approved_drops = sorted(set(approved_drops)) + report.decision_reason = reason.strip() report.history = [*report.history, APPROVED] self._save(report) self._emit( @@ -896,6 +916,7 @@ def approve(self, project_id: str, *, approved_drops: Sequence[str] = ()) -> Ado detail=( f"approved drops: {', '.join(report.approved_drops) or 'none'}; " f"proposed: {', '.join(sorted(proposed)) or 'none'}" + + (f"; reason: {report.decision_reason}" if report.decision_reason else "") ), ) return report @@ -929,14 +950,21 @@ def reconcile(self, project_id: str, *, dry_run: bool = False) -> AdoptionReport report.dry_run = True return report - self.queue.add_project( - Project( - project_id=project_id, - name=project_id, - work_dir=str(self.repository), - created_at=self.now(), + # Adoption can start a project from nothing, but the HTTP/browser + # workflow normally targets a project that is already configured. + # Re-registering a minimal row in that case would erase its checks, + # budgets, routes, repository and plan path at the moment a human + # confirms adoption. Existing configuration is authoritative and is + # therefore left byte-for-byte alone. + if self.queue.get_project(project_id) is None: + self.queue.add_project( + Project( + project_id=project_id, + name=project_id, + work_dir=str(self.repository), + created_at=self.now(), + ) ) - ) approved = set(report.approved_drops) self.queue.add( [ @@ -1182,6 +1210,10 @@ def report_from_dict(raw: Mapping[str, Any]) -> AdoptionReport: created_at=float(raw["created_at"]), dry_run=bool(raw.get("dry_run", False)), items=items, + plan_path=str(raw.get("plan_path") or ""), + configured_repo=(str(raw["configured_repo"]) if raw.get("configured_repo") else None), + input_digest=str(raw.get("input_digest") or ""), + inspect_remote=bool(raw.get("inspect_remote", False)), approved_drops=[str(value) for value in raw.get("approved_drops") or []], history=[str(value) for value in raw.get("history") or []] or [DRAFT, INSPECTING, str(raw["state"])], diff --git a/src/agent_harness/adoption_service.py b/src/agent_harness/adoption_service.py new file mode 100644 index 0000000..17e9d5a --- /dev/null +++ b/src/agent_harness/adoption_service.py @@ -0,0 +1,249 @@ +"""Shared application boundary for adopting an already configured project. + +The adoption engine owns evidence ranking and reconciliation. This module +only resolves persisted project inputs and wires optional remote inspection; +API and browser controllers use the same boundary so neither can invent a +different meaning for approval or a different source of repository paths. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .adoption import ( + Adoption, + AdoptionReport, + ExternalCandidate, + GitHubAdoptionInspector, + InspectionSnapshot, + git_branches, +) +from .events import WORK, Event +from .plan import ParsedPlan, WorkItem, parse_plan_file +from .plan_service import parse_result +from .redaction import Redact, redact_text +from .routing_service import safe_endpoint +from .schemas import AdoptionReportModel, PlanParseResult +from .work import Project, WorkQueue + + +class AdoptionConfigurationError(ValueError): + """The persisted project does not supply a required adoption input.""" + + +class AdoptionInspectionFailure(RuntimeError): + """A configured external evidence source could not be read safely.""" + + +class AdoptionReconciliationFailure(RuntimeError): + """Approved reconciliation failed after it may have started mutating state.""" + + +class _SafeGitHubAdoptionInspector: + """Keep adapter diagnostics out of HTTP while preserving failure phase.""" + + def __init__(self, delegate: GitHubAdoptionInspector) -> None: + self.delegate = delegate + + def inspect(self, items: list[WorkItem]) -> InspectionSnapshot: + try: + return self.delegate.inspect(items) + except Exception as exc: + raise AdoptionInspectionFailure( + "the configured remote repository could not be inspected; no proposal was saved" + ) from exc + + def backfill_marker(self, candidate: ExternalCandidate, item_id: str) -> None: + try: + self.delegate.backfill_marker(candidate, item_id) + except Exception as exc: + raise AdoptionReconciliationFailure( + "an approved external adoption change failed; queue or earlier remote " + "changes may already have landed, so inspect the report and current " + "state before retrying" + ) from exc + + +@dataclass(frozen=True) +class AdoptionContext: + """Resolved engine plus the exact persisted paths it operates on.""" + + project: Project + plan: ParsedPlan + adopter: Adoption + input_digest: str + inspect_remote: bool + + +def resolve_adoption( + queue: WorkQueue, + project_id: str, + *, + github_factory: Callable[[str], Any] | None = None, + inspect_remote: bool = True, + branches: Callable[[Path], list[str]] = git_branches, + on_event: Callable[[dict[str, Any]], None] | None = None, +) -> AdoptionContext: + """Resolve configuration without accepting paths from the HTTP caller.""" + project = queue.get_project(project_id) + if project is None: + raise AdoptionConfigurationError(f"project {project_id!r} is not configured") + missing = [name for name in ("work_dir", "plan_path") if not getattr(project, name)] + if missing: + raise AdoptionConfigurationError( + f"project {project_id!r} needs persisted {', '.join(missing)} before adoption" + ) + assert project.work_dir is not None + assert project.plan_path is not None + external = None + if inspect_remote and project.repo: + if github_factory is None: + from .github import GitHub + + github = GitHub(project.repo) + else: + github = github_factory(project.repo) + external = _SafeGitHubAdoptionInspector(GitHubAdoptionInspector(github)) + try: + plan_bytes = Path(project.plan_path).read_bytes() + plan = parse_plan_file(project.plan_path) + except OSError as exc: + raise AdoptionConfigurationError( + f"configured plan {project.plan_path!r} cannot be read: {exc}" + ) from exc + input_digest = hashlib.sha256( + json.dumps( + { + "project_id": project.project_id, + "work_dir": str(Path(project.work_dir).resolve()), + "plan_path": project.plan_path, + "configured_repo": project.repo, + "plan_sha256": hashlib.sha256(plan_bytes).hexdigest(), + "inspect_remote": inspect_remote, + }, + sort_keys=True, + ).encode() + ).hexdigest() + return AdoptionContext( + project=project, + plan=plan, + adopter=Adoption( + queue, + project.work_dir, + external=external, + branches=branches, + on_event=on_event, + ), + input_digest=input_digest, + inspect_remote=inspect_remote, + ) + + +def inspect_project(context: AdoptionContext) -> AdoptionReport: + """Persist a proposal; create no queue rows and perform no remote write.""" + return _inspect(context, persist=True, emit=True) + + +def fresh_report(context: AdoptionContext) -> AdoptionReport: + """Re-inspect without persisting or emitting, for apply-time drift refusal.""" + try: + return _inspect(context, persist=False, emit=False) + except AdoptionInspectionFailure as exc: + raise AdoptionInspectionFailure( + "the configured remote repository could not be re-inspected; " + "the existing proposal remains and nothing was applied" + ) from exc + + +def _inspect(context: AdoptionContext, *, persist: bool, emit: bool) -> AdoptionReport: + return context.adopter.inspect( + context.project.project_id, + context.plan, + persist=persist, + plan_path=context.project.plan_path or "", + configured_repo=context.project.repo, + input_digest=context.input_digest, + inspect_remote=context.inspect_remote, + emit=emit, + ) + + +def reconcile_project(context: AdoptionContext, *, dry_run: bool = False) -> AdoptionReport: + """Delegate reconciliation and report that a failed apply may be partial.""" + try: + return context.adopter.reconcile(context.project.project_id, dry_run=dry_run) + except ValueError: + raise + except AdoptionReconciliationFailure: + raise + except Exception as exc: + raise AdoptionReconciliationFailure( + "adoption reconciliation failed; queue or remote changes may already have " + "landed, so inspect the report and current state before retrying" + ) from exc + + +def adoption_event_sink(sink: Any, *, source: str) -> Callable[[dict[str, Any]], None]: + """Translate engine events into the append-only store used by this deployment.""" + + def append(raw: dict[str, Any]) -> None: + known = {"ts", "kind", "worker", "role", "model", "endpoint", "outcome"} + sink.append( + [ + Event( + ts=float(raw.get("ts", 0.0)), + kind=WORK, + source=source, + worker=raw.get("worker"), + role=raw.get("role"), + model=raw.get("model"), + endpoint=raw.get("endpoint"), + outcome=raw.get("outcome"), + data={key: value for key, value in raw.items() if key not in known}, + ) + ] + ) + + return append + + +def report_model( + report: AdoptionReport, + *, + redact: Redact, + parsed: ParsedPlan | None = None, +) -> AdoptionReportModel: + """Return the public allowlisted, redacted adoption projection.""" + clean = redact_text(report.to_dict(), redact) + if not isinstance(clean, dict): # pragma: no cover - structure is fixed by dataclass + raise TypeError("adoption report did not serialize to an object") + for item in clean.get("items") or []: + if not isinstance(item, dict): + continue + for candidate in item.get("candidates") or []: + if not isinstance(candidate, dict): + continue + # Bodies are used internally for explicit marker backfill. They + # can contain arbitrary remote prose and are not a public field. + candidate.pop("body", None) + url = candidate.get("url") + if isinstance(url, str) and url: + candidate["url"] = safe_endpoint(url) + parse: PlanParseResult | None = None + if parsed is not None: + clean_parse = redact_text(parse_result(parsed).model_dump(mode="json"), redact) + parse = PlanParseResult.model_validate(clean_parse) + return AdoptionReportModel.model_validate( + { + **clean, + "digest": report.content_digest(), + "proposed_drops": redact_text(report.proposed_drops(), redact), + "unconfirmed_drops": redact_text(report.unconfirmed_drops(), redact), + "parse": parse, + } + ) diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index de2bea5..c907104 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -29,6 +29,17 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from . import __version__ +from .adoption_service import ( + AdoptionConfigurationError, + AdoptionInspectionFailure, + AdoptionReconciliationFailure, + adoption_event_sink, + fresh_report, + inspect_project, + reconcile_project, + report_model, + resolve_adoption, +) from .audit import AuditStore from .audit_service import maintain_audit, reconcile_repository from .events import RATE_LIMIT_CLASSES, UNCLASSIFIED @@ -45,6 +56,10 @@ from .schemas import ( AddItemsRequest, AddItemsResult, + AdoptionDecisionRequest, + AdoptionInspectRequest, + AdoptionReconcileRequest, + AdoptionReportModel, AnalyticsDashboard, AnswerRequest, AnswerResult, @@ -186,6 +201,7 @@ def create_api( default_preset: str = "", github_factory: Any | None = None, process_metrics: ProcessMetricsSource | None = None, + adoption_branches: Any | None = None, ) -> FastAPI: """Build the API. @@ -236,6 +252,7 @@ def create_api( app.state.default_preset = default_preset app.state.github_factory = github_factory app.state.process_metrics = process_metrics or ProcessMetricsSampler() + app.state.adoption_branches = adoption_branches app.state.ask_model = _model_asker(model_client) app.state.base_checks = BaseChecks() app.state.token = token @@ -691,6 +708,140 @@ def dependency_override( readiness=_readiness_model(queue.readiness(item_id, project_id=project_id), record), ) + # ------------------------------------------------------------- adoption + + def adoption_context(project_id: str, *, inspect_remote: bool = True) -> Any: + queue = need_queue() + sink = app.state.audit or store + kwargs: dict[str, Any] = { + "github_factory": app.state.github_factory, + "inspect_remote": inspect_remote, + "on_event": adoption_event_sink(sink, source="api-adoption"), + } + if app.state.adoption_branches is not None: + kwargs["branches"] = app.state.adoption_branches + try: + return resolve_adoption(queue, project_id, **kwargs) + except AdoptionConfigurationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + def current_adoption(project_id: str, *, inspect_remote: bool = False) -> tuple[Any, Any]: + context = adoption_context(project_id, inspect_remote=inspect_remote) + try: + report = context.adopter.load(project_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return context, report + + @app.post( + "/api/adoption/{project_id}/inspect", + tags=["plan"], + summary="Inspect an existing project without adopting it", + response_model=AdoptionReportModel, + ) + def adoption_inspect( + request: AdoptionInspectRequest, + project_id: str = PathParam(description="Persisted project id."), + _: None = Depends(require_token), + ) -> AdoptionReportModel: + """Persist a proposal; create no queue row and make no remote write.""" + context = adoption_context(project_id, inspect_remote=request.inspect_remote) + try: + report = inspect_project(context) + except AdoptionInspectionFailure as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return report_model(report, redact=(app.state.audit or store).redact, parsed=context.plan) + + @app.get( + "/api/adoption/{project_id}", + tags=["plan"], + summary="Current adoption proposal", + response_model=AdoptionReportModel, + responses={404: {"description": "No inspection has been persisted"}}, + ) + def adoption_report( + project_id: str = PathParam(description="Persisted project id."), + _: None = Depends(require_token), + ) -> AdoptionReportModel: + context, report = current_adoption(project_id) + return report_model(report, redact=(app.state.audit or store).redact, parsed=context.plan) + + @app.post( + "/api/adoption/{project_id}/decision", + tags=["plan"], + summary="Approve, reject or revise one adoption proposal", + response_model=AdoptionReportModel, + ) + def adoption_decision( + request: AdoptionDecisionRequest, + project_id: str = PathParam(description="Persisted project id."), + _: None = Depends(require_token), + ) -> AdoptionReportModel: + context, report = current_adoption(project_id) + if not secrets.compare_digest(report.content_digest(), request.expected_digest): + raise HTTPException(status_code=409, detail="adoption findings changed after review") + try: + if request.decision == "approve": + report = context.adopter.approve( + project_id, + approved_drops=request.approved_drops, + reason=request.reason, + ) + else: + if request.approved_drops: + raise ValueError("reject/revise cannot approve item drops") + report = context.adopter.reject( + project_id, + reason=request.reason, + revise=request.decision == "revise", + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return report_model(report, redact=(app.state.audit or store).redact, parsed=context.plan) + + @app.post( + "/api/adoption/{project_id}/reconcile", + tags=["plan"], + summary="Preview or apply an approved adoption", + response_model=AdoptionReportModel, + ) + def adoption_reconcile( + request: AdoptionReconcileRequest, + project_id: str = PathParam(description="Persisted project id."), + _: None = Depends(require_token), + ) -> AdoptionReportModel: + context, report = current_adoption(project_id) + if not request.dry_run: + if not request.expected_digest or not secrets.compare_digest( + report.content_digest(), request.expected_digest + ): + raise HTTPException( + status_code=409, detail="adoption findings changed after review" + ) + if request.expected_approved_drops is None or sorted( + request.expected_approved_drops + ) != sorted(report.approved_drops): + raise HTTPException( + status_code=409, detail="approved drop list changed after review" + ) + context = adoption_context(project_id, inspect_remote=report.inspect_remote) + try: + fresh = fresh_report(context) + except AdoptionInspectionFailure as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + if not secrets.compare_digest(fresh.content_digest(), report.content_digest()): + raise HTTPException( + status_code=409, + detail="adoption inputs or evidence changed after review; inspect again", + ) + try: + report = reconcile_project(context, dry_run=request.dry_run) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except AdoptionReconciliationFailure as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return report_model(report, redact=(app.state.audit or store).redact, parsed=context.plan) + # ------------------------------------------------------------- control @app.get( diff --git a/src/agent_harness/schemas.py b/src/agent_harness/schemas.py index 131257c..a2e7f5f 100644 --- a/src/agent_harness/schemas.py +++ b/src/agent_harness/schemas.py @@ -1091,6 +1091,131 @@ class DependencyOverrideResult(BaseModel): readiness: ItemReadiness = Field(description="The item's readiness after the override.") +# ---------------------------------------------------------------- adoption + + +class AdoptionInspectRequest(BaseModel): + """Read-only evidence sources to include in a persisted adoption proposal.""" + + inspect_remote: bool = Field( + True, + description="Inspect the persisted GitHub repository when configured. Read-only; " + "the caller cannot supply a different repository.", + ) + + +class AdoptionEvidenceModel(BaseModel): + kind: str = Field(description="Evidence rung: explicit, runnable, judged or prior attempt.") + outcome: str = Field(description="What that evidence source reported.") + detail: str = Field(description="Redacted explanation retained whether decisive or not.") + citations: list[str] = Field( + default_factory=list, description="Paths, symbols or commits cited by the evidence." + ) + + +class AdoptionCandidateModel(BaseModel): + kind: str = Field(description="Existing issue, pull request or branch candidate kind.") + identity: str = Field(description="Candidate identity within its kind.") + state: str = Field(description="Observed external or branch state.") + confidence: str = Field(description="Why this is a lead rather than automatically a fact.") + evidence: str = Field(description="Redacted reason the candidate was associated.") + marker_present: bool = Field(description="Whether the candidate already carries the marker.") + harness_created: bool = Field( + description="Whether explicit evidence says the harness created the candidate." + ) + title: str = Field(description="Redacted candidate title, when one was observed.") + branch: str = Field(description="Redacted branch identity, when one was observed.") + url: str = Field(description="Redacted candidate URL, when one was observed.") + repository: str | None = Field(None, description="Repository attributed to the candidate.") + same_repository: bool = Field( + description="Whether a pull-request head belongs to the persisted repository." + ) + + +class AdoptionMutationModel(BaseModel): + kind: str = Field(description="Kind of queue or external change reconciliation proposes.") + target: str = Field(description="Redacted target of the proposed change.") + detail: str = Field(description="Exact redacted effect described before it is applied.") + requires_approval: bool = Field( + description="Whether this mutation needs the item in the human-named drop list." + ) + + +class AdoptionItemModel(BaseModel): + item_id: str = Field(description="Plan item identity.") + title: str = Field(description="Redacted plan title.") + brief: str = Field(description="Redacted plan brief.") + depends_on: list[str] = Field(description="Dependencies retained from the parsed plan.") + queue_state: str | None = Field(None, description="Existing queue state, if already known.") + queue_brief: str | None = Field(None, description="Existing redacted queue brief.") + deliverable: str = Field(description="Whether the plan expects code or findings.") + proposed_state: str = Field(description="State reconciliation proposes for the item.") + evidence: list[AdoptionEvidenceModel] = Field(description="Ranked retained evidence.") + candidates: list[AdoptionCandidateModel] = Field(description="External and branch leads.") + mutations: list[AdoptionMutationModel] = Field(description="Proposed changes for this item.") + ambiguity: str | None = Field(None, description="Why a human must inspect competing facts.") + prior_failure: str | None = Field(None, description="Redacted prior harness failure.") + requires_drop_approval: bool = Field( + description="Whether treating this item as done requires it to be named explicitly." + ) + + +class AdoptionReportModel(BaseModel): + project_id: str = Field(description="Persisted project being adopted.") + state: str = Field(description="Current adoption lifecycle state.") + repository: str = Field(description="Redacted resolved local checkout path.") + created_at: float = Field(description="Unix time at which these findings were created.") + dry_run: bool = Field(description="Whether reconciliation was previewed without mutation.") + items: list[AdoptionItemModel] = Field(description="Every parsed item; none silently dropped.") + plan_path: str = Field(description="Persisted plan path used for this inspection.") + configured_repo: str | None = Field( + None, description="Persisted remote repository inspected, when enabled/configured." + ) + input_digest: str = Field( + description="Digest of persisted paths, remote scope, plan bytes and inspection mode." + ) + inspect_remote: bool = Field( + description="Whether the persisted remote repository contributed evidence." + ) + approved_drops: list[str] = Field(description="Exact item ids the human allowed as done.") + history: list[str] = Field(description="Ordered lifecycle states for this proposal.") + decision_reason: str = Field(description="Redacted human reason for the decision, if any.") + digest: str = Field(description="Stable identity of findings, excluding clock and decision.") + proposed_drops: list[str] = Field(description="Items evidence proposes as already delivered.") + unconfirmed_drops: list[str] = Field( + description="Droppable candidates that evidence does not itself assert are done." + ) + parse: PlanParseResult | None = Field( + None, description="Current loss-reporting parse evidence when available." + ) + + +class AdoptionDecisionRequest(BaseModel): + decision: Literal["approve", "reject", "revise"] = Field( + description="Human decision. Approval still drops only explicitly named item ids." + ) + approved_drops: list[str] = Field( + default_factory=list, description="Exact proposed item ids allowed to land as done." + ) + reason: str = Field(min_length=1, description="Why this decision is being made.") + expected_digest: str = Field( + min_length=1, description="Findings digest this human decision was made against." + ) + + +class AdoptionReconcileRequest(BaseModel): + dry_run: bool = Field( + True, + description="Safe default. False is the first operation that changes queue/remote state.", + ) + expected_digest: str | None = Field( + None, description="Required for apply; findings digest the human reviewed." + ) + expected_approved_drops: list[str] | None = Field( + None, description="Required for apply; exact approved drop list the human reviewed." + ) + + # ------------------------------------------------------------------- errors diff --git a/src/agent_harness/templates/adoption_decision_review.html b/src/agent_harness/templates/adoption_decision_review.html new file mode 100644 index 0000000..5b951f3 --- /dev/null +++ b/src/agent_harness/templates/adoption_decision_review.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block content %} +

Human adoption gate

Review adoption decision

{{ decision }}
+
+

Project: {{ project_id }}
Resolved checkout: {{ report.repository }}
Reason: {{ reason }}

+

No queue row or remote resource has changed. This step records only the human decision; reconciliation is a separate reviewed action.

+ {% for item in report.items %}{% endfor %}
ItemProposalDecision effect
{{ item.item_id }} — {{ item.title }}{{ item.proposed_state }}{% if item.item_id in approved_drops %}Explicitly allowed to enter as done{% elif item.requires_drop_approval %}Not approved as done; remains pending{% else %}No drop proposed{% endif %}
+
+
+{% endblock %} diff --git a/src/agent_harness/templates/adoption_reconcile_review.html b/src/agent_harness/templates/adoption_reconcile_review.html new file mode 100644 index 0000000..480a3cd --- /dev/null +++ b/src/agent_harness/templates/adoption_reconcile_review.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block content %} +

First real mutation

Review adoption reconciliation

One-time confirmation
+
+

Project: {{ project_id }}
Resolved checkout: {{ report.repository }}
Reason: {{ reason }}

+

This confirmation is the first step that creates or refreshes queue rows and may apply explicitly approved remote marker changes. The engine preview below made no mutation.

+ {% for item in report.items %}{% endfor %}
ItemState after reconcileExact proposed changes
{{ item.item_id }} — {{ item.title }}{{ 'done' if item.item_id in report.approved_drops else item.proposed_state }}
    {% for mutation in item.mutations %}
  • {{ mutation.kind }} — {{ mutation.target }}: {{ mutation.detail }}{% if mutation.requires_approval and item.item_id not in report.approved_drops %} (will not apply; not approved){% endif %}
  • {% endfor %}
+
+
+{% endblock %} diff --git a/src/agent_harness/templates/plans.html b/src/agent_harness/templates/plans.html index ef37101..8ad4a5d 100644 --- a/src/agent_harness/templates/plans.html +++ b/src/agent_harness/templates/plans.html @@ -53,6 +53,44 @@

Describe a project

{% if sync_error %}{% endif %}
{% endif %} +
+

Existing work

Adoption

Proposal, never decision
+ {% if adoption_available %} +

Inspect the persisted checkout and plan. Inspection may read the configured remote repository, but creates no queue rows and edits nothing outside the harness.

+
+ + {% if repo %}{% endif %} + +
+ {% else %} +

Configure both a checkout and plan path before adoption can inspect existing work.

+ {% endif %} + {% if adoption %} +

{{ adoption.state }}

Adoption proposal

{{ adoption.items|length }} items
+

Resolved checkout: {{ adoption.repository }}
Plan: {{ adoption.plan_path }}{% if adoption.inspect_remote %}
Remote evidence: {{ adoption.configured_repo }}{% endif %}

+ {% if adoption.parse and (adoption.parse.skipped or adoption.parse.duplicate_ids or adoption.parse.unresolved_dependencies or adoption.parse.malformed_dependencies or adoption.parse.dependency_cycles or adoption.parse.unattached_arrows) %}

The plan has parse findings. They remain visible in the parse review above; adoption did not silently discard them.

{% endif %} +

Nothing has been dropped and no queue row has been created by this proposal.

+
+ {% for item in adoption.items %}{% endfor %} +
ItemProposalEvidence / ambiguityProposed changes
{{ item.item_id }}
{{ item.title }}
{{ item.proposed_state }}{% if item.requires_drop_approval %}
Needs named drop approval{% endif %}
{% if item.ambiguity %}{{ item.ambiguity }}{% endif %}
    {% for evidence in item.evidence %}
  • {{ evidence.kind }} / {{ evidence.outcome }}: {{ evidence.detail }}{% if evidence.citations %} ({{ evidence.citations|join(', ') }}){% endif %}
  • {% else %}
  • No affirmative evidence
  • {% endfor %}
    {% for mutation in item.mutations %}
  • {{ mutation.kind }} — {{ mutation.target }}: {{ mutation.detail }}{% if mutation.requires_approval %} (approval required){% endif %}
  • {% endfor %}
+ {% if adoption.state == 'proposed' %} +
+ + {% if adoption.proposed_drops %}
Items allowed to enter as done{% for item_id in adoption.proposed_drops %}{% endfor %}
{% endif %} + + + +
+ {% elif adoption.state == 'approved' %} +

Approved drops: {{ adoption.approved_drops|join(', ') if adoption.approved_drops else 'none' }}. Approval alone still created no queue rows.

+
+ {% elif adoption.state in ['rejected', 'revise'] %} +

Decision: {{ adoption.decision_reason }}. Inspect again to create a fresh proposal.

+ {% elif adoption.state == 'stopped' %} +

Reconciled. Earlier queue progress and project configuration were preserved.

+ {% endif %} + {% endif %} +
{% if proposal %}

Revision {{ proposal.revision }}

{{ proposal.goal or 'Proposal' }}

{{ proposal.item_count }} items
diff --git a/src/agent_harness/ui.py b/src/agent_harness/ui.py index e5898b8..9df2a33 100644 --- a/src/agent_harness/ui.py +++ b/src/agent_harness/ui.py @@ -23,6 +23,17 @@ from fastapi.templating import Jinja2Templates from pydantic import ValidationError +from .adoption_service import ( + AdoptionConfigurationError, + AdoptionInspectionFailure, + AdoptionReconciliationFailure, + adoption_event_sink, + fresh_report, + inspect_project, + reconcile_project, + report_model, + resolve_adoption, +) from .audit import AuditStore from .audit_service import maintain_audit, reconcile_repository from .browser_session import BrowserSession, BrowserSessions @@ -50,6 +61,7 @@ stored_role_map, ) from .schemas import ( + AdoptionReportModel, BaseCheckStatus, PlanSyncResult, PreflightCheck, @@ -203,6 +215,39 @@ def github_for(request: Request, repo: str) -> Any: return GitHub(repo) + def adoption_context(request: Request, project_id: str, *, inspect_remote: bool) -> Any: + queue = request.app.state.queue + if queue is None: + raise HTTPException(status_code=503, detail="work queue is not configured") + sink = request.app.state.audit or request.app.state.store + kwargs: dict[str, Any] = { + "github_factory": request.app.state.github_factory, + "inspect_remote": inspect_remote, + "on_event": adoption_event_sink(sink, source="browser-adoption"), + } + if request.app.state.adoption_branches is not None: + kwargs["branches"] = request.app.state.adoption_branches + try: + return resolve_adoption(queue, project_id, **kwargs) + except AdoptionConfigurationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + def adoption_report( + request: Request, project_id: str, *, required: bool = False + ) -> AdoptionReportModel | None: + context = adoption_context(request, project_id, inspect_remote=False) + try: + report = context.adopter.load(project_id) + except ValueError as exc: + if required: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return None + return report_model( + report, + redact=(request.app.state.audit or request.app.state.store).redact, + parsed=context.plan, + ) + def plans_context( request: Request, project_id: str | None, @@ -219,6 +264,11 @@ def plans_context( selected_summary = query.project(selected) if selected else None plan_path = selected_summary.project.plan_path if selected_summary else None repo = selected_summary.project.repo if selected_summary else None + adoption_available = bool( + selected_summary + and selected_summary.project.plan_path + and selected_summary.project.work_dir + ) return { "projects": projects, "project_id": selected, @@ -226,9 +276,13 @@ def plans_context( "plan_markdown": query.inception_plan(selected, selected) if selected else None, "plan_path": plan_path, "repo": repo, + "adoption_available": adoption_available, "parse_result": query.plan_parse(plan_path) if plan_path else None, "sync_preview": sync_preview, "sync_error": sync_error, + "adoption": ( + adoption_report(request, selected) if selected and adoption_available else None + ), } def optional(value: str) -> str | None: @@ -1358,6 +1412,317 @@ async def plan_sync_apply(request: Request) -> RedirectResponse: url=str(request.url_for("plans")) + f"?project_id={project_id}", status_code=303 ) + @app.post( + "/ui/actions/adoption/inspect", + name="adoption_inspect_action", + include_in_schema=False, + ) + async def adoption_inspect_action(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + context = adoption_context( + request, project_id, inspect_remote=body.get("inspect_remote") == "yes" + ) + try: + report = inspect_project(context) + except AdoptionInspectionFailure as exc: + refusal_audit( + request, + action="adoption_inspect", + reason_kind="remote_inspection_failed", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=502, detail=str(exc)) from exc + action_audit( + request, + action="adoption_inspect", + outcome="operator_inspected_adoption", + data={ + "project_id": project_id, + "digest": report.content_digest(), + "inspect_remote": context.inspect_remote, + "proposed_drops": report.proposed_drops(), + }, + ) + return project_redirect(request, project_id) + + @app.post( + "/ui/actions/adoption/decision/review", + name="adoption_decision_review", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def adoption_decision_review(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + decision = body.get("decision", "").strip() + reason = body.get("reason", "").strip() + if decision not in {"approve", "reject", "revise"}: + raise HTTPException(status_code=422, detail="choose approve, reject or revise") + if not reason: + raise HTTPException(status_code=422, detail="an adoption decision reason is required") + report = adoption_report(request, project_id, required=True) + assert report is not None + approved_drops = sorted( + key.removeprefix("approve_drop:") + for key, value in body.items() + if key.startswith("approve_drop:") and value == "yes" + ) + if decision != "approve" and approved_drops: + raise HTTPException(status_code=422, detail="reject/revise cannot approve item drops") + unknown = sorted(set(approved_drops) - set(report.proposed_drops)) + if unknown: + raise HTTPException( + status_code=422, + detail=f"cannot approve unproposed drops: {', '.join(unknown)}", + ) + review = sessions.create_review( + session, + kind="adoption_decision", + target_id=project_id, + baseline_digest=report.digest, + baseline_version=0.0, + payload={ + "decision": decision, + "reason": reason, + "approved_drops": approved_drops, + }, + ) + return render( + request, + "adoption_decision_review.html", + title="Review adoption decision", + project_id=project_id, + report=report, + review=review, + decision=decision, + reason=reason, + approved_drops=approved_drops, + ) + + @app.post( + "/ui/actions/adoption/decision/apply", + name="adoption_decision_apply", + include_in_schema=False, + ) + async def adoption_decision_apply(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + try: + review = sessions.consume_review( + session, + body.get("review_id", ""), + kind="adoption_decision", + target_id=project_id, + ) + except HTTPException: + refusal_audit( + request, + action="adoption_decision", + reason_kind="invalid_or_expired_review", + data={"project_id": project_id}, + ) + raise + context = adoption_context(request, project_id, inspect_remote=False) + report = context.adopter.load(project_id) + if not secrets.compare_digest(report.content_digest(), review.baseline_digest): + refusal_audit( + request, + action="adoption_decision", + reason_kind="adoption_findings_changed", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=409, detail="adoption findings changed after review") + decision = review.payload.get("decision") + reason = review.payload.get("reason") + approved_drops = review.payload.get("approved_drops") + if ( + decision not in {"approve", "reject", "revise"} + or not isinstance(reason, str) + or not reason + or not isinstance(approved_drops, list) + or not all(isinstance(value, str) for value in approved_drops) + ): + raise HTTPException(status_code=409, detail="adoption decision review is invalid") + try: + if decision == "approve": + report = context.adopter.approve( + project_id, approved_drops=approved_drops, reason=reason + ) + else: + report = context.adopter.reject( + project_id, reason=reason, revise=decision == "revise" + ) + except ValueError as exc: + refusal_audit( + request, + action="adoption_decision", + reason_kind="adoption_decision_refused", + data={"project_id": project_id, "detail": str(exc)}, + ) + raise HTTPException(status_code=409, detail=str(exc)) from exc + action_audit( + request, + action="adoption_decision", + outcome=f"operator_adoption_{decision}", + data={ + "project_id": project_id, + "reason": reason, + "approved_drops": report.approved_drops, + "digest": report.content_digest(), + }, + ) + return project_redirect(request, project_id) + + @app.post( + "/ui/actions/adoption/reconcile/review", + name="adoption_reconcile_review", + response_class=HTMLResponse, + include_in_schema=False, + ) + async def adoption_reconcile_review(request: Request) -> HTMLResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + reason = body.get("reason", "").strip() + if not reason: + raise HTTPException(status_code=422, detail="a reconciliation reason is required") + context = adoption_context(request, project_id, inspect_remote=False) + report = context.adopter.load(project_id) + if report.state != "approved": + raise HTTPException(status_code=409, detail="adoption proposal is not approved") + public = report_model( + report, + redact=(request.app.state.audit or request.app.state.store).redact, + parsed=context.plan, + ) + preview = reconcile_project(context, dry_run=True) + review = sessions.create_review( + session, + kind="adoption_reconcile", + target_id=project_id, + baseline_digest=report.content_digest(), + baseline_version=0.0, + payload={ + "reason": reason, + "approved_drops": list(report.approved_drops), + "inspect_remote": report.inspect_remote, + "input_digest": report.input_digest, + }, + ) + return render( + request, + "adoption_reconcile_review.html", + title="Review adoption reconciliation", + project_id=project_id, + report=public, + preview=preview, + review=review, + reason=reason, + ) + + @app.post( + "/ui/actions/adoption/reconcile/apply", + name="adoption_reconcile_apply", + include_in_schema=False, + ) + async def adoption_reconcile_apply(request: Request) -> RedirectResponse: + session = require_session(request) + body = await form(request) + sessions.require_csrf(request, session, body.get("csrf_token")) + project_id = body.get("project_id", "").strip() + try: + review = sessions.consume_review( + session, + body.get("review_id", ""), + kind="adoption_reconcile", + target_id=project_id, + ) + except HTTPException: + refusal_audit( + request, + action="adoption_reconcile", + reason_kind="invalid_or_expired_review", + data={"project_id": project_id}, + ) + raise + reason = review.payload.get("reason") + approved_drops = review.payload.get("approved_drops") + inspect_remote = review.payload.get("inspect_remote") + if ( + not isinstance(reason, str) + or not reason + or not isinstance(approved_drops, list) + or not all(isinstance(value, str) for value in approved_drops) + or not isinstance(inspect_remote, bool) + ): + raise HTTPException(status_code=409, detail="adoption reconciliation review is invalid") + context = adoption_context(request, project_id, inspect_remote=inspect_remote) + report = context.adopter.load(project_id) + try: + fresh = fresh_report(context) + except AdoptionInspectionFailure as exc: + refusal_audit( + request, + action="adoption_reconcile", + reason_kind="remote_reinspection_failed", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=502, detail=str(exc)) from exc + if ( + report.state != "approved" + or not secrets.compare_digest(report.content_digest(), review.baseline_digest) + or not secrets.compare_digest(fresh.content_digest(), review.baseline_digest) + or sorted(report.approved_drops) != sorted(approved_drops) + ): + refusal_audit( + request, + action="adoption_reconcile", + reason_kind="adoption_inputs_changed", + data={"project_id": project_id}, + ) + raise HTTPException( + status_code=409, + detail="adoption inputs, evidence or approved drops changed after review", + ) + try: + report = reconcile_project(context) + except ValueError as exc: + refusal_audit( + request, + action="adoption_reconcile", + reason_kind="adoption_reconcile_refused", + data={"project_id": project_id, "detail": str(exc)}, + ) + raise HTTPException(status_code=409, detail=str(exc)) from exc + except AdoptionReconciliationFailure as exc: + refusal_audit( + request, + action="adoption_reconcile", + reason_kind="external_reconcile_failed_may_be_partial", + data={"project_id": project_id}, + ) + raise HTTPException(status_code=502, detail=str(exc)) from exc + action_audit( + request, + action="adoption_reconcile", + outcome="operator_reconciled_adoption", + data={ + "project_id": project_id, + "reason": reason, + "approved_drops": report.approved_drops, + "items": len(report.items), + }, + ) + return project_redirect(request, project_id) + @app.get("/graph", name="graph", response_class=HTMLResponse, include_in_schema=False) def graph_page(request: Request, project_id: str | None = None) -> HTMLResponse: require_session(request) diff --git a/tests/test_adoption.py b/tests/test_adoption.py index 9ff3b4f..4413fca 100644 --- a/tests/test_adoption.py +++ b/tests/test_adoption.py @@ -34,7 +34,7 @@ from agent_harness.audit import AuditStore from agent_harness.github import MARKER, GitHub from agent_harness.plan import parse_plan -from agent_harness.work import DONE, FAILED, PENDING, WorkQueue, WorkRecord +from agent_harness.work import DONE, FAILED, PENDING, Project, WorkQueue, WorkRecord from stage_a_support import event_sink PLAN = """\ @@ -322,6 +322,41 @@ def test_dry_run_reconciliation_performs_no_mutation(queue: WorkQueue, repo: Pat assert gh.mutations() == [] +def test_reconciliation_preserves_an_existing_project_configuration( + queue: WorkQueue, repo: Path +) -> None: + configured = Project( + project_id="existing", + name="Existing project", + repo="owner/repository", + work_dir=str(repo), + base_branch="develop", + checks=["pytest -q"], + fixes={"ruff format --check .": ["ruff", "format", "."]}, + apply_fixes=True, + durability="sync", + max_item_seconds=60, + max_item_spend_usd=2.5, + max_hold_seconds=120, + plan_path=str(repo / "PLAN.md"), + roles={"reviewer": {"model": "reviewer-a"}}, + max_workers=3, + max_attempts=4, + min_free_disk_gb=1.5, + ) + queue.add_project(configured) + before = queue.get_project("existing") + assert before is not None + adopter = adoption(queue, repo, gh=default_gh()) + adopter.inspect("existing", parse_plan(PLAN)) + adopter.approve("existing", approved_drops=[]) + + adopter.reconcile("existing") + + after = queue.get_project("existing") + assert after == before + + # ------------------------------------------------------- §5.2 the ladder diff --git a/tests/test_api.py b/tests/test_api.py index f27946b..c60c8e7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -15,6 +15,7 @@ from agent_harness.audit import AuditStore from agent_harness.events import MODEL_CALL, UNCLASSIFIED, WORK, Event from agent_harness.fleet import WorkerSnapshot +from agent_harness.github import GitHub from agent_harness.process_metrics import ProcessSample from agent_harness.redaction import Redactor from agent_harness.store import EventStore @@ -629,6 +630,205 @@ def test_gateway_logs_strip_userinfo_from_endpoints_without_a_scheme( assert "secret" not in response.text +def test_adoption_http_lifecycle_is_typed_dry_run_first_and_drop_exact( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + repository.mkdir() + plan = repository / "PLAN.md" + plan.write_text( + "# Existing project\n\n" + "- [x] T1: Already delivered\n\nbrief\n\n" + "- [ ] T2: Still needed\n\nanother brief\n" + ) + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="existing", + name="Existing project", + work_dir=str(repository), + plan_path=str(plan), + checks=["pytest -q"], + max_workers=2, + ) + ) + + def branches(_repo: Path) -> list[str]: + return ["harness/T1"] + + with TestClient( + create_api(store, queue=queue, token=TOKEN, adoption_branches=branches) + ) as client: + inspected = client.post( + "/api/adoption/existing/inspect", headers=auth(), json={"inspect_remote": False} + ) + assert inspected.status_code == 200 + proposal = inspected.json() + assert proposal["state"] == "proposed" + assert proposal["proposed_drops"] == ["T1"] + assert proposal["parse"]["items"][0]["id"] == "T1" + assert queue.items(project_id="existing") == [] + + unknown = client.post( + "/api/adoption/existing/decision", + headers=auth(), + json={ + "decision": "approve", + "approved_drops": ["T2"], + "reason": "reviewed", + "expected_digest": proposal["digest"], + }, + ) + assert unknown.status_code == 409 + assert queue.items(project_id="existing") == [] + + approved = client.post( + "/api/adoption/existing/decision", + headers=auth(), + json={ + "decision": "approve", + "approved_drops": ["T1"], + "reason": "reviewed", + "expected_digest": proposal["digest"], + }, + ) + assert approved.status_code == 200 + preview = client.post("/api/adoption/existing/reconcile", headers=auth(), json={}) + assert preview.status_code == 200 + assert preview.json()["dry_run"] is True + assert queue.items(project_id="existing") == [] + + applied = client.post( + "/api/adoption/existing/reconcile", + headers=auth(), + json={ + "dry_run": False, + "expected_digest": proposal["digest"], + "expected_approved_drops": ["T1"], + }, + ) + assert applied.status_code == 200 + assert {row.item_id: row.state for row in queue.items(project_id="existing")} == { + "T1": DONE, + "T2": PENDING, + } + configured = queue.get_project("existing") + assert configured is not None + assert configured.name == "Existing project" + assert configured.checks == ["pytest -q"] + assert configured.max_workers == 2 + + +def test_adoption_inspection_refuses_missing_persisted_paths(client: TestClient) -> None: + response = client.post( + "/api/adoption/default/inspect", headers=auth(), json={"inspect_remote": False} + ) + assert response.status_code == 409 + assert "work_dir" in response.json()["detail"] or "plan_path" in response.json()["detail"] + + +def test_adoption_report_omits_remote_body_and_safely_renders_candidate_url( + tmp_path: Path, +) -> None: + secret = "remote-secret-value" + repository = tmp_path / "repo" + repository.mkdir() + plan = repository / "PLAN.md" + plan.write_text("# Existing\n\n- [ ] T1: First\n\nbrief\n") + store = EventStore(tmp_path / "events.sqlite", redact=Redactor([secret])) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="existing", + name="Existing", + repo="owner/repository", + work_dir=str(repository), + plan_path=str(plan), + ) + ) + + def runner(args: Any, stdin: str | None = None) -> str: + del stdin + if args[1:3] == ["issue", "list"]: + return ( + '[{"number":7,"title":"T1 remote-secret-value",' + '"body":"T1 arbitrary remote prose must stay private remote-secret-value",' + '"state":"OPEN","url":"https://alice:remote-secret-value@github.example/' + 'owner/repository/issues/7?token=remote-secret-value"}]' + ) + if args[1:3] == ["pr", "list"]: + return "[]" + raise AssertionError(args) + + with TestClient( + create_api( + store, + queue=queue, + token=TOKEN, + github_factory=lambda repo: GitHub(repo, runner), + adoption_branches=lambda _repo: [], + ) + ) as client: + response = client.post( + "/api/adoption/existing/inspect", + headers=auth(), + json={"inspect_remote": True}, + ) + assert response.status_code == 200 + candidate = response.json()["items"][0]["candidates"][0] + assert "body" not in candidate + assert candidate["title"] == "T1 [redacted]" + assert candidate["url"] == "https://github.example/owner/repository/issues/7" + assert secret not in response.text + assert "arbitrary remote prose" not in response.text + + +def test_adoption_remote_inspection_failure_is_generic_and_saves_no_proposal( + tmp_path: Path, +) -> None: + repository = tmp_path / "repo" + repository.mkdir() + plan = repository / "PLAN.md" + plan.write_text("# Existing\n\n- [ ] T1: First\n\nbrief\n") + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="existing", + name="Existing", + repo="owner/repository", + work_dir=str(repository), + plan_path=str(plan), + ) + ) + + def runner(args: Any, stdin: str | None = None) -> str: + del args, stdin + raise RuntimeError("transport exposed remote-secret-value") + + with TestClient( + create_api( + store, + queue=queue, + token=TOKEN, + github_factory=lambda repo: GitHub(repo, runner), + adoption_branches=lambda _repo: [], + ) + ) as client: + failed = client.post( + "/api/adoption/existing/inspect", + headers=auth(), + json={"inspect_remote": True}, + ) + missing = client.get("/api/adoption/existing", headers=auth()) + assert failed.status_code == 502 + assert "could not be inspected" in failed.json()["detail"] + assert "remote-secret-value" not in failed.text + assert missing.status_code == 404 + assert queue.items(project_id="existing") == [] + + def test_worker_inventory_is_explicitly_monitoring_only_without_fleet( client: TestClient, ) -> None: @@ -723,6 +923,10 @@ def test_the_schema_documents_response_shapes_not_empty_objects( ("/api/process", "get"), ("/api/workers", "get"), ("/api/analytics", "get"), + ("/api/adoption/{project_id}/inspect", "post"), + ("/api/adoption/{project_id}", "get"), + ("/api/adoption/{project_id}/decision", "post"), + ("/api/adoption/{project_id}/reconcile", "post"), ("/healthz", "get"), ]: content = schema["paths"][path][method]["responses"]["200"]["content"] @@ -855,6 +1059,13 @@ def test_sync_defaults_to_a_dry_run(client: TestClient) -> None: assert prop["default"] is True +def test_adoption_reconciliation_defaults_to_a_dry_run(client: TestClient) -> None: + schema = client.get("/openapi.json").json() + prop = schema["components"]["schemas"]["AdoptionReconcileRequest"]["properties"]["dry_run"] + assert prop["default"] is True + assert "first operation" in prop["description"] + + # --------------------------------------------------------------- control diff --git a/tests/test_ui.py b/tests/test_ui.py index a5b04f4..5ab9391 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -61,6 +61,14 @@ def __call__(self, args: Sequence[str], stdin: str | None = None) -> str: return "https://github.com/o/r/issues/1\n" +class AdoptionGitHubRunner(FakeGitHubRunner): + def __call__(self, args: Sequence[str], stdin: str | None = None) -> str: + if args[1:3] in (["issue", "list"], ["pr", "list"]): + self.calls.append([*args]) + return "[]" + return super().__call__(args, stdin) + + class StatefulGitHubRunner(FakeGitHubRunner): """A remote backlog that can drift or refuse the confirmed write.""" @@ -73,6 +81,9 @@ def __call__(self, args: Sequence[str], stdin: str | None = None) -> str: if args[1:3] == ["issue", "list"]: self.calls.append([*args]) return self.issues + if args[1:3] == ["pr", "list"]: + self.calls.append([*args]) + return "[]" if self.fail_writes and args[1:3] in (["issue", "create"], ["issue", "edit"]): self.calls.append([*args]) raise GitHubError("the remote rejected the write") @@ -167,6 +178,321 @@ def __getattribute__(self, name: str) -> Any: assert "/api/gateway-logs" in html +def test_adoption_wizard_keeps_inspection_and_reviews_non_mutating_then_applies_once( + tmp_path: Path, +) -> None: + repository = tmp_path / "existing-repo" + repository.mkdir() + plan = repository / "PLAN.md" + plan.write_text( + "# Existing project\n\n" + "- [x] T1: Already delivered\n\nbrief\n\n" + "- [ ] T2: Still needed\n\nanother brief\n" + ) + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="existing", + name="Existing project", + repo="owner/repository", + work_dir=str(repository), + plan_path=str(plan), + checks=["pytest -q"], + max_workers=3, + ) + ) + github_calls: list[str] = [] + + def github_factory(repo: str) -> GitHub: + github_calls.append(repo) + return GitHub(repo, AdoptionGitHubRunner()) + + with TestClient( + create_api( + store, + queue=queue, + audit=audit, + token=TOKEN, + github_factory=github_factory, + adoption_branches=lambda _repo: [], + ) + ) as client: + login(client) + page = client.get("/plans?project_id=existing") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + inspected = client.post( + "/ui/actions/adoption/inspect", + data={"csrf_token": csrf, "project_id": "existing", "inspect_remote": "yes"}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert inspected.status_code == 303 + assert queue.items(project_id="existing") == [] + assert github_calls == ["owner/repository"] + + proposal = client.get("/plans?project_id=existing") + assert "Adoption proposal" in proposal.text + assert "Already delivered" in proposal.text + assert "Nothing has been dropped" in proposal.text + decision_review = client.post( + "/ui/actions/adoption/decision/review", + data={ + "csrf_token": csrf, + "project_id": "existing", + "decision": "approve", + "reason": "checked the existing delivery", + "approve_drop:T1": "yes", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert decision_review.status_code == 200 + assert "T1" in decision_review.text + assert "T2" in decision_review.text + assert "No queue row or remote resource has changed" in decision_review.text + decision_review_id = decision_review.text.split('name="review_id" value="', 1)[1].split( + '"', 1 + )[0] + assert queue.items(project_id="existing") == [] + + decided = client.post( + "/ui/actions/adoption/decision/apply", + data={"csrf_token": csrf, "project_id": "existing", "review_id": decision_review_id}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert decided.status_code == 303 + assert queue.items(project_id="existing") == [] + + assert ( + "Approval alone still created no queue rows" + in client.get("/plans?project_id=existing").text + ) + reconcile_review = client.post( + "/ui/actions/adoption/reconcile/review", + data={ + "csrf_token": csrf, + "project_id": "existing", + "reason": "bring pending work into the queue", + }, + headers={"X-CSRF-Token": csrf}, + ) + assert reconcile_review.status_code == 200 + assert "First real mutation" in reconcile_review.text + assert "T1" in reconcile_review.text and "done" in reconcile_review.text + assert "T2" in reconcile_review.text and "pending" in reconcile_review.text + reconcile_review_id = reconcile_review.text.split('name="review_id" value="', 1)[1].split( + '"', 1 + )[0] + assert queue.items(project_id="existing") == [] + + applied = client.post( + "/ui/actions/adoption/reconcile/apply", + data={ + "csrf_token": csrf, + "project_id": "existing", + "review_id": reconcile_review_id, + }, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert applied.status_code == 303 + assert {row.item_id: row.state for row in queue.items(project_id="existing")} == { + "T1": "done", + "T2": "pending", + } + project = queue.get_project("existing") + assert project is not None + assert project.repo == "owner/repository" + assert project.checks == ["pytest -q"] + assert project.max_workers == 3 + + replay = client.post( + "/ui/actions/adoption/reconcile/apply", + data={ + "csrf_token": csrf, + "project_id": "existing", + "review_id": reconcile_review_id, + }, + headers={"X-CSRF-Token": csrf}, + ) + assert replay.status_code == 409 + assert len(queue.items(project_id="existing")) == 2 + events = [json.loads(row["data"]) for row in audit.recent(limit=50)] + assert any( + row.get("action") == "adoption_reconcile" + and row.get("operator") == "operator" + and row.get("reason") == "bring pending work into the queue" + for row in events + ) + assert any( + row.get("action") == "adoption_reconcile" + and row.get("reason_kind") == "invalid_or_expired_review" + for row in events + ) + + +def test_adoption_reconciliation_refuses_plan_drift_before_mutation(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + plan = repository / "PLAN.md" + plan.write_text("# Existing\n\n- [ ] T1: First\n\nbrief\n") + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="existing", + name="Existing", + work_dir=str(repository), + plan_path=str(plan), + ) + ) + with TestClient( + create_api( + store, + queue=queue, + audit=audit, + token=TOKEN, + adoption_branches=lambda _repo: [], + ) + ) as client: + login(client) + page = client.get("/plans?project_id=existing") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + client.post( + "/ui/actions/adoption/inspect", + data={"csrf_token": csrf, "project_id": "existing"}, + headers={"X-CSRF-Token": csrf}, + ) + report = client.get("/api/adoption/existing", headers={"Authorization": f"Bearer {TOKEN}"}) + digest = report.json()["digest"] + client.post( + "/api/adoption/existing/decision", + headers={"Authorization": f"Bearer {TOKEN}"}, + json={ + "decision": "approve", + "approved_drops": [], + "reason": "reviewed", + "expected_digest": digest, + }, + ) + reviewed = client.post( + "/ui/actions/adoption/reconcile/review", + data={"csrf_token": csrf, "project_id": "existing", "reason": "adopt it"}, + headers={"X-CSRF-Token": csrf}, + ) + review_id = reviewed.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + plan.write_text("# Existing\n\n- [ ] T1: Changed\n\na different brief\n") + refused = client.post( + "/ui/actions/adoption/reconcile/apply", + data={"csrf_token": csrf, "project_id": "existing", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert refused.status_code == 409 + assert queue.items(project_id="existing") == [] + events = [json.loads(row["data"]) for row in audit.recent(limit=50)] + assert any( + row.get("action") == "adoption_reconcile" + and row.get("reason_kind") == "adoption_inputs_changed" + for row in events + ) + + +def test_adoption_reconciliation_reports_a_partial_external_failure(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + plan = repository / "PLAN.md" + plan.write_text("# Existing\n\n- [ ] T1: First\n\nbrief\n") + store = EventStore(tmp_path / "events.sqlite") + audit = AuditStore(tmp_path / "audit.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="existing", + name="Existing", + repo="owner/repository", + work_dir=str(repository), + plan_path=str(plan), + ) + ) + runner = StatefulGitHubRunner() + runner.issues = json.dumps( + [ + { + "number": 7, + "title": "First", + "body": "This is T1 and deliberately has no harness marker.", + "state": "CLOSED", + "url": "https://github.example/owner/repository/issues/7", + } + ] + ) + with TestClient( + create_api( + store, + queue=queue, + audit=audit, + token=TOKEN, + github_factory=lambda repo: GitHub(repo, runner), + adoption_branches=lambda _repo: [], + ) + ) as client: + login(client) + page = client.get("/plans?project_id=existing") + csrf = page.text.split('name="csrf_token" value="', 1)[1].split('"', 1)[0] + inspected = client.post( + "/ui/actions/adoption/inspect", + data={"csrf_token": csrf, "project_id": "existing", "inspect_remote": "yes"}, + headers={"X-CSRF-Token": csrf}, + follow_redirects=False, + ) + assert inspected.status_code == 303 + proposal = client.get( + "/api/adoption/existing", headers={"Authorization": f"Bearer {TOKEN}"} + ) + payload = proposal.json() + assert payload["proposed_drops"] == ["T1"] + approved = client.post( + "/api/adoption/existing/decision", + headers={"Authorization": f"Bearer {TOKEN}"}, + json={ + "decision": "approve", + "approved_drops": ["T1"], + "reason": "confirmed closed issue", + "expected_digest": payload["digest"], + }, + ) + assert approved.status_code == 200 + reviewed = client.post( + "/ui/actions/adoption/reconcile/review", + data={"csrf_token": csrf, "project_id": "existing", "reason": "adopt it"}, + headers={"X-CSRF-Token": csrf}, + ) + assert reviewed.status_code == 200 + review_id = reviewed.text.split('name="review_id" value="', 1)[1].split('"', 1)[0] + runner.fail_writes = True + failed = client.post( + "/ui/actions/adoption/reconcile/apply", + data={"csrf_token": csrf, "project_id": "existing", "review_id": review_id}, + headers={"X-CSRF-Token": csrf}, + ) + assert failed.status_code == 502 + assert "may already have landed" in failed.text + assert "remote rejected" not in failed.text + landed = queue.get("T1", project_id="existing") + assert landed is not None and landed.state == "done" + current = client.get("/api/adoption/existing", headers={"Authorization": f"Bearer {TOKEN}"}) + assert current.json()["state"] == "approved" + events = [json.loads(row["data"]) for row in audit.recent(limit=50)] + assert any( + row.get("action") == "adoption_reconcile" + and row.get("reason_kind") == "external_reconcile_failed_may_be_partial" + for row in events + ) + + def test_login_cookie_is_opaque_and_pages_are_read_only(tmp_path: Path) -> None: with make_client(tmp_path) as client: session = login(client) From deed5a13b888566835517db0a3a86b7366fc4af6 Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 05:37:28 +0000 Subject: [PATCH 09/12] feat: add interactive dependency graph explorer --- GUI_PLAN.md | 39 ++- docs/evidence/2026-08-05-gui-milestone-0-1.md | 36 ++- src/agent_harness/static/app.css | 46 ++- src/agent_harness/static/app.js | 286 +++++++++++++++++- src/agent_harness/templates/graph.html | 97 +++++- tests/test_ui.py | 100 ++++++ tests/test_ui_packaging.py | 4 + 7 files changed, 575 insertions(+), 33 deletions(-) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index 5f9fb2b..b8a6c6b 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -88,10 +88,10 @@ The complete implementation tree has the following evidence: | Check | Most recent result | |---|---| -| `TMPDIR=/tmp/agent-harness-gui-adoption-full.V0396Z uv run pytest -q` | Passed at 100%, 1 skipped | +| `TMPDIR=/tmp/agent-harness-gui-graph-full.7qCVp2 uv run pytest -q` | Passed at 100%, 1 skipped | | `uv run ruff check .` | Passed | | `uv run ruff format --check .` | Passed, 135 files checked | -| `TMPDIR=/tmp/agent-harness-gui-adoption-mypy.foKNPD uv run mypy` | Passed, 130 source files | +| `TMPDIR=/tmp/agent-harness-gui-graph-mypy.j3dXUJ uv run mypy` | Passed, 130 source files | The full suite includes the wheel packaging and in-process browser journeys. Browser automation, accessibility tooling, a forced browser SSE reconnect, real GitHub concurrency, @@ -125,11 +125,11 @@ meaning, allowlist fields, re-redact displayed text, remove URL userinfo/query/f bound detail, and report degraded history. Focused tests prove both reads avoid session-host state, omit arbitrary model output, scope projects, and fail closed for malformed endpoints. -Milestone 4's substantially owned control-plane surface is implemented. Continue with the -remaining earlier acceptance gaps rather than starting Milestone 5: first reconcile the -Milestone 2 bulk-action review/notification requirements and Milestone 3 typed adoption and -interactive-graph requirements against current code, then implement the smallest complete -missing contract and update this state section before and after it. +Milestone 4's substantially owned control-plane surface is implemented. The subsequent +audit found and closed the Milestone 3 typed-adoption and interactive-graph gaps described +below. Continue with the remaining Milestone 2 bulk-action review/notification requirements +rather than starting Milestone 5; implement the smallest complete missing contract and +update this state section before and after it. Milestone 3.6 is now implemented. `POST /api/adoption/{project_id}/inspect`, `GET /api/adoption/{project_id}`, and the decision/reconcile routes are typed. They resolve @@ -143,10 +143,21 @@ project configuration is preserved during reconciliation. A failed remote write that earlier queue/remote changes may be partial and records that fact instead of claiming atomicity. -The next remaining Milestone 3 contract is 3.7: add accessible search and item focus to the -dependency view while retaining its complete list equivalent and the typed graph's exact -edge/readiness semantics. Zoom and pan should enhance the visual representation, never -replace keyboard-readable evidence or become an authorization gesture. +Milestone 3.7 is now implemented as progressive enhancement over the existing typed report, +not a new graph model. Server-rendered readiness cards, cycle warnings, override controls +and the complete edge table remain authoritative and usable without script. Packaged, +repository-owned JavaScript lays those same escaped rows out as an SVG; search covers items, +targets, kinds, states, resolvers and evidence; keyboard/pointer focus highlights exact +nodes and adjacent table rows; and explicit keyboard-accessible controls zoom, pan and +reset. Styling retains target kind, resolution state, advisory status and cycle membership. +The explorer initializes hidden and appears only after successful enhancement. It makes no +request, derives no readiness answer, persists nothing, and cannot authorize an override. + +The next implementation slice returns to the remaining Milestone 2 gaps: audit current +work controls against the bulk-action review requirement, choose the smallest safe batch +whose members share one existing queue transition, and bind its preview/apply to exact +project, item identities, current states and operator reason. Notification delivery remains +a separate subsystem rather than an incidental side effect of that control. ## 1. Product decision @@ -288,7 +299,7 @@ services rather than growing a second interpretation in HTML controllers. | Holds | Authenticated inbox and structured answer form exist | Draft preservation on expiry/mismatch and notifications | | Events | SSE over the monotonic cursor and event views exist | Forced reconnect/replay proof, richer filtering and polling fallback evidence | | Audit | Health, events, cost, delivery, rollups, baselines, maintenance, and reconcile APIs exist | Dashboards, confirmations, reason/operator audit for actions, missing breakdowns | -| Plans | Inception, question gates, generated preview, parse-loss report, reviewed plan sync, and typed/reviewed adoption lifecycle exist | Richer accessible dependency-graph interaction | +| Plans | Inception, question gates, generated preview, parse-loss report, reviewed plan sync, and typed/reviewed adoption lifecycle exist | Additional end-to-end browser/accessibility journeys | | Routing | Role map and route-health APIs exist | Editor, used/unused explanation, independence warnings, secret-safe validation | | Workers | Project summaries expose counts and failures | Worker/claim/lease/heartbeat/session inventory API | | Attempts and artifacts | Durable data exists in internal modules | Typed item-scoped API for attempts, stages, patches, diffs, and evidence links | @@ -554,6 +565,10 @@ write is reported and audited as potentially partial rather than represented as 3.7. Build an accessible dependency graph with zoom, pan, search, item focus, and a list equivalent. Distinguish local work, external references, human decisions, cross-project dependencies, advisory edges, satisfied edges, blocked edges, unresolved edges, and cycles. +Implemented: the server-rendered complete list remains the authoritative no-script +equivalent. A packaged read-only SVG enhancement uses the same typed rows, exposes semantic +legends and live focus/search summaries, supports keyboard and button zoom/pan/reset, and +styles every target kind, edge state, advisory edge and cycle member distinctly. 3.8. Show resolver status/evidence and the exact item-readiness explanation. Overrides are revision-scoped and require authenticated identity and reason. diff --git a/docs/evidence/2026-08-05-gui-milestone-0-1.md b/docs/evidence/2026-08-05-gui-milestone-0-1.md index 129093c..28c7b5d 100644 --- a/docs/evidence/2026-08-05-gui-milestone-0-1.md +++ b/docs/evidence/2026-08-05-gui-milestone-0-1.md @@ -218,7 +218,8 @@ This is not a release claim for the full `GUI_PLAN`: browser automation, screen-reader checks, forced reconnect with replayed events, and all additional accessibility/security/concurrency journeys remain to run. Milestone 2 remains partial (bulk-action review, notifications and other controls are not yet wired). Milestone 3 -remains partial (adoption is wired; richer graph interactions are not yet wired). Milestone 4's +is implemented in-process, including adoption and graph interaction, but its real-browser +accessibility journeys remain unexercised. Milestone 4's substantially owned control-plane surface is implemented: global routing, worker inventory, filtered events, typed analytics, confirmed reconciliation and maintenance, portable process metrics and structured gateway-call evidence. This is not a claim that core reads @@ -228,9 +229,10 @@ GitHub repository or external deployment was used. The post-Milestone-4 audit found adoption was CLI-only despite a mature engine; that gap is now implemented and exercised. The engine correction prevents reconciliation from -replacing an existing project with a minimal row. The next Milestone 3 gap is accessible -dependency-graph search/focus and visual zoom/pan while keeping the current complete list -equivalent authoritative for edge and readiness evidence. +replacing an existing project with a minimal row. The graph interaction gap is also +implemented as a read-only progressive enhancement while keeping the complete list +equivalent authoritative for edge and readiness evidence. The next implementation gap is +Milestone 2's reviewed bulk-action contract. ## Current slice verification @@ -247,3 +249,29 @@ The full suite includes the adoption engine/API/browser journeys, generic-core g API isolation, write-boundary redaction and wheel packaging tests. No real remote repository was contacted: remote reads, drift and partial-write failure use an in-process stateful transport double, so this is not evidence of atomicity or real GitHub behavior. + +Milestone 3.7 now preserves the complete server-rendered readiness/cycle/edge evidence as +the no-script and assistive-technology equivalent, then progressively adds an SVG laid out +from those same escaped rows. Search and item focus cover typed identities, target kinds, +states, resolver/evidence text, advisory status and cycles; buttons and canvas keys provide +zoom, pan and reset. The enhancement is initially hidden, makes no HTTP request, and appears +only after initialization. Focused fixtures exercise every target kind, all three edge +states, advisory edges, cycles, hostile data-attribute text, CSP-compatible markup, package +contents and the unchanged revision-scoped override path. `node --check` passes for the +packaged script. No executable Chromium is installed—the available launcher is an +uninstalled snap stub—so actual keyboard, screen-reader and visual browser behavior remains +unexercised and is not claimed. + +After the Milestone 3.7 graph interaction slice: + +| Check | Result | +|---|---| +| `TMPDIR=/tmp/agent-harness-gui-graph-full.7qCVp2 uv run pytest -q` | passed at 100%, 1 skipped | +| `uv run ruff check .` | passed | +| `uv run ruff format --check .` | passed, 135 files already formatted | +| `TMPDIR=/tmp/agent-harness-gui-graph-mypy.j3dXUJ uv run mypy` | passed, 130 source files | +| `node --check src/agent_harness/static/app.js` | passed | + +The pytest result is the complete repository suite, including the fresh-wheel packaging +test. The Node result is syntax validation only and does not replace the explicitly unmet +real-browser journey. diff --git a/src/agent_harness/static/app.css b/src/agent_harness/static/app.css index afb42d2..a435fd1 100644 --- a/src/agent_harness/static/app.css +++ b/src/agent_harness/static/app.css @@ -91,7 +91,51 @@ dd { margin: .1rem 0 0; font-weight: 700; overflow-wrap: anywhere; } table { width: 100%; border-collapse: collapse; font-size: .9rem; } .table-scroll { overflow-x: auto; } th, td { border-bottom: 1px solid var(--line); text-align: left; padding: .6rem .35rem; vertical-align: top; } +.graph-toolbar { display: grid; grid-template-columns: minmax(240px, 1fr) auto; gap: 1rem; align-items: end; } +.graph-view-controls { display: flex; flex-wrap: wrap; gap: .4rem; } +.graph-view-controls button { min-width: 2.75rem; padding: .55rem .7rem; } +.graph-legend { display: flex; flex-wrap: wrap; gap: .55rem 1rem; margin: .8rem 0; color: var(--muted); font-size: .82rem; } +.graph-legend span { display: inline-flex; gap: .4rem; align-items: center; } +.graph-swatch { width: .9rem; height: .9rem; border: 2px solid var(--line); border-radius: 4px; background: var(--surface-alt); } +.graph-swatch.kind-local_work { border-color: var(--accent); } +.graph-swatch.kind-external_reference { border-color: var(--warning); } +.graph-swatch.kind-human_decision { border-color: var(--danger); } +.graph-swatch.kind-cross_project_work { border-color: var(--success); } +.graph-swatch.node-cycle { border-color: var(--danger); border-style: double; border-width: 4px; } +.graph-line { width: 1.6rem; height: 0; border-top: 3px solid var(--line); } +.graph-line.state-satisfied { border-color: var(--success); } +.graph-line.state-blocked { border-color: var(--danger); } +.graph-line.state-unresolved { border-color: var(--warning); border-top-style: dotted; } +.graph-line.edge-advisory { border-color: var(--muted); border-top-style: dashed; } +.dependency-diagram { min-height: 360px; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-alt); } +.dependency-svg { display: block; width: 100%; min-width: 640px; height: clamp(360px, 52vw, 620px); } +.graph-fallback { padding: 2rem; } +.graph-edge { fill: none; stroke-width: 3; opacity: .86; } +.graph-edge.state-satisfied { stroke: var(--success); } +.graph-edge.state-blocked { stroke: var(--danger); } +.graph-edge.state-unresolved { stroke: var(--warning); stroke-dasharray: 3 5; } +.graph-edge.edge-advisory { stroke-dasharray: 10 7; stroke-width: 2; } +.graph-arrow { fill: var(--muted); } +.graph-node { color: var(--text); cursor: pointer; } +.graph-node-shape { fill: var(--surface); stroke: var(--line); stroke-width: 2; } +.graph-node.kind-local_work .graph-node-shape { stroke: var(--accent); } +.graph-node.kind-external_reference .graph-node-shape { stroke: var(--warning); } +.graph-node.kind-human_decision .graph-node-shape { stroke: var(--danger); } +.graph-node.kind-cross_project_work .graph-node-shape { stroke: var(--success); } +.graph-node.node-ready .graph-node-shape { fill: color-mix(in srgb, var(--success) 9%, var(--surface)); } +.graph-node.node-not-ready .graph-node-shape { fill: color-mix(in srgb, var(--danger) 7%, var(--surface)); } +.graph-node.node-cycle .graph-node-shape { stroke: var(--danger); stroke-width: 5; stroke-dasharray: 4 3; } +.graph-node:focus { outline: none; } +.graph-node:focus .graph-node-shape, .graph-node.graph-focused .graph-node-shape { stroke: var(--focus); stroke-width: 5; } +.graph-edge.graph-focused { stroke-width: 6; opacity: 1; } +.graph-node-id { fill: currentcolor; font-size: 14px; font-weight: 750; } +.graph-node-kind { fill: var(--muted); font-size: 10px; } +.graph-focus { margin-top: .8rem; padding: .75rem 1rem; border-left: 3px solid var(--accent); background: var(--surface-alt); } +.graph-focus h3 { margin-top: 0; } +.graph-search-muted { opacity: .2; } +tr.graph-focused { outline: 3px solid var(--focus); outline-offset: -3px; } +article.graph-focused, li.graph-focused { outline: 3px solid var(--focus); outline-offset: 3px; } .login-card { max-width: 430px; margin: 10vh auto; } .breadcrumb { margin-top: 0; } -@media (max-width: 720px) { .topbar { position: static; } nav { order: 3; flex-basis: 100%; gap: .6rem; } .operator { margin-left: 0; } .detail-grid { grid-template-columns: 1fr; } .filterbar { align-items: stretch; flex-direction: column; } .filterbar button { width: 100%; } .page-heading { flex-direction: column; } } +@media (max-width: 720px) { .topbar { position: static; } nav { order: 3; flex-basis: 100%; gap: .6rem; } .operator { margin-left: 0; } .detail-grid { grid-template-columns: 1fr; } .filterbar { align-items: stretch; flex-direction: column; } .filterbar button { width: 100%; } .page-heading { flex-direction: column; } .graph-toolbar { grid-template-columns: 1fr; } .graph-view-controls button { flex: 1; } .dependency-diagram { overflow-x: auto; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } } diff --git a/src/agent_harness/static/app.js b/src/agent_harness/static/app.js index b069ab4..91667b0 100644 --- a/src/agent_harness/static/app.js +++ b/src/agent_harness/static/app.js @@ -1,6 +1,289 @@ /* First-party browser behavior. Server-rendered HTML remains usable without it. */ (() => { - const status = (text) => { const node = document.querySelector('#connection-status'); if (node) node.textContent = text; }; + 'use strict'; + + const status = (text) => { + const node = document.querySelector('#connection-status'); + if (node) node.textContent = text; + }; + + const svgElement = (name, attributes = {}) => { + const node = document.createElementNS('http://www.w3.org/2000/svg', name); + Object.entries(attributes).forEach(([key, value]) => node.setAttribute(key, String(value))); + return node; + }; + + const graphKey = (kind, identity) => `${kind}:${identity}`; + + const initializeDependencyGraph = (root) => { + const canvas = root.querySelector('[data-graph-canvas]'); + const search = root.querySelector('[data-graph-search]'); + const searchStatus = root.querySelector('[data-graph-search-status]'); + const focusSummary = root.querySelector('[data-graph-focus-summary]'); + if (!canvas || !search || !searchStatus || !focusSummary) return; + + const edgeRows = Array.from(document.querySelectorAll('[data-graph-edge]')); + const itemRows = Array.from(document.querySelectorAll('[data-graph-item]')); + const cycleRows = Array.from(document.querySelectorAll('[data-graph-cycle]')); + const readiness = new Map(); + itemRows.forEach((row) => { + readiness.set(row.dataset.graphItem, { + ready: row.dataset.graphReady === 'true', + explanation: row.dataset.graphExplanation || '', + }); + }); + const cycleMembers = new Set(); + cycleRows.forEach((row) => { + try { + JSON.parse(row.dataset.graphCycle || '[]').forEach((identity) => cycleMembers.add(identity)); + } catch (_) { + // The server renders the cycle text even if enhancement cannot parse it. + } + }); + + const edges = edgeRows.map((row) => ({ + row, + source: row.dataset.source || '', + target: row.dataset.target || '', + kind: row.dataset.kind || 'local_work', + state: row.dataset.state || 'unresolved', + required: row.dataset.required === 'true', + resolver: row.dataset.resolver || '', + evidence: row.dataset.evidence || '', + })); + const nodes = new Map(); + const addNode = (kind, identity) => { + const key = graphKey(kind, identity); + if (!nodes.has(key)) nodes.set(key, {key, kind, identity, incoming: [], outgoing: []}); + return nodes.get(key); + }; + readiness.forEach((_, identity) => addNode('local_work', identity)); + edges.forEach((edge) => { + const source = addNode('local_work', edge.source); + const target = addNode(edge.kind, edge.target); + source.outgoing.push(edge); + target.incoming.push(edge); + edge.sourceKey = source.key; + edge.targetKey = target.key; + }); + + const columns = ['local_work', 'human_decision', 'external_reference', 'cross_project_work']; + const grouped = new Map(columns.map((kind) => [kind, []])); + nodes.forEach((node) => { + if (!grouped.has(node.kind)) grouped.set(node.kind, []); + grouped.get(node.kind).push(node); + }); + grouped.forEach((values) => values.sort((left, right) => left.identity.localeCompare(right.identity))); + const occupied = columns.filter((kind) => (grouped.get(kind) || []).length > 0); + const marginX = 105; + const columnGap = 285; + const rowGap = 105; + const width = Math.max(680, marginX * 2 + Math.max(1, occupied.length - 1) * columnGap + 180); + const height = Math.max( + 360, + 130 + Math.max(1, ...occupied.map((kind) => (grouped.get(kind) || []).length)) * rowGap, + ); + occupied.forEach((kind, columnIndex) => { + const values = grouped.get(kind) || []; + const x = occupied.length === 1 ? width / 2 : marginX + columnIndex * ((width - marginX * 2) / (occupied.length - 1)); + values.forEach((node, rowIndex) => { + node.x = x; + node.y = 85 + rowIndex * rowGap; + }); + }); + + const svg = svgElement('svg', { + class: 'dependency-svg', + viewBox: `0 0 ${width} ${height}`, + role: 'group', + 'aria-label': `Dependency graph revision ${root.dataset.graphRevision || ''}`, + }); + const definitions = svgElement('defs'); + const marker = svgElement('marker', { + id: 'dependency-arrow', + viewBox: '0 0 10 10', + refX: '9', + refY: '5', + markerWidth: '7', + markerHeight: '7', + orient: 'auto-start-reverse', + }); + marker.append(svgElement('path', {d: 'M 0 0 L 10 5 L 0 10 z', class: 'graph-arrow'})); + definitions.append(marker); + svg.append(definitions); + + const edgeLayer = svgElement('g', {class: 'graph-edge-layer'}); + const nodeLayer = svgElement('g', {class: 'graph-node-layer'}); + edges.forEach((edge) => { + const source = nodes.get(edge.sourceKey); + const target = nodes.get(edge.targetKey); + if (!source || !target) return; + let pathData; + if (source.key === target.key) { + pathData = `M ${source.x + 70} ${source.y} C ${source.x + 145} ${source.y - 70}, ${source.x + 145} ${source.y + 70}, ${source.x + 70} ${source.y + 8}`; + } else if (source.x === target.x) { + const bend = source.x + 115; + pathData = `M ${source.x} ${source.y + 23} C ${bend} ${source.y + 23}, ${bend} ${target.y - 23}, ${target.x} ${target.y - 23}`; + } else { + const direction = target.x > source.x ? 1 : -1; + const startX = source.x + direction * 76; + const endX = target.x - direction * 76; + const middle = (startX + endX) / 2; + pathData = `M ${startX} ${source.y} C ${middle} ${source.y}, ${middle} ${target.y}, ${endX} ${target.y}`; + } + const path = svgElement('path', { + d: pathData, + class: `graph-edge state-${edge.state} ${edge.required ? 'edge-required' : 'edge-advisory'}`, + 'marker-end': 'url(#dependency-arrow)', + }); + const title = svgElement('title'); + title.textContent = `${edge.source} waits on ${edge.target}: ${edge.state}; ${edge.required ? 'required' : 'advisory'}. ${edge.evidence}`; + path.append(title); + edgeLayer.append(path); + edge.element = path; + }); + svg.append(edgeLayer); + + const nodeElements = new Map(); + const focusNode = (node) => { + nodeElements.forEach((element) => element.classList.toggle('graph-focused', element.dataset.nodeKey === node.key)); + itemRows.forEach((row) => row.classList.toggle('graph-focused', node.kind === 'local_work' && row.dataset.graphItem === node.identity)); + edges.forEach((edge) => { + const adjacent = edge.sourceKey === node.key || edge.targetKey === node.key; + edge.row.classList.toggle('graph-focused', adjacent); + edge.element.classList.toggle('graph-focused', adjacent); + }); + const state = readiness.get(node.identity); + const kind = node.kind.replaceAll('_', ' '); + const cycle = node.kind === 'local_work' && cycleMembers.has(node.identity); + const readinessText = state ? state.explanation : 'This target has no local readiness row.'; + focusSummary.textContent = `${node.identity} — ${kind}. ${readinessText} ${node.outgoing.length} outgoing and ${node.incoming.length} incoming edge(s).${cycle ? ' This item belongs to a required cycle.' : ''}`; + }; + nodes.forEach((node) => { + const state = readiness.get(node.identity); + const classes = ['graph-node', `kind-${node.kind}`]; + if (state) classes.push(state.ready ? 'node-ready' : 'node-not-ready'); + if (node.kind === 'local_work' && cycleMembers.has(node.identity)) classes.push('node-cycle'); + const group = svgElement('g', { + class: classes.join(' '), + transform: `translate(${node.x} ${node.y})`, + tabindex: '0', + role: 'button', + 'data-node-key': node.key, + 'aria-label': `${node.identity}, ${node.kind.replaceAll('_', ' ')}${state ? `, ${state.explanation}` : ''}${cycleMembers.has(node.identity) ? ', cycle member' : ''}`, + }); + group.append(svgElement('rect', {x: '-76', y: '-29', width: '152', height: '58', rx: '10', class: 'graph-node-shape'})); + const identity = svgElement('text', {x: '0', y: '-2', 'text-anchor': 'middle', class: 'graph-node-id'}); + identity.textContent = node.identity; + const kind = svgElement('text', {x: '0', y: '16', 'text-anchor': 'middle', class: 'graph-node-kind'}); + kind.textContent = node.kind.replaceAll('_', ' '); + group.append(identity, kind); + group.addEventListener('click', () => focusNode(node)); + group.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + focusNode(node); + } + }); + nodeLayer.append(group); + node.element = group; + nodeElements.set(node.key, group); + }); + svg.append(nodeLayer); + canvas.replaceChildren(svg); + + const initialView = {x: 0, y: 0, width, height}; + const view = {...initialView}; + const applyView = () => svg.setAttribute('viewBox', `${view.x} ${view.y} ${view.width} ${view.height}`); + const zoom = (factor) => { + const nextWidth = Math.max(width * 0.3, Math.min(width * 2.5, view.width * factor)); + const nextHeight = nextWidth * (height / width); + view.x += (view.width - nextWidth) / 2; + view.y += (view.height - nextHeight) / 2; + view.width = nextWidth; + view.height = nextHeight; + applyView(); + }; + const pan = (x, y) => { + view.x += view.width * x; + view.y += view.height * y; + applyView(); + }; + const reset = () => { + Object.assign(view, initialView); + applyView(); + }; + root.querySelector('[data-graph-zoom-in]').addEventListener('click', () => zoom(0.8)); + root.querySelector('[data-graph-zoom-out]').addEventListener('click', () => zoom(1.25)); + root.querySelector('[data-graph-pan-left]').addEventListener('click', () => pan(-0.12, 0)); + root.querySelector('[data-graph-pan-right]').addEventListener('click', () => pan(0.12, 0)); + root.querySelector('[data-graph-pan-up]').addEventListener('click', () => pan(0, -0.12)); + root.querySelector('[data-graph-pan-down]').addEventListener('click', () => pan(0, 0.12)); + root.querySelector('[data-graph-reset]').addEventListener('click', reset); + canvas.addEventListener('keydown', (event) => { + const actions = { + ArrowLeft: () => pan(-0.08, 0), + ArrowRight: () => pan(0.08, 0), + ArrowUp: () => pan(0, -0.08), + ArrowDown: () => pan(0, 0.08), + '+': () => zoom(0.8), + '=': () => zoom(0.8), + '-': () => zoom(1.25), + '0': reset, + }; + if (actions[event.key]) { + event.preventDefault(); + actions[event.key](); + } + }); + + const applySearch = () => { + const term = search.value.trim().toLocaleLowerCase(); + let matchingEdges = 0; + let matchingNodes = 0; + edges.forEach((edge) => { + const text = [edge.source, edge.target, edge.kind, edge.state, edge.required ? 'required' : 'advisory', edge.resolver, edge.evidence].join(' ').toLocaleLowerCase(); + const matches = !term || text.includes(term); + edge.row.classList.toggle('graph-search-muted', !matches); + edge.element.classList.toggle('graph-search-muted', !matches); + if (matches) matchingEdges += 1; + }); + nodes.forEach((node) => { + const state = readiness.get(node.identity); + const adjacent = [...node.incoming, ...node.outgoing].map((edge) => `${edge.state} ${edge.evidence} ${edge.resolver}`).join(' '); + const text = `${node.identity} ${node.kind} ${state ? state.explanation : ''} ${adjacent}`.toLocaleLowerCase(); + const matches = !term || text.includes(term); + node.element.classList.toggle('graph-search-muted', !matches); + node.element.setAttribute('tabindex', matches ? '0' : '-1'); + if (matches) matchingNodes += 1; + }); + itemRows.forEach((row) => { + const text = `${row.dataset.graphItem || ''} ${row.dataset.graphExplanation || ''}`.toLocaleLowerCase(); + row.classList.toggle('graph-search-muted', Boolean(term) && !text.includes(term)); + }); + searchStatus.textContent = term + ? `${matchingNodes} node(s) and ${matchingEdges} edge(s) match “${search.value.trim()}”. Nonmatches remain visible but muted.` + : `All ${nodes.size} node(s) and ${edges.length} edge(s) shown.`; + return Array.from(nodes.values()).find((node) => !node.element.classList.contains('graph-search-muted')); + }; + search.addEventListener('input', applySearch); + search.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + const first = applySearch(); + if (first) { + first.element.focus(); + focusNode(first); + } + } else if (event.key === 'Escape') { + search.value = ''; + applySearch(); + } + }); + applySearch(); + root.hidden = false; + }; + document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('[data-csrf-form]').forEach((form) => form.addEventListener('submit', (event) => { event.preventDefault(); @@ -26,5 +309,6 @@ }; connect(); }); + document.querySelectorAll('[data-dependency-graph]').forEach(initializeDependencyGraph); }); })(); diff --git a/src/agent_harness/templates/graph.html b/src/agent_harness/templates/graph.html index f547552..4fbe37b 100644 --- a/src/agent_harness/templates/graph.html +++ b/src/agent_harness/templates/graph.html @@ -1,20 +1,87 @@ {% extends "base.html" %} {% block content %} -

Coordination plane

Dependency graph

Revision {{ graph.revision if graph else '—' }}
-
- {% if not graph %}

No graph available

Choose a configured project.

{% else %} -

Admission

Readiness

{{ graph.ready|length }} ready
- {% if graph.cycles %}
Cycles block work.
    {% for cycle in graph.cycles %}
  • {{ cycle|join(' → ') }}
  • {% endfor %}
{% endif %} - {% if graph.ready %}

Ready at revision {{ graph.revision }}

{% endif %} - {% if graph.not_ready %}
{% for item in graph.not_ready %}

{{ item.item_id }}

{{ item.explanation }}

{% if item.advisory %}

Advisory: {{ item.advisory|length }} edge(s)

{% endif %} -
- - - -
-
{% endfor %}
{% else %}

Every item is ready at this revision.

{% endif %} - {% if overrides %}

Recorded overrides

{% for override in overrides %}{% endfor %}
ItemRevisionOperatorReason
{{ override.item_id }}{{ override.revision }}{{ override.who or '—' }}{{ override.reason }}
{% endif %} +
+

Coordination plane

Dependency graph

+ Revision {{ graph.revision if graph else '—' }} +
+
+
+ + + +
+
+ {% if not graph %} +

No graph available

Choose a configured project.

+ {% else %} + +
+

Admission

Readiness

{{ graph.ready|length }} ready
+ {% if graph.cycles %} +
Cycles block work.
    {% for cycle in graph.cycles %}
  • {{ cycle|join(' → ') }}
  • {% endfor %}
+ {% endif %} + {% if graph.ready %} +

Ready at revision {{ graph.revision }}

+ + {% endif %} + {% if graph.not_ready %} +
{% for item in graph.not_ready %}

{{ item.item_id }}

{{ item.explanation }}

{% if item.advisory %}

Advisory: {{ item.advisory|length }} edge(s)

{% endif %} +
+ + + +
+
{% endfor %}
+ {% else %}

Every item is ready at this revision.

{% endif %} + {% if overrides %}

Recorded overrides

{% for override in overrides %}{% endfor %}
ItemRevisionOperatorReason
{{ override.item_id }}{{ override.revision }}{{ override.who or '—' }}{{ override.reason }}
{% endif %} +
+
+

Complete edge list

+

This table is the accessible, no-script equivalent of the diagram and remains complete while search highlights matches.

+ {% if graph.edges %}
{% for edge in graph.edges %}{% endfor %}
Waiting itemTargetKindRequirementStateResolverEvidence
{{ edge.source_item }}{{ edge.target_id }}{{ edge.target_kind|replace('_', ' ') }}{{ 'required' if edge.required else 'advisory' }}{{ edge.state }}{{ edge.resolver or '—' }}{{ edge.evidence }}
{% else %}

No dependency edges are declared.

{% endif %}
-

Edges

{% if graph.edges %}
{% for edge in graph.edges %}{% endfor %}
Waiting itemTargetKindStateEvidence
{{ edge.source_item }}{{ edge.target_id }}{{ edge.target_kind }}{{ edge.state }}{% if not edge.required %} (advisory){% endif %}{{ edge.evidence }}
{% else %}

No dependency edges are declared.

{% endif %}
{% endif %} {% endblock %} diff --git a/tests/test_ui.py b/tests/test_ui.py index 5ab9391..712c485 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1026,6 +1026,106 @@ def test_graph_page_shows_typed_edges_and_readiness(tmp_path: Path) -> None: assert "blocked" in html.lower() +def test_graph_explorer_preserves_every_typed_distinction_and_list_equivalent( + tmp_path: Path, +) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + for project_id in ("p", "other"): + queue.add_project(Project(project_id=project_id, name=project_id)) + queue.add([WorkRecord(item_id="REMOTE", title="Remote work")], project_id="other") + queue.add( + [ + WorkRecord(item_id="T1", title="First", state="done"), + WorkRecord(item_id="D9", title="Human choice"), + WorkRecord( + item_id="T2", + title="Everything waits here", + depends_on=[ + "T1", + "?external:tracker:TICKET-9", + "decision:D9", + "project:other/REMOTE", + ], + ), + WorkRecord(item_id="C1", title="Cycle one", depends_on=["C2"]), + WorkRecord(item_id="C2", title="Cycle two", depends_on=["C1"]), + ], + project_id="p", + ) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + response = client.get("/graph?project_id=p") + assert response.status_code == 200 + html = response.text + assert 'data-dependency-graph data-graph-revision="' in html + assert 'data-graph-revision="' in html and " hidden>" in html + assert 'data-graph-search aria-controls="dependency-diagram graph-edge-table"' in html + assert 'data-graph-canvas tabindex="0" role="region"' in html + assert "Search items, targets, kinds, states, or evidence" in html + assert "Zoom in" in html and "Pan left" in html and "Reset view" in html + assert "Complete edge list" in html + assert "accessible, no-script equivalent" in html + for kind in ( + "local_work", + "external_reference", + "human_decision", + "cross_project_work", + ): + assert f'data-kind="{kind}"' in html + for state in ("satisfied", "blocked", "unresolved"): + assert f'data-state="{state}"' in html + assert 'data-required="false"' in html + assert "advisory" in html.lower() + assert "data-graph-cycle=" in html + assert "Cycles block work" in html + assert "TICKET-9" in html and "tracker" in html + assert "other/REMOTE" in html + + +def test_packaged_graph_script_is_keyboard_accessible_and_read_only() -> None: + script = Path(__file__).parents[1] / "src" / "agent_harness" / "static" / "app.js" + source = script.read_text(encoding="utf-8") + for key in ("ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Enter", "Escape"): + assert key in source + assert "focusSummary.textContent" in source + assert 'aria-live="polite"' in ( + Path(__file__).parents[1] / "src" / "agent_harness" / "templates" / "graph.html" + ).read_text(encoding="utf-8") + assert "aria-label" in source + assert "data-graph-edge" in source and "data-graph-item" in source + assert "root.hidden = false" in source + assert ( + "fetch(" + not in source[ + source.index("const initializeDependencyGraph") : source.index( + "document.addEventListener('DOMContentLoaded'" + ) + ] + ) + + +def test_graph_data_attributes_escape_untrusted_evidence(tmp_path: Path) -> None: + store = EventStore(tmp_path / "events.sqlite") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p")) + hostile = '">' + queue.add( + [ + WorkRecord(item_id=hostile, title="Hostile target"), + WorkRecord(item_id="T2", title="Waiting", depends_on=[hostile]), + ], + project_id="p", + ) + with TestClient(create_api(store, queue=queue, token=TOKEN)) as client: + login(client) + response = client.get("/graph?project_id=p") + assert response.status_code == 200 + assert hostile not in response.text + assert "><img" in response.text + assert "" not in response.text + + def test_dependency_override_is_explicit_revision_scoped_and_audited(tmp_path: Path) -> None: store = EventStore(tmp_path / "events.sqlite") queue = WorkQueue(str(tmp_path / "queue.sqlite")) diff --git a/tests/test_ui_packaging.py b/tests/test_ui_packaging.py index de23fae..cf556e8 100644 --- a/tests/test_ui_packaging.py +++ b/tests/test_ui_packaging.py @@ -13,7 +13,9 @@ def test_ui_resources_are_in_the_installed_package() -> None: package = importlib.resources.files("agent_harness") assert package.joinpath("templates", "base.html").is_file() + assert package.joinpath("templates", "graph.html").is_file() assert package.joinpath("static", "app.css").is_file() + assert package.joinpath("static", "app.js").is_file() assert package.joinpath("static", "htmx.min.js").is_file() @@ -34,5 +36,7 @@ def test_wheel_contains_templates_and_static_assets(tmp_path: Path) -> None: with zipfile.ZipFile(wheels[0]) as archive: names = set(archive.namelist()) assert "agent_harness/templates/base.html" in names + assert "agent_harness/templates/graph.html" in names assert "agent_harness/static/app.css" in names + assert "agent_harness/static/app.js" in names assert "agent_harness/static/htmx.min.js" in names From aabdfdb777395ecf9423ebb9814b21bb9673bb10 Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 07:11:27 +0000 Subject: [PATCH 10/12] Add metadata-selected implementer role runner --- docs/DESIGN.md | 59 ++- docs/STATUS.md | 238 +++++++-- docs/USAGE.md | 33 ++ .../2026-08-06-stage-1-role-runner.md | 85 +++ pyproject.toml | 6 + src/agent_harness/__main__.py | 41 ++ src/agent_harness/adapters/minisweagent.py | 176 ++++++- src/agent_harness/api.py | 5 + src/agent_harness/budgets.py | 6 + src/agent_harness/doctor.py | 17 + src/agent_harness/executor.py | 261 +++++++++- src/agent_harness/model_client.py | 25 +- src/agent_harness/preflight.py | 5 + src/agent_harness/role_runners.py | 167 ++++++ tests/test_agent_loop_e2e.py | 55 +- tests/test_generic.py | 11 + tests/test_preflight.py | 15 + tests/test_role_runner_e2e.py | 490 ++++++++++++++++++ tests/test_role_runners.py | 67 +++ 19 files changed, 1682 insertions(+), 80 deletions(-) create mode 100644 docs/evidence/2026-08-06-stage-1-role-runner.md create mode 100644 src/agent_harness/role_runners.py create mode 100644 tests/test_role_runner_e2e.py create mode 100644 tests/test_role_runners.py diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 16e36dd..a8a1d73 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -79,7 +79,7 @@ on. │ context selection ────── primary target does not fit? ESCALATE, do not guess │ - implementer ────────── asked for EDIT BLOCKS; the harness computes the diff + implementer ────────── direct EDIT BLOCKS, or a selected tool-using loop │ validate the patch ──── before git is touched at all │ @@ -107,7 +107,7 @@ on. | **Tree sync** | The tree is at the item's base *before* anything reads it, and before the branch is cut. The context selector reads the file at the base while the edit applier reads the working tree; when those disagreed, the second item to touch a file was told its own correctly-quoted text did not occur. The branch is cut late, so an item producing no usable diff leaves no branch behind. | | **Planner** | Named targets, in importance order, plus an explicit `cannot_identify_target`. That last field is a first-class answer, not an error. | | **Context selection** | The implementer is never asked to change a file it was not shown. If the primary target alone exceeds the budget the item escalates (`context_unavailable`) rather than proceeding — retrying cannot change the size of a file. | -| **Implementer** | A change expressed as text to find and text to put there. See §2.1. | +| **Implementer** | In direct mode, a change expressed as text to find and text to put there (§2.1). With a role runner selected, a bounded loop works in the repository and the harness computes the complete candidate diff against the item base, including new files and local commits. Both paths then enter the same validator and gates. | | **Patch validation** | A reply that is not a well-formed diff is diagnosed as a *model* failure before git is involved, because once it reaches `git apply: corrupt patch at line 549` it is indistinguishable from a patch written against the wrong base — and the two are fixed in completely different places. | | **Apply** | Either the change is in the tree, or the branch is destroyed and the patch is kept on disk for whoever diagnoses it. | | **Checks** | The project's own commands answered on the *applied* tree, with five distinct outcomes (§6.4) — not a boolean. | @@ -185,8 +185,10 @@ form. One refusal precedes the ladder entirely: a hunk header claiming `@@ -0,0` against a file that exists with content is rejected outright, because `--unidiff-zero` would "succeed" by inserting the whole thing at line 1. -The ladder is why the *applied* diff — `git diff HEAD`, re-read after the apply -and again if a declared fix touched the tree — is what the reviewer sees. +The ladder is why the *applied* diff — re-read after the apply and again if a +declared fix touched the tree — is what the reviewer sees. The reader uses a +temporary Git index so untracked files are included without changing the real +index; a plain `git diff HEAD` would silently omit a newly created module. Reviewing the model's text instead would reject good work for an artefact of the plumbing and, worse, make the gate structurally unable to catch a diff that claims more than it did. @@ -210,29 +212,42 @@ its gates: sequence, and where the two executors once disagreed about a gate, they no longer do. -- **An agent loop** (`adapters/minisweagent.py`) supplies what the other two - lack: turns. Its `Model` routes every call through `ModelClient` — so +- **A selected role runner** replaces the direct implementer call without + replacing the executor. Core defines `RoleRunner` and `RoleRunRequest` in + `role_runners.py`, resolves the configured name through installed metadata, + and knows no adapter module path. The shipped agent-loop adapter + (`adapters/minisweagent.py`) supplies what the other two paths lack: turns. + Its `Model` routes every call through `ModelClient` — so fallback chains, the retry ladder, per-endpoint parking, classification, pricing and the recorded answer all still apply, and the loop never learns what a provider is — and its `Environment` puts every command the agent runs through the same `CommandGuard` that screens check commands. A call is billed before its body is parsed, because a reply nobody could parse was still paid for and a ceiling that counts only the calls that went well is not a ceiling. - **It is not yet reachable from `run`** (#215): the queue, the gates, the - audit, the attempt record and the budgets have never seen a loop-executed - item, and its budget mapping is therefore enforceable rather than enforced. + `run --role-runner NAME` selects it before any item is claimed; the choice is + stored for doctor and preflight. The loop may run declared checks for + feedback, but its complete candidate tree is converted to a diff and enters + the existing `_from_diff` path, where the harness runs those checks again as + the authoritative gate, checkpoints, reviews and records the attempt. + + Whole-item wall-clock and spend bounds are translated to their remaining + values before the loop starts, while the step ceiling remains an independent + emergency control. Usage is folded into the item on every successful reply, + including a reply whose body cannot be parsed. Once any call is unpriced, a + dollar total is only a lower bound, so the dollar ceiling becomes + unenforceable rather than stopping the item on a known subtotal; step and + wall-clock bounds still hold. Which files inside the repository an agent touches is deliberately *not* constrained. An agent using the whole repository to reach an outcome is how work gets done; "it changed something the item did not ask for" is a question for the reviewer. The guard bounds what is **dangerous**, not what is untidy. -**The direction of travel.** Issue #195 reframes the single-shot model call as -the defect rather than any one prompt or format: every role that answers -questions about a repository — planner, implementer, reviewer, surveyor, -assessor, scoper — is today a context someone guessed at, one call, and parsed -text, and the correct shape is a bounded loop with tools and, for the gates, -read-only access. This document describes the pipeline that exists. +**The remaining direction.** Issue #195 reframes the single-shot model call as +the defect rather than any one prompt or format. The implementer now has a +selectable bounded loop; planner, reviewer, surveyor, assessor and scoper still +use one call over context assembled for them. A gate converted to a loop needs +a read-only environment rather than the writable implementation environment. --- @@ -1152,6 +1167,12 @@ format lives in `adapters/` and is declared under `agent_harness.dependency_resolvers`. A dotted module path written into `graph.py` would still be core knowing what a particular tracker is called. +**Role runners, the same door again.** Core publishes the versioned contract +and resolves `agent_harness.role_runners` entry points by name. The distribution +declares its shipped loop in metadata; `executor.py` receives only a structural +runner and neither imports nor names its adapter. An incompatible contract is a +configuration failure before work is claimed, not a substitution. + **Adapters generally.** Log readers, telemetry export and the agent loop are all opt-in and lazily loaded, and nothing in core imports any of them. Telemetry is **export-only**: it projects the event stream outward, nothing reads back, and @@ -1187,8 +1208,12 @@ Named here rather than left for a reader to discover. messages and human participation over the API are designed and not built, and two modules currently spell an item's room differently — harmless only because nothing in production constructs a ledger. -- **The single-shot call is the known shape defect** (§2.3), and is why #195 - exists. +- **The role-runner path still works in one shared checkout.** It is selectable + from `run`, but it is not yet the isolated per-item worktree fleet described + by the accepted product direction. Its subprocess inherits the controller's + environment, and `CommandGuard` remains screening rather than an OS security + boundary. No secret-bearing real workload should be run through it until the + Stage 2 confinement boundary exists. Where to go next: [`AGENTS.md`](../AGENTS.md) for the binding rules, [`STATUS.md`](STATUS.md) for what is built and what has been proven, diff --git a/docs/STATUS.md b/docs/STATUS.md index 5cb4880..c8bb511 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,10 +3,12 @@ **Date:** 2026-08-06. **This is the only status document for this repository.** It says where the project stands, what is left to do, what the first real -workload is, and how to run the harness against it. It does not describe how -the harness works — that is [`DESIGN.md`](DESIGN.md), which owns the design and -is being written in parallel with this document. Where DESIGN.md and this file -disagree about mechanism, DESIGN.md is right. +workload is, and what must be true before it is run again. It does not describe +how the harness works today — that is [`DESIGN.md`](DESIGN.md), which owns the +implemented design. Section 2 records the product decisions and development +sequence agreed on 2026-08-06; where the current code does not implement one of +them, that is pending work rather than permission for DESIGN.md to claim it +already exists. [`FIT-FOR-PURPOSE-STATUS.md`](FIT-FOR-PURPOSE-STATUS.md) is frozen at 2026-08-04 and is being deprecated. Read it for the history of the stage @@ -39,7 +41,9 @@ What *is* true, and is worth stating alongside it: ladder, the checks gate, the reviewer gate, the API contract, the redactor, the command guard, and the first-run/demo path. `uv run pytest` fails if any of them stops being true. -- The service runs and is deployed inside AIDevEnv. That is **observed**. +- The service runs and is deployed inside AIDevEnv. That is **observed**, but + AIDevEnv is not part of the accepted product architecture below and this + deployment does not satisfy the execution requirement. - Real model calls have been made against a real gateway, against a real repository, and they exposed real defects — several of which now have regression tests (#216, #217, #218). The defects are tested; the runs that @@ -56,29 +60,176 @@ indentation tolerance → quoting the file back on a failed match) each improved the output and each delivered nothing. The format was never the problem. The same item, same models, same gateway, run as a **loop** with tools reached -`cargo test` green in 31 turns. That run went through a standalone script: the -queue, gates, audit, attempt record, budgets and reviewer have never seen a -loop-executed item. That is **observed**, once, on one item — it is not -evidence that the harness works, and it is explicitly not a delivered item. +`cargo test` green in 31 turns. That real run went through a standalone script. +The loop has since been put through the queue, gates, append-only audit sink, +attempt record, item budgets and reviewer against local scripted fixture +repositories; that path is **tested**, while a real workload through it remains +unobserved. The standalone +run is **observed**, once, on one item — it is not evidence that the harness +works, and it is explicitly not a delivered item. + +**Stage 1 / #215 is implemented and tested locally; Stage 2 is the next +implementation block.** The GitHub issue remains open while this exists only +locally, per P12 and D1. A useful loop must now be confined before it touches a +real workload, then become reachable from an AIDevEnv-independent service +fleet, execute in isolated worktrees, promote +several items safely to one plan branch, surface exceptions, and attribute its +calls and outcomes to the item that caused them. None of that end-to-end path +has run. -**The single thing standing between the work already done and an item landing -is #215.** Everything else in the backlog is either downstream of it, blocked -on a run that cannot happen without it, or independent of delivery entirely. +--- + +## 2. Settled product direction and development plan + +This section records owner decisions made on 2026-08-06 after the failed +single-shot runs and the successful standalone loop experiment. They are the +requirements for the next implementation cycle. They are written with their +reasoning because changing one in isolation recreates the four days of local +repairs that did not improve delivery. + +These decisions do not claim the code implements them. The first local +acceptance run in §2.4 is what moves them from requirements to observations. + +### 2.1 Immediate outcome + +The immediate success criterion is **a local, autonomous, multi-item coding +fleet**, not one agent completing one item. Given a plan, a repository and its +declared checks, the service must: + +1. start and supervise workers without AIDevEnv, a terminal session host, or a + local Codex/Claude/OpenCode process; +2. run at least two independent items concurrently in separate worktrees; +3. use a tool-using model loop for implementation, with all model traffic still + routed through `ModelClient` and the configured API provider; +4. wait for prerequisites, then base dependent work on the locally promoted + results of every prerequisite; +5. run the configured gates authoritatively, serialise promotion, and build one + local integration branch for the plan; +6. continue other work when one item fails, and ask a person only for a real + question, policy refusal, ambiguity or unrecoverable failure; and +7. preserve enough item-scoped evidence to explain every call, change, gate, + promotion, hold and failure. + +The first acceptance is deliberately local. After it passes, remote +acceptance publishes the integration branch and opens one pull request against +the target branch for human review. Later corrections update that same branch +and PR. There are no per-item pull requests and no automated merge to the +target branch. + +### 2.2 Decisions that are not to be re-litigated during this cycle + +| ID | decision | context and consequence | +|---|---|---| +| **P1 — multi-item first** | The delivery slice is a fleet executing a plan, not a one-item demo. | A one-item loop already reached green checks once and still proved neither scheduling nor delivery. The minimum acceptance graph therefore contains two independent items and one item that depends on promoted work. | +| **P2 — harness-owned execution** | The primary executor is an in-process role runner owned by `agent-harness`. AIDevEnv and subscription-backed CLI agents are not runtime dependencies. | The current supervised service can execute only when `--session-host` is supplied. That is useful historical scaffolding, but it fails the product requirement. AIDevEnv may be used to develop the repository and the session-host adapter may remain supported; neither may be required by `serve`, preflight, starting a project or completing an item. | +| **P3 — loop, not proxy** | `mini-swe-agent` supplies the tool-using interaction loop; it is not a proxy to the provider. Every model call continues through `ModelClient` to the configured route. | Direct API transport was healthy; the single-shot interaction model produced unusable work. The same API and model became useful when allowed to inspect, edit and test iteratively. Core owns a generic `RoleRunner` contract and resolves implementations by installed metadata; it must not import or name a particular adapter, preserving the generic-core rule. The selected runner and compatible version are load-bearing deployment configuration and must be reported by doctor/preflight. | +| **P4 — checks have two jobs** | Agents may run project checks during their loop for feedback. The harness always runs the configured gates again after the loop, on the exact tree proposed for promotion. | Preventing feedback recreates the blind single-shot defect. Treating an agent's claim that tests passed as the gate weakens the product. Feedback never substitutes for the authoritative gate and never changes its command or outcome taxonomy. | +| **P5 — real local confinement** | Every item receives its own git worktree and an OS-enforced filesystem boundary. The repository is writable; related dependency roots are explicitly declared per project with read/write mode, and default to read-only. A minimal platform runtime/toolchain may be mounted read-only and reported; dependency caches are declared or ephemeral writable mounts. No other undeclared host data path is readable or writable. | `CommandGuard` is screening, explicitly not a sandbox, and cannot enforce this requirement against inline programs. The current loop subprocess also inherits the controller environment. Before a real run, command execution needs a deliberately constructed environment, with provider, GitHub and harness credentials retained only by the controller. | +| **P6 — internet is available** | Agents have near-full outbound internet access, subject only to explicit platform policy and auditable denials. | Agents are expected to consult documentation and obtain ordinary dependencies. Filesystem and credential isolation still apply. This is not a confidentiality boundary: an agent that can read repository content and use the internet can transmit that content, so secret-bearing source must not be placed in an agent-readable checkout unless that exposure is accepted. | +| **P7 — one integration branch per plan** | The harness creates a plan branch from an exact target-branch commit. Workers use disposable item branches/worktrees; gated item commits are promoted to the plan branch under a single promotion lock. | Independent work can run in parallel, while promotion remains deterministic. If the plan branch advanced after an item started, the item is replayed onto the new head and the authoritative gates run again before promotion. An item with several prerequisites starts only after all have been promoted, so it needs no arbitrary choice among unmerged bases. “Automatic approval” means this gate-controlled local promotion, never a fabricated remote review approval. | +| **P8 — one human-reviewed PR** | When the whole plan is locally acceptable, the integration branch is pushed and one PR is opened against the target branch. Only a person approves and merges that PR. | Per-item/stacked PRs make dependency bases, review ordering and rebases the product's main complexity. The integration branch solves that locally and leaves one coherent human decision. Item-level commits, dependencies, gate results and summaries must remain visible in the final PR and API. | +| **P9 — review feedback resumes automatically** | New actionable review comments on the final PR create or resume correction work on the same integration branch, rerun the gates and update the existing PR. | Human review must not require an operator to reconstruct an agent session. Ambiguous, contradictory, policy-changing or already-resolved comments become holds for a person; they are not guessed at. Webhook delivery is preferred and polling is an acceptable recovery path, both deduplicated by immutable remote event identity. | +| **P10 — human involvement is exceptional** | Before publication, people are involved only for questions, holds and failures. The single final PR review and merge is the normal human approval point. | There is no approval ceremony per item and no need to watch agents work. A held item keeps its claim under D12; unrelated workers continue. The GUI/API must make the exception and the evidence needed to answer it visible. | +| **P11 — calls are not the optimisation target** | Use generous configurable loop bounds. Keep time, spend and call ceilings as emergency controls and continue measuring them, but do not shorten the loop to minimise request count. | The useful run took 31 turns; the failed design used one. Number of calls is not currently a material product concern. A limit still must stop a pathological loop, and a provider cost cap is still terminal and never retried. | +| **P12 — local development cadence** | Develop, commit, gate and integrate locally. Do not push, open a PR, wait for hosted CI, or deploy each implementation slice. | Those remote steps are materially delaying the feedback loop. GitHub support is retained and tested with local fakes; GitHub issues remain the state record required by D1, but issues stay open while implementation exists only locally. Publication happens once at the explicit milestone in §2.4. | +| **P13 — GUI is a client, not an execution dependency** | The GUI workstream consumes typed API state and event/notification contracts. Its assumed webhook support must be verified; it is not an input to executor design. | The current GUI plan describes authenticated webhook support as later work, not a landed facility. Execution must continue with the GUI offline. Holds, failures, completion and review events live in the harness's durable stores/API; a GUI or external channel presents and notifies. This section does not decide the GUI's repository placement, which is governed separately. | + +### 2.3 Plan-branch and dependency semantics + +The integration branch replaces stacked pull requests as the coordination +mechanism: + +```text +target branch at an exact SHA + │ + ▼ + local plan branch ────────────────────────────────────────┐ + │ │ + ├── item A worktree ── gates ── promote A ─────────┤ + ├── item B worktree ── gates ── promote B ─────────┤ concurrent + │ │ + └── item C waits for A+B, starts from their │ + promoted plan head ── gates ── promote C ───────┘ + │ + publish one branch, one PR to target + │ + human review and merge only +``` + +“Done” inside the plan means promoted to the local plan branch, not merged to +the target branch. The queue may then release dependants. Only the completed +plan has a PR. If a promotion conflicts, the item returns to agent work with +the current plan head and the conflict evidence; it is not resolved by an +unreviewed merge strategy. If the target branch moves, the plan branch must be +updated and the full integration gates rerun before publication. + +### 2.4 Development sequence and exit evidence + +Work proceeds in this order. A stage is not complete because its code exists; +its exit evidence must be retained locally with the commands, commit and +denominator. Safety defects discovered in an earlier stage pre-empt the order. + +| stage | implementation | exit evidence before moving on | +|---|---|---| +| **0. Preserve the baseline** | Keep the direct and session executors while building the new path. Record these decisions and align affected tracker issues at the publication milestone. | The current four repository gates pass. No existing gate or historical evidence is removed to simplify the runner. | +| **1. Put the loop behind a generic runner** | Define the core role-runner protocol and installed-metadata lookup; adapt the existing mini-SWE loop to it; pass role, item, project and whole-loop bounds explicitly. Let the loop use checks for feedback, then feed its resulting tree into the existing checks/review/attempt/audit pipeline. | In local fixture repositories, a multi-turn implementer can inspect, edit and test; the harness reruns the declared gates; all calls are attributable to one project/item/attempt; no AIDevEnv or CLI-agent process is involved. A real workload is not run yet. | +| **2. Enforce the execution boundary** | Replace inherited shell execution with an OS-enforced local sandbox, a minimal allow-listed environment, an item worktree, and declared dependency mounts. Keep outbound internet available. Treat `CommandGuard` as an earlier explanatory refusal, not the security boundary. | Tests prove an agent can work throughout its repository, cannot read or write an undeclared sibling/host path, cannot read controller credentials from its environment, can use an allowed dependency root according to its mode, and can reach an allowed network fixture. Do not run rdpapp before this passes. | +| **3. Make `serve` own a local fleet** | Add an AIDevEnv-independent executor factory and worker pool to `serve`; make executor capability—not presence of `--session-host`—drive readiness and preflight. Allocate one worktree and runner per claimed item, with item-scoped telemetry and failure isolation. | With no AIDevEnv variables or session host, the API starts a project and two fixture items are observed running concurrently. Killing or failing one does not stop, park or corrupt the other; restart/reaping leaves claims and worktrees consistent. | +| **4. Build local plan integration** | Create and durably record the plan branch/base SHA; serialise promotion; rebase/replay and regate work produced from an older plan head; release dependants only after every prerequisite is promoted. Preserve item commits and promotion events. | A local fixture plan with two independent items and one item depending on both completes into one branch. The dependent item demonstrably sees both promoted changes. A conflicting promotion is returned for repair, and no remote is contacted. | +| **5. Complete exception and feedback control** | Expose item-scoped runner progress, questions, holds, gate evidence and promotion state through typed API/events. Add a deduplicated remote-review event contract and automatic correction-item/resume path, exercised against a local fake. Connect the GUI/notification workstream only through those contracts. | A question pauses only its item and can be answered through the API; an injected actionable review comment resumes work once; duplicates do nothing; ambiguous feedback opens a hold; the fleet continues throughout. The test does not require a GUI or GitHub. | +| **6. Local multi-item acceptance** | Run a real supplied plan against rdpapp, or another explicitly authorised real repository, entirely locally. Use at least two workers and a graph containing two independent items plus a dependent item. Build the local plan branch, run the real project gates and retain an evidence package. | The plan branch contains the promoted item commits and passes its declared integration gates. There was no AIDevEnv/session-host/CLI-agent dependency, no push, no PR and no deployment. Report delivery rate, failures, turns and cost honestly; one successful run is **observed**, not proven. | +| **7. Remote publication acceptance** | On a repository whose authoritative remote permits it, update from the target branch, rerun integration gates, publish the one plan branch and open one PR. Keep remote credentials in the controller. Detect review comments and exercise one automatic correction if review supplies one. | Exactly one plan PR is raised against the target branch with item/dependency/gate evidence. Corrections update that PR's branch; no item PR exists. The harness never merges it, and publication does not authorise deployment. A person reviews and merges or rejects it. rdpapp's GitHub mirror is not used for this while its own plan forbids that publication path. | +| **8. Measure, then broaden** | Convert the reviewer to its read-only loop (#226), then run #33/#44/#51 as their prerequisites become true. Move surveyor, assessor and deletion work only after implementer-fleet evidence exists. | Published denominators establish delivery, cost, gate and unattended reliability. Until then, do not describe the fleet as proven and do not spend the critical path on more roles or framework breadth. | + +### 2.5 Local development operating rule + +During stages 0–6, a coherent slice is committed locally after its focused +tests pass. The four repository gates run at every stage boundary. Local +commit identifiers and gate output go into the stage evidence package; they do +not need a remote PR to be valid evidence. + +Do **not** push, deploy, open a PR or wait for hosted CI between slices. Do not +close the corresponding GitHub issue while its only implementation is local, +because D1 makes that tracker the issue-state authority. At stage 7, publish +the accumulated, locally accepted milestone in one branch and one PR. This is +a cadence decision only: the GitHub client, PR support, reconciliation and +tests remain product functionality. + +Until the stage-6 evidence exists, pause work on additional role conversions, +planner/context deletion, lesson memory, UI features that are not needed to +expose the contracts above, and further output-format repairs. None addresses +the currently measured delivery failure. + +### 2.6 Current development position + +- **Stage 0:** passes on the current Stage 1 tree. The direct and session + executors remain present, and no historical gate or evidence was removed. +- **Stage 1:** implemented and tested locally. `run --role-runner agent-loop` + resolves the adapter through installed metadata before claiming, runs a + multi-turn implementer through `ModelClient`, captures the complete candidate + tree, and rejoins the existing checks/checkpoint/reviewer/attempt pipeline. + Model-call events carry project, item and work-attempt identity; item budgets + and terminal policy refusals stop at loop boundaries. Evidence is in + [`evidence/2026-08-06-stage-1-role-runner.md`](evidence/2026-08-06-stage-1-role-runner.md). +- **Next: Stage 2.** The current `HarnessEnvironment` invokes the host shell in + the controller environment. `CommandGuard` explains and terminates known + refusals, but it is not confinement. No real workload run is authorised by + the Stage 1 result. --- -## 2. All pending work +## 3. All pending work Every open issue, organised by what a reader can act on. Issue state lives on GitHub (D1); this section is a reading of it on 2026-08-06 and will drift. -### 2.1 What blocks everything +### 3.1 First implementation block | # | what it is | why it is where it is | |---|---|---| -| **#215** | Build the agentic role runner, and put the implementer through it. One runner: given a role, a task, an environment (read-only or writable, screened by `CommandGuard`), and bounds, run a loop and return the result. | Nothing else in #195 can be built until it exists, and nothing built so far can be used without it. `run` chooses between `SessionExecutor` and `Executor`; the loop is neither. It carries two decisions the issue refuses to guess at: who runs the check command (feedback vs gate), and how a per-item budget in `budgets.py` bounds a loop whose only boundary is the whole loop. | +| **#215** | Build the generic agentic role runner and put the implementer through it. | Implemented and tested on the local development branch; the tracker stays open until the publication milestone (P12/D1). It is stage 1, not the whole milestone. The agent may run checks for feedback, the harness reruns them as gates, and bounds apply to the whole loop with generous call limits rather than forcing it back toward one-shot behaviour. The implementation follows P3's installed-metadata boundary: core imports no shipped runner. | -### 2.2 The #195 programme +### 3.2 The #195 programme **#195** is the organising idea and should be read in full before any of its parts. It says the interaction model is the defect, names what survives @@ -86,21 +237,23 @@ parts. It says the interaction model is the defect, names what survives queue, holds, the graph, budgets, the guard, the audit, and the gates themselves) and what is revealed as scaffolding (the planner, context pre-selection, edit blocks and `to_diff`, structured-text parsing). It also -states the cost being accepted: `mini-swe-agent` becomes a dependency of the -core execution path rather than an opt-in extra, and turn count rises from 1 to -roughly 30 per role per item. +states the cost being accepted: `mini-swe-agent` is load-bearing in the selected +execution deployment rather than an experiment, and turn count rises from 1 to +roughly 30 per role per item. P3 refines the dependency wording: the core path +depends on a generic runner contract and metadata lookup, never on a named +adapter import. Its parts, in the order the workload's own evidence puts them: | # | what it is | why it is where it is | |---|---|---| -| #215 | implementer through the role runner | first, and blocks the rest — see above | +| #215 | implementer through the role runner | implemented/tested locally; publication and tracker closure wait for the milestone — see above | | #226 | reviewer through the runner, with a **read-only** environment | highest value after delivery works. The gate that rejected rdpapp T1 was *inferring* from a diff; it was right and it was guessing. A false rejection costs an attempt and blames a model that was correct. Read-only is not a detail: a gate with write access to the tree it judges can be talked out of a rejection. | | #224 | surveyor through the runner, read-only | a plan should be written by something that read the repository. Matters more for the *next* project than for rdpapp M2, whose plan already exists. | | #225 | assessor (`adopt`) through the runner, read-only | `adopt` asks a model to find evidence it cannot go and find. Real, and on nobody's critical path today. | | #227 | retire the planner and context pre-selection | **deliberately last, and gated on evidence rather than a date.** Deleting the only path that has tests in favour of one that has never run in-harness is the trade AGENTS.md rejects. It moves once the loop has delivered items *through the harness*. | -### 2.3 Defects with no blocker +### 3.3 Defects with no blocker Each of these can be picked up today. All were found by reading code or by reviewing a real failure, and none is waiting on anything. @@ -115,7 +268,7 @@ reviewing a real failure, and none is waiting on anything. | #209 | a stored model answer is redacted, so it cannot be used to reproduce what the model actually said | two promises in tension — "what did the model say" and "no credential reaches an append-only store" — with the second silently winning. It bites hardest on rdpapp, a credential vault whose fixtures are full of credential-shaped source. It matters most for exact-match edit failures, which are questions about characters, in a record whose characters were changed. | | #103 | silent-but-active CLI sessions are indistinguishable from hangs | session-host path: PTY output is the only activity signal, so a working agent that prints nothing reports `activity: idle`. Independent of the #195 programme; note that #195 also deprecates `--session-host` in help and docs, so weigh effort here against that. | -### 2.4 Blocked on a decision or a measurement +### 3.4 Blocked on a decision or a measurement These are not waiting on effort. Each names what it is waiting for. @@ -130,10 +283,10 @@ no stage may hold the review prompt as a variable while it is). See AGENTS.md § Decision hygiene; D1–D6 and D10–D14 are settled and are not to be re-litigated. -### 2.5 Blocked on a real run +### 3.5 Blocked on a real run These cannot be closed by writing code. They need the harness to run against a -real workload for a real duration — which needs #215 first. +real workload for a real duration — which needs stages 1–6, not merely #215. | # | what it is | why it is where it is | |---|---|---| @@ -148,7 +301,7 @@ still be argued into a pass by its own reading of the code. --- -## 3. rdpapp is the first application under test +## 4. rdpapp is the first application under test agent-harness is being exercised against **`TheDancingDeveloper-org/rdpapp`**, also hosted on Forgejo at `repo.indexarr.net/indexarr/rdpapp`. **The Forgejo @@ -185,9 +338,10 @@ not duplicated here. In summary: - The standalone loop run hit `LimitsExceeded` at 40 turns, ~15 of them lost to a guard false positive. Guard defect fixed (#217). -Its decision — **do not start another delivery run until #215 exists** — is -this repository's operating instruction too. A rerun today uses the execution -model measured to deliver nothing. +Its decision — **do not repeat the existing delivery command** — remains this +repository's operating instruction. A rerun today uses the execution model +measured to deliver nothing. The next real run is stage 6 and must wait for the +loop, confinement, local fleet and plan-integration exits in stages 1–5. **These numbers are rdpapp's.** They are one repository, one gateway, one model family, and nothing in them is a universal measurement about the harness. A @@ -203,18 +357,19 @@ to a single item. --- -## 4. Running the harness against rdpapp +## 5. Last rdpapp run recipe — retained for evidence, do not execute -This section is versioned here so that a reader can follow it without asking -anyone anything. Its content comes from the evidence package above and from an -**unversioned** file at `~/Working/Active/.harness-runs/rdpapp-m2/env.sh`; that -file remains the thing actually sourced, and this is the record of what it -contains. +This is the recipe that produced the observations in §4. It is retained so the +evidence is reproducible, **not** as the command for the next run. It invokes +the old direct executor and must be replaced by the stage-6 local-fleet command +after stages 1–5 pass. Its content comes from the evidence package above and +from an **unversioned** file at +`~/Working/Active/.harness-runs/rdpapp-m2/env.sh`. Paths below assume the layout of the machine this was run on (`~/Working/Active/...`). Adjust them; nothing in the harness requires them. -### 4.1 Preconditions, each verifiable +### 5.1 Preconditions, each verifiable ```bash # 1. Base lineage is not stale. This is what invalidated the abandoned @@ -237,7 +392,7 @@ curl -s -H "Authorization: Bearer $THECLAWBAY_API_KEY" \ https://api.theclawbay.com/v1/models | head -c 200 ``` -### 4.2 The environment +### 5.2 The environment Run-scoped rather than written into a shell profile: the attempt is meant to be discardable by deleting one directory, and a profile edit would outlive it. @@ -272,7 +427,7 @@ export HARNESS_AGENT_COMMAND="" export CARGO_TARGET_DIR="$HOME/Working/Active/.harness-runs/rdpapp-m2/cargo-target" ``` -### 4.3 The run +### 5.3 The historical run ```bash cd ~/Working/Active/apps/agent-harness @@ -309,7 +464,7 @@ Flags that are not decoration: declared formatter's fix run and re-checks, but it is off unless `apply_fixes` is set on the project. -### 4.4 Retrying failed items without destroying `last_error` +### 5.4 Retrying failed items without destroying `last_error` ```bash uv run python -c " @@ -323,7 +478,7 @@ that is the only thing that makes a retry different from a repeat. Clear `attempts` too **only** when the previous failure was the harness's fault rather than the item's; otherwise the attempt ceiling stops meaning anything. -### 4.5 Monitoring +### 5.5 Monitoring ```bash R=~/Working/Active/.harness-runs/rdpapp-m2 @@ -368,7 +523,7 @@ Symptoms and what they mean: | 429 / 503 storms | the gateway, not the harness. `--implementer a,b,c` chains past it; `survey` could not until #193. | | nothing claimed, queue full | was a real deadlock (#218) — the claim scan stopped after one page. Fixed; a recurrence is a regression worth reporting. | -### 4.6 Cleaning up an attempt +### 5.6 Cleaning up an attempt ```bash rm -rf ~/Working/Active/.harness-runs/rdpapp-m2 # queue, events, logs, cargo cache @@ -384,7 +539,7 @@ evidence and do not build on them. --- -## 5. What is proven, observed and tested +## 6. What is proven, observed and tested | claim | word | how to check it | |---|---|---| @@ -392,6 +547,7 @@ evidence and do not build on them. | The core stays generic — no workload-specific paths, numbers or adapter imports | **tested** | `tests/test_generic.py` (`EXECUTION_PATH` is the authoritative list) | | The store has no UPDATE and no DELETE | **tested** | the source-level assertion in the store tests | | The four rdpapp-derived defects: edit-block rendering, stale worktree, guard false positives, claim-scan page deadlock | **tested** | regression tests landed with #216, #217, #218 | +| A metadata-selected multi-turn implementer can inspect, edit, run feedback checks, create new files, and then pass through the harness's authoritative checks, attempt record and reviewer with item-scoped events and budgets | **tested** | `tests/test_role_runners.py`, `tests/test_role_runner_e2e.py`, and the adapter regressions in `tests/test_agent_loop_e2e.py` | | The service runs and is deployed inside AIDevEnv | **observed** | no preserved artefacts | | An earlier supervised NGMS attempt and later direct calls exercised real agents and providers | **observed** | [`evidence/2026-08-03-04-ngms-first-sustained-run-v1.md`](evidence/2026-08-03-04-ngms-first-sustained-run-v1.md) — lacks a common run ID, complete configuration, checksums and a comparable follow-up | | Four executor passes against rdpapp delivered nothing, and why each failed | **observed** | [`evidence/2026-08-05-06-rdpapp-m2-status.md`](evidence/2026-08-05-06-rdpapp-m2-status.md); the pass 3–4 attribution is hindsight and has not been confirmed by re-running against the fix | diff --git a/docs/USAGE.md b/docs/USAGE.md index 94665c5..1fcb3c6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -848,6 +848,36 @@ agent-harness run --repo owner/name --work ./target-repo \ --endpoint https://api.your-gateway.example --check 'pytest -q' ``` +That command keeps the historical single-shot implementer. To select an +installed tool-using loop instead: + +```bash +uv sync --extra agent-loop + +agent-harness run --repo owner/name --work ./target-repo \ + --role-runner agent-loop --runner-step-limit 80 \ + --planner gpt-5.6 --implementer gpt-5.6-terra --reviewer claude-sonnet-4-6 \ + --endpoint https://api.your-gateway.example --check 'pytest -q' +``` + +The runner name is resolved through the installed +`agent_harness.role_runners` metadata. `run` refuses an unknown or incompatible +runner before claiming work, stores an explicitly selected name in the queue +database, and prints the implementation version plus contract compatibility. +`doctor` and project preflight report that same selection. + +The loop may run the declared checks itself for feedback. That result is not a +gate: after the loop submits, the harness captures the complete candidate tree +(including new files and local commits), validates and reapplies its diff, and +runs every declared check again before review. `--runner-step-limit` bounds the +whole loop; `--runner-command-timeout` bounds one feedback command. Project and +item wall-clock/spend ceilings still apply across attempts. + +This selectable path is the Stage 1 execution path, not the autonomous fleet. +It still works in the shared checkout and its command subprocess inherits the +controller environment. Do not use it for a secret-bearing real repository +until the OS-enforced confinement described in `STATUS.md` Stage 2 is present. + **A role may name several models, in preference order.** The first that answers does the work; the others are tried only when it will not: @@ -1782,6 +1812,9 @@ while one of those exists is exactly the failure this closes. | `HARNESS_HOLD_WEBHOOK` | `run`, `serve` | URL POSTed a JSON notice when an item stops to ask a person something (`--hold-webhook`). Unset means nothing is sent and the pull routes are unchanged. Delivery is best-effort: it can never fail or stall the item. | | `HARNESS_ROUTE_PRESET` | `run`, `serve` | Default route preset (`--preset`) for roles that name none: the wire protocol, the authentication header, the response reader and a failure classifier, as one name. Default `chat-completions`. | | `HARNESS_ROUTE_PRESETS` | all | Extra presets to make resolvable, as `name=module:attribute` pairs. For a preset that lives in your own code rather than in an installed distribution's entry points. | +| `HARNESS_ROLE_RUNNER` | `run` | Installed role runner selected for implementation (`--role-runner`). Empty keeps the historical single-shot implementer. An explicit flag is stored in the deployment database. | +| `HARNESS_RUNNER_STEP_LIMIT` | `run` | Whole-loop model-call ceiling (`--runner-step-limit`, default 80). An emergency control, not a call-minimisation target. | +| `HARNESS_RUNNER_COMMAND_TIMEOUT` | `run` | Timeout in seconds for one feedback command inside the loop (`--runner-command-timeout`, default 300). | | `HARNESS_CONTEXT_BUDGET` | `run` | The most characters of repository the implementer may be shown (`--context-budget`, default 60000). A file bigger than this cannot be supplied at all — see [§6a.1](#6a1-when-the-target-does-not-fit-in-the-prompt). A ceiling, not a target. | | `HARNESS_CONTEXT_FALLBACK_BUDGET` | `run` | How much of that the *surroundings* — files the planner did not name — may use (`--context-fallback-budget`, default 60000, never more than the budget). Raising the budget for one large target does not raise this. | | `HARNESS_ROOT_PATH` | `serve` | Prefix when behind a proxy, e.g. `/api/harness`. | diff --git a/docs/evidence/2026-08-06-stage-1-role-runner.md b/docs/evidence/2026-08-06-stage-1-role-runner.md new file mode 100644 index 0000000..a93d891 --- /dev/null +++ b/docs/evidence/2026-08-06-stage-1-role-runner.md @@ -0,0 +1,85 @@ +# Stage 1 evidence — generic role runner + +**Date:** 2026-08-06 +**Scope:** local fixture repositories only; no provider, GitHub, AIDevEnv, +session-host or CLI-agent process was contacted. + +This package records the exit evidence for Stage 1 in +[`docs/STATUS.md`](../STATUS.md). It is deliberately not a real-workload +acceptance record. Stage 2 (OS-enforced confinement) is still required before a +secret-bearing or real repository may be run. + +## What was exercised + +The installed `agent-loop` entry point was selected by name. The fixture +implementer made multiple tool calls, inspected the initial file, ran a +feedback check, edited a tracked file, and in a separate case created an +untracked file. The executor then captured the complete candidate tree and sent +it through the existing diff validation, authoritative checks, checkpoint, +review and attempt-record paths. A policy refusal was terminal. Step and +item-spend bounds were exercised, including a resumed item with an earlier +unpriced call; the latter keeps the spend total as a lower bound rather than +re-enabling a misleading dollar ceiling. + +Model events in the fixture carry `project_id`, `item_id` and `work_attempt`. +The implementation artefact records the runner name, call count and submission; +the runner-started event and doctor output record its implementation version. +The queue records priced and unpriced calls and cumulative spend; the CLI event +fan-out also writes the same attributed events to its append-only audit sink. +Doctor and preflight report or refuse the selected metadata entry point. + +## Commands and results + +All commands were run from the repository root with the repository's required +fast temporary directory: + +```console +TMPDIR=/home/sprooty/Working/Active/apps/.agent-harness-tmp.8pjfQd \ + uv run pytest -q tests/test_role_runner_e2e.py tests/test_role_runners.py \ + tests/test_agent_loop_e2e.py tests/test_preflight.py tests/test_generic.py +89 passed + +uv run ruff check . +All checks passed! + +uv run ruff format --check . +all files already formatted + +TMPDIR=/home/sprooty/Working/Active/apps/.agent-harness-tmp.8pjfQd uv run mypy +Success: no issues found in 121 source files +``` + +The full repository suite was rerun after this package was written: + +```console +TMPDIR=/home/sprooty/Working/Active/apps/.agent-harness-tmp.8pjfQd uv run pytest +1509 passed, 1 skipped in 448.59s (0:07:28) +``` + +The working tree was then checked with the repository-wide lint, format and +strict typing gates shown above. The focused command collected 89 tests; the +full-suite count is the regression denominator. + +## Acceptance table + +| Criterion | Result | Test/evidence | +|---|---|---| +| Generic contract and installed-metadata lookup | pass | `tests/test_role_runners.py`, `tests/test_generic.py` | +| Multi-turn inspect/edit/test through the executor | pass | `test_loop_changes_feed_the_existing_checks_review_and_attempt_pipeline` | +| New files and local candidate tree are retained | pass | `test_a_new_file_created_by_the_loop_reaches_the_authoritative_pipeline` | +| Feedback checks do not replace authoritative checks | pass | fixture check runs before edit, after edit, and in the harness gate | +| Project/item/attempt call attribution | pass | item-scoped model event assertions in the e2e test | +| Terminal policy refusal | pass | `test_a_policy_refusal_is_terminal_in_the_harness_path` | +| Whole-loop step and item-spend bounds | pass | the two bound tests in `tests/test_role_runner_e2e.py` | +| Unknown pricing remains a lower bound across attempts | pass | unpriced-loop tests in `tests/test_agent_loop_e2e.py` and role-runner e2e | +| Direct/session paths remain available | pass | full repository suite | +| OS-enforced confinement and fleet concurrency | pending | Stage 2 and Stage 3; not authorised by this evidence | + +## Boundary + +This is local scripted evidence of the Stage 1 seam, not delivery evidence. No +real item was merged or pushed, no pull request was opened, and no claim is made +about delivery rate, cost per merged item, unattended reliability or portability +to a second repository. The next implementation block is Stage 2: replace the +adapter's inherited controller shell with an OS-enforced execution boundary and +test it before any real workload run. diff --git a/pyproject.toml b/pyproject.toml index d40ffcd..1d11a50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,12 @@ claw-bay = "agent_harness.adapters.claw_bay:PRESET" [project.entry-points."agent_harness.dependency_resolvers"] github-issue = "agent_harness.adapters.github_issue:resolver" +# Repository-aware role loops, selected by name on the same terms as route +# presets and dependency resolvers. Core owns the contract and lookup; only +# installed metadata knows which adapter supplies a selected implementation. +[project.entry-points."agent_harness.role_runners"] +agent-loop = "agent_harness.adapters.minisweagent:RUNNER" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/agent_harness/__main__.py b/src/agent_harness/__main__.py index 21ebb23..4c096ac 100644 --- a/src/agent_harness/__main__.py +++ b/src/agent_harness/__main__.py @@ -573,6 +573,22 @@ def _run(args: argparse.Namespace) -> int: # that varies per terminal. `agent-harness guard` writes it. guard = CommandGuard.from_settings(queue.get_setting(GUARD_KEY)) checks = Checks(commands=[shlex.split(c) for c in args.check], guard=guard) + role_runner = None + runner_name = str(args.role_runner or queue.get_setting("role_runner") or "").strip() + if runner_name: + from .role_runners import describe, resolve + + try: + role_runner = resolve(runner_name) + runner_detail = describe(role_runner) + except Exception as exc: # noqa: BLE001 - configuration refusal + print(f"role runner: {exc}", file=sys.stderr) + return 2 + if args.role_runner and queue.get_setting("role_runner") != runner_name: + queue.set_setting("role_runner", runner_name) + print(f"role runner: {runner_detail}") + else: + print("role runner: direct single-shot implementer (historical path)") # This project's counts, not the rollup. `--project` decides which queue # this run works, so a cross-project total here would report items no # worker in this process can claim. @@ -839,6 +855,9 @@ def live_routes() -> dict[str, Chain]: # from `default` — which is nobody's project once more than one # exists, and reports "nothing to do" over a full queue. project_id=args.project, + role_runner=role_runner, + runner_step_limit=args.runner_step_limit, + runner_command_timeout=args.runner_command_timeout, ) # Typing `agent-harness run` IS the human deciding to start this project. # A project starts `stopped` so a restart never resumes on its own, but @@ -1465,6 +1484,28 @@ def main(argv: list[str] | None = None) -> int: "TERMINAL SESSION you can attach to, which is the point. Without it, the " "harness calls the model API directly and there is nothing to watch.", ) + p_run.add_argument( + "--role-runner", + default=os.environ.get("HARNESS_ROLE_RUNNER", ""), + metavar="NAME", + help="installed role runner for implementation (or $HARNESS_ROLE_RUNNER). " + "Resolved by name through installed metadata; empty keeps the historical " + "single-shot implementer while the loop path earns delivery evidence.", + ) + p_run.add_argument( + "--runner-step-limit", + type=int, + default=int(os.environ.get("HARNESS_RUNNER_STEP_LIMIT", "") or 80), + metavar="N", + help="whole-loop model-call ceiling (default 80, or $HARNESS_RUNNER_STEP_LIMIT).", + ) + p_run.add_argument( + "--runner-command-timeout", + type=int, + default=int(os.environ.get("HARNESS_RUNNER_COMMAND_TIMEOUT", "") or 300), + metavar="SECONDS", + help="timeout for one feedback command inside the role loop (default 300).", + ) p_run.add_argument( "--agent", # The executor's default, not a second one. They disagreed: the diff --git a/src/agent_harness/adapters/minisweagent.py b/src/agent_harness/adapters/minisweagent.py index bfaff3d..7a71f74 100644 --- a/src/agent_harness/adapters/minisweagent.py +++ b/src/agent_harness/adapters/minisweagent.py @@ -56,6 +56,7 @@ from ..budgets import Budget, Spend from ..guard import CommandGuard, CommandRefused, Refusal from ..model_client import ModelClient +from ..role_runners import API_VERSION, RoleRunRequest, RoleRunResult #: The role this loop's calls are billed and routed under. The same name the #: direct executor uses, so a deployment does not have to configure a second @@ -578,10 +579,9 @@ class HarnessModel: client: ModelClient role: str = IMPLEMENTER #: What the loop reports it has spent, and what its own `cost_limit` reads. - #: It stayed at 0.0 for the life of a run, so the loop's spend ceiling -- - #: theirs, defaulting to 3.0 -- could never fire and the only bound on a - #: run was the step count. `ModelClient` still owns real pricing and the - #: audit; this is that number, folded in per call. + #: The harness supplies that limit explicitly (zero when the item is + #: unlimited), while `ModelClient` owns real pricing and the audit; this is + #: that number, folded in per call. cost: float = 0.0 #: Priced and unpriced calls, kept apart. **Unknown cost is not zero cost** #: (`budgets.py`): while `unpriced` is non-zero, `cost` is a LOWER BOUND, @@ -600,6 +600,10 @@ class HarnessModel: observation_template: str = field( default_factory=lambda: _bundled_model("observation_template") ) + #: Per-call accounting owned by the caller. Reporting only when the loop + #: returns loses every call made before a policy refusal or provider + #: exception, which is exactly when an item most needs an honest total. + on_usage: Callable[[Spend], None] | None = None def query(self, messages: list[dict[str, str]], **kwargs: Any) -> dict[str, Any]: """One turn, as a tool call. @@ -659,13 +663,15 @@ def _bill(self, body: Any) -> float: which is the honest shape: the loop's ceiling is then a lower bound and `measurable` says so, rather than a zero pretending to be a price. """ - before = self.spend.usd + call = Spend() try: - self.spend.add_call(self.client.usage_for(self.role, body)) + call.add_call(self.client.usage_for(self.role, body)) except Exception: # pragma: no cover - a reader that cannot read - self.spend.unpriced += 1 - return 0.0 - charged = self.spend.usd - before + call.unpriced += 1 + self.spend.add(call) + if self.on_usage is not None: + self.on_usage(call) + charged = call.usd self.cost += charged return charged @@ -753,6 +759,14 @@ class HarnessEnvironment: #: said so. A deployment passes an event writer here; nothing is imported #: to reach one, because core must not learn this adapter's name. on_refusal: Callable[[str, Refusal], None] | None = None + #: The standalone experiment lets a loop correct a refused command. The + #: harness execution path does not: AGENTS.md rules a policy refusal + #: terminal, so its runner enables this and the exception reaches the + #: executor's existing blocked-by-policy handler. + terminal_refusals: bool = False + #: Item-scoped command progress. The callback owns persistence; this + #: adapter supplies only the command and its eventual return code. + on_command: Callable[[str, int | None], None] | None = None config: Any = None def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: @@ -766,6 +780,9 @@ def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: except CommandRefused as refused: return self._refuse(str(command), refused) + if self.on_command is not None: + self.on_command(str(command), None) + try: result = subprocess.run( # noqa: S602 - screened above, agent-supplied by design str(command), @@ -783,7 +800,7 @@ def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: # instead, in the shape it reads every other result in, and can # run something cheaper. partial = _text(expired.stdout) + _text(expired.stderr) - return { + timed_out = { "output": _bounded(partial), "returncode": TIMED_OUT, "exception_info": ( @@ -791,8 +808,13 @@ def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: "deployment's command timeout; any output above is partial" ), } + if self.on_command is not None: + self.on_command(str(command), TIMED_OUT) + return timed_out full = result.stdout + result.stderr + if self.on_command is not None: + self.on_command(str(command), result.returncode) # Checked on the WHOLE output, then truncated for the model. The marker # has to be the first line, and keeping the last 32k of a command that # printed it and then a lot else threw it away -- an agent that had @@ -822,6 +844,8 @@ def _refuse(self, command: str, refused: CommandRefused) -> dict[str, Any]: self.refused.append(refused.refusal) if self.on_refusal is not None: self.on_refusal(command, refused.refusal) + if self.terminal_refusals: + raise refused return { "output": ( f"REFUSED by this deployment's command policy: {refused}\n" @@ -910,6 +934,9 @@ def build( budget: Budget | None = None, timeout: int = 300, on_refusal: Callable[[str, Refusal], None] | None = None, + terminal_refusals: bool = False, + on_command: Callable[[str, int | None], None] | None = None, + on_usage: Callable[[Spend], None] | None = None, ) -> Any: """A loop wired to this harness's client, guard and budget. @@ -928,22 +955,141 @@ def build( (`HarnessModel.spend`), so it can fail to fire and cannot fire early. """ agent_class = _require() + + class BudgetAwareAgent(agent_class): # type: ignore[misc, valid-type] + """Keep an unknown-cost loop from enforcing a known-cost subtotal.""" + + def query(self) -> dict[str, Any]: + # mini-swe-agent compares its numeric ``cost`` with ``cost_limit``. + # Once one call is unpriced that number is only a lower bound, and + # budgets.py forbids stopping an item on a number nobody can + # defend. Disable only the dollar limit; step and wall-clock + # limits remain emergency controls, and the harness reports the + # unenforceable spend ceiling at its next boundary. + if self.model.spend.unpriced: + self.config.cost_limit = 0.0 + return super().query() # type: ignore[no-any-return] + prompts = bundled()["agent"] limits: dict[str, Any] = {} if budget is not None and budget.seconds: - limits["wall_time_limit_seconds"] = int(budget.seconds) - if budget is not None and budget.spend_usd: - limits["cost_limit"] = float(budget.spend_usd) - return agent_class( - HarnessModel(client=client, role=role), + # Zero means unlimited to the loop, so a positive sub-second remainder + # must round up rather than silently remove the ceiling. + import math + + limits["wall_time_limit_seconds"] = max(1, math.ceil(budget.seconds)) + # mini-swe-agent's own default is a finite dollar limit. The harness owns + # the item budget, whose default is unlimited, so never let an adapter + # default silently become a second ceiling. + limits["cost_limit"] = ( + float(budget.spend_usd) if budget is not None and budget.spend_usd else 0.0 + ) + return BudgetAwareAgent( + HarnessModel(client=client, role=role, on_usage=on_usage), HarnessEnvironment( repo=repo, guard=guard or CommandGuard(), timeout=timeout, on_refusal=on_refusal, + terminal_refusals=terminal_refusals, + on_command=on_command, ), system_template=prompts["system_template"], instance_template=prompts["instance_template"], step_limit=step_limit, **limits, ) + + +@dataclass(frozen=True) +class MiniSweRoleRunner: + """The installed adapter for core's generic role-runner contract.""" + + name: str = "agent-loop" + api_version: int = API_VERSION + + @property + def version(self) -> str: + from importlib.metadata import version + + return version("mini-swe-agent") + + def run(self, request: RoleRunRequest, /) -> RoleRunResult: + if not request.writable: + raise ValueError("this runner adapter does not yet provide a read-only environment") + + commands = 0 + + def command(line: str, returncode: int | None) -> None: + nonlocal commands + if returncode is None: + commands += 1 + if request.report is not None: + request.report( + "runner_command_started" if returncode is None else "runner_command_finished", + line, + {"command_index": commands, "returncode": returncode}, + ) + + def refused(line: str, refusal: Refusal) -> None: + if request.report is not None: + request.report("runner_command_refused", line, {"refusal": refusal.as_dict()}) + + if request.report is not None: + request.report( + "runner_started", + f"{self.name} {self.version} running {request.role}", + { + "role": request.role, + "step_limit": request.step_limit, + "budget": request.budget.as_dict(), + "writable": request.writable, + }, + ) + agent = build( + request.client, + request.repo, + guard=request.guard, + step_limit=request.step_limit, + role=request.role, + budget=request.budget, + timeout=request.command_timeout, + on_refusal=refused, + terminal_refusals=True, + on_command=command, + on_usage=request.account, + ) + raw = agent.run( + request.task, + project_id=request.project_id, + item_id=request.item_id, + attempt=request.attempt, + ) + exit_status = str(raw.get("exit_status") or "") + normalized = exit_status + if exit_status == "Submitted": + normalized = "completed" + elif exit_status == "TimeExceeded": + normalized = "wall_clock_limit" + elif exit_status == "LimitsExceeded": + cost_limit = float(getattr(agent.config, "cost_limit", 0.0) or 0.0) + spent_out = bool(cost_limit and agent.model.cost >= cost_limit) + normalized = "spend_limit" if spent_out else "step_limit" + elif not exit_status: + normalized = "failed" + result = RoleRunResult( + exit_status=normalized, + submission=str(raw.get("submission") or ""), + calls=int(agent.model.n_calls), + spend=agent.model.spend, + ) + if request.report is not None: + request.report( + "runner_finished", + normalized, + {"calls": result.calls, "commands": commands, "spend": result.spend.as_dict()}, + ) + return result + + +RUNNER = MiniSweRoleRunner() diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index 4bb8f12..cc95818 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -1994,6 +1994,11 @@ def _preflight( else None ), } + selected_runner = str(queue.get_setting("role_runner") or "") + if selected_runner: + from .role_runners import probe as runner_probe + + kwargs["role_runner"] = lambda: runner_probe(selected_runner) # Injected probes win, so a test can answer any of these without a # network, a subprocess or a model. kwargs.update(getattr(state, "probes", None) or {}) diff --git a/src/agent_harness/budgets.py b/src/agent_harness/budgets.py index 66f91a6..38c2890 100644 --- a/src/agent_harness/budgets.py +++ b/src/agent_harness/budgets.py @@ -154,6 +154,12 @@ def add_call(self, usage: Mapping[str, Any]) -> None: self.usd += cost self.priced += 1 + def add(self, other: Spend) -> None: + """Fold an already classified set of calls into this item.""" + self.usd += other.usd + self.unpriced += other.unpriced + self.priced += other.priced + def as_dict(self) -> dict[str, Any]: return { "usd": round(self.usd, 6), diff --git a/src/agent_harness/doctor.py b/src/agent_harness/doctor.py index 3db6310..ac8cad5 100644 --- a/src/agent_harness/doctor.py +++ b/src/agent_harness/doctor.py @@ -172,6 +172,22 @@ def _resolvers_finding() -> Finding: return Finding("dependency resolvers", OK, f"declared: {', '.join(names)}") +def _runner_finding(selected: str) -> Finding: + """The selected execution loop and whether its contract can load.""" + from .role_runners import names, probe + + if not selected: + available = ", ".join(names()) or "none" + return Finding( + "role runner", + WARN, + "none selected — implementation uses the historical single-shot path; " + f"installed runner names: {available}", + ) + ok, detail = probe(selected) + return Finding("role runner", OK if ok else FAIL, detail, blocking=not ok) + + def _redaction_finding() -> Finding: """What the write-boundary filter can and cannot promise. @@ -576,6 +592,7 @@ def diagnose(queue: Any, projects: list[Any], *, ask: Any = None) -> Report: # Deployment-wide, like the role map: the worktrees, the host and the # credentials on disk are shared by every project in one database. report.environment.append(_guard_finding(queue.get_setting(GUARD_KEY))) + report.environment.append(_runner_finding(str(queue.get_setting("role_runner") or ""))) stored = queue.get_setting(ROLE_MAP_KEY) or {} for project in projects: diff --git a/src/agent_harness/executor.py b/src/agent_harness/executor.py index cb74641..d12c994 100644 --- a/src/agent_harness/executor.py +++ b/src/agent_harness/executor.py @@ -81,6 +81,7 @@ Stop, stop_for, ) +from .role_runners import RoleRunner, RoleRunRequest, RoleRunResult from .work import ( BLOCKED, DEFAULT_PROJECT, @@ -209,6 +210,37 @@ def run_git(repo: Path, *args: str, check: bool = True) -> str: return result.stdout +def candidate_diff(repo: Path, base: str = "HEAD") -> str: + """The complete candidate tree as a patch against ``base``. + + ``git diff HEAD`` omits untracked files and also omits commits an agent + made while working. Both are ordinary loop outcomes, and losing either + here turns correct work into "completed without changing the repository". + + A temporary index lets git describe the worktree exactly as ``git add -A`` + followed by a cached diff would, without changing the repository's real + index. ``--binary`` keeps new or modified binary files representable by + the same downstream patch pipeline. + """ + with tempfile.TemporaryDirectory(prefix="agent-harness-index-") as directory: + environment = {**os.environ, "GIT_INDEX_FILE": str(Path(directory) / "index")} + + def indexed(*args: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run( # noqa: S603 - fixed argv, no shell + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + env=environment, + ) + if result.returncode != 0: + raise GitError(f"git {' '.join(args)}: {result.stderr.strip()}") + return result + + indexed("read-tree", "HEAD") + indexed("add", "-A") + return indexed("diff", "--cached", "--binary", base).stdout + + #: How much repository the implementer is shown. Big enough that a small #: project arrives whole, small enough not to dominate the prompt. #: @@ -2054,6 +2086,9 @@ def __init__( artifacts: Path | None = None, project_id: str = DEFAULT_PROJECT, durability: str | None = None, + role_runner: RoleRunner | None = None, + runner_step_limit: int = 80, + runner_command_timeout: int = 300, ) -> None: self.queue = queue #: How often this worker makes progress durable. None takes the @@ -2112,6 +2147,11 @@ def __init__( self._spend = Spend() self._budget_cache: Budget | None = None self._unenforceable_said: set[str] = set() + #: Optional and injected: direct mode remains available while the new + #: path earns delivery evidence. Core knows only this protocol. + self.role_runner = role_runner + self.runner_step_limit = runner_step_limit + self.runner_command_timeout = runner_command_timeout # ------------------------------------------------------------- driving @@ -2127,7 +2167,17 @@ def _execute_with_heartbeat(self, record: WorkRecord) -> Outcome: ) self._heartbeat = heartbeat try: - with heartbeat: + # Every call in every role gets the work identity. The client + # still owns request-attempt identity; this scope supplies the + # project/item/attempt no transport can infer. + with ( + heartbeat, + self.client.event_scope( + project_id=self.project_id, + item_id=record.item_id, + work_attempt=record.attempts, + ), + ): return self._execute(record) finally: self._heartbeat = None @@ -2428,6 +2478,34 @@ def _budget(self, record: WorkRecord) -> Budget: self._budget_cache = budget_for(self.queue.get_project(self.project_id), record) return self._budget_cache + def _remaining_runner_budget(self, record: WorkRecord) -> Budget: + """Translate the whole-item ceiling to what this loop may consume. + + Item spend and time accumulate across attempts. Passing the configured + total to every loop would let one item spend it once per attempt. + """ + budget = self._budget(record) + started = record.first_started_at + elapsed = max(0.0, self.now() - started) if started is not None else 0.0 + seconds = max(0.0, budget.seconds - elapsed) if budget.seconds else 0.0 + spent = record.spend_usd + self._spend.usd + # A prior unpriced call makes the item's total a lower bound across + # attempts. Do not hand a fresh loop a dollar ceiling it could enforce + # against only its known subtotal; the next boundary will report the + # ceiling as unenforceable, as budgets.py requires. + unknown_spend = bool(record.unpriced_calls or self._spend.unpriced) + spend = ( + max(0.0, budget.spend_usd - spent) if budget.spend_usd and not unknown_spend else 0.0 + ) + # In the public Budget type zero means unlimited. Here a zero remainder + # under a configured total means the opposite, so preserve a positive + # sentinel and let the loop stop at its next call boundary. + if budget.seconds and not seconds: + seconds = 1e-9 + if budget.spend_usd and not spend and not unknown_spend: + spend = 1e-12 + return Budget(seconds=seconds, spend_usd=spend) + def _budget_stop(self, record: WorkRecord) -> None: """Refuse to go past a ceiling this item declared. @@ -2440,7 +2518,12 @@ def _budget_stop(self, record: WorkRecord) -> None: if not budget.bounded: return started = record.first_started_at or self.now() - verdict = budget_check(budget, elapsed=self.now() - started, spend=self._spend) + spend = Spend( + usd=record.spend_usd + self._spend.usd, + unpriced=record.unpriced_calls + self._spend.unpriced, + priced=self._spend.priced, + ) + verdict = budget_check(budget, elapsed=self.now() - started, spend=spend) for ceiling, why in verdict.unenforceable: # Said once per stop, and said as a fact rather than a warning # nobody reads: a ceiling that cannot be checked is not a ceiling @@ -2632,6 +2715,11 @@ def _execute(self, record: WorkRecord) -> Outcome: record, outcome, planner, stored, base, stacked_on, resume, log, attempt, mode ) + if self.role_runner is not None: + return self._run_implementer( + record, outcome, base, stacked_on, resume, log, attempt, mode + ) + if planner.cannot_identify_target and self.ask_when_uncertain: # The planner has said, in as many words, that the task is # ambiguous, contradicts the codebase, or depends on something @@ -2759,6 +2847,170 @@ def _execute(self, record: WorkRecord) -> Outcome: record, outcome, planner, diff, base, stacked_on, resume, log, attempt, mode ) + def _run_implementer( + self, + record: WorkRecord, + outcome: Outcome, + base: str, + stacked_on: str | None, + resume: A.Resume, + log: A.AttemptLog, + attempt: int, + mode: str, + ) -> Outcome: + """Run implementation as a loop, then rejoin the existing gates.""" + runner = self.role_runner + assert runner is not None + branch = f"{self.branch_prefix}{record.item_id.lower()}" + outcome.branch = branch + outcome.base = base + self._prepare_branch(branch, base) + if stacked_on: + self._emit(record, "stacked", detail=f"based on {base} ({stacked_on})") + + self._budget_stop(record) + + def report(stage: str, detail: str, evidence: Mapping[str, Any]) -> None: + self._emit(record, stage, detail=detail, evidence=evidence) + + self._emit(record, "calling", detail=f"{IMPLEMENTER} through {runner.name}") + try: + with self.client.event_scope( + project_id=self.project_id, + item_id=record.item_id, + work_attempt=attempt, + ): + result = runner.run( + RoleRunRequest( + role=IMPLEMENTER, + task=self._runner_task(record), + repo=self.repo, + project_id=self.project_id, + item_id=record.item_id, + attempt=attempt, + client=self.client, + guard=self.checks.guard, + budget=self._remaining_runner_budget(record), + step_limit=self.runner_step_limit, + command_timeout=self.runner_command_timeout, + writable=True, + report=report, + account=self._spend.add, + ) + ) + except Exception: + self._abandon_branch(branch) + outcome.branch = None + raise + outcome.stages.append("implement") + + if result.exit_status == "wall_clock_limit": + self._abandon_branch(branch) + outcome.branch = None + raise self._runner_budget_exceeded(record, result, BUDGET_WALL_CLOCK) + if result.exit_status == "spend_limit": + self._abandon_branch(branch) + outcome.branch = None + raise self._runner_budget_exceeded(record, result, BUDGET_SPEND) + if result.exit_status != "completed": + outcome.reason = ( + f"role runner {runner.name} stopped with {result.exit_status} " + f"after {result.calls} model call(s)" + ) + self._emit( + record, + "runner_incomplete", + detail=outcome.reason, + evidence={"submission": result.submission[:4000]}, + ) + outcome.stop = Stop(CRASHED, WORKER_ERROR, detail=outcome.reason) + self._abandon_branch(branch) + outcome.branch = None + return outcome + + # Against the item base, not merely the current HEAD: a tool-using + # agent may create untracked files or make local commits, and both are + # part of the candidate the gates must judge. + diff = candidate_diff(self.repo, base) + if not diff: + outcome.reason = "the role runner completed without changing the repository" + self._emit(record, "no_diff", detail=outcome.reason) + outcome.stop = Stop(REFUSED, NO_TARGET, detail=outcome.reason) + self._abandon_branch(branch) + outcome.branch = None + return outcome + log.record( + self.project_id, + record.item_id, + attempt, + A.IMPLEMENTED, + { + "diff": diff, + "runner": runner.name, + "calls": result.calls, + "submission": result.submission[:4000], + }, + admitted_revision=record.admitted_revision, + mode=mode, + ) + # `_from_diff` stays the sole downstream path. It cuts this branch + # afresh and applies the observed diff, so the validator, authoritative + # checks, checkpoint and reviewer all see the loop's exact candidate. + return self._from_diff( + record, + outcome, + PlannerResult(plan="role runner inspected the repository directly"), + diff, + base, + stacked_on, + resume, + log, + attempt, + mode, + ) + + def _runner_task(self, record: WorkRecord) -> str: + checks = [" ".join(command) for command in self.checks.commands if command] + parts = [ + record.brief.strip(), + "Work in the repository directly. Inspect what you need, make the change, " + "and use the project's checks for feedback before submitting.", + ] + if checks: + parts.append( + "Declared checks (feedback only; the harness reruns them as gates):\n" + + "\n".join(checks) + ) + prior = self._prior_failure_prompt(record).strip() + guidance = self._guidance(record).strip() + if prior: + parts.append(prior) + if guidance: + parts.append(guidance) + return "\n\n".join(parts) + + def _runner_budget_exceeded( + self, record: WorkRecord, result: RoleRunResult, ceiling: str + ) -> BudgetExceeded: + budget = self._budget(record) + if ceiling == BUDGET_WALL_CLOCK: + observed = self.now() - (record.first_started_at or self.now()) + return BudgetExceeded( + ceiling, + budget.seconds, + observed, + f"the role runner reached this item's {budget.seconds:g}s wall-clock ceiling " + f"after {result.calls} model call(s)", + ) + observed = record.spend_usd + self._spend.usd + return BudgetExceeded( + ceiling, + budget.spend_usd, + observed, + f"the role runner reached this item's {budget.spend_usd:g} spend ceiling " + f"after {result.calls} model call(s); recorded spend is {observed:.4f}", + ) + def _changes_from(self, record: WorkRecord, outcome: Outcome, reply: str) -> str | None: """The diff this reply describes, however the model chose to say it. @@ -2923,7 +3175,7 @@ def _from_diff( # reviewer has to see the second one: reviewing the first rejects good # work for an artefact of the plumbing, and, worse, makes the gate # structurally unable to catch a diff that claims more than it did. - applied_diff = run_git(self.repo, "diff", "HEAD") or diff + applied_diff = candidate_diff(self.repo) or diff self._keepalive(record) # 5. Cheap checks BEFORE the expensive reviewer call. Paying a model @@ -2972,7 +3224,7 @@ def _from_diff( # harness ran a declared fix over it. Re-reading it is what makes # the reviewer's copy the truth — and what stops the commit below # from containing lines the reviewer was never shown. - applied_diff = run_git(self.repo, "diff", "HEAD") or applied_diff + applied_diff = candidate_diff(self.repo) or applied_diff self._emit(record, "checks_passed") # Recorded, though it makes resumption no cheaper: re-running a # project's checks is idempotent and costs no model call, so a resumed @@ -3486,6 +3738,7 @@ def _emit( "error_class": error_class, "detail": detail, "project_id": self.project_id, + **({"evidence": dict(evidence)} if evidence else {}), } ) self._say(record, stage, detail, evidence or {}) diff --git a/src/agent_harness/model_client.py b/src/agent_harness/model_client.py index f886737..788272f 100644 --- a/src/agent_harness/model_client.py +++ b/src/agent_harness/model_client.py @@ -39,12 +39,13 @@ from __future__ import annotations import contextlib +import contextvars import itertools import logging import random import time import uuid -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass, field from typing import Any @@ -660,6 +661,27 @@ def __init__( # not. run_id changes per process, seq is monotonic within it. self.run_id = run_id or uuid.uuid4().hex[:12] self._seq = itertools.count() + # Item identity is context-local rather than an attribute mutated by a + # worker. One ModelClient is shared by a fleet, and two workers making + # calls concurrently must not stamp each other's project or attempt on + # the event stream. + self._event_context: contextvars.ContextVar[Mapping[str, Any] | None] = ( + contextvars.ContextVar(f"model_event_context_{id(self)}", default=None) + ) + + @contextlib.contextmanager + def event_scope(self, **identity: Any) -> Iterator[None]: + """Attach caller identity to every model event in this context. + + The model client owns request-attempt identity; the executor owns the + project, item and work-attempt identity. Context variables keep the + two composable and safe across worker threads. + """ + token = self._event_context.set({**(self._event_context.get() or {}), **identity}) + try: + yield + finally: + self._event_context.reset(token) def reviewer_independence(self, implemented_by: str = "") -> tuple[bool, str]: """Whether this client's reviewer is independent of the implementer. @@ -1019,6 +1041,7 @@ def _emit( with contextlib.suppress(Exception): self.on_event( { + **(self._event_context.get() or {}), "run_id": self.run_id, "seq": next(self._seq), "ts": self.now(), diff --git a/src/agent_harness/preflight.py b/src/agent_harness/preflight.py index 07312fe..6189f3b 100644 --- a/src/agent_harness/preflight.py +++ b/src/agent_harness/preflight.py @@ -621,6 +621,7 @@ def preflight_project( reviewer_independent: tuple[bool, str] | None = None, role_probe: Callable[[], RoleReachability] | None = None, session_host: Probe | None = None, + role_runner: Probe | None = None, git_probe: Callable[[str], tuple[bool, str]] = _is_git_repo, github_probe: Callable[[str], tuple[bool, str]] = _gh_can_write, checks_probe: Probe | None = None, @@ -662,6 +663,10 @@ def preflight_project( ) ) + if role_runner is not None: + ok, detail = role_runner() + checks.append(Check("role runner", ok, detail)) + work_dir = getattr(project, "work_dir", None) if work_dir: ok, detail = git_probe(work_dir) diff --git a/src/agent_harness/role_runners.py b/src/agent_harness/role_runners.py new file mode 100644 index 0000000..9445f3f --- /dev/null +++ b/src/agent_harness/role_runners.py @@ -0,0 +1,167 @@ +"""Generic, metadata-resolved runners for repository-aware model roles. + +A role runner answers one bounded question by letting a routed model inspect +an environment over several turns. Core owns this contract and the lookup; +the implementation belongs to an adapter. The separation is load-bearing: +adding a runner must not add an adapter import (or even an adapter's dotted +module path) to the execution path. + +Runners are selected by name through the ``agent_harness.role_runners`` entry +point group. Reading the available names imports nothing. Resolving one +loads only the module that declared that name and checks the contract version +before any item is claimed. +""" + +from __future__ import annotations + +import importlib +import logging +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Protocol + +from .budgets import Budget, Spend +from .guard import CommandGuard +from .model_client import ModelClient + +log = logging.getLogger(__name__) + +ENTRY_POINT_GROUP = "agent_harness.role_runners" +SETTING_KEY = "role_runner" +API_VERSION = 1 + + +class RoleRunnerError(RuntimeError): + """A named runner cannot safely be used by this build.""" + + +class UnknownRoleRunner(RoleRunnerError): + """No installed distribution declared the selected runner name.""" + + +class IncompatibleRoleRunner(RoleRunnerError): + """The runner implements a different version of the core contract.""" + + +Report = Callable[[str, str, Mapping[str, Any]], None] +Account = Callable[[Spend], None] + + +@dataclass(frozen=True) +class RoleRunRequest: + """Everything one role loop is allowed to depend on, supplied explicitly.""" + + role: str + task: str + repo: Path + project_id: str + item_id: str + attempt: int + client: ModelClient + guard: CommandGuard + budget: Budget = field(default_factory=Budget) + step_limit: int = 80 + command_timeout: int = 300 + writable: bool = True + report: Report | None = None + account: Account | None = None + + +@dataclass(frozen=True) +class RoleRunResult: + """The loop's terminal answer and the usage the item must account for.""" + + exit_status: str + submission: str = "" + calls: int = 0 + spend: Spend = field(default_factory=Spend) + + +class RoleRunner(Protocol): + """One installed implementation of the repository-aware role loop.""" + + @property + def name(self) -> str: ... + + @property + def api_version(self) -> int: ... + + @property + def version(self) -> str: ... + + def run(self, request: RoleRunRequest, /) -> RoleRunResult: ... + + +def _declared_targets() -> dict[str, str]: + """Return ``name -> module:attribute`` without importing a runner.""" + from importlib.metadata import entry_points + + try: + return {point.name: point.value for point in entry_points(group=ENTRY_POINT_GROUP)} + except Exception: # noqa: BLE001 - broken metadata is a named readiness failure + log.warning("could not read %s entry points", ENTRY_POINT_GROUP, exc_info=True) + return {} + + +def names() -> list[str]: + """Every installed runner name, without loading any declaring module.""" + return sorted(_declared_targets()) + + +def _load_target(name: str, target: str) -> RoleRunner: + module_name, _, attribute = target.partition(":") + module = importlib.import_module(module_name) + found: Any = getattr(module, attribute) if attribute else module + if callable(found) and not hasattr(found, "run"): + found = found() + if not callable(getattr(found, "run", None)): + raise TypeError(f"{target!r} does not provide run(request)") + declared = str(getattr(found, "name", "")) + if declared and declared != name: + log.info("runner %r declares name %r; resolving it as %r", target, declared, name) + version = getattr(found, "api_version", None) + if version != API_VERSION: + raise IncompatibleRoleRunner( + f"role runner {name!r} uses contract version {version!r}; " + f"this harness requires {API_VERSION}" + ) + return found # type: ignore[no-any-return] + + +def resolve(name: str) -> RoleRunner: + """Load the one selected runner, failing before work is claimed.""" + target = _declared_targets().get(name) + if target is None: + available = ", ".join(names()) or "none" + raise UnknownRoleRunner( + f"unknown role runner {name!r}; installed names: {available} " + f"(entry-point group {ENTRY_POINT_GROUP})" + ) + try: + return _load_target(name, target) + except RoleRunnerError: + raise + except Exception as exc: + raise RoleRunnerError( + f"role runner {name!r} could not load from {target!r}: {exc}" + ) from exc + + +def describe(runner: RoleRunner) -> str: + """A stable readiness sentence naming both sides of the contract.""" + version = str(getattr(runner, "version", "unknown")) + return ( + f"{runner.name} {version}; role-runner contract " + f"{runner.api_version}/{API_VERSION} compatible" + ) + + +def probe(name: str) -> tuple[bool, str]: + """Resolve a configured runner without calling a model or making a tree.""" + try: + runner = resolve(name) + detail = describe(runner) + except Exception as exc: # noqa: BLE001 - a readiness answer, never a crash + return (False, str(exc)) + return (True, detail) diff --git a/tests/test_agent_loop_e2e.py b/tests/test_agent_loop_e2e.py index 3f4e66f..e84b4d5 100644 --- a/tests/test_agent_loop_e2e.py +++ b/tests/test_agent_loop_e2e.py @@ -701,6 +701,55 @@ def test_an_unpriced_call_is_not_a_free_one(repo: Path) -> None: assert agent.model.serialize()["cost_measurable"] is False +def test_one_unpriced_call_makes_the_loop_s_dollar_ceiling_unenforceable(repo: Path) -> None: + """A known subtotal is not an enforceable total after one unknown call.""" + replies = iter(("echo first", "echo second", DONE)) + calls = 0 + + def transport(route: Route, messages: Any, options: Any) -> Response: + nonlocal calls + del route, messages, options + calls += 1 + command = next(replies) + payload: dict[str, Any] = { + "choices": [ + { + "message": { + "role": "assistant", + "content": "working", + "tool_calls": [ + { + "id": f"call_{calls}", + "type": "function", + "function": { + "name": "bash", + "arguments": json.dumps({"command": command}), + }, + } + ], + } + } + ] + } + if calls > 1: + payload["usage"] = {"prompt_tokens": 1_000_000, "completion_tokens": 1_000_000} + return Response(200, {}, json.dumps(payload)) + + client = ModelClient( + roles={"implementer": Route("scripted", "https://e.example")}, + transport=transport, + prices=PriceTable(version="test", prices={"scripted": Price(1.0, 1.0)}), + ) + agent = build(client, repo, step_limit=5, budget=Budget(spend_usd=1.0)) + + result = agent.run("Finish despite an unenforceable dollar total.") + + assert result.get("exit_status") == "Submitted" + assert calls == 3 + assert agent.model.spend.unpriced == 1 + assert agent.model.spend.usd == pytest.approx(4.0) + + def test_a_wall_clock_budget_reaches_the_loop(repo: Path) -> None: """The item's ceilings were never handed to the thing that runs long. @@ -715,8 +764,10 @@ def test_a_wall_clock_budget_reaches_the_loop(repo: Path) -> None: assert agent.config.cost_limit == 2.5 other, _ = scripted(DONE) - assert build(other, repo).config.wall_time_limit_seconds == 0, ( - "a budget nobody set is not a ceiling" + unlimited = build(other, repo) + assert unlimited.config.wall_time_limit_seconds == 0, "a budget nobody set is not a ceiling" + assert unlimited.config.cost_limit == 0.0, ( + "the loop library's finite default must not override an unlimited item budget" ) diff --git a/tests/test_generic.py b/tests/test_generic.py index 81f941c..5f85a3d 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -21,6 +21,7 @@ "work.py", "graph.py", "fleet.py", + "role_runners.py", "session_executor.py", "executor.py", # The refusal list is on the path an item passes through, and a refusal @@ -153,3 +154,13 @@ def test_the_shipped_dependency_resolvers_are_declared_the_same_way() -> None: declared = manifest["project"]["entry-points"]["agent_harness.dependency_resolvers"] assert set(declared) == {"github-issue"} assert all(value.startswith("agent_harness.adapters.") for value in declared.values()) + + +def test_the_shipped_role_runners_are_declared_the_same_way() -> None: + """A runner is an adapter even when it is the primary execution path.""" + import tomllib + + manifest = tomllib.loads((SRC.parents[1] / "pyproject.toml").read_text()) + declared = manifest["project"]["entry-points"]["agent_harness.role_runners"] + assert set(declared) == {"agent-loop"} + assert all(value.startswith("agent_harness.adapters.") for value in declared.values()) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 505d2cf..0785995 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -68,6 +68,21 @@ def test_a_fully_configured_project_is_ready() -> None: assert report.summary() == "ready" +def test_a_selected_runner_is_a_required_preflight_check() -> None: + report = run(project(), role_runner=lambda: (True, "agent-loop 1.0 compatible")) + + check = next(item for item in report.checks if item.name == "role runner") + assert check.ok and check.blocking + + +def test_an_unavailable_selected_runner_blocks_preflight() -> None: + report = run(project(), role_runner=lambda: (False, "agent-loop is unavailable")) + + assert not report.ready + check = next(item for item in report.blockers if item.name == "role runner") + assert "unavailable" in check.detail + + def test_no_worker_pool_blocks() -> None: """The false-running state, refused at source. Without a pool, starting can only set a flag nobody acts on.""" diff --git a/tests/test_role_runner_e2e.py b/tests/test_role_runner_e2e.py new file mode 100644 index 0000000..e441994 --- /dev/null +++ b/tests/test_role_runner_e2e.py @@ -0,0 +1,490 @@ +"""Stage 1: a multi-turn implementer through the real executor and gates.""" + +from __future__ import annotations + +import json +import subprocess +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +import pytest + +from agent_harness import __main__ as cli +from agent_harness.adapters.minisweagent import RUNNER +from agent_harness.audit import AuditStore +from agent_harness.events import KINDS, MODEL_CALL, Event +from agent_harness.executor import Checks, Executor +from agent_harness.model_client import ModelClient, Response, Route +from agent_harness.pricing import Price, PriceTable +from agent_harness.work import BLOCKED, DONE, FAILED, Project, WorkRecord +from conftest import make_queue + +DONE_COMMAND = "echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" +APPROVED = "APPROVED\nVerified the requested change.\n\n4. Follow-ups\n- none" + + +def git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=True + ).stdout + + +def tool_reply(command: str, call: int) -> str: + return json.dumps( + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "working", + "tool_calls": [ + { + "id": f"call_{call}", + "type": "function", + "function": { + "name": "bash", + "arguments": json.dumps({"command": command}), + }, + } + ], + } + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1}, + } + ) + + +class ScriptedLoop: + def __init__( + self, + commands: Sequence[str], + *, + tokens: int = 0, + usage_roles: Sequence[str] | None = None, + ) -> None: + self.commands = iter(commands) + self.tokens = tokens + self.usage_roles = set(usage_roles) if usage_roles is not None else None + self.calls = 0 + self.implementer_messages: list[list[Mapping[str, Any]]] = [] + + def __call__( + self, + route: Route, + messages: Sequence[Mapping[str, Any]], + options: Mapping[str, Any], + ) -> Response: + del options + role = str(route.options.get("role") or route.model) + if role == "planner": + body = json.dumps( + { + "choices": [ + { + "message": { + "content": json.dumps( + { + "plan": "change the greeting", + "targets": [ + {"path": "greeting.txt", "reason": "requested file"} + ], + "cannot_identify_target": None, + } + ) + } + } + ] + } + ) + elif role == "reviewer": + body = json.dumps({"choices": [{"message": {"content": APPROVED}}]}) + else: + self.calls += 1 + self.implementer_messages.append(list(messages)) + body = tool_reply(next(self.commands), self.calls) + if self.tokens and (self.usage_roles is None or role in self.usage_roles): + payload = json.loads(body) + payload["usage"] = { + "prompt_tokens": self.tokens, + "completion_tokens": self.tokens, + } + body = json.dumps(payload) + return Response(200, {}, body) + + +def repository(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-q", "-b", "main") + git(repo, "config", "user.email", "test@example.invalid") + git(repo, "config", "user.name", "Test") + (repo / "greeting.txt").write_text("hello world\n") + check = repo / "check.sh" + check.write_text( + "\n".join( + ( + "#!/bin/sh", + "echo called >> check-runs.txt", + "grep -q '^hello harness$' greeting.txt", + "", + ) + ) + ) + check.chmod(0o755) + (repo / ".gitignore").write_text("check-runs.txt\n") + git(repo, "add", "-A") + git(repo, "commit", "-qm", "initial") + return repo + + +def test_loop_changes_feed_the_existing_checks_review_and_attempt_pipeline( + tmp_path: Path, +) -> None: + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add( + [ + WorkRecord( + item_id="T1", + title="Change the greeting", + brief="Make greeting.txt say hello harness.", + ) + ] + ) + transport = ScriptedLoop( + ( + "cat greeting.txt", + "./check.sh || true", + "printf 'hello harness\\n' > greeting.txt", + "./check.sh", + DONE_COMMAND, + ) + ) + events: list[dict[str, Any]] = [] + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + on_event=events.append, + ) + executor = Executor( + queue, + client, + repo, + checks=Checks(commands=[["./check.sh"]]), + role_runner=RUNNER, + push=False, + on_event=events.append, + ) + + outcome = executor.run_once() + + assert outcome is not None + assert outcome.state == DONE, outcome.reason + assert git(repo, "show", "harness/t1:greeting.txt") == "hello harness\n" + # Once before the edit and once after it were feedback; the third run was + # the harness's authoritative gate over the exact candidate it committed. + assert (repo / "check-runs.txt").read_text().splitlines() == ["called"] * 3 + assert transport.calls == 5, "the implementer was collapsed back to one call" + + outcomes = [event.get("outcome") for event in events] + assert "runner_started" in outcomes and "runner_finished" in outcomes + assert "checks_passed" in outcomes and "review_approved" in outcomes + model_events = [event for event in events if event.get("role") == "implementer"] + assert len([event for event in model_events if event.get("outcome") == "ok"]) == 5 + assert all(event.get("project_id") == "default" for event in model_events) + assert all(event.get("item_id") == "T1" for event in model_events) + assert all(event.get("work_attempt") == 1 for event in model_events) + + history = queue.attempts_log.history("default", "T1") + implementation = next(row for _, row in history if row.stage == "implemented") + assert implementation.artefact["runner"] == "agent-loop" + assert implementation.artefact["calls"] == 5 + item = queue.get("T1") + assert item is not None and item.unpriced_calls == 7 + + final_messages = "\n".join( + str(message.get("content") or "") for message in transport.implementer_messages[-1] + ) + assert "hello world" in final_messages, "the first observation did not reach later turns" + + +def test_loop_events_can_be_written_to_the_append_only_audit_sink(tmp_path: Path) -> None: + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add([WorkRecord(item_id="T1", title="Change it", brief="Change greeting.txt.")]) + transport = ScriptedLoop(("printf 'changed\\n' > greeting.txt", DONE_COMMAND)) + raw: list[dict[str, Any]] = [] + audit = AuditStore(tmp_path / "audit.sqlite") + + def sink(event: dict[str, Any]) -> None: + raw.append(event) + known = { + "ts", + "kind", + "worker", + "role", + "model", + "endpoint", + "outcome", + "error_class", + "latency_s", + } + data = {key: value for key, value in event.items() if key not in known} + audit.append( + [ + Event( + ts=float(event.get("ts", 0.0)), + kind=(str(event.get("kind")) if event.get("kind") in KINDS else MODEL_CALL), + source="role-runner-test", + worker=event.get("worker"), + role=event.get("role"), + model=event.get("model"), + endpoint=event.get("endpoint"), + outcome=event.get("outcome"), + error_class=event.get("error_class"), + latency_s=event.get("latency_s"), + data=data, + ) + ] + ) + + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + on_event=sink, + ) + outcome = Executor( + queue, + client, + repo, + role_runner=RUNNER, + push=False, + on_event=sink, + ).run_once() + + assert outcome is not None and outcome.state == DONE + assert audit.count() == len(raw) + model_rows = [row for row in audit.recent(limit=100) if row["kind"] == MODEL_CALL] + assert model_rows and all(json.loads(row["data"]).get("item_id") == "T1" for row in model_rows) + + +def test_a_new_file_created_by_the_loop_reaches_the_authoritative_pipeline( + tmp_path: Path, +) -> None: + """Untracked files are candidate work, not an empty implementation.""" + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add( + [ + WorkRecord( + item_id="T1", + title="Add a farewell", + brief="Create farewell.txt containing goodbye.", + ) + ] + ) + transport = ScriptedLoop(("printf 'goodbye\\n' > farewell.txt", DONE_COMMAND)) + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + ) + executor = Executor( + queue, + client, + repo, + checks=Checks(commands=[["test", "-f", "farewell.txt"]]), + role_runner=RUNNER, + push=False, + ) + + outcome = executor.run_once() + + assert outcome is not None + assert outcome.state == DONE, outcome.reason + assert git(repo, "show", "harness/t1:farewell.txt") == "goodbye\n" + + +def test_a_policy_refusal_is_terminal_in_the_harness_path(tmp_path: Path) -> None: + from agent_harness.guard import CommandGuard + + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add([WorkRecord(item_id="T1", title="Try it", brief="Make a change.")]) + transport = ScriptedLoop(("rm -rf .", DONE_COMMAND)) + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + ) + executor = Executor( + queue, + client, + repo, + checks=Checks(guard=CommandGuard(refusals=("rm",))), + role_runner=RUNNER, + push=False, + ) + + outcome = executor.run_once() + + assert outcome is not None + assert outcome.state == "blocked" + assert outcome.reason_kind == "command_blocked" + assert transport.calls == 1, "a terminal refusal was returned as another loop turn" + assert git(repo, "branch", "--list", "harness/t1").strip() == "" + + +def test_a_spend_ceiling_stops_the_loop_inside_the_harness_path(tmp_path: Path) -> None: + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="default", name="Default", max_item_spend_usd=0.003)) + queue.add([WorkRecord(item_id="T1", title="Never finish", brief="Keep working.")]) + transport = ScriptedLoop(["echo working"] * 20, tokens=1_000) + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + prices=PriceTable(version="test", prices={"scripted": Price(1.0, 1.0)}), + ) + executor = Executor(queue, client, repo, role_runner=RUNNER, push=False) + + outcome = executor.run_once() + + assert outcome is not None and outcome.state == BLOCKED + assert outcome.reason_kind == "item_spend" + assert transport.calls == 1, "the loop crossed its remaining item budget and kept calling" + item = queue.get("T1") + assert item is not None + assert item.spend_usd == pytest.approx(0.004), "planner and loop calls were not combined" + + +def test_a_loop_that_never_terminates_exhausts_its_steps_in_the_harness_path( + tmp_path: Path, +) -> None: + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add([WorkRecord(item_id="T1", title="Never finish", brief="Keep working.")]) + transport = ScriptedLoop(["echo working"] * 20) + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + ) + executor = Executor( + queue, + client, + repo, + role_runner=RUNNER, + runner_step_limit=3, + push=False, + ) + + outcome = executor.run_once() + + assert outcome is not None and outcome.state == FAILED + assert "step_limit" in outcome.reason + assert transport.calls == 3 + assert git(repo, "branch", "--list", "harness/t1").strip() == "" + + +def test_an_unpriced_prior_attempt_does_not_reenable_the_loop_spend_limit( + tmp_path: Path, +) -> None: + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="default", name="Default", max_item_spend_usd=0.005)) + queue.add([WorkRecord(item_id="T1", title="Keep working", brief="Keep working.")]) + transport = ScriptedLoop( + ( + "printf 'first\\n' > result.txt", + DONE_COMMAND, + "printf 'second\\n' > result.txt", + DONE_COMMAND, + ), + tokens=500, + usage_roles=("implementer",), + ) + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + prices=PriceTable(version="test", prices={"scripted": Price(1.0, 1.0)}), + ) + executor = Executor(queue, client, repo, role_runner=RUNNER, push=False) + + first = executor.run_once() + assert first is not None and first.state == DONE + queue.requeue("T1") + # The scripted client reports no usage for the first attempt in this + # fixture's planner/reviewer responses, so the item carries an unpriced + # history even though the loop's own reply was priced. + record = queue.get("T1") + assert record is not None and record.unpriced_calls > 0 + + second = executor.run_once() + + assert second is not None and second.state == DONE + assert transport.calls == 4 + + +def test_run_selects_the_installed_loop_and_delivers_through_the_cli( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = repository(tmp_path) + database = tmp_path / "queue.sqlite" + queue = make_queue(str(database)) + queue.add([WorkRecord(item_id="T1", title="Change it", brief="Change the greeting.")]) + transport = ScriptedLoop(("printf 'hello harness\\n' > greeting.txt", DONE_COMMAND)) + monkeypatch.setenv("HARNESS_API_KEY", "test-key") + monkeypatch.setattr(cli, "_http_transport", lambda _key: transport) + + code = cli.main( + [ + "--db", + str(database), + "run", + "--role-runner", + "agent-loop", + "--work", + str(repo), + "--no-push", + "--planner", + "planner", + "--implementer", + "implementer", + "--reviewer", + "reviewer", + "--endpoint", + "https://example.invalid", + "--check", + "./check.sh", + "--events", + str(tmp_path / "events.jsonl"), + "--limit", + "1", + ] + ) + + assert code == 0 + assert git(repo, "show", "harness/t1:greeting.txt") == "hello harness\n" + assert queue.get_setting("role_runner") == "agent-loop" diff --git a/tests/test_role_runners.py b/tests/test_role_runners.py new file mode 100644 index 0000000..d23a982 --- /dev/null +++ b/tests/test_role_runners.py @@ -0,0 +1,67 @@ +"""The generic role-runner contract and its installed-metadata boundary.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from agent_harness import role_runners +from agent_harness.doctor import diagnose +from agent_harness.work import WorkQueue + + +def test_the_shipped_runner_is_discoverable_without_an_adapter_import() -> None: + assert "agent-loop" in role_runners.names() + + +def test_the_selected_runner_reports_a_compatible_contract_and_version() -> None: + runner = role_runners.resolve("agent-loop") + assert runner.api_version == role_runners.API_VERSION + detail = role_runners.describe(runner) + assert "compatible" in detail + assert "unknown" not in detail + + +def test_an_unknown_runner_is_a_named_preflight_failure() -> None: + ok, detail = role_runners.probe("missing") + assert not ok + assert "missing" in detail + assert role_runners.ENTRY_POINT_GROUP in detail + + +def test_core_knows_no_runner_adapter_module_path() -> None: + source = Path(role_runners.__file__ or "").read_text() + assert "agent_harness.adapters." not in source + + +def test_an_incompatible_runner_is_refused_before_it_runs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class OldRunner: + name = "old" + api_version = 0 + version = "1" + + def run(self, request: object) -> None: + raise AssertionError("an incompatible runner was called") + + monkeypatch.setattr(role_runners, "_declared_targets", lambda: {"old": "fixture:RUNNER"}) + module = type("M", (), {"RUNNER": OldRunner})() + with ( + patch("importlib.import_module", return_value=module), + pytest.raises(role_runners.IncompatibleRoleRunner, match="contract version"), + ): + role_runners.resolve("old") + + +def test_doctor_reports_the_selected_runner_from_installed_metadata(tmp_path: Path) -> None: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.set_setting(role_runners.SETTING_KEY, "agent-loop") + + report = diagnose(queue, []) + + finding = next(item for item in report.environment if item.name == "role runner") + assert finding.state == "ok" + assert "agent-loop" in finding.detail From ab5b52362c77073aa4f907f51e268918e80b050e Mon Sep 17 00:00:00 2001 From: sprooty Date: Thu, 6 Aug 2026 07:12:37 +0000 Subject: [PATCH 11/12] Record Stage 1 implementation commit --- docs/evidence/2026-08-06-stage-1-role-runner.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/evidence/2026-08-06-stage-1-role-runner.md b/docs/evidence/2026-08-06-stage-1-role-runner.md index a93d891..e3fb8b4 100644 --- a/docs/evidence/2026-08-06-stage-1-role-runner.md +++ b/docs/evidence/2026-08-06-stage-1-role-runner.md @@ -4,6 +4,9 @@ **Scope:** local fixture repositories only; no provider, GitHub, AIDevEnv, session-host or CLI-agent process was contacted. +**Implementation commit:** `aabdfdb` (`Add metadata-selected implementer role +runner`). This is a local commit only; nothing was pushed or published. + This package records the exit evidence for Stage 1 in [`docs/STATUS.md`](../STATUS.md). It is deliberately not a real-workload acceptance record. Stage 2 (OS-enforced confinement) is still required before a From 61a90fb7b8d9eed36059a571b3277584f0e640ca Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 8 Aug 2026 05:16:22 +0000 Subject: [PATCH 12/12] Stages 2-5, and the Node B deployment that can finally test stage 2 The accumulated locally-accepted milestone: the execution-environment contract and its metadata-selected Docker backend, the AIDevEnv-independent local fleet in `serve`, local plan-branch integration and promotion, the normalized remote-review intake contract with its durable deduplication, the notification outbox, and the first-party browser control plane reconciled onto this tree. Landed in this pass: - `github-pr-review`, the first installed review source. Identity is per-endpoint, because reviews and review comments number independently. Disposition is decided by explicit markers and review state, never by a model reading a human's prose -- anything unmarked becomes a hold for a person rather than work an agent guessed at. - `PlanPublisher` and its wiring into the executor factory. One plan branch yields exactly one pull request; a correction updates that same PR; an unchanged plan head touches no remote; a branch this plan does not contain is refused rather than discarded. Nothing merges, approves or marks ready. - #220: `queue.now()` is authoritative for a lease. The retry and block routes compared against the wall clock, silently opting out of the injectable clock that exists so lease behaviour can be tested at all. - #223: `tool` leaves `_WIRE_ROLES`. `_for_the_wire` reduces a message to role and content, so it can never produce the `tool_call_id` that role requires -- the allow-list contradicted the rule the function enforces. And the deployment, which exists for one reason: stage 2's exit needs the live Docker tests to run against a real daemon, and no daemon was reachable. The controller ships as an image with the Docker CLI and no Docker socket; the daemon arrives at runtime through DOCKER_HOST, pointing at a dedicated DinD sidecar on an internal network. The host socket was rejected: it is root-equivalent on the deploy host and would make every agent sandbox a sibling of every production stack there. The live suite deliberately does not run on the publish CI runners. They reach a separate DinD container with no shared volume, so a bind mount from the job workspace resolves to an empty directory on the daemon's side and would prove nothing. Acceptance runs against the deployed stack's own daemon instead. Gates on this tree: 1712 tests pass with one skip (live Docker, no daemon here), ruff check, ruff format --check and mypy all clean. No stage exit is claimed. No real remote has been contacted, no real pull request has been polled, and the live execution boundary remains untested until the deployed stack runs it. --- .github/workflows/build-deploy.yml | 97 ++ Dockerfile | 113 ++ README.md | 8 +- docs/AUDIT-PLAN.md | 4 +- docs/DEPLOYMENT.md | 112 +- docs/DESIGN.md | 29 +- docs/HARNESS-PLAN.md | 8 +- docs/INTERNALS.md | 4 +- docs/MULTI-PROJECT-PLAN.md | 2 +- docs/STATUS.md | 111 +- docs/USAGE.md | 46 +- ...026-08-06-stage-2-execution-environment.md | 63 + .../2026-08-06-stage-4-plan-integration.md | 126 ++ ...8-stage-5-review-source-and-publication.md | 186 +++ pyproject.toml | 9 + src/agent_harness/__main__.py | 415 ++++- src/agent_harness/adapters/docker.py | 262 +++ .../adapters/github_pr_review.py | 228 +++ src/agent_harness/adapters/minisweagent.py | 58 +- src/agent_harness/api.py | 127 +- src/agent_harness/doctor.py | 31 + src/agent_harness/execution_environment.py | 203 +++ src/agent_harness/execution_environments.py | 90 ++ src/agent_harness/executor.py | 282 +++- src/agent_harness/notifications.py | 358 +++++ src/agent_harness/outcomes.py | 4 + src/agent_harness/plan_integration.py | 727 +++++++++ src/agent_harness/plan_publication.py | 423 +++++ src/agent_harness/preflight.py | 19 +- src/agent_harness/project_service.py | 2 + src/agent_harness/query_service.py | 113 ++ src/agent_harness/review_events.py | 176 ++ src/agent_harness/review_sources.py | 148 ++ src/agent_harness/role_runners.py | 2 + src/agent_harness/runtime.py | 218 ++- src/agent_harness/schemas.py | 155 +- src/agent_harness/work.py | 805 +++++++++- tests/test_agent_loop_e2e.py | 25 + tests/test_api.py | 31 + tests/test_execution_environment.py | 147 ++ tests/test_execution_environment_live.py | 100 ++ tests/test_generic.py | 21 + tests/test_github_pr_review_source.py | 252 +++ tests/test_notifications.py | 100 ++ tests/test_plan_integration.py | 1419 +++++++++++++++++ tests/test_plan_publication.py | 371 +++++ tests/test_review_events.py | 390 +++++ tests/test_review_sources.py | 114 ++ tests/test_role_runner_e2e.py | 104 ++ tests/test_serve_fleet.py | 173 +- 50 files changed, 8832 insertions(+), 179 deletions(-) create mode 100644 .github/workflows/build-deploy.yml create mode 100644 Dockerfile create mode 100644 docs/evidence/2026-08-06-stage-2-execution-environment.md create mode 100644 docs/evidence/2026-08-06-stage-4-plan-integration.md create mode 100644 docs/evidence/2026-08-08-stage-5-review-source-and-publication.md create mode 100644 src/agent_harness/adapters/docker.py create mode 100644 src/agent_harness/adapters/github_pr_review.py create mode 100644 src/agent_harness/execution_environment.py create mode 100644 src/agent_harness/execution_environments.py create mode 100644 src/agent_harness/notifications.py create mode 100644 src/agent_harness/plan_integration.py create mode 100644 src/agent_harness/plan_publication.py create mode 100644 src/agent_harness/review_events.py create mode 100644 src/agent_harness/review_sources.py create mode 100644 tests/test_execution_environment.py create mode 100644 tests/test_execution_environment_live.py create mode 100644 tests/test_github_pr_review_source.py create mode 100644 tests/test_notifications.py create mode 100644 tests/test_plan_integration.py create mode 100644 tests/test_plan_publication.py create mode 100644 tests/test_review_events.py create mode 100644 tests/test_review_sources.py diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 0000000..ba74ca4 --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,97 @@ +name: build and deploy + +# Self-hosted runners only. GitHub-hosted runners are not permitted in this +# org and `runs-on: ubuntu-latest` is rejected -- see ops docs/CI-RUNNER-GATES.md. +# The `docker`/`publish` labels are advertised only by the two +# Docker-in-Docker-backed workers, which this job needs to build and push. +# +# The four repository gates run inside the image itself, as the `test` build +# target, rather than through setup-uv on the runner. Two reasons: the gates +# then run against the exact Python and toolchain the deployed image ships, +# and `ci.yml` keeps its own faster path for pull requests. +# +# What this workflow deliberately does NOT do: run the Stage 2 live execution +# tests. Those need a daemon that shares a filesystem with the process asking +# for the bind mount, and the publish runners talk to a separate DinD container +# over `DOCKER_HOST` with no shared volume -- a bind mount there would resolve +# to an empty directory on the daemon's side and prove nothing. The live suite +# runs against the deployed stack's own daemon instead, through Komodo's +# RunStackService. See docs/DEPLOYMENT.md. + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: build-deploy-${{ github.ref }} + cancel-in-progress: false + +env: + IMAGE_NAME: repo.indexarr.net/indexarr/agent-harness + # A separate repository, not another tag on IMAGE_NAME. `komodo-deploy.sh` + # rewrites every `image: ${IMAGE_NAME}:*` line in the stack, so a test image + # sharing that name would be silently rewritten to the runtime tag and the + # acceptance service would quietly run a container with no pytest in it. + TEST_IMAGE_NAME: repo.indexarr.net/indexarr/agent-harness-test + STACK_NAME: personal-agent-harness + STACK_DIR: personal/agent-harness + +jobs: + test-build-push: + runs-on: [self-hosted, node-b, linux, x64, docker, publish] + steps: + - uses: actions/checkout@v5 + + - name: Gates, in the image that will be deployed + run: docker build --target test -t agent-harness-test . + + - name: Log in to the Forgejo registry + run: | + echo "${{ secrets.FORGEJO_TOKEN }}" \ + | docker login repo.indexarr.net -u "${{ secrets.FORGEJO_USER }}" --password-stdin + + - name: Build and push + run: | + docker build --target runtime \ + -t "${IMAGE_NAME}:latest" \ + -t "${IMAGE_NAME}:${GITHUB_SHA}" \ + . + docker push "${IMAGE_NAME}:latest" + docker push "${IMAGE_NAME}:${GITHUB_SHA}" + # The test target is published too: Stage 2's acceptance runs the + # live suite on the deployed host, so the tests must be a deployable + # artefact rather than something only CI ever holds. Same commit as + # the runtime image above, from the same build. + docker tag agent-harness-test "${TEST_IMAGE_NAME}:latest" + docker tag agent-harness-test "${TEST_IMAGE_NAME}:${GITHUB_SHA}" + docker push "${TEST_IMAGE_NAME}:latest" + docker push "${TEST_IMAGE_NAME}:${GITHUB_SHA}" + + - name: Log out + if: always() + run: docker logout repo.indexarr.net || true + + deploy: + needs: test-build-push + runs-on: [self-hosted, node-b, linux, x64] + steps: + - name: Bump the ops compose tag and trigger Komodo + env: + STACK_NAME: ${{ env.STACK_NAME }} + STACK_DIR: ${{ env.STACK_DIR }} + IMAGE_NAME: ${{ env.IMAGE_NAME }} + IMAGE_TAG: ${{ github.sha }} + GIT_AUTH_TOKEN: ${{ secrets.GIT_AUTH_TOKEN }} + KOMODO_API_KEY: ${{ secrets.KOMODO_API_KEY }} + KOMODO_API_SECRET: ${{ secrets.KOMODO_API_SECRET }} + KOMODO_GIT_ACCOUNT: sprooty + KOMODO_URL: http://192.168.1.75:3011 + run: | + # Reuse the shared deploy step body from the ops repo rather than + # reimplementing the tag bump and DeployStack poll here. + curl -fsSL \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + "https://repo.indexarr.net/api/v1/repos/indexarr/ops/raw/scripts/komodo-deploy.sh" \ + -o komodo-deploy.sh + bash komodo-deploy.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c03b142 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,113 @@ +# agent-harness — controller image. +# +# This image is the **controller**, not an agent sandbox. It holds the queue, +# the gates, the model client and the credentials; it creates a separate, +# disposable container per work item through the selected execution backend and +# never runs an agent's commands itself. +# +# That distinction decides two things here: +# +# * the Docker CLI is installed, because `adapters/docker.py` shells out to +# it — but no Docker socket is baked in. The daemon is supplied at runtime +# through `DOCKER_HOST`, which in the Node B stack points at a dedicated +# DinD sidecar on an internal network. The controller therefore never holds +# root-equivalent access to the deploy host (STATUS.md §2.7). +# * item worktrees live under a path that must be **identical** in this +# container and in whichever daemon creates the item containers. A bind +# mount is resolved by the daemon, not by the client, so a controller that +# mounts `/harness/work` while the daemon knows that content by another +# path would silently mount an empty directory into every agent's +# checkout. `HARNESS_WORK_ROOT` names that shared path. +# +# Targets, per STATUS.md §2.7's "publish deliberately different image targets": +# +# test the four repository gates, with dev dependencies and the tests +# runtime the service, without them +# +# Base pinned by tag; the resolved digest is recorded by preflight and in item +# evidence so a result stays explicable after a tag moves. + +# ---------------------------------------------------------------- base + +FROM python:3.12-slim-bookworm AS base + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 + +# git is not optional: the harness allocates a worktree per item, computes the +# candidate diff and drives plan-branch promotion. docker-cli talks to the +# daemon named by DOCKER_HOST. ca-certificates is needed to reach a gateway. +RUN apt-get update \ + && apt-get install --no-install-recommends -y \ + ca-certificates \ + curl \ + git \ + docker.io \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv + +# A committing identity, because the harness commits: the suite builds real +# git repositories and the executor commits an item branch. Without one, git +# refuses with "Please tell me who you are" in an image where no human can. +RUN git config --system user.name "agent-harness" \ + && git config --system user.email "agent-harness@invalid" \ + && git config --system init.defaultBranch main \ + && git config --system --add safe.directory '*' + +WORKDIR /app + +# Dependency layer first, so a source-only change does not re-resolve. +COPY pyproject.toml uv.lock README.md ./ +RUN uv sync --frozen --no-install-project --all-extras + +COPY src ./src +COPY tests ./tests +COPY examples ./examples +RUN uv sync --frozen --all-extras + +# ---------------------------------------------------------------- test + +FROM base AS test + +# The same four gates the repository runs, in the image that will be deployed. +# TMPDIR matters: the suite creates temporary git repositories heavily. +ENV TMPDIR=/tmp +RUN uv run ruff check . \ + && uv run ruff format --check . \ + && uv run mypy \ + && uv run pytest -q + +# ------------------------------------------------------------- runtime + +FROM base AS runtime + +# Runtime carries no dev dependencies. Re-synced rather than copied from a +# clean layer so the lock file remains the single source of what is installed. +RUN uv sync --frozen --no-dev --extra agent-loop + +# `/harness/work` is where a project's checkout must live. It is not a setting +# the harness reads — a project's `work_dir` is a row on the project, supplied +# when the project is registered — it is a **deployment constraint**: the +# daemon that creates item containers resolves bind mounts by its own paths, so +# a project registered outside the shared volume would hand every agent an +# empty checkout. Register projects under this path and nowhere else. +ENV HARNESS_DB=/harness/state/queue.sqlite \ + HARNESS_AUDIT_DB=/harness/state/audit.sqlite \ + PATH="/app/.venv/bin:$PATH" + +# The controller does not need root, and an agent's commands never run here +# anyway. The Docker CLI only needs to reach DOCKER_HOST over TCP. +RUN groupadd --gid 1000 harness \ + && useradd --uid 1000 --gid 1000 --create-home harness \ + && mkdir -p /harness/work /harness/state \ + && chown -R harness:harness /harness /app +USER harness + +EXPOSE 8080 + +# `serve` is the deployment entry point; `run` is the one-shot CLI. Neither +# claims work until a project is started through the API. +CMD ["agent-harness", "serve", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index ca3562e..fc1ac0b 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ uv run agent-harness --db harness.sqlite ingest --events ./run/events.jsonl HARNESS_TOKEN=$(openssl rand -hex 16) \ uv run agent-harness --db harness.sqlite serve --port 8099 -# Supervised: the same API, plus a worker pool it can actually start. +# Supervised session-host mode: the same API, plus a worker pool it can actually start. # Still nothing runs until someone starts a project through the API. HARNESS_TOKEN=$(openssl rand -hex 16) HARNESS_API_KEY=… \ uv run agent-harness --db harness.sqlite serve --port 8099 \ @@ -415,9 +415,11 @@ HARNESS_TOKEN=$(openssl rand -hex 16) HARNESS_API_KEY=… \ --reviewer claude-sonnet-4-6 --endpoint https://api.your-gateway.example ``` -Without `--session-host` the service is **monitoring only**: everything reads, +Without `--session-host`, configure `--role-runner`, `--environment-backend` +and `--environment-image` for the local in-process fleet. If neither executor +capability is configured, the service is **monitoring only**: everything reads, and starting a project is refused rather than setting a flag no worker acts on. -Both modes, and the read-only check that tells them apart after a deploy, are +All three modes, and the read-only check that tells them apart after a deploy, are in [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). Keep it fed with `ingest --watch 30`. Optionally pass `--baseline TOTAL:DAYS:LABEL` to diff --git a/docs/AUDIT-PLAN.md b/docs/AUDIT-PLAN.md index 7838863..7ab4c42 100644 --- a/docs/AUDIT-PLAN.md +++ b/docs/AUDIT-PLAN.md @@ -29,8 +29,8 @@ than timestamp. That foundation is sound and none of it needs replacing. ### 0.1 The audit log shares a file with the operational queue ```python -queue = WorkQueue(args.db) # mutable: claims, leases, retries -store = EventStore(args.db) # append-only: what happened, forever +queue = WorkQueue(args.db) # mutable: claims, leases, retries +store = EventStore(args.db) # append-only: what happened, forever ``` Same database. So the history shares fate with the state: a queue migration diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 91f376d..b3fd057 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -4,15 +4,15 @@ The contract between whatever starts this process — systemd, a compose file or a Kubernetes manifest — and what the service can then actually do. The JSON API and browser GUI are served directly; no host application is required. -There are **two supported modes**, and the difference is deliberate rather +There are **three supported modes**, and the difference is deliberate rather than a degraded state: -| | Monitoring-only | Supervised | -|---|---|---| -| Flags | `--db`, `--host`, `--port`, `--root-path` | those, plus `--session-host`, `--agent`, `--reviewer`, `--endpoint` | -| Reads (work, events, audit, projects) | yes | yes | -| `POST /api/projects/{id}/start` | **refuses, by design** | starts real workers, after preflight | -| Who it is for | a dashboard over someone else's harness | the deployment that does the work | +| | Monitoring-only | Local fleet | Supervised | +|---|---|---|---| +| Flags | `--db`, `--host`, `--port`, `--root-path` | those, plus `--role-runner`, `--environment-backend`, `--environment-image`, and model routes | those, plus `--session-host`, `--agent`, `--reviewer`, `--endpoint` | +| Reads (work, events, audit, projects) | yes | yes | yes | +| `POST /api/projects/{id}/start` | **refuses, by design** | starts local workers, after preflight | starts session workers, after preflight | +| Who it is for | a dashboard over someone else's harness | the deployment that does the work without a session host | the deployment that delegates execution to a session host | The trap this document exists to close: **a monitoring-only process is healthy.** `/healthz` returns `ok`, the API answers, the GUI renders a @@ -80,7 +80,7 @@ agent-harness --db /var/lib/harness/harness.sqlite serve \ The process says so on startup: ``` -monitoring only: no --session-host, so no worker pool is attached and +monitoring only: no executor is configured, so no worker pool is attached and starting a project will be refused. ``` @@ -88,6 +88,98 @@ starting a project will be refused. around. Marking a project `running` with nothing able to claim is the failure this refusal exists to prevent. +## Local in-process execution + +This mode owns the worker pool in `serve`. It does not require AIDevEnv, a +terminal session host or a local provider CLI process. The role runner and +item execution backend are selected through installed metadata; the backend +must be configured explicitly and ready before the service starts. + +```bash +export HARNESS_TOKEN=… +export HARNESS_API_KEY=… + +agent-harness --db /var/lib/harness/harness.sqlite serve \ + --role-runner agent-loop \ + --environment-backend docker \ + --environment-image registry.example/project-toolchain@sha256:… \ + --planner claude-sonnet-4-6 \ + --implementer claude-sonnet-4-6 \ + --reviewer claude-sonnet-4-6 \ + --endpoint https://api.your-gateway.example +``` + +Local mode does not publish a remote branch. It runs the existing checks, +checkpoint and reviewer gates and keeps the result local until plan +integration is implemented. `/api/readiness` reports `mode: local` and the +execution backend as the capability that makes starting possible. + +--- + +## Containerised execution: a controller and its own daemon + +The reference deployment of local in-process execution runs the controller as +a container beside a **dedicated** Docker daemon, rather than against the +host's. `Dockerfile` builds it and `indexarr/ops` `personal/agent-harness` +holds the deployed shape. + +The controller is not an agent sandbox. It holds the queue, the gates, the +model client and every credential; it creates one disposable container per +item through the selected backend and runs no agent command itself. That is +why it carries the Docker CLI but no Docker socket: the daemon arrives at +runtime through `DOCKER_HOST`. + +```yaml +agent-harness-docker: # privileged, internal network, nothing published + image: docker:28-dind + volumes: [harness-work:/harness/work] + +agent-harness: + environment: + DOCKER_HOST: tcp://agent-harness-docker:2375 + volumes: [harness-work:/harness/work] +``` + +### The identical-path constraint + +**A bind mount is resolved by the daemon that creates the container, not by +the client that asks for it.** The backend creates each item container with +`-v :/workspace` using the controller's own path. If the daemon does +not know that exact path, Docker does not fail — it creates an empty directory +and mounts that. Every agent then gets an empty checkout, and the failure +looks like a model that could not find the code rather than a mount that was +never there. This repository has already paid once for a stale-worktree defect +that blamed the model for four passes (#216); this is the same failure wearing +a different hat. + +So: the worktree volume is mounted at the **same path in both containers**, +and every project's `work_dir` must be registered underneath it. Registering a +project anywhere else is not a configuration preference — it is a silent +outage. + +The same reasoning rules out running the live execution tests on the +`docker`/`publish` CI runners. Those talk to a separate DinD container over +`DOCKER_HOST` with no shared volume, so a bind mount from the job's workspace +resolves to nothing on the daemon's side. Acceptance runs against the deployed +stack's own daemon instead: + +```bash +# Komodo, POST /execute/RunStackService, then poll /read/GetUpdate. +{"stack": "personal-agent-harness", "service": "agent-harness-tests"} +``` + +### Why not the host socket + +Mounting `/var/run/docker.sock` into the controller is simpler and was +rejected. The socket is root-equivalent on the deploy host, and it would make +every agent sandbox a sibling of every other stack on that machine. A nested +daemon on an internal network gives the controller exactly one thing it can +reach and nothing on the host, which is what `STATUS.md` §2.7 asks for. + +The nested daemon is `privileged: true`, which is a real cost and is confined +deliberately: it publishes no port and sits on an `internal: true` network +whose only other member is the controller. + --- ## Supervised execution @@ -109,8 +201,8 @@ agent-harness --db /var/lib/harness/harness.sqlite serve \ What the deployment must provide, and why each one: | Requirement | Why | -|---|---| -| `--session-host` reachable, and `AIDEVENV_TOKEN` accepted by it | Agents run as terminal sessions on it. Without it there is no worker pool and `start` refuses. | +|---|---|---|---| +| `--session-host` reachable, and `AIDEVENV_TOKEN` accepted by it | Required only for supervised session execution. A local fleet uses the role-runner and execution-backend requirements instead. | | `--agent` runnable **in the session host's environment**, with the credentials it needs to clone, commit and push | The agent — not the harness — does the implementing. Its environment is where `git` and `gh` credentials have to be. The harness never injects them. | | `gh` authenticated with **push** permission on each project's repo | Preflight asks GitHub for `permissions.push` rather than looking for a token: a token that exists and lacks the scope fails at the point where an agent has already done the work. | | `--reviewer` + `--endpoint`, and `HARNESS_API_KEY` | The reviewer is the only role that needs a model in this mode. With none routed, every review fails closed, so every item fails *after* the implementation has been paid for. | diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 31ab88a..2ccc78a 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1149,7 +1149,7 @@ from agent_harness.providers import VendorEnvelopeProvider PRESET = RoutePreset( name="somevendor", request=JsonChatRequest(path="/v2/generate", model_key="model_id", messages_key="turns"), - auth=BearerAuth(header="x-api-key", scheme=""), # no scheme word + auth=BearerAuth(header="x-api-key", scheme=""), # no scheme word reader=JsonResponseReader(text_paths=("result.reply",), usage_key="counters"), classifier=VendorEnvelopeProvider(vendor_field="problem", quota_categories=("budget",)), ) @@ -1181,6 +1181,20 @@ declares its shipped loop in metadata; `executor.py` receives only a structural runner and neither imports nor names its adapter. An incompatible contract is a configuration failure before work is claimed, not a substitution. +**Review sources, the same door once more.** `review_events.py` owns what a +piece of remote feedback *becomes* — one correction item, one hold, or nothing +— and deduplicates on an immutable source identity. It never owns what a +particular forge's record looks like, and it never asks a model whether a +human's comment was actionable. An adapter declared under +`agent_harness.review_sources` supplies that upstream's authentication, +polling, identity and an **explicit** disposition; the shipped +`github-pr-review` adapter reaches core through that entry point like anyone +else's would. Its disposition rules are deterministic and legible — explicit +markers first, then review state — and anything unmarked defaults to +`ambiguous`, which opens a hold for a person. Defaulting the other way would +put an agent to work on a guess about what somebody meant, which is the +failure this whole contract exists to prevent. + **Adapters generally.** Log readers, telemetry export and the agent loop are all opt-in and lazily loaded, and nothing in core imports any of them. Telemetry is **export-only**: it projects the event stream outward, nothing reads back, and @@ -1216,12 +1230,13 @@ Named here rather than left for a reader to discover. messages and human participation over the API are designed and not built, and two modules currently spell an item's room differently — harmless only because nothing in production constructs a ledger. -- **The role-runner path still works in one shared checkout.** It is selectable - from `run`, but it is not yet the isolated per-item worktree fleet described - by the accepted product direction. Its subprocess inherits the controller's - environment, and `CommandGuard` remains screening rather than an OS security - boundary. No secret-bearing real workload should be run through it until the - Stage 2 confinement boundary exists. +- **The host compatibility path still works in one shared checkout.** It remains + useful for fixtures and explicitly selected local development, but it inherits + the controller's environment and `CommandGuard` remains screening rather than + an OS security boundary. When an execution backend is selected, the role runner + gets a disposable per-item Git worktree and the backend owns the command + boundary; the shipped Docker backend still requires live-daemon acceptance + evidence before a secret-bearing real workload is authorised. Where to go next: [`AGENTS.md`](../AGENTS.md) for the binding rules, [`STATUS.md`](STATUS.md) for what is built and what has been proven, diff --git a/docs/HARNESS-PLAN.md b/docs/HARNESS-PLAN.md index cedcdef..f1ca9fe 100644 --- a/docs/HARNESS-PLAN.md +++ b/docs/HARNESS-PLAN.md @@ -411,10 +411,12 @@ Replaces both `pick_model_fn` and the global governor. Five responsibilities: ```python TERMINAL = {"weekly_cost_limit_reached", "invalid_api_key"} -WINDOW = {"5h_cost_limit_reached"} +WINDOW = {"5h_cost_limit_reached"} + class CapExhausted(Exception): ... + def call_model(client, role, messages, max_attempts=6): for attempt in range(max_attempts): try: @@ -424,9 +426,9 @@ def call_model(client, role, messages, max_attempts=6): raise kind = (e.response.json().get("error", {}) or {}).get("theclawbayError") if kind in TERMINAL or kind in WINDOW: - raise CapExhausted(kind) from e # retrying cannot help + raise CapExhausted(kind) from e # retrying cannot help ra = e.response.headers.get("retry-after") - delay = float(ra) if ra else min(2 ** attempt, 30) + delay = float(ra) if ra else min(2**attempt, 30) time.sleep(delay + random.uniform(0, delay)) raise RuntimeError(f"{role}: 429 after {max_attempts} attempts") ``` diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md index 22729af..72edf1e 100644 --- a/docs/INTERNALS.md +++ b/docs/INTERNALS.md @@ -317,7 +317,7 @@ from agent_harness.providers import VendorEnvelopeProvider PRESET = RoutePreset( name="somevendor", request=JsonChatRequest(path="/v2/generate", model_key="model_id", messages_key="turns"), - auth=BearerAuth(header="x-api-key", scheme=""), # no scheme word + auth=BearerAuth(header="x-api-key", scheme=""), # no scheme word reader=JsonResponseReader(text_paths=("result.reply",), usage_key="counters"), classifier=VendorEnvelopeProvider(vendor_field="problem", quota_categories=("budget",)), ) @@ -408,7 +408,7 @@ endpoint**, and jitter. ### Full jitter, not a jittered cap ```python -delay = random() * min(base * 2 ** attempt, cap) +delay = random() * min(base * 2**attempt, cap) ``` The cap bounds the curve, not the result. Capping after jittering diff --git a/docs/MULTI-PROJECT-PLAN.md b/docs/MULTI-PROJECT-PLAN.md index 18bc315..88bdfb1 100644 --- a/docs/MULTI-PROJECT-PLAN.md +++ b/docs/MULTI-PROJECT-PLAN.md @@ -137,7 +137,7 @@ Found by reviewing the code, not by it failing. Ordered by what breaks first. ```python outcome = self.run_once() if outcome is None: - break # queue empty -> return -> process exits + break # queue empty -> return -> process exits ``` `--watch` exists, but only for `ingest`. The executor drains the backlog and diff --git a/docs/STATUS.md b/docs/STATUS.md index ceabb1e..5d331f4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -68,11 +68,11 @@ unobserved. The standalone run is **observed**, once, on one item — it is not evidence that the harness works, and it is explicitly not a delivered item. -**Stage 1 / #215 is implemented and tested locally; Stage 2 is the next -implementation block.** The GitHub issue remains open while this exists only -locally, per P12 and D1. A useful loop must now be confined before it touches a -real workload, then become reachable from an AIDevEnv-independent service -fleet, execute in isolated worktrees, promote +**Stage 1 / #215 is implemented and tested locally. Stage 2 live acceptance +remains a prerequisite; Stage 3 wiring is now the next implementation block.** +The GitHub issue remains open while this exists only locally, per P12 and D1. A +useful loop must now be confined before it touches a real workload, then become +reachable from an AIDevEnv-independent service fleet, execute in isolated worktrees, promote several items safely to one plan branch, surface exceptions, and attribute its calls and outcomes to the item that caused them. None of that end-to-end path has run. @@ -150,7 +150,7 @@ target branch. | **P10 — human involvement is exceptional** | Before publication, people are involved only for questions, holds and failures. The single final PR review and merge is the normal human approval point. | There is no approval ceremony per item and no need to watch agents work. A held item keeps its claim under D12; unrelated workers continue. The GUI/API must make the exception and the evidence needed to answer it visible. | | **P11 — calls are not the optimisation target** | Use generous configurable loop bounds. Keep time, spend and call ceilings as emergency controls and continue measuring them, but do not shorten the loop to minimise request count. | The useful run took 31 turns; the failed design used one. Number of calls is not currently a material product concern. A limit still must stop a pathological loop, and a provider cost cap is still terminal and never retried. | | **P12 — local development cadence** | Develop, commit, gate and integrate locally. Do not push, open a PR, wait for hosted CI, or deploy each implementation slice. | Those remote steps are materially delaying the feedback loop. GitHub support is retained and tested with local fakes; GitHub issues remain the state record required by D1, but issues stay open while implementation exists only locally. Publication happens once at the explicit milestone in §2.4. | -| **P13 — GUI is a client, not an execution dependency** | The GUI consumes typed API state and event/notification contracts. It is owned and served by `agent-harness`, but is not an input to executor design. | The imported browser control plane is a first-party client over shared services. Authenticated webhook/notification delivery is still unbuilt. Execution must continue with the GUI offline. Holds, failures, completion and review events live in durable harness stores/API; the GUI or a later external channel presents and notifies. | +| **P13 — GUI is a client, not an execution dependency** | The GUI consumes typed API state and event/notification contracts. It is owned and served by `agent-harness`, but is not an input to executor design. | The imported browser control plane is a first-party client over shared services. Authenticated notification delivery and a generic review-source contract exist, while a deployed external source adapter remains optional. Execution must continue with the GUI offline. Holds, failures, completion and review events live in durable harness stores/API; the GUI or a later external channel presents and notifies. | ### 2.3 Plan-branch and dependency semantics @@ -229,10 +229,71 @@ the currently measured delivery failure. Model-call events carry project, item and work-attempt identity; item budgets and terminal policy refusals stop at loop boundaries. Evidence is in [`evidence/2026-08-06-stage-1-role-runner.md`](evidence/2026-08-06-stage-1-role-runner.md). -- **Next: Stage 2.** The current `HarnessEnvironment` invokes the host shell in - the controller environment. `CommandGuard` explains and terminates known - refusals, but it is not confinement. No real workload run is authorised by - the Stage 1 result. +- **Stage 2 is in implementation; its exit is pending.** The generic + execution-environment contract, metadata-selected Docker backend, disposable + per-item self-contained Git checkout, explicit image/network/mount configuration, + controller-environment allow-list, pre-claim readiness check and teardown path + are implemented and covered by local contract tests. The current host has + Docker CLI but no reachable daemon, so the required tests against the actual + backend — repository-wide work, undeclared sibling/host refusal, declared + mount modes, allowed network access and clean teardown — have not yet run. No + real workload run is authorised by the Stage 2 implementation evidence. See + + [`evidence/2026-08-06-stage-2-execution-environment.md`](evidence/2026-08-06-stage-2-execution-environment.md). +- **Stage 3 wiring is present but its exit is not claimed.** `serve` can now + construct an AIDevEnv-independent local fleet from the metadata-selected + role runner and execution backend; readiness and preflight use that executor + capability, fixture coverage runs two items through separate self-contained + checkouts, and restart cleanup now has deterministic item paths plus + worktree-scoped Docker reaping. The live-backend evidence required by Stage 2 + is still missing, and Stage 3 has not yet proved killed-backend isolation or + the full acceptance graph. Plan-branch promotion now has local fixture + evidence: two independent items are promoted under the in-process lock and + durable cross-process lease, and a dependent item sees both changes. No real + workload run is authorised. +- **Stage 4 implementation has local fixture evidence but its exit is not + claimed.** The fleet fixture runs two independent items and one item depending + on both, preserves promotion records, exposes both promoted files to the + dependent runner, and retains the conflicting-promotion repair path. It does + not contact a remote. The live execution boundary and Stage 3 failure + isolation criteria remain open prerequisites. +- **Stage 5 is in implementation; its exit is not claimed.** A generic + normalized remote-review contract now accepts an immutable source/event id, + an explicit adapter-supplied disposition, and bounded feedback. Intake is + durably deduplicated in the queue database: actionable feedback creates one + correction item dependent on the reviewed item, ambiguous feedback creates a + pending correction held for a person, and already-resolved feedback creates + no work. An answer returns an ambiguity correction to `pending`; audit + delivery is best-effort and item-scoped. The typed API route and local tests + cover these semantics. `WorkEvidence` now also projects retained runner + progress, authoritative gate answers (including argv), plan-promotion state + and normalized review intake without replacing the raw event history. + A local-plan fleet acceptance now proves an actionable correction is claimed + once on the configured integration branch while a sibling item continues; + duplicates remain no-ops. A durable generic notification outbox now records + selected hold, failure, completion and review outcomes and retries them + through an authenticated bearer/HMAC webhook channel. + Two of the three remaining items now have implementations and local tests. + An installed review source (`github-pr-review`) resolves through metadata, + gives reviews and review comments distinct immutable identities, and decides + disposition by explicit markers and review state — with unmarked human prose + defaulting to a hold rather than to guessed work. A `PlanPublisher` pushes + one plan branch under `--force-with-lease` and maintains exactly one pull + request: a correction updates that same PR, an unchanged plan head touches no + remote, an existing PR is adopted rather than duplicated, a branch moved by + somebody else is refused, and nothing merges, approves or marks ready. That + publisher is wired into `direct_executor_factory`: `push=True` on a project + with a plan branch now means one plan branch and one pull request rather than + being refused, the executor is given no GitHub client and pushes no item + branch, publication waits until nothing is in flight and nothing failed, and + a remote failure is an event rather than a failed item. + Evidence is in + [`evidence/2026-08-08-stage-5-review-source-and-publication.md`](evidence/2026-08-08-stage-5-review-source-and-publication.md). + **No real remote was contacted for any of it**: the pull-request client is a + fake and the Git remote is a bare repository in a temporary directory. The + review source has never polled a real pull request, no publication has ever + reached GitHub, and remote workload acceptance remains open. No Stage 5 exit + and no remote workload run is authorised. ### 2.7 Execution backend recommendation — Docker/OCI, selectively adopted @@ -352,19 +413,24 @@ Implemented and covered by in-process tests: Remaining work, in dependency order: -1. **Finish the execution-facing contracts in Stage 5.** Expose item-scoped - runner progress, questions, authoritative gate evidence and plan-promotion - state through typed API/events. Add deduplicated remote-review events and the - correction/resume path before adding UI for them. The GUI must consume those - contracts and remain optional to execution. +1. **Finish the remaining execution-facing work in Stage 5.** The single-PR + publication/resume mechanism and an installed `github-pr-review` source now + exist with local tests, and the typed evidence projections, deduplicated + intake contract, local fleet acceptance and generic notification outbox were + already present, and publication is now wired into the executor factory so a + promoted correction updates the plan PR without an operator. What remains: + run that path against a real remote (Stage 7's milestone), deploy and poll + the review source against a real pull request, and prove fleet continuation + while an item is held or receives review feedback. The GUI must consume + these contracts and remain optional to execution. 2. **Complete existing operator controls.** Add exact-state reviewed bulk transitions, complete continue/force-start and refusal parity, preserve hold answers across expiry/version conflicts, and add any missing item filters or artifact/diff links. No visual gesture is authority for a transition. -3. **Build notification delivery as a subsystem.** Holds, failures, completion - and remote review need a durable generic notification contract and an - authenticated webhook/channel adapter. The current GUI has no proven push or - phone-notification path; do not infer one from the optional session host. +3. **Extend notification delivery beyond the first channel.** The durable + generic contract and authenticated webhook/channel adapter are present. + Add any deployment-specific presentation or phone channel only as an + opt-in adapter; do not infer one from the optional session host. 4. **Prove the browser boundary.** Add browser-runtime journeys for forced SSE disconnect/replay and polling fallback, keyboard-only use, focus handling, reduced motion, desktop/phone layouts and screen-reader semantics. Add the @@ -432,9 +498,9 @@ reviewing a real failure, and none is waiting on anything. | # | what it is | why it is where it is | |---|---|---| | #219 | two edit blocks naming one file by different path strings (`a.txt` and `./a.txt`) render its diff twice; the second copy cannot apply | found during the #216 review and deliberately left out of it, because the fix changes `plan_edits`' public keying. Low severity and fails safely — but the message blames the model for an edit it got right, which is the class of bug this repository spent a day removing. | -| #220 | two API routes compare a lease against `time.time()`, not `queue.now()` | identical in production; the divergence matters because the queue's clock is injectable *so that* lease behaviour can be tested, and two routes silently opt out. Needs a one-line ruling that `queue.now()` is authoritative, applied everywhere. | +| ~~#220~~ | two API routes compare a lease against `time.time()`, not `queue.now()` | **fixed locally, 2026-08-08.** The ruling taken is that `queue.now()` is authoritative for a lease everywhere; the retry and block routes now use it. Red-first: with the wall-clock comparison both new tests return 200 where 409 is correct. Tracker stays open until the publication milestone (P12/D1). `tests/test_api.py::test_retry_honours_the_queue_clock_not_the_wall_clock`, `…::test_blocking_honours_the_queue_clock_not_the_wall_clock`. | | #221 | two holds opened by one attempt in the same tick raise a bare `sqlite3.IntegrityError` instead of a `HoldError` | `asked_at` is a float used as part of an identity. Effectively unreachable against a real clock, immediately reachable with an injected one. The fix needs a small design call: may one attempt hold twice at all? | -| #223 | `_WIRE_ROLES` permits a `tool` message that `_for_the_wire` has already stripped the `tool_call_id` from | latent, not live: nothing currently emits a `tool` role. The allow-list contradicts the rule `format_observation_messages` was written to enforce. The failure mode when it is reached is a gateway refusal naming no message, which has already cost one live run. | +| ~~#223~~ | `_WIRE_ROLES` permits a `tool` message that `_for_the_wire` has already stripped the `tool_call_id` from | **fixed locally, 2026-08-08.** `tool` is removed from the allow-list, so the role a reduced message cannot validly carry can no longer reach the wire; the observation still goes back as a `user` turn and no content is lost. Tracker stays open until the publication milestone (P12/D1). `tests/test_agent_loop_e2e.py::test_a_tool_message_cannot_reach_the_wire_without_its_id`. | | #207 | `test_pausing_a_project_stops_claiming` asserts completions stop within 100 ms, which is a timing assumption about the host | a CI flake on an unrelated branch. The property worth protecting is that pausing stops *claiming*; the assertion instead measures how fast an in-flight item finishes. The resume half of the same test already waits on a condition and is not flaky. | | #209 | a stored model answer is redacted, so it cannot be used to reproduce what the model actually said | two promises in tension — "what did the model say" and "no credential reaches an append-only store" — with the second silently winning. It bites hardest on rdpapp, a credential vault whose fixtures are full of credential-shaped source. It matters most for exact-match edit failures, which are questions about characters, in a record whose characters were changed. | | #103 | silent-but-active CLI sessions are indistinguishable from hangs | session-host path: PTY output is the only activity signal, so a working agent that prints nothing reports `activity: idle`. Independent of the #195 programme; note that #195 also deprecates `--session-host` in help and docs, so weigh effort here against that. | @@ -719,6 +785,9 @@ evidence and do not build on them. | The store has no UPDATE and no DELETE | **tested** | the source-level assertion in the store tests | | The four rdpapp-derived defects: edit-block rendering, stale worktree, guard false positives, claim-scan page deadlock | **tested** | regression tests landed with #216, #217, #218 | | A metadata-selected multi-turn implementer can inspect, edit, run feedback checks, create new files, and then pass through the harness's authoritative checks, attempt record and reviewer with item-scoped events and budgets | **tested** | `tests/test_role_runners.py`, `tests/test_role_runner_e2e.py`, and the adapter regressions in `tests/test_agent_loop_e2e.py` | +| One plan branch yields exactly one pull request: a correction updates it, an unchanged head touches no remote, an existing PR is adopted, a foreign push is refused, and nothing is merged | **tested** | `tests/test_plan_publication.py` — against a local bare remote and a fake pull-request client, never GitHub | +| A fleet publishes that one pull request only once the plan has stopped moving, pushes no item branch, and updates the same PR for a later correction | **tested** | `tests/test_plan_integration.py::test_fleet_publishes_one_plan_pr_only_when_the_plan_is_finished` | +| An installed review source gives reviews and review comments distinct immutable identities and decides disposition without a model, defaulting unmarked prose to a hold | **tested** | `tests/test_github_pr_review_source.py` — `gh` is injected; no real pull request has been polled | | The service runs and is deployed inside AIDevEnv | **observed** | no preserved artefacts | | An earlier supervised NGMS attempt and later direct calls exercised real agents and providers | **observed** | [`evidence/2026-08-03-04-ngms-first-sustained-run-v1.md`](evidence/2026-08-03-04-ngms-first-sustained-run-v1.md) — lacks a common run ID, complete configuration, checksums and a comparable follow-up | | Four executor passes against rdpapp delivered nothing, and why each failed | **observed** | [`evidence/2026-08-05-06-rdpapp-m2-status.md`](evidence/2026-08-05-06-rdpapp-m2-status.md); the pass 3–4 attribution is hindsight and has not been confirmed by re-running against the fix | diff --git a/docs/USAGE.md b/docs/USAGE.md index 87271a3..d1548b6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1191,9 +1191,22 @@ to any harness module. See ## 4d. Serve the API *and* the workers -`serve` on its own is monitoring only — it exposes the API and has no workers, -so starting a project is refused rather than marking it running with nothing -able to claim. Give it a session host and it owns both: +`serve` with no executor configuration is monitoring only — it exposes the API +and has no workers, so starting a project is refused rather than marking it +running with nothing able to claim. For an AIDevEnv-independent local fleet, +give it a metadata-selected role runner and execution backend: + +```bash +HARNESS_TOKEN=… HARNESS_API_KEY=… \ +agent-harness --db harness.sqlite serve --port 8099 \ + --role-runner agent-loop \ + --environment-backend docker \ + --environment-image registry.example/project-toolchain@sha256:… \ + --planner claude-sonnet-4-6 --implementer claude-sonnet-4-6 \ + --reviewer claude-sonnet-4-6 --endpoint https://api.your-gateway.example +``` + +For the supported session-host deployment, give it a session host and it owns both: ```bash HARNESS_TOKEN=… HARNESS_API_KEY=… AIDEVENV_TOKEN=… \ @@ -1766,8 +1779,10 @@ Opening a hold now emits one notice — into the event stream the run already writes, and to one URL you name: ```bash -uv run agent-harness --db harness.sqlite serve --hold-webhook https://your-host/holds -# or: HARNESS_HOLD_WEBHOOK=https://your-host/holds +uv run agent-harness --db harness.sqlite serve \ + --notification-webhook https://your-host/notifications \ + --notification-db notifications.sqlite +# Configure HARNESS_NOTIFICATION_TOKEN or HARNESS_NOTIFICATION_SECRET as well. ``` ```json @@ -1783,11 +1798,11 @@ uv run agent-harness --db harness.sqlite serve --hold-webhook https://your-host/ Three things about it are deliberate. -- **It is not a notification system.** One URL, one POST, no retries and no - queue. What is on the other end — a session host that already has push - notifications, a chat relay you wrote, a log file — is not this service's - business, and adding a product here would be the coupling `AGENTS.md` - forbids. +- **Delivery is a durable notification subsystem.** Selected hold, failure, + completion and review outcomes enter an append-only outbox and are retried + after receiver failure or process restart. The built-in webhook channel + requires a bearer token or HMAC secret; other destinations remain opt-in + channels rather than core knowledge. - **A failed delivery is dropped, never raised.** It cannot fail the item, stall it, or un-hold it. This is the rule telemetry already follows, for the same reason: the fleet must not depend on it. @@ -1795,6 +1810,17 @@ Three things about it are deliberate. spending it is an authenticated call to the API, which looks the token up itself. +### Normalized remote review sources + +Remote polling or webhook translation is an adapter concern. An installed +adapter publishes the `agent_harness.review_sources` entry point and returns +`ReviewBatch` values; the harness stores the source cursor only after every +event in that batch is accepted. `POST /api/review-poll` invokes one configured +source poll through the normal authenticated API and returns typed, +duplicate-aware results. A repeated batch is safe because `(source, +remote_id)` is the durable identity journal. No external system is contacted +unless a source adapter is installed and configured. + Configure nothing and nothing changes: the inbox is still `GET /api/holds`, and `GET /api/summary` reports `holds_open` plus a `holds_overdue` entry for any question still open past its own deadline — a status line that reads healthy diff --git a/docs/evidence/2026-08-06-stage-2-execution-environment.md b/docs/evidence/2026-08-06-stage-2-execution-environment.md new file mode 100644 index 0000000..8f3914a --- /dev/null +++ b/docs/evidence/2026-08-06-stage-2-execution-environment.md @@ -0,0 +1,63 @@ +# Stage 2 implementation evidence — execution environment + +**Date:** 2026-08-06 +**Scope:** generic contract, Docker/OCI backend wiring, and local backend +tests. No real workload was run. + +The Stage 1 loop no longer has to inherit the controller shell. With a selected +execution backend, the executor first creates a disposable, self-contained Git +checkout for the item at the exact selected base SHA; the role loop never edits +the controller checkout directly. A linked worktree is not sufficient here: +its `.git` file points at controller-only metadata that must not be mounted into +the item container. Core defines +the item-scoped execution-environment contract in +[`execution_environment.py`](../../src/agent_harness/execution_environment.py) +and resolves a selected implementation by the +`agent_harness.execution_environments` entry-point group. The shipped Docker +adapter creates one container per item, mounts only the worktree and declared +paths, passes only explicitly supplied environment variables, drops all Linux +capabilities, enables `no-new-privileges`, uses a non-root identity, applies +resource limits and removes the container on completion or startup failure. + +The command line accepts `--environment-backend docker`, a pinned +`--environment-image`, `--environment-network bridge|none`, and repeatable +`--environment-mount SOURCE:TARGET[:rw]` values. `doctor` reports the selected +backend and image before work can be claimed. Environment evidence records +variable names and image digest, never variable values. + +## Local checks + +```console +uv run pytest -q tests/test_execution_environment.py \ + tests/test_execution_environment_live.py tests/test_agent_loop_e2e.py \ + tests/test_role_runner_e2e.py tests/test_generic.py +58 passed, 2 skipped + +TMPDIR=/tmp uv run pytest +1630 passed, 3 skipped + +uv run ruff check . +All checks passed! + +uv run ruff format --check . +143 files already formatted + +TMPDIR=/tmp uv run mypy +Success: no issues found in 138 source files +``` + +The Docker command-construction tests use a controlled Docker CLI seam to +assert the actual security flags, managed item labels, mount modes, allow-listed +environment and teardown calls. A restarted worker now maps each project/item +to a deterministic disposable checkout, reaps only a stale container carrying +that exact worktree label, and removes the stale checkout before reuse. The +host in this evidence environment has Docker CLI 29.7.0, +but no reachable Docker daemon (`dial unix /var/run/docker.sock: connect: no +such file or directory`). Therefore the Stage 2 exit criterion is **not +met**: repository-wide reads/writes, sibling refusal, network access and clean +teardown have not yet been exercised against a live configured backend. + +This evidence authorises no real workload run. A live-daemon acceptance run is +still required before Stage 2 can exit. Stage 3 wiring may be developed against +fixture backends, but its acceptance also remains pending until the live +backend and failure-isolation criteria are exercised. diff --git a/docs/evidence/2026-08-06-stage-4-plan-integration.md b/docs/evidence/2026-08-06-stage-4-plan-integration.md new file mode 100644 index 0000000..6da9b1b --- /dev/null +++ b/docs/evidence/2026-08-06-stage-4-plan-integration.md @@ -0,0 +1,126 @@ +# Stage 4 implementation evidence — local plan integration + +**Date:** 2026-08-07 +**Scope:** local fixture repositories only; no provider, remote, GitHub, +AIDevEnv, session-host or CLI-agent process was contacted. + +This package records the local evidence for the Stage 4 implementation slice in +[`docs/STATUS.md`](../STATUS.md). It does not authorise a real workload run or +claim the Stage 4 exit while the live execution boundary and Stage 3 failure +isolation criteria remain pending. + +## What was exercised + +The fleet ran a project with two independent items (`A` and `B`) and a +dependent item (`C`) using two workers, the generic executor factory and a +fixture execution backend. Each independent item ran from the same plan base. +Their item changes were promoted serially to the durable local plan branch. +Only after both promotion records existed did the queue release `C`; its +runner observed both promoted files in its checkout. Queue admission now +checks those promotion records inside the claim transaction, so an item that +is merely `done` cannot race ahead of its integration branch. The test also +covers the conflicting-promotion path and verifies that a conflict leaves the +plan head unchanged and records no successful promotion for the conflicting +item. Advisory dependencies remain reportable without becoming mandatory +promotion prerequisites. Promotion records are marked `applying` before the +Git ref update; coordinator startup reconciles that record against the old and +new ref, so a crash around the ref update is recoverable rather than silently +becoming plan drift. + +The coordinator now serialises first-use plan creation as well as promotion, +including across separate coordinator instances in the same serving process +and across processes through a durable, expiring SQLite lease with heartbeat. +An actual two-process fixture holds one process inside the authoritative gate, +proves the other waits without entering its gate, and then verifies both +promotions complete on the shared branch. +The executor computes the candidate diff against the immutable item base SHA. +This keeps a dependent self-contained checkout independent of controller local +branch refs while still preserving its complete candidate tree. A +promotion's plan-head projection and successful promotion history are written +in one SQLite transaction, so a restart cannot expose an advanced head without +the prerequisite fact that releases dependants. + +A promotion conflict is a repairable item outcome at the executor boundary. +The runtime preserves the typed conflict status, the item returns to `pending` +with a `withheld/plan_promotion_conflict` disposition, its retry budget is not +consumed, and the detail names the exact current plan head to repair against. +Other coordinator errors remain escalated rather than being silently retried. + +When the target branch moves, the coordinator journals the refresh before +replaying any promoted item. It rebuilds from the new target SHA, reapplies the +recorded item deltas in promotion order, reruns the authoritative checks after +each replay, and advances the local plan ref and projection only after the +rebuild is ready. A conflict or failed gate closes as a durable refresh result +and leaves the prior plan untouched. If the process stops after the Git ref +update, startup reconciles the refresh journal and completes the projection. +The target is also revalidated around item integration: a target move during +the authoritative gate supersedes the stale promotion attempt and retries from +the newer target. Promotion events retain the immutable promotion id, item +commit, item base, prior and resulting plan heads, target SHA, status and +detail in the existing event stream; the item commit is also retained as the +second parent of the local +plan merge commit. Event delivery is diagnostic and cannot make a durable +promotion fail. + +## Commands and results + +```console +uv run pytest -q tests/test_plan_integration.py +26 passed + +TMPDIR=/tmp uv run pytest -q +passed at 100%; Docker-dependent tests were skipped because this host has no +reachable Docker daemon. + +TMPDIR=/tmp uv run pytest -q tests/test_plan_integration.py tests/test_generic.py +35 passed + +uv run ruff check . +All checks passed! + +uv run ruff format --check . +145 files already formatted + +TMPDIR=/tmp uv run mypy +Success: no issues found in 140 source files + +git diff --check +clean +``` + +The full-suite run is the repository regression denominator for this slice. +The Docker skips are expected environment limitations, not passing evidence +for the live execution boundary. The implementation remains in the shared +working tree and has not been pushed or published. + +## Acceptance table + +| Criterion | Result | Test/evidence | +|---|---|---| +| Exact target SHA and durable plan identity | pass | `test_plan_branch_is_created_from_exact_target_and_survives_restart` | +| Serialised independent promotions | pass | `test_independent_promotions_then_dependent_sees_both` and fleet acceptance | +| Separate coordinator instances and processes share serialized promotion ownership | pass | `test_separate_coordinators_serialize_integration_gates`, `test_plan_promotion_lease_blocks_live_owner_and_allows_expiry_takeover` | +| Separate OS processes do not overlap authoritative promotion gates | pass | `test_separate_processes_serialize_authoritative_promotion_gates` | +| Older item base is replayed onto the current plan head | pass | `test_promotion_replays_item_created_from_older_plan_head` | +| Item commits remain in plan-branch ancestry after promotion and refresh replay | pass | `test_promotion_replays_item_created_from_older_plan_head`, `test_target_move_rebuilds_plan_and_replays_promoted_items` | +| Dependent item commits replay cleanly after a target move | pass | `test_target_move_replays_dependent_item_created_from_promoted_plan_head` | +| Queue admission waits for every prerequisite promotion | pass | `test_dependent_admission_waits_for_every_prerequisite_promotion` | +| Configured plan dependants cannot be admitted before durable plan initialization | pass | `test_dependent_admission_waits_for_plan_initialization` | +| Advisory dependency does not block promotion | pass | `test_advisory_local_dependency_does_not_block_promotion` | +| Restart recovers or abandons an interrupted Git ref update safely and emits its durable identity | pass | `test_restart_recovers_promotion_after_git_ref_advanced`, `test_restart_abandons_promotion_when_git_ref_did_not_move` | +| Restart recovery re-emits the same durable promotion identity | pass | `test_restart_recovers_promotion_after_git_ref_advanced` | +| Target movement replays promoted items from the new target and reruns gates | pass | `test_target_move_rebuilds_plan_and_replays_promoted_items` | +| Long-lived promotion refreshes a moved target before applying new work | pass | `test_promotion_refreshes_plan_when_target_moves_during_long_lived_run` | +| Target movement during replay supersedes the stale rebuild and retries from the newer target | pass | `test_refresh_restarts_if_target_moves_while_replaying_promotions` | +| Target movement during promotion is rechecked before and after publication | pass | `test_promotion_rechecks_target_after_integration_gates` | +| Target-refresh conflict/failure is durable and leaves the existing plan unchanged | pass | `test_target_move_conflict_leaves_existing_plan_and_projection_unchanged` | +| Restart recovers a refresh after the Git ref advanced | pass | `test_restart_recovers_refresh_after_git_ref_advanced` | +| Restart abandons an unadvanced refresh conservatively | pass | `test_restart_abandons_refresh_when_git_ref_did_not_move` | +| Promotion reconciles a pending refresh before continuing | pass | `test_promotion_recovers_pending_refresh_before_continuing` | +| Refresh gate failure closes its durable journal | pass | `test_refresh_gate_failure_closes_refresh_journal` | +| Dependent item waits for and sees both prerequisites | pass | `test_fleet_promotes_two_independent_items_before_the_dependent_item` | +| Conflict is surfaced without advancing the plan head | pass | `test_conflicting_promotion_is_returned_for_repair_and_head_is_unchanged` | +| Promotion conflict returns the item to agent work without consuming an attempt | pass | `test_promotion_conflict_returns_item_to_work_without_consuming_attempt` | +| Promotion records are retained | pass | fleet acceptance assertions on all three items | +| Promotion events retain immutable promotion-row identities for success, conflict, and recovery | pass | `test_promotion_event_retains_item_and_plan_commit_identity`, `test_conflicting_promotion_is_returned_for_repair_and_head_is_unchanged`, `test_restart_recovers_promotion_after_git_ref_advanced` | +| Remote publication or real workload delivery | not exercised | explicitly outside this fixture evidence | diff --git a/docs/evidence/2026-08-08-stage-5-review-source-and-publication.md b/docs/evidence/2026-08-08-stage-5-review-source-and-publication.md new file mode 100644 index 0000000..a0448e7 --- /dev/null +++ b/docs/evidence/2026-08-08-stage-5-review-source-and-publication.md @@ -0,0 +1,186 @@ +# Stage 5 implementation evidence — installed review source and single-PR publication + +**Date:** 2026-08-08 +**Scope:** local fixture repositories, a local bare Git remote and injected +clients only. No provider, no GitHub, no network, no AIDevEnv, no session host +and no CLI-agent process was contacted. Nothing was pushed to any remote this +repository does not create inside a temporary directory. + +This package records one Stage 5 implementation slice from +[`docs/STATUS.md`](../STATUS.md) §2.6. It does **not** claim the Stage 5 exit, +does not authorise a real workload run, and does not authorise publication. + +## What was added + +Two of the three items STATUS listed as open for Stage 5 now have +implementations and local tests. The third — remote workload acceptance — +cannot be closed by code and is untouched. + +### 1. An installed review source, `github-pr-review` + +Previously the normalized review-event contract had no installed adapter at +all: `agent_harness.review_sources` was an empty entry-point group, so a +deployment could not select a source by name. `adapters/github_pr_review.py` +now supplies one, on exactly the terms `review_sources.resolve` already +enforced — core imports nothing from it, and the adapter is reached only when +installed metadata is asked for that name. + +The adapter owns the two things GitHub knows and the harness does not: + +- **Immutable identity.** Reviews and review comments number independently on + their own endpoints, so identity is `REPO#PR/endpoint/id` rather than the id + alone. Deduplication remains the queue's, on that identity. +- **Disposition.** Decided by explicit, deterministic rules, never by a model + reading prose. Explicit markers (`harness: fix` / `hold` / `resolved`) win; + otherwise `CHANGES_REQUESTED` is actionable and `APPROVED` is already + resolved; **anything else defaults to ambiguous**, which opens a hold for a + person rather than sending an agent after a guess. + +The item a comment concerns is likewise explicit — `harness-item: T3` names +one, and the configured `default_item_id` is used otherwise, because a plan +pull request carries every item in the plan and this adapter will not infer +which one a line comment belongs to. + +`gh` is injected, so the tests exercise the real polling, cursor and identity +logic without a network or a credential. + +### 2. Single-pull-request publication with resume, `plan_publication.py` + +`PlanPublisher` is the one remote step the product permits, kept in its own +module so local promotion never depends on a remote being reachable and a +deployment that never publishes never loads it. Three properties make it safe +to call repeatedly, which is what "corrections resume automatically" requires: + +- **One pull request per plan.** A durable record is consulted first, then + `find_open_pr`, before anything is created. A failure to *ask* is a refusal, + not permission to create a second one. There is never a per-item PR. +- **An unchanged plan head does nothing.** A duplicate review event, a retried + poll or a correction that promoted nothing does not push, does not comment + and does not re-present a decision a person already has. +- **The harness never merges.** Nothing here approves, marks ready or merges. + +The push is `--force-with-lease` against the sha this harness last published, +because a plan branch legitimately rewinds when a moved target rebuilds it and +replays every promotion. The lease turns "the branch was rebuilt" into a safe +update and "somebody else pushed to it" into a named refusal. + +With no durable record — a first publication, or an adopted pull request whose +record was lost — the lease is derived rather than skipped. The remote branch +is fetched; if it is absent the lease is empty, which asserts the ref does not +exist; if it is present and this plan already contains it, it becomes the +lease; and if it is present and unexplained, publication is refused by name +rather than discarding work the harness cannot see. + +### 3. Publication wired into the fleet, on the plan's terms + +`direct_executor_factory` previously refused `push=True` whenever a project had +plan integration, because the only thing push could have meant was publishing +item branches. It now means what P7/P8 say it should: the executor is given no +GitHub client and never pushes an item branch, and a `PlanPublisher` is built +for the plan instead. + +After each successful promotion the factory asks whether the plan has stopped +moving. `PlanPublisher.readiness()` answers no while anything is pending, +claimed or held, and also no while anything failed, exhausted or blocked — +that second case is an exception for a person under P10, not a reason to ship +a partial plan whose gaps only the queue knows about. The item whose promotion +is asking is excluded from the in-flight count, because it is still claimed at +that moment while its work is already in the plan branch; without that, the +last item of a plan could never trigger publication. + +A publication failure cannot fail the item. The promotion is already gated and +local when this runs, so a remote that is down, slow or refusing is reported as +a `plan_publication_failed` event and the promotion stands. + +`test_generic.py`'s `EXECUTION_PATH` was extended to hold plan integration, +publication, review intake and notifications to the same no-adapter, +no-workload-name rule as the rest of the path. + +## Commands and results + +```console +uv run pytest -q tests/test_github_pr_review_source.py +12 passed + +uv run pytest -q tests/test_plan_publication.py +17 passed + +uv run pytest -q tests/test_plan_integration.py +27 passed + +uv run pytest -q tests/test_generic.py +9 passed + +TMPDIR=/tmp uv run pytest -q +1712 collected; passed at 100% with one skip. The skip is the live Docker +backend test: this host has a Docker CLI but no reachable daemon. + +uv run ruff check src tests +All checks passed! + +uv run ruff format --check src tests +150 files already formatted + +TMPDIR=/tmp uv run mypy +Success: no issues found in 150 source files +``` + +The full-suite run is the regression denominator for this slice. It also +covers two unblocked defects fixed on the same tree and recorded in +`STATUS.md` §3.3 — #220 (`queue.now()` is authoritative for a lease on the +retry and block routes) and #223 (`tool` removed from `_WIRE_ROLES`) — which +are not part of this Stage 5 slice and are not claimed by it. Docker-backed +tests skip on this host, which has no reachable daemon; those skips remain an +environment limitation and are not evidence for the Stage 2 execution +boundary. + +## Acceptance table + +| Criterion | Result | Test/evidence | +|---|---|---| +| A review source is installed and resolves by name through metadata | pass | `test_the_source_is_installed_and_resolves_by_name` | +| Unmarked human prose becomes a hold, not guessed work | pass | `test_unmarked_prose_is_ambiguous_rather_than_guessed_at`, `test_polling_the_installed_source_creates_correction_work_once` | +| Explicit markers and review state decide disposition deterministically | pass | `test_explicit_markers_decide_the_disposition`, `test_review_state_decides_when_no_marker_is_present` | +| Reviews and review comments sharing a number stay distinct | pass | `test_identity_separates_reviews_from_review_comments_sharing_a_number` | +| An unsubmitted draft review produces no work | pass | `test_an_unsubmitted_draft_review_is_not_feedback_yet` | +| Actionable feedback creates correction work once; replay is a no-op | pass | `test_polling_the_installed_source_creates_correction_work_once` | +| A source failure leaves the cursor unadvanced | pass | `test_unreadable_output_fails_loudly_rather_than_polling_empty`, existing `test_poller_leaves_cursor_unchanged_when_processing_fails` | +| First publication pushes the plan branch and opens exactly one PR | pass | `test_the_first_publication_pushes_the_branch_and_opens_one_pr` | +| A correction updates that same PR's branch; no second PR | pass | `test_a_correction_updates_the_same_pr_rather_than_opening_another` | +| An unchanged plan head touches no remote | pass | `test_republishing_an_unchanged_head_touches_nothing` | +| An existing remote PR is adopted rather than duplicated | pass | `test_an_existing_remote_pr_is_adopted_instead_of_duplicated` | +| A rebuilt (rewound) plan branch still publishes | pass | `test_a_rebuilt_plan_branch_still_publishes_under_the_lease` | +| A branch moved by somebody else is refused, not clobbered | pass | `test_a_branch_moved_by_somebody_else_is_refused` | +| A lost record does not strand an adopted PR this plan already contains | pass | `test_an_adopted_branch_this_plan_already_contains_is_published` | +| An unexplained remote branch is refused rather than discarded | pass | `test_an_unexplained_remote_branch_is_not_discarded` | +| An unreadable or mismatched record never opens a second PR | pass | `test_an_unreadable_record_is_reported_rather_than_overwritten`, `test_publishing_a_second_branch_for_one_plan_is_refused` | +| A failed PR comment does not lose the published head | pass | `test_a_failed_comment_does_not_lose_the_published_head` | +| Publication is reported as one event | pass | `test_publication_is_reported_as_one_event` | +| Publication waits for work that could still change the tree | pass | `test_readiness_waits_for_work_that_could_still_change_the_tree` | +| An item that did not deliver withholds publication for a person | pass | `test_readiness_withholds_publication_when_an_item_did_not_deliver` | +| The promoting item does not block its own plan | pass | `test_the_promoting_item_does_not_block_its_own_plan` | +| An empty plan is not published | pass | `test_an_empty_plan_is_not_something_to_publish` | +| A fleet publishes one plan PR only once the plan is finished, and a later correction updates it | pass | `test_fleet_publishes_one_plan_pr_only_when_the_plan_is_finished` | +| No item branch reaches the remote when a plan owns integration | pass | same test — the bare remote holds only `main` and the plan branch | +| A remote failure cannot fail a promoted item | pass | by construction: `_publish_if_ready` reports `plan_publication_failed` and returns | +| The harness merges, approves or marks ready | never | no such call exists in `plan_publication.py` | + +## What this does not show + +Named explicitly, because the tests above could otherwise be read as more than +they are: + +- **No real remote was contacted.** The pull-request client is a fake and the + Git remote is a bare repository in a temporary directory. `gh` was never + run, and no GitHub API behaviour is evidence here. +- **The wiring has never run against a real remote.** The fleet test drives + the real `direct_executor_factory` path, but its remote is a bare repository + in a temporary directory and its pull-request client is a fake. Nothing here + says how GitHub behaves, and P12 still forbids pushing between slices — a + deployment only reaches this path by setting `push=True` on a project that + has a plan branch and a repo. +- **The review source is installed but not deployed.** Nothing has polled a + real pull request, so the marker convention has never been used by a real + reviewer and the `since`/pagination behaviour is untested against GitHub. +- **The Stage 5 exit is not claimed**, and neither is Stage 2's live execution + boundary, on which every later stage still depends. diff --git a/pyproject.toml b/pyproject.toml index 331ea3f..4a47548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,15 @@ github-issue = "agent_harness.adapters.github_issue:resolver" [project.entry-points."agent_harness.role_runners"] agent-loop = "agent_harness.adapters.minisweagent:RUNNER" +[project.entry-points."agent_harness.execution_environments"] +docker = "agent_harness.adapters.docker:BACKEND" + +# Sources of normalized remote review events. Core owns correction semantics +# and deduplication; a source adapter owns one upstream's authentication, +# polling and immutable record identity. Nothing in core imports one. +[project.entry-points."agent_harness.review_sources"] +github-pr-review = "agent_harness.adapters.github_pr_review:source" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/src/agent_harness/__main__.py b/src/agent_harness/__main__.py index 690a9f9..1eba4d6 100644 --- a/src/agent_harness/__main__.py +++ b/src/agent_harness/__main__.py @@ -36,6 +36,22 @@ log = logging.getLogger(__name__) +def _environment_mounts(values: Sequence[str]) -> tuple[Any, ...]: + """Parse explicit host mounts for an execution backend.""" + from .execution_environment import EnvironmentMount + + mounts = [] + for value in values: + parts = value.split(":") + if len(parts) not in (2, 3) or not parts[0] or not parts[1]: + raise ValueError(f"invalid --environment-mount {value!r}; expected SOURCE:TARGET[:rw]") + mode = parts[2] if len(parts) == 3 else "ro" + if mode not in {"ro", "rw"}: + raise ValueError(f"invalid mount mode {mode!r}; use ro or rw") + mounts.append(EnvironmentMount(Path(parts[0]).resolve(), parts[1], writable=mode == "rw")) + return tuple(mounts) + + def resolve_sources(args: argparse.Namespace) -> list[Source]: """Turn CLI arguments into sources. Adapters are imported lazily so the core never depends on one.""" @@ -589,6 +605,51 @@ def _run(args: argparse.Namespace) -> int: print(f"role runner: {runner_detail}") else: print("role runner: direct single-shot implementer (historical path)") + environment_factory = None + environment_mount_spec: tuple[Any, ...] = () + environment_backend = str( + args.environment_backend or queue.get_setting("execution_backend") or "" + ).strip() + environment_image = str( + args.environment_image or queue.get_setting("execution_image") or "" + ).strip() + if environment_backend: + if role_runner is None: + print( + "execution environment: an OS-enforced backend requires --role-runner; " + "the historical single-shot path cannot use it", + file=sys.stderr, + ) + return 2 + from .execution_environments import resolve as resolve_environment + + try: + environment_factory = resolve_environment(environment_backend) + environment_mount_spec = _environment_mounts(args.environment_mount) + except Exception as exc: # noqa: BLE001 - configuration refusal + print(f"execution environment: {exc}", file=sys.stderr) + return 2 + if not environment_image: + print( + "execution environment: --environment-image is required when a backend is selected", + file=sys.stderr, + ) + return 2 + stored_environment = queue.get_setting("execution_backend") + if args.environment_backend and stored_environment != environment_backend: + queue.set_setting("execution_backend", environment_backend) + if environment_image != queue.get_setting("execution_image"): + queue.set_setting("execution_image", environment_image) + backend_ok, backend_detail = environment_factory.check() + if not backend_ok: + print( + f"execution environment: {environment_backend} is not ready: {backend_detail}", + file=sys.stderr, + ) + return 2 + print(f"execution environment: {environment_backend} ({environment_image})") + else: + print("execution environment: host compatibility backend (not an OS security boundary)") # This project's counts, not the rollup. `--project` decides which queue # this run works, so a cross-project total here would report items no # worker in this process can claim. @@ -858,6 +919,10 @@ def live_routes() -> dict[str, Chain]: role_runner=role_runner, runner_step_limit=args.runner_step_limit, runner_command_timeout=args.runner_command_timeout, + environment_factory=environment_factory, + environment_image=environment_image, + environment_mounts=environment_mount_spec, + environment_network=args.environment_network, ) # Typing `agent-harness run` IS the human deciding to start this project. # A project starts `stopped` so a restart never resumes on its own, but @@ -1506,6 +1571,33 @@ def main(argv: list[str] | None = None) -> int: metavar="SECONDS", help="timeout for one feedback command inside the role loop (default 300).", ) + p_run.add_argument( + "--environment-backend", + default=os.environ.get("HARNESS_EXECUTION_BACKEND", ""), + metavar="NAME", + help="OS-enforced item command backend resolved through installed metadata " + "(or $HARNESS_EXECUTION_BACKEND); empty keeps the fixture-only host path.", + ) + p_run.add_argument( + "--environment-image", + default=os.environ.get("HARNESS_EXECUTION_IMAGE", ""), + metavar="IMAGE", + help="pinned OCI image reference used by the selected execution backend " + "(or $HARNESS_EXECUTION_IMAGE).", + ) + p_run.add_argument( + "--environment-network", + choices=("bridge", "none"), + default=os.environ.get("HARNESS_EXECUTION_NETWORK", "bridge"), + help="container network policy; bridge keeps ordinary outbound access, none disables it.", + ) + p_run.add_argument( + "--environment-mount", + action="append", + default=[], + metavar="SOURCE:TARGET[:rw]", + help="explicit dependency/toolchain mount, read-only by default; repeatable.", + ) p_run.add_argument( "--agent", # The executor's default, not a second one. They disagreed: the @@ -1683,6 +1775,48 @@ def main(argv: list[str] | None = None) -> int: "service can start work. WITHOUT it the packaged GUI and every read work " "in monitoring-only mode, while starting is refused because nothing can claim.", ) + p_serve.add_argument( + "--role-runner", + default=os.environ.get("HARNESS_ROLE_RUNNER", ""), + metavar="NAME", + help="in-process repository-aware role runner for a local fleet; selected through " + "installed metadata and paired with an execution backend.", + ) + p_serve.add_argument( + "--environment-backend", + default=os.environ.get("HARNESS_EXECUTION_BACKEND", ""), + metavar="NAME", + help="OS-enforced item command backend for a local fleet, resolved through metadata.", + ) + p_serve.add_argument( + "--environment-image", + default=os.environ.get("HARNESS_EXECUTION_IMAGE", ""), + metavar="IMAGE", + help="pinned OCI image reference for the local fleet execution backend.", + ) + p_serve.add_argument( + "--environment-network", + choices=("bridge", "none"), + default=os.environ.get("HARNESS_EXECUTION_NETWORK", "bridge"), + help="container network policy for the local fleet.", + ) + p_serve.add_argument( + "--environment-mount", + action="append", + default=[], + metavar="SOURCE:TARGET[:rw]", + help="explicit local-fleet dependency/toolchain mount; read-only by default.", + ) + p_serve.add_argument( + "--planner", + default=os.environ.get("HARNESS_PLANNER", ""), + help="model for the planner in an in-process local fleet.", + ) + p_serve.add_argument( + "--implementer", + default=os.environ.get("HARNESS_IMPLEMENTER", ""), + help="model for the implementer in an in-process local fleet.", + ) p_serve.add_argument( "--agent", # The same resolved default as `run`, and for the same reason. This @@ -1720,6 +1854,18 @@ def main(argv: list[str] | None = None) -> int: metavar="PATH", help="where the fleet appends its event stream. Defaults to events.jsonl beside --db.", ) + p_serve.add_argument( + "--runner-step-limit", + type=int, + default=80, + help="whole-loop tool-step ceiling for the in-process local fleet.", + ) + p_serve.add_argument( + "--runner-command-timeout", + type=int, + default=300, + help="feedback-command timeout inside each local-fleet item environment.", + ) p_serve.add_argument( "--hold-webhook", default=os.environ.get("HARNESS_HOLD_WEBHOOK", ""), @@ -1729,6 +1875,20 @@ def main(argv: list[str] | None = None) -> int: "decides what a question means to it. Delivery is best-effort and can never " "fail or stall the item.", ) + p_serve.add_argument( + "--notification-webhook", + default=os.environ.get("HARNESS_NOTIFICATION_WEBHOOK", ""), + metavar="URL", + help="authenticated endpoint for durable harness notifications. Configure " + "HARNESS_NOTIFICATION_TOKEN or HARNESS_NOTIFICATION_SECRET as well.", + ) + p_serve.add_argument( + "--notification-db", + type=Path, + default=None, + metavar="PATH", + help="SQLite outbox for notifications. Defaults beside --db.", + ) p_serve.add_argument( "--no-push", action="store_true", help="commit locally but do not push or open PRs" ) @@ -1819,6 +1979,7 @@ def main(argv: list[str] | None = None) -> int: from .audit import open_audit_store from .holds import webhook_hook from .maintenance import DEFAULT_RETENTION_DAYS, MaintenanceLoop + from .notifications import NotificationOutbox, WebhookChannel from .work import WorkQueue # A separate file, deliberately. History must not share a fate with the @@ -1847,6 +2008,23 @@ def main(argv: list[str] | None = None) -> int: else: print(f"audit: {audit_path} ({audit.count()} events)") + notification_path = ( + args.notification_db + or os.environ.get("HARNESS_NOTIFICATION_DB") + or Path(args.db).with_name("notifications.sqlite") + ) + notification_url = str(args.notification_webhook or "").strip() + notification_channel = None + if notification_url: + notification_channel = WebhookChannel( + notification_url, + bearer_token=os.environ.get("HARNESS_NOTIFICATION_TOKEN", ""), + hmac_secret=os.environ.get("HARNESS_NOTIFICATION_SECRET", ""), + ) + notifications = NotificationOutbox(notification_path, notification_channel) + if notification_url: + print(f"notifications: durable outbox at {notification_path}") + # Started here rather than left to cron: retention that depends on an # external scheduler silently stops when nobody installs it, and the # symptom is a database that grows for months before anyone notices. @@ -1866,16 +2044,28 @@ def main(argv: list[str] | None = None) -> int: maintenance.start() fleet, reviewer_client, host, executor_roles = _fleet_for_serve( - args, queue_for_serve, audit=audit + args, queue_for_serve, audit=audit, notifications=notifications ) if fleet is None: print( - "monitoring only: no --session-host, so no worker pool is attached and " + "monitoring only: no executor is configured, so no worker pool is attached and " "starting a project will be refused.", file=sys.stderr, ) + execution_environment_probe = None + remote_required = True + if fleet is not None and not args.session_host: + from .execution_environments import probe as probe_execution_environment + + backend_name = str(queue_for_serve.get_setting("execution_backend") or "") + execution_environment_probe = lambda: probe_execution_environment( # noqa: E731 + backend_name + ) + remote_required = False + try: + notifications.start() uvicorn.run( create_api( store, @@ -1883,6 +2073,7 @@ def main(argv: list[str] | None = None) -> int: token=token, root_path=args.root_path, audit=audit, + notifications=notifications, fleet=fleet, model_client=reviewer_client, # Readiness probes it with a read. Passing the client rather @@ -1892,6 +2083,8 @@ def main(argv: list[str] | None = None) -> int: # The same default the workers route with, so a readiness # probe asks the URL the work will actually use. default_preset=args.preset, + execution_environment=execution_environment_probe, + remote_required=remote_required, ), host=args.host, port=args.port, @@ -1903,11 +2096,16 @@ def main(argv: list[str] | None = None) -> int: # context that makes its work resumable, so in-flight work is # joined and only new claims stop. fleet.stop_all(reason="the harness process is stopping") + notifications.close() return 0 def _fleet_for_serve( - args: argparse.Namespace, queue: Any, *, audit: Any | None = None + args: argparse.Namespace, + queue: Any, + *, + audit: Any | None = None, + notifications: Any | None = None, ) -> tuple[Any | None, Any | None, Any | None, Any | None]: """The supervised half of `serve`: a fleet the API's start action can use. @@ -1919,7 +2117,10 @@ def _fleet_for_serve( **Nothing is started here.** Building the fleet creates no workers; only the API's start action does, and only after preflight passes. """ - if not args.session_host: + configured_runner = str( + getattr(args, "role_runner", "") or queue.get_setting("role_runner") or "" + ).strip() + if not args.session_host and not configured_runner: return (None, None, None, None) import json as _json @@ -1930,13 +2131,73 @@ def _fleet_for_serve( from .github import GitHub from .holds import fanout from .model_client import Chain, ModelClient, chains_from_map, effective_routes - from .runtime import ExecutorRoles, session_executor_factory + from .runtime import ExecutorRoles, direct_executor_factory, session_executor_factory from .session_executor import AgentSpec from .session_host import HttpSessionHost api_key = os.environ.get("HARNESS_API_KEY", "") host_token = os.environ.get("AIDEVENV_TOKEN", "") or api_key + direct_mode = not bool(args.session_host) + if direct_mode: + from .execution_environments import resolve as resolve_environment + from .role_runners import describe as describe_runner + from .role_runners import resolve as resolve_runner + + runner_name = configured_runner + backend_name = str( + getattr(args, "environment_backend", "") or queue.get_setting("execution_backend") or "" + ).strip() + image = str( + getattr(args, "environment_image", "") or queue.get_setting("execution_image") or "" + ).strip() + if not backend_name or not image: + raise SystemExit("local fleet requires --environment-backend and --environment-image") + try: + runner = resolve_runner(runner_name) + backend = resolve_environment(backend_name) + ready, detail = backend.check() + except Exception as exc: # noqa: BLE001 - deployment refusal before serving + print(f"local fleet: {exc}", file=sys.stderr) + raise SystemExit(2) from exc + if not ready: + print(f"local fleet execution backend is not ready: {detail}", file=sys.stderr) + raise SystemExit(2) + if getattr(args, "role_runner", ""): + queue.set_setting("role_runner", runner_name) + if getattr(args, "environment_backend", ""): + queue.set_setting("execution_backend", backend_name) + if getattr(args, "environment_image", ""): + queue.set_setting("execution_image", image) + if ( + getattr(args, "planner", "") + and args.endpoint + and "planner" not in (queue.get_setting(ROLE_MAP_KEY) or {}) + ): + stored = { + **(queue.get_setting(ROLE_MAP_KEY) or {}), + "planner": { + "model": args.planner, + "endpoint": args.endpoint, + "provider": "claw-bay", + }, + } + queue.set_setting(ROLE_MAP_KEY, stored) + if ( + getattr(args, "implementer", "") + and args.endpoint + and "implementer" not in (queue.get_setting(ROLE_MAP_KEY) or {}) + ): + stored = { + **(queue.get_setting(ROLE_MAP_KEY) or {}), + "implementer": { + "model": args.implementer, + "endpoint": args.endpoint, + "provider": "claw-bay", + }, + } + queue.set_setting(ROLE_MAP_KEY, stored) + # Seed the reviewer route from the flags when the stored map has none. # The stored map wins where it has an opinion: re-routing a role live # through PUT /api/roles is the reason it exists. @@ -1983,6 +2244,11 @@ def routes_for(project_id: str) -> dict[str, Chain]: ) routes = live_routes() + required_roles = {"reviewer"} if not direct_mode else {"planner", "implementer", "reviewer"} + if direct_mode and not required_roles <= set(routes): + missing = ", ".join(sorted(required_roles - set(routes))) + print(f"local fleet: no route for role(s): {missing}", file=sys.stderr) + raise SystemExit(2) if "reviewer" not in routes: # Not fatal, and not silent: preflight blocks the start with exactly # this reason, so the fleet may as well exist and say why now. @@ -2003,48 +2269,52 @@ def emit(event: dict[str, Any]) -> None: # A broken convenience stream must not prevent the durable audit # sink below from recording the event, or stop the work itself. log.warning("events: could not append %s", events_path, exc_info=True) - if audit is None: - return - # The JSONL stream remains tail-able, but the audit database is the - # durable system of record used by every projection. Keep the sink - # translation here, at the deployment boundary, so the core remains - # generic about producers and their payloads. - kind = event.get("kind") - if kind not in KINDS: - kind = MODEL_CALL - known = { - "ts", - "kind", - "source", - "worker", - "role", - "model", - "endpoint", - "outcome", - "error_class", - "latency_s", - } - data = {k: v for k, v in event.items() if k not in known} - try: - audit.append( - [ - Event( - ts=float(event.get("ts", time.time())), - kind=kind, - source="serve", - worker=event.get("worker"), - role=event.get("role"), - model=event.get("model"), - endpoint=event.get("endpoint"), - outcome=event.get("outcome"), - error_class=event.get("error_class"), - latency_s=event.get("latency_s"), - data=data, - ) - ] - ) - except Exception: # telemetry is never load-bearing - log.warning("audit: could not append live event", exc_info=True) + if audit is not None: + # The JSONL stream remains tail-able, but the audit database is the + # durable system of record used by every projection. Keep the sink + # translation here, at the deployment boundary, so the core remains + # generic about producers and their payloads. + kind = event.get("kind") + if kind not in KINDS: + kind = MODEL_CALL + known = { + "ts", + "kind", + "source", + "worker", + "role", + "model", + "endpoint", + "outcome", + "error_class", + "latency_s", + } + data = {k: v for k, v in event.items() if k not in known} + try: + audit.append( + [ + Event( + ts=float(event.get("ts", time.time())), + kind=kind, + source="serve", + worker=event.get("worker"), + role=event.get("role"), + model=event.get("model"), + endpoint=event.get("endpoint"), + outcome=event.get("outcome"), + error_class=event.get("error_class"), + latency_s=event.get("latency_s"), + data=data, + ) + ] + ) + except Exception: # telemetry is never load-bearing + log.warning("audit: could not append live event", exc_info=True) + if notifications is not None: + try: + notifications.enqueue_event(event) + except Exception: # notifications are never load-bearing + log.warning("notifications: could not enqueue live event", exc_info=True) # The question goes into the same stream as the work it stopped, next to # whatever the operator already configured (#188). Composed rather than @@ -2058,20 +2328,41 @@ def emit(event: dict[str, Any]) -> None: routes_provider=live_routes, ) - host = HttpSessionHost(args.session_host, token=host_token) - agent = AgentSpec(command=tuple(shlex.split(args.agent))) - factory = session_executor_factory( - queue, - host=host, - agent=agent, - reviewer=reviewer_client, - routes_for=routes_for, - github_for=GitHub, - ui_base_url=args.session_host, - on_event=emit, - push=not args.no_push, - ) - print(f"fleet: `{args.agent}` as sessions on {args.session_host}") + host = None + if direct_mode: + factory = direct_executor_factory( + queue, + reviewer=reviewer_client, + routes_for=routes_for, + github_for=GitHub, + on_event=emit, + push=False, + role_runner=runner, + runner_step_limit=args.runner_step_limit, + runner_command_timeout=args.runner_command_timeout, + environment_factory=backend, + environment_image=image, + environment_mounts=_environment_mounts(args.environment_mount), + environment_network=args.environment_network, + ) + print(f"fleet: `{runner.name} {describe_runner(runner)}` in-process") + roles = ExecutorRoles() + else: + host = HttpSessionHost(args.session_host, token=host_token) + agent = AgentSpec(command=tuple(shlex.split(args.agent))) + factory = session_executor_factory( + queue, + host=host, + agent=agent, + reviewer=reviewer_client, + routes_for=routes_for, + github_for=GitHub, + ui_base_url=args.session_host, + on_event=emit, + push=not args.no_push, + ) + print(f"fleet: `{args.agent}` as sessions on {args.session_host}") + roles = ExecutorRoles.for_session(agent) print(f"events: {events_path}") # The fleet emits into the same stream as the executors: a worker that # dies is recorded next to the work it was doing, not in a separate log. @@ -2081,7 +2372,7 @@ def emit(event: dict[str, Any]) -> None: host, # What this deployment will actually call, so the API can stop # advertising the two roles the agent process does instead. - ExecutorRoles.for_session(agent), + roles, ) diff --git a/src/agent_harness/adapters/docker.py b/src/agent_harness/adapters/docker.py new file mode 100644 index 0000000..65ed2e4 --- /dev/null +++ b/src/agent_harness/adapters/docker.py @@ -0,0 +1,262 @@ +"""Docker/OCI execution backend. + +The backend creates one short-lived container per item and uses ``docker exec`` +for the loop's individual commands. The controller's environment is never +inherited: only explicitly supplied variable names and values are passed to +the container. The Docker socket is not mounted, and the container is +non-root with all Linux capabilities dropped. +""" + +from __future__ import annotations + +import shlex +import shutil +import subprocess +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ..execution_environment import ( + API_VERSION, + EnvironmentMount, + EnvironmentResult, + EnvironmentSpec, + ExecutionEnvironment, +) + + +class DockerEnvironmentError(RuntimeError): + """The configured Docker backend could not create or use its container.""" + + +@dataclass +class DockerItemEnvironment: + spec: EnvironmentSpec + container: str = "" + digest: str | None = None + started: bool = False + + name = "docker" + api_version = API_VERSION + version = "docker-cli" + + @property + def cwd(self) -> str: + return "/workspace" + + def _docker(self, *args: str, timeout: float | None = None) -> subprocess.CompletedProcess[str]: + if shutil.which("docker") is None: + raise DockerEnvironmentError("docker is not installed or is not on PATH") + try: + return subprocess.run( + ["docker", *args], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise DockerEnvironmentError(f"docker command failed to start: {exc}") from exc + + def check(self) -> tuple[bool, str]: + if shutil.which("docker") is None: + return False, "docker is not installed or is not on PATH" + result = self._docker("info", "--format", "{{.ServerVersion}}", timeout=5) + if result.returncode != 0: + detail = result.stderr.strip().splitlines()[-1:] or ["Docker daemon is unavailable"] + return False, detail[0] + return True, f"Docker daemon {result.stdout.strip() or 'available'}" + + def _ensure_started(self) -> None: + if self.started: + return + if not self.spec.worktree.is_dir(): + raise DockerEnvironmentError(f"item worktree does not exist: {self.spec.worktree}") + self.container = "agent-harness-" + uuid.uuid4().hex + args = [ + "create", + "--name", + self.container, + "--label", + "agent_harness.managed=true", + "--label", + f"agent_harness.worktree={self.spec.worktree}", + "--user", + self.spec.user, + "--workdir", + "/workspace", + "--network", + self.spec.network, + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges=true", + "--pids-limit", + str(self.spec.pids_limit), + "--memory", + self.spec.memory_limit, + "--cpus", + self.spec.cpus, + "--tmpfs", + f"/tmp:rw,nosuid,nodev,size={self.spec.tmpfs_size}", + "-v", + f"{self.spec.worktree}:/workspace:rw", + ] + if self.spec.rootfs_read_only: + args.append("--read-only") + for mount in self.spec.mounts: + mode = "rw" if mount.writable else "ro" + args.extend(("-v", f"{mount.source}:{mount.target}:{mode}")) + for name, value in sorted(self.spec.environment.items()): + args.extend(("-e", f"{name}={value}")) + # Keep the container alive without running user code. Commands are + # separately audited and executed by `exec`, not interpolated here. + args.extend((self.spec.image, "/bin/sh", "-c", "while :; do sleep 3600; done")) + created = self._docker(*args, timeout=30) + if created.returncode != 0: + raise DockerEnvironmentError(created.stderr.strip()[-1000:]) + started = self._docker("start", self.container, timeout=30) + if started.returncode != 0: + self.close() + raise DockerEnvironmentError(started.stderr.strip()[-1000:]) + inspected = self._docker("inspect", "--format", "{{.Image}}", self.container, timeout=30) + self.digest = inspected.stdout.strip() if inspected.returncode == 0 else None + self.started = True + + def start(self) -> None: + self._ensure_started() + + def run(self, command: str, *, cwd: Path, timeout: int) -> EnvironmentResult: + self._ensure_started() + try: + relative = cwd.resolve().relative_to(self.spec.worktree.resolve()) + except ValueError as exc: + raise DockerEnvironmentError( + f"command cwd {cwd} is outside the item worktree {self.spec.worktree}" + ) from exc + target = "/workspace" if str(relative) == "." else f"/workspace/{relative}" + # `timeout` is inside the container so a client-side timeout cannot + # leave a test process running after docker exec has gone away. + wrapped = f"timeout --signal=TERM {timeout}s /bin/sh -lc {shlex.quote(command)}" + result = self._docker( + "exec", + "--user", + self.spec.user, + "--workdir", + target, + self.container, + "/bin/sh", + "-lc", + wrapped, + timeout=timeout + 10, + ) + timed_out = result.returncode == 124 + return EnvironmentResult(result.stdout, result.stderr, result.returncode, timed_out) + + def template_vars(self) -> Mapping[str, str]: + # Do not pass controller environment variables into the model prompt. + return { + "system": "container", + "node": "docker", + "release": self.spec.image, + "version": self.version, + "machine": "container", + "processor": "container", + "cwd": self.cwd, + } + + def describe(self) -> Mapping[str, Any]: + return self.spec.describe(backend=self.name, digest=self.digest) + + def close(self) -> None: + if self.container: + self._docker("rm", "--force", "--volumes", self.container, timeout=30) + self.container = "" + self.started = False + + +class DockerEnvironmentFactory: + name = "docker" + api_version = API_VERSION + version = "docker-cli" + + def check(self) -> tuple[bool, str]: + if shutil.which("docker") is None: + return False, "docker is not installed or is not on PATH" + try: + result = subprocess.run( + ["docker", "info", "--format", "{{.ServerVersion}}"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + return False, f"Docker daemon check failed: {exc}" + if result.returncode != 0: + detail = result.stderr.strip().splitlines() + return False, detail[-1] if detail else "Docker daemon is unavailable" + return True, f"Docker daemon {result.stdout.strip() or 'available'}" + + def reap(self, worktree: Path) -> None: + """Remove containers left by a killed controller for one item tree.""" + if shutil.which("docker") is None: + return + try: + listed = subprocess.run( + [ + "docker", + "ps", + "--all", + "--quiet", + "--filter", + "label=agent_harness.managed=true", + "--filter", + f"label=agent_harness.worktree={worktree.resolve()}", + ], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if listed.returncode != 0: + return + for container in listed.stdout.splitlines(): + container = container.strip() + if container: + subprocess.run( + ["docker", "rm", "--force", "--volumes", container], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError): + # Reaping is recovery work. A backend that is unavailable cannot + # make the item safer by turning cleanup into a worker crash; the + # next start/check reports backend readiness authoritatively. + return + + def create( + self, + worktree: Path, + *, + image: str, + mounts: tuple[EnvironmentMount, ...] = (), + environment: Mapping[str, str] | None = None, + network: str = "bridge", + ) -> ExecutionEnvironment: + return DockerItemEnvironment( + EnvironmentSpec( + image=image, + worktree=worktree.resolve(), + mounts=mounts, + environment=environment or {}, + network=network, + ) + ) + + +BACKEND = DockerEnvironmentFactory() diff --git a/src/agent_harness/adapters/github_pr_review.py b/src/agent_harness/adapters/github_pr_review.py new file mode 100644 index 0000000..b0a1003 --- /dev/null +++ b/src/agent_harness/adapters/github_pr_review.py @@ -0,0 +1,228 @@ +"""Review source for one GitHub pull request. + +**Opt-in.** Nothing in the core imports this. `review_sources.resolve` knows +the *name* `github-pr-review` and the module path installed metadata gives it, +and imports this file only when a deployment selects it. Core never learns +what a GitHub review comment looks like. + +The harness owns correction semantics; this adapter owns two things GitHub +knows and the harness does not: + + identity a review comment's immutable numeric id, per endpoint + disposition whether the comment asks for work, asks a person a + question, or reports something already settled + +Both are decided here by **explicit, deterministic rules**. No model reads a +human's prose to guess what they meant — that is the failure this contract was +written to avoid. A reviewer who wants a specific outcome says so with a +marker; anything unmarked defaults to `ambiguous`, which opens a hold for a +person rather than sending an agent after a guess. + +Markers, case-insensitive, anywhere in the comment body, first match wins:: + + harness: fix actionable — create correction work + harness: hold ambiguous — hold for a person + harness: resolved already settled — record it, create nothing + +With no marker, the review state decides: + + CHANGES_REQUESTED actionable + APPROVED already_resolved + anything else `default_disposition` (itself defaulting to ambiguous) + +The item a comment is about is likewise explicit. `harness-item: T3` in the +body names one; otherwise the configured `default_item_id` is used, because a +plan pull request carries every item in the plan and this adapter will not +infer which one a line comment belongs to. +""" + +from __future__ import annotations + +import json +import re +import subprocess +from collections.abc import Callable, Mapping, Sequence +from typing import Any + +from ..review_events import RemoteReviewEvent, ReviewDisposition +from ..review_sources import API_VERSION, ReviewBatch + +__all__ = ["GitHubPullRequestReviewSource", "source"] + +Runner = Callable[[Sequence[str]], str] + +#: `harness-item: T3`. Deliberately strict — a body that does not name an item +#: in this exact form falls back to the configured default rather than being +#: pattern-matched against whatever else looks like an identifier. +_ITEM = re.compile(r"harness-item:\s*(?P[A-Za-z0-9._\-]+)", re.IGNORECASE) + +_MARKERS: tuple[tuple[re.Pattern[str], ReviewDisposition], ...] = ( + (re.compile(r"harness:\s*fix\b", re.IGNORECASE), "actionable"), + (re.compile(r"harness:\s*hold\b", re.IGNORECASE), "ambiguous"), + (re.compile(r"harness:\s*resolved\b", re.IGNORECASE), "already_resolved"), +) + +_BY_REVIEW_STATE: dict[str, ReviewDisposition] = { + "CHANGES_REQUESTED": "actionable", + "APPROVED": "already_resolved", +} + +#: A summary is evidence for a person, not a transcript. Long review prose is +#: truncated here so a single comment cannot dominate a queue row or a hold. +SUMMARY_LIMIT = 2000 + + +class GitHubPullRequestReviewSource: + """Poll one pull request's reviews and review comments.""" + + api_version = API_VERSION + + def __init__( + self, + *, + repo: str, + pr: int | str, + project_id: str, + default_item_id: str, + name: str = "github-pr-review", + default_disposition: ReviewDisposition = "ambiguous", + limit: int = 100, + runner: Runner | None = None, + ) -> None: + if not str(repo).strip() or "/" not in str(repo): + raise ValueError("github-pr-review needs an OWNER/REPO repo") + if not str(project_id).strip() or not str(default_item_id).strip(): + raise ValueError("github-pr-review needs a project_id and default_item_id") + if default_disposition not in ("actionable", "ambiguous", "already_resolved"): + raise ValueError(f"unknown default_disposition {default_disposition!r}") + self.name = str(name) + self.repo = str(repo) + self.pr = int(pr) + self.project_id = str(project_id) + self.default_item_id = str(default_item_id) + self.default_disposition: ReviewDisposition = default_disposition + self.limit = max(1, int(limit)) + self._run: Runner = runner or _gh + + # -- polling --------------------------------------------------------- + + def poll(self, cursor: str | None, /) -> ReviewBatch: + """Return everything updated since `cursor`, and the next cursor. + + The cursor is GitHub's own `updated_at` timestamp, which is a + recovery aid rather than the deduplication mechanism: overlap is + expected and harmless because the harness deduplicates on the + immutable per-endpoint comment identity carried in each event. + """ + rows: list[tuple[str, dict[str, Any]]] = [] + for endpoint in ("reviews", "comments"): + for raw in self._fetch(endpoint, cursor): + rows.append((endpoint, raw)) + events: list[RemoteReviewEvent] = [] + stamps: list[str] = [] + for endpoint, raw in rows: + stamp = str(raw.get("updated_at") or raw.get("submitted_at") or "") + if stamp: + stamps.append(stamp) + event = self._event(endpoint, raw) + if event is not None: + events.append(event) + next_cursor = max(stamps) if stamps else cursor + return ReviewBatch(tuple(events), next_cursor=next_cursor or None) + + def _fetch(self, endpoint: str, cursor: str | None) -> list[dict[str, Any]]: + path = f"repos/{self.repo}/pulls/{self.pr}/{endpoint}?per_page={self.limit}" + if cursor and endpoint == "comments": + # Only the review-comments endpoint supports `since`. Reviews are + # filtered client-side below, so a missing filter costs bandwidth + # rather than correctness. + path = f"{path}&since={cursor}" + out = self._run(["gh", "api", "--paginate", path]) + try: + raw = json.loads(out or "[]") + except ValueError as exc: + raise RuntimeError( + f"gh returned unreadable {endpoint} for {self.repo}#{self.pr}: {exc}" + ) from exc + if not isinstance(raw, list): + raise RuntimeError(f"gh returned no {endpoint} list for {self.repo}#{self.pr}") + return [row for row in raw if isinstance(row, dict)] + + def _event(self, endpoint: str, raw: Mapping[str, Any]) -> RemoteReviewEvent | None: + identity = raw.get("id") + if identity is None: + return None + body = str(raw.get("body") or "").strip() + state = str(raw.get("state") or "").upper() + if state == "PENDING": + # An unsubmitted draft review is not feedback yet. Acting on one + # would answer a reviewer before they had finished writing. + return None + disposition = self._disposition(body, state) + summary = self._summary(body, state, raw) + if not summary: + return None + return RemoteReviewEvent( + source=self.name, + # Reviews and review comments number independently, so the + # endpoint is part of the identity rather than the id alone. + remote_id=f"{self.repo}#{self.pr}/{endpoint}/{identity}", + project_id=self.project_id, + item_id=self._item_id(body), + disposition=disposition, + summary=summary, + pr_url=str(raw.get("html_url") or "") or None, + ) + + # -- explicit rules -------------------------------------------------- + + def _disposition(self, body: str, state: str) -> ReviewDisposition: + for pattern, disposition in _MARKERS: + if pattern.search(body): + return disposition + return _BY_REVIEW_STATE.get(state, self.default_disposition) + + def _item_id(self, body: str) -> str: + match = _ITEM.search(body) + return match.group("item") if match else self.default_item_id + + @staticmethod + def _summary(body: str, state: str, raw: Mapping[str, Any]) -> str: + where = str(raw.get("path") or "").strip() + prefix = f"{state or 'COMMENT'}" + if where: + line = raw.get("line") or raw.get("original_line") + prefix = f"{prefix} on {where}{f':{line}' if line else ''}" + text = body or ("approved with no comment" if state == "APPROVED" else "") + if not text: + return "" + return f"[{prefix}] {text}"[:SUMMARY_LIMIT] + + +def source(config: Mapping[str, Any]) -> GitHubPullRequestReviewSource: + """The factory `review_sources.resolve` looks for by name.""" + known = { + "repo", + "pr", + "project_id", + "default_item_id", + "name", + "default_disposition", + "limit", + } + unknown = sorted(set(config) - known) + if unknown: + raise ValueError(f"github-pr-review does not accept {', '.join(unknown)}") + missing = sorted({"repo", "pr", "project_id", "default_item_id"} - set(config)) + if missing: + raise ValueError(f"github-pr-review needs {', '.join(missing)}") + return GitHubPullRequestReviewSource(**config) + + +def _gh(args: Sequence[str]) -> str: + result = subprocess.run( # noqa: S603 - fixed argv, no shell + args, capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "gh failed") + return result.stdout diff --git a/src/agent_harness/adapters/minisweagent.py b/src/agent_harness/adapters/minisweagent.py index 7a71f74..c5f8fb3 100644 --- a/src/agent_harness/adapters/minisweagent.py +++ b/src/agent_harness/adapters/minisweagent.py @@ -54,6 +54,7 @@ from typing import Any from ..budgets import Budget, Spend +from ..execution_environment import ExecutionEnvironment, LocalExecutionEnvironment from ..guard import CommandGuard, CommandRefused, Refusal from ..model_client import ModelClient from ..role_runners import API_VERSION, RoleRunRequest, RoleRunResult @@ -434,9 +435,18 @@ def _segments(command: str, depth: int = 0) -> list[list[str]]: return out -#: Roles a chat-completions endpoint defines. The loop uses `exit` for its own -#: bookkeeping, which is its business and not a role any API knows. -_WIRE_ROLES = frozenset({"system", "user", "assistant", "tool"}) +#: Roles a chat-completions endpoint defines **and this function can emit +#: validly**. The loop uses `exit` for its own bookkeeping, which is its +#: business and not a role any API knows. +#: +#: `tool` is deliberately absent even though endpoints define it. A `tool` +#: message must carry the `tool_call_id` it answers, and `_for_the_wire` +#: reduces every message to `role` and `content` — so passing one through +#: would send a malformed request whose refusal names no message, the exact +#: shape that cost a live run. An observation goes back as a `user` turn +#: instead: the pairing is lost, which costs nothing while there is one tool, +#: and the conversation stays valid. +_WIRE_ROLES = frozenset({"system", "user", "assistant"}) def _for_the_wire(messages: Sequence[Mapping[str, Any]]) -> list[dict[str, str]]: @@ -589,6 +599,7 @@ class HarnessModel: spend: Spend = field(default_factory=Spend) n_calls: int = 0 config: Any = None + environment: ExecutionEnvironment | None = None #: Theirs, read from the same config the prompts come from. Hand-copied, #: these drifted: the error template told a model on the *tool call* path #: to "provide EXACTLY ONE action in triple backticks" -- the text @@ -768,6 +779,7 @@ class HarnessEnvironment: #: adapter supplies only the command and its eventual return code. on_command: Callable[[str, int | None], None] | None = None config: Any = None + environment: ExecutionEnvironment | None = None def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: import subprocess @@ -783,15 +795,9 @@ def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: if self.on_command is not None: self.on_command(str(command), None) + backend = self.environment or LocalExecutionEnvironment(self.repo) try: - result = subprocess.run( # noqa: S602 - screened above, agent-supplied by design - str(command), - shell=True, - cwd=where, - capture_output=True, - text=True, - timeout=self.timeout, - ) + result = backend.run(str(command), cwd=where, timeout=self.timeout) except subprocess.TimeoutExpired as expired: # Unhandled, this left `execute` as a `TimeoutExpired` that the # loop does not catch: `DefaultAgent.run` records an exit message @@ -812,6 +818,18 @@ def execute(self, action: dict[str, Any], cwd: str = "") -> dict[str, Any]: self.on_command(str(command), TIMED_OUT) return timed_out + if result.timed_out: + if self.on_command is not None: + self.on_command(str(command), TIMED_OUT) + return { + "output": _bounded(result.stdout + result.stderr), + "returncode": TIMED_OUT, + "exception_info": ( + f"the command was killed after {self.timeout}s by this " + "deployment's command timeout; any output above is partial" + ), + } + full = result.stdout + result.stderr if self.on_command is not None: self.on_command(str(command), result.returncode) @@ -897,14 +915,8 @@ def get_template_vars(self, **kwargs: Any) -> dict[str, Any]: curated: the templates are theirs, and guessing which variables they will reference next is how this breaks again on an upgrade. """ - import platform - - return { - **platform.uname()._asdict(), - **os.environ, - "cwd": str(self.repo), - **kwargs, - } + backend = self.environment or LocalExecutionEnvironment(self.repo) + return {**backend.template_vars(), **kwargs} def serialize(self) -> dict[str, Any]: """What the trajectory keeps. The refusals themselves, not a count. @@ -937,6 +949,7 @@ def build( terminal_refusals: bool = False, on_command: Callable[[str, int | None], None] | None = None, on_usage: Callable[[Spend], None] | None = None, + environment: ExecutionEnvironment | None = None, ) -> Any: """A loop wired to this harness's client, guard and budget. @@ -993,6 +1006,7 @@ def query(self) -> dict[str, Any]: on_refusal=on_refusal, terminal_refusals=terminal_refusals, on_command=on_command, + environment=environment, ), system_template=prompts["system_template"], instance_template=prompts["instance_template"], @@ -1044,6 +1058,11 @@ def refused(line: str, refusal: Refusal) -> None: "step_limit": request.step_limit, "budget": request.budget.as_dict(), "writable": request.writable, + "environment": ( + dict(request.environment.describe()) + if request.environment is not None + else {"backend": "host", "security_boundary": "none; compatibility backend"} + ), }, ) agent = build( @@ -1058,6 +1077,7 @@ def refused(line: str, refusal: Refusal) -> None: terminal_refusals=True, on_command=command, on_usage=request.account, + environment=request.environment, ) raw = agent.run( request.task, diff --git a/src/agent_harness/api.py b/src/agent_harness/api.py index 4d55163..eabd8fa 100644 --- a/src/agent_harness/api.py +++ b/src/agent_harness/api.py @@ -17,6 +17,7 @@ from __future__ import annotations +import contextlib import json import secrets import time @@ -43,6 +44,7 @@ from .audit import AuditStore from .audit_service import maintain_audit, reconcile_repository from .events import RATE_LIMIT_CLASSES, UNCLASSIFIED +from .events import Event as AuditEvent from .maintenance import DEFAULT_RETENTION_DAYS from .plan_service import PlanSyncConflict, PlanSyncFailure from .plan_service import execute as execute_plan_sync @@ -114,6 +116,9 @@ ReconcileResult, ResolveQuestion, RetryResult, + ReviewEventRequest, + ReviewEventResult, + ReviewPollResultModel, RoleMap, RoleMapView, RouteReachability, @@ -193,6 +198,8 @@ def create_api( token: str | None = None, root_path: str = "", audit: AuditStore | None = None, + notifications: Any | None = None, + review_poller: Any | None = None, fleet: Any | None = None, model_client: Any | None = None, session_host: Any | None = None, @@ -202,6 +209,8 @@ def create_api( github_factory: Any | None = None, process_metrics: ProcessMetricsSource | None = None, adoption_branches: Any | None = None, + execution_environment: Any | None = None, + remote_required: bool = True, ) -> FastAPI: """Build the API. @@ -240,6 +249,8 @@ def create_api( app.state.store = store app.state.queue = queue app.state.audit = audit + app.state.notifications = notifications + app.state.review_poller = review_poller app.state.fleet = fleet app.state.model_client = model_client app.state.session_host = session_host @@ -253,6 +264,8 @@ def create_api( app.state.github_factory = github_factory app.state.process_metrics = process_metrics or ProcessMetricsSampler() app.state.adoption_branches = adoption_branches + app.state.execution_environment = execution_environment + app.state.remote_required = remote_required app.state.ask_model = _model_asker(model_client) app.state.base_checks = BaseChecks() app.state.token = token @@ -360,6 +373,85 @@ def work_item( queue.now(), ) + @app.post( + "/api/review-events", + tags=["work", "observability"], + summary="Accept one normalized remote review event", + response_model=ReviewEventResult, + ) + def review_event( + request: ReviewEventRequest, + _: None = Depends(require_token), + ) -> ReviewEventResult: + """Deduplicate remote feedback and create only explicit correction work. + + The caller supplies the disposition. This route does not parse human + prose, contact a remote service, or let a model decide what feedback + means. + """ + from .review_events import RemoteReviewEvent, ReviewEventProcessor + + queue = need_queue() + sink = app.state.audit + notifications = app.state.notifications + + def emit(event: dict[str, Any]) -> None: + if sink is not None: + sink.append( + [ + AuditEvent( + ts=float(event.get("ts") or time.time()), + kind="work", + source="review-event", + outcome=str(event.get("outcome") or "remote_review_received"), + data={ + k: v for k, v in event.items() if k not in {"ts", "kind", "outcome"} + }, + ) + ] + ) + if notifications is not None: + with contextlib.suppress(Exception): + notifications.enqueue_event(event) + + try: + result = ReviewEventProcessor(queue, on_event=emit).process( + RemoteReviewEvent(**request.model_dump()) + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return ReviewEventResult(**result.__dict__) + + @app.post( + "/api/review-poll", + tags=["work", "observability"], + summary="Poll the configured remote review source once", + response_model=ReviewPollResultModel, + ) + def review_poll(_: None = Depends(require_token)) -> ReviewPollResultModel: + """Run one adapter-owned poll and persist its cursor after intake. + + The adapter owns remote authentication and translation. The harness + only applies its normalized event contract and never advances a cursor + when any event in the batch fails to persist. + """ + poller = app.state.review_poller + if poller is None: + raise HTTPException(status_code=409, detail="no review source is configured") + try: + result = poller.poll_once() + except Exception as exc: # noqa: BLE001 - source failure is an API outcome + raise HTTPException( + status_code=502, detail=f"review source poll failed: {exc}" + ) from exc + return ReviewPollResultModel( + fetched=result.fetched, + accepted=result.accepted, + duplicates=result.duplicates, + cursor=result.cursor, + results=[ReviewEventResult(**item.__dict__) for item in result.results], + ) + @app.get( "/api/work/{item_id}/evidence", tags=["work"], @@ -450,7 +542,10 @@ def retry( record = queue.get(item_id, project_id=project_id) if record is None: raise HTTPException(status_code=404, detail=f"no item {item_id!r}") - if record.state == CLAIMED and record.lease_until > time.time(): + # `queue.now()`, not `time.time()`: the queue's clock is injectable + # precisely so lease behaviour can be exercised, and a route that + # reads the wall clock instead silently opts out of that. + if record.state == CLAIMED and record.lease_until > queue.now(): raise HTTPException( status_code=409, detail=f"{item_id} is claimed by {record.owner} and its lease is live; " @@ -580,7 +675,9 @@ def block( record = queue.get(item_id, project_id=project_id) if record is None: raise HTTPException(status_code=404, detail=f"no item {item_id!r}") - if record.state == CLAIMED and record.lease_until > time.time() and not request.override: + # The queue's clock is authoritative for a lease -- see the same + # decision on the retry route. + if record.state == CLAIMED and record.lease_until > queue.now() and not request.override: raise HTTPException( status_code=409, detail=f"{item_id} is claimed by {record.owner} and its lease is live; " @@ -1537,6 +1634,21 @@ def readiness( session_host_state = ReadinessProbe(configured=True, ok=ok, detail=detail) probe = lambda ok=ok, detail=detail: (ok, detail) # noqa: E731 + environment_probe = getattr(app.state, "execution_environment", None) + if environment_probe is None: + execution_environment_state = ReadinessProbe( + configured=False, + ok=False, + detail="no item execution backend is configured; local execution is unavailable", + ) + else: + environment_ok, environment_detail = environment_probe() + execution_environment_state = ReadinessProbe( + configured=True, + ok=environment_ok, + detail=environment_detail, + ) + projects = [p for p in queue.projects() if project_id is None or p.project_id == project_id] if project_id is not None and not projects: raise HTTPException(status_code=404, detail=f"no project {project_id!r}") @@ -1597,10 +1709,17 @@ def readiness( ) return ExecutionReadiness( - mode="supervised" if fleet_ is not None else "monitoring-only", + mode=( + "local" + if fleet_ is not None and environment_probe is not None + else "supervised" + if fleet_ is not None + else "monitoring-only" + ), ready_to_start=any(r.ready_to_start for r in reports), workers=workers, session_host=session_host_state, + execution_environment=execution_environment_state, reviewer=reviewer_state, projects=reports, ) @@ -2250,6 +2369,8 @@ def _preflight( else None ), "session_host": session_host or (session_host_probe(host) if host is not None else None), + "execution_environment": getattr(state, "execution_environment", None), + "remote_required": getattr(state, "remote_required", True), "checks_probe": ( last_base_result_probe(state.base_checks, project.project_id) if check_base and getattr(state, "base_checks", None) is not None diff --git a/src/agent_harness/doctor.py b/src/agent_harness/doctor.py index ac8cad5..abfa122 100644 --- a/src/agent_harness/doctor.py +++ b/src/agent_harness/doctor.py @@ -188,6 +188,36 @@ def _runner_finding(selected: str) -> Finding: return Finding("role runner", OK if ok else FAIL, detail, blocking=not ok) +def _execution_environment_finding(queue: Any) -> Finding: + """Report the item command boundary before a worker can claim anything.""" + selected = str(queue.get_setting("execution_backend") or "").strip() + image = str(queue.get_setting("execution_image") or "").strip() + if not selected: + return Finding( + "execution environment", + WARN, + "host compatibility execution is selected; it is not an OS security boundary. " + "Configure a metadata-selected backend and image before a real workload.", + blocking=False, + ) + from .execution_environments import probe + + ok, detail = probe(selected) + if not image: + return Finding( + "execution environment", + FAIL, + f"backend {selected!r} is selected but no execution image is configured; {detail}", + blocking=True, + ) + return Finding( + "execution environment", + OK if ok else FAIL, + f"{detail}; image {image}", + blocking=not ok, + ) + + def _redaction_finding() -> Finding: """What the write-boundary filter can and cannot promise. @@ -593,6 +623,7 @@ def diagnose(queue: Any, projects: list[Any], *, ask: Any = None) -> Report: # credentials on disk are shared by every project in one database. report.environment.append(_guard_finding(queue.get_setting(GUARD_KEY))) report.environment.append(_runner_finding(str(queue.get_setting("role_runner") or ""))) + report.environment.append(_execution_environment_finding(queue)) stored = queue.get_setting(ROLE_MAP_KEY) or {} for project in projects: diff --git a/src/agent_harness/execution_environment.py b/src/agent_harness/execution_environment.py new file mode 100644 index 0000000..01035b2 --- /dev/null +++ b/src/agent_harness/execution_environment.py @@ -0,0 +1,203 @@ +"""The generic command-environment boundary used by role runners. + +The model loop is deliberately kept in the harness process. It asks an +execution environment to inspect, edit and test an item's checkout, while the +environment owns the operating-system boundary around those commands. Core +defines only this contract; concrete backends are selected through installed +metadata. +""" + +from __future__ import annotations + +import os +import platform +import re +import subprocess +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Protocol + +API_VERSION = 1 +_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_PROTECTED_TARGETS = frozenset({"/", "/workspace", "/proc", "/sys", "/dev"}) + + +@dataclass(frozen=True) +class EnvironmentMount: + """One explicitly declared host path made visible to an item.""" + + source: Path + target: str + writable: bool = False + + def __post_init__(self) -> None: + if not self.source.is_absolute(): + raise ValueError("execution-environment mount sources must be absolute") + if not self.source.exists(): + raise ValueError(f"execution-environment mount source does not exist: {self.source}") + target = Path(self.target) + if not target.is_absolute() or ".." in target.parts: + raise ValueError("execution-environment mount targets must be safe absolute paths") + if str(target) in _PROTECTED_TARGETS or str(target).startswith("/workspace/"): + raise ValueError(f"execution-environment mount target is protected: {target}") + if target == Path("/var/run/docker.sock") or self.source.resolve().name == "docker.sock": + raise ValueError("the Docker socket cannot be mounted into an item") + + +@dataclass(frozen=True) +class EnvironmentSpec: + """The complete, auditable configuration for one item environment.""" + + image: str + worktree: Path + mounts: tuple[EnvironmentMount, ...] = () + environment: Mapping[str, str] = field(default_factory=dict) + network: str = "bridge" + command_timeout: int = 300 + memory_limit: str = "2g" + cpus: str = "2" + pids_limit: int = 512 + user: str = "1000:1000" + rootfs_read_only: bool = True + tmpfs_size: str = "512m" + + def __post_init__(self) -> None: + if not self.image.strip(): + raise ValueError("an execution environment needs an image reference") + if not self.worktree.is_absolute(): + raise ValueError("the item worktree must be an absolute path") + if self.network not in {"bridge", "none", "host"}: + raise ValueError(f"unsupported execution network policy: {self.network!r}") + if self.network == "host": + raise ValueError("host networking is not an accepted item-environment policy") + if self.command_timeout <= 0 or self.pids_limit <= 0: + raise ValueError("execution limits must be positive") + names = set(self.environment) + if any(_ENVIRONMENT_NAME.fullmatch(name) is None for name in names): + raise ValueError("environment names must be valid variable names") + + def describe(self, *, backend: str, digest: str | None = None) -> dict[str, Any]: + """Return evidence safe to persist; never include environment values.""" + return { + "backend": backend, + "image": self.image, + "image_digest": digest, + "worktree": {"source": str(self.worktree), "target": "/workspace", "writable": True}, + "mounts": [ + {"source": str(m.source), "target": m.target, "writable": m.writable} + for m in self.mounts + ], + "environment_names": sorted(self.environment), + "network": self.network, + "limits": { + "command_timeout": self.command_timeout, + "memory": self.memory_limit, + "cpus": self.cpus, + "pids": self.pids_limit, + }, + "identity": self.user, + "security": { + "rootfs_read_only": self.rootfs_read_only, + "no_new_privileges": True, + "capabilities_dropped": "ALL", + }, + } + + +@dataclass(frozen=True) +class EnvironmentResult: + """The result of one command executed inside an item environment.""" + + stdout: str + stderr: str + returncode: int + timed_out: bool = False + + +class ExecutionEnvironment(Protocol): + """One item-scoped environment, created before its first tool command.""" + + @property + def name(self) -> str: ... + + @property + def api_version(self) -> int: ... + + @property + def version(self) -> str: ... + + @property + def cwd(self) -> str: ... + + def run(self, command: str, *, cwd: Path, timeout: int) -> EnvironmentResult: ... + + def start(self) -> None: ... + + def check(self) -> tuple[bool, str]: ... + + def template_vars(self) -> Mapping[str, str]: ... + + def describe(self) -> Mapping[str, Any]: ... + + def close(self) -> None: ... + + +class LocalExecutionEnvironment: + """The historical host backend, retained for fixtures and compatibility. + + This is explicitly not a security boundary. A real deployment must select + an OS-enforced backend such as the shipped Docker backend. + """ + + name = "host" + api_version = API_VERSION + version = "compatibility" + + def __init__(self, repo: Path) -> None: + self.repo = repo + + @property + def cwd(self) -> str: + return str(self.repo) + + def run(self, command: str, *, cwd: Path, timeout: int) -> EnvironmentResult: + try: + result = subprocess.run( + command, + shell=True, + cwd=cwd, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + stdout = _text(exc.stdout) + stderr = _text(exc.stderr) + return EnvironmentResult(stdout, stderr, 124, timed_out=True) + return EnvironmentResult(result.stdout, result.stderr, result.returncode) + + def start(self) -> None: + return None + + def check(self) -> tuple[bool, str]: + return True, "host compatibility backend available" + + def template_vars(self) -> Mapping[str, str]: + return {**platform.uname()._asdict(), **os.environ, "cwd": str(self.repo)} + + def describe(self) -> Mapping[str, Any]: + return { + "backend": self.name, + "security_boundary": "none; compatibility backend only", + "worktree": str(self.repo), + } + + def close(self) -> None: + return None + + +def _text(value: bytes | str | None) -> str: + if value is None: + return "" + return value.decode(errors="replace") if isinstance(value, bytes) else value diff --git a/src/agent_harness/execution_environments.py b/src/agent_harness/execution_environments.py new file mode 100644 index 0000000..cdcede9 --- /dev/null +++ b/src/agent_harness/execution_environments.py @@ -0,0 +1,90 @@ +"""Metadata lookup for item-scoped execution environments.""" + +from __future__ import annotations + +import importlib +import logging +from collections.abc import Mapping +from pathlib import Path +from typing import Any, Protocol + +from .execution_environment import EnvironmentMount, ExecutionEnvironment + +log = logging.getLogger(__name__) +ENTRY_POINT_GROUP = "agent_harness.execution_environments" +API_VERSION = 1 + + +class ExecutionEnvironmentFactory(Protocol): + name: str + api_version: int + version: str + + def check(self) -> tuple[bool, str]: ... + + def create( + self, + worktree: Path, + *, + image: str, + mounts: tuple[EnvironmentMount, ...] = (), + environment: Mapping[str, str] | None = None, + network: str = "bridge", + ) -> ExecutionEnvironment: ... + + +class UnknownEnvironment(RuntimeError): + """No installed execution backend has the configured name.""" + + +def _targets() -> dict[str, str]: + from importlib.metadata import entry_points + + try: + return {point.name: point.value for point in entry_points(group=ENTRY_POINT_GROUP)} + except Exception: # noqa: BLE001 + log.warning("could not read %s entry points", ENTRY_POINT_GROUP, exc_info=True) + return {} + + +def names() -> list[str]: + return sorted(_targets()) + + +def resolve(name: str) -> ExecutionEnvironmentFactory: + target = _targets().get(name) + if target is None: + raise UnknownEnvironment( + f"unknown execution backend {name!r}; installed names: " + f"{', '.join(names()) or 'none'} ({ENTRY_POINT_GROUP})" + ) + module_name, _, attribute = target.partition(":") + try: + found: Any = getattr(importlib.import_module(module_name), attribute) + if callable(found) and not hasattr(found, "create"): + found = found() + except Exception as exc: # noqa: BLE001 + raise UnknownEnvironment(f"execution backend {name!r} could not load: {exc}") from exc + compatible = ( + callable(getattr(found, "create", None)) + and getattr(found, "api_version", None) == API_VERSION + ) + has_check = callable(getattr(found, "check", None)) + if not compatible or not has_check: + raise UnknownEnvironment( + f"execution backend {name!r} does not implement contract {API_VERSION} " + "with a runtime readiness check" + ) + return found # type: ignore[no-any-return] + + +def probe(name: str) -> tuple[bool, str]: + try: + backend = resolve(name) + except Exception as exc: # noqa: BLE001 + return False, str(exc) + ok, detail = backend.check() + return ( + ok, + f"{backend.name} {backend.version}; execution contract {API_VERSION} compatible; {detail}", + ) diff --git a/src/agent_harness/executor.py b/src/agent_harness/executor.py index d12c994..d3a2c37 100644 --- a/src/agent_harness/executor.py +++ b/src/agent_harness/executor.py @@ -33,10 +33,13 @@ import contextlib import json +import logging import os import re +import shutil import subprocess import tempfile +import threading import time from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field, replace @@ -49,6 +52,7 @@ from .budgets import Budget, BudgetExceeded, Spend, budget_for from .budgets import check as budget_check from .edits import EditError, parse_edits, to_diff +from .execution_environment import EnvironmentMount, ExecutionEnvironment from .graph import LOCAL_WORK from .guard import CommandGuard, CommandRefused, guard_field from .model_client import CapExhausted, ModelClient, RequestRefused, RetryExhausted @@ -70,6 +74,7 @@ PASS, PASSED, PATCH_REJECTED, + PLAN_PROMOTION_CONFLICT, PROVIDER_EXHAUSTED, REFUSED, RETRY, @@ -96,6 +101,8 @@ worker_identity, ) +log = logging.getLogger(__name__) + #: Which reason kind each ceiling reports. Named here so the executor never #: has to branch on a ceiling string. BUDGET_REASON = {BUDGET_WALL_CLOCK: ITEM_WALL_CLOCK, BUDGET_SPEND: ITEM_SPEND} @@ -1400,7 +1407,7 @@ def _run_one(self, repo: Path, command: Sequence[str]) -> CheckResult: if fix: return CheckResult(FIX_AVAILABLE, detail, command=tuple(argv), fix=fix) return CheckResult(FAIL, detail, command=tuple(argv)) - return PASSED + return CheckResult(PASS, command=tuple(argv)) def run(self, repo: Path) -> CheckResult: applied: list[AppliedFix] = [] @@ -2089,6 +2096,13 @@ def __init__( role_runner: RoleRunner | None = None, runner_step_limit: int = 80, runner_command_timeout: int = 300, + environment_factory: Any | None = None, + environment_image: str = "", + environment_mounts: tuple[EnvironmentMount, ...] = (), + environment_variables: Mapping[str, str] | None = None, + environment_network: str = "bridge", + plan_base_for: Callable[[WorkRecord], tuple[str, str | None]] | None = None, + plan_promote: Callable[[WorkRecord, str, str], tuple[str, str]] | None = None, ) -> None: self.queue = queue #: How often this worker makes progress durable. None takes the @@ -2134,6 +2148,7 @@ def __init__( #: before the implementer is called, because that is what it needs to #: be looking at. self._base: str | None = None + self._base_sha: str | None = None # Where a patch that could not be applied is kept. Supplied, never # guessed: the core owns no directory layout. Without it the reply is # gone the moment the item fails, and the only way to see what the @@ -2152,6 +2167,13 @@ def __init__( self.role_runner = role_runner self.runner_step_limit = runner_step_limit self.runner_command_timeout = runner_command_timeout + self.environment_factory = environment_factory + self.environment_image = environment_image + self.environment_mounts = environment_mounts + self.environment_variables = dict(environment_variables or {}) + self.environment_network = environment_network + self.plan_base_for = plan_base_for + self.plan_promote = plan_promote # ------------------------------------------------------------- driving @@ -2380,6 +2402,37 @@ def run_once(self) -> Outcome | None: with contextlib.suppress(Exception): self._hold(record, outcome) return outcome + if outcome.state == DONE and self.plan_promote is not None: + try: + status, detail = self.plan_promote( + record, outcome.branch or "", self._base_sha or outcome.base or self.base_branch + ) + except Exception as exc: # noqa: BLE001 - integration must not kill a worker + status, detail = "deferred", f"plan promotion could not be attempted: {exc}" + if status == "conflict": + outcome.state = PENDING + outcome.reason = detail + outcome.stop = Stop( + WITHHELD, + PLAN_PROMOTION_CONFLICT, + detail=detail, + state=PENDING, + consumes_attempt=False, + ) + self._emit(record, "plan_promotion_conflict", detail=detail) + elif status != "promoted": + outcome.state = BLOCKED + outcome.reason = detail + outcome.stop = Stop( + ESCALATED, + PLAN_PROMOTION_CONFLICT, + detail=detail, + state=BLOCKED, + consumes_attempt=False, + ) + self._emit(record, "plan_promotion_deferred", detail=detail) + else: + self._emit(record, "plan_promoted", detail=detail) self.queue.release( record.item_id, outcome.state, @@ -2471,6 +2524,33 @@ def run(self, limit: int | None = None) -> list[Outcome]: outcomes.append(outcome) return outcomes + def serve( + self, + *, + poll_seconds: float = 15.0, + stop: threading.Event | None = None, + max_idle_polls: int | None = None, + ) -> list[Outcome]: + """Keep one in-process worker alive, waiting for later queue additions.""" + outcomes: list[Outcome] = [] + stop = stop or threading.Event() + idle = 0 + while not stop.is_set(): + try: + outcome = self.run_once() + except CapExhausted as exc: + log.info("budget exhausted, waiting: %s", exc) + outcome = None + if outcome is None: + idle += 1 + if max_idle_polls is not None and idle >= max_idle_polls: + return outcomes + stop.wait(poll_seconds) + continue + idle = 0 + outcomes.append(outcome) + return outcomes + # ------------------------------------------------------------ the loop def _budget(self, record: WorkRecord) -> Budget: @@ -2666,7 +2746,9 @@ def _execute(self, record: WorkRecord) -> Outcome: # no usable diff leaves no branch behind. base, stacked_on = self._base_for(record) self._base = base - self._sync_worktree(base) + self._base_sha = run_git(self.repo, "rev-parse", base).strip() + if self.environment_factory is None: + self._sync_worktree(base) # 1. Plan. Cheap, once per item, and the highest-leverage call. planner = _planner_from(resume.artefact(A.PLANNED)) if resume.skips(A.PLANNED) else None @@ -2676,7 +2758,7 @@ def _execute(self, record: WorkRecord) -> Outcome: planner_reply = self._call( record, PLANNER, - PLAN_PROMPT.format(brief=record.brief, listing=self._repo_listing()), + PLAN_PROMPT.format(brief=record.brief, listing=self._repo_listing(base)), ) planner = parse_planner_result(planner_reply) log.record( @@ -2864,9 +2946,80 @@ def _run_implementer( branch = f"{self.branch_prefix}{record.item_id.lower()}" outcome.branch = branch outcome.base = base - self._prepare_branch(branch, base) - if stacked_on: - self._emit(record, "stacked", detail=f"based on {base} ({stacked_on})") + execution_tree: Path | None = None + environment: ExecutionEnvironment | None = None + runner_repo = self.repo + if self.environment_factory is None: + self._prepare_branch(branch, base) + if stacked_on: + self._emit(record, "stacked", detail=f"based on {base} ({stacked_on})") + else: + execution_tree = self._execution_tree_path(record) + try: + # A killed controller can leave both the worktree and its + # container behind. This item has just been claimed by this + # executor, so no live sibling may own this deterministic + # path. Backends that support lifecycle reaping remove their + # orphan before the path is reused. + reap = getattr(self.environment_factory, "reap", None) + if callable(reap): + reap(execution_tree) + if execution_tree.exists(): + if (execution_tree / ".git").is_dir(): + shutil.rmtree(execution_tree) + else: + run_git( + self.repo, + "worktree", + "remove", + "--force", + str(execution_tree), + check=False, + ) + run_git(self.repo, "worktree", "prune", check=False) + if execution_tree.exists(): + shutil.rmtree(execution_tree) + # A linked Git worktree's `.git` file points into the + # controller checkout. That metadata is intentionally not + # mounted into an item container, so use a self-contained + # local clone for the loop checkout instead. The exact base + # SHA keeps this equivalent to `worktree add` without + # exposing the controller's Git directory. + clone = subprocess.run( + [ + "git", + "clone", + "--no-local", + "--no-checkout", + str(self.repo), + str(execution_tree), + ], + capture_output=True, + text=True, + check=False, + ) + if clone.returncode != 0: + raise GitError(f"git clone: {clone.stderr.strip()}") + run_git( + execution_tree, + "checkout", + "--detach", + self._base_sha or run_git(self.repo, "rev-parse", base).strip(), + ) + except Exception: + with contextlib.suppress(OSError): + if execution_tree.exists(): + shutil.rmtree(execution_tree) + raise + runner_repo = execution_tree + if stacked_on: + self._emit(record, "stacked", detail=f"based on {base} ({stacked_on})") + + def cleanup_execution_tree() -> None: + if execution_tree is None: + return + with contextlib.suppress(OSError): + shutil.rmtree(execution_tree) self._budget_stop(record) @@ -2875,6 +3028,23 @@ def report(stage: str, detail: str, evidence: Mapping[str, Any]) -> None: self._emit(record, "calling", detail=f"{IMPLEMENTER} through {runner.name}") try: + if self.environment_factory is not None: + if not self.environment_image: + raise ValueError("an execution backend requires an image reference") + environment = self.environment_factory.create( + runner_repo, + image=self.environment_image, + mounts=self.environment_mounts, + environment=self.environment_variables, + network=self.environment_network, + ) + environment.start() + self._emit( + record, + "execution_environment_created", + detail=f"{environment.name} {environment.version}", + evidence=dict(environment.describe()), + ) with self.client.event_scope( project_id=self.project_id, item_id=record.item_id, @@ -2884,7 +3054,7 @@ def report(stage: str, detail: str, evidence: Mapping[str, Any]) -> None: RoleRunRequest( role=IMPLEMENTER, task=self._runner_task(record), - repo=self.repo, + repo=runner_repo, project_id=self.project_id, item_id=record.item_id, attempt=attempt, @@ -2896,21 +3066,34 @@ def report(stage: str, detail: str, evidence: Mapping[str, Any]) -> None: writable=True, report=report, account=self._spend.add, + environment=environment, ) ) except Exception: - self._abandon_branch(branch) - outcome.branch = None + if self.environment_factory is None: + self._abandon_branch(branch) + outcome.branch = None + else: + cleanup_execution_tree() raise + finally: + if environment is not None: + environment.close() outcome.stages.append("implement") if result.exit_status == "wall_clock_limit": - self._abandon_branch(branch) - outcome.branch = None + if self.environment_factory is None: + self._abandon_branch(branch) + outcome.branch = None + else: + cleanup_execution_tree() raise self._runner_budget_exceeded(record, result, BUDGET_WALL_CLOCK) if result.exit_status == "spend_limit": - self._abandon_branch(branch) - outcome.branch = None + if self.environment_factory is None: + self._abandon_branch(branch) + outcome.branch = None + else: + cleanup_execution_tree() raise self._runner_budget_exceeded(record, result, BUDGET_SPEND) if result.exit_status != "completed": outcome.reason = ( @@ -2924,21 +3107,33 @@ def report(stage: str, detail: str, evidence: Mapping[str, Any]) -> None: evidence={"submission": result.submission[:4000]}, ) outcome.stop = Stop(CRASHED, WORKER_ERROR, detail=outcome.reason) - self._abandon_branch(branch) - outcome.branch = None + if self.environment_factory is None: + self._abandon_branch(branch) + outcome.branch = None + else: + cleanup_execution_tree() return outcome # Against the item base, not merely the current HEAD: a tool-using # agent may create untracked files or make local commits, and both are # part of the candidate the gates must judge. - diff = candidate_diff(self.repo, base) + # The self-contained item clone has the exact base commit checked out, + # but it deliberately does not expose the controller's local branch + # refs (including a plan branch advanced by another worker). Compare + # against the immutable SHA so dependent work remains isolated from + # controller metadata while still capturing the full candidate. + diff = candidate_diff(runner_repo, self._base_sha or base) if not diff: outcome.reason = "the role runner completed without changing the repository" self._emit(record, "no_diff", detail=outcome.reason) outcome.stop = Stop(REFUSED, NO_TARGET, detail=outcome.reason) - self._abandon_branch(branch) - outcome.branch = None + if self.environment_factory is None: + self._abandon_branch(branch) + outcome.branch = None + else: + cleanup_execution_tree() return outcome + cleanup_execution_tree() log.record( self.project_id, record.item_id, @@ -3192,11 +3387,14 @@ def _from_diff( detail=failure[:2000], evidence={ "check": " ".join(checked.command) if checked.command else "", + "command": list(checked.command), + "commands": [list(checked.command)] if checked.command else [], "outcome": checked.outcome, # The tail is where a build tool prints what it objected # to, which is the part a person reads first. "output": failure[-4000:], "fix_declared": " ".join(checked.fix) if checked.fix else "", + "fix": list(checked.fix), }, # The gate's own word for what happened, so a client branches # on a token rather than on English. `disk_exhausted` is kept @@ -3211,6 +3409,10 @@ def _from_diff( record, "fix_available", detail="`" + " ".join(checked.fix) + "` is declared to clear this", + evidence={ + "command": list(checked.command), + "fix": list(checked.fix), + }, ) self._announce_fixes(record, checked) outcome.reason = failure @@ -3225,7 +3427,9 @@ def _from_diff( # the reviewer's copy the truth — and what stops the commit below # from containing lines the reviewer was never shown. applied_diff = candidate_diff(self.repo) or applied_diff - self._emit(record, "checks_passed") + passed_evidence = checked.as_dict() + passed_evidence["commands"] = [list(command) for command in self.checks.commands] + self._emit(record, "checks_passed", evidence=passed_evidence) # Recorded, though it makes resumption no cheaper: re-running a # project's checks is idempotent and costs no model call, so a resumed # attempt runs them again rather than trusting a result from a tree @@ -3469,6 +3673,8 @@ def _base_for(self, record: WorkRecord) -> tuple[str, str | None]: them, so the first is used and the fact is reported rather than hidden. """ + if self.plan_base_for is not None: + return self.plan_base_for(record) candidates = [ spec.target_id for spec in record.dependency_specs() @@ -3516,7 +3722,7 @@ def _starved_prompt(self, starved: Sequence[str]) -> str: paths="\n".join(f" {path} ({self._size_of(path)})" for path in starved) ) - def _repo_listing(self) -> str: + def _repo_listing(self, base: str | None = None) -> str: """The tracked paths, for the planner. The planner's whole job is to name files, and it was given only the @@ -3531,7 +3737,13 @@ def _repo_listing(self) -> str: implementer's job and it has its own budget for that. """ try: - tracked = [path for path in run_git(self.repo, "ls-files").splitlines() if path] + tracked = [ + path + for path in run_git( + self.repo, "ls-tree", "-r", "--name-only", base or "HEAD" + ).splitlines() + if path + ] except GitError: return "" if not tracked: @@ -3602,6 +3814,21 @@ def _sync_worktree(self, base: str) -> None: run_git(self.repo, "clean", "-fd", check=False) run_git(self.repo, "checkout", base) + def _execution_tree_path(self, record: WorkRecord) -> Path: + """The stable private worktree path for one project item. + + ``mkdtemp`` made cleanup after a killed worker impossible: after a + restart there was no durable relationship between a random directory + and the item that owned it. The digest avoids putting item text into + a path while making one item map to one exact harness-owned location. + """ + import hashlib + + identity = f"{self.repo}\0{self.project_id}\0{record.item_id}".encode() + root = self.repo.parent / ".agent-harness-items" + root.mkdir(parents=True, exist_ok=True) + return root / hashlib.sha256(identity).hexdigest()[:32] + def _prepare_branch(self, branch: str, base: str | None = None) -> None: """A clean tree at `base`, on a branch of this item's own. @@ -3614,6 +3841,14 @@ def _prepare_branch(self, branch: str, base: str | None = None) -> None: """ run_git(self.repo, "checkout", "--", ".", check=False) run_git(self.repo, "clean", "-fd", check=False) + if self.environment_factory is not None: + # The worker checkout is itself a disposable worktree whose source + # branch is checked out by the user's repository. Checking that + # branch out here would make git reject the operation because the + # source worktree owns it; create the item branch directly from + # the detached base instead. + run_git(self.repo, "checkout", "-B", branch, base or self.base_branch) + return run_git(self.repo, "checkout", base or self.base_branch) run_git(self.repo, "checkout", "-B", branch) @@ -3626,7 +3861,10 @@ def _abandon_branch(self, branch: str) -> None: """ run_git(self.repo, "checkout", "--", ".", check=False) run_git(self.repo, "clean", "-fd", check=False) - run_git(self.repo, "checkout", self.base_branch, check=False) + if self.environment_factory is not None: + run_git(self.repo, "checkout", "--detach", self.base_branch, check=False) + else: + run_git(self.repo, "checkout", self.base_branch, check=False) run_git(self.repo, "branch", "-D", branch, check=False) def _commit(self, record: WorkRecord, verdict: str = "", checkpoint: bool = False) -> None: diff --git a/src/agent_harness/notifications.py b/src/agent_harness/notifications.py new file mode 100644 index 0000000..d967a5a --- /dev/null +++ b/src/agent_harness/notifications.py @@ -0,0 +1,358 @@ +"""Durable, generic delivery of harness notifications. + +The event and audit stores are history. A notification has a different job: +it must remember a delivery that has not happened yet, retry it, and recover +after a process dies while a receiver is being called. The payload is +immutable; only delivery bookkeeping is mutable. + +Nothing here knows what consumes a notice. ``WebhookChannel`` is one opt-in +authenticated channel, and the protocol makes other channels injectable +without adding their names to core. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import hmac +import json +import logging +import sqlite3 +import threading +import time +import urllib.request +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from .redaction import Redact, from_environment, redact_text + +log = logging.getLogger(__name__) + +DEFAULT_RETRY_SECONDS = 5.0 +DEFAULT_MAX_RETRY_SECONDS = 15 * 60.0 +DEFAULT_LEASE_SECONDS = 60.0 + +# Work events are intentionally a small, stable notification contract. A +# caller can still enqueue any explicit kind/payload; this set prevents a +# progress event for every tool step becoming an accidental alert stream. +NOTIFIABLE_OUTCOMES = frozenset( + { + "blocked", + "blocked_by_policy", + "checks_failed", + "done", + "failed", + "hold_opened", + "plan_promotion", + "remote_review_received", + "review_rejected", + "worker_died", + } +) + + +@dataclass(frozen=True) +class Notification: + """One immutable notification and its current delivery metadata.""" + + notification_id: int + dedupe_key: str + kind: str + payload: dict[str, Any] + created_at: float + attempts: int + state: str + next_attempt_at: float + lease_until: float + last_error: str | None + + +class NotificationChannel(Protocol): + """A destination that either accepts a notification or raises.""" + + def send(self, notification: Notification) -> None: ... + + +Sender = Callable[[urllib.request.Request, float], None] + + +class WebhookChannel: + """POST JSON to an authenticated, operator-supplied endpoint. + + At least one of ``bearer_token`` or ``hmac_secret`` is required. Secrets + stay in process configuration and are never put in the outbox payload. + ``send`` is injectable so delivery can be tested without a network call. + """ + + def __init__( + self, + url: str, + *, + bearer_token: str = "", + hmac_secret: str = "", + timeout: float = 5.0, + send: Sender | None = None, + ) -> None: + if not url.strip(): + raise ValueError("notification webhook URL cannot be empty") + if not bearer_token and not hmac_secret: + raise ValueError("notification webhook requires a bearer token or HMAC secret") + if timeout <= 0: + raise ValueError("notification webhook timeout must be positive") + self.url = url + self.bearer_token = bearer_token + self.hmac_secret = hmac_secret.encode() + self.timeout = timeout + self._send = send or self._post + + @staticmethod + def _post(request: urllib.request.Request, timeout: float) -> None: + with urllib.request.urlopen(request, timeout=timeout) as response: + if not 200 <= response.status < 300: + raise OSError(f"notification endpoint returned HTTP {response.status}") + + def send(self, notification: Notification) -> None: + body = json.dumps( + { + "notification_id": notification.notification_id, + "dedupe_key": notification.dedupe_key, + "kind": notification.kind, + "created_at": notification.created_at, + "payload": notification.payload, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() + request = urllib.request.Request( + self.url, + data=body, + method="POST", + headers={"Content-Type": "application/json"}, + ) + if self.bearer_token: + request.add_header("Authorization", f"Bearer {self.bearer_token}") + if self.hmac_secret: + digest = hmac.new(self.hmac_secret, body, hashlib.sha256).hexdigest() + request.add_header("X-Harness-Signature", f"sha256={digest}") + self._send(request, self.timeout) + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS notifications ( + notification_id INTEGER PRIMARY KEY AUTOINCREMENT, + dedupe_key TEXT NOT NULL UNIQUE, + created_at REAL NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at REAL NOT NULL, + lease_until REAL NOT NULL DEFAULT 0, + delivered_at REAL, + last_error TEXT +); +CREATE INDEX IF NOT EXISTS notifications_due + ON notifications (state, next_attempt_at, lease_until); +""" + + +class NotificationOutbox: + """A durable queue whose delivery cannot affect the work path.""" + + def __init__( + self, + path: Path | str, + channel: NotificationChannel | None = None, + *, + redact: Redact | None = None, + retry_seconds: float = DEFAULT_RETRY_SECONDS, + max_retry_seconds: float = DEFAULT_MAX_RETRY_SECONDS, + lease_seconds: float = DEFAULT_LEASE_SECONDS, + clock: Callable[[], float] = time.time, + ) -> None: + if retry_seconds <= 0 or max_retry_seconds < retry_seconds or lease_seconds <= 0: + raise ValueError("notification retry and lease durations are invalid") + self.path = Path(path) + self.channel = channel + self.redact = redact if redact is not None else from_environment() + self.retry_seconds = retry_seconds + self.max_retry_seconds = max_retry_seconds + self.lease_seconds = lease_seconds + self.clock = clock + self._local = threading.local() + self._stop = threading.Event() + self._thread: threading.Thread | None = None + self.path.parent.mkdir(parents=True, exist_ok=True) + self._connect().executescript(SCHEMA) + + def _connect(self) -> sqlite3.Connection: + conn: sqlite3.Connection | None = getattr(self._local, "conn", None) + if conn is None: + conn = sqlite3.connect(self.path, isolation_level=None, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + self._local.conn = conn + return conn + + def close(self) -> None: + self.stop() + conn: sqlite3.Connection | None = getattr(self._local, "conn", None) + if conn is not None: + with contextlib.suppress(sqlite3.Error): + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.close() + self._local.conn = None + + def enqueue( + self, + kind: str, + payload: Mapping[str, Any], + *, + dedupe_key: str | None = None, + ) -> bool: + """Persist one notification. Returns false when it is a duplicate.""" + clean = redact_text(dict(payload), self.redact) + encoded = json.dumps(clean, sort_keys=True, separators=(",", ":")) + identity = dedupe_key or hashlib.sha256(f"{kind}\0{encoded}".encode()).hexdigest() + now = self.clock() + cursor = self._connect().execute( + "INSERT OR IGNORE INTO notifications " + "(dedupe_key, created_at, kind, payload, next_attempt_at) VALUES (?, ?, ?, ?, ?)", + (identity, now, kind, encoded, now), + ) + return bool(cursor.rowcount) + + def enqueue_event(self, event: Mapping[str, Any]) -> bool: + """Queue selected work outcomes using the event's stable content.""" + outcome = str(event.get("outcome") or "") + if outcome not in NOTIFIABLE_OUTCOMES: + return False + source = str(event.get("source") or "") + remote_id = str(event.get("remote_id") or "") + identity = f"event:{source}\0{remote_id}" if source and remote_id else None + return self.enqueue(str(event.get("kind") or "work"), event, dedupe_key=identity) + + def hook(self) -> Callable[[dict[str, Any]], None]: + """Return a hold/event-compatible durable notification hook.""" + + def notify(payload: dict[str, Any]) -> None: + self.enqueue_event(payload) + + return notify + + def _row(self, row: sqlite3.Row) -> Notification: + return Notification( + notification_id=int(row["notification_id"]), + dedupe_key=str(row["dedupe_key"]), + kind=str(row["kind"]), + payload=dict(json.loads(str(row["payload"]))), + created_at=float(row["created_at"]), + attempts=int(row["attempts"]), + state=str(row["state"]), + next_attempt_at=float(row["next_attempt_at"]), + lease_until=float(row["lease_until"]), + last_error=str(row["last_error"]) if row["last_error"] is not None else None, + ) + + def _claim(self, now: float) -> Notification | None: + conn = self._connect() + conn.execute("BEGIN IMMEDIATE") + try: + row = conn.execute( + "SELECT * FROM notifications WHERE " + "(state = 'pending' AND next_attempt_at <= ?) " + "OR (state = 'inflight' AND lease_until <= ?) " + "ORDER BY notification_id LIMIT 1", + (now, now), + ).fetchone() + if row is None: + conn.execute("COMMIT") + return None + lease = now + self.lease_seconds + conn.execute( + "UPDATE notifications SET state = 'inflight', attempts = attempts + 1, " + "lease_until = ?, last_error = NULL WHERE notification_id = ?", + (lease, row["notification_id"]), + ) + conn.execute("COMMIT") + row = conn.execute( + "SELECT * FROM notifications WHERE notification_id = ?", + (row["notification_id"],), + ).fetchone() + assert row is not None + return self._row(row) + except Exception: + with contextlib.suppress(sqlite3.Error): + conn.execute("ROLLBACK") + raise + + def _ack(self, notification_id: int) -> None: + self._connect().execute( + "UPDATE notifications SET state = 'delivered', delivered_at = ?, lease_until = 0 " + "WHERE notification_id = ? AND state = 'inflight'", + (self.clock(), notification_id), + ) + + def _fail(self, notification: Notification, error: str) -> None: + delay = min( + self.max_retry_seconds, + self.retry_seconds * (2 ** max(notification.attempts - 1, 0)), + ) + self._connect().execute( + "UPDATE notifications SET state = 'pending', next_attempt_at = ?, " + "lease_until = 0, last_error = ? WHERE notification_id = ? AND state = 'inflight'", + (self.clock() + delay, self.redact(error), notification.notification_id), + ) + + def deliver_due(self, *, limit: int = 100, now: float | None = None) -> int: + """Attempt due rows and return the number accepted by the channel.""" + if self.channel is None: + return 0 + delivered = 0 + for _ in range(limit): + notification = self._claim(self.clock() if now is None else now) + if notification is None: + break + try: + self.channel.send(notification) + except Exception as exc: # noqa: BLE001 - retry is the contract + self._fail(notification, str(exc)) + log.warning("notification %s delivery failed", notification.notification_id) + else: + self._ack(notification.notification_id) + delivered += 1 + return delivered + + def start(self, *, poll_seconds: float = 1.0) -> None: + """Start a small dispatcher; pending rows are recoverable on restart.""" + if self.channel is None or self._thread is not None: + return + self._stop.clear() + + def run() -> None: + while not self._stop.is_set(): + self.deliver_due() + self._stop.wait(poll_seconds) + + self._thread = threading.Thread(target=run, name="notification-dispatcher", daemon=True) + self._thread.start() + + def stop(self) -> None: + thread = self._thread + if thread is None: + return + self._stop.set() + thread.join(timeout=max(self.lease_seconds, 1.0)) + self._thread = None + + def rows(self) -> list[Notification]: + """Read delivery state for diagnostics and tests.""" + rows = ( + self._connect() + .execute("SELECT * FROM notifications ORDER BY notification_id") + .fetchall() + ) + return [self._row(row) for row in rows] diff --git a/src/agent_harness/outcomes.py b/src/agent_harness/outcomes.py index ca11d9f..6dd085e 100644 --- a/src/agent_harness/outcomes.py +++ b/src/agent_harness/outcomes.py @@ -286,6 +286,9 @@ def as_dict(self) -> dict[str, Any]: #: pattern hit is answered by looking at the policy, and this is answered by #: looking at what the command was reaching for. PATH_ESCAPE = "path_escape" +# The item gates passed, but its committed delta could not be replayed onto +# the current local plan head. This is repair work, not a provider failure. +PLAN_PROMOTION_CONFLICT = "plan_promotion_conflict" REASON_KINDS = ( CHECKS_FAILED, @@ -307,6 +310,7 @@ def as_dict(self) -> dict[str, Any]: ITEM_IMPOSSIBLE, COMMAND_BLOCKED, PATH_ESCAPE, + PLAN_PROMOTION_CONFLICT, ) diff --git a/src/agent_harness/plan_integration.py b/src/agent_harness/plan_integration.py new file mode 100644 index 0000000..035771c --- /dev/null +++ b/src/agent_harness/plan_integration.py @@ -0,0 +1,727 @@ +"""Local plan-branch integration for a project. + +The coordinator owns no remote and knows nothing about a repository's file +layout. It receives a project checkout, an explicit integration ref and the +same configured checks used by the item executor. Item branches remain intact; +promotion replays their delta onto one serialized local branch and gates that +result again. +""" + +from __future__ import annotations + +import hashlib +import subprocess +import tempfile +import threading +import time +import uuid +from collections.abc import Callable +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .executor import Checks, GitError +from .graph import LOCAL_WORK +from .work import WorkQueue, WorkRecord + + +class PromotionError(RuntimeError): + """A promotion could not become the new plan head.""" + + +class PromotionConflict(PromotionError): + """The item delta does not apply cleanly to the current plan head.""" + + +DEFAULT_PROMOTION_LEASE_SECONDS = 60.0 +DEFAULT_PROMOTION_WAIT_SECONDS = 300.0 + + +@dataclass(frozen=True) +class PlanState: + project_id: str + branch: str + target_branch: str + target_sha: str + head_sha: str + plan_digest: str + + +@dataclass(frozen=True) +class Promotion: + item_id: str + status: str + old_head_sha: str + new_head_sha: str | None + detail: str = "" + + +_LOCKS: dict[tuple[str, str], threading.Lock] = {} +_LOCKS_GUARD = threading.Lock() + + +def _promotion_lock(project_id: str, repo: Path) -> threading.Lock: + key = (project_id, str(repo)) + with _LOCKS_GUARD: + return _LOCKS.setdefault(key, threading.Lock()) + + +class _PromotionLeaseHeartbeat: + """Keep a durable promotion lease alive while gates are running.""" + + def __init__(self, queue: WorkQueue, project_id: str, owner: str, seconds: float) -> None: + self.queue = queue + self.project_id = project_id + self.owner = owner + self.seconds = seconds + self.lost = threading.Event() + self._stop = threading.Event() + self._thread = threading.Thread( + target=self._run, + name="harness-plan-lease-heartbeat", + daemon=True, + ) + + def start(self) -> None: + self._thread.start() + + def stop(self) -> None: + self._stop.set() + self._thread.join(timeout=max(1.0, self.seconds)) + + def _run(self) -> None: + interval = max(0.01, self.seconds / 3.0) + while not self._stop.wait(interval): + if not self.queue.renew_plan_promotion_lease(self.project_id, self.owner, self.seconds): + self.lost.set() + return + + +class PlanCoordinator: + """Durably create and advance one local integration branch.""" + + def __init__( + self, + queue: WorkQueue, + project_id: str, + repo: Path, + *, + checks: Checks, + on_event: Callable[[dict[str, Any]], None] | None = None, + promotion_lease_seconds: float = DEFAULT_PROMOTION_LEASE_SECONDS, + promotion_wait_seconds: float = DEFAULT_PROMOTION_WAIT_SECONDS, + promotion_sleep: Callable[[float], None] = time.sleep, + promotion_clock: Callable[[], float] = time.monotonic, + ) -> None: + if promotion_lease_seconds <= 0: + raise ValueError("promotion lease must be positive") + if promotion_wait_seconds < 0: + raise ValueError("promotion wait must not be negative") + self.queue = queue + self.project_id = project_id + self.repo = Path(repo).resolve() + self.checks = checks + self.on_event = on_event + self.promotion_lease_seconds = promotion_lease_seconds + self.promotion_wait_seconds = promotion_wait_seconds + self._promotion_sleep = promotion_sleep + self._promotion_clock = promotion_clock + self._lease_owner = uuid.uuid4().hex + self._lease_heartbeat: _PromotionLeaseHeartbeat | None = None + # Executors are built per worker, so an instance lock would leave + # concurrent workers free to advance the same ref simultaneously. + self._lock = _promotion_lock(project_id, self.repo) + + def ensure(self, *, target_branch: str, branch: str, plan_path: str | None = None) -> PlanState: + """Create or restore the plan while serialising first-use races.""" + with self._promotion_guard(): + return self._ensure( + target_branch=target_branch, + branch=branch, + plan_path=plan_path, + ) + + @contextmanager + def _promotion_guard(self) -> Any: + """Hold both local and durable serialization while integrating.""" + with self._lock: + deadline = self._promotion_clock() + self.promotion_wait_seconds + while True: + acquired, lease_until = self.queue.acquire_plan_promotion_lease( + self.project_id, + self._lease_owner, + self.promotion_lease_seconds, + ) + if acquired: + break + remaining = deadline - self._promotion_clock() + if remaining <= 0: + raise PromotionError( + f"plan promotion for {self.project_id!r} remained leased until " + f"{lease_until} after waiting {self.promotion_wait_seconds} seconds" + ) + self._promotion_sleep( + min(remaining, max(0.01, min(1.0, self.promotion_lease_seconds / 10))) + ) + heartbeat = _PromotionLeaseHeartbeat( + self.queue, + self.project_id, + self._lease_owner, + self.promotion_lease_seconds, + ) + self._lease_heartbeat = heartbeat + heartbeat.start() + try: + yield + finally: + heartbeat.stop() + self.queue.release_plan_promotion_lease(self.project_id, self._lease_owner) + self._lease_heartbeat = None + + def _assert_lease(self) -> None: + heartbeat = self._lease_heartbeat + if heartbeat is None or heartbeat.lost.is_set(): + raise PromotionError("plan promotion lease was lost before publication") + if not self.queue.renew_plan_promotion_lease( + self.project_id, self._lease_owner, self.promotion_lease_seconds + ): + heartbeat.lost.set() + raise PromotionError("plan promotion lease was lost before publication") + + def _ensure( + self, *, target_branch: str, branch: str, plan_path: str | None = None + ) -> PlanState: + if not branch.strip(): + raise PromotionError("plan integration requires an explicit local plan branch") + target_sha = self._git("rev-parse", target_branch).strip() + digest = "" + if plan_path: + digest = hashlib.sha256(Path(plan_path).read_bytes()).hexdigest() + current = self.queue.plan(self.project_id) + if current is not None: + state = PlanState(**dict(current)) + if (state.branch, state.target_branch) != (branch, target_branch): + raise PromotionError( + "the durable plan identity changed; create a new project/plan before continuing" + ) + self._recover_in_progress(state) + self._recover_refreshes(self.state()) + state = self.state() + if state.target_sha != target_sha: + return self._refresh(state, target_sha) + actual = self._git("rev-parse", state.branch).strip() + if actual != state.head_sha: + raise PromotionError( + f"plan branch {state.branch!r} moved outside the coordinator " + f"({state.head_sha} -> {actual})" + ) + return state + existing_ref = self._git( + "rev-parse", "--verify", f"refs/heads/{branch}", check=False + ).strip() + if existing_ref and existing_ref != target_sha: + raise PromotionError( + f"plan branch {branch!r} already exists at {existing_ref}, not target {target_sha}" + ) + if not existing_ref: + self._git("branch", branch, target_sha) + try: + self.queue.create_plan( + self.project_id, + branch=branch, + target_branch=target_branch, + target_sha=target_sha, + head_sha=target_sha, + plan_digest=digest, + ) + except Exception: + current = self.queue.plan(self.project_id) + if current is None: + raise + return PlanState(**dict(current)) + return PlanState(self.project_id, branch, target_branch, target_sha, target_sha, digest) + + def _recover_in_progress(self, state: PlanState) -> None: + """Finish or abandon a promotion interrupted around ``update-ref``.""" + pending = self.queue.in_progress_promotions(self.project_id) + if not pending: + return + actual = self._git("rev-parse", state.branch).strip() + for row in pending: + old_head = str(row["old_head_sha"]) + new_head = str(row["new_head_sha"]) + if actual == new_head and state.head_sha == old_head: + self.queue.complete_promotion( + self.project_id, + str(row["item_id"]), + str(row["item_branch"]), + str(row["base_sha"]), + old_head, + new_head, + int(row["promotion_id"]), + "recovered after restart", + ) + self._emit_promotion_row( + row, + status="promoted", + target_sha=state.target_sha, + detail="recovered after restart", + ) + state = self.state() + continue + if actual == old_head and state.head_sha == old_head: + detail = "Git ref was unchanged when the interrupted promotion was recovered" + self.queue.finish_promotion( + int(row["promotion_id"]), + "abandoned", + detail, + ) + self._emit_promotion_row( + row, + status="abandoned", + target_sha=state.target_sha, + detail=detail, + ) + continue + raise PromotionError( + f"in-progress promotion {row['promotion_id']} has unexpected plan ref " + f"{actual}; expected {old_head} or {new_head}" + ) + + def _recover_refreshes(self, state: PlanState) -> None: + """Finish or abandon a refresh interrupted around ``update-ref``.""" + pending = self.queue.in_progress_refreshes(self.project_id) + if not pending: + return + actual = self._git("rev-parse", state.branch).strip() + for row in pending: + old_target = str(row["old_target_sha"]) + new_target = str(row["new_target_sha"]) + old_head = str(row["old_head_sha"]) + new_head = str(row["new_head_sha"]) + if actual == new_head and state.target_sha == old_target and state.head_sha == old_head: + self.queue.complete_refresh( + int(row["refresh_id"]), + self.project_id, + old_target, + new_target, + old_head, + new_head, + "recovered after restart", + ) + state = self.state() + continue + if actual == old_head and state.target_sha == old_target and state.head_sha == old_head: + self.queue.finish_refresh( + int(row["refresh_id"]), + "abandoned", + "Git ref was unchanged when the interrupted refresh was recovered", + ) + continue + raise PromotionError( + f"in-progress refresh {row['refresh_id']} has unexpected plan ref " + f"{actual}; expected {old_head} or {new_head}" + ) + + def _refresh(self, state: PlanState, target_sha: str) -> PlanState: + """Rebuild the plan from a moved target and replay promoted items. + + The existing plan ref and projection are not touched until every + recorded promotion has applied and passed the integration checks. + """ + self._assert_lease() + refresh_id = self.queue.begin_refresh( + self.project_id, + state.target_sha, + target_sha, + state.head_sha, + "", + ) + try: + with tempfile.TemporaryDirectory( + prefix="harness-plan-refresh-", dir=self.repo.parent + ) as temp: + tree = Path(temp) + self._git("worktree", "add", "--detach", str(tree), target_sha) + try: + replayed = 0 + for row in self.queue.successful_promotions(self.project_id): + item_sha = row["item_sha"] + if not item_sha: + item_sha = self._git( + "rev-parse", str(row["item_branch"]), check=False + ).strip() + if not item_sha: + raise PromotionError( + f"cannot refresh plan: item {row['item_id']!r} " + "has no durable commit" + ) + try: + self._merge_item(tree, str(item_sha)) + except PromotionConflict as exc: + raise PromotionConflict( + str(exc) + or f"promotion {row['item_id']!r} conflicts with moved target" + ) from exc + checked = self.checks.run(tree) + if not checked.ok: + raise PromotionError( + checked.detail + or f"integration gate failed while replaying {row['item_id']!r}" + ) + message = self._git("show", "-s", "--format=%B", str(item_sha)).strip() + if not message: + message = f"Replay {row['item_id']}" + self._git_in(tree, "add", "-A") + self._git_in(tree, "commit", "-m", message) + replayed += 1 + new_head = self._git_in(tree, "rev-parse", "HEAD").strip() + finally: + self._git("worktree", "remove", "--force", str(tree), check=False) + self._git("worktree", "prune", check=False) + except PromotionConflict as exc: + self.queue.finish_refresh(refresh_id, "conflict", str(exc)) + raise + except PromotionError as exc: + self.queue.finish_refresh(refresh_id, "gates_failed", str(exc)) + raise + except Exception as exc: + self.queue.finish_refresh(refresh_id, "failed", str(exc)) + raise + + # The target is external state. It may have advanced while replaying + # and gating the promoted items, so do not publish a rebuild that was + # based on an already stale target. The durable plan still points at + # the old target here; close this journal as superseded and rebuild + # from the newer target instead. + try: + latest_target = self._git("rev-parse", state.target_branch).strip() + except Exception as exc: + self.queue.finish_refresh(refresh_id, "failed", str(exc)) + raise + if latest_target != target_sha: + self.queue.finish_refresh( + refresh_id, + "superseded", + f"target advanced during replay ({target_sha} -> {latest_target})", + ) + return self._refresh(self.state(), latest_target) + + self._assert_lease() + self.queue.set_refresh_head(refresh_id, new_head) + self._assert_lease() + self._git( + "update-ref", + f"refs/heads/{state.branch}", + new_head, + state.head_sha, + ) + self._assert_lease() + self.queue.complete_refresh( + refresh_id, + self.project_id, + state.target_sha, + target_sha, + state.head_sha, + new_head, + f"replayed {replayed} promoted item(s) after target moved", + ) + return self.state() + + def state(self) -> PlanState: + row = self.queue.plan(self.project_id) + if row is None: + raise PromotionError(f"project {self.project_id!r} has no initialized plan") + return PlanState(**dict(row)) + + def base_for(self, record: WorkRecord) -> tuple[str, str | None]: + state = self.state() + return state.branch, "local plan branch" + + def promote(self, record: WorkRecord, *, item_branch: str, base: str) -> Promotion: + with self._promotion_guard(): + while True: + self._assert_lease() + state = self.state() + self._recover_in_progress(state) + self._recover_refreshes(self.state()) + state = self.state() + live_target = self._git("rev-parse", state.target_branch).strip() + if live_target != state.target_sha: + state = self._refresh(state, live_target) + previous = self.queue.latest_promotion(self.project_id, record.item_id) + if previous is not None: + return Promotion( + record.item_id, + "promoted", + str(previous["old_head_sha"]), + str(previous["new_head_sha"]), + "promotion already durable", + ) + for dependency in record.dependency_specs(): + if not dependency.required or dependency.target_kind != LOCAL_WORK: + continue + if self.queue.latest_promotion(self.project_id, dependency.target_id) is None: + raise PromotionError( + f"cannot promote {record.item_id}: prerequisite " + f"{dependency.target_id} is not promoted" + ) + old_head = state.head_sha + base_sha = self._git("rev-parse", base).strip() + item_sha = self._git("rev-parse", item_branch).strip() + retry_target: str | None = None + promotion_id: int | None = None + new_head = "" + with tempfile.TemporaryDirectory( + prefix="harness-plan-", dir=self.repo.parent + ) as temp: + tree = Path(temp) + self._git("worktree", "add", "--detach", str(tree), state.branch) + try: + try: + self._merge_item(tree, item_sha) + except PromotionConflict as exc: + detail = str(exc) + promotion_id = self.queue.record_promotion( + self.project_id, + record.item_id, + item_branch, + base_sha, + old_head, + None, + "conflict", + detail, + item_sha, + ) + self._emit_promotion( + record, + status="conflict", + promotion_id=promotion_id, + base_sha=base_sha, + item_sha=item_sha, + old_head_sha=old_head, + detail=detail, + ) + raise PromotionConflict( + f"plan branch {state.branch!r} is at {old_head}; " + "repair the item against that head: " + + (detail or "item delta conflicts with plan head") + ) from exc + checked = self.checks.run(tree) + if not checked.ok: + detail = checked.detail or "authoritative integration gate failed" + promotion_id = self.queue.record_promotion( + self.project_id, + record.item_id, + item_branch, + base_sha, + old_head, + None, + "gates_failed", + detail, + item_sha, + ) + self._emit_promotion( + record, + status="gates_failed", + promotion_id=promotion_id, + base_sha=base_sha, + item_sha=item_sha, + old_head_sha=old_head, + detail=detail, + ) + raise PromotionError(detail) + + self._assert_lease() + latest_target = self._git("rev-parse", state.target_branch).strip() + if latest_target != state.target_sha: + retry_target = latest_target + else: + message = self._git("log", "-1", "--format=%B", item_branch).strip() + if not message: + message = f"Promote {record.item_id}" + self._git_in(tree, "add", "-A") + self._git_in(tree, "commit", "-m", message) + new_head = self._git_in(tree, "rev-parse", "HEAD").strip() + self._assert_lease() + promotion_id = self.queue.begin_promotion( + self.project_id, + record.item_id, + item_branch, + base_sha, + old_head, + new_head, + item_sha, + ) + latest_target = self._git("rev-parse", state.target_branch).strip() + if latest_target != state.target_sha: + self.queue.finish_promotion( + promotion_id, + "superseded", + f"target advanced during promotion ({state.target_sha} -> " + f"{latest_target})", + ) + self._emit_promotion( + record, + status="superseded", + promotion_id=promotion_id, + base_sha=base_sha, + item_sha=item_sha, + old_head_sha=old_head, + new_head_sha=new_head, + target_sha=latest_target, + detail=( + f"target advanced during promotion ({state.target_sha} -> " + f"{latest_target})" + ), + ) + retry_target = latest_target + promotion_id = None + else: + self._assert_lease() + self._git( + "update-ref", + f"refs/heads/{state.branch}", + new_head, + old_head, + ) + finally: + self._git("worktree", "remove", "--force", str(tree), check=False) + self._git("worktree", "prune", check=False) + if retry_target is not None: + self._refresh(self.state(), retry_target) + continue + if promotion_id is None: + raise PromotionError("promotion did not produce a durable journal entry") + self._assert_lease() + self.queue.complete_promotion( + self.project_id, + record.item_id, + item_branch, + base_sha, + old_head, + new_head, + promotion_id, + "authoritative integration gates passed", + ) + self._emit_promotion( + record, + status="promoted", + promotion_id=promotion_id, + base_sha=base_sha, + item_sha=item_sha, + old_head_sha=old_head, + new_head_sha=new_head, + target_sha=state.target_sha, + detail="authoritative integration gates passed", + ) + latest_target = self._git("rev-parse", state.target_branch).strip() + if latest_target != state.target_sha: + refreshed = self._refresh(self.state(), latest_target) + return Promotion( + record.item_id, + "promoted", + old_head, + refreshed.head_sha, + "authoritative integration gates passed after target refresh", + ) + return Promotion(record.item_id, "promoted", old_head, new_head) + + def _emit_promotion( + self, + record: WorkRecord, + *, + promotion_id: int | None = None, + status: str, + base_sha: str, + item_sha: str, + old_head_sha: str, + new_head_sha: str | None = None, + target_sha: str | None = None, + detail: str = "", + ) -> None: + """Publish a non-load-bearing, item-scoped promotion fact.""" + if self.on_event is None: + return + try: + event = { + "ts": time.time(), + "kind": "work", + "project_id": self.project_id, + "item_id": record.item_id, + "outcome": "plan_promotion", + "promotion_id": promotion_id, + "status": status, + "plan_branch": self.state().branch, + "base_sha": base_sha, + "item_sha": item_sha, + "old_head_sha": old_head_sha, + "new_head_sha": new_head_sha, + "target_sha": target_sha, + "detail": detail, + } + self.on_event(event) + except Exception: + # Telemetry must never turn a durable promotion into a worker + # failure. The queue and Git projections are authoritative. + return + + def _emit_promotion_row( + self, + row: Any, + *, + status: str, + detail: str, + target_sha: str | None = None, + ) -> None: + """Emit a durable promotion row, including restart recovery facts.""" + if self.on_event is None: + return + item_id = str(row["item_id"]) + item_sha = str(row["item_sha"] or "") + if not item_sha: + item_sha = self._git("rev-parse", str(row["item_branch"]), check=False).strip() + self._emit_promotion( + WorkRecord(item_id, item_id), + promotion_id=int(row["promotion_id"]), + status=status, + base_sha=str(row["base_sha"]), + item_sha=item_sha, + old_head_sha=str(row["old_head_sha"]), + new_head_sha=(str(row["new_head_sha"]) if row["new_head_sha"] is not None else None), + target_sha=target_sha, + detail=detail, + ) + + def _git(self, *args: str, check: bool = True) -> str: + return self._git_at(self.repo, *args, check=check) + + @staticmethod + def _git_at(repo: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + check=False, + ) + if check and result.returncode != 0: + raise GitError(f"git {' '.join(args)}: {result.stderr.strip()}") + return result.stdout + + def _git_in(self, repo: Path, *args: str, check: bool = True) -> str: + return self._git_at(repo, *args, check=check) + + def _merge_item(self, tree: Path, item_sha: str) -> None: + """Stage an item commit as the second parent of the plan merge.""" + result = subprocess.run( + ["git", "-C", str(tree), "merge", "--no-ff", "--no-commit", item_sha], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return + self._git_in(tree, "merge", "--abort", check=False) + detail = (result.stderr or result.stdout).strip() + raise PromotionConflict(detail or f"item commit {item_sha} conflicts with plan head") diff --git a/src/agent_harness/plan_publication.py b/src/agent_harness/plan_publication.py new file mode 100644 index 0000000..273fdd9 --- /dev/null +++ b/src/agent_harness/plan_publication.py @@ -0,0 +1,423 @@ +"""Publish one plan branch as exactly one pull request, and keep it that way. + +The coordinator in :mod:`plan_integration` owns the local integration branch +and knows nothing about a remote. This module owns the single remote step the +product allows: push that branch and put **one** pull request in front of a +person. It is deliberately separate so local promotion never depends on a +remote being reachable, and so a deployment that never publishes never runs +this code. + +Three rules make it safe to call repeatedly, which is what "corrections resume +automatically" requires: + +1. **One pull request per plan.** A recorded URL is adopted before anything is + created, and `find_open_pr` is asked before that. There is never a second + pull request for a plan, and never a per-item one. +2. **Republishing an unchanged head does nothing.** A correction that produced + no new plan head must not push, must not comment and must not reopen a + decision a person has already been given. +3. **The harness never merges.** Publication is the hand-off, not the + decision. Nothing here approves, marks ready or merges. + +The push uses `--force-with-lease` against the sha this harness last +published, because a plan branch legitimately rewinds: a moved target branch +rebuilds it from the new target and replays every promotion. The lease is what +turns "the branch was rebuilt" into a safe update and "somebody else pushed to +it" into a refusal. +""" + +from __future__ import annotations + +import contextlib +import json +import subprocess +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Protocol + +from .plan_integration import PlanState +from .work import BLOCKED, CLAIMED, EXHAUSTED, FAILED, HELD, PENDING, WorkQueue + +SETTING_PREFIX = "plan-publication:" + +#: Work that could still change the tree. Publishing over it would put a plan +#: in front of a person while the harness was still writing it. +IN_FLIGHT_STATES = (PENDING, CLAIMED, HELD) + +#: Work that stopped without delivering. This is an exception for a person +#: under P10, and it withholds publication rather than quietly shipping a +#: partial plan whose gaps only the queue knows about. +UNRESOLVED_STATES = (FAILED, EXHAUSTED, BLOCKED) + + +class PublicationError(RuntimeError): + """The plan could not be published, and no remote state was assumed.""" + + +class PullRequests(Protocol): + """The subset of the GitHub client publication uses.""" + + def create_pr( + self, *, title: str, body: str, head: str, base: str, draft: bool = False + ) -> str: ... + + def find_open_pr(self, head: str) -> str | None: ... + + def comment_pr(self, pr: str, body: str) -> None: ... + + +@dataclass(frozen=True) +class Publication: + """What one publish call did, and the durable record it left.""" + + status: str # "created" | "updated" | "unchanged" + pr_url: str + head_sha: str + published_sha: str | None + detail: str = "" + + +@dataclass(frozen=True) +class PlanReadiness: + """Whether the whole plan is locally acceptable, and why not if it is not.""" + + ready: bool + detail: str + in_flight: int = 0 + unresolved: int = 0 + + +@dataclass(frozen=True) +class PublicationRecord: + """The durable answer to "have we already published this plan, and where".""" + + pr_url: str | None = None + head_sha: str | None = None + branch: str | None = None + target_branch: str | None = None + + +class PlanPublisher: + """Push one plan branch and maintain its single pull request.""" + + def __init__( + self, + queue: WorkQueue, + project_id: str, + repo: Path, + github: PullRequests, + *, + remote: str = "origin", + on_event: Callable[[dict[str, Any]], None] | None = None, + runner: Callable[[list[str]], str] | None = None, + ) -> None: + if not str(project_id).strip(): + raise ValueError("publication needs a project id") + if not str(remote).strip(): + raise ValueError("publication needs an explicit remote name") + self.queue = queue + self.project_id = project_id + self.repo = Path(repo).resolve() + self.github = github + self.remote = remote + self.on_event = on_event + self._run = runner or self._subprocess_run + self.setting_key = f"{SETTING_PREFIX}{project_id}" + + # -- durable record -------------------------------------------------- + + @property + def record(self) -> PublicationRecord: + raw = self.queue.get_setting(self.setting_key) + if not raw: + return PublicationRecord() + try: + data = json.loads(str(raw)) + except ValueError: + # An unreadable record is not permission to open a second pull + # request, so it is reported rather than silently discarded. + raise PublicationError( + f"publication record for {self.project_id!r} is unreadable; " + "repair or clear it before publishing again" + ) from None + if not isinstance(data, dict): + raise PublicationError(f"publication record for {self.project_id!r} is not an object") + return PublicationRecord( + pr_url=data.get("pr_url"), + head_sha=data.get("head_sha"), + branch=data.get("branch"), + target_branch=data.get("target_branch"), + ) + + def _remember(self, state: PlanState, pr_url: str) -> None: + self.queue.set_setting( + self.setting_key, + json.dumps( + { + "pr_url": pr_url, + "head_sha": state.head_sha, + "branch": state.branch, + "target_branch": state.target_branch, + } + ), + ) + + # -- when a plan is ready to be seen --------------------------------- + + def readiness(self, *, excluding: str | None = None) -> PlanReadiness: + """Is the whole plan locally acceptable? + + Publication is a hand-off to a person, so it waits for the plan to + stop moving. Anything still claimable, claimed or held could change + the tree; anything failed, exhausted or blocked did not deliver, and + shipping around it would present a partial plan as a finished one. + Both are reported by name so an operator is never left guessing which + item is holding publication up. + + `excluding` names the item whose promotion is asking. It is still + claimed at that moment — the queue marks it done afterwards — but its + work is already in the plan branch, so counting it as in flight would + mean the last item of a plan could never trigger publication. + """ + counts = self._counts(excluding) + in_flight = sum(counts.get(state, 0) for state in IN_FLIGHT_STATES) + unresolved = sum(counts.get(state, 0) for state in UNRESOLVED_STATES) + if in_flight: + detail = f"{in_flight} item(s) still in flight" + elif unresolved: + detail = f"{unresolved} item(s) did not deliver and need a person" + elif not counts: + detail = "the plan has no items" + else: + detail = "every item is done" + return PlanReadiness( + ready=not in_flight and not unresolved and bool(counts), + detail=detail, + in_flight=in_flight, + unresolved=unresolved, + ) + + def _counts(self, excluding: str | None) -> dict[str, int]: + counts = dict(self.queue.counts(self.project_id)) + if excluding is None: + return counts + record = self.queue.get(excluding, project_id=self.project_id) + if record is not None and counts.get(record.state): + counts[record.state] -= 1 + return counts + + def item_evidence(self) -> str: + """The promoted items, in promotion order, for the pull-request body. + + P8 requires item commits, dependencies and gate results to stay + visible in the one pull request. This is the item half of that: the + queue is the source, so the body cannot claim a promotion the durable + record does not have. + """ + rows = self.queue.successful_promotions(self.project_id) + if not rows: + return "_No promoted items._" + lines = ["| item | item commit | plan head |", "|---|---|---|"] + for row in rows: + item_sha = str(row["item_sha"] or "")[:12] or "—" + head_sha = str(row["new_head_sha"] or "")[:12] or "—" + lines.append(f"| `{row['item_id']}` | `{item_sha}` | `{head_sha}` |") + return "\n".join(lines) + + def publish_if_ready( + self, + state: PlanState, + *, + title: str, + body: str = "", + draft: bool = False, + summary: str | None = None, + excluding: str | None = None, + ) -> Publication | None: + """Publish only a plan that has stopped moving; otherwise do nothing. + + Returning `None` rather than raising is deliberate: "not finished + yet" is the normal answer on every promotion but the last, and it is + not a failure of the item that triggered the check. + """ + ready = self.readiness(excluding=excluding) + if not ready.ready: + return None + return self.publish( + state, + title=title, + body=body or self.item_evidence(), + draft=draft, + summary=summary, + ) + + # -- publication ----------------------------------------------------- + + def publish( + self, + state: PlanState, + *, + title: str, + body: str, + draft: bool = False, + summary: str | None = None, + ) -> Publication: + """Publish, or update, the one pull request for this plan. + + `summary` is the note left on an existing pull request when a + correction moves the plan head. It is evidence for the reviewer who + already looked once, not a new request for review. + """ + record = self.record + if record.branch and record.branch != state.branch: + raise PublicationError( + f"plan {self.project_id!r} was published from branch {record.branch!r}, " + f"not {state.branch!r}; publishing a second branch would open a second " + "pull request" + ) + existing = record.pr_url or self._find_existing(state.branch) + if existing and record.head_sha == state.head_sha: + # Nothing was promoted since the last publication. A duplicate + # review event, a retried poll or a no-op correction all land + # here, and none of them may touch the remote. + result = Publication( + status="unchanged", + pr_url=existing, + head_sha=state.head_sha, + published_sha=record.head_sha, + detail="plan head is unchanged since it was published", + ) + self._emit(state, result) + return result + + self._push(state, record) + + if existing: + if summary: + with contextlib.suppress(Exception): + # A comment is evidence, not the publication. Failing to + # leave one must not lose the fact that the branch moved. + self.github.comment_pr(existing, summary) + status, detail = "updated", "existing plan pull request now carries the new head" + pr_url = existing + else: + pr_url = str( + self.github.create_pr( + title=title, + body=body, + head=state.branch, + base=state.target_branch, + draft=draft, + ) + ).strip() + if not pr_url: + raise PublicationError("the remote returned no pull request URL") + status, detail = "created", "one plan pull request opened for human review" + + self._remember(state, pr_url) + result = Publication( + status=status, + pr_url=pr_url, + head_sha=state.head_sha, + published_sha=record.head_sha, + detail=detail, + ) + self._emit(state, result) + return result + + def _find_existing(self, branch: str) -> str | None: + try: + found = self.github.find_open_pr(branch) + except Exception as exc: # noqa: BLE001 - a blind create would duplicate + raise PublicationError( + f"could not ask the remote whether {branch!r} already has a pull " + f"request, so none was created: {exc}" + ) from exc + return str(found) if found else None + + def _lease(self, state: PlanState, record: PublicationRecord) -> str: + """What this harness expects the remote branch to be right now. + + Normally that is the sha it last published. When there is no record — + a first publication, or an adopted pull request whose record was lost + — the only safe expectation is one this checkout can prove it already + contains. An empty lease means "expect the ref not to exist", which is + correct for a branch nobody has published, and a refusal for one + somebody has. + """ + if record.head_sha: + return record.head_sha + remote_sha = self._remote_sha(state.branch) + if not remote_sha: + return "" + if self._contains(state.head_sha, remote_sha): + return remote_sha + raise PublicationError( + f"{self.remote}/{state.branch} is at {remote_sha}, which this plan does not " + "contain, and no publication record explains it; publishing would discard " + "work this harness cannot see" + ) + + def _remote_sha(self, branch: str) -> str: + """The remote branch's sha, fetched so it is a local object.""" + try: + self._run(["git", "-C", str(self.repo), "fetch", self.remote, branch]) + except Exception: # noqa: BLE001 - an absent branch is the common case + return "" + return self._run(["git", "-C", str(self.repo), "rev-parse", "FETCH_HEAD"]).strip() + + def _contains(self, head_sha: str, candidate: str) -> bool: + try: + self._run( + ["git", "-C", str(self.repo), "merge-base", "--is-ancestor", candidate, head_sha] + ) + except Exception: # noqa: BLE001 - "not an ancestor" is an exit code + return False + return True + + def _push(self, state: PlanState, record: PublicationRecord) -> None: + lease = self._lease(state, record) + args = [ + "git", + "-C", + str(self.repo), + "push", + f"--force-with-lease={state.branch}:{lease}", + self.remote, + f"{state.branch}:{state.branch}", + ] + try: + self._run(args) + except Exception as exc: # noqa: BLE001 - remote divergence is a named refusal + raise PublicationError( + f"could not publish {state.branch!r} to {self.remote!r}: {exc}" + ) from exc + + @staticmethod + def _subprocess_run(args: list[str]) -> str: + result = subprocess.run( # noqa: S603 - fixed argv, no shell + args, capture_output=True, text=True + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "git push failed") + return result.stdout + + def _emit(self, state: PlanState, result: Publication) -> None: + if self.on_event is None: + return + with contextlib.suppress(Exception): + self.on_event( + { + "kind": "work", + "outcome": "plan_published", + "project_id": self.project_id, + "status": result.status, + "branch": state.branch, + "target_branch": state.target_branch, + "head_sha": result.head_sha, + "previous_head_sha": result.published_sha, + "pr_url": result.pr_url, + "detail": result.detail, + } + ) diff --git a/src/agent_harness/preflight.py b/src/agent_harness/preflight.py index 6189f3b..2464468 100644 --- a/src/agent_harness/preflight.py +++ b/src/agent_harness/preflight.py @@ -630,6 +630,8 @@ def preflight_project( allow_dirty: bool = False, base_probe: Callable[[str, str], tuple[bool, str]] = _base_is_current, allow_stale_base: bool = False, + execution_environment: Probe | None = None, + remote_required: bool = True, ) -> Preflight: """Everything that must hold before this project can produce a pull request.""" checks: list[Check] = [] @@ -667,6 +669,10 @@ def preflight_project( ok, detail = role_runner() checks.append(Check("role runner", ok, detail)) + if execution_environment is not None: + ok, detail = execution_environment() + checks.append(Check("execution environment", ok, detail)) + work_dir = getattr(project, "work_dir", None) if work_dir: ok, detail = git_probe(work_dir) @@ -708,7 +714,7 @@ def preflight_project( ) repo = getattr(project, "repo", None) - if repo: + if repo and remote_required: ok, detail = github_probe(repo) checks.append( Check( @@ -717,10 +723,19 @@ def preflight_project( detail if ok else f"{detail} — items cannot reach a pull request", ) ) - else: + elif remote_required: checks.append( Check("github write", False, "no repo is configured, so no pull request can be opened") ) + else: + checks.append( + Check( + "github write", + True, + "remote publication is disabled; local promotion is the configured destination", + blocking=False, + ) + ) # No reviewer means every review fails closed, so every item fails. That # is worse than not starting: it spends the implementer's tokens first. diff --git a/src/agent_harness/project_service.py b/src/agent_harness/project_service.py index fe9af91..fe8c9c8 100644 --- a/src/agent_harness/project_service.py +++ b/src/agent_harness/project_service.py @@ -36,6 +36,7 @@ def project_spec(project: Project) -> ProjectSpec: max_item_spend_usd=project.max_item_spend_usd, max_hold_seconds=project.max_hold_seconds, plan_path=project.plan_path, + plan_branch=project.plan_branch, roles=( {name: RoleRoute(**route) for name, route in project.roles.items()} if project.roles @@ -75,6 +76,7 @@ def configure_project( max_item_spend_usd=spec.max_item_spend_usd, max_hold_seconds=spec.max_hold_seconds, plan_path=spec.plan_path, + plan_branch=spec.plan_branch, roles=( {name: route.model_dump() for name, route in spec.roles.items()} if spec.roles else None ), diff --git a/src/agent_harness/query_service.py b/src/agent_harness/query_service.py index ee0413c..8650854 100644 --- a/src/agent_harness/query_service.py +++ b/src/agent_harness/query_service.py @@ -36,6 +36,7 @@ EventFilters, EventPage, FleetControl, + GateEvidence, GatewayLog, GatewayLogPage, HoldList, @@ -44,12 +45,15 @@ LatestEvent, OpenQuestion, PlanParseResult, + PlanPromotionEvidence, ProcessMetrics, ProjectList, ProjectSummary, ProposalModel, RateLimits, ReadinessReasonModel, + RemoteReviewEvidence, + RunnerProgressEvidence, WorkerInventory, WorkerInventoryItem, WorkEvidence, @@ -693,14 +697,123 @@ def evidence(self, project_id: str, item_id: str) -> WorkEvidence | None: HoldView(**hold.as_dict(queue.now())) for hold in queue.holds.history(project_id, item_id) ] + runner_progress, gates, promotions, remote_reviews = self._typed_item_events(events) return WorkEvidence( project_id=project_id, item_id=item_id, events=events, stages=stages, holds=holds, + runner_progress=runner_progress, + gates=gates, + promotions=promotions, + remote_reviews=remote_reviews, ) + @staticmethod + def _typed_item_events( + events: list[Event], + ) -> tuple[ + list[RunnerProgressEvidence], + list[GateEvidence], + list[PlanPromotionEvidence], + list[RemoteReviewEvidence], + ]: + progress: list[RunnerProgressEvidence] = [] + gates: list[GateEvidence] = [] + promotions: list[PlanPromotionEvidence] = [] + reviews: list[RemoteReviewEvidence] = [] + gate_stages = {"checks_passed", "checks_failed", "fix_available"} + promotion_stages = { + "plan_promotion", + "plan_promoted", + "plan_promotion_conflict", + "plan_promotion_deferred", + } + + def text(value: Any) -> str | None: + return None if value is None else str(value) + + def argv(value: Any) -> list[str]: + return [str(part) for part in value] if isinstance(value, list) else [] + + for event in events: + data = event.data + raw_evidence = data.get("evidence") + evidence = raw_evidence if isinstance(raw_evidence, dict) else {} + if event.outcome in gate_stages: + command = argv(evidence.get("command")) + if not command: + command = argv(data.get("command")) + commands_raw = evidence.get("commands") + commands = ( + [argv(one) for one in commands_raw] if isinstance(commands_raw, list) else [] + ) + if not commands and command: + commands = [command] + applied = evidence.get("applied") + applied_fixes = ( + [dict(one) for one in applied if isinstance(one, dict)] + if isinstance(applied, list) + else [] + ) + gates.append( + GateEvidence( + event_id=event.id, + outcome=str(evidence.get("outcome") or event.outcome), + ts=event.ts, + detail=text(data.get("detail") or evidence.get("detail")), + command=command, + commands=commands, + fix=argv(evidence.get("fix") or evidence.get("fix_declared")), + applied=applied_fixes, + ) + ) + continue + if event.outcome in promotion_stages: + promotions.append( + PlanPromotionEvidence( + event_id=event.id, + ts=event.ts, + status=str(data.get("status") or event.outcome), + plan_branch=text(data.get("plan_branch")), + base_sha=text(data.get("base_sha")), + item_sha=text(data.get("item_sha")), + old_head_sha=text(data.get("old_head_sha")), + new_head_sha=text(data.get("new_head_sha")), + target_sha=text(data.get("target_sha")), + detail=text(data.get("detail")), + ) + ) + continue + if event.outcome == "remote_review_received": + reviews.append( + RemoteReviewEvidence( + event_id=event.id, + ts=event.ts, + source=str(data.get("source") or event.source), + remote_id=str(data.get("remote_id") or ""), + disposition=str(data.get("disposition") or ""), + status=str(data.get("status") or ""), + duplicate=bool(data.get("duplicate", False)), + correction_item_id=text(data.get("correction_item_id")), + detail=text(data.get("detail")), + ) + ) + continue + progress.append( + RunnerProgressEvidence( + event_id=event.id, + stage=str(event.outcome or event.kind), + ts=event.ts, + detail=text(data.get("detail")), + worker=event.worker, + attempt=_optional_int(data.get("attempt")), + evidence=dict(evidence), + ) + ) + return progress, gates, promotions, reviews + def _latest_by_item(self, project_id: str | None = None) -> dict[str, dict[str, Any]]: if self.audit is not None: rows = self.audit.latest_by_item(project_id=project_id) diff --git a/src/agent_harness/review_events.py b/src/agent_harness/review_events.py new file mode 100644 index 0000000..ea4dcce --- /dev/null +++ b/src/agent_harness/review_events.py @@ -0,0 +1,176 @@ +"""Generic remote-review event intake. + +This module is deliberately upstream-neutral. A remote adapter translates its +own webhook or polling record into :class:`RemoteReviewEvent`; the harness +only accepts the immutable identity and an explicit disposition. It never +asks a model to decide whether a human comment is actionable. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Literal + +from .work import WorkQueue + +ReviewDisposition = Literal["actionable", "ambiguous", "already_resolved"] + + +@dataclass(frozen=True) +class RemoteReviewEvent: + """A normalized, deduplicable remote review observation.""" + + source: str + remote_id: str + project_id: str + item_id: str + disposition: ReviewDisposition + summary: str + pr_url: str | None = None + received_at: float | None = None + + def __post_init__(self) -> None: + if not self.source.strip() or not self.remote_id.strip(): + raise ValueError("remote review events need a source and immutable remote_id") + if not self.project_id.strip() or not self.item_id.strip(): + raise ValueError("remote review events need a project_id and item_id") + if not self.summary.strip(): + raise ValueError("remote review events need a bounded summary") + + +@dataclass(frozen=True) +class ReviewIntakeResult: + """What intake did, including whether the source record was a replay.""" + + accepted: bool + duplicate: bool + status: str + correction_item_id: str | None = None + detail: str = "" + + +class ReviewEventProcessor: + """Persist and project normalized review events into one project queue.""" + + def __init__( + self, + queue: WorkQueue, + *, + on_event: Callable[[dict[str, Any]], None] | None = None, + ) -> None: + self.queue = queue + self.on_event = on_event + + def process(self, event: RemoteReviewEvent) -> ReviewIntakeResult: + original = self.queue.get(event.item_id, project_id=event.project_id) + if original is None: + raise ValueError( + f"remote review names no existing item {event.project_id!r}/{event.item_id!r}" + ) + correction_id = self._correction_id(event) + if event.disposition == "actionable": + status = "queued" + title = f"Address remote review for {event.item_id}" + brief = ( + f"Address this externally reported review feedback for item {event.item_id}.\n\n" + f"Feedback summary: {event.summary}" + ) + state = "pending" + last_error = None + elif event.disposition == "ambiguous": + # Do not hand an ambiguous human comment to an agent. It is a + # durable exception for a person to resolve; the eventual hold or + # correction action is explicit and cannot be inferred here. + status = "needs_human" + title = f"Resolve ambiguous remote review for {event.item_id}" + brief = event.summary + state = "blocked" + last_error = "ambiguous remote review requires a human decision" + else: + status = "already_resolved" + title = f"Remote review already resolved for {event.item_id}" + brief = event.summary + state = "done" + last_error = None + + accepted, previous = self.queue.accept_remote_review( + source=event.source, + remote_id=event.remote_id, + project_id=event.project_id, + item_id=event.item_id, + disposition=event.disposition, + status=status, + correction_item_id=correction_id if event.disposition != "already_resolved" else None, + title=title, + brief=brief, + depends_on=[event.item_id] if event.disposition != "already_resolved" else [], + state=state, + last_error=last_error, + received_at=event.received_at, + ) + if not accepted: + if previous is None: # pragma: no cover - INSERT OR IGNORE guarantees a row + raise RuntimeError("remote review duplicate had no durable row") + result = ReviewIntakeResult( + accepted=False, + duplicate=True, + status=str(previous["status"]), + correction_item_id=previous["correction_item_id"], + detail="remote review event was already recorded", + ) + else: + if event.disposition == "ambiguous" and correction_id is not None: + self.queue.hold_pending( + correction_id, + question="How should this remote review feedback be handled?", + reason=event.summary, + project_id=event.project_id, + ) + detail = ( + "queued correction work" + if status == "queued" + else "held for explicit human resolution" + if status == "needs_human" + else "recorded without creating work" + ) + result = ReviewIntakeResult( + accepted=True, + duplicate=False, + status=status, + correction_item_id=( + correction_id if event.disposition != "already_resolved" else None + ), + detail=detail, + ) + self._emit(event, result) + return result + + @staticmethod + def _correction_id(event: RemoteReviewEvent) -> str: + import hashlib + + digest = hashlib.sha256(f"{event.source}\0{event.remote_id}".encode()).hexdigest()[:24] + return f"review-{digest}" + + def _emit(self, event: RemoteReviewEvent, result: ReviewIntakeResult) -> None: + if self.on_event is None: + return + with contextlib.suppress(Exception): + self.on_event( + { + "ts": event.received_at, + "kind": "work", + "outcome": "remote_review_received", + "project_id": event.project_id, + "item_id": event.item_id, + "source": event.source, + "remote_id": event.remote_id, + "disposition": event.disposition, + "status": result.status, + "duplicate": result.duplicate, + "correction_item_id": result.correction_item_id, + "detail": result.detail, + } + ) diff --git a/src/agent_harness/review_sources.py b/src/agent_harness/review_sources.py new file mode 100644 index 0000000..2c5b4c0 --- /dev/null +++ b/src/agent_harness/review_sources.py @@ -0,0 +1,148 @@ +"""Metadata-resolved sources for normalized remote review events. + +The harness owns review state and correction semantics. A source adapter owns +how an external system authenticates, polls or receives a webhook, and how it +turns that system's record into :class:`RemoteReviewEvent`. Core only sees +the normalized batch and a durable cursor. +""" + +from __future__ import annotations + +import importlib +import logging +from collections.abc import Mapping +from dataclasses import dataclass +from importlib.metadata import entry_points +from typing import Any, Protocol + +from .review_events import RemoteReviewEvent, ReviewEventProcessor, ReviewIntakeResult +from .work import WorkQueue + +log = logging.getLogger(__name__) + +ENTRY_POINT_GROUP = "agent_harness.review_sources" +API_VERSION = 1 +CURSOR_PREFIX = "review-source-cursor:" + + +@dataclass(frozen=True) +class ReviewBatch: + """One source response and the cursor that follows it.""" + + events: tuple[RemoteReviewEvent, ...] = () + next_cursor: str | None = None + + def __post_init__(self) -> None: + if self.next_cursor is not None and not self.next_cursor.strip(): + raise ValueError("a review batch cursor must be non-empty or None") + + +class ReviewSource(Protocol): + """A configured, authenticated source of normalized review events.""" + + name: str + api_version: int + + def poll(self, cursor: str | None, /) -> ReviewBatch: ... + + +def _declared_targets() -> dict[str, str]: + """Read source metadata without importing any source adapter.""" + try: + return {point.name: point.value for point in entry_points(group=ENTRY_POINT_GROUP)} + except Exception: # noqa: BLE001 - broken metadata is a named readiness failure + log.warning("could not read %s entry points", ENTRY_POINT_GROUP, exc_info=True) + return {} + + +def names() -> list[str]: + """Return installed source names without loading their implementations.""" + return sorted(_declared_targets()) + + +def resolve(name: str, config: Mapping[str, Any] | None = None) -> ReviewSource: + """Load one named source and validate its contract before use. + + A declared target may be a source instance or a factory accepting the + supplied configuration. Configuration is intentionally opaque to core; + the selected adapter decides which values it needs, including credentials. + """ + target = _declared_targets().get(name) + if target is None: + raise LookupError( + f"unknown review source {name!r}; installed names: " + f"{', '.join(names()) or 'none'} ({ENTRY_POINT_GROUP})" + ) + module_name, _, attribute = target.partition(":") + try: + found: Any = getattr(importlib.import_module(module_name), attribute) + if callable(found) and not callable(getattr(found, "poll", None)): + found = found(dict(config or {})) + except Exception as exc: # noqa: BLE001 - named source must fail before polling + raise RuntimeError(f"review source {name!r} could not load from {target!r}: {exc}") from exc + if ( + not callable(getattr(found, "poll", None)) + or getattr(found, "api_version", None) != API_VERSION + ): + raise RuntimeError( + f"review source {name!r} does not implement review-source contract {API_VERSION}" + ) + return found # type: ignore[no-any-return] + + +@dataclass(frozen=True) +class ReviewPollResult: + """What one poll did, including the cursor now durable in the queue.""" + + fetched: int + accepted: int + duplicates: int + cursor: str | None + results: tuple[ReviewIntakeResult, ...] + + +class ReviewPoller: + """Poll one source with crash-safe, duplicate-safe cursor advancement.""" + + def __init__( + self, + queue: WorkQueue, + source: ReviewSource, + *, + processor: ReviewEventProcessor | None = None, + on_event: Any | None = None, + ) -> None: + if getattr(source, "api_version", None) != API_VERSION: + raise ValueError(f"review source must implement contract {API_VERSION}") + if not str(getattr(source, "name", "")).strip(): + raise ValueError("review source needs a non-empty name") + self.queue = queue + self.source = source + self.processor = processor or ReviewEventProcessor(queue, on_event=on_event) + self.cursor_key = f"{CURSOR_PREFIX}{source.name}" + + @property + def cursor(self) -> str | None: + value = self.queue.get_setting(self.cursor_key) + return str(value) if value else None + + def poll_once(self) -> ReviewPollResult: + """Process a complete batch before advancing its source cursor. + + If processing raises, the cursor is unchanged. Replaying already + processed rows is safe because the queue's immutable source identity + journal makes the processor idempotent. + """ + batch = self.source.poll(self.cursor) + if not isinstance(batch, ReviewBatch): + raise TypeError("review source poll() must return ReviewBatch") + results = tuple(self.processor.process(event) for event in batch.events) + if batch.next_cursor is not None: + self.queue.set_setting(self.cursor_key, batch.next_cursor) + return ReviewPollResult( + fetched=len(batch.events), + accepted=sum(result.accepted for result in results), + duplicates=sum(result.duplicate for result in results), + cursor=batch.next_cursor, + results=results, + ) diff --git a/src/agent_harness/role_runners.py b/src/agent_harness/role_runners.py index 9445f3f..0ab02b0 100644 --- a/src/agent_harness/role_runners.py +++ b/src/agent_harness/role_runners.py @@ -22,6 +22,7 @@ from typing import Any, Protocol from .budgets import Budget, Spend +from .execution_environment import ExecutionEnvironment from .guard import CommandGuard from .model_client import ModelClient @@ -66,6 +67,7 @@ class RoleRunRequest: writable: bool = True report: Report | None = None account: Account | None = None + environment: ExecutionEnvironment | None = None @dataclass(frozen=True) diff --git a/src/agent_harness/runtime.py b/src/agent_harness/runtime.py index 9f8d546..312fa8f 100644 --- a/src/agent_harness/runtime.py +++ b/src/agent_harness/runtime.py @@ -25,9 +25,12 @@ from pathlib import Path from typing import Any -from .executor import Checks +from .execution_environment import EnvironmentMount +from .executor import DEFAULT_CONTEXT_BUDGET, Checks, ContextPolicy, Executor from .guard import GUARD_KEY, CommandGuard from .model_client import Route +from .plan_integration import PlanCoordinator, PromotionConflict, PromotionError +from .plan_publication import PlanPublisher from .session_executor import AgentSpec, SessionExecutor from .work import Project, WorkQueue @@ -180,6 +183,219 @@ def _reviewer_for(project_id: str) -> Any: return build +def direct_executor_factory( + queue: WorkQueue, + *, + reviewer: Any, + routes_for: Callable[[str], Mapping[str, Route | Sequence[Route]]] | None = None, + github_for: Callable[[str], Any] | None = None, + on_event: Callable[[dict[str, Any]], None] | None = None, + push: bool = True, + role_runner: Any, + runner_step_limit: int = 80, + runner_command_timeout: int = 300, + context_budget: int | None = None, + context_fallback_budget: int | None = None, + environment_factory: Any, + environment_image: str, + environment_mounts: tuple[EnvironmentMount, ...] = (), + environment_variables: Mapping[str, str] | None = None, + environment_network: str = "bridge", + publication_remote: str = "origin", +) -> ExecutorFactory: + """Build the in-process role-runner executor used by ``serve``. + + A worker owns a disposable checkout for its whole lifetime. The executor + then creates a second, item-scoped worktree for the model loop and feeds + its candidate through the existing authoritative gates. Keeping the + worker checkout separate is essential: the gate path still commits an + item branch, and two workers must never checkout those branches in the + same directory. + + The selected environment backend is required here rather than silently + falling back to the host shell. The host compatibility backend remains a + fixture-only option for direct tests; a real ``serve`` fleet needs the + operating-system boundary selected by deployment metadata. + """ + import contextlib + import subprocess + import tempfile + + def git(repo: Path, *args: str, check: bool = True) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + check=False, + ) + if check and result.returncode != 0: + raise NotExecutable(f"git {' '.join(args)}: {result.stderr.strip()}") + return result.stdout + + def build(project_id: str) -> Any: + project = queue.get_project(project_id) + if project is None: + raise NotExecutable(f"no project {project_id!r}") + if not project.work_dir: + raise NotExecutable( + f"project {project_id!r} has no work_dir, so there is nothing to " + "make a worker checkout from" + ) + ready, detail = environment_factory.check() + if not ready: + raise NotExecutable(f"execution environment is not ready: {detail}") + + source = Path(project.work_dir).resolve() + if not (source / ".git").exists(): + raise NotExecutable(f"project {project_id!r} work_dir is not a git repository") + worker_tree = Path( + tempfile.mkdtemp(prefix=f".harness-worker-{project_id}-", dir=source.parent) + ) + try: + git(source, "worktree", "add", "--detach", str(worker_tree), project.base_branch) + except Exception: + with contextlib.suppress(OSError): + worker_tree.rmdir() + raise + + client = reviewer.routed_by(lambda: routes_for(project_id)) if routes_for else reviewer + guard = CommandGuard.from_settings(queue.get_setting(GUARD_KEY)) + checks = _checks_for(project, guard) + coordinator: PlanCoordinator | None = None + publisher: PlanPublisher | None = None + if project.plan_path and project.plan_branch: + if push: + # Publishing a plan never means publishing its items. When a + # deployment asks for a remote, it gets exactly one plan + # branch and one pull request (P7/P8); the executor keeps its + # item branches local and is given no client of its own. + if github_for is None or not project.repo: + raise NotExecutable( + "publishing a plan needs a configured GitHub client and a " + "project repo; set push=False to integrate locally only" + ) + publisher = PlanPublisher( + queue, + project_id, + source, + github_for(project.repo), + remote=publication_remote, + on_event=on_event, + ) + coordinator = PlanCoordinator( + queue, + project_id, + source, + checks=checks, + on_event=on_event, + ) + coordinator.ensure( + target_branch=project.base_branch, + branch=project.plan_branch, + plan_path=project.plan_path, + ) + + def plan_base_for(record: Any) -> tuple[str, str | None]: + assert coordinator is not None + return coordinator.base_for(record) + + def plan_promote(record: Any, item_branch: str, base: str) -> tuple[str, str]: + assert coordinator is not None + try: + promotion = coordinator.promote(record, item_branch=item_branch, base=base) + except PromotionConflict as exc: + return "conflict", str(exc) + except PromotionError as exc: + return "deferred", str(exc) + _publish_if_ready(promotion) + return promotion.status, promotion.detail + + def _publish_if_ready(promotion: Any) -> None: + """Offer the finished plan to a person, without risking the item. + + The item is already promoted and gated locally when this runs. A + remote that is down, slow or refusing must not undo that or fail + the item, so a publication failure is reported as an event and + the promotion stands. + """ + if publisher is None or coordinator is None or promotion.status != "promoted": + return + try: + publisher.publish_if_ready( + coordinator.state(), + title=f"{project.name or project_id}: {project.plan_branch}", + summary=( + f"Promoted `{promotion.item_id}` to the plan branch " + f"({(promotion.new_head_sha or '')[:12]})." + ), + excluding=promotion.item_id, + ) + except Exception as exc: # noqa: BLE001 - a remote cannot fail local work + if on_event is not None: + with contextlib.suppress(Exception): + on_event( + { + "kind": "work", + "outcome": "plan_publication_failed", + "project_id": project_id, + "item_id": promotion.item_id, + "detail": str(exc), + } + ) + + executor = Executor( + queue, + client, + worker_tree, + checks=checks, + github=( + github_for(project.repo) + if github_for and push and project.repo and coordinator is None + else None + ), + base_branch=project.base_branch, + on_event=on_event, + # An item branch is never published when a plan owns integration: + # the plan branch is the only thing that reaches a remote. + push=push and coordinator is None, + context_policy=ContextPolicy( + budget=context_budget or DEFAULT_CONTEXT_BUDGET, + fallback_budget=context_fallback_budget, + ), + project_id=project_id, + role_runner=role_runner, + runner_step_limit=runner_step_limit, + runner_command_timeout=runner_command_timeout, + environment_factory=environment_factory, + environment_image=environment_image, + environment_mounts=environment_mounts, + environment_variables=environment_variables, + environment_network=environment_network, + durability=project.durability or None, + plan_base_for=plan_base_for if coordinator else None, + plan_promote=plan_promote if coordinator else None, + ) + + class ManagedExecutor: + owner = executor.owner + + def serve(self, **kwargs: Any) -> Any: + try: + return executor.serve(**kwargs) + finally: + git(source, "worktree", "remove", "--force", str(worker_tree), check=False) + git(source, "worktree", "prune", check=False) + with contextlib.suppress(OSError): + worker_tree.rmdir() + + def __getattr__(self, name: str) -> Any: + return getattr(executor, name) + + return ManagedExecutor() + + return build + + def _checks_for(project: Project, guard: CommandGuard | None = None) -> Checks: """The project's own verification commands, split without a shell. diff --git a/src/agent_harness/schemas.py b/src/agent_harness/schemas.py index a2e7f5f..97cd49a 100644 --- a/src/agent_harness/schemas.py +++ b/src/agent_harness/schemas.py @@ -587,6 +587,12 @@ class ProjectSpec(BaseModel): ), ) plan_path: str | None = None + plan_branch: str | None = Field( + None, + description=( + "Local integration branch receiving gated item promotions. No remote is contacted." + ), + ) roles: dict[str, RoleRoute] | None = Field( None, description="Role overrides for this project. Null uses the global map." ) @@ -761,10 +767,10 @@ class ExecutionReadiness(BaseModel): is claimed, and no state is mutated. """ - mode: Literal["supervised", "monitoring-only"] = Field( - description="`supervised` means a worker pool is attached and starting a project " - "can create workers. `monitoring-only` is a legitimate deployment — a dashboard " - "over someone else's harness — and starting is expected to refuse." + mode: Literal["local", "supervised", "monitoring-only"] = Field( + description="`local` means the in-process executor and its item environment are " + "attached; `supervised` means a session-host worker pool is attached; " + "`monitoring-only` has no executor and starting is expected to refuse." ) ready_to_start: bool = Field( description="Whether at least one project could be started right now. False on a " @@ -775,6 +781,11 @@ class ExecutionReadiness(BaseModel): description="The terminal-session host the agents run in. Probed with a read, so " "it proves reachability AND that the token is accepted, without creating a session." ) + execution_environment: ReadinessProbe = Field( + description="The selected item execution backend. In local mode this is the " + "capability that makes item commands runnable; it is never inferred from a " + "session-host setting." + ) reviewer: ReadinessProbe = Field( description="Is a reviewer role routed? Without one every review fails closed, so " "every item fails after the implementation has been paid for." @@ -1507,6 +1518,51 @@ class EventPage(BaseModel): cursor: int = Field(description="Pass as `since_id` next time. Unchanged when empty.") +class ReviewEventRequest(BaseModel): + """A normalized review event supplied by an optional remote adapter.""" + + source: str = Field(description="Adapter identity, not a vendor-specific protocol name.") + remote_id: str = Field(description="Immutable identity of the remote review record.") + project_id: str = Field(description="Harness project containing the reviewed item.") + item_id: str = Field(description="Harness item the remote review concerns.") + disposition: Literal["actionable", "ambiguous", "already_resolved"] = Field( + description="Explicit adapter classification. The harness never infers this from text." + ) + summary: str = Field( + min_length=1, + max_length=4000, + description="Bounded, adapter-normalized feedback summary retained for the correction.", + ) + pr_url: str | None = Field(None, description="Remote review URL, when one exists.") + received_at: float | None = Field(None, description="Remote event time, when supplied.") + + +class ReviewEventResult(BaseModel): + """The idempotent result of accepting one remote review event.""" + + accepted: bool = Field(description="Whether this request inserted a new remote event.") + duplicate: bool = Field(description="True when source and remote_id had already been seen.") + status: str = Field(description="queued, needs_human or already_resolved.") + correction_item_id: str | None = Field( + None, description="Stable generated correction item, when one was created." + ) + detail: str = Field(description="Bounded explanation of the intake result.") + + +class ReviewPollResultModel(BaseModel): + """The result of one authenticated, adapter-owned review-source poll.""" + + fetched: int = Field(description="Normalized review records returned by the source.") + accepted: int = Field(description="Records newly accepted by the harness.") + duplicates: int = Field(description="Records replayed from the source identity journal.") + cursor: str | None = Field( + description="The source cursor saved after the complete batch was accepted." + ) + results: list[ReviewEventResult] = Field( + description="Per-record idempotent intake results, in source order." + ) + + class ProcessMetrics(BaseModel): """A session-independent observation of the serving process.""" @@ -1684,6 +1740,81 @@ class AttemptStageEvidence(BaseModel): ) +class RunnerProgressEvidence(BaseModel): + """A runner event projected into stable item-scoped progress fields.""" + + event_id: int = Field(description="Monotonic event id for this progress observation.") + stage: str = Field(description="Executor stage or runner progress token.") + ts: float = Field(description="Unix timestamp when the progress was recorded.") + detail: str | None = Field(None, description="Bounded human-readable stage detail.") + worker: str | None = Field(None, description="Worker identity, when one was attached.") + attempt: int | None = Field(None, description="Attempt number, when the runner supplied it.") + evidence: dict[str, Any] = Field( + default_factory=dict, + description="Structured runner evidence retained with the event.", + ) + + +class GateEvidence(BaseModel): + """The answer and argv evidence for an authoritative cheap gate.""" + + event_id: int = Field(description="Monotonic event id for this gate answer.") + outcome: str = Field(description="Gate answer token, such as `pass` or `fail`.") + ts: float = Field(description="Unix timestamp when the gate answered.") + detail: str | None = Field(None, description="Bounded gate detail or failure output.") + command: list[str] = Field( + default_factory=list, + description="Exact argv that produced this gate answer, when available.", + ) + commands: list[list[str]] = Field( + default_factory=list, + description="All configured gate argv values for a successful gate run.", + ) + fix: list[str] = Field( + default_factory=list, + description="Declared fix argv, when the gate exposed one.", + ) + applied: list[dict[str, Any]] = Field( + default_factory=list, + description="Mechanical fixes actually applied before this answer.", + ) + authoritative: bool = Field( + True, + description="This answer came from the configured gate, not a model or projection.", + ) + + +class PlanPromotionEvidence(BaseModel): + """Durable state from local, gated plan-branch promotion.""" + + event_id: int = Field(description="Monotonic event id for this promotion observation.") + ts: float = Field(description="Unix timestamp when promotion state was recorded.") + status: str = Field(description="Promotion state token, such as promoted or conflict.") + plan_branch: str | None = Field(None, description="Integration branch, when configured.") + base_sha: str | None = Field(None, description="Immutable item base commit, when known.") + item_sha: str | None = Field(None, description="Candidate item commit, when known.") + old_head_sha: str | None = Field(None, description="Plan branch head before promotion.") + new_head_sha: str | None = Field(None, description="Plan branch head after promotion.") + target_sha: str | None = Field(None, description="Promotion target commit, when known.") + detail: str | None = Field(None, description="Bounded promotion detail.") + + +class RemoteReviewEvidence(BaseModel): + """The normalized and idempotent remote-review intake result.""" + + event_id: int = Field(description="Monotonic event id for this review observation.") + ts: float = Field(description="Unix timestamp when intake was recorded.") + source: str = Field(description="Normalized adapter identity.") + remote_id: str = Field(description="Immutable remote review identity.") + disposition: str = Field(description="Explicit adapter disposition.") + status: str = Field(description="Intake status, such as queued or needs_human.") + duplicate: bool = Field(description="Whether this was a replay of an accepted event.") + correction_item_id: str | None = Field( + None, description="Generated correction item, when intake created one." + ) + detail: str | None = Field(None, description="Bounded intake detail.") + + class WorkEvidence(BaseModel): """Item-scoped history without fabricated gaps.""" @@ -1702,6 +1833,22 @@ class WorkEvidence(BaseModel): default_factory=list, description="Every retained question for the item, including closed questions.", ) + runner_progress: list[RunnerProgressEvidence] = Field( + default_factory=list, + description="Typed runner progress, oldest first; derived from retained events.", + ) + gates: list[GateEvidence] = Field( + default_factory=list, + description="Authoritative gate answers, oldest first; derived from retained events.", + ) + promotions: list[PlanPromotionEvidence] = Field( + default_factory=list, + description="Plan-branch promotion state observations, oldest first.", + ) + remote_reviews: list[RemoteReviewEvidence] = Field( + default_factory=list, + description="Normalized remote-review intake observations, oldest first.", + ) # ------------------------------------------------------------------ summary diff --git a/src/agent_harness/work.py b/src/agent_harness/work.py index 5386cdf..53a3567 100644 --- a/src/agent_harness/work.py +++ b/src/agent_harness/work.py @@ -19,6 +19,7 @@ from __future__ import annotations +import contextlib import json import logging import os @@ -28,15 +29,17 @@ import time from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field -from typing import Any +from typing import Any, cast from .attempts import DEFAULT_MODE as DEFAULT_DURABILITY from .attempts import AttemptLog from .graph import ( + LOCAL_WORK, WORK_DECLARATION, DependencyGraph, DependencySpec, Readiness, + ReadinessReason, Resolver, parse_dependencies, ) @@ -138,6 +141,7 @@ -- question from consuming a worker indefinitely. max_hold_seconds REAL NOT NULL DEFAULT 21600, plan_path TEXT, + plan_branch TEXT, roles TEXT, max_workers INTEGER NOT NULL DEFAULT 1, max_attempts INTEGER NOT NULL DEFAULT 5, @@ -203,6 +207,74 @@ ); CREATE INDEX IF NOT EXISTS work_state ON work (project_id, state, lease_until); +-- The mutable current plan projection. The event stream records transitions; +-- this table answers which exact local ref is current after a restart. +CREATE TABLE IF NOT EXISTS plans ( + project_id TEXT PRIMARY KEY, + branch TEXT NOT NULL, + target_branch TEXT NOT NULL, + target_sha TEXT NOT NULL, + head_sha TEXT NOT NULL, + plan_digest TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL DEFAULT 0, + updated_at REAL NOT NULL DEFAULT 0 +); + +-- A short-lived, per-project lease for the serialized plan integration path. +-- It is separate from work claims: a promotion lease protects the expensive +-- read/gate/publish sequence across serving processes, while the append-only +-- promotion journal remains the recovery record if a process dies. +CREATE TABLE IF NOT EXISTS plan_promotion_leases ( + project_id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + lease_until REAL NOT NULL, + updated_at REAL NOT NULL DEFAULT 0 +); + +-- Append-only promotion history. A conflict or failed integration gate is a +-- fact worth retaining, even though it does not advance the plan head. +CREATE TABLE IF NOT EXISTS plan_promotions ( + promotion_id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + item_id TEXT NOT NULL, + item_branch TEXT NOT NULL, + base_sha TEXT NOT NULL, + item_sha TEXT, + old_head_sha TEXT NOT NULL, + new_head_sha TEXT, + status TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL DEFAULT 0 +); + +-- Durable journal for rebuilding a plan branch after its target branch moves. +CREATE TABLE IF NOT EXISTS plan_refreshes ( + refresh_id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + old_target_sha TEXT NOT NULL, + new_target_sha TEXT NOT NULL, + old_head_sha TEXT NOT NULL, + new_head_sha TEXT NOT NULL, + status TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + created_at REAL NOT NULL DEFAULT 0 +); + +-- Immutable identity journal for remote review webhooks or polling records. +-- The source adapter owns interpretation; this table only prevents a replay +-- from creating a second correction item. +CREATE TABLE IF NOT EXISTS remote_review_events ( + source TEXT NOT NULL, + remote_id TEXT NOT NULL, + project_id TEXT NOT NULL, + item_id TEXT NOT NULL, + disposition TEXT NOT NULL, + status TEXT NOT NULL, + correction_item_id TEXT, + received_at REAL NOT NULL DEFAULT 0, + PRIMARY KEY (source, remote_id) +); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -421,6 +493,7 @@ class Project: #: ever, and "unlimited" is not a safe reading of "nobody said". max_hold_seconds: float = DEFAULT_MAX_HOLD_SECONDS plan_path: str | None = None + plan_branch: str | None = None roles: dict[str, Any] | None = None max_workers: int = 1 max_attempts: int = DEFAULT_MAX_ATTEMPTS @@ -583,6 +656,7 @@ def _migrate(self) -> None: base_branch TEXT NOT NULL DEFAULT 'main', checks TEXT NOT NULL DEFAULT '[]', plan_path TEXT, + plan_branch TEXT, roles TEXT, max_workers INTEGER NOT NULL DEFAULT 1, created_at REAL NOT NULL DEFAULT 0, @@ -622,6 +696,7 @@ def _migrate(self) -> None: "max_item_seconds": "REAL NOT NULL DEFAULT 0", "max_item_spend_usd": "REAL NOT NULL DEFAULT 0", "max_hold_seconds": "REAL NOT NULL DEFAULT 21600", + "plan_branch": "TEXT", }, # Stage G. Additive, so a rollback to an older build still reads every # column it knows and simply ignores this one. The migration plan is @@ -651,6 +726,9 @@ def _migrate(self) -> None: "abandoned_sessions": { "project_id": "TEXT", }, + "plan_promotions": { + "item_sha": "TEXT", + }, } def _add_missing_columns(self, conn: sqlite3.Connection) -> None: @@ -755,9 +833,9 @@ def add_project(self, project: Project) -> None: conn.execute( "INSERT INTO projects (project_id, name, repo, work_dir, base_branch, " "checks, fixes, apply_fixes, durability, max_item_seconds, max_item_spend_usd, " - "max_hold_seconds, plan_path, roles, max_workers, max_attempts, " + "max_hold_seconds, plan_path, plan_branch, roles, max_workers, max_attempts, " "min_free_disk_gb, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT(project_id) DO UPDATE SET " "name=excluded.name, repo=excluded.repo, work_dir=excluded.work_dir, " "base_branch=excluded.base_branch, checks=excluded.checks, " @@ -767,6 +845,7 @@ def add_project(self, project: Project) -> None: "max_item_spend_usd=excluded.max_item_spend_usd, " "max_hold_seconds=excluded.max_hold_seconds, " "plan_path=excluded.plan_path, roles=excluded.roles, " + "plan_branch=excluded.plan_branch, " "max_workers=excluded.max_workers, max_attempts=excluded.max_attempts, " "min_free_disk_gb=excluded.min_free_disk_gb, " "updated_at=excluded.updated_at", @@ -784,6 +863,7 @@ def add_project(self, project: Project) -> None: project.max_item_spend_usd, project.max_hold_seconds, project.plan_path, + project.plan_branch, json.dumps(project.roles) if project.roles else None, project.max_workers, project.max_attempts, @@ -813,7 +893,8 @@ def update_project(self, project: Project, *, expected_updated_at: float) -> boo changed = conn.execute( "UPDATE projects SET name = ?, repo = ?, work_dir = ?, base_branch = ?, " "checks = ?, fixes = ?, durability = ?, max_item_seconds = ?, " - "max_item_spend_usd = ?, max_hold_seconds = ?, plan_path = ?, roles = ?, " + "max_item_spend_usd = ?, max_hold_seconds = ?, plan_path = ?, " + "plan_branch = ?, roles = ?, " "max_workers = ?, max_attempts = ?, min_free_disk_gb = ?, updated_at = ? " "WHERE project_id = ? AND updated_at = ?", ( @@ -828,6 +909,7 @@ def update_project(self, project: Project, *, expected_updated_at: float) -> boo project.max_item_spend_usd, project.max_hold_seconds, project.plan_path, + project.plan_branch, json.dumps(project.roles) if project.roles else None, project.max_workers, project.max_attempts, @@ -860,6 +942,483 @@ def get_project(self, project_id: str) -> Project | None: finally: conn.close() + # ------------------------------------------------------ plan integration + + def plan(self, project_id: str) -> sqlite3.Row | None: + conn = self._connect() + try: + return cast( + sqlite3.Row | None, + conn.execute( + "SELECT project_id, branch, target_branch, target_sha, head_sha, plan_digest " + "FROM plans WHERE project_id = ?", + (project_id,), + ).fetchone(), + ) + finally: + conn.close() + + def create_plan( + self, + project_id: str, + *, + branch: str, + target_branch: str, + target_sha: str, + head_sha: str, + plan_digest: str, + ) -> None: + conn = self._connect() + try: + conn.execute( + "INSERT INTO plans (project_id, branch, target_branch, target_sha, head_sha, " + "plan_digest, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + project_id, + branch, + target_branch, + target_sha, + head_sha, + plan_digest, + self.now(), + self.now(), + ), + ) + finally: + conn.close() + + def acquire_plan_promotion_lease( + self, project_id: str, owner: str, lease_seconds: float + ) -> tuple[bool, float]: + """Acquire a cross-process lease for serialized plan integration. + + The compare-and-replace is one immediate SQLite transaction. A live + owner cannot be displaced; an expired owner can be taken over after a + crash without an operator clearing state. + """ + if lease_seconds <= 0: + raise ValueError("plan promotion lease must be positive") + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + now = self.now() + row = conn.execute( + "SELECT owner, lease_until FROM plan_promotion_leases WHERE project_id = ?", + (project_id,), + ).fetchone() + if row is not None and row["owner"] != owner and float(row["lease_until"]) > now: + conn.rollback() + return False, float(row["lease_until"]) + lease_until = now + lease_seconds + conn.execute( + "INSERT INTO plan_promotion_leases (project_id, owner, lease_until, updated_at) " + "VALUES (?, ?, ?, ?) ON CONFLICT(project_id) DO UPDATE SET owner = excluded.owner, " + "lease_until = excluded.lease_until, updated_at = excluded.updated_at", + (project_id, owner, lease_until, now), + ) + conn.commit() + return True, lease_until + except Exception: + with contextlib.suppress(sqlite3.Error): + conn.rollback() + raise + finally: + conn.close() + + def renew_plan_promotion_lease(self, project_id: str, owner: str, lease_seconds: float) -> bool: + """Extend a still-owned promotion lease; false means it was lost.""" + if lease_seconds <= 0: + raise ValueError("plan promotion lease must be positive") + conn = self._connect() + try: + now = self.now() + changed = conn.execute( + "UPDATE plan_promotion_leases SET lease_until = ?, updated_at = ? " + "WHERE project_id = ? AND owner = ? AND lease_until > ?", + (now + lease_seconds, now, project_id, owner, now), + ).rowcount + return changed == 1 + finally: + conn.close() + + def release_plan_promotion_lease(self, project_id: str, owner: str) -> bool: + """Release only this coordinator's promotion lease.""" + conn = self._connect() + try: + changed = conn.execute( + "DELETE FROM plan_promotion_leases WHERE project_id = ? AND owner = ?", + (project_id, owner), + ).rowcount + return changed == 1 + finally: + conn.close() + + def advance_plan(self, project_id: str, head_sha: str) -> None: + conn = self._connect() + try: + changed = conn.execute( + "UPDATE plans SET head_sha = ?, updated_at = ? WHERE project_id = ?", + (head_sha, self.now(), project_id), + ).rowcount + if changed != 1: + raise KeyError(f"no plan for project {project_id!r}") + finally: + conn.close() + + def complete_promotion( + self, + project_id: str, + item_id: str, + item_branch: str, + base_sha: str, + old_head_sha: str, + new_head_sha: str, + promotion_id: int | None = None, + detail: str = "", + ) -> None: + """Commit the plan head and its successful promotion fact together. + + The git ref is advanced before this method is called, but the two + durable SQLite projections must still be one fact. Keeping the + ``plans`` update and the successful ``plan_promotions`` row in + separate transactions creates a restart window in which a dependent + item can see the new head but no promoted prerequisite. + """ + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + changed = conn.execute( + "UPDATE plans SET head_sha = ?, updated_at = ? " + "WHERE project_id = ? AND head_sha = ?", + (new_head_sha, self.now(), project_id, old_head_sha), + ).rowcount + if changed != 1: + conn.execute("ROLLBACK") + raise KeyError( + f"plan {project_id!r} no longer has head {old_head_sha!r}; " + "promotion must be replayed from its current head" + ) + if promotion_id is None: + changed = conn.execute( + "UPDATE plan_promotions SET status = 'promoted', detail = ? " + "WHERE project_id = ? AND item_id = ? AND item_branch = ? " + "AND base_sha = ? AND old_head_sha = ? AND new_head_sha = ? " + "AND status = 'applying'", + ( + detail, + project_id, + item_id, + item_branch, + base_sha, + old_head_sha, + new_head_sha, + ), + ).rowcount + else: + changed = conn.execute( + "UPDATE plan_promotions SET status = 'promoted', detail = ? " + "WHERE promotion_id = ? AND project_id = ? AND status = 'applying'", + (detail, promotion_id, project_id), + ).rowcount + if changed != 1: + conn.execute("ROLLBACK") + raise KeyError("no matching in-progress promotion to complete") + conn.execute("COMMIT") + except Exception: + with contextlib.suppress(sqlite3.Error): + conn.execute("ROLLBACK") + raise + + def begin_promotion( + self, + project_id: str, + item_id: str, + item_branch: str, + base_sha: str, + old_head_sha: str, + new_head_sha: str, + item_sha: str | None = None, + ) -> int: + """Durably record the Git ref update that is about to happen.""" + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT head_sha FROM plans WHERE project_id = ?", (project_id,) + ).fetchone() + if row is None or row["head_sha"] != old_head_sha: + conn.execute("ROLLBACK") + raise KeyError("plan head changed before promotion began") + cursor = conn.execute( + "INSERT INTO plan_promotions (project_id, item_id, item_branch, base_sha, " + "item_sha, old_head_sha, new_head_sha, status, detail, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, 'applying', '', ?)", + ( + project_id, + item_id, + item_branch, + base_sha, + item_sha, + old_head_sha, + new_head_sha, + self.now(), + ), + ) + conn.execute("COMMIT") + if cursor.lastrowid is None: + raise RuntimeError("SQLite did not return the promotion id") + return cursor.lastrowid + except Exception: + with contextlib.suppress(sqlite3.Error): + conn.execute("ROLLBACK") + raise + + def in_progress_promotions(self, project_id: str) -> list[sqlite3.Row]: + conn = self._connect() + try: + return cast( + list[sqlite3.Row], + conn.execute( + "SELECT * FROM plan_promotions WHERE project_id = ? " + "AND status = 'applying' ORDER BY promotion_id", + (project_id,), + ).fetchall(), + ) + finally: + conn.close() + + def promotion(self, promotion_id: int) -> sqlite3.Row | None: + """Return one durable promotion fact by its immutable identity.""" + conn = self._connect() + try: + return cast( + sqlite3.Row | None, + conn.execute( + "SELECT * FROM plan_promotions WHERE promotion_id = ?", + (promotion_id,), + ).fetchone(), + ) + finally: + conn.close() + + def finish_promotion(self, promotion_id: int, status: str, detail: str) -> None: + """Close an interrupted promotion without claiming success.""" + conn = self._connect() + try: + changed = conn.execute( + "UPDATE plan_promotions SET status = ?, detail = ? " + "WHERE promotion_id = ? AND status = 'applying'", + (status, detail, promotion_id), + ).rowcount + if changed != 1: + raise KeyError(f"no in-progress promotion {promotion_id}") + finally: + conn.close() + + def record_promotion( + self, + project_id: str, + item_id: str, + item_branch: str, + base_sha: str, + old_head_sha: str, + new_head_sha: str | None, + status: str, + detail: str = "", + item_sha: str | None = None, + ) -> int: + conn = self._connect() + try: + cursor = conn.execute( + "INSERT INTO plan_promotions (project_id, item_id, item_branch, base_sha, " + "item_sha, old_head_sha, new_head_sha, status, detail, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + project_id, + item_id, + item_branch, + base_sha, + item_sha, + old_head_sha, + new_head_sha, + status, + detail, + self.now(), + ), + ) + if cursor.lastrowid is None: + raise RuntimeError("SQLite did not return the promotion id") + return cursor.lastrowid + finally: + conn.close() + + def successful_promotions(self, project_id: str) -> list[sqlite3.Row]: + conn = self._connect() + try: + return cast( + list[sqlite3.Row], + conn.execute( + "SELECT * FROM plan_promotions WHERE project_id = ? " + "AND status = 'promoted' ORDER BY promotion_id", + (project_id,), + ).fetchall(), + ) + finally: + conn.close() + + def begin_refresh( + self, + project_id: str, + old_target_sha: str, + new_target_sha: str, + old_head_sha: str, + new_head_sha: str, + ) -> int: + """Journal a plan rebuild before changing its Git ref.""" + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT target_sha, head_sha FROM plans WHERE project_id = ?", + (project_id,), + ).fetchone() + if ( + row is None + or row["target_sha"] != old_target_sha + or row["head_sha"] != old_head_sha + ): + conn.execute("ROLLBACK") + raise KeyError("plan changed before refresh began") + cursor = conn.execute( + "INSERT INTO plan_refreshes (project_id, old_target_sha, new_target_sha, " + "old_head_sha, new_head_sha, status, detail, created_at) " + "VALUES (?, ?, ?, ?, ?, 'applying', '', ?)", + ( + project_id, + old_target_sha, + new_target_sha, + old_head_sha, + new_head_sha, + self.now(), + ), + ) + conn.execute("COMMIT") + if cursor.lastrowid is None: + raise RuntimeError("SQLite did not return the refresh id") + return cursor.lastrowid + except Exception: + with contextlib.suppress(sqlite3.Error): + conn.execute("ROLLBACK") + raise + + def set_refresh_head(self, refresh_id: int, new_head_sha: str) -> None: + """Durably set the ref value before the Git ref update is attempted.""" + conn = self._connect() + try: + changed = conn.execute( + "UPDATE plan_refreshes SET new_head_sha = ? " + "WHERE refresh_id = ? AND status = 'applying'", + (new_head_sha, refresh_id), + ).rowcount + if changed != 1: + raise KeyError(f"no in-progress refresh {refresh_id}") + finally: + conn.close() + + def complete_refresh( + self, + refresh_id: int, + project_id: str, + old_target_sha: str, + new_target_sha: str, + old_head_sha: str, + new_head_sha: str, + detail: str = "", + ) -> None: + """Commit a rebuilt Git ref and plan projection together.""" + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + changed = conn.execute( + "UPDATE plans SET target_sha = ?, head_sha = ?, updated_at = ? " + "WHERE project_id = ? AND target_sha = ? AND head_sha = ?", + ( + new_target_sha, + new_head_sha, + self.now(), + project_id, + old_target_sha, + old_head_sha, + ), + ).rowcount + if changed != 1: + conn.execute("ROLLBACK") + raise KeyError("plan changed before refresh completed") + changed = conn.execute( + "UPDATE plan_refreshes SET status = 'refreshed', detail = ? " + "WHERE refresh_id = ? AND project_id = ? AND status = 'applying' " + "AND old_target_sha = ? AND new_target_sha = ? AND old_head_sha = ? " + "AND new_head_sha = ?", + ( + detail, + refresh_id, + project_id, + old_target_sha, + new_target_sha, + old_head_sha, + new_head_sha, + ), + ).rowcount + if changed != 1: + conn.execute("ROLLBACK") + raise KeyError("no matching in-progress refresh to complete") + conn.execute("COMMIT") + except Exception: + with contextlib.suppress(sqlite3.Error): + conn.execute("ROLLBACK") + raise + + def in_progress_refreshes(self, project_id: str) -> list[sqlite3.Row]: + conn = self._connect() + try: + return cast( + list[sqlite3.Row], + conn.execute( + "SELECT * FROM plan_refreshes WHERE project_id = ? " + "AND status = 'applying' ORDER BY refresh_id", + (project_id,), + ).fetchall(), + ) + finally: + conn.close() + + def finish_refresh(self, refresh_id: int, status: str, detail: str) -> None: + conn = self._connect() + try: + changed = conn.execute( + "UPDATE plan_refreshes SET status = ?, detail = ? " + "WHERE refresh_id = ? AND status = 'applying'", + (status, detail, refresh_id), + ).rowcount + if changed != 1: + raise KeyError(f"no in-progress refresh {refresh_id}") + finally: + conn.close() + + def latest_promotion(self, project_id: str, item_id: str) -> sqlite3.Row | None: + conn = self._connect() + try: + return cast( + sqlite3.Row | None, + conn.execute( + "SELECT * FROM plan_promotions WHERE project_id = ? AND item_id = ? " + "AND status = 'promoted' ORDER BY promotion_id DESC LIMIT 1", + (project_id, item_id), + ).fetchone(), + ) + finally: + conn.close() + # ------------------------------------------------------------ loading def add( @@ -958,6 +1517,96 @@ def add( conn.close() return added + def accept_remote_review( + self, + *, + source: str, + remote_id: str, + project_id: str, + item_id: str, + disposition: str, + status: str, + correction_item_id: str | None, + title: str, + brief: str, + depends_on: list[str], + state: str, + last_error: str | None, + received_at: float | None = None, + ) -> tuple[bool, sqlite3.Row | None]: + """Record one remote review and its correction projection atomically.""" + if disposition not in {"actionable", "ambiguous", "already_resolved"}: + raise ValueError(f"unknown remote review disposition {disposition!r}") + conn = self._connect() + try: + conn.execute("BEGIN IMMEDIATE") + cursor = conn.execute( + "INSERT OR IGNORE INTO remote_review_events " + "(source, remote_id, project_id, item_id, disposition, status, " + "correction_item_id, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + source, + remote_id, + project_id, + item_id, + disposition, + status, + correction_item_id, + received_at if received_at is not None else self.now(), + ), + ) + if cursor.rowcount == 0: + row = conn.execute( + "SELECT status, correction_item_id FROM remote_review_events " + "WHERE source = ? AND remote_id = ?", + (source, remote_id), + ).fetchone() + conn.execute("COMMIT") + return False, row + conn.execute( + "INSERT OR IGNORE INTO projects (project_id, name, created_at, updated_at) " + "VALUES (?, ?, ?, ?)", + (project_id, project_id, self.now(), self.now()), + ) + conn.execute( + "INSERT OR IGNORE INTO control (project_id, state, changed_at) VALUES (?, ?, ?)", + (project_id, STOPPED, self.now()), + ) + if correction_item_id is not None: + conn.execute( + "INSERT INTO work (project_id, item_id, title, brief, depends_on, state, " + "last_error, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ( + project_id, + correction_item_id, + title, + brief, + json.dumps(depends_on), + state, + last_error, + self.now(), + ), + ) + self.graph.set_edges( + project_id, + correction_item_id, + WorkRecord( + correction_item_id, + title, + brief=brief, + depends_on=depends_on, + ).dependency_specs(), + conn=conn, + ) + conn.execute("COMMIT") + return True, None + except Exception: + with contextlib.suppress(sqlite3.Error): + conn.execute("ROLLBACK") + raise + finally: + conn.close() + # ------------------------------------------------------------ control def control(self, project_id: str = DEFAULT_PROJECT) -> tuple[str, str | None]: @@ -1204,6 +1853,11 @@ def claim( ) if not admission.ready: continue + # A plan's graph can say a prerequisite is done before its + # promotion fact is durable. Do not release a dependent + # into a checkout that cannot contain that prerequisite. + if self._plan_promotion_reasons(conn, record): + continue # D11, resolved 2026-08-04: **a resumed attempt continues # the existing one.** A crash is not a failure of the work, # so re-claiming an item that left a durable position keeps @@ -1273,7 +1927,94 @@ def readiness(self, item_id: str, *, project_id: str = DEFAULT_PROJECT) -> Readi is two answers, and the one that disagreed would be the one that let ineligible work reach a durable gate. """ - return self.graph.readiness(project_id, item_id) + state = self.graph.readiness(project_id, item_id) + if not state.ready: + return state + conn = self._connect() + try: + row = conn.execute( + "SELECT * FROM work WHERE project_id = ? AND item_id = ?", + (project_id, item_id), + ).fetchone() + if row is None: + return state + reasons = self._plan_promotion_reasons(conn, WorkRecord.from_row(row)) + if not reasons: + return state + return Readiness( + project_id, + item_id, + False, + state.revision, + reasons=(*state.reasons, *reasons), + advisory=state.advisory, + overridden=state.overridden, + override_reason=state.override_reason, + ) + finally: + conn.close() + + def _plan_promotion_reasons( + self, conn: sqlite3.Connection, record: WorkRecord + ) -> tuple[ReadinessReason, ...]: + """Require local prerequisites to be promoted before plan admission. + + The dependency graph answers whether prerequisite work is complete; + this projection answers whether that completed work is present on the + plan's durable integration branch. It runs inside ``claim``'s write + transaction, so a dependent cannot slip in between those two facts. + """ + plan_configured = conn.execute( + "SELECT 1 FROM projects WHERE project_id = ? " + "AND plan_path IS NOT NULL AND TRIM(plan_path) <> '' " + "AND plan_branch IS NOT NULL AND TRIM(plan_branch) <> ''", + (record.project_id,), + ).fetchone() + plan_initialized = conn.execute( + "SELECT 1 FROM plans WHERE project_id = ?", (record.project_id,) + ).fetchone() + if plan_configured is None and plan_initialized is None: + return () + reasons: list[ReadinessReason] = [] + for dependency in record.dependency_specs(): + if not dependency.required or dependency.target_kind != LOCAL_WORK: + continue + if plan_initialized is None: + reasons.append( + ReadinessReason( + kind="plan_promotion", + explanation=( + f"local work target {dependency.target_id!r} cannot be released " + "until the configured plan branch is initialized" + ), + target_kind=dependency.target_kind, + target_id=dependency.target_id, + state="blocked", + evidence="durable plan identity is absent", + ) + ) + continue + promoted = conn.execute( + "SELECT 1 FROM plan_promotions WHERE project_id = ? AND item_id = ? " + "AND status = 'promoted' LIMIT 1", + (record.project_id, dependency.target_id), + ).fetchone() + if promoted is not None: + continue + reasons.append( + ReadinessReason( + kind="plan_promotion", + explanation=( + f"local work target {dependency.target_id!r} is complete but " + "has not been promoted to the local plan branch" + ), + target_kind=dependency.target_kind, + target_id=dependency.target_id, + state="blocked", + evidence="promotion record is absent", + ) + ) + return tuple(reasons) def unmet_dependencies(self, item_id: str, *, project_id: str = DEFAULT_PROJECT) -> list[str]: """Required targets of an item that are not satisfied, right now. @@ -1504,6 +2245,51 @@ def hold( conn.close() return hold + def hold_pending( + self, + item_id: str, + *, + question: str, + reason: str = "", + project_id: str = DEFAULT_PROJECT, + max_seconds: float | None = None, + ) -> Hold: + """Open a human hold before a worker exists. + + Remote review can be ambiguous before correction work is claimable. + This narrow path is distinct from ``hold``: it accepts only a pending + or blocked correction item, gives it no worker owner, and answering + returns it to ``pending`` so a later worker can claim it. + """ + record = self.get(item_id, project_id=project_id) + if record is None: + raise HoldError(f"no item {item_id!r} in project {project_id!r}") + if record.state not in {PENDING, BLOCKED}: + raise HoldError(f"{item_id} is {record.state!r}, not a waiting correction item") + project = self.get_project(project_id) + limit = ( + max_seconds + if max_seconds is not None + else float(getattr(project, "max_hold_seconds", DEFAULT_MAX_HOLD_SECONDS) or 0.0) + ) + hold = self.holds.open( + project_id, + item_id, + question=question, + reason=reason, + max_seconds=limit, + ) + conn = self._connect() + try: + conn.execute( + "UPDATE work SET state = ?, owner = NULL, lease_until = 0, held_until = ?, " + "updated_at = ? WHERE project_id = ? AND item_id = ? AND state IN (?, ?)", + (HELD, hold.expires_at, self.now(), project_id, item_id, PENDING, BLOCKED), + ) + finally: + conn.close() + return hold + def answer_hold( self, item_id: str, @@ -1532,7 +2318,14 @@ def answer_hold( conn.execute( "UPDATE work SET state = ?, lease_until = ?, held_until = 0, updated_at = ? " "WHERE project_id = ? AND item_id = ? AND state = ?", - (CLAIMED, now + self.lease_seconds, now, project_id, item_id, HELD), + ( + CLAIMED if hold.owner else PENDING, + now + self.lease_seconds if hold.owner else 0, + now, + project_id, + item_id, + HELD, + ), ) finally: conn.close() diff --git a/tests/test_agent_loop_e2e.py b/tests/test_agent_loop_e2e.py index e84b4d5..18e03ea 100644 --- a/tests/test_agent_loop_e2e.py +++ b/tests/test_agent_loop_e2e.py @@ -344,6 +344,31 @@ def test_an_observation_goes_back_as_a_well_formed_turn(repo: Path) -> None: assert "wrong" in seen, "the command output never returned to the model" +def test_a_tool_message_cannot_reach_the_wire_without_its_id() -> None: + """The allow-list must agree with what this function can actually emit. + + `_for_the_wire` strips everything but `role` and `content`, so it cannot + produce a valid `tool` message — that role requires the `tool_call_id` it + answers. Permitting the role anyway left a latent contradiction: nothing + emits one today, and the day something does, the request is malformed and + the refusal names no message. The content still goes through, as a user + turn; only the unsendable role is refused. + """ + from agent_harness.adapters.minisweagent import _for_the_wire + + wire = _for_the_wire( + [ + {"role": "assistant", "content": "running it"}, + {"role": "tool", "content": "0", "tool_call_id": "call_1"}, + {"role": "exit", "content": "done"}, + ] + ) + + assert [message["role"] for message in wire] == ["assistant", "user", "user"] + assert "0" in wire[1]["content"] + assert all(set(message) == {"role", "content"} for message in wire) + + def test_the_prompts_match_the_protocol() -> None: """Prompts and protocol must agree, and once they did not. diff --git a/tests/test_api.py b/tests/test_api.py index c60c8e7..1daf7e0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -242,6 +242,37 @@ def test_retry_allows_an_item_whose_lease_expired(tmp_path: Path, store: EventSt assert c.post("/api/work/W1/retry", headers=auth()).status_code == 200 +def test_retry_honours_the_queue_clock_not_the_wall_clock( + tmp_path: Path, store: EventStore +) -> None: + """The lease belongs to the queue's clock, which is injectable so lease + behaviour can be tested at all. A route reading `time.time()` instead + opts out of that silently, and reports a live claim as expired.""" + clock = [1000.0] + q = make_queue(str(tmp_path / "w.sqlite"), lease_seconds=10.0, now=lambda: clock[0]) + q.add([WorkRecord(item_id="W1", title="t", brief="b")]) + q.claim("worker-a") + with TestClient(create_api(store, queue=q, token=TOKEN)) as c: + response = c.post("/api/work/W1/retry", headers=auth()) + assert response.status_code == 409 + assert "worker-a" in response.json()["detail"] + assert q.get("W1").state == CLAIMED # type: ignore[union-attr] + + +def test_blocking_honours_the_queue_clock_not_the_wall_clock( + tmp_path: Path, store: EventStore +) -> None: + clock = [1000.0] + q = make_queue(str(tmp_path / "w.sqlite"), lease_seconds=10.0, now=lambda: clock[0]) + q.add([WorkRecord(item_id="W1", title="t", brief="b")]) + q.claim("worker-a") + with TestClient(create_api(store, queue=q, token=TOKEN)) as c: + response = c.post("/api/work/W1/block", json={"reason": "decision"}, headers=auth()) + assert response.status_code == 409 + assert "worker-a" in response.json()["detail"] + assert q.get("W1").state == CLAIMED # type: ignore[union-attr] + + def test_retry_on_an_unknown_item_is_404(client: TestClient) -> None: assert client.post("/api/work/NOPE/retry", headers=auth()).status_code == 404 diff --git a/tests/test_execution_environment.py b/tests/test_execution_environment.py new file mode 100644 index 0000000..4514caf --- /dev/null +++ b/tests/test_execution_environment.py @@ -0,0 +1,147 @@ +"""Stage 2 contract tests for the item execution boundary.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from agent_harness.adapters.docker import DockerEnvironmentFactory, DockerItemEnvironment +from agent_harness.execution_environment import EnvironmentMount, EnvironmentSpec +from agent_harness.execution_environments import names, probe, resolve + + +def test_docker_backend_is_selected_by_installed_metadata() -> None: + assert "docker" in names() + ok, detail = probe("docker") + assert ok or "daemon" in detail.lower() or "docker api" in detail.lower(), detail + backend = resolve("docker") + assert backend.name == "docker" + + +def test_environment_spec_rejects_host_networking_and_unsafe_mounts(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="host networking"): + EnvironmentSpec(image="rust:1", worktree=tmp_path.resolve(), network="host") + with pytest.raises(ValueError, match="safe absolute"): + EnvironmentMount(tmp_path.resolve(), "/workspace/../host") + with pytest.raises(ValueError, match="protected"): + EnvironmentMount(tmp_path.resolve(), "/workspace") + with pytest.raises(ValueError, match="valid variable"): + EnvironmentSpec( + image="rust:1", worktree=tmp_path.resolve(), environment={"not-valid": "secret"} + ) + + +def test_environment_evidence_names_secrets_but_never_records_values(tmp_path: Path) -> None: + spec = EnvironmentSpec( + image="registry.invalid/rust@sha256:abc", + worktree=tmp_path.resolve(), + mounts=(EnvironmentMount(tmp_path.resolve(), "/opt/toolchain"),), + environment={"PATH": "/usr/bin", "TOKEN": "must-not-be-recorded"}, + network="bridge", + ) + evidence = spec.describe(backend="docker", digest="sha256:resolved") + rendered = repr(evidence) + assert evidence["image_digest"] == "sha256:resolved" + assert evidence["environment_names"] == ["PATH", "TOKEN"] + assert "must-not-be-recorded" not in rendered + assert evidence["mounts"][0]["writable"] is False + assert evidence["security"]["no_new_privileges"] is True + + +def test_docker_backend_constructs_an_isolated_container_and_tears_it_down( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + calls.append(argv) + if argv[1] == "inspect": + return subprocess.CompletedProcess(argv, 0, "sha256:resolved\n", "") + if argv[1] == "exec": + return subprocess.CompletedProcess(argv, 0, "ok\n", "") + return subprocess.CompletedProcess(argv, 0, "container-id\n", "") + + monkeypatch.setattr("agent_harness.adapters.docker.shutil.which", lambda _: "/usr/bin/docker") + monkeypatch.setattr("agent_harness.adapters.docker.subprocess.run", fake_run) + source = tmp_path / "deps" + source.mkdir() + item = tmp_path / "item" + item.mkdir() + environment = DockerItemEnvironment( + EnvironmentSpec( + image="rust@sha256:image", + worktree=item.resolve(), + mounts=(EnvironmentMount(source.resolve(), "/opt/deps"),), + environment={"SAFE": "yes"}, + network="bridge", + ) + ) + + environment.start() + result = environment.run("printf ok", cwd=item, timeout=7) + environment.close() + + create = next(call for call in calls if call[1] == "create") + assert "--read-only" in create + assert "--label" in create + assert "agent_harness.managed=true" in create + assert any(value.startswith("agent_harness.worktree=") for value in create) + assert "--cap-drop" in create and create[create.index("--cap-drop") + 1] == "ALL" + assert "--security-opt" in create + assert any("/opt/deps:ro" in value for value in create) + assert "SAFE=yes" in create + assert "TOKEN" not in " ".join(create) + exec_call = next(call for call in calls if call[1] == "exec") + assert "7s" in " ".join(exec_call) + assert result.stdout == "ok\n" + assert any(call[1:4] == ["rm", "--force", "--volumes"] for call in calls) + + +def test_docker_reaps_only_containers_for_the_requested_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + calls.append(argv) + if argv[1:3] == ["ps", "--all"]: + return subprocess.CompletedProcess(argv, 0, "old-item\n", "") + return subprocess.CompletedProcess(argv, 0, "", "") + + monkeypatch.setattr("agent_harness.adapters.docker.shutil.which", lambda _: "/usr/bin/docker") + monkeypatch.setattr("agent_harness.adapters.docker.subprocess.run", fake_run) + worktree = tmp_path / "item" + worktree.mkdir() + + DockerEnvironmentFactory().reap(worktree) + + listing = next(call for call in calls if call[1:3] == ["ps", "--all"]) + assert f"label=agent_harness.worktree={worktree.resolve()}" in listing + assert ["docker", "rm", "--force", "--volumes", "old-item"] in calls + + +def test_docker_start_failure_removes_the_created_container( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + calls.append(argv) + if argv[1] == "start": + return subprocess.CompletedProcess(argv, 1, "", "start failed") + return subprocess.CompletedProcess(argv, 0, "", "") + + monkeypatch.setattr("agent_harness.adapters.docker.shutil.which", lambda _: "/usr/bin/docker") + monkeypatch.setattr("agent_harness.adapters.docker.subprocess.run", fake_run) + item = tmp_path / "item" + item.mkdir() + environment = DockerItemEnvironment(EnvironmentSpec(image="rust:1", worktree=item.resolve())) + + with pytest.raises(Exception, match="start failed"): + environment.start() + + assert any(call[1] == "rm" for call in calls) + assert environment.container == "" diff --git a/tests/test_execution_environment_live.py b/tests/test_execution_environment_live.py new file mode 100644 index 0000000..a310472 --- /dev/null +++ b/tests/test_execution_environment_live.py @@ -0,0 +1,100 @@ +"""Acceptance tests for the configured Docker backend. + +These use the backend itself, not a mocked subprocess. They run when a live +Docker daemon and acceptance image are explicitly supplied. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from agent_harness.adapters.docker import DockerEnvironmentFactory, DockerItemEnvironment +from agent_harness.execution_environment import EnvironmentMount + + +def _image() -> str: + return os.environ.get("HARNESS_STAGE2_IMAGE", "").strip() + + +def _docker_ready() -> bool: + if not _image() or shutil.which("docker") is None: + return False + result = subprocess.run( + ["docker", "info"], capture_output=True, text=True, timeout=5, check=False + ) + return result.returncode == 0 + + +pytestmark = pytest.mark.skipif( + not _docker_ready(), + reason="HARNESS_STAGE2_IMAGE and a reachable Docker daemon are required", +) + + +def test_live_backend_confines_worktree_and_keeps_controller_credentials_out( + tmp_path: Path, +) -> None: + worktree = tmp_path / "worktree" + dependency = tmp_path / "dependency" + sibling = tmp_path / "sibling-secret.txt" + worktree.mkdir() + dependency.mkdir() + (worktree / "inside.txt").write_text("inside\n") + (dependency / "readme.txt").write_text("declared\n") + sibling.write_text("must stay on host\n") + + backend = DockerEnvironmentFactory() + environment = backend.create( + worktree, + image=_image(), + mounts=(EnvironmentMount(dependency.resolve(), "/opt/dependency"),), + environment={"DECLARED": "yes", "HOST_SIBLING": str(sibling.resolve())}, + network="none", + ) + os.environ["HARNESS_STAGE2_CONTROLLER_SECRET"] = "must-not-enter" + assert isinstance(environment, DockerItemEnvironment) + container = "" + try: + environment.start() + container = environment.container + visible = environment.run( + "cat /workspace/inside.txt; test -f /opt/dependency/readme.txt; " + 'test -z "$HARNESS_STAGE2_CONTROLLER_SECRET"; ' + 'test "$DECLARED" = yes; test ! -e "$HOST_SIBLING"; ' + "! wget -q -O - --timeout=3 https://example.com; " + "printf changed > /workspace/result.txt", + cwd=worktree, + timeout=30, + ) + assert visible.returncode == 0, visible.stderr + assert (worktree / "result.txt").read_text() == "changed" + finally: + environment.close() + os.environ.pop("HARNESS_STAGE2_CONTROLLER_SECRET", None) + assert container + assert not environment.container + inspected = subprocess.run( + ["docker", "inspect", container], capture_output=True, text=True, check=False + ) + assert inspected.returncode != 0, inspected.stdout + + +def test_live_backend_allows_outbound_network_when_configured(tmp_path: Path) -> None: + worktree = tmp_path / "worktree" + worktree.mkdir() + environment = DockerEnvironmentFactory().create(worktree, image=_image(), network="bridge") + try: + environment.start() + result = environment.run( + "wget -q -O - --timeout=10 https://example.com >/dev/null", + cwd=worktree, + timeout=30, + ) + assert result.returncode == 0, result.stderr + finally: + environment.close() diff --git a/tests/test_generic.py b/tests/test_generic.py index 5f85a3d..c09531f 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -22,6 +22,8 @@ "graph.py", "fleet.py", "role_runners.py", + "execution_environment.py", + "execution_environments.py", "session_executor.py", "executor.py", # The refusal list is on the path an item passes through, and a refusal @@ -32,6 +34,15 @@ "protocols.py", "pricing.py", "plan.py", + # Plan integration and its single publication step are on the path an + # item reaches "done" through, so they are held to the same rule. + "plan_integration.py", + "plan_publication.py", + # Review intake decides what becomes correction work. A source adapter + # owns one upstream's format; core must never import one. + "review_events.py", + "review_sources.py", + "notifications.py", "github.py", "preflight.py", "audit.py", @@ -164,3 +175,13 @@ def test_the_shipped_role_runners_are_declared_the_same_way() -> None: declared = manifest["project"]["entry-points"]["agent_harness.role_runners"] assert set(declared) == {"agent-loop"} assert all(value.startswith("agent_harness.adapters.") for value in declared.values()) + + +def test_the_shipped_execution_backends_are_declared_the_same_way() -> None: + """The OS boundary is an adapter too: core selects it by name.""" + import tomllib + + manifest = tomllib.loads((SRC.parents[1] / "pyproject.toml").read_text()) + declared = manifest["project"]["entry-points"]["agent_harness.execution_environments"] + assert set(declared) == {"docker"} + assert all(value.startswith("agent_harness.adapters.") for value in declared.values()) diff --git a/tests/test_github_pr_review_source.py b/tests/test_github_pr_review_source.py new file mode 100644 index 0000000..a93b827 --- /dev/null +++ b/tests/test_github_pr_review_source.py @@ -0,0 +1,252 @@ +"""The installed GitHub review source, exercised without a network or a token. + +`gh` is injected, as it is everywhere else a GitHub path is tested. What is +under test is the two things this adapter decides — immutable identity and +explicit disposition — not GitHub's own behaviour. +""" + +from __future__ import annotations + +import json +from collections.abc import Sequence +from pathlib import Path + +import pytest + +from agent_harness.adapters.github_pr_review import GitHubPullRequestReviewSource, source +from agent_harness.review_sources import API_VERSION, ReviewPoller, names, resolve +from agent_harness.work import HELD, PENDING, Project, WorkQueue, WorkRecord + + +class Gh: + """A `gh api` stand-in returning one payload per endpoint.""" + + def __init__( + self, + reviews: list[dict[str, object]] | None = None, + comments: list[dict[str, object]] | None = None, + ) -> None: + self.reviews = reviews or [] + self.comments = comments or [] + self.calls: list[str] = [] + + def __call__(self, args: Sequence[str]) -> str: + path = args[-1] + self.calls.append(path) + return json.dumps(self.reviews if "/reviews" in path else self.comments) + + +def make(gh: Gh, **kwargs: object) -> GitHubPullRequestReviewSource: + config: dict[str, object] = { + "repo": "acme/widgets", + "pr": 7, + "project_id": "p", + "default_item_id": "T1", + "runner": gh, + } + config.update(kwargs) + return GitHubPullRequestReviewSource(**config) # type: ignore[arg-type] + + +def test_unmarked_prose_is_ambiguous_rather_than_guessed_at() -> None: + gh = Gh( + comments=[ + {"id": 5, "body": "Not sure this is right?", "updated_at": "2026-08-07T00:00:00Z"} + ] + ) + + batch = make(gh).poll(None) + + assert [event.disposition for event in batch.events] == ["ambiguous"] + + +def test_explicit_markers_decide_the_disposition() -> None: + gh = Gh( + comments=[ + { + "id": 1, + "body": "harness: fix — rename the field", + "updated_at": "2026-08-07T00:00:01Z", + }, + { + "id": 2, + "body": "harness: hold — is this in scope?", + "updated_at": "2026-08-07T00:00:02Z", + }, + { + "id": 3, + "body": "harness: resolved, done upstream", + "updated_at": "2026-08-07T00:00:03Z", + }, + ] + ) + + batch = make(gh).poll(None) + + assert [event.disposition for event in batch.events] == [ + "actionable", + "ambiguous", + "already_resolved", + ] + + +def test_review_state_decides_when_no_marker_is_present() -> None: + gh = Gh( + reviews=[ + { + "id": 10, + "state": "CHANGES_REQUESTED", + "body": "Fix the lock", + "submitted_at": "2026-08-07T01:00:00Z", + }, + {"id": 11, "state": "APPROVED", "body": "", "submitted_at": "2026-08-07T01:00:01Z"}, + ] + ) + + batch = make(gh).poll(None) + + assert [event.disposition for event in batch.events] == ["actionable", "already_resolved"] + assert "approved with no comment" in batch.events[1].summary + + +def test_an_unsubmitted_draft_review_is_not_feedback_yet() -> None: + gh = Gh( + reviews=[ + { + "id": 12, + "state": "PENDING", + "body": "harness: fix half a thought", + "submitted_at": "2026-08-07T01:00:02Z", + } + ] + ) + + assert make(gh).poll(None).events == () + + +def test_identity_separates_reviews_from_review_comments_sharing_a_number() -> None: + gh = Gh( + reviews=[ + { + "id": 42, + "state": "CHANGES_REQUESTED", + "body": "a", + "submitted_at": "2026-08-07T02:00:00Z", + } + ], + comments=[{"id": 42, "body": "harness: fix b", "updated_at": "2026-08-07T02:00:01Z"}], + ) + + remote_ids = {event.remote_id for event in make(gh).poll(None).events} + + assert remote_ids == { + "acme/widgets#7/reviews/42", + "acme/widgets#7/comments/42", + } + + +def test_an_explicit_item_marker_overrides_the_default_item() -> None: + gh = Gh( + comments=[ + { + "id": 8, + "body": "harness: fix\nharness-item: T4", + "updated_at": "2026-08-07T03:00:00Z", + } + ] + ) + + batch = make(gh).poll(None) + + assert batch.events[0].item_id == "T4" + + +def test_the_cursor_is_the_latest_stamp_and_filters_the_comments_endpoint() -> None: + gh = Gh( + reviews=[ + { + "id": 1, + "state": "COMMENTED", + "body": "harness: fix a", + "submitted_at": "2026-08-07T04:00:00Z", + } + ], + comments=[{"id": 2, "body": "harness: fix b", "updated_at": "2026-08-07T05:00:00Z"}], + ) + api = make(gh) + + first = api.poll(None) + api.poll(first.next_cursor) + + assert first.next_cursor == "2026-08-07T05:00:00Z" + assert "since=2026-08-07T05:00:00Z" in gh.calls[-1] + assert "since=" not in gh.calls[-2] # the reviews endpoint has no `since` + + +def test_a_long_body_is_truncated_into_a_bounded_summary() -> None: + gh = Gh( + comments=[ + {"id": 9, "body": "harness: fix " + "x" * 9000, "updated_at": "2026-08-07T06:00:00Z"} + ] + ) + + assert len(make(gh).poll(None).events[0].summary) <= 2000 + + +def test_unreadable_output_fails_loudly_rather_than_polling_empty() -> None: + class Broken: + def __call__(self, args: Sequence[str]) -> str: + return "not json" + + with pytest.raises(RuntimeError, match="unreadable"): + make(Broken()).poll(None) # type: ignore[arg-type] + + +def test_the_factory_names_what_it_needs_and_rejects_what_it_does_not_know() -> None: + with pytest.raises(ValueError, match="default_item_id"): + source({"repo": "acme/widgets", "pr": 1, "project_id": "p"}) + with pytest.raises(ValueError, match="does not accept token"): + source( + { + "repo": "acme/widgets", + "pr": 1, + "project_id": "p", + "default_item_id": "T1", + "token": "secret", + } + ) + + +def test_the_source_is_installed_and_resolves_by_name() -> None: + assert "github-pr-review" in names() + + resolved = resolve( + "github-pr-review", + {"repo": "acme/widgets", "pr": 3, "project_id": "p", "default_item_id": "T1"}, + ) + + assert resolved.api_version == API_VERSION + assert resolved.name == "github-pr-review" + + +def test_polling_the_installed_source_creates_correction_work_once(tmp_path: Path) -> None: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("p", "project")) + queue.add([WorkRecord("T1", "original")], project_id="p") + gh = Gh( + comments=[ + {"id": 1, "body": "harness: fix the lock", "updated_at": "2026-08-07T07:00:00Z"}, + {"id": 2, "body": "should this move?", "updated_at": "2026-08-07T07:00:01Z"}, + ] + ) + poller = ReviewPoller(queue, make(gh)) + + first = poller.poll_once() + gh.calls.clear() + second = poller.poll_once() + + assert (first.accepted, first.duplicates) == (2, 0) + assert (second.accepted, second.duplicates) == (0, 2) + states = sorted(item.state for item in queue.items(project_id="p")) + assert states.count(PENDING) == 2 # the original plus the actionable correction + assert states.count(HELD) == 1 # the unmarked comment is a person's decision diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..d4cba9d --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +from pathlib import Path +from typing import cast +from urllib.request import Request + +import pytest + +from agent_harness.notifications import Notification, NotificationOutbox, WebhookChannel + + +class RecordingChannel: + def __init__(self) -> None: + self.fail = True + self.received: list[Notification] = [] + + def send(self, notification: Notification) -> None: + self.received.append(notification) + if self.fail: + raise OSError("receiver unavailable") + + +def test_outbox_deduplicates_retries_and_survives_reopen(tmp_path: Path) -> None: + now = [100.0] + channel = RecordingChannel() + path = tmp_path / "notifications.sqlite" + outbox = NotificationOutbox(path, channel, retry_seconds=2, clock=lambda: now[0]) + + assert outbox.enqueue_event({"kind": "work", "outcome": "hold_opened", "item_id": "T1"}) + assert not outbox.enqueue_event({"kind": "work", "outcome": "hold_opened", "item_id": "T1"}) + assert outbox.deliver_due(now=now[0]) == 0 + first = outbox.rows()[0] + assert first.state == "pending" + assert first.attempts == 1 + assert first.last_error == "receiver unavailable" + + outbox.close() + channel.fail = False + reopened = NotificationOutbox(path, channel, retry_seconds=2, clock=lambda: now[0]) + assert reopened.deliver_due(now=now[0]) == 0 + now[0] = 102.0 + assert reopened.deliver_due(now=now[0]) == 1 + assert reopened.rows()[0].state == "delivered" + assert len(channel.received) == 2 + reopened.close() + + +def test_outbox_only_alerts_on_explicit_outcomes(tmp_path: Path) -> None: + outbox = NotificationOutbox(tmp_path / "notifications.sqlite") + + assert not outbox.enqueue_event({"kind": "work", "outcome": "tool_step"}) + assert outbox.enqueue_event({"kind": "work", "outcome": "done", "item_id": "T1"}) + assert outbox.rows()[0].payload["item_id"] == "T1" + outbox.close() + + +def test_authenticated_webhook_supports_bearer_and_hmac(tmp_path: Path) -> None: + calls: list[tuple[Request, bytes]] = [] + + def send(request: Request, timeout: float) -> None: + del timeout + body = cast(bytes, request.data or b"") + calls.append((request, body)) + + channel = WebhookChannel( + "https://notifications.invalid/inbox", + bearer_token="bearer-secret", + hmac_secret="hmac-secret", + send=send, + ) + notification = Notification( + notification_id=4, + dedupe_key="dedupe", + kind="work", + payload={"outcome": "done", "detail": "safe"}, + created_at=1.0, + attempts=1, + state="inflight", + next_attempt_at=1.0, + lease_until=2.0, + last_error=None, + ) + + channel.send(notification) + + assert len(calls) == 1 + request, body = calls[0] + assert request.get_header("Authorization") == "Bearer bearer-secret" + expected = hmac.new(b"hmac-secret", body, hashlib.sha256).hexdigest() + assert request.get_header("X-harness-signature") == f"sha256={expected}" + assert b"bearer-secret" not in body + assert json.loads(body)["payload"]["detail"] == "safe" + + +def test_webhook_requires_authentication() -> None: + with pytest.raises(ValueError, match="requires a bearer token"): + WebhookChannel("https://notifications.invalid/inbox") diff --git a/tests/test_plan_integration.py b/tests/test_plan_integration.py new file mode 100644 index 0000000..7acbb13 --- /dev/null +++ b/tests/test_plan_integration.py @@ -0,0 +1,1419 @@ +from __future__ import annotations + +import json +import multiprocessing +import subprocess +import tempfile +import threading +import time +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any, cast + +import pytest + +from agent_harness.execution_environment import LocalExecutionEnvironment +from agent_harness.executor import Checks, Executor +from agent_harness.fleet import Fleet +from agent_harness.model_client import ModelClient, Response, Route +from agent_harness.outcomes import PASS, CheckResult +from agent_harness.plan_integration import PlanCoordinator, PromotionConflict, PromotionError +from agent_harness.role_runners import RoleRunResult +from agent_harness.work import DONE, RUNNING, Project, WorkQueue, WorkRecord + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def commit(repo: Path, message: str, content: str, name: str = "value.txt") -> str: + (repo / name).write_text(content) + git(repo, "add", name) + git(repo, "commit", "-m", message) + return git(repo, "rev-parse", "HEAD") + + +def make_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-b", "main") + git(repo, "config", "user.email", "test@example.invalid") + git(repo, "config", "user.name", "Test") + commit(repo, "base", "base\n") + return repo + + +class _ProcessPromotionChecks: + def __init__( + self, + item_id: str, + first_entered: Any, + second_entered: Any, + release_first: Any, + ) -> None: + self.item_id = item_id + self.first_entered = first_entered + self.second_entered = second_entered + self.release_first = release_first + + def run(self, tree: Path) -> CheckResult: + del tree + if self.item_id == "A": + self.first_entered.set() + if not self.release_first.wait(timeout=10): + raise AssertionError("first promotion gate was not released") + else: + self.second_entered.set() + return CheckResult(PASS) + + +def _promote_from_process( + queue_path: str, + repo: str, + item_id: str, + item_branch: str, + first_entered: Any, + second_entered: Any, + release_first: Any, + second_blocked: Any, + allow_second_wait: Any, +) -> None: + queue = WorkQueue(queue_path, lease_seconds=100.0) + + def promotion_sleep(_: float) -> None: + second_blocked.set() + if not allow_second_wait.wait(timeout=10): + raise AssertionError("second promotion was not released from lease wait") + + coordinator = PlanCoordinator( + queue, + "p", + Path(repo), + checks=cast( + Checks, + _ProcessPromotionChecks(item_id, first_entered, second_entered, release_first), + ), + promotion_lease_seconds=10.0, + promotion_wait_seconds=10.0, + promotion_sleep=promotion_sleep if item_id == "B" else (lambda _: None), + ) + record = queue.get(item_id, project_id="p") + assert record is not None + coordinator.promote(record, item_branch=item_branch, base="main") + + +def test_plan_branch_is_created_from_exact_target_and_survives_restart(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + plan_file = tmp_path / "PLAN.md" + plan_file.write_text("# plan\n") + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="p", + name="p", + work_dir=str(repo), + plan_path=str(plan_file), + plan_branch="integration/p", + ) + ) + target = git(repo, "rev-parse", "main") + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + state = coordinator.ensure( + target_branch="main", branch="integration/p", plan_path=str(plan_file) + ) + + assert state.target_sha == target + assert state.head_sha == target + assert git(repo, "rev-parse", "integration/p") == target + restored = PlanCoordinator( + WorkQueue(str(tmp_path / "queue.sqlite")), "p", repo, checks=Checks() + ) + assert restored.state() == state + + +def test_independent_promotions_then_dependent_sees_both(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + coordinator.ensure(target_branch="main", branch="integration/p") + target = git(repo, "rev-parse", "main") + + a_branch = "harness/a" + git(repo, "branch", a_branch, "integration/p") + git(repo, "checkout", a_branch) + commit(repo, "A", "A\n", "a.txt") + git(repo, "checkout", "main") + b_branch = "harness/b" + git(repo, "branch", b_branch, "integration/p") + git(repo, "checkout", b_branch) + commit(repo, "B", "B\n", "b.txt") + git(repo, "checkout", "main") + + queue.add( + [ + WorkRecord("A", "A", state=DONE, branch=a_branch), + WorkRecord("B", "B", state=DONE, branch=b_branch), + WorkRecord("C", "C", depends_on=["A", "B"]), + ], + project_id="p", + ) + a = queue.get("A", project_id="p") + b = queue.get("B", project_id="p") + assert a is not None and b is not None + coordinator.promote(a, item_branch=a_branch, base=target) + coordinator.promote(b, item_branch=b_branch, base=target) + c = queue.get("C", project_id="p") + assert c is not None + assert coordinator.base_for(c) == ("integration/p", "local plan branch") + assert git(repo, "show", "integration/p:a.txt") == "A" + assert git(repo, "show", "integration/p:b.txt") == "B" + c_branch = "harness/c" + git(repo, "branch", c_branch, "integration/p") + git(repo, "checkout", c_branch) + commit(repo, "C", "C\n", "c.txt") + git(repo, "checkout", "main") + c.branch = c_branch + c.state = DONE + coordinator.promote(c, item_branch=c_branch, base="integration/p") + assert git(repo, "show", "integration/p:c.txt") == "C" + + +def test_promotion_replays_item_created_from_older_plan_head(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + coordinator.ensure(target_branch="main", branch="integration/p") + target = git(repo, "rev-parse", "main") + + # Both workers started from the original plan head. B reaches promotion + # first, so A must be replayed onto a plan branch that has since moved. + a_branch = "harness/older-a" + git(repo, "branch", a_branch, target) + git(repo, "checkout", a_branch) + commit(repo, "A", "A\n", "a.txt") + git(repo, "checkout", "main") + b_branch = "harness/first-b" + git(repo, "branch", b_branch, target) + git(repo, "checkout", b_branch) + commit(repo, "B", "B\n", "b.txt") + git(repo, "checkout", "main") + + queue.add( + [ + WorkRecord("A", "A", state=DONE, branch=a_branch), + WorkRecord("B", "B", state=DONE, branch=b_branch), + ], + project_id="p", + ) + a = queue.get("A", project_id="p") + b = queue.get("B", project_id="p") + assert a is not None and b is not None + coordinator.promote(b, item_branch=b_branch, base=target) + before_a = coordinator.state() + coordinator.promote(a, item_branch=a_branch, base=target) + + after_a = coordinator.state() + assert after_a.head_sha != before_a.head_sha + assert git(repo, "show", "integration/p:a.txt") == "A" + assert git(repo, "show", "integration/p:b.txt") == "B" + assert ( + git(repo, "merge-base", "--is-ancestor", git(repo, "rev-parse", b_branch), after_a.head_sha) + == "" + ) + assert ( + git(repo, "merge-base", "--is-ancestor", git(repo, "rev-parse", a_branch), after_a.head_sha) + == "" + ) + assert git(repo, "rev-parse", f"{after_a.head_sha}^2") == git(repo, "rev-parse", a_branch) + promotion = queue.latest_promotion("p", "A") + assert promotion is not None + assert promotion["base_sha"] == target + assert promotion["old_head_sha"] == before_a.head_sha + assert promotion["new_head_sha"] == after_a.head_sha + + +def test_dependent_admission_waits_for_every_prerequisite_promotion(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + coordinator.ensure(target_branch="main", branch="integration/p") + target = git(repo, "rev-parse", "main") + + branches: dict[str, str] = {} + for item, filename in (("A", "a.txt"), ("B", "b.txt")): + branch = f"harness/{item.lower()}-admission" + git(repo, "branch", branch, target) + git(repo, "checkout", branch) + commit(repo, item, item + "\n", filename) + git(repo, "checkout", "main") + branches[item] = branch + + queue.add( + [ + WorkRecord("A", "A", state=DONE, branch=branches["A"]), + WorkRecord("B", "B", state=DONE, branch=branches["B"]), + WorkRecord("C", "C", depends_on=["A", "B"]), + ], + project_id="p", + ) + queue.set_control("running", project_id="p") + + assert queue.claim("worker", project_id="p") is None + blocked = queue.readiness("C", project_id="p") + assert blocked.ready is False + assert {reason.target_id for reason in blocked.reasons} == {"A", "B"} + + a = queue.get("A", project_id="p") + b = queue.get("B", project_id="p") + assert a is not None and b is not None + coordinator.promote(a, item_branch=branches["A"], base=target) + assert queue.claim("worker", project_id="p") is None + coordinator.promote(b, item_branch=branches["B"], base=target) + claimed = queue.claim("worker", project_id="p") + assert claimed is not None and claimed.item_id == "C" + + +def test_advisory_local_dependency_does_not_block_promotion(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + coordinator.ensure(target_branch="main", branch="integration/p") + target = git(repo, "rev-parse", "main") + + branch = "harness/advisory" + git(repo, "branch", branch, target) + git(repo, "checkout", branch) + commit(repo, "work", "work\n", "work.txt") + git(repo, "checkout", "main") + record = WorkRecord("A", "A", state=DONE, branch=branch, depends_on=["?MISSING"]) + queue.add([record], project_id="p") + + coordinator.promote(record, item_branch=branch, base=target) + assert queue.latest_promotion("p", "A") is not None + + +def test_restart_recovers_promotion_after_git_ref_advanced(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + state = coordinator.ensure(target_branch="main", branch="integration/p") + target = state.head_sha + branch = "harness/recover-a" + git(repo, "branch", branch, target) + git(repo, "checkout", branch) + item_sha = commit(repo, "A", "A\n", "a.txt") + git(repo, "checkout", "main") + queue.add([WorkRecord("A", "A", state=DONE, branch=branch)], project_id="p") + + old_head = state.head_sha + new_head = item_sha + promotion_id = queue.begin_promotion("p", "A", branch, target, old_head, new_head) + git(repo, "update-ref", "refs/heads/integration/p", new_head, old_head) + events: list[dict[str, Any]] = [] + restarted = PlanCoordinator( + WorkQueue(str(tmp_path / "queue.sqlite")), + "p", + repo, + checks=Checks(), + on_event=events.append, + ) + recovered = restarted.ensure(target_branch="main", branch="integration/p") + assert recovered.head_sha == new_head + promotion = queue.latest_promotion("p", "A") + assert promotion is not None + assert promotion["promotion_id"] == promotion_id + assert promotion["status"] == "promoted" + assert len(events) == 1 + assert events[0]["outcome"] == "plan_promotion" + assert events[0]["promotion_id"] == promotion_id + assert events[0]["status"] == "promoted" + assert events[0]["item_sha"] == item_sha + assert events[0]["new_head_sha"] == new_head + assert events[0]["detail"] == "recovered after restart" + + +def test_restart_abandons_promotion_when_git_ref_did_not_move(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + state = coordinator.ensure(target_branch="main", branch="integration/p") + events: list[dict[str, Any]] = [] + promotion_id = queue.begin_promotion( + "p", "A", "harness/a", state.head_sha, state.head_sha, "not-a-real-sha" + ) + + restarted = PlanCoordinator( + WorkQueue(str(tmp_path / "queue.sqlite")), + "p", + repo, + checks=Checks(), + on_event=events.append, + ) + recovered = restarted.ensure(target_branch="main", branch="integration/p") + assert recovered.head_sha == state.head_sha + conn = queue._connect() + try: + row = conn.execute( + "SELECT status, detail FROM plan_promotions WHERE promotion_id = ?", + (promotion_id,), + ).fetchone() + finally: + conn.close() + assert row is not None and row["status"] == "abandoned" + assert len(events) == 1 + assert events[0]["outcome"] == "plan_promotion" + assert events[0]["promotion_id"] == promotion_id + assert events[0]["status"] == "abandoned" + assert events[0]["detail"] == row["detail"] + + +def test_conflicting_promotion_is_returned_for_repair_and_head_is_unchanged(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + events: list[dict[str, Any]] = [] + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks(), on_event=events.append) + coordinator.ensure(target_branch="main", branch="integration/p") + target = git(repo, "rev-parse", "main") + branch = "harness/conflict" + git(repo, "branch", branch, "integration/p") + git(repo, "checkout", branch) + commit(repo, "conflict", "base\nitem\n") + git(repo, "checkout", "main") + other = "harness/other" + git(repo, "branch", other, "integration/p") + git(repo, "checkout", other) + commit(repo, "other", "base\nother\n") + git(repo, "checkout", "main") + record = WorkRecord("X", "X", state=DONE, branch=branch) + queue.add([record], project_id="p") + other_record = WorkRecord("Y", "Y", state=DONE, branch=other) + queue.add([other_record], project_id="p") + coordinator.promote(other_record, item_branch=other, base="integration/p") + before = coordinator.state().head_sha + with pytest.raises(PromotionConflict): + coordinator.promote(record, item_branch=branch, base=target) + assert coordinator.state().head_sha == before + assert queue.latest_promotion("p", "X") is None + conn = queue._connect() + try: + promotion = conn.execute( + "SELECT promotion_id, status, item_sha FROM plan_promotions " + "WHERE project_id = ? AND item_id = ? ORDER BY promotion_id DESC LIMIT 1", + ("p", "X"), + ).fetchone() + finally: + conn.close() + assert promotion is not None and promotion["status"] == "conflict" + event = next(event for event in events if event["item_id"] == "X") + assert event["promotion_id"] == promotion["promotion_id"] + assert event["status"] == "conflict" + assert event["item_sha"] == promotion["item_sha"] + + +def test_target_move_rebuilds_plan_and_replays_promoted_items(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + + item_branch = "harness/refresh-item" + git(repo, "branch", item_branch, initial.target_sha) + git(repo, "checkout", item_branch) + item_sha = commit(repo, "item", "item\n", "item.txt") + git(repo, "checkout", "main") + queue.add( + [WorkRecord("A", "A", state=DONE, branch=item_branch)], + project_id="p", + ) + item = queue.get("A", project_id="p") + assert item is not None + coordinator.promote(item, item_branch=item_branch, base=initial.target_sha) + + target_sha = commit(repo, "target moved", "target\n", "target.txt") + refreshed = coordinator.ensure(target_branch="main", branch="integration/p") + + assert refreshed.target_sha == target_sha + assert refreshed.head_sha == git(repo, "rev-parse", "integration/p") + assert git(repo, "show", "integration/p:item.txt") == "item" + assert git(repo, "show", "integration/p:target.txt") == "target" + assert git(repo, "merge-base", "--is-ancestor", item_sha, refreshed.head_sha) == "" + assert git(repo, "rev-parse", f"{refreshed.head_sha}^2") == item_sha + promotion = queue.latest_promotion("p", "A") + assert promotion is not None and promotion["item_sha"] == item_sha + refreshes = queue.in_progress_refreshes("p") + assert refreshes == [] + conn = queue._connect() + try: + row = conn.execute( + "SELECT status, old_target_sha, new_target_sha FROM plan_refreshes " + "WHERE project_id = ? ORDER BY refresh_id DESC LIMIT 1", + ("p",), + ).fetchone() + finally: + conn.close() + assert row is not None + assert row["status"] == "refreshed" + assert row["old_target_sha"] == initial.target_sha + assert row["new_target_sha"] == target_sha + + +def test_target_move_replays_dependent_item_created_from_promoted_plan_head( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + + a_branch = "harness/refresh-dependent-a" + git(repo, "branch", a_branch, initial.target_sha) + git(repo, "checkout", a_branch) + a_sha = commit(repo, "A", "A\n", "a.txt") + git(repo, "checkout", "main") + queue.add([WorkRecord("A", "A", state=DONE, branch=a_branch)], project_id="p") + a = queue.get("A", project_id="p") + assert a is not None + coordinator.promote(a, item_branch=a_branch, base=initial.target_sha) + after_a = coordinator.state() + + # B is authored from the already-promoted plan head, as a real dependent + # worker is. Refresh must replay its own item commit after A, not assume + # every item was based directly on the target branch. + b_branch = "harness/refresh-dependent-b" + git(repo, "branch", b_branch, after_a.head_sha) + git(repo, "checkout", b_branch) + b_sha = commit(repo, "B", "B\n", "b.txt") + git(repo, "checkout", "main") + queue.add( + [WorkRecord("B", "B", state=DONE, branch=b_branch, depends_on=["A"])], + project_id="p", + ) + b = queue.get("B", project_id="p") + assert b is not None + coordinator.promote(b, item_branch=b_branch, base=after_a.branch) + + target_sha = commit(repo, "target moved", "target\n", "target.txt") + refreshed = coordinator.ensure(target_branch="main", branch="integration/p") + + assert refreshed.target_sha == target_sha + assert git(repo, "show", "integration/p:a.txt") == "A" + assert git(repo, "show", "integration/p:b.txt") == "B" + assert git(repo, "show", "integration/p:target.txt") == "target" + assert git(repo, "merge-base", "--is-ancestor", a_sha, refreshed.head_sha) == "" + assert git(repo, "merge-base", "--is-ancestor", b_sha, refreshed.head_sha) == "" + assert git(repo, "rev-parse", f"{refreshed.head_sha}^2") == b_sha + + +def test_promotion_refreshes_plan_when_target_moves_during_long_lived_run( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + + item_branch = "harness/live-target-item" + git(repo, "branch", item_branch, initial.target_sha) + git(repo, "checkout", item_branch) + item_sha = commit(repo, "item", "item\n", "item.txt") + git(repo, "checkout", "main") + queue.add( + [WorkRecord("A", "A", state=DONE, branch=item_branch)], + project_id="p", + ) + item = queue.get("A", project_id="p") + assert item is not None + + target_sha = commit(repo, "target moved before promotion", "target\n", "target.txt") + promoted = coordinator.promote(item, item_branch=item_branch, base=initial.target_sha) + + state = coordinator.state() + assert promoted.status == "promoted" + assert state.target_sha == target_sha + assert git(repo, "show", "integration/p:item.txt") == "item" + assert git(repo, "show", "integration/p:target.txt") == "target" + promotion = queue.latest_promotion("p", "A") + assert promotion is not None and promotion["item_sha"] == item_sha + + +def test_refresh_restarts_if_target_moves_while_replaying_promotions(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + + item_branch = "harness/refresh-target-race-item" + git(repo, "branch", item_branch, initial.target_sha) + git(repo, "checkout", item_branch) + item_sha = commit(repo, "item", "item\n", "item.txt") + git(repo, "checkout", "main") + queue.add( + [WorkRecord("A", "A", state=DONE, branch=item_branch)], + project_id="p", + ) + item = queue.get("A", project_id="p") + assert item is not None + coordinator.promote(item, item_branch=item_branch, base=initial.target_sha) + + first_target = commit(repo, "target moved once", "one\n", "target.txt") + moved = False + + class TargetMovingChecks: + def run(self, tree: Path) -> CheckResult: + nonlocal moved + if not moved: + moved = True + commit(repo, "target moved twice", "two\n", "target.txt") + return CheckResult(PASS) + + coordinator.checks = TargetMovingChecks() # type: ignore[assignment] + refreshed = coordinator.ensure(target_branch="main", branch="integration/p") + second_target = git(repo, "rev-parse", "main") + + assert first_target != second_target + assert refreshed.target_sha == second_target + assert git(repo, "show", "integration/p:target.txt") == "two" + assert git(repo, "show", "integration/p:item.txt") == "item" + promotion = queue.latest_promotion("p", "A") + assert promotion is not None and promotion["item_sha"] == item_sha + conn = queue._connect() + try: + rows = conn.execute( + "SELECT status, detail FROM plan_refreshes WHERE project_id = ? ORDER BY refresh_id", + ("p",), + ).fetchall() + finally: + conn.close() + assert [row["status"] for row in rows] == ["superseded", "refreshed"] + assert "target advanced during replay" in rows[0]["detail"] + + +def test_promotion_rechecks_target_after_integration_gates(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + + item_branch = "harness/promotion-target-race-item" + git(repo, "branch", item_branch, initial.target_sha) + git(repo, "checkout", item_branch) + item_sha = commit(repo, "item", "item\n", "item.txt") + git(repo, "checkout", "main") + queue.add( + [WorkRecord("A", "A", state=DONE, branch=item_branch)], + project_id="p", + ) + item = queue.get("A", project_id="p") + assert item is not None + moved = False + + class TargetMovingChecks: + def run(self, tree: Path) -> CheckResult: + nonlocal moved + if not moved: + moved = True + commit(repo, "target moved during promotion", "target\n", "target.txt") + return CheckResult(PASS) + + coordinator.checks = TargetMovingChecks() # type: ignore[assignment] + promoted = coordinator.promote(item, item_branch=item_branch, base=initial.target_sha) + state = coordinator.state() + + assert promoted.status == "promoted" + assert state.target_sha == git(repo, "rev-parse", "main") + assert git(repo, "show", "integration/p:target.txt") == "target" + assert git(repo, "show", "integration/p:item.txt") == "item" + promotion = queue.latest_promotion("p", "A") + assert promotion is not None and promotion["item_sha"] == item_sha + assert queue.in_progress_refreshes("p") == [] + + +def test_promotion_event_retains_item_and_plan_commit_identity(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + events: list[dict[str, Any]] = [] + + def record_then_fail(event: dict[str, Any]) -> None: + events.append(event) + raise RuntimeError("audit sink unavailable") + + coordinator = PlanCoordinator( + queue, + "p", + repo, + checks=Checks(), + on_event=record_then_fail, + ) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + item_branch = "harness/promotion-event-item" + git(repo, "branch", item_branch, initial.target_sha) + git(repo, "checkout", item_branch) + item_sha = commit(repo, "item", "item\n", "item.txt") + git(repo, "checkout", "main") + queue.add( + [WorkRecord("A", "A", state=DONE, branch=item_branch)], + project_id="p", + ) + item = queue.get("A", project_id="p") + assert item is not None + + promoted = coordinator.promote(item, item_branch=item_branch, base=initial.target_sha) + promotion = queue.latest_promotion("p", "A") + assert promotion is not None + plan_head = git(repo, "rev-parse", "integration/p") + + assert promoted.status == "promoted" + assert events == [ + { + "ts": events[0]["ts"], + "kind": "work", + "project_id": "p", + "item_id": "A", + "outcome": "plan_promotion", + "promotion_id": promotion["promotion_id"], + "status": "promoted", + "plan_branch": "integration/p", + "base_sha": initial.target_sha, + "item_sha": item_sha, + "old_head_sha": initial.head_sha, + "new_head_sha": plan_head, + "target_sha": initial.target_sha, + "detail": "authoritative integration gates passed", + } + ] + assert promotion["item_sha"] == events[0]["item_sha"] + assert promotion["new_head_sha"] == events[0]["new_head_sha"] + assert git(repo, "rev-parse", item_branch) == item_sha + assert git(repo, "show", f"{item_branch}:item.txt") == "item" + + +def test_separate_coordinators_serialize_integration_gates(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + initial = PlanCoordinator(queue, "p", repo, checks=Checks()).ensure( + target_branch="main", branch="integration/p" + ) + branches: list[tuple[str, str]] = [] + for item_id, filename in (("A", "a.txt"), ("B", "b.txt")): + branch = f"harness/serialized-{item_id.lower()}" + git(repo, "branch", branch, initial.target_sha) + git(repo, "checkout", branch) + commit(repo, item_id, item_id + "\n", filename) + branches.append((item_id, branch)) + git(repo, "checkout", "main") + queue.add( + [WorkRecord(item_id, item_id, state=DONE, branch=branch) for item_id, branch in branches], + project_id="p", + ) + + entered = threading.Event() + release = threading.Event() + active = 0 + maximum_active = 0 + state_lock = threading.Lock() + + class SerialChecks: + def run(self, tree: Path) -> CheckResult: + del tree + nonlocal active, maximum_active + with state_lock: + active += 1 + maximum_active = max(maximum_active, active) + entered.set() + assert release.wait(timeout=5) + with state_lock: + active -= 1 + return CheckResult(PASS) + + errors: list[BaseException] = [] + coordinator_a = PlanCoordinator(queue, "p", repo, checks=cast(Checks, SerialChecks())) + coordinator_b = PlanCoordinator(queue, "p", repo, checks=cast(Checks, SerialChecks())) + + def promote(item_id: str, branch: str, coordinator: PlanCoordinator) -> None: + try: + record = queue.get(item_id, project_id="p") + assert record is not None + coordinator.promote(record, item_branch=branch, base=initial.target_sha) + except BaseException as exc: + errors.append(exc) + + first = threading.Thread(target=promote, args=(*branches[0], coordinator_a)) + second = threading.Thread(target=promote, args=(*branches[1], coordinator_b)) + first.start() + assert entered.wait(timeout=5) + second.start() + assert not release.is_set() + release.set() + first.join(timeout=5) + second.join(timeout=5) + + assert not first.is_alive() and not second.is_alive() + assert not errors + assert maximum_active == 1 + assert git(repo, "show", "integration/p:a.txt") == "A" + assert git(repo, "show", "integration/p:b.txt") == "B" + + +def test_plan_promotion_lease_blocks_live_owner_and_allows_expiry_takeover( + tmp_path: Path, +) -> None: + clock = [100.0] + + def now() -> float: + return clock[0] + + path = str(tmp_path / "queue.sqlite") + first = WorkQueue(path, now=now) + second = WorkQueue(path, now=now) + + acquired, until = first.acquire_plan_promotion_lease("p", "first", 10.0) + assert acquired + assert until == 110.0 + blocked, current_until = second.acquire_plan_promotion_lease("p", "second", 10.0) + assert not blocked + assert current_until == until + assert first.renew_plan_promotion_lease("p", "first", 10.0) + + clock[0] = 111.0 + acquired, until = second.acquire_plan_promotion_lease("p", "second", 10.0) + assert acquired + assert until == 121.0 + assert not first.release_plan_promotion_lease("p", "first") + assert second.release_plan_promotion_lease("p", "second") + + +def test_coordinator_waits_for_a_live_cross_process_promotion_owner( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + path = str(tmp_path / "queue.sqlite") + queue = WorkQueue(path) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + external = WorkQueue(path) + acquired, _ = external.acquire_plan_promotion_lease("p", "other-process", 10.0) + assert acquired + + sleep_calls: list[float] = [] + + def release_on_wait(seconds: float) -> None: + sleep_calls.append(seconds) + assert external.release_plan_promotion_lease("p", "other-process") + + coordinator = PlanCoordinator( + queue, + "p", + repo, + checks=Checks(), + promotion_wait_seconds=1.0, + promotion_sleep=release_on_wait, + ) + state = coordinator.ensure(target_branch="main", branch="integration/p") + + assert state.target_sha == git(repo, "rev-parse", "main") + assert sleep_calls + + +def test_dependent_admission_waits_for_plan_initialization( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project( + Project( + project_id="p", + name="p", + work_dir=str(repo), + plan_path=str(tmp_path / "PLAN.md"), + plan_branch="integration/p", + ) + ) + queue.add( + [ + WorkRecord("A", "A", state=DONE), + WorkRecord("B", "B", depends_on=["A"]), + ], + project_id="p", + ) + queue.set_control(RUNNING, project_id="p") + + assert queue.claim("worker", project_id="p") is None + readiness = queue.readiness("B", project_id="p") + assert readiness.ready is False + assert len(readiness.reasons) == 1 + assert readiness.reasons[0].kind == "plan_promotion" + assert readiness.reasons[0].evidence == "durable plan identity is absent" + + +def test_separate_processes_serialize_authoritative_promotion_gates(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue_path = str(tmp_path / "queue.sqlite") + queue = WorkQueue(queue_path) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + initial = PlanCoordinator(queue, "p", repo, checks=Checks()).ensure( + target_branch="main", branch="integration/p" + ) + branches: list[tuple[str, str]] = [] + for item_id, filename in (("A", "process-a.txt"), ("B", "process-b.txt")): + branch = f"harness/process-{item_id.lower()}" + git(repo, "branch", branch, initial.target_sha) + git(repo, "checkout", branch) + commit(repo, item_id, item_id + "\n", filename) + branches.append((item_id, branch)) + git(repo, "checkout", "main") + queue.add( + [WorkRecord(item_id, item_id, state=DONE, branch=branch) for item_id, branch in branches], + project_id="p", + ) + + context = multiprocessing.get_context() + first_entered = context.Event() + second_entered = context.Event() + release_first = context.Event() + second_blocked = context.Event() + allow_second_wait = context.Event() + + first = context.Process( + target=_promote_from_process, + args=( + queue_path, + str(repo), + branches[0][0], + branches[0][1], + first_entered, + second_entered, + release_first, + second_blocked, + allow_second_wait, + ), + ) + second = context.Process( + target=_promote_from_process, + args=( + queue_path, + str(repo), + branches[1][0], + branches[1][1], + first_entered, + second_entered, + release_first, + second_blocked, + allow_second_wait, + ), + ) + first.start() + try: + assert first_entered.wait(timeout=10) + second.start() + assert second_blocked.wait(timeout=10) + assert not second_entered.is_set() + release_first.set() + first.join(timeout=10) + assert first.exitcode == 0 + allow_second_wait.set() + second.join(timeout=10) + assert second.exitcode == 0 + finally: + release_first.set() + allow_second_wait.set() + first.join(timeout=10) + second.join(timeout=10) + if first.is_alive(): + first.terminate() + if second.is_alive(): + second.terminate() + + assert second_entered.is_set() + assert git(repo, "show", "integration/p:process-a.txt") == "A" + assert git(repo, "show", "integration/p:process-b.txt") == "B" + assert queue.latest_promotion("p", "A") is not None + assert queue.latest_promotion("p", "B") is not None + + +def test_target_move_conflict_leaves_existing_plan_and_projection_unchanged( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + + item_branch = "harness/refresh-conflict-item" + git(repo, "branch", item_branch, initial.target_sha) + git(repo, "checkout", item_branch) + commit(repo, "item", "item\n") + git(repo, "checkout", "main") + queue.add( + [WorkRecord("A", "A", state=DONE, branch=item_branch)], + project_id="p", + ) + item = queue.get("A", project_id="p") + assert item is not None + coordinator.promote(item, item_branch=item_branch, base=initial.target_sha) + before = coordinator.state() + plan_file_before = git(repo, "show", "integration/p:value.txt") + + commit(repo, "target conflict", "target\n") + with pytest.raises(PromotionConflict): + coordinator.ensure(target_branch="main", branch="integration/p") + + after = coordinator.state() + assert after == before + assert git(repo, "rev-parse", "integration/p") == before.head_sha + assert git(repo, "show", "integration/p:value.txt") == plan_file_before + conn = queue._connect() + try: + row = conn.execute( + "SELECT status, detail FROM plan_refreshes " + "WHERE project_id = ? ORDER BY refresh_id DESC LIMIT 1", + ("p",), + ).fetchone() + finally: + conn.close() + assert row is not None and row["status"] == "conflict" + assert row["detail"] + + +def test_restart_recovers_refresh_after_git_ref_advanced(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + target_sha = commit(repo, "target moved", "target\n", "target.txt") + + with tempfile.TemporaryDirectory(prefix="harness-test-refresh-", dir=repo.parent) as temp: + tree = Path(temp) + git(repo, "worktree", "add", "--detach", str(tree), target_sha) + try: + new_head = git(repo, "rev-parse", "HEAD") + finally: + git(repo, "worktree", "remove", "--force", str(tree)) + git(repo, "worktree", "prune") + refresh_id = queue.begin_refresh( + "p", initial.target_sha, target_sha, initial.head_sha, new_head + ) + git(repo, "update-ref", "refs/heads/integration/p", new_head, initial.head_sha) + restarted = PlanCoordinator( + WorkQueue(str(tmp_path / "queue.sqlite")), "p", repo, checks=Checks() + ) + recovered = restarted.ensure(target_branch="main", branch="integration/p") + + assert recovered.target_sha == target_sha + assert recovered.head_sha == new_head + conn = queue._connect() + try: + row = conn.execute( + "SELECT status FROM plan_refreshes WHERE refresh_id = ?", (refresh_id,) + ).fetchone() + finally: + conn.close() + assert row is not None and row["status"] == "refreshed" + + +def test_restart_abandons_refresh_when_git_ref_did_not_move(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + git(repo, "branch", "future", initial.target_sha) + git(repo, "checkout", "future") + target_sha = commit(repo, "future target", "future\n", "future.txt") + git(repo, "checkout", "main") + refresh_id = queue.begin_refresh("p", initial.target_sha, target_sha, initial.head_sha, "") + + restarted = PlanCoordinator( + WorkQueue(str(tmp_path / "queue.sqlite")), "p", repo, checks=Checks() + ) + recovered = restarted.ensure(target_branch="main", branch="integration/p") + + assert recovered == initial + conn = queue._connect() + try: + row = conn.execute( + "SELECT status FROM plan_refreshes WHERE refresh_id = ?", (refresh_id,) + ).fetchone() + finally: + conn.close() + assert row is not None and row["status"] == "abandoned" + + +def test_promotion_recovers_pending_refresh_before_continuing(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + target_sha = commit(repo, "target moved", "target\n", "target.txt") + + refresh_id = queue.begin_refresh( + "p", initial.target_sha, target_sha, initial.head_sha, target_sha + ) + git(repo, "update-ref", "refs/heads/integration/p", target_sha, initial.head_sha) + item_branch = "harness/recover-before-promote" + git(repo, "branch", item_branch, target_sha) + git(repo, "checkout", item_branch) + commit(repo, "item", "item\n", "item.txt") + git(repo, "checkout", "main") + queue.add([WorkRecord("A", "A", state=DONE, branch=item_branch)], project_id="p") + item = queue.get("A", project_id="p") + assert item is not None + + coordinator.promote(item, item_branch=item_branch, base=target_sha) + + conn = queue._connect() + try: + refresh = conn.execute( + "SELECT status FROM plan_refreshes WHERE refresh_id = ?", (refresh_id,) + ).fetchone() + finally: + conn.close() + assert refresh is not None and refresh["status"] == "refreshed" + assert git(repo, "show", "integration/p:item.txt") == "item" + + +def test_refresh_gate_failure_closes_refresh_journal(tmp_path: Path) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks()) + initial = coordinator.ensure(target_branch="main", branch="integration/p") + item_branch = "harness/refresh-gate-failure" + git(repo, "branch", item_branch, initial.target_sha) + git(repo, "checkout", item_branch) + commit(repo, "item", "item\n", "item.txt") + git(repo, "checkout", "main") + queue.add([WorkRecord("A", "A", state=DONE, branch=item_branch)], project_id="p") + item = queue.get("A", project_id="p") + assert item is not None + coordinator.promote(item, item_branch=item_branch, base=initial.target_sha) + + commit(repo, "target moved", "target\n", "target.txt") + coordinator.checks = Checks(commands=[["false"]]) + with pytest.raises(PromotionError, match="failed"): + coordinator.ensure(target_branch="main", branch="integration/p") + + refreshes = queue.in_progress_refreshes("p") + assert refreshes == [] + conn = queue._connect() + try: + row = conn.execute( + "SELECT status FROM plan_refreshes " + "WHERE project_id = ? ORDER BY refresh_id DESC LIMIT 1", + ("p",), + ).fetchone() + finally: + conn.close() + assert row is not None and row["status"] == "gates_failed" + + +def test_promotion_conflict_returns_item_to_work_without_consuming_attempt( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project(project_id="p", name="p", work_dir=str(repo))) + queue.add([WorkRecord("A", "A", brief="change the repository")], project_id="p") + queue.set_control("running", project_id="p") + runner = FixtureRunner() + statuses: list[tuple[str, str, str]] = [] + + def plan_base_for(_: WorkRecord) -> tuple[str, str | None]: + return "main", "fixture plan head" + + def plan_promote(record: WorkRecord, branch: str, base: str) -> tuple[str, str]: + statuses.append((record.item_id, branch, base)) + return "conflict", "plan branch 'integration/p' is at exact-head; repair against it" + + executor = Executor( + queue, + fixture_client(), + repo, + checks=Checks(), + role_runner=runner, + push=False, + project_id="p", + plan_base_for=plan_base_for, + plan_promote=plan_promote, + ) + + outcome = executor.run_once() + + assert outcome is not None + assert outcome.state == "pending" + assert outcome.stop is not None + assert outcome.stop.disposition == "withheld" + assert outcome.stop.reason_kind == "plan_promotion_conflict" + assert "exact-head" in outcome.reason + record = queue.get("A", project_id="p") + assert record is not None + assert record.state == "pending" + assert record.attempts == 0 + assert statuses == [("A", "harness/a", git(repo, "rev-parse", "main"))] + + +class FixtureEnvironmentFactory: + name = "fixture-host" + api_version = 1 + version = "test" + + def check(self) -> tuple[bool, str]: + return True, "fixture environment available" + + def create(self, worktree: Path, **_: Any) -> LocalExecutionEnvironment: + return LocalExecutionEnvironment(worktree) + + +class FixtureRunner: + name = "fixture-runner" + api_version = 1 + version = "test" + + def __init__(self) -> None: + self.seen: dict[str, tuple[str, ...]] = {} + + def run(self, request: Any) -> RoleRunResult: + visible = tuple(sorted(path.name for path in request.repo.iterdir())) + self.seen[request.item_id] = visible + if request.item_id in {"A", "B"}: + (request.repo / f"{request.item_id.lower()}.txt").write_text(request.item_id + "\n") + else: + (request.repo / "dependent.txt").write_text("\n".join(visible) + "\n") + return RoleRunResult(exit_status="completed", submission="fixture", calls=1) + + +def fixture_client() -> ModelClient: + def transport( + route: Route, messages: Sequence[Mapping[str, Any]], options: Mapping[str, Any] + ) -> Response: + del messages, options + role = str(route.options.get("role") or "") + if role == "planner": + content = json.dumps( + { + "plan": "change the fixture repository", + "targets": [{"path": "base.txt", "reason": "fixture context"}], + "cannot_identify_target": None, + } + ) + else: + content = "APPROVED\nfixture review" + return Response(200, {}, json.dumps({"choices": [{"message": {"content": content}}]})) + + return ModelClient( + roles={ + role: Route("fixture", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + sleep=lambda _seconds: None, + ) + + +def test_fleet_promotes_two_independent_items_before_the_dependent_item( + tmp_path: Path, +) -> None: + repo = make_repo(tmp_path) + plan_file = tmp_path / "PLAN.md" + plan_file.write_text("# fixture plan\n") + queue = WorkQueue(str(tmp_path / "queue.sqlite"), lease_seconds=100.0) + queue.add_project( + Project( + project_id="p", + name="p", + work_dir=str(repo), + base_branch="main", + checks=["true"], + plan_path=str(plan_file), + plan_branch="integration/p", + max_workers=2, + ) + ) + queue.add( + [ + WorkRecord("A", "A", brief="add A"), + WorkRecord("B", "B", brief="add B"), + WorkRecord("C", "C", brief="use A and B", depends_on=["A", "B"]), + ], + project_id="p", + ) + runner = FixtureRunner() + from agent_harness.runtime import direct_executor_factory + + fleet = Fleet( + queue, + direct_executor_factory( + queue, + reviewer=fixture_client(), + role_runner=runner, + push=False, + environment_factory=FixtureEnvironmentFactory(), + environment_image="fixture-image", + ), + poll_seconds=0.01, + ) + try: + fleet.start("p") + + deadline = time.time() + 15 + while time.time() < deadline: + records = [queue.get(item, project_id="p") for item in ("A", "B", "C")] + if all(record is not None and record.state == DONE for record in records): + break + time.sleep(0.02) + else: + states = { + item: ( + (record.state, record.last_error) + if (record := queue.get(item, project_id="p")) + else "missing" + ) + for item in ("A", "B", "C") + } + raise AssertionError( + f"fixture plan did not finish: {states}; failures={fleet.failures()}" + ) + finally: + fleet.stop_all() + + assert {"a.txt", "b.txt"} <= set(runner.seen["C"]) + assert git(repo, "show", "integration/p:a.txt") == "A" + assert git(repo, "show", "integration/p:b.txt") == "B" + dependent = git(repo, "show", "integration/p:dependent.txt") + assert "a.txt" in dependent and "b.txt" in dependent + promotions = [queue.latest_promotion("p", item) for item in ("A", "B", "C")] + assert all(promotion is not None for promotion in promotions) + + +class FakePullRequests: + """A pull-request client that records what a real one would have done.""" + + def __init__(self) -> None: + self.created: list[dict[str, str]] = [] + self.comments: list[tuple[str, str]] = [] + self.url: str | None = None + + def create_pr(self, *, title: str, body: str, head: str, base: str, draft: bool = False) -> str: + self.created.append({"title": title, "head": head, "base": base, "body": body}) + self.url = f"https://example.invalid/pr/{len(self.created)}" + return self.url + + def find_open_pr(self, head: str) -> str | None: + del head + return self.url + + def comment_pr(self, pr: str, body: str) -> None: + self.comments.append((pr, body)) + + +def _await(condition: Any, fleet: Any, what: str, seconds: float = 20.0) -> None: + deadline = time.time() + seconds + while time.time() < deadline: + if condition(): + return + time.sleep(0.02) + raise AssertionError(f"{what} did not happen; failures={fleet.failures()}") + + +def test_fleet_publishes_one_plan_pr_only_when_the_plan_is_finished(tmp_path: Path) -> None: + """The whole point of P7/P8, exercised through the executor factory. + + Nothing is published while an item is still in flight; the plan branch — + never an item branch — reaches the remote once; and a correction added + afterwards updates that same pull request instead of opening a second. + """ + repo = make_repo(tmp_path) + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], check=True) + git(repo, "remote", "add", "origin", str(remote)) + git(repo, "push", "origin", "main") + plan_file = tmp_path / "PLAN.md" + plan_file.write_text("# fixture plan\n") + queue = WorkQueue(str(tmp_path / "queue.sqlite"), lease_seconds=100.0) + queue.add_project( + Project( + project_id="p", + name="fixture", + repo="acme/widgets", + work_dir=str(repo), + base_branch="main", + checks=["true"], + plan_path=str(plan_file), + plan_branch="integration/p", + max_workers=1, + ) + ) + queue.add( + [WorkRecord("A", "A", brief="add A"), WorkRecord("B", "B", brief="add B")], + project_id="p", + ) + github = FakePullRequests() + from agent_harness.runtime import direct_executor_factory + + fleet = Fleet( + queue, + direct_executor_factory( + queue, + reviewer=fixture_client(), + role_runner=FixtureRunner(), + push=True, + github_for=lambda _repo: github, + environment_factory=FixtureEnvironmentFactory(), + environment_image="fixture-image", + ), + poll_seconds=0.01, + ) + try: + fleet.start("p") + _await( + lambda: all( + (record := queue.get(item, project_id="p")) is not None and record.state == DONE + for item in ("A", "B") + ), + fleet, + "the fixture plan finished", + ) + _await(lambda: bool(github.created), fleet, "the plan was published") + + assert len(github.created) == 1 + assert github.created[0]["head"] == "integration/p" + assert github.created[0]["base"] == "main" + # P8: the one pull request carries the item evidence. + assert "`A`" in github.created[0]["body"] and "`B`" in github.created[0]["body"] + published = git(remote, "rev-parse", "integration/p") + assert published == git(repo, "rev-parse", "integration/p") + # No item branch was ever pushed. + assert "harness/" not in git(remote, "branch", "--list") + + queue.add([WorkRecord("R", "R", brief="correction")], project_id="p") + _await( + lambda: (record := queue.get("R", project_id="p")) is not None and record.state == DONE, + fleet, + "the correction finished", + ) + _await(lambda: bool(github.comments), fleet, "the existing pull request was updated") + finally: + fleet.stop_all() + + assert len(github.created) == 1 # still exactly one, after the correction + assert git(remote, "rev-parse", "integration/p") == git(repo, "rev-parse", "integration/p") + assert git(remote, "rev-parse", "integration/p") != published diff --git a/tests/test_plan_publication.py b/tests/test_plan_publication.py new file mode 100644 index 0000000..3d44449 --- /dev/null +++ b/tests/test_plan_publication.py @@ -0,0 +1,371 @@ +"""One plan branch, one pull request, and a correction that updates it. + +The remote here is a local bare repository and a fake pull-request client, so +these tests exercise the real push and the real record without contacting +GitHub. What is under test is the property the product requires: publishing +repeatedly never produces a second pull request, and a plan head that did not +move never touches the remote at all. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from agent_harness.plan_integration import PlanState +from agent_harness.plan_publication import PlanPublisher, PublicationError +from agent_harness.work import FAILED, Project, WorkQueue, WorkRecord + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +def make_repo(tmp_path: Path) -> tuple[Path, Path]: + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], check=True) + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-b", "main") + git(repo, "config", "user.email", "test@example.invalid") + git(repo, "config", "user.name", "Test") + (repo / "value.txt").write_text("base\n") + git(repo, "add", "value.txt") + git(repo, "commit", "-m", "base") + git(repo, "remote", "add", "origin", str(remote)) + git(repo, "push", "origin", "main") + git(repo, "branch", "plan/one") + return repo, remote + + +class FakePRs: + """A pull-request client that records what it was asked to do.""" + + def __init__(self, open_pr: str | None = None) -> None: + self.open_pr = open_pr + self.created: list[dict[str, object]] = [] + self.comments: list[tuple[str, str]] = [] + + def create_pr(self, *, title: str, body: str, head: str, base: str, draft: bool = False) -> str: + self.created.append({"title": title, "head": head, "base": base, "draft": draft}) + self.open_pr = f"https://example.invalid/pr/{len(self.created)}" + return self.open_pr + + def find_open_pr(self, head: str) -> str | None: + return self.open_pr + + def comment_pr(self, pr: str, body: str) -> None: + self.comments.append((pr, body)) + + +def advance(repo: Path, message: str) -> str: + """Promote something onto the plan branch, as the coordinator would.""" + git(repo, "checkout", "plan/one") + (repo / "value.txt").write_text(message + "\n") + git(repo, "add", "value.txt") + git(repo, "commit", "-m", message) + head = git(repo, "rev-parse", "HEAD") + git(repo, "checkout", "main") + return head + + +def state_for(repo: Path) -> PlanState: + return PlanState( + project_id="p", + branch="plan/one", + target_branch="main", + target_sha=git(repo, "rev-parse", "main"), + head_sha=git(repo, "rev-parse", "plan/one"), + plan_digest="digest", + ) + + +def publisher(tmp_path: Path, repo: Path, github: FakePRs, **kwargs: object) -> PlanPublisher: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("p", "project")) + return PlanPublisher(queue, "p", repo, github, **kwargs) # type: ignore[arg-type] + + +def test_the_first_publication_pushes_the_branch_and_opens_one_pr(tmp_path: Path) -> None: + repo, remote = make_repo(tmp_path) + head = advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + + result = api.publish(state_for(repo), title="Plan one", body="items") + + assert result.status == "created" + assert github.created == [ + {"title": "Plan one", "head": "plan/one", "base": "main", "draft": False} + ] + assert git(remote, "rev-parse", "plan/one") == head + assert api.record.pr_url == result.pr_url and api.record.head_sha == head + + +def test_republishing_an_unchanged_head_touches_nothing(tmp_path: Path) -> None: + repo, remote = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + first = api.publish(state_for(repo), title="Plan one", body="items") + + again = api.publish(state_for(repo), title="Plan one", body="items", summary="note") + + assert again.status == "unchanged" + assert again.pr_url == first.pr_url + assert github.created == [github.created[0]] # still exactly one + assert github.comments == [] + + +def test_a_correction_updates_the_same_pr_rather_than_opening_another(tmp_path: Path) -> None: + repo, remote = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + first = api.publish(state_for(repo), title="Plan one", body="items") + corrected = advance(repo, "correction") + + result = api.publish( + state_for(repo), + title="Plan one", + body="items", + summary="correction for T1 promoted", + ) + + assert result.status == "updated" + assert result.pr_url == first.pr_url + assert len(github.created) == 1 + assert github.comments == [(first.pr_url, "correction for T1 promoted")] + assert git(remote, "rev-parse", "plan/one") == corrected + assert api.record.head_sha == corrected + + +def test_an_existing_remote_pr_is_adopted_instead_of_duplicated(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs(open_pr="https://example.invalid/pr/existing") + api = publisher(tmp_path, repo, github) + + result = api.publish(state_for(repo), title="Plan one", body="items") + + assert (result.status, result.pr_url) == ("updated", "https://example.invalid/pr/existing") + assert github.created == [] + + +def test_an_adopted_branch_this_plan_already_contains_is_published(tmp_path: Path) -> None: + """A lost publication record must not strand an existing pull request.""" + repo, remote = make_repo(tmp_path) + published = advance(repo, "first") + git(repo, "push", "origin", "plan/one") + github = FakePRs(open_pr="https://example.invalid/pr/existing") + api = publisher(tmp_path, repo, github) + assert api.record.head_sha is None # nothing durable explains the remote + corrected = advance(repo, "correction") + + result = api.publish(state_for(repo), title="Plan one", body="items") + + assert result.status == "updated" + assert git(remote, "rev-parse", "plan/one") == corrected + assert published != corrected + + +def test_an_unexplained_remote_branch_is_not_discarded(tmp_path: Path) -> None: + repo, remote = make_repo(tmp_path) + other = tmp_path / "other" + subprocess.run(["git", "clone", str(remote), str(other)], check=True, capture_output=True) + git(other, "config", "user.email", "other@example.invalid") + git(other, "config", "user.name", "Other") + git(other, "checkout", "-b", "plan/one") + (other / "value.txt").write_text("theirs\n") + git(other, "add", "value.txt") + git(other, "commit", "-m", "theirs") + theirs = git(other, "rev-parse", "HEAD") + git(other, "push", "origin", "plan/one") + advance(repo, "ours") + github = FakePRs(open_pr="https://example.invalid/pr/existing") + api = publisher(tmp_path, repo, github) + + with pytest.raises(PublicationError, match="does not contain"): + api.publish(state_for(repo), title="Plan one", body="items") + assert git(remote, "rev-parse", "plan/one") == theirs + + +def test_a_rebuilt_plan_branch_still_publishes_under_the_lease(tmp_path: Path) -> None: + """A moved target rebuilds the plan, so its history is rewritten.""" + repo, remote = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + api.publish(state_for(repo), title="Plan one", body="items") + + git(repo, "checkout", "plan/one") + git(repo, "reset", "--hard", "main") + rebuilt = advance(repo, "replayed") + + result = api.publish(state_for(repo), title="Plan one", body="items") + + assert result.status == "updated" + assert git(remote, "rev-parse", "plan/one") == rebuilt + + +def test_a_branch_moved_by_somebody_else_is_refused(tmp_path: Path) -> None: + repo, remote = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + api.publish(state_for(repo), title="Plan one", body="items") + + # Another clone advances the published branch behind this harness's back. + other = tmp_path / "other" + subprocess.run(["git", "clone", str(remote), str(other)], check=True, capture_output=True) + git(other, "config", "user.email", "other@example.invalid") + git(other, "config", "user.name", "Other") + git(other, "checkout", "plan/one") + (other / "value.txt").write_text("theirs\n") + git(other, "add", "value.txt") + git(other, "commit", "-m", "theirs") + theirs = git(other, "rev-parse", "HEAD") + git(other, "push", "origin", "plan/one") + advance(repo, "ours") + + with pytest.raises(PublicationError, match="could not publish"): + api.publish(state_for(repo), title="Plan one", body="items") + assert git(remote, "rev-parse", "plan/one") == theirs + + +def test_publishing_a_second_branch_for_one_plan_is_refused(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + api.publish(state_for(repo), title="Plan one", body="items") + + other = PlanState("p", "plan/two", "main", "sha", "sha", "digest") + with pytest.raises(PublicationError, match="second"): + api.publish(other, title="Plan two", body="items") + assert github.created == [github.created[0]] + + +def test_an_unreadable_remote_listing_never_creates_a_duplicate(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + + class Broken(FakePRs): + def find_open_pr(self, head: str) -> str | None: + raise RuntimeError("gh unreachable") + + github = Broken() + api = publisher(tmp_path, repo, github) + + with pytest.raises(PublicationError, match="already has a pull"): + api.publish(state_for(repo), title="Plan one", body="items") + assert github.created == [] + + +def test_a_failed_comment_does_not_lose_the_published_head(tmp_path: Path) -> None: + repo, remote = make_repo(tmp_path) + advance(repo, "first") + + class Mute(FakePRs): + def comment_pr(self, pr: str, body: str) -> None: + raise RuntimeError("comment rejected") + + github = Mute() + api = publisher(tmp_path, repo, github) + api.publish(state_for(repo), title="Plan one", body="items") + corrected = advance(repo, "correction") + + result = api.publish(state_for(repo), title="Plan one", body="items", summary="note") + + assert result.status == "updated" + assert api.record.head_sha == corrected + assert git(remote, "rev-parse", "plan/one") == corrected + + +def test_publication_is_reported_as_one_event(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + seen: list[dict[str, object]] = [] + api = publisher(tmp_path, repo, FakePRs(), on_event=seen.append) + + api.publish(state_for(repo), title="Plan one", body="items") + + assert [event["outcome"] for event in seen] == ["plan_published"] + assert seen[0]["status"] == "created" and seen[0]["branch"] == "plan/one" + + +def test_an_unreadable_record_is_reported_rather_than_overwritten(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + api.queue.set_setting(api.setting_key, "{not json") + + with pytest.raises(PublicationError, match="unreadable"): + api.publish(state_for(repo), title="Plan one", body="items") + assert github.created == [] + + +def test_readiness_waits_for_work_that_could_still_change_the_tree(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + api = publisher(tmp_path, repo, FakePRs()) + api.queue.add([WorkRecord("A", "A"), WorkRecord("B", "B")], project_id="p") + + assert api.readiness().ready is False + assert "in flight" in api.readiness().detail + assert api.publish_if_ready(state_for(repo), title="Plan one") is None + + +def test_readiness_withholds_publication_when_an_item_did_not_deliver(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + api.queue.add([WorkRecord("A", "A")], project_id="p") + api.queue.set_control("running", project_id="p") + claimed = api.queue.claim("worker", project_id="p") + assert claimed is not None + api.queue.release("A", FAILED, owner="worker", project_id="p") + + readiness = api.readiness() + + assert readiness.ready is False and readiness.unresolved == 1 + assert "need a person" in readiness.detail + assert api.publish_if_ready(state_for(repo), title="Plan one") is None + assert github.created == [] + + +def test_the_promoting_item_does_not_block_its_own_plan(tmp_path: Path) -> None: + """The last item is still claimed while it promotes; its work is in already.""" + repo, _ = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + api.queue.add([WorkRecord("A", "A")], project_id="p") + api.queue.set_control("running", project_id="p") + assert api.queue.claim("worker", project_id="p") is not None + + assert api.readiness().ready is False + assert api.readiness(excluding="A").ready is True + + result = api.publish_if_ready(state_for(repo), title="Plan one", excluding="A") + + assert result is not None and result.status == "created" + + +def test_an_empty_plan_is_not_something_to_publish(tmp_path: Path) -> None: + repo, _ = make_repo(tmp_path) + advance(repo, "first") + github = FakePRs() + api = publisher(tmp_path, repo, github) + + assert api.readiness().detail == "the plan has no items" + assert api.publish_if_ready(state_for(repo), title="Plan one") is None + assert github.created == [] diff --git a/tests/test_review_events.py b/tests/test_review_events.py new file mode 100644 index 0000000..3f8b8b3 --- /dev/null +++ b/tests/test_review_events.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import json +import subprocess +import time +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient + +from agent_harness.api import create_api +from agent_harness.audit import AuditStore +from agent_harness.events import WORK +from agent_harness.events import Event as HarnessEvent +from agent_harness.execution_environment import LocalExecutionEnvironment +from agent_harness.executor import Checks +from agent_harness.fleet import Fleet +from agent_harness.holds import Answer +from agent_harness.model_client import ModelClient, Response, Route +from agent_harness.notifications import NotificationOutbox +from agent_harness.plan_integration import PlanCoordinator +from agent_harness.query_service import HarnessQueries +from agent_harness.review_events import RemoteReviewEvent, ReviewEventProcessor +from agent_harness.role_runners import RoleRunResult +from agent_harness.runtime import direct_executor_factory +from agent_harness.store import EventStore +from agent_harness.work import DONE, HELD, PENDING, Project, WorkQueue, WorkRecord + + +def queue_for(tmp_path: Path) -> WorkQueue: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("p", "project")) + queue.add([WorkRecord("T1", "original", brief="the original")], project_id="p") + return queue + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, check=True + ) + return result.stdout.strip() + + +def repository(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init", "-q", "-b", "main") + git(repo, "config", "user.email", "test@example.invalid") + git(repo, "config", "user.name", "Test") + (repo / "seed.txt").write_text("seed\n") + git(repo, "add", "seed.txt") + git(repo, "commit", "-qm", "base") + return repo + + +class LocalBackend: + name = "review-test-backend" + api_version = 1 + version = "test" + + def check(self) -> tuple[bool, str]: + return True, "available" + + def create(self, worktree: Path, **_: Any) -> LocalExecutionEnvironment: + return LocalExecutionEnvironment(worktree) + + +class ReviewCorrectionRunner: + name = "review-correction-runner" + api_version = 1 + version = "test" + + def __init__(self) -> None: + self.items: list[str] = [] + + def run(self, request: Any) -> RoleRunResult: + self.items.append(request.item_id) + (request.repo / f"{request.item_id}.txt").write_text(request.item_id + "\n") + return RoleRunResult(exit_status="completed", submission="done", calls=1) + + +def review_client() -> ModelClient: + def transport( + route: Route, messages: Sequence[Mapping[str, Any]], options: Mapping[str, Any] + ) -> Response: + del messages, options + role = str(route.options.get("role") or "") + if role == "planner": + content = json.dumps( + { + "plan": "write an item marker", + "targets": [{"path": "seed.txt", "reason": "item context"}], + "cannot_identify_target": None, + } + ) + else: + content = "APPROVED\nlocal review" + return Response( + 200, + {}, + json.dumps({"choices": [{"message": {"content": content}}]}), + ) + + return ModelClient( + roles={ + role: Route("review-test", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + sleep=lambda _: None, + ) + + +def wait_for(predicate: Any, timeout: float = 15.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + +def event(disposition: str) -> RemoteReviewEvent: + return RemoteReviewEvent( + source="test-review", + remote_id="comment-7", + project_id="p", + item_id="T1", + disposition=disposition, # type: ignore[arg-type] + summary="Please handle the reported issue.", + ) + + +def test_actionable_review_is_deduplicated_and_waits_for_original(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + processor = ReviewEventProcessor(queue) + + first = processor.process(event("actionable")) + second = processor.process(event("actionable")) + + assert first.accepted and not first.duplicate + assert second.duplicate and not second.accepted + assert first.correction_item_id is not None + correction = queue.get(first.correction_item_id, project_id="p") + assert correction is not None + assert correction.state == PENDING + assert correction.depends_on == ["T1"] + assert queue.items(project_id="p").count(correction) == 1 + + +def test_ambiguous_review_never_becomes_agent_work(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + result = ReviewEventProcessor(queue).process(event("ambiguous")) + + assert result.status == "needs_human" + assert result.correction_item_id is not None + correction = queue.get(result.correction_item_id, project_id="p") + assert correction is not None + assert correction.state == HELD + hold = queue.holds.current("p", result.correction_item_id) + assert hold is not None + queue.answer_hold( + result.correction_item_id, + hold.resume_token, + Answer(text="Please address the specific security issue.", who="operator"), + project_id="p", + ) + correction = queue.get(result.correction_item_id, project_id="p") + assert correction is not None and correction.state == PENDING + + +def test_already_resolved_review_records_no_correction(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + result = ReviewEventProcessor(queue).process(event("already_resolved")) + + assert result.status == "already_resolved" + assert result.correction_item_id is None + assert len(queue.items(project_id="p")) == 1 + + +def test_review_event_api_is_typed_and_idempotent(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + notifications = NotificationOutbox(tmp_path / "notifications.sqlite") + client = TestClient( + create_api( + EventStore(tmp_path / "events.sqlite"), + queue=queue, + audit=AuditStore(tmp_path / "audit.sqlite"), + notifications=notifications, + token="token", + ) + ) + payload = { + "source": "test-review", + "remote_id": "comment-api-1", + "project_id": "p", + "item_id": "T1", + "disposition": "actionable", + "summary": "Please address this API review.", + } + headers = {"Authorization": "Bearer token"} + first = client.post("/api/review-events", json=payload, headers=headers) + second = client.post("/api/review-events", json=payload, headers=headers) + assert first.status_code == 200 + assert first.json()["accepted"] is True + assert second.status_code == 200 + assert second.json()["duplicate"] is True + assert len(notifications.rows()) == 1 + assert notifications.rows()[0].payload["outcome"] == "remote_review_received" + notifications.close() + + +def test_item_evidence_projects_runner_gates_promotion_and_reviews(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + audit = AuditStore(tmp_path / "audit.sqlite") + audit.append( + [ + HarnessEvent( + ts=1.0, + kind=WORK, + source="runner", + worker="worker-1", + outcome="calling", + data={"project_id": "p", "item_id": "T1", "attempt": 2, "detail": "running"}, + ), + HarnessEvent( + ts=2.0, + kind=WORK, + source="runner", + worker="worker-1", + outcome="checks_passed", + data={ + "project_id": "p", + "item_id": "T1", + "detail": "all gates passed", + "evidence": { + "outcome": "pass", + "command": ["tool", "check", "--strict"], + "commands": [["tool", "check", "--strict"], ["tool", "test"]], + "applied": [], + }, + }, + ), + HarnessEvent( + ts=3.0, + kind=WORK, + source="promotion", + outcome="plan_promotion", + data={ + "project_id": "p", + "item_id": "T1", + "status": "promoted", + "plan_branch": "integration", + "base_sha": "base", + "item_sha": "item", + "old_head_sha": "old", + "new_head_sha": "new", + "target_sha": "target", + "detail": "promoted after authoritative checks", + }, + ), + HarnessEvent( + ts=4.0, + kind=WORK, + source="review-event", + outcome="remote_review_received", + data={ + "project_id": "p", + "item_id": "T1", + "source": "test-review", + "remote_id": "comment-7", + "disposition": "actionable", + "status": "queued", + "duplicate": False, + "correction_item_id": "review-123", + "detail": "queued correction work", + }, + ), + ] + ) + + evidence = HarnessQueries(EventStore(tmp_path / "events.sqlite"), queue, audit=audit).evidence( + "p", "T1" + ) + + assert evidence is not None + assert [(one.stage, one.attempt) for one in evidence.runner_progress] == [("calling", 2)] + assert evidence.gates[0].command == ["tool", "check", "--strict"] + assert evidence.gates[0].commands == [["tool", "check", "--strict"], ["tool", "test"]] + assert evidence.gates[0].authoritative is True + assert evidence.promotions[0].new_head_sha == "new" + assert evidence.remote_reviews[0].remote_id == "comment-7" + + +def test_actionable_review_runs_once_on_the_existing_local_plan_and_fleet_continues( + tmp_path: Path, +) -> None: + repo = repository(tmp_path) + plan = tmp_path / "PLAN.md" + plan.write_text("# local plan\n") + queue = WorkQueue(str(tmp_path / "queue.sqlite"), lease_seconds=100.0) + queue.add_project( + Project( + project_id="p", + name="p", + work_dir=str(repo), + plan_path=str(plan), + plan_branch="integration/p", + checks=["true"], + max_workers=2, + ) + ) + queue.add( + [ + WorkRecord("T1", "original"), + WorkRecord("B", "sibling", brief="Write the sibling marker."), + ], + project_id="p", + ) + + coordinator = PlanCoordinator(queue, "p", repo, checks=Checks(commands=[["true"]])) + coordinator.ensure(target_branch="main", branch="integration/p", plan_path=str(plan)) + target = git(repo, "rev-parse", "main") + git(repo, "branch", "harness/t1", target) + git(repo, "checkout", "harness/t1") + (repo / "original.txt").write_text("original\n") + git(repo, "add", "original.txt") + git(repo, "commit", "-qm", "original") + git(repo, "checkout", "main") + original = queue.get("T1", project_id="p") + assert original is not None + original.state = DONE + original.branch = "harness/t1" + queue.release("T1", DONE, branch="harness/t1", project_id="p") + coordinator.promote(original, item_branch="harness/t1", base=target) + + review = event("actionable") + review = RemoteReviewEvent( + source=review.source, + remote_id=review.remote_id, + project_id="p", + item_id="T1", + disposition=review.disposition, + summary=review.summary, + ) + processor = ReviewEventProcessor(queue) + first = processor.process(review) + duplicate = processor.process(review) + assert first.accepted and not first.duplicate + assert duplicate.duplicate and not duplicate.accepted + assert first.correction_item_id is not None + correction_id = first.correction_item_id + correction = queue.get(correction_id, project_id="p") + assert correction is not None and correction.state == PENDING + assert coordinator.base_for(correction) == ("integration/p", "local plan branch") + + runner = ReviewCorrectionRunner() + fleet = Fleet( + queue, + direct_executor_factory( + queue, + reviewer=review_client(), + role_runner=runner, + push=False, + environment_factory=LocalBackend(), + environment_image="test-image", + ), + poll_seconds=0.01, + ) + try: + fleet.start("p") + assert wait_for( + lambda: all( + (record := queue.get(item, project_id="p")) is not None and record.state == DONE + for item in (correction_id, "B") + ) + ) + finally: + fleet.stop_all() + + assert runner.items.count(correction_id) == 1 + assert runner.items.count("B") == 1 + correction_promotion = queue.latest_promotion("p", correction_id) + sibling_promotion = queue.latest_promotion("p", "B") + assert correction_promotion is not None + assert correction_promotion["status"] == "promoted" + assert sibling_promotion is not None and sibling_promotion["status"] == "promoted" + assert git(repo, "show", f"integration/p:{correction_id}.txt") == correction_id + assert git(repo, "show", "integration/p:B.txt") == "B" diff --git a/tests/test_review_sources.py b/tests/test_review_sources.py new file mode 100644 index 0000000..ad5c6c6 --- /dev/null +++ b/tests/test_review_sources.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from agent_harness.api import create_api +from agent_harness.review_events import RemoteReviewEvent +from agent_harness.review_sources import ReviewBatch, ReviewPoller +from agent_harness.store import EventStore +from agent_harness.work import PENDING, Project, WorkQueue, WorkRecord + + +def queue_for(tmp_path: Path) -> WorkQueue: + queue = WorkQueue(str(tmp_path / "queue.sqlite")) + queue.add_project(Project("p", "project")) + queue.add([WorkRecord("T1", "original")], project_id="p") + return queue + + +def review(remote_id: str) -> RemoteReviewEvent: + return RemoteReviewEvent( + source="configured-source", + remote_id=remote_id, + project_id="p", + item_id="T1", + disposition="actionable", + summary="Please update the implementation.", + ) + + +class Source: + name = "configured-source" + api_version = 1 + + def __init__(self, batches: list[ReviewBatch]) -> None: + self.batches = batches + self.cursors: list[str | None] = [] + + def poll(self, cursor: str | None) -> ReviewBatch: + self.cursors.append(cursor) + return self.batches.pop(0) + + +def test_poller_advances_cursor_only_after_the_batch_is_durable(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + source = Source([ReviewBatch((review("r1"),), next_cursor="cursor-1")]) + poller = ReviewPoller(queue, source) + + result = poller.poll_once() + + assert result.fetched == 1 + assert result.accepted == 1 + assert result.duplicates == 0 + assert poller.cursor == "cursor-1" + correction = queue.get(result.results[0].correction_item_id or "", project_id="p") + assert correction is not None and correction.state == PENDING + + +def test_poller_replay_is_safe_and_uses_the_saved_cursor(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + source = Source( + [ + ReviewBatch((review("r1"),), next_cursor="cursor-1"), + ReviewBatch((review("r1"),), next_cursor="cursor-2"), + ] + ) + poller = ReviewPoller(queue, source) + + first = poller.poll_once() + second = poller.poll_once() + + assert first.accepted == 1 + assert second.accepted == 0 + assert second.duplicates == 1 + assert source.cursors == [None, "cursor-1"] + assert poller.cursor == "cursor-2" + assert len(queue.items(project_id="p")) == 2 + + +def test_poller_leaves_cursor_unchanged_when_processing_fails(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + source = Source([ReviewBatch((review("r1"),), next_cursor="cursor-1")]) + + class FailingProcessor: + def process(self, event: RemoteReviewEvent) -> Any: + raise RuntimeError("normalization sink unavailable") + + poller = ReviewPoller(queue, source, processor=FailingProcessor()) # type: ignore[arg-type] + with pytest.raises(RuntimeError, match="sink unavailable"): + poller.poll_once() + assert poller.cursor is None + + +def test_api_poll_is_typed_and_requires_a_configured_source(tmp_path: Path) -> None: + queue = queue_for(tmp_path) + source = Source([ReviewBatch((review("api-r1"),), next_cursor="api-cursor")]) + poller = ReviewPoller(queue, source) + client = TestClient( + create_api( + EventStore(tmp_path / "events.sqlite"), + queue=queue, + review_poller=poller, + token="token", + ) + ) + + response = client.post("/api/review-poll", headers={"Authorization": "Bearer token"}) + + assert response.status_code == 200 + assert response.json()["accepted"] == 1 + assert response.json()["cursor"] == "api-cursor" diff --git a/tests/test_role_runner_e2e.py b/tests/test_role_runner_e2e.py index e441994..956bf6e 100644 --- a/tests/test_role_runner_e2e.py +++ b/tests/test_role_runner_e2e.py @@ -14,6 +14,7 @@ from agent_harness.adapters.minisweagent import RUNNER from agent_harness.audit import AuditStore from agent_harness.events import KINDS, MODEL_CALL, Event +from agent_harness.execution_environment import LocalExecutionEnvironment from agent_harness.executor import Checks, Executor from agent_harness.model_client import ModelClient, Response, Route from agent_harness.pricing import Price, PriceTable @@ -139,6 +140,35 @@ def repository(tmp_path: Path) -> Path: return repo +class RecordingEnvironmentFactory: + """A host-backed test seam for executor/worktree wiring only.""" + + name = "recording" + api_version = 1 + version = "test" + + def __init__(self) -> None: + self.worktree: Path | None = None + self.closed = False + self.git_is_self_contained = False + + def check(self) -> tuple[bool, str]: + return True, "test backend available" + + def create(self, worktree: Path, **_: Any) -> LocalExecutionEnvironment: + self.worktree = worktree + self.git_is_self_contained = (worktree / ".git").is_dir() + environment = LocalExecutionEnvironment(worktree) + original_close = environment.close + + def close() -> None: + self.closed = True + original_close() + + environment.close = close # type: ignore[method-assign] + return environment + + def test_loop_changes_feed_the_existing_checks_review_and_attempt_pipeline( tmp_path: Path, ) -> None: @@ -213,6 +243,80 @@ def test_loop_changes_feed_the_existing_checks_review_and_attempt_pipeline( assert "hello world" in final_messages, "the first observation did not reach later turns" +def test_selected_environment_gets_a_disposable_item_worktree( + tmp_path: Path, +) -> None: + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add([WorkRecord(item_id="T1", title="Change the greeting", brief="Change greeting.txt.")]) + transport = ScriptedLoop(("printf 'hello harness\\n' > greeting.txt", DONE_COMMAND)) + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + ) + factory = RecordingEnvironmentFactory() + + outcome = Executor( + queue, + client, + repo, + checks=Checks(), + role_runner=RUNNER, + push=False, + environment_factory=factory, + environment_image="test-image", + ).run_once() + + assert outcome is not None and outcome.state == DONE, outcome.reason if outcome else "missing" + assert factory.worktree is not None and factory.worktree != repo + assert factory.closed + assert factory.git_is_self_contained + assert not factory.worktree.exists() + assert git(repo, "show", "harness/t1:greeting.txt") == "hello harness\n" + assert str(factory.worktree) not in git(repo, "worktree", "list") + + +def test_selected_environment_reuses_and_reaps_a_stale_item_worktree( + tmp_path: Path, +) -> None: + repo = repository(tmp_path) + queue = make_queue(str(tmp_path / "queue.sqlite")) + queue.add([WorkRecord(item_id="T1", title="Change the greeting", brief="Change greeting.txt.")]) + transport = ScriptedLoop(("printf 'hello harness\\n' > greeting.txt", DONE_COMMAND)) + client = ModelClient( + roles={ + role: Route("scripted", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + ) + factory = RecordingEnvironmentFactory() + executor = Executor( + queue, + client, + repo, + checks=Checks(), + role_runner=RUNNER, + push=False, + environment_factory=factory, + environment_image="test-image", + ) + record = queue.get("T1") + assert record is not None + stale = executor._execution_tree_path(record) + stale.mkdir(parents=True) + (stale / "stale.txt").write_text("orphaned\n") + + outcome = executor.run_once() + + assert outcome is not None and outcome.state == DONE, outcome.reason if outcome else "missing" + assert not stale.exists() + assert not (stale / "stale.txt").exists() + + def test_loop_events_can_be_written_to_the_append_only_audit_sink(tmp_path: Path) -> None: repo = repository(tmp_path) queue = make_queue(str(tmp_path / "queue.sqlite")) diff --git a/tests/test_serve_fleet.py b/tests/test_serve_fleet.py index e0d4a66..758a5f6 100644 --- a/tests/test_serve_fleet.py +++ b/tests/test_serve_fleet.py @@ -28,6 +28,7 @@ from agent_harness.__main__ import _fleet_for_serve from agent_harness.api import ROLE_MAP_KEY, create_api from agent_harness.audit import AuditStore +from agent_harness.execution_environment import LocalExecutionEnvironment from agent_harness.fleet import Fleet from agent_harness.model_client import ( ModelClient, @@ -36,7 +37,11 @@ effective_routes, routes_from_map, ) -from agent_harness.runtime import NotExecutable, session_executor_factory +from agent_harness.runtime import ( + NotExecutable, + direct_executor_factory, + session_executor_factory, +) from agent_harness.session_executor import AgentSpec, SessionExecutor from agent_harness.session_host import IDLE, RUNNING, Session from agent_harness.store import EventStore @@ -267,6 +272,172 @@ def test_a_project_with_no_checkout_is_refused_at_build_time(tmp_path: Path) -> build("p") +class LocalFleetBackend: + name = "test-local" + api_version = 1 + version = "test" + + def __init__(self) -> None: + self.created: list[Path] = [] + + def check(self) -> tuple[bool, str]: + return True, "test execution backend available" + + def create(self, worktree: Path, **_: Any) -> LocalExecutionEnvironment: + self.created.append(worktree) + return LocalExecutionEnvironment(worktree) + + +class LocalRoleRunner: + name = "test-runner" + api_version = 1 + version = "test" + + def __init__(self) -> None: + self.repositories: list[Path] = [] + + def run(self, request: Any) -> Any: + self.repositories.append(request.repo) + (request.repo / f"{request.item_id}.txt").write_text(request.item_id + "\n") + return type("Result", (), {"exit_status": "completed", "submission": "done", "calls": 1})() + + +def local_client() -> ModelClient: + def transport( + route: Route, messages: Sequence[Mapping[str, Any]], options: Mapping[str, Any] + ) -> Response: + del messages, options + role = str(route.options.get("role") or "") + if role == "planner": + content = json.dumps( + { + "plan": "write the item marker", + "targets": [{"path": "calc.py", "reason": "item context"}], + "cannot_identify_target": None, + } + ) + else: + content = "APPROVED\nlocal test" + return Response(200, {}, json.dumps({"choices": [{"message": {"content": content}}]})) + + return ModelClient( + roles={ + role: Route("test-model", "https://example.invalid", options={"role": role}) + for role in ("planner", "implementer", "reviewer") + }, + transport=transport, + sleep=lambda _seconds: None, + ) + + +def test_local_factory_runs_two_items_in_separate_environment_worktrees( + repo: Path, tmp_path: Path +) -> None: + """The in-process fleet owns execution without sharing a mutable checkout.""" + queue = WorkQueue(str(tmp_path / "w.sqlite"), lease_seconds=100.0) + queue.add_project( + Project( + project_id="p", + name="P", + work_dir=str(repo), + base_branch="main", + checks=["true"], + max_workers=2, + ) + ) + queue.add( + [ + WorkRecord(item_id="A", title="A", brief="Write marker A."), + WorkRecord(item_id="B", title="B", brief="Write marker B."), + ], + project_id="p", + ) + backend = LocalFleetBackend() + runner = LocalRoleRunner() + fleet = Fleet( + queue, + direct_executor_factory( + queue, + reviewer=local_client(), + role_runner=runner, + push=False, + environment_factory=backend, + environment_image="test-image", + ), + poll_seconds=0.01, + ) + + fleet.start("p") + + def is_finished() -> bool: + records = [queue.get(item, project_id="p") for item in ("A", "B")] + return all(record is not None and record.state == DONE for record in records) + + finished = wait_for(is_finished, timeout=15) + if not finished: + states = { + item: (record.state if (record := queue.get(item, project_id="p")) else "missing") + for item in ("A", "B") + } + raise AssertionError(f"local items did not finish: {states}; failures={fleet.failures()}") + fleet.stop_all() + + assert len(runner.repositories) == 2 + assert len(set(runner.repositories)) == 2 + assert len(backend.created) == 2 + assert all(path != repo and not path.exists() for path in backend.created) + assert git(repo, "status", "--porcelain") == "" + + +def test_local_preflight_names_environment_and_does_not_require_remote( + repo: Path, tmp_path: Path +) -> None: + queue = WorkQueue(str(tmp_path / "w.sqlite")) + queue.add_project(project_for(repo)) + queue.set_setting( + ROLE_MAP_KEY, + {"reviewer": {"model": "reviewer", "endpoint": "https://e", "provider": "generic"}}, + ) + store = EventStore(tmp_path / "e.sqlite") + fleet = Fleet(queue, lambda _project_id: object()) + + class Roles: + implemented_by = "" + + @staticmethod + def calls_role(_role: str) -> bool: + return True + + with TestClient( + create_api( + store, + queue=queue, + token=TOKEN, + fleet=fleet, + executor_roles=Roles(), + execution_environment=lambda: (True, "test-local available"), + remote_required=False, + probes={ + "git_probe": lambda _path: (True, "git"), + "clean_probe": lambda _path: (True, "clean"), + "base_probe": lambda _path, _base: (True, "current"), + "disk_probe": lambda _path, _floor: (True, "space"), + }, + ) + ) as client: + body = client.get("/api/readiness", headers=hdr()).json() + + assert body["mode"] == "local" + assert body["execution_environment"] == { + "configured": True, + "ok": True, + "detail": "test-local available", + } + assert body["projects"][0]["blockers"] == [] + assert body["projects"][0]["ready_to_start"] is True + assert not any(check["name"] == "github write" for check in body["projects"][0]["blockers"]) + + # ------------------------------------------------------- API start -> work