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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.9.2 (2026-09-24)

### Added
- The sandbox executor links a published PR to its task's source issue. When a `cc_task` carries `github_issue_repo` and `github_issue_number`, the PR's first line is `Fixes #N` for an issue in the target repository, or `Refs owner/name#N` for one elsewhere. That makes GitHub close the issue on merge, and lets PR-side checks such as the agent-acceptance Action find the maintainer's contract in the issue. The new `issueReference` helper is exported from `@stackbilt/aegis-core/factory`.
- `aegis_create_cc_task` accepts `github_issue_repo` and `github_issue_number`, which must be given together.

## 0.9.1 (2026-09-24)

### Fixed
Expand Down
9 changes: 9 additions & 0 deletions docs/sandbox-executor.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ prompt:

The heartbeat dispatches it within the hour. To dispatch now, call `POST /api/task-executor/run` with your AEGIS bearer token. Read artifacts from `GET /api/task-executor/artifacts/<taskId>/acceptance.json`.

## Linking a task to its issue

Pass `github_issue_repo` (`"owner/name"`) and `github_issue_number` to `aegis_create_cc_task` when a task comes from a GitHub issue:

- If the issue is in the repository the PR targets, the PR's first line is `Fixes #N`. GitHub links the PR and closes the issue on merge.
- For an issue elsewhere, the first line is a non-closing `Refs owner/name#N`.

This also lets the maintainer verify the PR independently. With the [agent-acceptance](https://github.com/Stackbilt-dev/agent-acceptance) Action on the upstream repo, put the same acceptance block in the issue (written by someone the repo trusts) and in the task prompt. The executor checks it before publishing, and the Action re-checks it on the PR from the issue, without trusting the executor.

## Acceptance rules

- `auto_safe` tasks must carry an acceptance block. `aegis_create_cc_task` rejects a task without one, and the executor rejects it again when claiming it.
Expand Down
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.9.1",
"version": "0.9.2",
"description": "Persistent AI agent framework for Cloudflare Workers. Multi-tier memory, autonomous goals, dreaming cycles, MCP native.",
"license": "Apache-2.0",
"publishConfig": {
Expand Down
17 changes: 12 additions & 5 deletions web/src/factory/executor-do.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
ensureFork,
externalRepoAdmissionError,
gitHubClient,
issueReference,
parseTaskRepo,
pullRequestHead,
recipeKey,
Expand Down Expand Up @@ -110,6 +111,8 @@ interface CCTaskRow {
repo: string;
category: string;
authority: string;
github_issue_repo: string | null;
github_issue_number: number | null;
}

interface TaskPlan {
Expand Down Expand Up @@ -256,7 +259,7 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) {
if (!taskId) return;

const task = await this.env.DB.prepare(
'SELECT id, title, prompt, repo, category, authority FROM cc_tasks WHERE id = ?',
'SELECT id, title, prompt, repo, category, authority, github_issue_repo, github_issue_number FROM cc_tasks WHERE id = ?',
)
.bind(taskId)
.first<CCTaskRow>();
Expand Down Expand Up @@ -335,7 +338,7 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) {
// The executor owns branch and publication metadata. Repository discovery,
// edits, and verification belong to the bounded tool loop below.
this.setState({ ...this.state, phase: 'planning' });
const plan = this.createPlan(task);
const plan = this.createPlan(task, target);
await this.workspace.writeFile(`tasks/${task.id}/plan.json`, JSON.stringify(plan, null, 2));

if (signal.aborted) return;
Expand Down Expand Up @@ -519,18 +522,22 @@ export function createTaskExecutorDO(config: TaskExecutorConfig) {
ORDER BY priority ASC, created_at ASC
LIMIT 1
)
RETURNING id, title, prompt, repo, category, authority`,
RETURNING id, title, prompt, repo, category, authority, github_issue_repo, github_issue_number`,
).first<CCTaskRow>();
}

private createPlan(task: CCTaskRow): TaskPlan {
private createPlan(task: CCTaskRow, target: TaskRepoTarget): TaskPlan {
const slug = task.id.slice(0, 8);
// First line, so GitHub and PR-side checks (the agent-acceptance Action)
// tie the PR to the issue — and the contract — the task came from.
const link = issueReference(target, task.github_issue_repo, task.github_issue_number);
const intro = `Automated, sandboxed change from task ${task.id}. The executor retained branch and publication authority.`;
return {
branch: `auto/do-sandbox/${slug}`,
executor: 'workers_ai_tool_loop',
commit_message: `chore: autonomous task ${slug}`,
pr_title: task.title.slice(0, 70),
pr_body: `Automated, sandboxed change from task ${task.id}. The executor retained branch and publication authority.`,
pr_body: link ? `${link}\n\n${intro}` : intro,
};
}

Expand Down
1 change: 1 addition & 0 deletions web/src/factory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
export {
defaultExternalRepoPolicy,
externalRepoAdmissionError,
issueReference,
parseTaskRepo,
type ExternalRepoPolicy,
type TaskAdmission,
Expand Down
20 changes: 20 additions & 0 deletions web/src/factory/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ export function pullRequestHead(target: TaskRepoTarget, branch: string, homeOrg:
return target.external ? `${homeOrg}:${branch}` : branch;
}

/**
* The line that ties a published PR to the issue its task came from.
*
* An issue in the repository the PR targets gets a closing keyword, so GitHub
* links and closes it, and PR-side checks such as the agent-acceptance Action
* find the contract the maintainer wrote in that issue. An issue elsewhere gets
* a non-closing reference. Returns null when the task has no usable link.
*/
export function issueReference(
target: TaskRepoTarget,
issueRepo: string | null | undefined,
issueNumber: number | null | undefined,
): string | null {
if (!issueRepo || !Number.isInteger(issueNumber) || (issueNumber as number) < 1) return null;
const parts = issueRepo.trim().split('/');
if (parts.length !== 2 || parts.some((part) => !SLUG_RE.test(part))) return null;
const sameRepo = `${target.owner}/${target.name}`.toLowerCase() === issueRepo.trim().toLowerCase();
return sameRepo ? `Fixes #${issueNumber}` : `Refs ${parts[0]}/${parts[1]}#${issueNumber}`;
}

export interface TaskAdmission {
repo: string;
executor: string;
Expand Down
18 changes: 15 additions & 3 deletions web/src/mcp/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,10 +324,22 @@ export async function toolAegisCreateCcTask(args: Record<string, unknown>, env:
if (admission) return { content: [{ type: 'text', text: `Error: ${admission}` }], isError: true };
}

// Optional source issue. The sandbox executor writes `Fixes #N` into the PR
// when the issue is in the repository it targets.
const issueRepo = typeof args.github_issue_repo === 'string' && /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(args.github_issue_repo.trim())
? args.github_issue_repo.trim()
: null;
const issueNumber = Number.isInteger(args.github_issue_number) && (args.github_issue_number as number) > 0
? args.github_issue_number as number
: null;
if ((issueRepo === null) !== (issueNumber === null)) {
return { content: [{ type: 'text', text: 'Error: github_issue_repo ("owner/name") and github_issue_number must be given together' }], isError: true };
}

await env.db.prepare(`
INSERT INTO cc_tasks (id, title, repo, prompt, completion_signal, priority, depends_on, blocked_by, max_turns, created_by, authority, category, business_unit, executor)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'aegis', ?, ?, ?, ?)
`).bind(id, title.trim(), repo.trim(), prompt.trim(), completionSignal, priority, dependsOn, blockedBy ? JSON.stringify(blockedBy) : null, maxTurns, authority, category, businessUnit, executor).run();
INSERT INTO cc_tasks (id, title, repo, prompt, completion_signal, priority, depends_on, blocked_by, max_turns, created_by, authority, category, business_unit, executor, github_issue_repo, github_issue_number)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'aegis', ?, ?, ?, ?, ?, ?)
`).bind(id, title.trim(), repo.trim(), prompt.trim(), completionSignal, priority, dependsOn, blockedBy ? JSON.stringify(blockedBy) : null, maxTurns, authority, category, businessUnit, executor, issueRepo, issueNumber).run();

return { content: [{ type: 'text', text: `Queued task "${title}" → ${repo} (ID: ${id}, priority: ${priority}, authority: ${authority}, category: ${category}, executor: ${executor}, business_unit: ${businessUnit})` }] };
}
Expand Down
2 changes: 2 additions & 0 deletions web/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ export const TOOLS = [
authority: { type: 'string', enum: ['proposed', 'auto_safe', 'operator'], description: 'Authority level: operator=run immediately, auto_safe=safe auto-execute, proposed=needs approval (default: operator)' },
executor: { type: 'string', enum: ['claude_code', 'do_sandbox'], description: 'Where the task runs: claude_code (local task runner, default) or do_sandbox (the sandbox task executor, if the Worker binds one). do_sandbox prompts may carry one fenced ```acceptance JSON block, checked before any PR is opened; it is required for auto_safe tasks.' },
business_unit: { type: 'string', description: 'Business unit this task belongs to (e.g. "stackbilt", "foodfiles"). Default: "stackbilt".' },
github_issue_repo: { type: 'string', description: 'Source issue repository as "owner/name". Give with github_issue_number. The sandbox executor writes "Fixes #N" into the PR when this is the repository it targets.' },
github_issue_number: { type: 'number', description: 'Source issue number in github_issue_repo.' },
},
required: ['title', 'repo', 'prompt'],
},
Expand Down
2 changes: 1 addition & 1 deletion web/src/version.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Semantic version - bump on each deploy
// Used in /health endpoint and changelog tracking
export const VERSION = '0.9.1';
export const VERSION = '0.9.2';
24 changes: 24 additions & 0 deletions web/tests/factory/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ensureFork,
externalRepoAdmissionError,
forkName,
issueReference,
parseTaskRepo,
pullRequestHead,
recipeKey,
Expand Down Expand Up @@ -169,3 +170,26 @@ describe('fork-mode publication', () => {
expect(push).toContain("push --set-upstream 'fork' ");
});
});

describe('issueReference', () => {
const upstream = parseTaskRepo('someone/lib', HOME);
const home = parseTaskRepo('app', HOME);

it('closes an issue in the repository the PR targets', () => {
expect(issueReference(upstream, 'someone/lib', 12)).toBe('Fixes #12');
expect(issueReference(upstream, 'Someone/Lib', 12)).toBe('Fixes #12');
expect(issueReference(home, 'example-org/app', 3)).toBe('Fixes #3');
});

it('only references an issue that lives elsewhere', () => {
expect(issueReference(home, 'example-org/tracker', 7)).toBe('Refs example-org/tracker#7');
});

it('ignores missing or malformed links', () => {
expect(issueReference(home, null, 3)).toBeNull();
expect(issueReference(home, 'example-org/app', null)).toBeNull();
expect(issueReference(home, 'example-org/app', 0)).toBeNull();
expect(issueReference(home, 'not a repo', 3)).toBeNull();
expect(issueReference(home, 'a/b/c', 3)).toBeNull();
});
});
19 changes: 19 additions & 0 deletions web/tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,25 @@ describe('MCP Tool Handlers', () => {
expect(result.content[0].text).toContain('must declare an ```acceptance block');
});

it('rejects a half-given issue link', async () => {
const env = makeEnv();
const result = await handlers.toolAegisCreateCcTask(
{ title: 'T', repo: 'r', prompt: 'p', github_issue_number: 4 },
env as any,
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('must be given together');
});

it('accepts a full issue link', async () => {
const env = makeEnv();
const result = await handlers.toolAegisCreateCcTask(
{ title: 'T', repo: 'someone/lib', prompt: 'p', github_issue_repo: 'someone/lib', github_issue_number: 4 },
env as any,
);
expect(result.isError).toBeUndefined();
});

it('rejects a malformed acceptance block for any authority', async () => {
const env = makeEnv();
const result = await handlers.toolAegisCreateCcTask(
Expand Down
Loading