Plan first: an agent task can plan before it builds, then be approved, changed or split (phase 3c) (#104)
* Plan first: a task can plan in a read-only sandbox before it builds, then be approved, changed or split (WIP) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 * Plan first: the settings form sets and checks plan_first in the project e2e Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 * Plan first e2e: a custom command's transcript doesn't echo its prompt Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 * Plan first e2e: follow-ups return 201; check an approved plan can't be split once a coordinator exists Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014VDQqT8oL18uLJ9fpsNxW2 --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 227d9a0commit 6e0e611578fa21f83fbc6475c22704437329f677Browse files
13 files changed+503−58
Files (13)
- apps/diffinery/docs/agents/projects.md+11−3
- apps/diffinery/docs/agents/tasks.md+18−2
- apps/diffinery/src/agentic/ProjectDO.ts+107−15
- apps/diffinery/src/agentic/api.ts+13−3
- apps/diffinery/src/agentic/project-web.tsx+8−3
- apps/diffinery/src/agentic/project.ts+18−2
- apps/diffinery/src/agentic/projects.ts+27−10
- apps/diffinery/src/agentic/runtimes.ts+22−4
- apps/diffinery/src/agentic/tasks-web.tsx+80−8
- apps/diffinery/src/agentic/tasks.ts+20−3
- apps/diffinery/test/e2e/tasks.test.mjs+142−3
- apps/diffinery/test/unit/project.test.ts+13−1
- apps/diffinery/test/unit/tasks.test.ts+24−1
modifiedapps/diffinery/docs/agents/projects.md+11−3View file
| @@ -87,6 +87,14 @@ | ||
| 87 | 87 | |
| 88 | 88 | Text every agent task and [agent review](/docs/agents/review) in the repository gets in its prompt: for example, which tests to run before opening a pull request. Up to 10,000 characters. Reviews also follow `REVIEW.md` files. |
| 89 | 89 | |
| 90 | +### Plan first | |
| 91 | + | |
| 92 | +Whether new tasks [plan before they build](/docs/agents/tasks#plan-first): | |
| 93 | + | |
| 94 | +- **Off** (the default): tasks build at once, unless someone ticks **Plan first**. | |
| 95 | +- **On by default**: **Plan first** is ticked on new tasks, and people can untick it. | |
| 96 | +- **Required**: every task plans first, ticked or not, including the tasks coordinators start. | |
| 97 | + | |
| 90 | 98 | ### How many at once |
| 91 | 99 | |
| 92 | 100 | How many tasks run at the same time in the repository, from 1 to 10. The default is 3. A task started beyond it is **Queued**: its page says so, and it starts, oldest first, when a running task finishes or stops, or when an admin raises the number. Follow-ups and retries queue the same way. Agent reviews don't count toward the number and never wait. |
| @@ -100,7 +108,7 @@ | ||
| 100 | 108 | | `POST .../diffinery/project/posts` | Post `{ "body": "…" }`; add `"coordinator": false` to skip your coordinator | Write access; browser session only | |
| 101 | 109 | | `GET .../diffinery/project/memory` | The memory with its versions | Write access; any token | |
| 102 | 110 | | `PUT .../diffinery/project/memory` | Save `{ "body": "…" }`, or restore `{ "restore": 4 }` | Write access; browser session only | |
| 103 | -| `PUT .../diffinery/project/settings` | Set `{ "max_running": 2, "instructions": "…" }` | Admin; browser session only | | |
| 111 | +| `PUT .../diffinery/project/settings` | Set `{ "max_running": 2, "instructions": "…", "plan_first": "default" }` (`plan_first` is `off`, `default` or `required`) | Admin; browser session only | | |
| 104 | 112 | | `GET .../diffinery/project/coordinator` | Your coordinator's settings | Write access; any token | |
| 105 | 113 | | `PUT .../diffinery/project/coordinator` | Turn yours on or change it: `{ "agent": "my-bot", "runtime": "claude-code", "tokens_per_run": 1000000, "tokens_per_task": 2000000, "accept": true }` | Write access; browser session only | |
| 106 | 114 | | `DELETE .../diffinery/project/coordinator` | Turn yours off | Write access; browser session only | |
| @@ -113,13 +121,13 @@ | ||
| 113 | 121 | |
| 114 | 122 | ```json |
| 115 | 123 | { |
| 116 | - "settings": { "max_running": 3, "instructions": "Run pnpm test before opening a pull request." }, | |
| 124 | + "settings": { "max_running": 3, "instructions": "Run pnpm test before opening a pull request.", "plan_first": "off" }, | |
| 117 | 125 | "memory": { "body": "Docs live in docs/.", "version": 4, "updated_by": { "login": "ada", "type": "User" }, "updated_at": "2026-10-13T09:12:00Z" }, |
| 118 | 126 | "board": { "waiting": [], "working": [{ "number": 7, "state": "running", "…": "…" }], "queued": [], "review": [], "landing": [], "done": [], "idle": [] } |
| 119 | 127 | } |
| 120 | 128 | ``` |
| 121 | 129 | |
| 122 | -Saving memory and settings is recorded in the audit log as `project.memory` and `project.settings`, and coordinator changes as `project.coordinator`. A task a coordinator started has `started_via` (with the post), `token_budget` and `waits_for` in its JSON. | |
| 130 | +Saving memory and settings is recorded in the audit log as `project.memory` and `project.settings`, and coordinator changes as `project.coordinator`. A task a coordinator started has `started_via` (with the post), `token_budget` and `waits_for` in its JSON; a task split from a plan has `parent_task`. | |
| 123 | 131 | |
| 124 | 132 | ## MCP |
| 125 | 133 |
modifiedapps/diffinery/docs/agents/tasks.md+18−2View file
| @@ -23,7 +23,8 @@ | ||
| 23 | 23 | 2. Write the **goal**, as you would ask a colleague. Markdown works. |
| 24 | 24 | 3. Pick the **runtime**: Claude Code, Codex or **Custom command** (see [Runtimes](#runtimes)). |
| 25 | 25 | 4. Pick the **agent** and the **base branch**. |
| 26 | -5. Click **Start task**. | |
| 26 | +5. Tick **Plan first** to have the agent plan before it builds (see [Plan first](#plan-first)). | |
| 27 | +6. Click **Start task**. | |
| 27 | 28 | |
| 28 | 29 | The task page shows the transcript live: what the agent says, the commands it runs and their output. |
| 29 | 30 | |
| @@ -50,6 +51,18 @@ | ||
| 50 | 51 | | **Failed** | The last run failed: the runtime exited with an error, the push was refused, or you lost access. The transcript says why. | |
| 51 | 52 | | **Stopped** | Someone stopped it. | |
| 52 | 53 | |
| 54 | +A [plan-first](#plan-first) task also has a plan state: `planning` while its plan runs, `waiting` when the plan waits for you (the board shows it under **Waiting on you**), then `approved` or `split`. | |
| 55 | + | |
| 56 | +### Plan first | |
| 57 | + | |
| 58 | +A plan-first task's first run writes a plan instead of code. The agent gets a **read-only** sandbox, as an [agent review](/docs/agents/review) does: it reads the code and runs read-only commands, its git credential can't push, and nothing it changes is kept. The plan is its final message (for a custom command, everything it printed), up to 20,000 characters, and shows on the task page. Then you choose: | |
| 59 | + | |
| 60 | +- **Approve plan**: a build run starts with the goal and the plan. It, and every later run of the task, gets the plan in its prompt as the one you approved. | |
| 61 | +- **Send a follow-up** to ask for changes: the agent plans again, with its previous plan and your message. | |
| 62 | +- **Split into tasks**: your [coordinator](/docs/agents/projects#coordinator) gets the goal and the plan as a post of yours and starts tasks for its parts. Each says it was split from this task, which is then done with. You need a coordinator in the project for this. | |
| 63 | + | |
| 64 | +Repository admins can make plans the default, or require them for every task, including ones coordinators start (see [Plan first](/docs/agents/projects#plan-first)). | |
| 65 | + | |
| 53 | 66 | ### Steer, retry and stop |
| 54 | 67 | |
| 55 | 68 | - **Send a follow-up** from the task page to continue: a new run starts on the task's branch with the goal, how the previous run ended and your message, and its work goes to the same pull request. Sent while a run is active, the message waits and starts when that run ends. |
| @@ -136,9 +149,11 @@ | ||
| 136 | 149 | | `GET /repos/{owner}/{repo}/diffinery/tasks` | List tasks | Write access; any token | |
| 137 | 150 | | `GET /repos/{owner}/{repo}/diffinery/tasks/{number}` | A task with its runs | Write access; any token | |
| 138 | 151 | | `GET /repos/{owner}/{repo}/diffinery/tasks/{number}/transcript?after=0` | Transcript lines | Write access; any token | |
| 139 | -| `POST /repos/{owner}/{repo}/diffinery/tasks` | Start a task | Browser session only | | |
| 152 | +| `POST /repos/{owner}/{repo}/diffinery/tasks` | Start a task; `"plan_first": true` or `false` overrides the project's default, unless it requires plans | Browser session only | | |
| 140 | 153 | | `POST .../tasks/{number}/messages` | Send a follow-up | Browser session only, the person who started it | |
| 141 | 154 | | `POST .../tasks/{number}/retry` | Retry | Browser session only, the person who started it | |
| 155 | +| `POST .../tasks/{number}/plan/approve` | Approve a waiting plan: the build run starts | Browser session only, the person who started it | | |
| 156 | +| `POST .../tasks/{number}/plan/split` | Hand a waiting plan to your coordinator to split into tasks | Browser session only, the person who started it | | |
| 142 | 157 | | `POST .../tasks/{number}/stop` | Stop | The person who started it, its agent or a repository admin | |
| 143 | 158 | | `GET /diffinery/tasks` | Tasks you started, across repositories | Any token | |
| 144 | 159 | | `GET /diffinery/model-keys` | Your saved keys (provider, last four characters, dates) | Any token | |
| @@ -158,6 +173,7 @@ | ||
| 158 | 173 | "runtime": "claude-code", |
| 159 | 174 | "agent": { "login": "ayse-bot", "type": "Bot" }, |
| 160 | 175 | "started_by": { "login": "ayse", "type": "User" }, |
| 176 | + "plan_first": false, | |
| 161 | 177 | "base": "main", |
| 162 | 178 | "branch": "agent/7-fix-the-contact-sales-link-on-the", |
| 163 | 179 | "pull_request": { "number": 42, "html_url": "https://diffinery.com/acme/website/pull/42" }, |
modifiedapps/diffinery/src/agentic/ProjectDO.ts+107−15View file
| @@ -7,10 +7,10 @@ | ||
| 7 | 7 | import { PROVIDERS, type Provider } from "./keys.js"; |
| 8 | 8 | import { enqueueMail } from "../mail/send.js"; |
| 9 | 9 | import { formatTokens, pinRefusal, weighted, type Usage } from "./usage.js"; |
| 10 | -import { lineDiff, MAX_RUNNING_DEFAULT, MEMORY_VERSIONS, projectContext, type MemoryRow, type PostRow, type ProjectSettings } from "./project.js"; | |
| 10 | +import { isPlanFirst, lineDiff, MAX_PLAN, MAX_POST, MAX_RUNNING_DEFAULT, MEMORY_VERSIONS, projectContext, type MemoryRow, type PlanState, type PostRow, type ProjectSettings } from "./project.js"; | |
| 11 | 11 | import { COORDINATOR_MAX_MODEL_CALLS, COORDINATOR_TIMEOUT_MS, COORDINATOR_TOKENS_DEFAULT, coordinatorPrompt, parseActions, TASKS_PER_DAY, TASKS_PER_RUN, type CoordinatorSettings } from "./coordinator.js"; |
| 12 | 12 | import { countText, dayAgo, findingComment, rulesFiles, findingId, MAX_AUTOFIXES, placeFindings, REPO_DAILY_DEFAULT, REVIEW_APP, REVIEW_CHECK, REVIEW_CREATOR, REVIEW_MAX_MODEL_CALLS, REVIEW_TIMEOUT_MS, REVIEW_TOKENS_DEFAULT, reviewBody, reviewLimit, reviewOutput, reviewPrompt, fixGoal, shouldMailLimit, type Finding, type PriorFinding, type ReviewLimit, type ReviewSettings } from "./review.js"; |
| 13 | -import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, runtimeLabel, taskBranch, taskTitle, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js"; | |
| 13 | +import { agentEnv, finalMessage, INSTALL_TIMEOUT_MS, planPrompt, RUN_TIMEOUT_MS, RUNTIMES, runPrompt, runtimeLabel, taskBranch, taskTitle, type ModelRoute, type RuntimeId, type TranscriptLine } from "./runtimes.js"; | |
| 14 | 14 | |
| 15 | 15 | /** |
| 16 | 16 | * One per repository: its agent tasks. A task is a goal a person gives one of |
| @@ -77,6 +77,13 @@ | ||
| 77 | 77 | token_budget: number | null; |
| 78 | 78 | /** Tasks (a JSON list of numbers) that must be ready before this one's first run starts. */ |
| 79 | 79 | after_tasks: string | null; |
| 80 | + /** Plan first: its first runs write a plan in a read-only sandbox, for the person to approve, change or split. */ | |
| 81 | + plan_first: number; | |
| 82 | + plan_state: PlanState | null; | |
| 83 | + /** The latest plan; once approved, every later run's prompt carries it. */ | |
| 84 | + plan: string | null; | |
| 85 | + /** A task a coordinator started while splitting another task's plan: that task. */ | |
| 86 | + parent_task: number | null; | |
| 80 | 87 | created_at: string; |
| 81 | 88 | updated_at: string; |
| 82 | 89 | }; |
| @@ -85,7 +92,7 @@ | ||
| 85 | 92 | task: number; |
| 86 | 93 | seq: number; |
| 87 | 94 | /** For a review run, `message` is who asked for it (null when automatic); for a coordinator run, the posts it handles (a JSON list). */ |
| 88 | - kind: "start" | "message" | "retry" | "review" | "coordinate"; | |
| 95 | + kind: "start" | "message" | "retry" | "review" | "coordinate" | "plan" | "build"; | |
| 89 | 96 | message: string | null; |
| 90 | 97 | prompt: string; |
| 91 | 98 | providers: string; |
| @@ -243,6 +250,11 @@ | ||
| 243 | 250 | ["posts", "handled_by", "handled_by INTEGER"], |
| 244 | 251 | ["posts", "memory_version", "memory_version INTEGER"], |
| 245 | 252 | ["runs", "applied_at", "applied_at TEXT"], |
| 253 | + ["tasks", "plan_first", "plan_first INTEGER NOT NULL DEFAULT 0"], | |
| 254 | + ["tasks", "plan_state", "plan_state TEXT"], | |
| 255 | + ["tasks", "plan", "plan TEXT"], | |
| 256 | + ["tasks", "parent_task", "parent_task INTEGER"], | |
| 257 | + ["posts", "split_of", "split_of INTEGER"], | |
| 246 | 258 | ]; |
| 247 | 259 | |
| 248 | 260 | /** A finding as stored. */ |
| @@ -292,7 +304,9 @@ | ||
| 292 | 304 | /** A title other than the goal's first line. */ |
| 293 | 305 | title?: string | null; |
| 294 | 306 | /** Started by a coordinator handling this post, with a token budget per run, after these tasks are ready. */ |
| 295 | - coordinator?: { post: number; tokenBudget: number; after: number[] }; | |
| 307 | + coordinator?: { post: number; tokenBudget: number; after: number[]; parent?: number | null }; | |
| 308 | + /** Plan first (agentic/project.ts): the first run writes a plan for the person to approve. */ | |
| 309 | + planFirst?: boolean; | |
| 296 | 310 | } |
| 297 | 311 | |
| 298 | 312 | export interface ReviewRequest { |
| @@ -344,12 +358,14 @@ | ||
| 344 | 358 | |
| 345 | 359 | projectSettings(): ProjectSettings { |
| 346 | 360 | const kv = new Map(this.sql.exec<{ k: string; v: string }>("SELECT k, v FROM project_settings").toArray().map((r) => [r.k, r.v])); |
| 347 | - return { maxRunning: Number(kv.get("max_running") ?? MAX_RUNNING_DEFAULT) || MAX_RUNNING_DEFAULT, instructions: kv.get("instructions") ?? "" }; | |
| 361 | + const planFirst = kv.get("plan_first"); | |
| 362 | + return { maxRunning: Number(kv.get("max_running") ?? MAX_RUNNING_DEFAULT) || MAX_RUNNING_DEFAULT, instructions: kv.get("instructions") ?? "", planFirst: isPlanFirst(planFirst) ? planFirst : "off" }; | |
| 348 | 363 | } |
| 349 | 364 | |
| 350 | 365 | setProjectSettings(s: Partial<ProjectSettings>): ProjectSettings { |
| 351 | 366 | if (s.maxRunning !== undefined) this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('max_running', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", String(s.maxRunning)); |
| 352 | 367 | if (s.instructions !== undefined) this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('instructions', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", s.instructions); |
| 368 | + if (s.planFirst !== undefined) this.sql.exec("INSERT INTO project_settings (k, v) VALUES ('plan_first', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", s.planFirst); | |
| 353 | 369 | // More room may start queued tasks. |
| 354 | 370 | this.promote(); |
| 355 | 371 | return this.projectSettings(); |
| @@ -468,7 +484,7 @@ | ||
| 468 | 484 | const branch = input.pull?.head ?? taskBranch(number, input.title || input.goal); |
| 469 | 485 | const title = input.title ? taskTitle(input.title) : taskTitle(input.goal); |
| 470 | 486 | this.sql.exec( |
| 471 | - "INSERT INTO tasks (number, id, repo_id, goal, title, runtime, command, via, model, agent_id, agent_login, person_id, person_login, base, branch, state, pr_number, autofix_of, coordinator_post, token_budget, after_tasks, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?)", | |
| 487 | + "INSERT INTO tasks (number, id, repo_id, goal, title, runtime, command, via, model, agent_id, agent_login, person_id, person_login, base, branch, state, pr_number, autofix_of, coordinator_post, token_budget, after_tasks, plan_first, plan_state, parent_task, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 472 | 488 | number, |
| 473 | 489 | crypto.randomUUID(), |
| 474 | 490 | input.repoId, |
| @@ -489,11 +505,14 @@ | ||
| 489 | 505 | input.coordinator?.post ?? null, |
| 490 | 506 | input.coordinator?.tokenBudget ?? null, |
| 491 | 507 | input.coordinator?.after.length ? JSON.stringify(input.coordinator.after) : null, |
| 508 | + input.planFirst ? 1 : 0, | |
| 509 | + input.planFirst ? "planning" : null, | |
| 510 | + input.coordinator?.parent ?? null, | |
| 492 | 511 | at, |
| 493 | 512 | at, |
| 494 | 513 | ); |
| 495 | 514 | this.event(number, `${input.autofixOf ? "Autofix" : input.coordinator ? `${input.person.login}'s coordinator` : input.person.login} started task #${number}: ${title}`); |
| 496 | - this.startRun(number, "start", null, input.providers); | |
| 515 | + this.startRun(number, input.planFirst ? "plan" : "start", null, input.providers); | |
| 497 | 516 | await this.index(number); |
| 498 | 517 | return this.get(number)!; |
| 499 | 518 | } |
| @@ -508,7 +527,8 @@ | ||
| 508 | 527 | this.sql.exec("UPDATE tasks SET queued = ?, updated_at = ? WHERE number = ?", t.queued ? `${t.queued}\n\n${text}` : text, now(), number); |
| 509 | 528 | this.note(number, this.lastRun(number)?.seq ?? 0, `${from ?? t.person_login} sent a message; it starts a new run when this one ends.`); |
| 510 | 529 | } else { |
| 511 | - this.startRun(number, "message", text, providers); | |
| 530 | + // While a plan waits, a message asks for changes to it: another plan run. | |
| 531 | + this.startRun(number, t.plan_state === "planning" || t.plan_state === "waiting" ? "plan" : "message", text, providers); | |
| 512 | 532 | } |
| 513 | 533 | await this.index(number); |
| 514 | 534 | return this.get(number)!; |
| @@ -518,7 +538,7 @@ | ||
| 518 | 538 | this.mustTask(number); |
| 519 | 539 | if (this.activeRun(number)) throw new HttpError(409, "the task is running; stop it before retrying"); |
| 520 | 540 | const last = this.lastRun(number); |
| 521 | - this.startRun(number, "retry", last?.message ?? null, providers); | |
| 541 | + this.startRun(number, last?.kind === "plan" ? "plan" : "retry", last?.message ?? null, providers); | |
| 522 | 542 | await this.index(number); |
| 523 | 543 | return this.get(number)!; |
| 524 | 544 | } |
| @@ -535,6 +555,40 @@ | ||
| 535 | 555 | return this.get(number)!; |
| 536 | 556 | } |
| 537 | 557 | |
| 558 | + // ---------------------------------------------------------------- plan first | |
| 559 | + | |
| 560 | + /** Approve a waiting plan: the build run starts with the plan in its prompt, as does every later run. */ | |
| 561 | + async approvePlan(number: number, providers: Provider[], by: string): Promise<TaskView> { | |
| 562 | + const t = this.waitingPlan(number); | |
| 563 | + this.sql.exec("UPDATE tasks SET plan_state = 'approved' WHERE number = ?", number); | |
| 564 | + this.event(number, `${by} approved the plan of task #${number}.`); | |
| 565 | + this.startRun(t.number, "build", null, providers); | |
| 566 | + await this.index(number); | |
| 567 | + return this.get(number)!; | |
| 568 | + } | |
| 569 | + | |
| 570 | + /** | |
| 571 | + * Split a waiting plan into tasks: the person's coordinator gets it as a post of theirs, and the tasks it starts | |
| 572 | + * link back to this one, which is then done with. | |
| 573 | + */ | |
| 574 | + async splitPlan(number: number, settings: CoordinatorSettings, providers: Provider[], repo: { id: string; base: string }): Promise<{ post: number; task: number; status: "started" | "queued" }> { | |
| 575 | + const t = this.waitingPlan(number); | |
| 576 | + const head = `Split the plan of task #${number} (${t.title}) into tasks. Its goal was:\n\n${t.goal}\n\nThe plan:\n\n`; | |
| 577 | + const p = this.addPost("person", settings.person, `${head}${t.plan!}`.slice(0, MAX_POST), number); | |
| 578 | + this.sql.exec("UPDATE posts SET split_of = ? WHERE n = ?", number, p.n); | |
| 579 | + this.sql.exec("UPDATE tasks SET plan_state = 'split', updated_at = ? WHERE number = ?", now(), number); | |
| 580 | + const out = await this.handlePost(p.n, settings, providers, repo); | |
| 581 | + await this.index(number); | |
| 582 | + return { post: p.n, ...out }; | |
| 583 | + } | |
| 584 | + | |
| 585 | + private waitingPlan(number: number): TaskRow { | |
| 586 | + const t = this.mustTask(number); | |
| 587 | + if (t.plan_state !== "waiting" || !t.plan) throw new HttpError(409, t.plan_state === "planning" ? "the plan isn't ready yet" : "this task has no plan waiting for approval"); | |
| 588 | + if (this.activeRun(number)) throw new HttpError(409, "the task is running"); | |
| 589 | + return t; | |
| 590 | + } | |
| 591 | + | |
| 538 | 592 | // ---------------------------------------------------------------- agent review (agentic/review.ts) |
| 539 | 593 | |
| 540 | 594 | reviewSettings(): ReviewSettings | null { |
| @@ -731,7 +785,12 @@ | ||
| 731 | 785 | // project memory (a pull request mustn't be reviewed against notes an agent wrote); they get the instructions. |
| 732 | 786 | const own = t.kind !== "task"; |
| 733 | 787 | const context = own ? null : projectContext({ instructions: this.projectSettings().instructions, memory: this.memory()?.body ?? null }); |
| 734 | - const prompt = own ? "" : runPrompt({ goal: t.goal, number, branch: t.branch, message: message ?? undefined, previous, by: t.person_login, context }); | |
| 788 | + const prompt = own | |
| 789 | + ? "" | |
| 790 | + : kind === "plan" | |
| 791 | + ? planPrompt({ goal: t.goal, number, by: t.person_login, context, message, plan: t.plan }) | |
| 792 | + : runPrompt({ goal: t.goal, number, branch: t.branch, message: message ?? undefined, previous, by: t.person_login, context, plan: t.plan_state === "approved" ? t.plan : null }); | |
| 793 | + if (kind === "plan") this.sql.exec("UPDATE tasks SET plan_state = 'planning' WHERE number = ?", number); | |
| 735 | 794 | const at = now(); |
| 736 | 795 | // A task's run waits its turn under the project's "how many at once"; reviews and coordinators have their own limits. |
| 737 | 796 | const step: RunStep = own ? "boot" : "queued"; |
| @@ -743,7 +802,21 @@ | ||
| 743 | 802 | this.note(number, seq, `Handling ${posts.map((p) => `post #${p}`).join(", ")} as ${t.agent_login} with ${runtimeLabel(t.runtime, t)} and ${t.person_login}'s model key.`); |
| 744 | 803 | } else { |
| 745 | 804 | if (message) this.line(number, seq, "note", `${t.person_login}: ${message}`); |
| 746 | - this.note(number, seq, kind === "retry" ? "Retrying." : kind === "message" ? "Starting a new run with the message." : `${t.coordinator_post ? `${t.person_login}'s coordinator` : t.person_login} started the task with ${runtimeLabel(t.runtime, t)}.`); | |
| 805 | + this.note( | |
| 806 | + number, | |
| 807 | + seq, | |
| 808 | + kind === "retry" | |
| 809 | + ? "Retrying." | |
| 810 | + : kind === "message" | |
| 811 | + ? "Starting a new run with the message." | |
| 812 | + : kind === "build" | |
| 813 | + ? "The plan is approved: building it." | |
| 814 | + : kind === "plan" && seq > 1 | |
| 815 | + ? message | |
| 816 | + ? "Planning again with the changes asked for." | |
| 817 | + : "Planning again." | |
| 818 | + : `${t.coordinator_post ? `${t.person_login}'s coordinator` : t.person_login} started the task with ${runtimeLabel(t.runtime, t)}${kind === "plan" ? ". It plans first" : ""}.`, | |
| 819 | + ); | |
| 747 | 820 | } |
| 748 | 821 | if (step === "queued" && !this.promote().includes(number) && this.run(number, seq)?.step === "queued") { |
| 749 | 822 | const waits = this.waitsFor(t); |
| @@ -864,6 +937,7 @@ | ||
| 864 | 937 | case "finish": { |
| 865 | 938 | if (t.kind === "review") return this.collect(t, r); |
| 866 | 939 | if (t.kind === "coordinator") return this.coordinate(t, r); |
| 940 | + if (r.kind === "plan") return this.keepPlan(t, r); | |
| 867 | 941 | if (!r.proc) { |
| 868 | 942 | return this.startProc(r, "finish", FINISH_SCRIPT, { DIFFINERY_TASK_BRANCH: t.branch, DIFFINERY_TASK_START: r.start_sha ?? "", DIFFINERY_TASK_MESSAGE: commitMessage(t, r) }, {}, 10 * 60 * 1000, `Pushing to ${t.branch}.`); |
| 869 | 943 | } |
| @@ -881,6 +955,18 @@ | ||
| 881 | 955 | } |
| 882 | 956 | } |
| 883 | 957 | |
| 958 | + /** A plan run is done: keep the plan from its final message, and wait for the person. */ | |
| 959 | + private async keepPlan(t: TaskRow, r: RunRow): Promise<void> { | |
| 960 | + if ((r.exit_code ?? 0) !== 0) return this.endRun(r, "failed", `${RUNTIMES[t.runtime].name} exited with code ${r.exit_code}.`); | |
| 961 | + const lines = this.sql.exec<TranscriptLine>("SELECT seq, run, at, stream, text FROM lines WHERE task = ? AND run = ? AND stream = 'out' ORDER BY seq", t.number, r.seq).toArray(); | |
| 962 | + const text = (t.runtime === "custom" ? lines.map((l) => l.text).join("\n") : (finalMessage(lines, Infinity) ?? "")).trim(); | |
| 963 | + if (!text) return this.endRun(r, "failed", "The plan run ended without a plan in its final message."); | |
| 964 | + const plan = text.length > MAX_PLAN ? `${text.slice(0, MAX_PLAN)}\n\n… (plan cut at ${MAX_PLAN} characters)` : text; | |
| 965 | + this.sql.exec("UPDATE tasks SET plan = ?, plan_state = 'waiting' WHERE number = ?", plan, t.number); | |
| 966 | + this.note(t.number, r.seq, `The plan is ready for ${t.person_login}: approve it, ask for changes, or split it into tasks.`); | |
| 967 | + return this.endRun(r, "done", null); | |
| 968 | + } | |
| 969 | + | |
| 884 | 970 | private async boot(t: TaskRow, r: RunRow): Promise<void> { |
| 885 | 971 | const { caller, repo } = await this.checkAccess(t); |
| 886 | 972 | const stub = this.env.REPO.get(this.env.REPO.idFromName(t.repo_id)); |
| @@ -888,8 +974,10 @@ | ||
| 888 | 974 | const ref = branchSha ? t.branch : t.base; |
| 889 | 975 | const startSha = branchSha ?? (await stub.getRef(`refs/heads/${t.base}`)); |
| 890 | 976 | if (!startSha) throw new HttpError(422, `the branch ${t.base} doesn't exist in ${repo.owner_login}/${repo.name}`); |
| 891 | - this.note(t.number, r.seq, `Starting a sandbox on ${ref} as ${t.agent_login}.`); | |
| 892 | - const sb = await stub.createSandbox(caller, ref, { personId: t.person_id }); | |
| 977 | + // A plan run can't change code: its sandbox is read-only, as a review's. | |
| 978 | + const plan = r.kind === "plan"; | |
| 979 | + this.note(t.number, r.seq, `Starting a ${plan ? "read-only sandbox to plan" : "sandbox"} on ${ref} as ${t.agent_login}.`); | |
| 980 | + const sb = await stub.createSandbox(caller, ref, { personId: t.person_id, ...(plan ? { readOnly: true } : {}) }); | |
| 893 | 981 | // Stopped while the sandbox started: close it again. |
| 894 | 982 | const now_ = this.run(t.number, r.seq); |
| 895 | 983 | if (!now_ || now_.step !== "boot") { |
| @@ -974,6 +1062,8 @@ | ||
| 974 | 1062 | await this.checkAccess(t); |
| 975 | 1063 | const repo = await this.repoPath(t); |
| 976 | 1064 | const post = handled[0] ?? null; |
| 1065 | + // Splitting a plan: the tasks link back to the task whose plan it was. | |
| 1066 | + const splitOf = handled.map((n) => this.post1(n)?.split_of).find((n) => n) ?? null; | |
| 977 | 1067 | const providers = JSON.parse(r.providers) as Provider[]; |
| 978 | 1068 | // From here on its actions take effect; nothing awaits between the check above and this mark. |
| 979 | 1069 | if (this.run(r.task, r.seq)?.applied_at) return; |
| @@ -992,7 +1082,8 @@ | ||
| 992 | 1082 | person: settings.person, |
| 993 | 1083 | base: x.base ?? t.base, |
| 994 | 1084 | providers, |
| 995 | - coordinator: { post: post!, tokenBudget: settings.tokensPerTask, after: x.after.map((i) => started[i]!) }, | |
| 1085 | + coordinator: { post: post!, tokenBudget: settings.tokensPerTask, after: x.after.map((i) => started[i]!), parent: splitOf }, | |
| 1086 | + planFirst: this.projectSettings().planFirst === "required", | |
| 996 | 1087 | }); |
| 997 | 1088 | started.push(task.number); |
| 998 | 1089 | this.ctx.waitUntil(new Directory(this.env).audit({ actorId: t.person_id, actorLogin: t.person_login, action: "task.start", target: `${repo}#${task.number}`, detail: { coordinator: true, post, agent: settings.agent.login, runtime: settings.runtime, ...(settings.via ? { provider: settings.via, model: settings.model } : {}), token_budget: settings.tokensPerTask } })); |
| @@ -1299,11 +1390,12 @@ | ||
| 1299 | 1390 | if (t.queued && step !== "stopped") { |
| 1300 | 1391 | this.sql.exec("UPDATE tasks SET queued = NULL WHERE number = ?", r.task); |
| 1301 | 1392 | if (t.kind === "review") await this.restartReview(t, t.queued === "automatic" ? null : t.queued, JSON.parse(r.providers) as Provider[]); |
| 1302 | - else this.startRun(r.task, t.kind === "coordinator" ? "coordinate" : "message", t.queued, JSON.parse(r.providers) as Provider[]); | |
| 1393 | + else this.startRun(r.task, t.kind === "coordinator" ? "coordinate" : this.task(r.task)!.plan_state === "planning" || this.task(r.task)!.plan_state === "waiting" ? "plan" : "message", t.queued, JSON.parse(r.providers) as Provider[]); | |
| 1303 | 1394 | } else { |
| 1304 | 1395 | this.sql.exec("UPDATE tasks SET state = ?, error = ?, updated_at = ? WHERE number = ?", step === "done" ? "ready" : step, error, at, r.task); |
| 1305 | 1396 | if (step === "failed") this.event(r.task, `Task #${r.task} failed: ${error ?? "the run failed"}`); |
| 1306 | 1397 | else if (step === "stopped") this.event(r.task, `Task #${r.task} stopped: ${error ?? "stopped"}`); |
| 1398 | + else if (r.kind === "plan") this.event(r.task, `Task #${r.task}'s plan needs approval.`); | |
| 1307 | 1399 | else if (!this.task(r.task)!.pr_number) this.event(r.task, `Task #${r.task} finished with no changes to review.`); |
| 1308 | 1400 | } |
| 1309 | 1401 | // Its slot is free: the next queued task starts. |
modifiedapps/diffinery/src/agentic/api.ts+13−3View file
| @@ -4,10 +4,10 @@ | ||
| 4 | 4 | import { loadRepo } from "../http/common.js"; |
| 5 | 5 | import { HttpError } from "../lib/util.js"; |
| 6 | 6 | import { isProvider, ModelKeys, type ModelKeyInfo } from "./keys.js"; |
| 7 | -import { getTask, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js"; | |
| 7 | +import { approvePlan, getTask, lineJson, messageTask, myTasks, projectStub, requirePersonSession, requireTaskReader, retryTask, startTask, stopTask, taskJson, taskNumber, type StartInput } from "./tasks.js"; | |
| 8 | 8 | import { askForReview, findingJson, reviewSettings, reviewSettingsJson, saveReviewSettings, type SettingsInput } from "./reviews.js"; |
| 9 | 9 | import { gatherToday, todayJson } from "./today.js"; |
| 10 | -import { boardJson, coordinatorJson, handlePost, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js"; | |
| 10 | +import { boardJson, coordinatorJson, handlePost, splitPlan, loadBoard, memoryJson, postJson, postToProject, saveCoordinator, saveMemory, saveProjectSettings, settingsJson, type CoordinatorInput } from "./projects.js"; | |
| 11 | 11 | |
| 12 | 12 | async function jsonBody<T>(c: AppContext): Promise<T> { |
| 13 | 13 | try { |
| @@ -177,6 +177,16 @@ | ||
| 177 | 177 | return c.json({ ...postJson(base(c), full(l), post), coordinator: handled }, 201); |
| 178 | 178 | }); |
| 179 | 179 | |
| 180 | +agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/plan/approve", async (c) => { | |
| 181 | + const l = await repoOf(c); | |
| 182 | + return c.json(taskJson(base(c), full(l), await approvePlan(c, l, taskNumber(c.req.param("number")))), 202); | |
| 183 | +}); | |
| 184 | + | |
| 185 | +agenticApi.post("/repos/:owner/:repo/diffinery/tasks/:number/plan/split", async (c) => { | |
| 186 | + const l = await repoOf(c); | |
| 187 | + return c.json(await splitPlan(c, l, taskNumber(c.req.param("number"))), 202); | |
| 188 | +}); | |
| 189 | + | |
| 180 | 190 | agenticApi.post("/repos/:owner/:repo/diffinery/project/posts/:n/handle", async (c) => { |
| 181 | 191 | const l = await repoOf(c); |
| 182 | 192 | requireTaskReader(l); |
| @@ -226,5 +236,5 @@ | ||
| 226 | 236 | agenticApi.put("/repos/:owner/:repo/diffinery/project/settings", async (c) => { |
| 227 | 237 | const l = await repoOf(c); |
| 228 | 238 | requireTaskReader(l); |
| 229 | - return c.json(settingsJson(await saveProjectSettings(c, l, await jsonBody<{ max_running?: unknown; instructions?: unknown }>(c)))); | |
| 239 | + return c.json(settingsJson(await saveProjectSettings(c, l, await jsonBody<{ max_running?: unknown; instructions?: unknown; plan_first?: unknown }>(c)))); | |
| 230 | 240 | }); |
modifiedapps/diffinery/src/agentic/project-web.tsx+8−3View file
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import { Form, Markdown, Time, act, flash, post, prm, render, repoUrl, statusOf, type WebApp } from "../web/ui.js"; |
| 11 | 11 | import { COORDINATOR_TOKENS_DEFAULT, TASK_TOKENS_DEFAULT, TASKS_PER_DAY, TASKS_PER_RUN, TOKENS_MAX, TOKENS_MIN, COORDINATOR_TIMEOUT_MS, type CoordinatorSettings } from "./coordinator.js"; |
| 12 | 12 | import { ModelKeys, PROVIDERS, type Provider } from "./keys.js"; |
| 13 | -import { BOARD, BOARD_TEXT, lineDiff, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, MAX_RUNNING_MAX, MAX_RUNNING_MIN, type BoardState, type MemoryRow, type PostRow } from "./project.js"; | |
| 13 | +import { BOARD, BOARD_TEXT, lineDiff, PLAN_FIRST, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, MAX_RUNNING_MAX, MAX_RUNNING_MIN, type BoardState, type MemoryRow, type PostRow } from "./project.js"; | |
| 14 | 14 | import type { TaskRow } from "./ProjectDO.js"; |
| 15 | 15 | import { handlePost, loadBoard, postToProject, saveCoordinator, saveMemory, saveProjectSettings, type Handled } from "./projects.js"; |
| 16 | 16 | import { parseRuntimeChoice, runtimeChoices, runtimeLabel } from "./runtimes.js"; |
| @@ -478,7 +478,7 @@ | ||
| 478 | 478 | ); |
| 479 | 479 | } |
| 480 | 480 | |
| 481 | -async function settingsForm(c: AppContext, l: Loaded, error?: string, values?: { max_running?: string; instructions?: string }) { | |
| 481 | +async function settingsForm(c: AppContext, l: Loaded, error?: string, values?: { max_running?: string; instructions?: string; plan_first?: string }) { | |
| 482 | 482 | const base = repoUrl(l.repo); |
| 483 | 483 | const s = await projectStub(c.env, l.repo.id).projectSettings(); |
| 484 | 484 | return settingsPage( |
| @@ -501,6 +501,11 @@ | ||
| 501 | 501 | </div> |
| 502 | 502 | </div> |
| 503 | 503 | <div class="field"> |
| 504 | + <label>Plan first</label> | |
| 505 | + <KRadio name="plan_first" label="Plan first" value={values?.plan_first ?? s.planFirst} items={Object.entries(PLAN_FIRST).map(([value, label]) => ({ value, label }))} /> | |
| 506 | + <div class="hint">A task that plans first writes a plan in a read-only sandbox, then waits for the person who started it to approve it, ask for changes, or split it into tasks with their coordinator. Autofix tasks never plan first.</div> | |
| 507 | + </div> | |
| 508 | + <div class="field"> | |
| 504 | 509 | <label for="instructions">Project instructions</label> |
| 505 | 510 | <textarea id="instructions" name="instructions" rows={10} maxlength={MAX_INSTRUCTIONS} placeholder="For example: run pnpm test before opening a pull request. Keep changes small."> |
| 506 | 511 | {values?.instructions ?? s.instructions} |
| @@ -615,7 +620,7 @@ | ||
| 615 | 620 | post(web, "/:owner/:repo/settings/project", async (c, f) => { |
| 616 | 621 | const l = await admin(c); |
| 617 | 622 | requireAccount(c); |
| 618 | - const values = { max_running: f.s("max_running"), instructions: f.s("instructions") }; | |
| 623 | + const values = { max_running: f.s("max_running"), instructions: f.s("instructions"), plan_first: f.s("plan_first") || "off" }; | |
| 619 | 624 | try { |
| 620 | 625 | await saveProjectSettings(c, l, values); |
| 621 | 626 | } catch (e) { |
modifiedapps/diffinery/src/agentic/project.ts+18−2View file
| @@ -20,8 +20,19 @@ | ||
| 20 | 20 | maxRunning: number; |
| 21 | 21 | /** Text every task's prompt carries, set by repository admins. */ |
| 22 | 22 | instructions: string; |
| 23 | + /** Whether new tasks plan first: never by default, by default (people can untick it), or always. */ | |
| 24 | + planFirst: PlanFirst; | |
| 23 | 25 | } |
| 24 | 26 | |
| 27 | +export const PLAN_FIRST = { off: "Off: tasks start building at once, unless someone ticks Plan first", default: "On by default: Plan first is ticked on new tasks", required: "Required: every task plans first, including ones coordinators start" } as const; | |
| 28 | +export type PlanFirst = keyof typeof PLAN_FIRST; | |
| 29 | +export const isPlanFirst = (v: unknown): v is PlanFirst => typeof v === "string" && Object.hasOwn(PLAN_FIRST, v); | |
| 30 | +/** The longest plan kept from a plan run's final message. */ | |
| 31 | +export const MAX_PLAN = 20_000; | |
| 32 | + | |
| 33 | +/** Where a plan-first task stands: its plan runs, the plan waits for the person, then it is built or split. */ | |
| 34 | +export type PlanState = "planning" | "waiting" | "approved" | "split"; | |
| 35 | + | |
| 25 | 36 | /** A line in the project conversation: a person's post, a coordinator's reply, or an event from a task. */ |
| 26 | 37 | export type PostRow = { |
| 27 | 38 | n: number; |
| @@ -34,6 +45,8 @@ | ||
| 34 | 45 | handled_by: number | null; |
| 35 | 46 | /** A memory change by a coordinator: the version it made. */ |
| 36 | 47 | memory_version: number | null; |
| 48 | + /** A post that asks the coordinator to split a task's plan: that task. */ | |
| 49 | + split_of: number | null; | |
| 37 | 50 | at: string; |
| 38 | 51 | }; |
| 39 | 52 | |
| @@ -51,7 +64,7 @@ | ||
| 51 | 64 | export type BoardState = (typeof BOARD)[number]; |
| 52 | 65 | |
| 53 | 66 | export const BOARD_TEXT: Record<BoardState, { label: string; tone: string; hint: string }> = { |
| 54 | - waiting: { label: "Waiting on you", tone: "amber", hint: "Failed, or its pull request has failing checks." }, | |
| 67 | + waiting: { label: "Waiting on you", tone: "amber", hint: "Failed, a plan to approve, or its pull request has failing checks." }, | |
| 55 | 68 | working: { label: "Working", tone: "blue", hint: "The agent is running, or its pull request's checks are." }, |
| 56 | 69 | queued: { label: "Queued", tone: "", hint: "Starts when a running task finishes." }, |
| 57 | 70 | review: { label: "Ready for review", tone: "green", hint: "Pull request open, checks green." }, |
| @@ -64,8 +77,11 @@ | ||
| 64 | 77 | export type PullState = { open: true; pull: Pick<TodayPull, "checks" | "approvedHead" | "autoMerge" | "queued" | "draft"> } | { open: false; merged: boolean }; |
| 65 | 78 | |
| 66 | 79 | /** Where a task stands on the board: its own state, then its pull request's. */ |
| 67 | -export function boardState(task: { state: string; pr_number: number | null }, pull: PullState | null): BoardState { | |
| 80 | +export function boardState(task: { state: string; pr_number: number | null; plan_state?: PlanState | null }, pull: PullState | null): BoardState { | |
| 68 | 81 | if (task.state === "queued") return "queued"; |
| 82 | + // A plan waiting for approval waits on you; a plan split into other tasks is done with. | |
| 83 | + if (task.state === "ready" && task.plan_state === "waiting") return "waiting"; | |
| 84 | + if (task.state === "ready" && task.plan_state === "split") return "idle"; | |
| 69 | 85 | if (task.state === "running") return "working"; |
| 70 | 86 | if (task.state === "failed") return "waiting"; |
| 71 | 87 | if (task.state === "stopped") return "idle"; |
modifiedapps/diffinery/src/agentic/projects.ts+27−10View file
| @@ -5,10 +5,10 @@ | ||
| 5 | 5 | import type { Account } from "../lib/directory.js"; |
| 6 | 6 | import { taskCaller, TaskAccessError } from "./access.js"; |
| 7 | 7 | import { COORDINATOR_TOKENS_DEFAULT, TASK_TOKENS_DEFAULT, TOKENS_MAX, TOKENS_MIN, type CoordinatorSettings } from "./coordinator.js"; |
| 8 | -import { BOARD, boardState, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, validMaxRunning, type BoardState, type MemoryRow, type PostRow, type ProjectSettings, type PullState } from "./project.js"; | |
| 8 | +import { BOARD, boardState, isPlanFirst, MAX_INSTRUCTIONS, MAX_MEMORY, MAX_POST, validMaxRunning, type BoardState, type MemoryRow, type PostRow, type ProjectSettings, type PullState } from "./project.js"; | |
| 9 | 9 | import type { TaskRow } from "./ProjectDO.js"; |
| 10 | 10 | import { isRuntime, MAX_COMMAND } from "./runtimes.js"; |
| 11 | -import { modelRoute, projectStub, providersFor, requirePersonSession, taskJson } from "./tasks.js"; | |
| 11 | +import { modelRoute, projectStub, providersFor, requirePersonSession, starter, taskJson } from "./tasks.js"; | |
| 12 | 12 | |
| 13 | 13 | /** |
| 14 | 14 | * The project layer over a repository (agentic/project.ts): the board, the |
| @@ -79,6 +79,15 @@ | ||
| 79 | 79 | } |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | +/** Split a task's waiting plan into tasks: the person who started it hands it to their coordinator. */ | |
| 83 | +export async function splitPlan(c: AppContext, l: Loaded, number: number): Promise<{ post: number; task: number; status: "started" | "queued" }> { | |
| 84 | + const { person } = await starter(c, l, number); | |
| 85 | + const { s, providers } = await coordinatorFor(c, l, person); | |
| 86 | + const out = await projectStub(c.env, l.repo.id).splitPlan(number, s, providers, { id: l.repo.id, base: l.repo.default_branch }); | |
| 87 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.plan_split", target: `${l.repo.owner_login}/${l.repo.name}#${number}`, detail: { post: out.post, coordinator_task: out.task } })); | |
| 88 | + return out; | |
| 89 | +} | |
| 90 | + | |
| 82 | 91 | /** "Handle this": hand a post nobody's coordinator took to yours. */ |
| 83 | 92 | export async function handlePost(c: AppContext, l: Loaded, n: number): Promise<{ task: number; status: "started" | "queued" }> { |
| 84 | 93 | const person = requirePersonSession(c); |
| @@ -86,9 +95,9 @@ | ||
| 86 | 95 | return handleAs(c, l, person, n); |
| 87 | 96 | } |
| 88 | 97 | |
| 89 | -async function handleAs(c: AppContext, l: Loaded, person: Account, n: number) { | |
| 90 | - const project = projectStub(c.env, l.repo.id); | |
| 91 | - const s = await project.coordinator(person.id); | |
| 98 | +/** A person's coordinator here, checked: they may still drive its agent, and have the keys its runtime needs. */ | |
| 99 | +async function coordinatorFor(c: AppContext, l: Loaded, person: Account) { | |
| 100 | + const s = await projectStub(c.env, l.repo.id).coordinator(person.id); | |
| 92 | 101 | if (!s) throw new HttpError(422, "set up your coordinator for this project first"); |
| 93 | 102 | const agent = await c.get("dir").account(s.agent.id); |
| 94 | 103 | if (!agent || agent.kind !== "agent") throw new HttpError(422, "your coordinator's agent no longer exists: choose another"); |
| @@ -98,8 +107,12 @@ | ||
| 98 | 107 | if (e instanceof TaskAccessError) throw new HttpError(403, e.message); |
| 99 | 108 | throw e; |
| 100 | 109 | } |
| 101 | - const providers = await providersFor(c.env, person, s.runtime, s.via); | |
| 102 | - const out = await project.handlePost(n, s, providers, { id: l.repo.id, base: l.repo.default_branch }); | |
| 110 | + return { s, providers: await providersFor(c.env, person, s.runtime, s.via) }; | |
| 111 | +} | |
| 112 | + | |
| 113 | +async function handleAs(c: AppContext, l: Loaded, person: Account, n: number) { | |
| 114 | + const { s, providers } = await coordinatorFor(c, l, person); | |
| 115 | + const out = await projectStub(c.env, l.repo.id).handlePost(n, s, providers, { id: l.repo.id, base: l.repo.default_branch }); | |
| 103 | 116 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "project.coordinate", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { post: n, task: out.task, agent: s.agent.login, runtime: s.runtime } })); |
| 104 | 117 | return out; |
| 105 | 118 | } |
| @@ -177,7 +190,7 @@ | ||
| 177 | 190 | return m; |
| 178 | 191 | } |
| 179 | 192 | |
| 180 | -export async function saveProjectSettings(c: AppContext, l: Loaded, input: { max_running?: unknown; instructions?: unknown }): Promise<ProjectSettings> { | |
| 193 | +export async function saveProjectSettings(c: AppContext, l: Loaded, input: { max_running?: unknown; instructions?: unknown; plan_first?: unknown }): Promise<ProjectSettings> { | |
| 181 | 194 | const person = requirePersonSession(c); |
| 182 | 195 | if (l.repo.archived) throw new HttpError(403, "repository is archived (read-only)"); |
| 183 | 196 | if (!atLeast(l.caller.role, "admin")) throw new HttpError(403, "admin access required"); |
| @@ -195,8 +208,12 @@ | ||
| 195 | 208 | if (text.length > MAX_INSTRUCTIONS) throw new HttpError(422, `\`instructions\` is longer than ${MAX_INSTRUCTIONS} characters`); |
| 196 | 209 | next.instructions = text; |
| 197 | 210 | } |
| 211 | + if (input.plan_first !== undefined) { | |
| 212 | + if (!isPlanFirst(input.plan_first)) throw new HttpError(422, "`plan_first` must be off, default or required"); | |
| 213 | + next.planFirst = input.plan_first; | |
| 214 | + } | |
| 198 | 215 | const s = await projectStub(c.env, l.repo.id).setProjectSettings(next); |
| 199 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "project.settings", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { max_running: s.maxRunning, instructions_length: s.instructions.length } })); | |
| 216 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "project.settings", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { max_running: s.maxRunning, instructions_length: s.instructions.length, plan_first: s.planFirst } })); | |
| 200 | 217 | return s; |
| 201 | 218 | } |
| 202 | 219 | |
| @@ -218,7 +235,7 @@ | ||
| 218 | 235 | |
| 219 | 236 | export const memoryJson = (m: MemoryRow | null) => (m ? { body: m.body, version: m.n, updated_by: { login: m.by_login, type: m.by_kind === "person" ? "User" : "Bot" }, updated_at: m.at } : { body: "", version: null, updated_by: null, updated_at: null }); |
| 220 | 237 | |
| 221 | -export const settingsJson = (s: ProjectSettings) => ({ max_running: s.maxRunning, instructions: s.instructions }); | |
| 238 | +export const settingsJson = (s: ProjectSettings) => ({ max_running: s.maxRunning, instructions: s.instructions, plan_first: s.planFirst }); | |
| 222 | 239 | |
| 223 | 240 | export function boardJson(base: string, repoFull: string, b: Board) { |
| 224 | 241 | return { |
modifiedapps/diffinery/src/agentic/runtimes.ts+22−4View file
| @@ -163,18 +163,36 @@ | ||
| 163 | 163 | return { env, secret }; |
| 164 | 164 | } |
| 165 | 165 | |
| 166 | -/** The prompt for a run: the goal, plus what came before for a follow-up or a retry. */ | |
| 167 | -export function runPrompt(o: { goal: string; number: number; branch: string; message?: string; previous?: string | null; by: string; context?: string | null }): string { | |
| 166 | +/** The prompt for a run: the goal, plus what came before for a follow-up or a retry, and the plan a person approved. */ | |
| 167 | +export function runPrompt(o: { goal: string; number: number; branch: string; message?: string; previous?: string | null; by: string; context?: string | null; plan?: string | null }): string { | |
| 168 | 168 | const rules = [ |
| 169 | 169 | `You are working on task #${o.number} in this Git repository, on behalf of ${o.by}.`, |
| 170 | 170 | "Work in the current directory. When you are done, leave your changes in the working tree or commit them; Diffinery pushes them to the branch " + o.branch + " and opens a pull request for review.", |
| 171 | 171 | "Don't push, change Git remotes or credentials, or open pull requests yourself.", |
| 172 | 172 | ].join(" "); |
| 173 | 173 | const head = o.context ? `${rules}\n\n${o.context}` : rules; |
| 174 | - if (!o.message) return `${head}\n\nGoal:\n${o.goal}`; | |
| 175 | - return [head, "", "The original goal:", o.goal, ...(o.previous ? ["", "Where the previous run ended:", o.previous] : []), "", "The branch already has the previous runs' work checked out.", "", `New instructions from ${o.by}:`, o.message].join("\n"); | |
| 174 | + const plan = o.plan ? `\n\nThe plan for this goal, which ${o.by} approved. Follow it:\n--- plan ---\n${o.plan}\n--- end of plan ---` : ""; | |
| 175 | + if (!o.message) return `${head}\n\nGoal:\n${o.goal}${plan}`; | |
| 176 | + return [head, "", "The original goal:", o.goal + plan, ...(o.previous ? ["", "Where the previous run ended:", o.previous] : []), "", "The branch already has the previous runs' work checked out.", "", `New instructions from ${o.by}:`, o.message].join("\n"); | |
| 176 | 177 | } |
| 177 | 178 | |
| 179 | +/** | |
| 180 | + * The prompt for a plan run: the agent reads the code in a read-only sandbox and writes a plan, which waits for | |
| 181 | + * the person to approve, change or split. A run asked to change the plan gets the plan it replaces. | |
| 182 | + */ | |
| 183 | +export function planPrompt(o: { goal: string; number: number; by: string; context?: string | null; message?: string | null; plan?: string | null }): string { | |
| 184 | + const rules = [ | |
| 185 | + `You are planning task #${o.number} in this Git repository, on behalf of ${o.by}. Plan only: the checkout is read-only, so don't change files, commit or push; nothing you change is kept.`, | |
| 186 | + "Read the code and run read-only commands to understand it. Then write a plan another agent can build from: what to change and where, in what order, how to test it, and the risks and open questions.", | |
| 187 | + `End your final message with the whole plan, in Markdown. ${o.by} approves it, asks for changes or splits it into tasks.`, | |
| 188 | + ].join(" "); | |
| 189 | + const head = o.context ? `${rules}\n\n${o.context}` : rules; | |
| 190 | + const parts = [head, "", "Goal:", o.goal]; | |
| 191 | + if (o.plan && o.message) parts.push("", "Your previous plan:", "--- plan ---", o.plan, "--- end of plan ---", "", `Changes ${o.by} asked for:`, o.message); | |
| 192 | + else if (o.message) parts.push("", `Also from ${o.by}:`, o.message); | |
| 193 | + return parts.join("\n"); | |
| 194 | +} | |
| 195 | + | |
| 178 | 196 | /** A transcript line, as stored. */ |
| 179 | 197 | export type TranscriptLine = { |
| 180 | 198 | seq: number; |
modifiedapps/diffinery/src/agentic/tasks-web.tsx+80−8View file
| @@ -11,7 +11,8 @@ | ||
| 11 | 11 | import type { TaskRow, TaskView } from "./ProjectDO.js"; |
| 12 | 12 | import { fixGoal } from "./review.js"; |
| 13 | 13 | import { OPENROUTER_DEFAULT_MODEL, parseRuntimeChoice, runtimeChoices, runtimeLabel, transcriptItem, type TranscriptItem } from "./runtimes.js"; |
| 14 | -import { drivableAgents, getTask, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js"; | |
| 14 | +import { approvePlan, drivableAgents, getTask, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js"; | |
| 15 | +import { splitPlan } from "./projects.js"; | |
| 15 | 16 | |
| 16 | 17 | /** |
| 17 | 18 | * Web pages for agent tasks: Settings → Model access, and a repository's |
| @@ -188,7 +189,7 @@ | ||
| 188 | 189 | return <pre class={`tx-pre${item.kind === "error" ? " tx-err" : ""}`}>{item.body}</pre>; |
| 189 | 190 | }; |
| 190 | 191 | |
| 191 | -function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean) { | |
| 192 | +function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false) { | |
| 192 | 193 | const base = repoUrl(l.repo); |
| 193 | 194 | const tk = csrfToken(c); |
| 194 | 195 | const me = requireAccount(c); |
| @@ -237,6 +238,51 @@ | ||
| 237 | 238 | <div class="body"><Markdown text={t.goal} /></div> |
| 238 | 239 | </div> |
| 239 | 240 | {t.error ? <div class="flash error">{t.error}</div> : null} |
| 241 | + {t.plan && t.plan_state === "waiting" ? ( | |
| 242 | + <div class="box" id="plan"> | |
| 243 | + <div class="head"> | |
| 244 | + <h2>Plan</h2> | |
| 245 | + <span class="badge amber">Waiting for {t.person_login}</span> | |
| 246 | + </div> | |
| 247 | + <div class="body"> | |
| 248 | + <Markdown text={t.plan} /> | |
| 249 | + {starter ? ( | |
| 250 | + <div class="row mt" style="gap:8px;flex-wrap:wrap"> | |
| 251 | + <Form action={`${base}/tasks/${t.number}/plan/approve`} t={tk} class="inline"> | |
| 252 | + <button class="btn primary">Approve plan</button> | |
| 253 | + </Form> | |
| 254 | + {canSplit ? ( | |
| 255 | + <Form action={`${base}/tasks/${t.number}/plan/split`} t={tk} class="inline" confirm="Hand this plan to your coordinator to split into tasks? This task is then done with."> | |
| 256 | + <button class="btn">Split into tasks</button> | |
| 257 | + </Form> | |
| 258 | + ) : ( | |
| 259 | + <span class="muted small"> | |
| 260 | + <a href={`${base}/project/coordinator`}>Set up your coordinator</a> to split plans into tasks. | |
| 261 | + </span> | |
| 262 | + )} | |
| 263 | + <span class="muted small">Or ask for changes below.</span> | |
| 264 | + </div> | |
| 265 | + ) : null} | |
| 266 | + </div> | |
| 267 | + </div> | |
| 268 | + ) : t.plan ? ( | |
| 269 | + <details class="box"> | |
| 270 | + <summary class="head"> | |
| 271 | + <h2>Plan</h2> | |
| 272 | + <span class="badge">{t.plan_state === "split" ? "Split into tasks" : t.plan_state === "approved" ? "Approved" : "Being revised"}</span> | |
| 273 | + </summary> | |
| 274 | + <div class="body"> | |
| 275 | + <Markdown text={t.plan} /> | |
| 276 | + </div> | |
| 277 | + </details> | |
| 278 | + ) : t.plan_state === "planning" ? ( | |
| 279 | + <div class="flash info">This task plans first: its run reads the code in a read-only sandbox and writes a plan for {t.person_login} to approve, change or split into tasks.</div> | |
| 280 | + ) : null} | |
| 281 | + {t.parent_task ? ( | |
| 282 | + <div class="flash info"> | |
| 283 | + Split from the plan of <a href={`${base}/tasks/${t.parent_task}`}>task #{t.parent_task}</a>. | |
| 284 | + </div> | |
| 285 | + ) : null} | |
| 240 | 286 | {t.queued ? <div class="flash info">A message is waiting for the current run to end: {t.queued}</div> : null} |
| 241 | 287 | <div class="box"> |
| 242 | 288 | <div class="head"> |
| @@ -260,7 +306,7 @@ | ||
| 260 | 306 | {starter ? ( |
| 261 | 307 | <Form action={`${base}/tasks/${t.number}/messages`} t={tk} class="box"> |
| 262 | 308 | <div class="head"> |
| 263 | - <h2>{running ? "Send a follow-up" : "Continue the task"}</h2> | |
| 309 | + <h2>{t.plan_state === "waiting" ? "Ask for changes to the plan" : running ? "Send a follow-up" : "Continue the task"}</h2> | |
| 264 | 310 | </div> |
| 265 | 311 | <div class="body"> |
| 266 | 312 | <div class="field"> |
| @@ -284,11 +330,11 @@ | ||
| 284 | 330 | ); |
| 285 | 331 | } |
| 286 | 332 | |
| 287 | -async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; model?: string; pull?: string } = {}) { | |
| 333 | +async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; model?: string; pull?: string; plan_first?: string } = {}) { | |
| 288 | 334 | const me = requireAccount(c); |
| 289 | 335 | const base = repoUrl(l.repo); |
| 290 | 336 | const tk = csrfToken(c); |
| 291 | - const [agents, keys, branches] = await Promise.all([drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id), l.stub.refs("refs/heads/")]); | |
| 337 | + const [agents, keys, branches, settings] = await Promise.all([drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id), l.stub.refs("refs/heads/"), projectStub(c.env, l.repo.id).projectSettings()]); | |
| 292 | 338 | const runtimes = runtimeChoices(keys, providerNames); |
| 293 | 339 | const firstUsable = runtimes.find((r) => !r.disabled)?.value ?? "custom"; |
| 294 | 340 | return render( |
| @@ -346,6 +392,14 @@ | ||
| 346 | 392 | </div> |
| 347 | 393 | )} |
| 348 | 394 | </div> |
| 395 | + <div class="field"> | |
| 396 | + <KCheckbox | |
| 397 | + name="plan_first" | |
| 398 | + checked={settings.planFirst === "required" || (v.plan_first ?? (settings.planFirst === "default" ? "1" : "")) === "1"} | |
| 399 | + disabled={settings.planFirst === "required"} | |
| 400 | + label={<>Plan first: the agent reads the code and writes a plan in a read-only sandbox; it builds once you approve the plan{settings.planFirst === "required" ? " (this project requires it)" : ""}</>} | |
| 401 | + /> | |
| 402 | + </div> | |
| 349 | 403 | <button class="btn primary" disabled={!agents.length}> |
| 350 | 404 | Start task |
| 351 | 405 | </button> |
| @@ -428,10 +482,10 @@ | ||
| 428 | 482 | |
| 429 | 483 | post(web, "/:owner/:repo/tasks", async (c, f) => { |
| 430 | 484 | const l = await forTasks(c); |
| 431 | - const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), model: f.s("model"), pull: f.s("pull") || undefined }; | |
| 485 | + const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), model: f.s("model"), pull: f.s("pull") || undefined, plan_first: f.all("plan_first").length ? "1" : "" }; | |
| 432 | 486 | try { |
| 433 | 487 | const { runtime, provider } = parseRuntimeChoice(v.runtime); |
| 434 | - const t = await startTask(c, l, { ...v, runtime, provider, model: provider ? v.model : undefined }); | |
| 488 | + const t = await startTask(c, l, { ...v, runtime, provider, model: provider ? v.model : undefined, plan_first: v.plan_first === "1" }); | |
| 435 | 489 | return c.redirect(`${repoUrl(l.repo)}/tasks/${t.number}`, 303); |
| 436 | 490 | } catch (e) { |
| 437 | 491 | const st = statusOf(e); |
| @@ -449,7 +503,8 @@ | ||
| 449 | 503 | const lines = await stub.lines(n, 0, 5000); |
| 450 | 504 | const tail = lines.slice(-1500); |
| 451 | 505 | const items = tail.flatMap((line) => transcriptItem(line).map((item) => ({ run: line.run, item }))); |
| 452 | - return taskPage(c, l, t, items, lines.length > tail.length); | |
| 506 | + const canSplit = t.plan_state === "waiting" && !!(await stub.coordinator(requireAccount(c).id)); | |
| 507 | + return taskPage(c, l, t, items, lines.length > tail.length, canSplit); | |
| 453 | 508 | }); |
| 454 | 509 | |
| 455 | 510 | post(web, "/:owner/:repo/tasks/:number/messages", async (c, f) => { |
| @@ -460,6 +515,23 @@ | ||
| 460 | 515 | }); |
| 461 | 516 | }); |
| 462 | 517 | |
| 518 | + post(web, "/:owner/:repo/tasks/:number/plan/approve", async (c) => { | |
| 519 | + const l = await forTasks(c); | |
| 520 | + const n = taskNumber(c.req.param("number")); | |
| 521 | + return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { | |
| 522 | + await approvePlan(c, l, n); | |
| 523 | + }, "Plan approved: building it."); | |
| 524 | + }); | |
| 525 | + | |
| 526 | + post(web, "/:owner/:repo/tasks/:number/plan/split", async (c) => { | |
| 527 | + const l = await forTasks(c); | |
| 528 | + const n = taskNumber(c.req.param("number")); | |
| 529 | + return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => { | |
| 530 | + const out = await splitPlan(c, l, n); | |
| 531 | + return `${repoUrl(l.repo)}/project#post-${out.post}`; | |
| 532 | + }, "Your coordinator is splitting the plan into tasks."); | |
| 533 | + }); | |
| 534 | + | |
| 463 | 535 | post(web, "/:owner/:repo/tasks/:number/retry", async (c) => { |
| 464 | 536 | const l = await forTasks(c); |
| 465 | 537 | const n = taskNumber(c.req.param("number")); |
modifiedapps/diffinery/src/agentic/tasks.ts+20−3View file
| @@ -100,6 +100,8 @@ | ||
| 100 | 100 | model?: unknown; |
| 101 | 101 | /** Work on this pull request's branch (Fix it) instead of a new branch. */ |
| 102 | 102 | pull?: unknown; |
| 103 | + /** Plan first: true or false; left out, the project's default. A project can require it. */ | |
| 104 | + plan_first?: unknown; | |
| 103 | 105 | } |
| 104 | 106 | |
| 105 | 107 | export async function startTask(c: AppContext, l: Loaded, input: StartInput): Promise<TaskView> { |
| @@ -131,14 +133,18 @@ | ||
| 131 | 133 | base = issue.pull.base; |
| 132 | 134 | } |
| 133 | 135 | if (!(await l.stub.getRef(`refs/heads/${base}`))) throw new HttpError(422, `branch ${base} doesn't exist`); |
| 136 | + if (input.plan_first !== undefined && typeof input.plan_first !== "boolean") throw new HttpError(422, "`plan_first` must be true or false"); | |
| 134 | 137 | const providers = await providersFor(c.env, person, runtime, route.via); |
| 135 | - const t = await projectStub(c.env, l.repo.id).create({ repoId: l.repo.id, goal, runtime, command, route, agent: { id: agent.id, login: agent.login }, person: { id: person.id, login: person.login }, base, providers, pull }); | |
| 136 | - c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.start", target: `${l.repo.owner_login}/${l.repo.name}#${t.number}`, detail: { agent: agent.login, runtime, ...(route.via ? { provider: route.via, model: route.model } : {}) } })); | |
| 138 | + const project = projectStub(c.env, l.repo.id); | |
| 139 | + const { planFirst: policy } = await project.projectSettings(); | |
| 140 | + const planFirst = policy === "required" || (input.plan_first === undefined ? policy === "default" : input.plan_first === true); | |
| 141 | + const t = await project.create({ repoId: l.repo.id, goal, runtime, command, route, agent: { id: agent.id, login: agent.login }, person: { id: person.id, login: person.login }, base, providers, pull, planFirst }); | |
| 142 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.start", target: `${l.repo.owner_login}/${l.repo.name}#${t.number}`, detail: { agent: agent.login, runtime, ...(route.via ? { provider: route.via, model: route.model } : {}), ...(planFirst ? { plan_first: true } : {}) } })); | |
| 137 | 143 | return t; |
| 138 | 144 | } |
| 139 | 145 | |
| 140 | 146 | /** The person who started the task, in a browser session, still allowed to drive its agent here. */ |
| 141 | -async function starter(c: AppContext, l: Loaded, number: number): Promise<{ person: Account; task: TaskView }> { | |
| 147 | +export async function starter(c: AppContext, l: Loaded, number: number): Promise<{ person: Account; task: TaskView }> { | |
| 142 | 148 | const person = requirePersonSession(c); |
| 143 | 149 | const task = await getTask(c, l, number); |
| 144 | 150 | if (task.kind === "review") throw new HttpError(409, "an agent review takes no messages and isn't retried: ask for a new review on its pull request"); |
| @@ -161,6 +167,14 @@ | ||
| 161 | 167 | return t; |
| 162 | 168 | } |
| 163 | 169 | |
| 170 | +/** Approve a task's waiting plan: its build run starts. */ | |
| 171 | +export async function approvePlan(c: AppContext, l: Loaded, number: number): Promise<TaskView> { | |
| 172 | + const { person, task } = await starter(c, l, number); | |
| 173 | + const t = await projectStub(c.env, l.repo.id).approvePlan(number, await providersFor(c.env, person, task.runtime, task.via), person.login); | |
| 174 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: person.id, actorLogin: person.login, action: "task.plan_approve", target: `${l.repo.owner_login}/${l.repo.name}#${number}` })); | |
| 175 | + return t; | |
| 176 | +} | |
| 177 | + | |
| 164 | 178 | export async function retryTask(c: AppContext, l: Loaded, number: number): Promise<TaskView> { |
| 165 | 179 | const { person, task } = await starter(c, l, number); |
| 166 | 180 | const t = await projectStub(c.env, l.repo.id).retry(number, await providersFor(c.env, person, task.runtime, task.via)); |
| @@ -207,6 +221,9 @@ | ||
| 207 | 221 | model: t.model ?? null, |
| 208 | 222 | agent: { login: t.agent_login, type: "Bot" }, |
| 209 | 223 | started_by: { login: t.person_login, type: "User" }, |
| 224 | + plan_first: !!t.plan_first, | |
| 225 | + ...(t.plan_first ? { plan_state: t.plan_state, plan: t.plan } : {}), | |
| 226 | + ...(t.parent_task ? { parent_task: t.parent_task } : {}), | |
| 210 | 227 | // Security 3b: a task a coordinator started says so, with the post it came from. |
| 211 | 228 | ...(t.coordinator_post ? { started_via: { type: "coordinator", post: t.coordinator_post }, token_budget: t.token_budget, waits_for: t.after_tasks ? (JSON.parse(t.after_tasks) as number[]) : [] } : {}), |
| 212 | 229 | base: t.base, |
modifiedapps/diffinery/test/e2e/tasks.test.mjs+142−3View file
| @@ -464,7 +464,7 @@ | ||
| 464 | 464 | // Settings: an admin in a browser; never a token. |
| 465 | 465 | assert.equal((await api("PUT", `${P}/settings`, { max_running: 1 })).status, 403); |
| 466 | 466 | for (const bad of [0, 11, 1.5, "x"]) assert.equal((await asPerson("PUT", `${P}/settings`, { max_running: bad })).status, 422, String(bad)); |
| 467 | - assert.deepEqual((await asPerson("PUT", `${P}/settings`, { max_running: 1, instructions: "Always say PROJECT-RULE-7." })).json, { max_running: 1, instructions: "Always say PROJECT-RULE-7." }); | |
| 467 | + assert.deepEqual((await asPerson("PUT", `${P}/settings`, { max_running: 1, instructions: "Always say PROJECT-RULE-7." })).json, { max_running: 1, instructions: "Always say PROJECT-RULE-7.", plan_first: "off" }); | |
| 468 | 468 | |
| 469 | 469 | // Memory: every save is a version, and a restore is one more. |
| 470 | 470 | assert.equal((await api("PUT", `${P}/memory`, { body: "x" })).status, 403); |
| @@ -561,8 +561,10 @@ | ||
| 561 | 561 | return fetch(`${base}/${owner}/tasks-demo/${path}`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams({ _csrf: csrf ?? "", ...fields }) }); |
| 562 | 562 | }; |
| 563 | 563 | assert.equal((await form("settings/project", { max_running: "0", instructions: "" })).status, 422); |
| 564 | - assert.equal((await form("settings/project", { max_running: "3", instructions: "" })).status, 303); | |
| 565 | - assert.deepEqual((await ok("GET", P)).settings, { max_running: 3, instructions: "" }); | |
| 564 | + assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "sometimes" })).status, 422); | |
| 565 | + assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "default" })).status, 303); | |
| 566 | + assert.deepEqual((await ok("GET", P)).settings, { max_running: 3, instructions: "", plan_first: "default" }); | |
| 567 | + assert.equal((await form("settings/project", { max_running: "3", instructions: "", plan_first: "off" })).status, 303); | |
| 566 | 568 | assert.equal((await form("project/posts", { body: "Posted from the page" })).status, 303); |
| 567 | 569 | assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === "Posted from the page")); |
| 568 | 570 | assert.equal((await form("project/memory/2/restore", {})).status, 303); |
| @@ -746,6 +748,143 @@ | ||
| 746 | 748 | assert.equal((await asPerson("POST", `${P}/posts/${after.json.id}/handle`)).status, 422); |
| 747 | 749 | }); |
| 748 | 750 | |
| 751 | +test("plan first: a read-only plan run, then approve, change or split the plan", async () => { | |
| 752 | + const me = await ok("GET", "/user"); | |
| 753 | + const owner = me.login; | |
| 754 | + const P = `/repos/${owner}/tasks-demo/diffinery/project`; | |
| 755 | + const R = `/repos/${owner}/tasks-demo/diffinery/tasks`; | |
| 756 | + const src = join(work, "src"); | |
| 757 | + writeFileSync( | |
| 758 | + join(src, "plan.sh"), | |
| 759 | + `p="$DIFFINERY_TASK_PROMPT" | |
| 760 | +case "$p" in | |
| 761 | + *"You are the project coordinator"*) | |
| 762 | + post=$(printf '%s\\n' "$p" | sed -n '/^--- post #/,/^--- end of post ---/p') | |
| 763 | + case "$post" in | |
| 764 | + *"Split the plan of task #"*"PLAN-V1-STEP"*) printf '%s\\n' '\`\`\`json' '{"reply": "Split it.", "tasks": [{"title": "Plan part one", "goal": "PLAN-PART-ONE: build step one."}]}' '\`\`\`' ;; | |
| 765 | + *) printf '%s\\n' '\`\`\`json' '{"reply": "Noted."}' '\`\`\`' ;; | |
| 766 | + esac ;; | |
| 767 | + *"You are planning task"*) | |
| 768 | + echo planned > should-not-land.txt | |
| 769 | + git add -A && git commit -qm "plan run commit" && git push -q origin HEAD:refs/heads/plan-run-push 2>/dev/null && echo "PUSHED" >&2 | |
| 770 | + case "$p" in | |
| 771 | + *"Your previous plan:"*"Use the KV store"*) printf '%s\\n' '## Plan' '1. PLAN-V2-STEP: write planned.txt, with the KV store.' ;; | |
| 772 | + *) printf '%s\\n' '## Plan' '1. PLAN-V1-STEP: write planned.txt.' ;; | |
| 773 | + esac ;; | |
| 774 | + *"approved. Follow it:"*) | |
| 775 | + printf '%s\\n' "$p" | sed -n '/^--- plan ---$/,/^--- end of plan ---$/p' | |
| 776 | + echo built > planned.txt ;; | |
| 777 | + *PLAN-PART-ONE*) echo part > part.txt ;; | |
| 778 | + *) echo other > other.txt ;; | |
| 779 | +esac | |
| 780 | +`, | |
| 781 | + ); | |
| 782 | + git(src, ["pull", "-q", "--rebase", "origin", "main"]); | |
| 783 | + git(src, ["add", "plan.sh"]); | |
| 784 | + git(src, ["commit", "-q", "-m", "plan script"]); | |
| 785 | + git(src, ["push", "-q", "origin", "main"]); | |
| 786 | + const settled = (n, what, fn = () => true) => | |
| 787 | + until(what, async () => { | |
| 788 | + const t = await ok("GET", `${R}/${n}`); | |
| 789 | + const last = t.runs.at(-1); | |
| 790 | + return last && !["queued", "boot", "install", "agent", "finish"].includes(last.state) && fn(t) ? t : null; | |
| 791 | + }); | |
| 792 | + const transcript = async (n) => (await ok("GET", `${R}/${n}/transcript`)).map((l) => l.text).join("\n"); | |
| 793 | + | |
| 794 | + // A plan-first task's first run plans in a read-only sandbox: nothing it changes is pushed. | |
| 795 | + assert.equal((await asPerson("POST", R, { goal: "x", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: "yes" })).status, 422); | |
| 796 | + const started = await asPerson("POST", R, { goal: "PLANNED-GOAL: write planned.txt", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: true }); | |
| 797 | + assert.equal(started.status, 201, JSON.stringify(started.json)); | |
| 798 | + const n = started.json.number; | |
| 799 | + assert.equal(started.json.plan_first, true); | |
| 800 | + assert.equal(started.json.plan_state, "planning"); | |
| 801 | + const planned = await settled(n, "the plan"); | |
| 802 | + const text1 = await transcript(n); | |
| 803 | + assert.equal(planned.runs[0].kind, "plan"); | |
| 804 | + assert.equal(planned.runs[0].state, "done", text1); | |
| 805 | + assert.equal(planned.state, "ready"); | |
| 806 | + assert.equal(planned.plan_state, "waiting"); | |
| 807 | + assert.match(planned.plan, /^## Plan\n1\. PLAN-V1-STEP: write planned\.txt\.$/); | |
| 808 | + assert.equal(planned.pull_request, null); | |
| 809 | + assert.match(text1, /Starting a read-only sandbox to plan on main as task-bot\./); | |
| 810 | + assert.match(text1, /It plans first\./); | |
| 811 | + assert.ok(!text1.includes("PUSHED"), "the plan run's push was refused"); | |
| 812 | + assert.equal(git(src, ["ls-remote", "origin", "refs/heads/plan-run-push", `refs/heads/${planned.branch}`]).trim(), "", "nothing was pushed"); | |
| 813 | + const board = await ok("GET", P); | |
| 814 | + assert.ok(board.board.waiting.some((t) => t.number === n), "a plan waiting for approval waits on you"); | |
| 815 | + assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `Task #${n}'s plan needs approval.`)); | |
| 816 | + const page = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${n}`)).text(); | |
| 817 | + assert.match(page, /PLAN-V1-STEP/); | |
| 818 | + assert.match(page, /Approve plan/); | |
| 819 | + | |
| 820 | + // Asking for changes plans again, with the plan it replaces. | |
| 821 | + assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "Use the KV store" })).status, 201); | |
| 822 | + const replanned = await settled(n, "the second plan", (t) => t.runs.length === 2); | |
| 823 | + assert.equal(replanned.runs[1].kind, "plan"); | |
| 824 | + assert.equal(replanned.plan_state, "waiting"); | |
| 825 | + assert.match(replanned.plan, /PLAN-V2-STEP/); | |
| 826 | + assert.match(await transcript(n), /Planning again with the changes asked for\./); | |
| 827 | + | |
| 828 | + // Approving starts the build run, which carries the approved plan; only a person in a browser may approve. | |
| 829 | + assert.equal((await api("POST", `${R}/${n}/plan/approve`)).status, 403); | |
| 830 | + const approved = await asPerson("POST", `${R}/${n}/plan/approve`); | |
| 831 | + assert.equal(approved.status, 202, JSON.stringify(approved.json)); | |
| 832 | + assert.equal(approved.json.plan_state, "approved"); | |
| 833 | + assert.equal((await asPerson("POST", `${R}/${n}/plan/approve`)).status, 409); | |
| 834 | + const built = await settled(n, "the build", (t) => t.runs.length === 3); | |
| 835 | + const text3 = await transcript(n); | |
| 836 | + assert.equal(built.runs[2].kind, "build"); | |
| 837 | + assert.equal(built.runs[2].state, "done", text3); | |
| 838 | + assert.match(text3, /The plan is approved: building it\./); | |
| 839 | + assert.match(text3, /^--- plan ---\n## Plan\n1\. PLAN-V2-STEP: write planned\.txt, with the KV store\.\n--- end of plan ---$/m); | |
| 840 | + assert.ok(built.pull_request, "the build opened a pull request"); | |
| 841 | + assert.ok((await ok("GET", `${P}/posts`)).some((p) => p.body === `${owner} approved the plan of task #${n}.`)); | |
| 842 | + // A follow-up after approval is an ordinary run, still with the plan. | |
| 843 | + assert.equal((await asPerson("POST", `${R}/${n}/messages`, { body: "One more thing" })).status, 201); | |
| 844 | + const after = await settled(n, "the follow-up", (t) => t.runs.length === 4); | |
| 845 | + assert.equal(after.runs[3].kind, "message"); | |
| 846 | + | |
| 847 | + // A project can require plans: every task plans first, ticked or not, including ones coordinators start. | |
| 848 | + assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "always" })).status, 422); | |
| 849 | + assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "required" })).json.plan_first, "required"); | |
| 850 | + const forced = await asPerson("POST", R, { goal: "SPLIT-GOAL: a goal to split", runtime: "custom", command: "sh plan.sh", agent: "task-bot", plan_first: false }); | |
| 851 | + assert.equal(forced.status, 201, JSON.stringify(forced.json)); | |
| 852 | + const m = forced.json.number; | |
| 853 | + assert.equal(forced.json.plan_first, true); | |
| 854 | + await settled(m, "the plan to split", (t) => t.plan_state === "waiting"); | |
| 855 | + | |
| 856 | + // Splitting hands the plan to the person's coordinator; the tasks it starts link back, and the task is done with. | |
| 857 | + assert.equal((await asPerson("POST", `${R}/${m}/plan/split`)).status, 422, "no coordinator yet"); | |
| 858 | + const coord = await asPerson("PUT", `${P}/coordinator`, { agent: "task-bot", runtime: "custom", command: "sh plan.sh", accept: true }); | |
| 859 | + assert.equal(coord.status, 200, JSON.stringify(coord.json)); | |
| 860 | + assert.equal((await asPerson("POST", `${R}/${n}/plan/split`)).status, 409, "an approved plan can't be split"); | |
| 861 | + assert.equal((await api("POST", `${R}/${m}/plan/split`)).status, 403); | |
| 862 | + const split = await asPerson("POST", `${R}/${m}/plan/split`); | |
| 863 | + assert.equal(split.status, 202, JSON.stringify(split.json)); | |
| 864 | + assert.equal((await asPerson("POST", `${R}/${m}/plan/split`)).status, 409); | |
| 865 | + const splitPost = (await ok("GET", `${P}/posts?after=${split.json.post - 1}`)).find((p) => p.id === split.json.post); | |
| 866 | + assert.match(splitPost.body, new RegExp(`^Split the plan of task #${m} \\(SPLIT-GOAL: a goal to split\\) into tasks\\.`)); | |
| 867 | + assert.equal(splitPost.author.login, owner); | |
| 868 | + const child = await until("the coordinator to split the plan", async () => { | |
| 869 | + const posts = await ok("GET", `${P}/posts?after=${split.json.post}`); | |
| 870 | + const ev = posts.find((p) => p.kind === "event" && p.body.startsWith(`${owner}'s coordinator started task`)); | |
| 871 | + return ev ? ev.task.number : null; | |
| 872 | + }); | |
| 873 | + const c = await ok("GET", `${R}/${child}`); | |
| 874 | + assert.equal(c.parent_task, m); | |
| 875 | + assert.equal(c.title, "Plan part one"); | |
| 876 | + assert.equal(c.plan_first, true, "a coordinator's task plans first when the project requires it"); | |
| 877 | + const parent = await ok("GET", `${R}/${m}`); | |
| 878 | + assert.equal(parent.plan_state, "split"); | |
| 879 | + assert.ok((await ok("GET", P)).board.idle.some((t) => t.number === m)); | |
| 880 | + const childPage = await (await fetch(`${base}/${owner}/tasks-demo/tasks/${child}`)).text(); | |
| 881 | + assert.match(childPage, new RegExp(`Split from the plan of <a href="[^"]*/tasks/${m}">task #${m}</a>`)); | |
| 882 | + await settled(child, "the child's plan", (t) => t.plan_state === "waiting"); | |
| 883 | + | |
| 884 | + assert.equal((await asPerson("PUT", `${P}/settings`, { plan_first: "off" })).json.plan_first, "off"); | |
| 885 | + assert.equal((await asPerson("DELETE", `${P}/coordinator`)).status, 204); | |
| 886 | +}); | |
| 887 | + | |
| 749 | 888 | test("losing access to the agent stops the task", async () => { |
| 750 | 889 | const me = await ok("GET", "/user"); |
| 751 | 890 | const owner = me.login; |
modifiedapps/diffinery/test/unit/project.test.ts+13−1View file
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | -import { boardState, lineDiff, projectContext, validMaxRunning, type PullState } from "../../src/agentic/project.js"; | |
| 2 | +import { boardState, isPlanFirst, lineDiff, projectContext, validMaxRunning, type PullState } from "../../src/agentic/project.js"; | |
| 3 | 3 | |
| 4 | 4 | const open = (p: Partial<{ checks: "success" | "failure" | "pending" | "none"; approvedHead: boolean; autoMerge: boolean; queued: boolean }> = {}): PullState => ({ open: true, pull: { checks: "success", approvedHead: false, autoMerge: false, queued: false, draft: false, ...p } }); |
| 5 | 5 | |
| @@ -11,6 +11,18 @@ | ||
| 11 | 11 | expect(boardState({ state: "stopped", pr_number: 4 }, open())).toBe("idle"); |
| 12 | 12 | expect(boardState({ state: "ready", pr_number: null }, null)).toBe("idle"); |
| 13 | 13 | }); |
| 14 | + it("puts a plan waiting for approval on you, and a split plan aside", () => { | |
| 15 | + expect(boardState({ state: "ready", pr_number: null, plan_state: "waiting" }, null)).toBe("waiting"); | |
| 16 | + expect(boardState({ state: "ready", pr_number: null, plan_state: "split" }, null)).toBe("idle"); | |
| 17 | + expect(boardState({ state: "running", pr_number: null, plan_state: "planning" }, null)).toBe("working"); | |
| 18 | + expect(boardState({ state: "queued", pr_number: null, plan_state: "planning" }, null)).toBe("queued"); | |
| 19 | + expect(boardState({ state: "ready", pr_number: 4, plan_state: "approved" }, open())).toBe("review"); | |
| 20 | + expect(boardState({ state: "failed", pr_number: null, plan_state: "waiting" }, null)).toBe("waiting"); | |
| 21 | + }); | |
| 22 | + it("knows the plan-first policies", () => { | |
| 23 | + for (const v of ["off", "default", "required"]) expect(isPlanFirst(v)).toBe(true); | |
| 24 | + for (const v of ["on", "", null, 1, "toString"]) expect(isPlanFirst(v)).toBe(false); | |
| 25 | + }); | |
| 14 | 26 | it("then by its pull request", () => { |
| 15 | 27 | expect(boardState({ state: "ready", pr_number: 4 }, open())).toBe("review"); |
| 16 | 28 | expect(boardState({ state: "ready", pr_number: 4 }, open({ checks: "none" }))).toBe("review"); |
modifiedapps/diffinery/test/unit/tasks.test.ts+24−1View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { PROVIDERS } from "../../src/agentic/keys.js"; |
| 3 | 3 | import { proxyAllows, proxyToken } from "../../src/agentic/proxy.js"; |
| 4 | -import { agentEnv, finalMessage, OPENROUTER_MODEL, parseRuntimeChoice, runPrompt, RUNTIMES, runtimeChoices, runtimeLabel, runtimeNeeds, taskBranch, taskTitle, transcriptItem, type TranscriptLine } from "../../src/agentic/runtimes.js"; | |
| 4 | +import { agentEnv, finalMessage, OPENROUTER_MODEL, parseRuntimeChoice, planPrompt, runPrompt, RUNTIMES, runtimeChoices, runtimeLabel, runtimeNeeds, taskBranch, taskTitle, transcriptItem, type TranscriptLine } from "../../src/agentic/runtimes.js"; | |
| 5 | 5 | import { modelRoute } from "../../src/agentic/tasks.js"; |
| 6 | 6 | import { mayStop } from "../../src/agentic/tasks.js"; |
| 7 | 7 | import type { TaskRow } from "../../src/agentic/ProjectDO.js"; |
| @@ -19,6 +19,29 @@ | ||
| 19 | 19 | }); |
| 20 | 20 | }); |
| 21 | 21 | |
| 22 | +describe("plan first prompts", () => { | |
| 23 | + it("asks a plan run for a plan only, read-only", () => { | |
| 24 | + const p = planPrompt({ goal: "Add caching", number: 7, by: "ayse" }); | |
| 25 | + expect(p).toContain("planning task #7"); | |
| 26 | + expect(p).toContain("read-only"); | |
| 27 | + expect(p).toContain("Goal:\nAdd caching"); | |
| 28 | + expect(p).not.toContain("previous plan"); | |
| 29 | + }); | |
| 30 | + it("gives a re-plan the plan it replaces and the asked-for changes", () => { | |
| 31 | + const p = planPrompt({ goal: "Add caching", number: 7, by: "ayse", plan: "1. Add a cache", message: "Use the KV store" }); | |
| 32 | + expect(p).toMatch(/Your previous plan:\n--- plan ---\n1\. Add a cache\n--- end of plan ---/); | |
| 33 | + expect(p).toContain("Changes ayse asked for:\nUse the KV store"); | |
| 34 | + }); | |
| 35 | + it("hands the build run the approved plan, also on a follow-up", () => { | |
| 36 | + const first = runPrompt({ goal: "Add caching", number: 7, branch: "agent/7", by: "ayse", plan: "1. Add a cache" }); | |
| 37 | + expect(first).toMatch(/Goal:\nAdd caching\n\nThe plan for this goal, which ayse approved\. Follow it:\n--- plan ---\n1\. Add a cache\n--- end of plan ---$/); | |
| 38 | + const next = runPrompt({ goal: "Add caching", number: 7, branch: "agent/7", by: "ayse", plan: "1. Add a cache", message: "Fix the test" }); | |
| 39 | + expect(next).toContain("--- plan ---\n1. Add a cache"); | |
| 40 | + expect(next.indexOf("--- plan ---")).toBeLessThan(next.indexOf("New instructions from ayse")); | |
| 41 | + expect(runPrompt({ goal: "Add caching", number: 7, branch: "agent/7", by: "ayse" })).not.toContain("plan"); | |
| 42 | + }); | |
| 43 | +}); | |
| 44 | + | |
| 22 | 45 | describe("runPrompt", () => { |
| 23 | 46 | it("carries the goal, and for a follow-up the previous ending and the message", () => { |
| 24 | 47 | const first = runPrompt({ goal: "Add a test", number: 4, branch: "agent/4-add-a-test", by: "ayse" }); |