From 2293efdc30e055a8a2b7c79315007aa8459fd94f Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Thu, 24 Sep 2026 08:36:34 -0500 Subject: [PATCH] feat(factory): link executor PRs to their source issue (0.9.2) Tasks carrying github_issue_repo/number get "Fixes #N" (same repo) or "Refs owner/name#N" as the PR's first line, so GitHub closes the issue and PR-side checks like the agent-acceptance Action find the contract the maintainer wrote there. aegis_create_cc_task accepts the link. Governed-By: docs/sandbox-executor.md Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 ++++++ docs/sandbox-executor.md | 9 +++++++++ web/package.json | 2 +- web/src/factory/executor-do.ts | 17 ++++++++++++----- web/src/factory/index.ts | 1 + web/src/factory/repo.ts | 20 ++++++++++++++++++++ web/src/mcp/handlers.ts | 18 +++++++++++++++--- web/src/mcp/tools.ts | 2 ++ web/src/version.ts | 2 +- web/tests/factory/repo.test.ts | 24 ++++++++++++++++++++++++ web/tests/mcp.test.ts | 19 +++++++++++++++++++ 11 files changed, 110 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0419a70..caa3f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/sandbox-executor.md b/docs/sandbox-executor.md index 641fbbd..d32c70e 100644 --- a/docs/sandbox-executor.md +++ b/docs/sandbox-executor.md @@ -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//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. diff --git a/web/package.json b/web/package.json index 04d4d36..6fbb924 100755 --- a/web/package.json +++ b/web/package.json @@ -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": { diff --git a/web/src/factory/executor-do.ts b/web/src/factory/executor-do.ts index 74ff858..2d4d033 100644 --- a/web/src/factory/executor-do.ts +++ b/web/src/factory/executor-do.ts @@ -49,6 +49,7 @@ import { ensureFork, externalRepoAdmissionError, gitHubClient, + issueReference, parseTaskRepo, pullRequestHead, recipeKey, @@ -110,6 +111,8 @@ interface CCTaskRow { repo: string; category: string; authority: string; + github_issue_repo: string | null; + github_issue_number: number | null; } interface TaskPlan { @@ -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(); @@ -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; @@ -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(); } - 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, }; } diff --git a/web/src/factory/index.ts b/web/src/factory/index.ts index cd66d04..4fd4ee0 100644 --- a/web/src/factory/index.ts +++ b/web/src/factory/index.ts @@ -18,6 +18,7 @@ export { export { defaultExternalRepoPolicy, externalRepoAdmissionError, + issueReference, parseTaskRepo, type ExternalRepoPolicy, type TaskAdmission, diff --git a/web/src/factory/repo.ts b/web/src/factory/repo.ts index e703711..2874784 100644 --- a/web/src/factory/repo.ts +++ b/web/src/factory/repo.ts @@ -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; diff --git a/web/src/mcp/handlers.ts b/web/src/mcp/handlers.ts index f152741..41fe211 100755 --- a/web/src/mcp/handlers.ts +++ b/web/src/mcp/handlers.ts @@ -324,10 +324,22 @@ export async function toolAegisCreateCcTask(args: Record, 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})` }] }; } diff --git a/web/src/mcp/tools.ts b/web/src/mcp/tools.ts index 1c8bba9..932fc02 100755 --- a/web/src/mcp/tools.ts +++ b/web/src/mcp/tools.ts @@ -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'], }, diff --git a/web/src/version.ts b/web/src/version.ts index e0b040a..82791b7 100755 --- a/web/src/version.ts +++ b/web/src/version.ts @@ -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'; diff --git a/web/tests/factory/repo.test.ts b/web/tests/factory/repo.test.ts index 26a1dd7..526a2e0 100644 --- a/web/tests/factory/repo.test.ts +++ b/web/tests/factory/repo.test.ts @@ -9,6 +9,7 @@ import { ensureFork, externalRepoAdmissionError, forkName, + issueReference, parseTaskRepo, pullRequestHead, recipeKey, @@ -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(); + }); +}); diff --git a/web/tests/mcp.test.ts b/web/tests/mcp.test.ts index 996f12f..3109d1c 100755 --- a/web/tests/mcp.test.ts +++ b/web/tests/mcp.test.ts @@ -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(