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
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Dependencies point downward only. `core/` imports nothing that does input or out
| Review service | `runner/review.ts` | Builds the review view: reads history, links it to the plan, computes approval states and merge blockers. Applies review commands through the store. | In use |
| Ask | `runner/questions.ts`, `runner/question-*.ts` | Answers a question about one plan item with a read-only Claude agent (Codex is refused in every phase, #93). Runs lane D's setup in a worker thread so the server stays responsive. Ask and planning share one read-only container runner (`runReadOnlyAgent`, #117) but keep separate workers, owners and leftovers. Labels its Docker objects with a per-database Ask owner and recovers only its own leftovers, so reviews sharing one Docker daemon do not block each other (#95). | In use |
| Merge coordinator | `runner/merge.ts` | Runs the full merge gate twice, re-reads the local generation, then merges the exact reviewed head. Tracks merge-queue attempts. For a runner task it inspects and merges the task's own published pull request; a configured `github.pullRequest` that differs is refused (#121). | In use |
| Planning agent | `runner/planning.ts`, `web/planning.ts`, `runner/planning-provider.ts` | Runs plan suggestions and drafts with Claude in lane D's planning phase, on a read-only copy of the current head, in its own worker with its own leftovers ledger and owner token. The request and its timer share one 10-minute budget. Issue text is read with collaborators' comments only. | In use for a non-demo review with a `github` block |
| Planning agent | `runner/planning.ts`, `web/planning.ts`, `runner/planning-provider.ts` | Runs plan suggestions and drafts with Claude in lane D's planning phase, on a read-only copy of the current head, in its own worker with its own leftovers ledger and owner token. The request and its timer share one 10-minute budget. Issue text includes current collaborators' comments, or every comment under explicit author-bound trust. | In use for a non-demo review with a `github` block |
| Runner coordinator | `runner/coordinator.ts`, `runner/lifecycle.ts` | Attempt admission, concurrency slots, compare-and-swap on results, retries, shutdown order. A stop made inside a user action takes effect only after that action's transaction commits (`Store.afterCommit`, #96). | In use with the opt-in `runner` block (#91) |
| Execution | `runner/execution.ts` | Runs plan items in order: fresh workspace, prompt, agent, post-run audit, then the runner's own commit and ledger entry. Before launch, D's tree check (`checkTaskTree`) must pass. Start and resume require current approvals, own the review version across the run, recheck approvals before later items, and verify that a completed prefix still ends at the current head (#107). Pauses in needs amendment on an out-of-scope edit. A safety violation is saved before its terminal write and remains durably owed when a human gate delays escalation; a failed run is audited too. | In use with the opt-in `runner` block; started by `start` and `resume` on `/api/runner` (#91, #107, PRs #105 and #130) |
| Workspace | `runner/workspace.ts`, `runner/runner-repository.ts` | The real lane D workspace. A runner-owned bare repository (owner-only directories) fetches base commits by ID and takes in each verified commit bundle under a per-attempt ref. Review and Ask read a task with runner commits from there, at the head the Store recorded. | In use with the opt-in `runner` block (#91) |
Expand Down
42 changes: 40 additions & 2 deletions docs/implementation/issue-prioritization.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,7 @@ The status line says one of:
- "✕ Unavailable": no list has been retrieved yet, with the error;
- "– Not configured": the review configuration has no `github.repository`.

A note says that trusting issues and queueing are not available yet. Demo mode
shows fixture issues and never contacts GitHub.
Demo mode shows fixture issues and never contacts GitHub.

**State holders.**

Expand All @@ -130,3 +129,42 @@ unconfigured state. `test/browser/issues.spec.ts` covers ranked order, reasons,
trust marks, `aria-current` navigation, review input kept across navigation,
review shortcuts ignored on the Issues screen, the unavailable → current → stale
sequence, a late refresh after leaving the screen, and the 1280px layout.

## H4b: Trust this issue

**Decision and scope.** A trust decision is bound to the lower-cased repository identity, issue number and the issue's
current author login. It applies to planning and execute prompts. Revoking trust does not interrupt an invocation that
already started; every later plan-item admission, planning read and publish evaluates the new decision. Publishing is
guarded too, so a revoked decision cannot cross the next irreversible boundary.

**Durable state.** Schema v17 adds one `issue_trust` row per repository and issue, retaining who decided, when, the
author that was observed, and a revocation time. Changing or deleting the GitHub author makes the row inapplicable.
Trust and untrust requests carry UUID v4 action IDs and use the ordinary durable action replay before GitHub is read.
Definite GitHub read failures are saved too and replay with their upstream-failure classification; shutdown remains
resendable. Concurrent callers with one action ID all observe the first durable outcome.
Each execute attempt also stores the SHA-256 digest and count of the exact comment strings put in its prompt. The
evidence is durably `prepared` after every pre-launch check and before the launcher receives the prompt, then becomes
`delivered` only after the launcher returns an owned handle; recovery can therefore distinguish either crash window.

**Admission.** Start, resume, continuation approval and every plan item fetch the issue author and the complete current
collaborator list under a bounded GitHub read. A collaborator-authored issue passes without a local decision. Every
other issue needs a live, unrevoked row for that exact repository, number and author. A prompt text read revalidates the
admitted author and collaborator result against the issue and collaborator snapshot used for that text, closing the gap
between authorization and prompt construction. When explicit trust widened the comments, its author-bound Store row is
re-read immediately after the awaited text fetch so revocation cannot admit the stale all-comments result. The returned
execute source carries the same synchronous guard into `prepareExecution`, and the planning description carries it
into the recorded user action; each runs in the same turn immediately before its prompt is constructed. Malformed,
partial, failed and over-limit reads fail closed. Action-time
failures are saved under the action ID; per-item failures settle that attempt without admitting the next item. The task
and review versions are still checked in the same transaction that admits an attempt, after the external read.

**Comments.** Without matching explicit trust, only current collaborators' comments enter planning and execute prompts.
With matching trust, every bounded comment enters the same untrusted-data block, including comments from deleted
accounts. The issue author is re-read with the comments, so a decision for an earlier author cannot widen the prompt.

**Screen and demo.** The Issues table offers `Trust this issue` for outside authors, `Trust all comments` for current
collaborators, and `Remove trust` for either explicit decision. The collaborator's author-bound decision widens comment
access without changing its already-eligible status. While a request is in flight, the focused control remains
enabled for focus purposes, uses `aria-disabled`, and ignores repeat activation. Responses merge only their own row;
overlapping refreshes and trust requests cannot overwrite a committed decision or reset another control. Demo fixtures
use the same Store and API, but resolve author and collaborator state locally and never contact GitHub.
94 changes: 84 additions & 10 deletions github/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ export const ISSUE_PAGE_MAX_BYTES = 64 * 1024 * 1024;
* to 0.75 s later: 12.75 s, below the 15-second serving request budget.
*/
export const ISSUE_KILL_GRACE_MS = 500, ISSUE_PIPE_GRACE_MS = 250;
/** One sequential access-and-text operation, including the subprocess settlement tail after its active-work abort. */
export const ISSUE_READ_TIMEOUT_MS = 30_000;
export const ISSUE_READ_ACTIVE_MS = ISSUE_READ_TIMEOUT_MS - ISSUE_KILL_GRACE_MS - ISSUE_PIPE_GRACE_MS;

/** Shares one active-work deadline across every sequential stage and awaits the caller's work through settlement. */
export async function withIssueReadDeadline<T>(signal: AbortSignal,
read: (sharedSignal: AbortSignal, activeTimeoutMs: number) => Promise<T>): Promise<T> {
signal.throwIfAborted();
const deadline = new AbortController();
const shared = AbortSignal.any([signal, deadline.signal]);
const timer = setTimeout(() => deadline.abort(new Error('Issue retrieval timed out.')), ISSUE_READ_ACTIVE_MS);
try {
const value = await read(shared, ISSUE_READ_ACTIVE_MS);
shared.throwIfAborted();
return value;
}
finally { clearTimeout(timer); }
}

export type IssueAuthorAssociation =
| 'OWNER' | 'MEMBER' | 'COLLABORATOR' | 'CONTRIBUTOR'
Expand Down Expand Up @@ -44,12 +62,21 @@ export interface IssueSnapshot {

/** The issue text an execute prompt carries, as untrusted data. */
export interface IssueText { readonly number: number; readonly title: string; readonly body: string; readonly comments: readonly string[] }
export interface IssueAccess { readonly number: number; readonly authorLogin: string | null; readonly collaborator: boolean }

export interface IssueGateway {
readonly repository: string;
fetch(options?: { signal?: AbortSignal; timeoutMs?: number }): Promise<IssueSnapshot>;
}

/** The extra current-issue reads used by trust actions and runner admission. */
export interface IssueTrustGateway extends IssueGateway {
issueAccess(number: number, options?: { signal?: AbortSignal; timeoutMs?: number }): Promise<IssueAccess>;
issueText(number: number, options?: { signal?: AbortSignal; timeoutMs?: number; trustedAuthor?: string | null;
/** Revalidate the admission read against the issue and collaborator snapshot used for this text read. */
expectedAccess?: IssueAccess }): Promise<IssueText>;
}

type RunGh = (args: readonly string[], options?: { signal?: AbortSignal }) => Promise<string>;

const associations = new Set<IssueAuthorAssociation>([
Expand Down Expand Up @@ -233,12 +260,39 @@ export class GhIssueGateway implements IssueGateway {
}));
}

async #loadIssueAuthor(number: number, signal: AbortSignal): Promise<string | null> {
let decoded: unknown;
const output = await this.run(['api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/issues/${number}`], { signal });
try { decoded = JSON.parse(output); }
catch { throw new Error('GitHub returned invalid issue JSON.'); }
const issue = object(decoded, 'GitHub returned a malformed issue.');
if (issue.number !== number) throw new Error('GitHub returned a different issue.');
if (Object.hasOwn(issue, 'pull_request')) throw new Error(`#${number} is a pull request, not an issue.`);
return issue.user === null ? null : login(object(issue.user, 'GitHub returned an invalid issue author.').login, 'issue author');
}

async #loadIssueAccess(number: number, signal: AbortSignal): Promise<{ access: IssueAccess; collaborators: ReadonlySet<string> }> {
const authorLogin = await this.#loadIssueAuthor(number, signal);
const collaborators = await this.#loadCollaborators(signal);
const currentAuthor = await this.#loadIssueAuthor(number, signal);
if (currentAuthor !== authorLogin) throw new Error(`Issue #${number}'s author changed during access verification.`);
return { access: { number, authorLogin: currentAuthor,
collaborator: currentAuthor !== null && collaborators.has(currentAuthor.toLocaleLowerCase('en-US')) }, collaborators };
}

async issueAccess(number: number, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssueAccess> {
if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid issue number.');
return this.#bounded(options, async signal => (await this.#loadIssueAccess(number, signal)).access);
}

/**
* One issue's text for an execute prompt (#91): its title, body and the comments of repository collaborators only
* (design, "Which comments reach the agent"), oldest first. Everything stays untrusted data inside the prompt.
* One issue's text for an execute prompt (#91): its title, body and the comments permitted by current collaborator
* access or explicit author-bound trust (design, "Which comments reach the agent"), oldest first.
* Everything stays untrusted data inside the prompt.
* Fails closed on anything malformed, on a pull request, and past the comment page limit.
*/
async issueText(number: number, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssueText> {
async issueText(number: number, options: { signal?: AbortSignal; timeoutMs?: number; trustedAuthor?: string | null;
expectedAccess?: IssueAccess } = {}): Promise<IssueText> {
if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid issue number.');
return this.#bounded(options, async signal => {
let decoded: unknown;
Expand All @@ -249,19 +303,28 @@ export class GhIssueGateway implements IssueGateway {
if (issue.number !== number) throw new Error('GitHub returned a different issue.');
if (Object.hasOwn(issue, 'pull_request')) throw new Error(`#${number} is a pull request, not an issue.`);
const title = boundedString(issue.title, 'title', 4096), body = boundedString(issue.body, 'body', MAX_BODY_LENGTH, true);
const authorLogin = issue.user === null ? null : login(object(issue.user, 'GitHub returned an invalid issue author.').login, 'issue author');
const includeEveryComment = options.trustedAuthor !== undefined && options.trustedAuthor === authorLogin;
// The execute prompt carries the issue as one JSON data block of at most MAX_PROMPT_BYTES (dataJSON). A running byte
// count stops reading early (a title and body already over it read no collaborator or comment page); the exact check
// count stops reading early (a title and body already over it read no included comment page); the exact check
// below uses the prompt's own serializer, so an issue accepted here is one the prompt can carry.
const tooLong = () => new Error(`Issue #${number}'s title, body and collaborator comments are larger than the ${MAX_PROMPT_BYTES / 1024} KiB an execute prompt carries; codeboost does not cut an issue to fit.`);
const tooLong = () => new Error(`Issue #${number}'s title, body and included comments are larger than the ${MAX_PROMPT_BYTES / 1024} KiB an execute prompt carries; codeboost does not cut an issue to fit.`);
// Only the size refusal is reworded; any other (text with a NUL, for one) keeps its own reason.
const carried = (text: IssueText) => {
try { dataJSON(text, 'Issue data'); } catch (error) { throw /exceeds/.test((error as Error).message) ? tooLong() : error; }
};
// The title and body alone, as the prompt serializes them (escaping included): an issue that cannot fit reads nothing more.
carried({ number, title, body, comments: [] });
let total = Buffer.byteLength(title) + Buffer.byteLength(body);
const collaborators = await this.#loadCollaborators(signal);
const comments: string[] = [];
const collaborators = includeEveryComment && !options.expectedAccess ? null : await this.#loadCollaborators(signal);
if (options.expectedAccess) {
const collaborator = authorLogin !== null && collaborators!.has(authorLogin.toLocaleLowerCase('en-US'));
const currentAuthor = await this.#loadIssueAuthor(number, signal);
if (currentAuthor !== authorLogin || options.expectedAccess.number !== number || options.expectedAccess.authorLogin !== currentAuthor
|| options.expectedAccess.collaborator !== collaborator)
throw new Error(`Issue #${number}'s author or collaborator access changed during admission.`);
}
const comments: string[] = [], includedCommentAuthors = new Set<string>();
for (let page = 1; ; page++) {
const listed = await this.run(['api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json',
`repos/${this.repository}/issues/${number}/comments`, '-f', `per_page=${PAGE_SIZE}`, '-f', `page=${page}`], { signal });
Expand All @@ -275,9 +338,13 @@ export class GhIssueGateway implements IssueGateway {
const comment = object(value, 'GitHub returned a malformed comment.');
// A deleted ("ghost") author is nobody's collaborator. Other people's comments are dropped before their body is
// checked, so none of theirs can make the issue unreadable.
if (comment.user === null) continue;
const author = login(object(comment.user, 'GitHub returned an invalid comment author.').login, 'issue author');
if (!collaborators.has(author.toLocaleLowerCase('en-US'))) continue;
if (!includeEveryComment) {
if (comment.user === null) continue;
const author = login(object(comment.user, 'GitHub returned an invalid comment author.').login, 'issue author');
const authorKey = author.toLocaleLowerCase('en-US');
if (!collaborators!.has(authorKey)) continue;
includedCommentAuthors.add(authorKey);
}
const text = boundedString(comment.body, 'comment', MAX_BODY_LENGTH, true);
total += Buffer.byteLength(text);
if (total > MAX_PROMPT_BYTES) throw tooLong();
Expand All @@ -287,6 +354,13 @@ export class GhIssueGateway implements IssueGateway {
}
const text = { number, title, body, comments };
carried(text);
if (options.expectedAccess) {
const current = await this.#loadIssueAccess(number, signal);
if (current.access.number !== options.expectedAccess.number || current.access.authorLogin !== options.expectedAccess.authorLogin
|| current.access.collaborator !== options.expectedAccess.collaborator
|| [...includedCommentAuthors].some(author => !current.collaborators.has(author)))
throw new Error(`Issue #${number}'s author or collaborator access changed during admission.`);
}
return text;
});
}
Expand Down
Loading
Loading