diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 3e5c857105..3387102b93 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -240,7 +240,7 @@ Routine execution issues add a routine-scoped env overlay after project env and - `work_mode` text not null default `standard`; supported values: - `standard`: normal autonomous execution. Agents may investigate, edit files, create artifacts, and complete the task. - `ask`: answer-only execution. Agents may use tools for investigation or temporary scratch work, but the deliverable is an issue-thread answer; they must not write implementation code or produce an implementation plan. - - `planning`: plan-only execution. Agents create or revise the plan without implementation work; accepted-plan continuations remain planning-specific and create child issues from the approved plan. + - `planning`: plan-only execution. Agents create or revise the plan without implementation work. Accepting a fresh confirmation for the issue's current `plan` revision atomically changes this mode to `standard`, so the continuation may implement the approved plan on the source issue. - `billing_code` text null - `assignee_adapter_overrides` jsonb null - `execution_policy` jsonb null @@ -258,6 +258,7 @@ Invariants: - `in_progress` requires assignee - an `in_review -> done | cancelled` verdict is authorized against the current review policy while the issue row is locked; a policy change in the same request or a concurrent request cannot relax that verdict gate - accepting or rejecting the review-confirmation interaction locks the issue row before resolving the interaction and reauthorizes against the current review policy in that transaction +- accepting a fresh `request_confirmation` for the current issue's `plan` revision changes `work_mode = planning` to `work_mode = standard` in the same transaction as the accepted interaction; the existing agent-return transition also moves an eligible `in_review` issue to `todo` without changing its agent owner - while a restrictive review policy is stored, changing it requires an actor who is allowed by that row-locked policy - the transition into `in_review` and its requester activity record commit atomically, including transitions without an explicit review-interaction binding - terminal states: `done | cancelled` diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index a93bf5bf4b..3f8834475e 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -1744,6 +1744,17 @@ describe.sequential("issue thread interaction routes", () => { }), }), ); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "issue.updated", + details: expect.objectContaining({ + source: "request_confirmation_accept", + workMode: "standard", + _previous: expect.objectContaining({ workMode: "planning" }), + }), + }), + ); }); it("forces a fresh workspace-aware session when accepting a plan document confirmation on a standard-work issue", async () => { diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 309bd8cb33..c6d2921fe4 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -102,6 +102,43 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { return { companyId, goalId, issueId }; } + async function attachPlanDocument(companyId: string, issueId: string) { + const documentId = randomUUID(); + const revisionId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Plan", + format: "markdown", + latestBody: "# Plan", + latestRevisionId: revisionId, + latestRevisionNumber: 1, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId, + documentId, + key: "plan", + }); + await db.insert(documentRevisions).values({ + id: revisionId, + companyId, + documentId, + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: "# Plan", + }); + return { + type: "issue_document" as const, + issueId, + documentId, + key: "plan", + revisionId, + revisionNumber: 1, + }; + } + async function recordReviewTransition(args: { companyId: string; issueId: string; @@ -2656,6 +2693,142 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { }); }); + it("atomically returns an accepted Plan-mode issue to its agent in Auto mode", async () => { + const { companyId, goalId, issueId } = await seedConfirmationIssue("Accept a plan into Auto mode"); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Plan owner", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.update(issues).set({ + status: "in_review", + workMode: "planning", + assigneeAgentId: agentId, + }).where(eq(issues.id, issueId)); + const target = await attachPlanDocument(companyId, issueId); + const created = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_confirmation", + continuationPolicy: "wake_assignee_on_accept", + payload: { version: 1, prompt: "Accept this plan?", target }, + }, { agentId }); + + const accepted = await interactionsSvc.acceptInteraction({ + id: issueId, + companyId, + goalId, + projectId: null, + }, created.id, {}, { userId: "local-board" }); + + expect(accepted.interaction).toMatchObject({ + id: created.id, + status: "accepted", + result: { outcome: "accepted" }, + }); + expect(accepted.continuationIssue).toEqual({ + id: issueId, + assigneeAgentId: agentId, + assigneeUserId: null, + status: "todo", + workMode: "standard", + }); + await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0])).resolves.toMatchObject({ + status: "todo", + workMode: "standard", + assigneeAgentId: agentId, + assigneeUserId: null, + }); + }); + + it("keeps Plan mode for non-plan and checkbox confirmations", async () => { + const { companyId, goalId, issueId } = await seedConfirmationIssue("Do not auto-transition other confirmations"); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Plan owner", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.update(issues).set({ + status: "in_review", + workMode: "planning", + assigneeAgentId: agentId, + }).where(eq(issues.id, issueId)); + + const nonPlan = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_confirmation", + payload: { version: 1, prompt: "Accept this unrelated decision?" }, + }, { agentId }); + await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, nonPlan.id, {}, { + userId: "local-board", + }); + await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) + .resolves.toBe("planning"); + + await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, issueId)); + const target = await attachPlanDocument(companyId, issueId); + const checkbox = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_checkbox_confirmation", + payload: { + version: 1, + prompt: "Select approved plan sections", + options: [{ id: "phase-1", label: "Phase 1" }], + target, + }, + }, { agentId }); + await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, checkbox.id, { + selectedOptionIds: ["phase-1"], + }, { userId: "local-board" }); + await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) + .resolves.toBe("planning"); + }); + + it.each(["ask", "standard"] as const)("keeps %s mode when accepting a plan confirmation", async (workMode) => { + const { companyId, goalId, issueId } = await seedConfirmationIssue(`Keep ${workMode} mode`); + await db.update(issues).set({ workMode }).where(eq(issues.id, issueId)); + const target = await attachPlanDocument(companyId, issueId); + const created = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_confirmation", + payload: { version: 1, prompt: "Accept this plan?", target }, + }, { userId: "local-board" }); + + await interactionsSvc.acceptInteraction({ id: issueId, companyId, goalId, projectId: null }, created.id, {}, { + userId: "local-board", + }); + + await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) + .resolves.toBe(workMode); + }); + + it("keeps Plan mode when a plan confirmation is rejected", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Reject a plan"); + await db.update(issues).set({ workMode: "planning" }).where(eq(issues.id, issueId)); + const target = await attachPlanDocument(companyId, issueId); + const created = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_confirmation", + payload: { version: 1, prompt: "Accept this plan?", target }, + }, { userId: "local-board" }); + + const rejected = await interactionsSvc.rejectInteraction({ id: issueId, companyId }, created.id, { + reason: "Revise the plan", + }, { userId: "local-board" }); + + expect(rejected.status).toBe("rejected"); + await expect(db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]?.workMode)) + .resolves.toBe("planning"); + }); + it("expires request confirmations by default when a user comments after creation", async () => { const { companyId, issueId } = await seedConfirmationIssue(); const commentId = randomUUID(); diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 107daa54c1..7b9ea530fa 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -1786,7 +1786,8 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti issueContext.id, ); const acceptedPlanStartsExecution = - acceptedPlanTarget?.issueId === issueContext.id + lockedCurrent.kind === "request_confirmation" + && acceptedPlanTarget?.issueId === issueContext.id && acceptedPlanTarget.key === "plan" && issueContext.workMode === "planning"; if (isNativeCompletionReview(lockedCurrent)) {