Skip to content
41 changes: 26 additions & 15 deletions core/planning-suggestions.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { identityKey, type PlanIdentity } from './identity.ts';
import { prepareSuggestions, type AuthorInput, type AuthorProvider } from './planning-author.ts';
import { prepareDraft, prepareSuggestions, type AuthorInput, type AuthorProvider, type PreparedAuthor } from './planning-author.ts';
import type { Diagnostic, EditReply, Plan } from './plan.ts';

/** Implemented by the existing Store. No SQL or second persistence writer in core. */
export interface SuggestionStore {
getPlan(identity: PlanIdentity): Plan;
getSnapshot(identity: PlanIdentity): { id: string; base: string; head: string };
beginSuggestions(identity: PlanIdentity, expected: { revision: number; snapshotId: string }): string;
beginSuggestions(identity: PlanIdentity, expected: { revision: number; snapshotId: string }, mode: PlanningMode): string;
completeSuggestions(identity: PlanIdentity, id: string, reply: unknown): void;
settleSuggestion(identity: PlanIdentity, id: string, expected: { revision: number; snapshotId: string }, outcome: { state: 'failed' | 'cancelled' | 'invalidated'; reason: string }): boolean;
getSuggestions(identity: PlanIdentity, id: string): { state: string; revision: number; snapshotId: string | null; reply: EditReply | null; reason: string | null };
}
/**
* A planning request (#124): typed edit cards for the current plan, or a whole draft of its next revision. `revision`
* in the input is always the current revision the request is bound to.
*/
export type PlanningMode = 'suggest' | 'draft';
export type SuggestionInput = Omit<AuthorInput, 'requestId' | 'previousPlan'> & { snapshotId: string };
export type SuggestionOutcome =
| { state: 'completed'; id: string; warnings: Diagnostic[] }
Expand All @@ -32,7 +37,10 @@ function retainDiagnostic(reason: string | null, diagnostic: string): string {
return reason && reason !== diagnostic ? `${reason} ${diagnostic}` : diagnostic;
}

/** One instance per runner. Close it before closing Store. Not a cross-process scheduler. */
/**
* One instance per runner. Close it before closing Store. Not a cross-process scheduler. Suggestions and drafts share
* one active invocation per plan.
*/
export class SuggestionCoordinator {
#store: SuggestionStore;
#provider: AuthorProvider;
Expand All @@ -45,18 +53,21 @@ export class SuggestionCoordinator {
throw new Error('Suggestion timeout must fit a positive timer interval.');
this.#store = store; this.#provider = provider; this.#timeoutMs = timeoutMs;
}
start(input: SuggestionInput): SuggestionHandle {
if (this.#closing) throw new Error('Suggestion coordinator is closing.');
start(input: SuggestionInput, mode: PlanningMode = 'suggest'): SuggestionHandle {
if (this.#closing) throw new Error('Planning coordinator is closing.');
const identity = { ...input.context.identity }, key = identityKey(identity);
if (this.#active.has(key)) throw new Error('A suggestion invocation is still active for this plan.');
if (this.#active.has(key)) throw new Error('A planning invocation is still active for this plan.');
const snapshotId = input.snapshotId;
const previousPlan = this.#store.getPlan(identity), snapshot = this.#store.getSnapshot(identity);
if (previousPlan.revision !== input.revision) throw new Error('Stale plan revision.');
if (snapshot.id !== snapshotId || snapshot.base !== input.repo.baseSha) throw new Error('Stale repository snapshot.');
// Validate before allocating a durable request; no await permits local state changes.
const prepared = prepareSuggestions({ ...input, requestId: 'pending', previousPlan });
const prepared: PreparedAuthor<unknown> = mode === 'draft'
? prepareDraft({ ...input, revision: input.revision + 1, requestId: 'pending', previousPlan })
: prepareSuggestions({ ...input, requestId: 'pending', previousPlan });
const expected = Object.freeze({ revision: input.revision, snapshotId });
const id = this.#store.beginSuggestions(identity, expected);
const noun = mode === 'draft' ? 'Draft' : 'Suggestion';
const id = this.#store.beginSuggestions(identity, expected, mode);
const request = Object.freeze({ ...prepared.request, requestId: id });
const controller = new AbortController();
let stopped: Omit<TerminalOutcome, 'id'> | undefined;
Expand Down Expand Up @@ -84,11 +95,11 @@ export class SuggestionCoordinator {
catch (error) { stopped.reason += ` Request cleanup failed: ${String(error)}`; }
finally { controller.abort(new Error(reason)); }
};
const timer = setTimeout(() => stop('failed', 'Suggestion invocation timed out.'), this.#timeoutMs);
const timer = setTimeout(() => stop('failed', `${noun} invocation timed out.`), this.#timeoutMs);
let finish!: (result: SuggestionOutcome) => void;
const result = new Promise<SuggestionOutcome>(resolve => { finish = resolve; });
const handle: SuggestionHandle = Object.freeze({ id, result,
cancel: (reason = 'Suggestion cancelled by user.') => stop('cancelled', reason) });
cancel: (reason = `${noun} cancelled by user.`) => stop('cancelled', reason) });
this.#active.set(key, { handle, stop });
// Defer invocation until the handle owns its slot, including synchronous provider errors.
void Promise.resolve().then(async () => {
Expand All @@ -101,11 +112,11 @@ export class SuggestionCoordinator {
if (afterInvocation) outcome = { id, ...afterInvocation };
else {
const current = this.#store.getSuggestions(identity, id);
if (this.#store.getPlan(identity).revision !== request.revision ||
if (this.#store.getPlan(identity).revision !== expected.revision ||
this.#store.getSnapshot(identity).id !== snapshot.id || current.state === 'invalidated') {
outcome = { id, state: 'stale', reason: 'Plan revision or snapshot changed during authoring.' };
} else if (current.state !== 'pending') {
outcome = { id, state: current.state === 'cancelled' ? 'cancelled' : 'stale', reason: `Suggestion request is ${current.state}.` };
outcome = { id, state: current.state === 'cancelled' ? 'cancelled' : 'stale', reason: `${noun} request is ${current.state}.` };
} else {
const validated = prepared.validate(source);
this.#store.completeSuggestions(identity, id, validated.value);
Expand All @@ -120,11 +131,11 @@ export class SuggestionCoordinator {
if (!stopped) {
try {
const current = this.#store.getSuggestions(identity, id);
if (current.state === 'invalidated' || this.#store.getPlan(identity).revision !== request.revision ||
if (current.state === 'invalidated' || this.#store.getPlan(identity).revision !== expected.revision ||
this.#store.getSnapshot(identity).id !== snapshot.id)
outcome = { id, state: 'stale', reason: `Plan revision or snapshot changed before publication. ${outcome.reason}` };
else if (current.state === 'cancelled')
outcome = { id, state: 'cancelled', reason: `Suggestion request was cancelled before publication. ${outcome.reason}` };
outcome = { id, state: 'cancelled', reason: `${noun} request was cancelled before publication. ${outcome.reason}` };
} catch { /* Preserve the original error if durable state cannot be read. */ }
}
}
Expand All @@ -151,7 +162,7 @@ export class SuggestionCoordinator {
this.#closing = true;
const active = [...this.#active.values()];
this.#closePromise = Promise.all(active.map(job => job.handle.result)).then(() => undefined);
for (const job of active) job.stop('cancelled', 'Suggestion coordinator is closing.');
for (const job of active) job.stop('cancelled', 'Planning coordinator is closing.');
return this.#closePromise;
}
}
8 changes: 4 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ Dependencies point downward only. `core/` imports nothing that does input or out
| Part | Main files | Owns | State |
|---|---|---|---|
| Command line | `web/cli.ts` | Parses `--demo` or `--config`, takes the single-runner lock, starts the server, releases the lock on Ctrl+C. | In use |
| HTTP server | `web/server.ts` | Binds `127.0.0.1` only. Checks the token, Host and Origin. Bounded JSON bodies. Routes `/api/review`, `/api/action`, `/api/questions`, `/api/merge`, `/api/issues`, `/api/settings`, plus `/api/runner` and `/api/plan/*` when a runner or planning provider is supplied. Ordered shutdown. | In use; `/api/plan/suggestions` runs Claude through lane D for a review with a `github` block (#117) |
| HTTP server | `web/server.ts` | Binds `127.0.0.1` only. Checks the token, Host and Origin. Bounded JSON bodies. Routes `/api/review`, `/api/action`, `/api/questions`, `/api/merge`, `/api/issues`, `/api/settings`, plus `/api/runner` and `/api/plan/*` when a runner or planning provider is supplied. Ordered shutdown. | In use; `/api/plan/suggestions` and `/api/plan/drafts` run Claude through lane D for a review with a `github` block (#117, #124) |
| Browser screen | `web/public/*` | Review screen and Issues screen. Plain JavaScript with IBM Plex fonts served locally. Sends item and segment IDs and a state token, never fingerprints or ownership. | Yes |
| 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. | Yes |
| Ask | `runner/questions.ts`, `runner/question-*.ts` | Answers a question about one plan item with a read-only agent. Runs lane D's synchronous setup in a worker thread so the server stays responsive. Tracks leftover containers. | Yes |
Expand All @@ -169,12 +169,12 @@ Dependencies point downward only. `core/` imports nothing that does input or out
| 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. Pauses in needs amendment on an out-of-scope edit; moves to needs human on a safety violation. | Not connected; uses a test workspace until #66 part 2 and #87 |
| Publishing | `runner/publish.ts`, `runner/publishing.ts`, `runner/branch-push.ts` | Runs the already-fixed check, pushes the task head, then opens or reuses the task's pull request (a draft when the task needs a person). Records each opening before calling GitHub, so a lost outcome can be found again by a marker. `TaskPublishing` (#103) publishes when a run ends, on the `publish` runner action, and once at startup, and records the last outcome. On cancel it closes the task's PRs (#111). | With a runner block (needs `github.baseBranch`); never in a demo |
| Lock and startup recovery | `runner/recovery.ts` | One runner per database, held as an OS lock on the database file's device and inode. Startup recovery finalizes interrupted attempts and removes leftover containers and storage. | Lock in use; recovery not connected |
| Store | `runner/store.ts` | The only database handle and all SQL. Revision numbers, snapshots, ledger, approvals, choices, notes, suggestion requests, tasks, attempts, user actions, feedback events, merge attempts, pull request openings, already-fixed results. | In use |
| Store | `runner/store.ts` | The only database handle and all SQL. Revision numbers, snapshots, ledger, approvals, choices, notes, suggestion and draft requests, tasks, attempts, user actions, feedback events, merge attempts, pull request openings, already-fixed results. | In use |
| Plan logic | `core/plan.ts`, `core/parse-v1.ts`, `schema/` | Schema validation, YAML and JSON import, meaning checks (IDs, dependencies, paths, projected file operations), literal command parsing, suggestion edits. | Yes |
| Linking engine | `core/linking.ts` | Replays each commit's line changes and assigns every final line to an owner from the ledger. Produces segments and the Unplanned and Ambiguous rows. | Yes |
| Approvals | `core/approvals.ts` | Approval fingerprints, dependency staleness, assignments, and accept-as-is choices. | Yes |
| Issue ranking | `core/issue-ranking.ts`, `github/issues.ts`, `web/issues.ts` | Deterministic, explainable issue score. Repository-scoped trust of issue authors. | Yes |
| Planning | `core/planning-author.ts`, `core/planning-suggestions.ts`, `prompts/plan-author.md` | Builds bounded planning prompts and validates plan drafts and suggestion cards. Does not run an agent itself. | Library tested; planning API not connected; no screen |
| Planning | `core/planning-author.ts`, `core/planning-suggestions.ts`, `prompts/plan-author.md` | Builds bounded planning prompts and validates plan drafts and suggestion cards. Does not run an agent itself. | In use through `/api/plan/*` (#117, #124); no screen yet |
| Execution prompt and audit | `core/execution-prompt.ts`, `core/run-audit.ts`, `prompts/execute.md` | Builds the execute and fix prompt with untrusted text only in escaped data blocks. Audits the change manifest after a run: safety violations first (Git metadata, links that leave the repository, agent commits), then scope against the declared files. | Not connected |
| Pull request text | `core/pull-request-body.ts` | Title and description with the plan. Plan and agent text go in fenced blocks, and issue references and @-mentions are neutralized, so the text cannot close other issues or notify people. | Not connected |
| Git adapter | `git/history.ts`, `git/clone.ts` | Bounded, hardened history reads. Standalone task clones with no links back to your repository. Every Git command runs with an allowlisted environment. | In use |
Expand Down Expand Up @@ -351,7 +351,7 @@ All state is in one SQLite file, opened with WAL and full synchronization. Each

| Group | Tables | Notes |
|---|---|---|
| Plans | `plans`, `revisions`, `requests` | SQLite allocates revision numbers. Old revisions are never changed. Suggestion requests are bound to a revision and snapshot. |
| Plans | `plans`, `revisions`, `requests` | SQLite allocates revision numbers. Old revisions are never changed. Suggestion and draft requests (`mode`, #124) are bound to a revision and snapshot. |
| Code history | `snapshots`, `ledger`, `rewrites` | Ledger entries are immutable. Rebase mappings record which old commit became which new one; foreign stays foreign. |
| Review | `approvals`, `choices`, `review_notes`, `checkpoints`, `continuations` | Stored approvals are claims about a past snapshot. Freshness is recomputed every time. |
| Runner | `tasks`, `attempts`, `user_actions`, `feedback_events`, `merge_attempts`, `app_settings` | User actions carry idempotency keys. Feedback events are append-only and feed the future learning feature. |
Expand Down
Loading
Loading