Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/server/src/agent/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,9 @@ dependencies only. Tasks with no dependency are independent roots and can run
in parallel; a dependency means the later task cannot be safely completed
first. Add, replace, reorder and remove definitions only through the graph
tool. Never edit plan prose while preparing implementation, and never approve,
lock or start implementation — those are decisions and actions for people.
lock or start implementation — those are decisions and actions for people. A
person's Build plan press is the approval: it approves the saved draft and starts
it, so never tell people that saved tasks still need approval.

Background work is separate from this conversation. Use
\`list_background_jobs\` and \`read_background_job\` only when a later user request makes
Expand Down
20 changes: 18 additions & 2 deletions apps/server/src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ const GRAPH_REFUSALS: Record<string, string> = {
"stale-graph": "The graph changed. Call read_implementation_graph again.",
};

/**
* What a saved draft means, for the Planner to say: there is no separate approval. A first build's
* Build plan press approves exactly this draft and starts it. A built document's Build carries its
* edits onto the pull requests from the document itself, so a draft there is not what it runs.
*/
function draftNote(plan: { live?: unknown }): string {
return plan.live
? "Saved as a draft. This document is already built: Build carries the document's edits "
+ "onto its pull requests from the document itself, not from this graph, and needs no "
+ "approval. Don't say the draft needs approval; say that Build builds the changes."
: "Saved as a draft. Pressing Build plan approves exactly this draft and starts it; there "
+ "is no other approval step. Don't say it needs approval; say that Build plan builds it.";
}

function sameMemberRequest(
current: ActiveMemberRequest | undefined,
expected: Pick<ActiveMemberRequest, "entryId" | "turnId" | "lifecycle">,
Expand Down Expand Up @@ -709,7 +723,9 @@ export const documentTools = {
description: "Create or revise the draft implementation graph against the plan and graph "
+ "revisions from read_implementation_graph. Submit one atomic batch of add, replace, "
+ "reorder and remove operations. This never changes plan content. Only people may "
+ "approve, lock or start implementation.",
+ "approve, lock or start implementation: a person's Build plan press approves the saved "
+ "draft and starts it, so never tell people a draft still needs approval. Read the "
+ "result's note before you describe what happens next.",
inputSchema: jsonSchema({
type: "object",
properties: {
Expand Down Expand Up @@ -752,7 +768,7 @@ export const documentTools = {
let ready = implementationReadiness(context.plan, args.planRevision);
if (!ready.ok) return { ok: false, reason: "not-ready", blockers: ready.blockers };
let result = await implementationGraphs().revise(context.plan, args);
if (result.ok) return { ok: true, graph: result.value };
if (result.ok) return { ok: true, graph: result.value, note: draftNote(context.plan) };
let message = GRAPH_REFUSALS[result.reason];
return { ok: false, reason: result.reason, ...(message ? { message } : {}) };
}),
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ function documentBackend(): Service.Backend {
},
liveBuild: !!config.liveBuild,
onBuildStopped: id => liveSync?.stopped(id),
onBuildNotice: (id, notice) => {
announceBuildNotice(id, notice).catch(err =>
console.error(`[build] could not post ${notice.id} in ${id}'s Chat:`, err)
);
},
onEditingUnlocked: createEditingUnlocked({
withDocumentLock,
opened: id => Rooms.get(id)?.plan,
Expand Down Expand Up @@ -2691,6 +2696,32 @@ function announceResearchTerminal(channelId: string, id: string, text: string):
});
}

/**
* A build's line in Chat, after the change it reports was persisted. It waits for the document lock
* the report may still hold, then posts on the open room, or on the document opened for it.
*/
function announceBuildNotice(
channelId: string,
notice: { id: string; text: string },
): Promise<void> {
if (cutting) return Promise.resolve();
return withDocumentLock(channelId, async () => {
let post = (plan: Service.Plan) =>
Chat.noticeOnce({ chat: plan.chat, plan, server, room: channelId }, notice.id, notice.text);
let active = Rooms.get(channelId)?.plan;
if (active) {
await post(active);
return;
}
let detached = await Service.open(channelId, documentBackend(), server);
try {
await post(detached);
} finally {
await Service.close(detached);
}
});
}

/** Run `action` on a document's live plan, or open it detached for the duration. */
function withDecisionPlan(channelId: string, action: (plan: Service.Plan) => Promise<void>) {
return withDocumentLock(channelId, async () => {
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/plan/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export type Backend = {
onBuildStopped?: (channelId: string) => void;
/** A document's editing lock was released and announced. */
onEditingUnlocked?: (channelId: string) => void;
/**
* A build opened a pull request or ended, after it was persisted: post the line in Chat. Called
* once per notice for each open plan; the notice's ID keeps a repeat to one line.
*/
onBuildNotice?: (channelId: string, notice: { id: string; text: string }) => void;
};

export type DocumentTarget = {
Expand Down
182 changes: 182 additions & 0 deletions apps/server/src/tasks/build-notices.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { expect, test } from "bun:test";

import { buildNotices, pullRequestLink } from "./build-notices";
import { transition } from "./lifecycle";

import type { BuildRequest } from "@chopin/protocol/implementation";
import type { Graph, Run } from "./graphs";
import type { Lifecycle, LifecycleState } from "./lifecycle";

let tasks = ["scaffold", "proof"].map((id, index) => ({
id,
title: index === 0 ? "Scaffold the crate" : "Prove the contract",
context: "The document.",
goal: "Do it.",
acceptance: ["It is done.", "It is checked."],
dependsOn: index === 0 ? [] : ["scaffold"],
}));

let graph: Graph = {
versions: [{ number: 1, revision: 1, planRevision: 3, state: "locked", definition: { tasks } }],
};

let run: Run = {
id: "build-1",
user: "U_ana",
client: { name: "Fake", version: "1" },
session: "s",
planRevision: 3,
graphVersion: 1,
graphRevision: 1,
repository: "octo/score",
branch: "main",
commit: "0".repeat(40),
startedAt: "2026-11-12T10:00:00.000Z",
};

let build: BuildRequest = {
id: "build-1",
user: "U_ana",
connectionId: "c",
repositoryId: "R",
checkout: { repository: "octo/score", commit: "0".repeat(40) },
planRevision: 3,
graphVersion: 1,
graphRevision: 1,
createdAt: "2026-11-12T10:00:00.000Z",
expiresAt: 0,
state: "running",
};

const PR = "https://github.com/octo/score/pull/12";

function step(state: LifecycleState, input: Record<string, unknown>): LifecycleState {
let result = transition(state, { runId: run.id, ...input } as never);
if (result.kind !== "accepted") throw new Error(result.kind);
return result.state;
}

function started(): LifecycleState {
let state: LifecycleState = { graph, execution: run, lifecycle: { history: [] } as Lifecycle };
state = step(state, { kind: "start", taskId: "scaffold", idempotencyKey: "a" });
return step(state, {
kind: "report_pr",
taskId: "scaffold",
url: PR,
state: "open",
idempotencyKey: "b",
});
}

test("a running build's pull request is one line, named for its first task, until the build ends", () => {
let state = started();
let notices = buildNotices({ ...state, builds: [build], live: undefined });
expect(notices).toEqual([{
id: `build-pr:build-1:${PR}`,
text: `Build opened [#12](${PR}) for “Scaffold the crate”.`,
}]);
// The next task reporting the same pull request adds no second line.
state = step(state, {
kind: "complete",
taskId: "scaffold",
summary: "Done.",
idempotencyKey: "c",
});
state = step(state, { kind: "start", taskId: "proof", idempotencyKey: "d" });
state = step(state, {
kind: "report_pr",
taskId: "proof",
url: PR,
state: "open",
idempotencyKey: "e",
});
expect(buildNotices({ ...state, builds: [build], live: undefined })).toEqual(notices);
});

test("a finished build says how many tasks and links its pull requests; a stopped one says why", () => {
let state = started();
state = step(state, {
kind: "complete",
taskId: "scaffold",
summary: "Done.",
idempotencyKey: "c",
});
state = step(state, { kind: "start", taskId: "proof", idempotencyKey: "d" });
let second = "https://github.com/octo/score/pull/13";
state = step(state, {
kind: "report_pr",
taskId: "proof",
url: second,
state: "open",
idempotencyKey: "e",
});
state = step(state, { kind: "complete", taskId: "proof", summary: "Done.", idempotencyKey: "f" });
state = step(state, {
kind: "report_verification",
passed: true,
summary: "Both pass.",
reviewerMethod: "Ran the checks.",
evidence: tasks.map(task => ({ taskId: task.id, evidence: ["passed"] })),
tasksNeedingWork: [],
idempotencyKey: "v",
});
expect(state.execution).toBeUndefined();
let ended = { ...build, state: "stopped" as const, finishedAt: "2026-11-12T11:00:00.000Z" };
let notices = buildNotices({ ...state, builds: [ended], live: undefined });
expect(notices.at(-1)).toEqual({
id: "build-end:build-1",
text: `Build finished: 2 tasks, [#12](${PR}) and [#13](${second}).`,
});

let blocked = step(started(), {
kind: "request_revision",
reason: "The verifier refused the crate.",
idempotencyKey: "r",
});
expect(buildNotices({ ...blocked, builds: [ended], live: undefined }).at(-1)).toEqual({
id: "build-end:build-1",
text: "Build stopped: The verifier refused the crate.",
});

let failed = { ...build, state: "failed" as const, error: "The agent did not start." };
expect(buildNotices({ graph, lifecycle: { history: [] }, builds: [failed], live: undefined }))
.toEqual([{ id: "build-end:build-1", text: "Build didn’t start: The agent did not start." }]);
});

test("a rebuild that lands says which pull requests it updated", () => {
let at = "2026-11-12T12:00:00.000Z";
let rebuild: BuildRequest = {
...build,
id: "rebuild-1",
kind: "rebuild",
state: "stopped",
finishedAt: at,
};
let notices = buildNotices({
graph,
lifecycle: { history: [] },
builds: [rebuild],
live: {
pullRequests: [PR],
commits: [
{
pullRequest: PR,
sha: "a",
message: "older",
revision: 2,
at: "2026-11-12T11:00:00.000Z",
},
{ pullRequest: PR, sha: "b", message: "edit", revision: 4, at },
],
} as never,
});
expect(notices).toEqual([{
id: "build-end:rebuild-1",
text: `Build finished: updated [#12](${PR}).`,
}]);
});

test("a pull request link is its number, and anything else is the link itself", () => {
expect(pullRequestLink(PR)).toBe(`[#12](${PR})`);
expect(pullRequestLink("https://example.com/x")).toBe("<https://example.com/x>");
});
Loading
Loading