Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .agent-zero.example.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,18 @@ mode: observe
proactive:
enabled: false

# Turn scoped GitHub issues into verified pull requests. Opt-in twice: enable it here and label
# the issue with requireLabel. Verified changes are published on a fresh branchPrefix branch and
# opened as a pull request carrying acceptance criteria and evidence; the default branch is never
# committed to.
issues:
enabled: false
requireLabel: agent-zero
branchPrefix: agent-zero/
# Report the validation verdict back on the issue as a comment: confirmed with evidence, not
# confirmed with the rejection reasons, or inconclusive for a human. Report-only.
validationComment: true

# Commands used to verify a change. Leave empty to discover this repository's own
# lint, typecheck, test, and build scripts. Commands run without a shell, so
# operators such as &&, |, ;, and $() are rejected.
Expand Down
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,9 @@ AGENT_ZERO_MODEL=gpt-5
AGENT_ZERO_PORT=4040
GITHUB_TOKEN=
GITHUB_WEBHOOK_SECRET=
# Checkout the webhook route binds incoming events to. POST /webhooks/github fails closed
# (503, nothing ingested) until both this and GITHUB_WEBHOOK_SECRET are set.
AGENT_ZERO_CHECKOUT_PATH=

# Authentication adapter (apps/auth-server).
# Generate the secret with: openssl rand -base64 32
Expand Down
2 changes: 1 addition & 1 deletion .skills/agent-zero-architecture/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,7 @@ Keep dependency direction explicit while changing the monorepo.
- `shared`: stable contracts only, plus pure functions over them (evidence rendering, redaction, path predicates).
- `config`: configuration, repository policy, and check discovery. Pure; the agent supplies what it read through the runner.
- `models`: provider-independent model contracts and provider adapters.
- `github`: GitHub-specific translation, event parsing, and Checks API behavior.
- `github`: GitHub-specific translation, event parsing, Checks API behavior, and issue-to-PR publication (branch and pull-request creation through the Git data API).
- `runner`: command execution and checkout mutation boundary, plus the policy-to-boundary factory.
- `agent`: orchestration, the lifecycle machine, and the validation policy.
- `cli`: argument parsing and terminal presentation.
Expand Down
3 changes: 3 additions & 0 deletions .skills/agent-zero-safety/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,9 @@ Safety properties are behavior, not documentation. Back every change with determ
- Runner pools enforce active, per-repository, and lease-duration ceilings before provisioning and stop expired leases.
- Persistent task records omit review input and checkout paths, and recursively redact every string before storage.
- A reviewer's claim is not evidence. Reject what the repository does not support, and keep the reasons.
- An issue becomes a task only when `issues.enabled` is true and the issue carries the required label; issue text is untrusted input, and the run mode comes only from repository policy.
- A pull request is published only from a completed, verified issue run. `prepareIssuePullRequest` is the single publication gate; branches are created fresh under `issues.branchPrefix`, never force-updated, and the default branch is never committed to.
- The issue validation comment is composed by `prepareIssueValidationComment` from persisted evidence only, is report-only, never claims an unverified fix, and is skipped for a run that failed before reaching a verdict.

## Review workflow

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,13 +17,14 @@

## Overview

Agent Zero runs one trustworthy loop: ingest review feedback or inspect a pull-request diff proactively, validate the finding, apply a narrowly scoped policy-approved fix, run the repository's real checks, inspect the resulting diff, and produce evidence.
Agent Zero runs one trustworthy loop: ingest review feedback, inspect a pull-request diff proactively, or take on a scoped GitHub issue, validate the finding, apply a narrowly scoped policy-approved fix, run the repository's real checks, inspect the resulting diff, and produce evidence.

Feedback is never treated as truth merely because it came from a human or an AI reviewer.

- **Evidence over assertion** – every fix carries the commands that verified it.
- **Proactive, not speculative** – diff review reports the highest-priority finding only when checkout evidence supports it.
- **Confidence and impact gates** – automatic fixes require confidence, an allowed change-risk class, repository permission, and verification.
- **Issues become reviewable pull requests** – a labeled, repository-scoped issue can be investigated, implemented on an isolated branch, verified, and published as a pull request that carries its acceptance criteria and evidence; never as a direct commit.
- **`observe` by default** – the safe mode inspects and reports, and never writes to a target repository.
- **One execution boundary** – `packages/runner` is the only code allowed to run commands or mutate a checkout.
- **Adapters at the edges** – the runtime stays independent of HTTP, GitHub, terminal UI, and model providers.
Expand DownExpand Up@@ -219,7 +220,9 @@ model:
outputCostPerMillionTokens: 10
```

`observe` is the safe default and never writes files. Proactive pull-request webhooks are ignored until `proactive.enabled` is true. Automatic changes additionally require `mode: fix` or `autonomous`, `autofix.enabled`, sufficient confidence, an allowed change-risk class, repository-native checks, and (by default for proactive/autonomous work) an isolated runner. High-impact changes always require human approval.
`observe` is the safe default and never writes files. Proactive pull-request webhooks are ignored until `proactive.enabled` is true. Automatic changes additionally require `mode: fix` or `autonomous`, `autofix.enabled`, sufficient confidence, an allowed change-risk class, repository-native checks, and (by default for proactive, issue, or autonomous work) an isolated runner. High-impact changes always require human approval.

Issue-to-PR work is opt-in twice: `issues.enabled` must be true and the issue must carry the `issues.requireLabel` label, so arbitrary issue text can never start a run. Issue text is untrusted input for the runtime to validate — never instructions. The run first decides from repository evidence whether the issue actually reports a real problem, and (unless `issues.validationComment` is disabled) posts that verdict back on the issue: confirmed with its evidence, not confirmed with every rejection reason, or inconclusive for a human. A pull request is opened only when the run completed, its changes were applied, and every repository check passed. Verified changes are published to a fresh `issues.branchPrefix` branch (never force-updated, never the default branch), and the pull request body is the run's evidence: acceptance criteria, plan, checks, and lifecycle.

---

Expand Down
2 changes: 1 addition & 1 deletion apps/auth-server/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-zero/auth-server",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-zero/dashboard",
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"build": "nuxt build",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-zero/server",
"version": "0.3.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"build": "vite build",
Expand Down
54 changes: 54 additions & 0 deletions apps/server/server/routes/webhooks/github.post.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
import { redactSecrets } from '@agent-zero/shared';
import { defineHandler } from 'nitro';
import type { EventHandlerWithFetch } from 'nitro/h3';

import { githubTokenFromEnvironment, ingestWebhook } from '../../../src/router.js';
import { json, messageOf } from '../../utils/respond.js';
import { deliveryClaimStore, taskStore } from '../../utils/store.js';

/**
* The production GitHub webhook entry point at `POST /webhooks/github`.
*
* This route only adapts transport: it maps headers and body onto the webhook contract and
* injects the deployment's durable stores. Signature verification, policy checks, delivery
* claims, and everything that can execute repository work stay behind `ingestWebhook`, and the
* durable `deliveryClaimStore` is what lets a redelivered issue event observe the recorded
* outcome across restarts and other instances instead of starting a duplicate run. Without a
* configured secret or checkout the route fails closed and ingests nothing.
*/
const route: EventHandlerWithFetch = defineHandler(async (event) => {
try {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) return json(503, { error: 'GITHUB_WEBHOOK_SECRET is not configured' });
const checkoutPath = process.env.AGENT_ZERO_CHECKOUT_PATH;
if (!checkoutPath) return json(503, { error: 'AGENT_ZERO_CHECKOUT_PATH is not configured' });

const delivery = event.req.headers.get('x-github-delivery');
const outcome = await ingestWebhook(
{
event: event.req.headers.get('x-github-event') ?? '',
body: await event.req.text(),
signature: event.req.headers.get('x-hub-signature-256') ?? undefined,
...(delivery ? { delivery } : {}),
},
{
secret,
checkoutPath,
store: taskStore,
deliveryClaims: deliveryClaimStore,
github: { token: githubTokenFromEnvironment() },
},
);

// The response never carries the run's evidence or result; GitHub's delivery log only needs
// the disposition, and everything else is reachable through the authenticated control plane.
if (outcome.status === 'rejected') return json(400, { status: 'rejected' });
if (outcome.status === 'ignored')
return json(200, { status: 'ignored', reason: outcome.reason });
return json(200, { status: 'accepted', taskId: outcome.result.id });
} catch (error) {
return json(500, { error: redactSecrets(messageOf(error)) });
}
});

export default route;
24 changes: 20 additions & 4 deletions apps/server/server/utils/store.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import { kv } from 'vite-hub/kv';

import {
PersistentDeliveryClaimStore,
PersistentTaskStore,
type DeliveryClaimStore,
type KeyValueStorage,
type TaskStore,
} from '../../src/control-plane.js';
Expand DownExpand Up@@ -32,8 +34,22 @@ class KvKeyValueStorage implements KeyValueStorage {
}

/**
* One task store per server process. The KV driver (fs-lite locally; Cloudflare KV,
* Deno KV, or Upstash when hosted) is selected in `vite.config.ts`, so this module
* never changes when the deployment target does.
* One shared storage instance per server process. The KV driver (fs-lite locally;
* Cloudflare KV, Deno KV, or Upstash when hosted) is selected in `vite.config.ts`,
* so this module never changes when the deployment target does.
*/
export const taskStore: TaskStore = new PersistentTaskStore(new KvKeyValueStorage());
const storage: KeyValueStorage = new KvKeyValueStorage();

export const taskStore: TaskStore = new PersistentTaskStore(storage);

/**
* The one durable delivery-claim store for this deployment, injected as
* `WebhookOptions.deliveryClaims` by the webhook route (`routes/webhooks/github.post.ts`):
* because the claims live in the shared KV backend rather than a process-local map, a
* redelivered issue event observes the recorded outcome across restarts and across server
* instances instead of starting a duplicate run. The KV facade has no conditional write, so
* the claim uses the store's splitter fallback, which grants at most one owner among
* contenders that all saw the key absent; the router's in-memory registry still serializes
* concurrent deliveries within one process.
*/
export const deliveryClaimStore: DeliveryClaimStore = new PersistentDeliveryClaimStore(storage);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
142 changes: 142 additions & 0 deletions apps/server/src/control-plane.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';

import {
MemoryTaskStore,
PersistentDeliveryClaimStore,
PersistentTaskStore,
TaskQueueQuotaError,
TaskScheduler,
Expand DownExpand Up@@ -41,6 +42,72 @@ class RecordingStorage implements KeyValueStorage {
async getKeys(base = ''): Promise<string[]> {
return [...this.values.keys()].filter((key) => key.startsWith(base));
}
async removeItem(key: string): Promise<void> {
this.values.delete(key);
}
}

/** A driver that offers the atomic conditional write; the base class exercises the fallback. */
class AtomicRecordingStorage extends RecordingStorage {
async setItemIfAbsent(key: string, value: unknown): Promise<boolean> {
if (this.values.has(key)) return false;
this.values.set(key, value);
return true;
}
}

/**
* A conditional-write-less driver that holds the first `contenders` absence checks at a barrier,
* so every contender observes the key absent before any of them writes — the exact interleaving
* a naive read-then-write claim resolves by granting the delivery to all of them.
*/
class RacingStorage extends RecordingStorage {
private waiting: (() => void)[] = [];
private held = 0;
constructor(private readonly contenders: number) {
super();
}

override async getItem(key: string): Promise<unknown> {
if (this.held < this.contenders && !this.values.has(key)) {
this.held += 1;
await new Promise<void>((resolve) => {
this.waiting.push(resolve);
if (this.waiting.length >= this.contenders) for (const release of this.waiting) release();
});
}
return super.getItem(key);
}
}

/**
* Parks the first delivery-marker write until released: the parked contender has already observed
* the delivery absent, and it resumes (overwriting the winner's marker and reading back) only
* after another contender claimed end to end. `parked` resolves once the contender is stalled.
*/
class ParkedWriteStorage extends RecordingStorage {
private resolveParked!: () => void;
readonly parked = new Promise<void>((resolve) => {
this.resolveParked = resolve;
});
private releaseGate!: () => void;
private readonly gate = new Promise<void>((resolve) => {
this.releaseGate = resolve;
});
private held = false;

release(): void {
this.releaseGate();
}

override async setItem(key: string, value: unknown): Promise<void> {
if (!this.held && key.startsWith('deliveries:') && !key.endsWith(':contender')) {
this.held = true;
this.resolveParked();
await this.gate;
}
await super.setItem(key, value);
}
}

describe('task persistence', () => {
Expand DownExpand Up@@ -71,6 +138,81 @@ describe('task persistence', () => {
});
});

describe('PersistentDeliveryClaimStore', () => {
for (const [driver, storage] of [
['a conditional-write driver', () => new AtomicRecordingStorage()],
['the splitter fallback', () => new RecordingStorage()],
] as const) {
it(`grants a claim exactly once and replays the completed outcome via ${driver}`, async () => {
const store = new PersistentDeliveryClaimStore(storage(), []);
await expect(store.claim('delivery:guid-1')).resolves.toEqual({ claimed: true });
// The claim is standing but unfinished, so there is no outcome to replay yet.
await expect(store.claim('delivery:guid-1')).resolves.toEqual({
claimed: false,
outcome: null,
});
await store.complete('delivery:guid-1', { status: 'ignored', reason: 'recorded' });
await expect(store.claim('delivery:guid-1')).resolves.toEqual({
claimed: false,
outcome: { status: 'ignored', reason: 'recorded' },
});
});
}

it('grants exactly one claim when concurrent contenders both observe the key absent', async () => {
// Two instances race the fallback: both absence checks return null before either write lands.
// The splitter must grant at most one of them the delivery, never both.
const store = new PersistentDeliveryClaimStore(new RacingStorage(2), []);
const outcomes = await Promise.all([
store.claim('delivery:guid-race'),
store.claim('delivery:guid-race'),
]);
expect(outcomes.filter((outcome) => outcome.claimed)).toHaveLength(1);
});

it('refuses the contender whose marker write lands after the winner already claimed', async () => {
// The schedule write-then-read arbitration resolved by granting BOTH contenders: one
// contender observes the delivery absent and stalls before its marker write, the winner
// claims end to end, and only then does the stalled contender overwrite the winner's marker
// and read back. The splitter's contender register makes the late writer lose instead.
const storage = new ParkedWriteStorage();
const store = new PersistentDeliveryClaimStore(storage, []);
const late = store.claim('delivery:guid-race');
await storage.parked;
await expect(store.claim('delivery:guid-race')).resolves.toEqual({ claimed: true });
storage.release();
await expect(late).resolves.toEqual({ claimed: false, outcome: null });
});

it('keeps distinct deliveries independent', async () => {
const store = new PersistentDeliveryClaimStore(new AtomicRecordingStorage(), []);
await expect(store.claim('delivery:guid-1')).resolves.toEqual({ claimed: true });
await expect(store.claim('delivery:guid-2')).resolves.toEqual({ claimed: true });
});

it('releases an unfinished claim so a redelivery may retry', async () => {
const store = new PersistentDeliveryClaimStore(new AtomicRecordingStorage(), []);
await expect(store.claim('delivery:guid-1')).resolves.toEqual({ claimed: true });
await store.release('delivery:guid-1');
await expect(store.claim('delivery:guid-1')).resolves.toEqual({ claimed: true });
});

it('redacts credentials before an outcome is persisted', async () => {
const storage = new AtomicRecordingStorage();
const store = new PersistentDeliveryClaimStore(storage, ['provider-secret-value']);
await store.claim('delivery:guid-1');
await store.complete('delivery:guid-1', {
status: 'rejected',
reason: 'token=provider-secret-value',
});
expect(JSON.stringify([...storage.values.values()])).not.toContain('provider-secret-value');
await expect(store.claim('delivery:guid-1')).resolves.toEqual({
claimed: false,
outcome: { status: 'rejected', reason: 'token=[redacted]' },
});
});
});

describe('TaskScheduler', () => {
it('enforces global and repository concurrency while draining FIFO work', async () => {
const scheduler = new TaskScheduler({
Expand Down
Loading
Loading