Skip to content
Open
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 0.11.0 (2026-10-16)

### Fixed
- **Approving a proposed task no longer makes it an operator task.** Approval (REST `POST /api/cc-tasks/:id/approve`, MCP `aegis_approve_cc_task` and `aegis_batch_approve`) rewrote `authority` to `operator`. The local task runner runs operator tasks in the operator's own checkout with no branch or PR, so approved work, often generated from untrusted issue bodies, ran there unisolated, and nothing afterwards showed it had started as proposed. Approval now keeps `authority = 'proposed'` and records `approved_at` and `approved_by` (`rest` or `mcp`); a task can be approved once. The task queue (`/next`), sandbox dispatch and the sandbox claim run proposed tasks once approved, and the runner keeps giving every non-operator task its own worktree and PR.
- Awaiting-approval listings (briefing, digest, task summary, dashboard) and the contract invariant `proposed_task_needs_approval` count only proposed tasks not yet approved. The shared conditions are `TASK_RUNNABLE_SQL` and `TASK_AWAITING_APPROVAL_SQL` in `schema-enums.ts`. The dashboard marks an approved task `[APPROVED]`, and `aegis_list_cc_tasks` returns `approved_at` and `approved_by`.
- `pnpm run setup` now applies `migrations/*.sql` after `schema.sql`, so re-running it on an existing database adds new columns. A migration whose columns already exist (a fresh database, or a re-run) is reported as already applied.

### Changed
- **Sandbox tasks need an acceptance block unless the operator queued them.** `acceptanceAdmissionError` used to require one only for `auto_safe`. Approved `proposed` tasks now keep their authority, often carry a prompt from an untrusted issue, and would otherwise run with no machine check, so `proposed` needs one too. MCP refuses to create a `proposed` `do_sandbox` task without a block. A pending one already in the queue fails at claim as `acceptance_missing`.
- **Approved proposed tasks can't run on repositories outside the home org.** The default `externalRepoPolicy` requires `operator` authority there, which approval no longer grants. Such a task is claimed and then fails as `external_repo_refused`, with a note to queue it again with operator authority. (The policy and home org live in the executor config, so approval can't check this earlier.)
- The sandbox claim is `claimNextSandboxTask` in `factory/claim.ts`, so it can be tested without the Workers runtime. The digest's task queries are `digestTasks(db, since)`.

### Upgrade
- Existing databases: apply `migrations/0002_cc_tasks_approval.sql` (adds `approved_at`, `approved_by`) before deploying. Until it is applied, the task queue, sandbox dispatch, briefing, digest, task summary and dashboard fail with `no such column: approved_at`. Fresh installs get the columns from `schema.sql`. The migration can't be run twice (SQLite has no `ADD COLUMN IF NOT EXISTS`). Tasks approved on earlier versions were already rewritten to `operator`; their original authority wasn't kept, so they can't be restored.

## 0.10.0 (2026-10-16)

### Breaking
Expand Down
13 changes: 13 additions & 0 deletions web/migrations/0002_cc_tasks_approval.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- 0.11.0: approving a proposed task records who and when, and keeps the
-- authority 'proposed' so the task still runs isolated (worktree + PR,
-- never in the operator's checkout).
--
-- Apply to a database created before 0.11.0:
-- npx wrangler d1 execute <db> --remote --file=migrations/0002_cc_tasks_approval.sql
-- Fresh installs get the columns from schema.sql and do not need this file.
--
-- Tasks approved before 0.11.0 were rewritten to authority 'operator'; that
-- can't be undone here because the original authority wasn't kept.

ALTER TABLE cc_tasks ADD COLUMN approved_at TEXT;
ALTER TABLE cc_tasks ADD COLUMN approved_by TEXT;
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stackbilt/aegis-core",
"version": "0.10.0",
"version": "0.11.0",
"description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.",
"license": "Apache-2.0",
"publishConfig": {
Expand Down
4 changes: 3 additions & 1 deletion web/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,9 @@ CREATE TABLE IF NOT EXISTS cc_tasks (
github_issue_number INTEGER, -- source issue number (repo-scoped)
business_unit TEXT NOT NULL DEFAULT 'stackbilt', -- partition key for multi-BU operators
executor TEXT NOT NULL DEFAULT 'claude_code' -- where it runs: claude_code (local runner) or do_sandbox (sandbox executor)
CHECK (executor IN ('claude_code', 'do_sandbox'))
CHECK (executor IN ('claude_code', 'do_sandbox')),
approved_at TEXT, -- when an operator approved a proposed task (authority stays 'proposed')
approved_by TEXT -- who approved it (e.g. 'mcp', 'rest')
);

CREATE INDEX IF NOT EXISTS idx_cc_tasks_status ON cc_tasks(status, priority);
Expand Down
14 changes: 14 additions & 0 deletions web/scripts/setup-lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ export function workerUrlFromDeployOutput(output) {
return match ? match[0] : null;
}

/** Migration files in apply order (`NNNN_name.sql`). */
export function migrationFiles(names) {
return names.filter((name) => /^\d{4}_[\w-]+\.sql$/.test(name)).sort();
}

/**
* True when a migration failed only because its columns already exist: a
* fresh database gets them from schema.sql, and a re-run has applied them.
* SQLite has no ADD COLUMN IF NOT EXISTS.
*/
export function migrationAlreadyApplied(output) {
return /duplicate column name/i.test(output ?? '');
}

/** A 256-bit bearer token for AEGIS_TOKEN. */
export function generateToken() {
return randomBytes(32).toString('hex');
Expand Down
21 changes: 17 additions & 4 deletions web/scripts/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@
// pnpm run setup -- --dry-run # print every command; change nothing
//
// Steps: check the wrangler login → create (or reuse) the D1 database → write
// wrangler.toml → apply schema.sql → deploy → set a generated AEGIS_TOKEN →
// check /health. Safe to re-run: it reuses an existing database of the same
// name, the schema is idempotent, and it refuses to overwrite an existing
// wrangler.toml → apply schema.sql and migrations/ → deploy → set a generated
// AEGIS_TOKEN → check /health. Safe to re-run: it reuses an existing database
// of the same name, the schema is idempotent, a migration whose columns
// already exist is skipped, and it refuses to overwrite an existing
// wrangler.toml unless you pass --force.

import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { createInterface } from 'node:readline/promises';
import { fileURLToPath } from 'node:url';
import {
findDatabaseId,
generateToken,
migrationAlreadyApplied,
migrationFiles,
renderWranglerToml,
workerNameError,
workerUrlFromDeployOutput,
Expand Down Expand Up @@ -112,6 +115,16 @@ async function main() {
const schema = run('npx', ['wrangler', 'd1', 'execute', name, '--remote', '--file=schema.sql', '--yes'], { capture: false });
if (schema.status !== 0) fail('Applying schema.sql failed (see the output above).');

// schema.sql only creates missing tables, so a reused database gets new
// columns from the migrations.
step('Applying migrations');
for (const file of migrationFiles(readdirSync(join(root, 'migrations')))) {
const migration = run('npx', ['wrangler', 'd1', 'execute', name, '--remote', `--file=migrations/${file}`, '--yes']);
if (migration.status === 0) console.log(` Applied ${file}`);
else if (migrationAlreadyApplied(migration.stdout)) console.log(` ${file}: already applied`);
else fail(`Applying migrations/${file} failed:\n${migration.stdout.slice(-2000)}`);
}

step('Building and deploying');
const deploy = run('npm', ['run', 'deploy'], {
dryResult: { status: 0, stdout: `https://${name}.example.workers.dev` },
Expand Down
6 changes: 4 additions & 2 deletions web/src/contracts/cc-task.contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export const CCTaskContract = defineContract({
githubIssueRepo: z.string().nullable(),
githubIssueNumber: z.number().int().positive().nullable(),
businessUnit: z.string().min(1).default('stackbilt'),
approvedAt: z.string().nullable().default(null),
approvedBy: z.string().nullable().default(null),
}),

operations: {
Expand Down Expand Up @@ -180,8 +182,8 @@ export const CCTaskContract = defineContract({
name: 'proposed_task_needs_approval',
description: 'Proposed tasks must be approved before they can run',
check: (entity: unknown) => {
const t = entity as { authority?: string; status?: string };
if (t.authority === 'proposed' && t.status === 'running') {
const t = entity as { authority?: string; status?: string; approvedAt?: string | null };
if (t.authority === 'proposed' && !t.approvedAt && t.status === 'running') {
return 'Proposed tasks require approval before execution';
}
return true;
Expand Down
9 changes: 5 additions & 4 deletions web/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ interface DashboardData {
repo: string;
status: string;
authority: string;
approved_at: string | null;
category: string;
exit_code: number | null;
pr_url: string | null;
Expand Down Expand Up @@ -123,9 +124,9 @@ export async function getDashboardData(db: D1Database, memoryBinding?: MemorySer
db.prepare("SELECT COALESCE(SUM(cost), 0) as total FROM episodic_memory WHERE created_at > datetime('now', '-7 days')").first<{ total: number }>(),
db.prepare("SELECT summary, outcome, cost, latency_ms, created_at FROM episodic_memory ORDER BY created_at DESC LIMIT 20").all<{ summary: string; outcome: string; cost: number; latency_ms: number; created_at: string }>(),
db.prepare("SELECT severity, summary, created_at FROM heartbeat_results ORDER BY created_at DESC LIMIT 1").first<{ severity: string; summary: string; created_at: string }>(),
db.prepare("SELECT status, authority, COUNT(*) as c FROM cc_tasks WHERE status IN ('pending', 'running') GROUP BY status, authority").all<{ status: string; authority: string; c: number }>(),
db.prepare("SELECT status, CASE WHEN authority = 'proposed' AND approved_at IS NULL THEN 'proposed' ELSE 'runnable' END AS authority, COUNT(*) as c FROM cc_tasks WHERE status IN ('pending', 'running') GROUP BY 1, 2").all<{ status: string; authority: string; c: number }>(),
db.prepare("SELECT status, COUNT(*) as c FROM cc_tasks WHERE completed_at > datetime('now', '-24 hours') GROUP BY status").all<{ status: string; c: number }>(),
db.prepare("SELECT id, title, repo, status, authority, category, exit_code, pr_url, completed_at FROM cc_tasks ORDER BY COALESCE(completed_at, started_at, created_at) DESC LIMIT 10").all<{ id: string; title: string; repo: string; status: string; authority: string; category: string; exit_code: number | null; pr_url: string | null; completed_at: string | null }>(),
db.prepare("SELECT id, title, repo, status, authority, approved_at, category, exit_code, pr_url, completed_at FROM cc_tasks ORDER BY COALESCE(completed_at, started_at, created_at) DESC LIMIT 10").all<{ id: string; title: string; repo: string; status: string; authority: string; approved_at: string | null; category: string; exit_code: number | null; pr_url: string | null; completed_at: string | null }>(),
]);

// Build executor stats from episodes
Expand Down Expand Up @@ -216,7 +217,7 @@ export async function getDashboardData(db: D1Database, memoryBinding?: MemorySer
} : null,
tasks: {
pending: taskStatusRows.results.filter(r => r.status === 'pending' && r.authority !== 'proposed').reduce((s, r) => s + r.c, 0),
proposed: taskStatusRows.results.filter(r => r.authority === 'proposed').reduce((s, r) => s + r.c, 0),
proposed: taskStatusRows.results.filter(r => r.status === 'pending' && r.authority === 'proposed').reduce((s, r) => s + r.c, 0),
running: taskStatusRows.results.filter(r => r.status === 'running').reduce((s, r) => s + r.c, 0),
completed24h: taskCompletedRows.results.find(r => r.status === 'completed')?.c ?? 0,
failed24h: taskCompletedRows.results.find(r => r.status === 'failed')?.c ?? 0,
Expand Down Expand Up @@ -586,7 +587,7 @@ export function dashboardPage(data: DashboardData): string {
${statusBadge(t.status)}
<span style="color:var(--text-secondary);font-family:'JetBrains Mono',monospace;min-width:50px">${esc(t.repo)}</span>
<span style="display:inline-block;background:${statusColors[t.status] ?? '#666'}15;color:var(--text-secondary);font-size:9px;padding:1px 4px;border-radius:2px">${esc(t.category)}</span>
${t.authority === 'proposed' ? '<span style="color:#a855f7;font-size:9px">[PROPOSED]</span>' : ''}
${t.authority === 'proposed' ? (t.approved_at ? '<span style="color:#a855f7;font-size:9px" title="Approved; runs isolated">[APPROVED]</span>' : '<span style="color:#a855f7;font-size:9px">[PROPOSED]</span>') : ''}
<span style="color:var(--text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
${t.pr_url ? `<a href="${esc(t.pr_url)}" target="_blank" style="color:#3dd6c8;font-size:9px;text-decoration:none" title="View PR">PR</a>` : ''}
<span style="color:var(--text-secondary);font-family:'JetBrains Mono',monospace;font-size:10px">${exitLabel}</span>
Expand Down
10 changes: 6 additions & 4 deletions web/src/factory/acceptance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,15 @@ export function parseAcceptanceSpec(prompt: string): ParsedAcceptance {

/**
* Admission rule shared by task creation and the executor: a malformed block is
* always rejected, and unattended (`auto_safe`) tasks must declare one. Returns
* the rejection reason, or null when the task may run.
* always rejected, and every task the operator didn't queue directly
* (`auto_safe`, and `proposed` even once approved -- its prompt often comes
* from an untrusted issue) must declare one. Returns the rejection reason, or
* null when the task may run.
*/
export function acceptanceAdmissionError(parsed: ParsedAcceptance, authority: string): string | null {
if (parsed.kind === 'invalid') return `acceptance block rejected: ${parsed.error}`;
if (parsed.kind === 'absent' && authority === 'auto_safe') {
return 'auto_safe do_sandbox tasks must declare an ```acceptance block; use operator authority to run without one';
if (parsed.kind === 'absent' && authority !== 'operator') {
return `${authority} do_sandbox tasks must declare an \`\`\`acceptance block; use operator authority to run without one`;
}
return null;
}
Expand Down
35 changes: 35 additions & 0 deletions web/src/factory/claim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// The sandbox executor's claim, kept apart from executor-do so it can be
// tested without the Workers runtime.

import { TASK_RUNNABLE_SQL } from '../schema-enums.js';

/**
* Claims the next runnable do_sandbox task: pending, and not a proposed task
* still awaiting approval. Marks it running and returns it, or null.
*/
export async function claimNextSandboxTask(db: D1Database): Promise<CCTaskRow | null> {
return db.prepare(
`UPDATE cc_tasks
SET status = 'running', started_at = datetime('now'), session_id = 'do-sandbox'
WHERE id = (
SELECT id FROM cc_tasks
WHERE status = 'pending'
AND executor = 'do_sandbox'
AND ${TASK_RUNNABLE_SQL}
ORDER BY priority ASC, created_at ASC
LIMIT 1
)
RETURNING id, title, prompt, repo, category, authority, github_issue_repo, github_issue_number`,
).first<CCTaskRow>();
}

export interface CCTaskRow {
id: string;
title: string;
prompt: string;
repo: string;
category: string;
authority: string;
github_issue_repo: string | null;
github_issue_number: number | null;
}
7 changes: 4 additions & 3 deletions web/src/factory/dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Heartbeat dispatcher for the sandbox task executor.
//
// The executor claims its own work (executor = 'do_sandbox', authority
// auto_safe | operator) when POSTed /run. This plugin does that on the hourly
// The executor claims its own work (executor = 'do_sandbox', and not a
// proposed task still awaiting approval) when POSTed /run. This plugin does that on the hourly
// heartbeat, and checks for an eligible task first so an empty queue never
// wakes the Durable Object. A Durable Object evicted mid-task re-attaches
// through onFiberRecovered, not here.
Expand All @@ -12,6 +12,7 @@

import type { ScheduledTaskPlugin } from '../core.js';
import type { EdgeEnv } from '../kernel/dispatch.js';
import { TASK_RUNNABLE_SQL } from '../schema-enums.js';

interface RunResponse {
ok?: boolean;
Expand All @@ -25,7 +26,7 @@ export async function runTaskExecutorDispatch(env: EdgeEnv): Promise<void> {
SELECT id FROM cc_tasks
WHERE status = 'pending'
AND executor = 'do_sandbox'
AND authority IN ('auto_safe', 'operator')
AND ${TASK_RUNNABLE_SQL}
LIMIT 1
`).first<{ id: string }>();
if (!eligible) return;
Expand Down
35 changes: 10 additions & 25 deletions web/src/factory/executor-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
type ExternalRepoPolicy,
type TaskRepoTarget,
} from './repo.js';
import { claimNextSandboxTask, type CCTaskRow } from './claim.js';

/** Bindings and secrets the executor reads. Declare them in your Worker's wrangler config. */
export interface TaskExecutorEnv {
Expand Down Expand Up @@ -104,17 +105,6 @@ const READABLE_ARTIFACTS = new Set([
'acceptance.json',
]);

interface CCTaskRow {
id: string;
title: string;
prompt: string;
repo: string;
category: string;
authority: string;
github_issue_repo: string | null;
github_issue_number: number | null;
}

interface TaskPlan {
branch: string;
executor: 'workers_ai_tool_loop';
Expand Down Expand Up @@ -283,7 +273,14 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) {
homeOrg,
config.externalRepoPolicy,
);
if (external) throw new TaskExecutorError('external_repo_refused', false, 1, external);
if (external) {
// Approval keeps a task 'proposed', so an approved issue-derived task
// can never meet a policy that asks for operator authority.
const hint = task.authority === 'proposed'
? ' (an approved proposed task keeps proposed authority; queue it again with operator authority to run it here)'
: '';
throw new TaskExecutorError('external_repo_refused', false, 1, external + hint);
}
const target = parseTaskRepo(task.repo, homeOrg);
// Recipes and the verification cwd key on the bare name for home-org
// repos, so `<homeOrg>/app` is configured the same as `app`.
Expand Down Expand Up @@ -511,19 +508,7 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) {
}

private async claimNextTask(): Promise<CCTaskRow | null> {
return this.env.DB.prepare(
`UPDATE cc_tasks
SET status = 'running', started_at = datetime('now'), session_id = 'do-sandbox'
WHERE id = (
SELECT id FROM cc_tasks
WHERE status = 'pending'
AND executor = 'do_sandbox'
AND authority IN ('auto_safe', 'operator')
ORDER BY priority ASC, created_at ASC
LIMIT 1
)
RETURNING id, title, prompt, repo, category, authority, github_issue_repo, github_issue_number`,
).first<CCTaskRow>();
return claimNextSandboxTask(this.env.DB);
}

private createPlan(task: CCTaskRow, target: TaskRepoTarget): TaskPlan {
Expand Down
3 changes: 2 additions & 1 deletion web/src/kernel/briefing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// No fluff, no preamble. Answer before the question is asked.

import type { EdgeEnv } from './dispatch.js';
import { TASK_AWAITING_APPROVAL_SQL } from '../schema-enums.js';

export interface Briefing {
// What happened while you were away
Expand Down Expand Up @@ -47,7 +48,7 @@ export async function generateBriefing(env: EdgeEnv): Promise<Briefing> {

// Proposed tasks awaiting approval
env.db.prepare(
"SELECT title, repo FROM cc_tasks WHERE authority = 'proposed' AND status = 'pending' ORDER BY created_at ASC LIMIT 5"
`SELECT title, repo FROM cc_tasks WHERE ${TASK_AWAITING_APPROVAL_SQL} AND status = 'pending' ORDER BY created_at ASC LIMIT 5`
).all<{ title: string; repo: string }>(),

// Currently running
Expand Down
Loading
Loading