From 1a4702fe47facf24610f4044b78bd01a235ed85b Mon Sep 17 00:00:00 2001 From: PeterGuy326 <739820174@qq.com> Date: Sun, 18 Oct 2026 17:14:20 +0800 Subject: [PATCH 1/2] feat(goals): carry rejection into bounded task repairs Refs #606 (R1). --- CHANGELOG.md | 4 + apps/desktop/packaging/runtime-layout.cjs | 1 + apps/server/src/goals/rework.ts | 60 +++++++++ apps/server/src/routes/turns.ts | 4 + apps/server/test/goal-task-rework.test.ts | 148 ++++++++++++++++++++++ docs/thread-context-and-collaboration.md | 33 +++++ 6 files changed, 250 insertions(+) create mode 100644 apps/server/src/goals/rework.ts create mode 100644 apps/server/test/goal-task-rework.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c89acb9..8b640a36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ## [Unreleased] +### Added + +- Human-started repairs of rejected goal tasks receive the saved criterion verdicts, feedback and rejected delivery through bounded context. Repairs stop after three attempts or when the plan, owner, session or reviewed delivery changes; an Agent result still requires human acceptance. Refs #606. + ## [0.5.0] — 2026-10-16 ### Added diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index 2f7a7678..b1905ef1 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -104,6 +104,7 @@ const SERVER_RUNTIME_FILES = [ "dist/src/experiments/service.js", "dist/src/experiments/store.js", "dist/src/gemini-binary.js", + "dist/src/goals/rework.js", "dist/src/goals/store.js", "dist/src/groups/relay-stop.js", "dist/src/groups/store.js", diff --git a/apps/server/src/goals/rework.ts b/apps/server/src/goals/rework.ts new file mode 100644 index 00000000..aadde2ab --- /dev/null +++ b/apps/server/src/goals/rework.ts @@ -0,0 +1,60 @@ +import { OrgApiError, errorCodes } from "@roleweave/shared"; +import type { ControlPlaneContext } from "../context.js"; +import type { SupplementalContext } from "../turns/thread-context.js"; +import { compareRfc3339Instants } from "../turns/store.js"; +import { projectTaskDeliveries } from "./store.js"; + +export const MAX_TASK_REPAIRS = 3; + +/** A human-started repair uses existing acceptance and execution records only. */ +export async function prepareGoalRework( + ctx: ControlPlaneContext, + workspace: string, + request: { goalId?: string; branchId?: string; positionId: string }, + sessionId?: string, +): Promise { + if (!request.goalId || !request.branchId) return []; + const goal = await ctx.goalStore.get(workspace, request.goalId); + const decisions = (goal.taskAcceptances ?? []).filter(record => record.taskId === request.branchId); + const rejection = decisions.at(-1); + if (rejection?.decision !== "rejected") return []; + const conflict = (message: string): never => { + throw new OrgApiError(errorCodes.goal_conflict, 409, message); + }; + const item = goal.workItems?.find(task => task.taskId === request.branchId); + if (!item || item.assigneePositionId !== request.positionId || + rejection.source.positionId !== request.positionId || rejection.source.sessionId !== sessionId) { + conflict("repair must use the rejected delivery's assigned employee and personal session"); + } + if (goal.updatedAt !== rejection.decidedAt || goal.status === "completed" || goal.status === "cancelled" || + JSON.stringify(goal.acceptanceCriteria) !== JSON.stringify(rejection.criteriaSnapshot)) { + conflict("repair criteria or goal state changed; review the plan before continuing"); + } + // Read failures propagate. An unavailable ledger must not reset the attempt count. + const turns = await ctx.turnStore.reportRecords(workspace); + const taskTurns = turns.filter(turn => turn.goalId === goal.goalId && turn.branchId === request.branchId); + const firstRejection = decisions.find(record => record.decision === "rejected")!; + const repairs = taskTurns.filter(turn => + turn.turnId !== firstRejection.source.turnId && + compareRfc3339Instants(turn.createdAt, firstRejection.decidedAt) >= 0); + if (repairs.length >= MAX_TASK_REPAIRS) { + conflict("repair limit reached (3); reassess the task and create a follow-up work item"); + } + const delivery = projectTaskDeliveries(goal, taskTurns).taskDeliveries[request.branchId]; + if (!delivery) return conflict("the latest execution needs a new human review before another repair"); + if ((["positionId", "turnId", "sessionId", "outputDigest"] as const) + .some(key => delivery.source[key] !== rejection.source[key])) { + conflict("the latest execution needs a new human review before another repair"); + } + const feedback = JSON.stringify({ + goalId: goal.goalId, taskId: request.branchId, acceptanceId: rejection.acceptanceId, + source: rejection.source, criteria: rejection.criteriaSnapshot, verdicts: rejection.verdicts, + feedback: rejection.note, repairAttempt: repairs.length + 1, maxRepairs: MAX_TASK_REPAIRS, + }); + // The existing context assembler caps each field at 8 KiB. Never silently lose a verdict. + if (Buffer.byteLength(feedback, "utf8") > 8 * 1024) { + conflict("repair feedback exceeds the context limit; split the task into smaller work items"); + } + return [{ label: `Human-reviewed repair ${rejection.acceptanceId}`, input: feedback, + output: delivery.output }]; +} diff --git a/apps/server/src/routes/turns.ts b/apps/server/src/routes/turns.ts index 96ee4cd0..20dda1b8 100644 --- a/apps/server/src/routes/turns.ts +++ b/apps/server/src/routes/turns.ts @@ -31,6 +31,7 @@ import type { TurnAttachment } from "@roleweave/shared"; import { ATTACHMENT_MAX_COUNT } from "@roleweave/shared"; import { assembleVaultContext } from "../vault/context.js"; import { recordVaultUsage } from "../vault/receipts.js"; +import { prepareGoalRework } from "../goals/rework.js"; const MAX_INPUT_BYTES = 256 * 1024; @@ -253,6 +254,9 @@ export async function executeTurn( void tracker.persist(workspace.dir, body.positionId, turnId); }; try { + const repairContext = await prepareGoalRework(ctx, workspace.dir, body, group ? undefined : session?.sessionId); + assertTurnWorkspace(ctx, workspace); + if (repairContext.length > 0) supplementalContext = [...(supplementalContext ?? []), ...repairContext]; let retryHistory: TurnRecord[] | undefined; if (body.retryOf !== undefined) { if (session === undefined || group !== undefined) { diff --git a/apps/server/test/goal-task-rework.test.ts b/apps/server/test/goal-task-rework.test.ts new file mode 100644 index 00000000..b33ee147 --- /dev/null +++ b/apps/server/test/goal-task-rework.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import test from "node:test"; +import type { GoalDetail, TurnRunDriver, TurnRunRequest } from "@roleweave/shared"; +import { GoalStore } from "../src/goals/store.js"; +import { TurnStore } from "../src/turns/store.js"; +import { prepareGoalRework } from "../src/goals/rework.js"; +import { api, copyExampleWorkspace, DefaultFakeTurnDriver, startTestServer } from "./helpers.js"; + +async function fixture(t: test.TestContext) { + const inputs: TurnRunRequest[] = []; + const fake = new DefaultFakeTurnDriver(); + const driver: TurnRunDriver = { async turnRun(request) { inputs.push(request); return fake.turnRun(request); } }; + const server = await startTestServer(undefined, driver); + const workspace = await copyExampleWorkspace(); + t.after(async () => { await server.close(); await server.ctx.contextExporter.waitForIdle(); + await fs.rm(workspace, { recursive: true, force: true }); }); + const call = (url: string, method = "GET", body?: unknown) => api(server.baseUrl, url, + { method, token: server.token, ...(body === undefined ? {} : { body }) }); + assert.equal((await call("/workspace/open", "POST", { path: workspace })).status, 200); + const created = await call("/goals", "POST", { title: "Generic review", description: "No business schema", + acceptanceCriteria: ["Include reproducible evidence"] }); + const goalId = (created.body as { goalId: string }).goalId; + const detail = async () => (await call(`/goals/${goalId}`)).body as GoalDetail; + assert.equal((await call(`/goals/${goalId}`, "PATCH", { expectedUpdatedAt: (await detail()).goal.updatedAt, + workItems: [{ taskId: "report", title: "Produce the report", priority: "normal", status: "todo", + assigneePositionId: "repo-owner" }] })).status, 200); + const sessionId = ((await call("/sessions", "POST", { positionId: "repo-owner" })).body as { sessionId: string }).sessionId; + const run = () => call(`/sessions/${sessionId}/turns`, "POST", { + input: "Produce the report", engine: "qoder", goalId, branchId: "report" }); + const reject = async (note = "Add a reproducible check, not a claim of success") => { + const current = await detail(); + const response = await call(`/goals/${goalId}/tasks/report/acceptance`, "POST", { + expectedWorkspacePath: workspace, expectedUpdatedAt: current.goal.updatedAt, + idempotencyKey: crypto.randomUUID(), source: current.taskDeliveries!["report"]!.source, + decision: "rejected", verdicts: [{ criteriaIndex: 0, passed: false }], note }); + assert.equal(response.status, 200); + }; + assert.equal((await run()).status, 200); + await reject(); + return { server, workspace, call, goalId, sessionId, detail, inputs, run, reject }; +} + +test("human-started repair receives saved feedback even with session history disabled", async t => { + const f = await fixture(t); + assert.equal((await f.call(`/sessions/${f.sessionId}/context`, "PATCH", { enabled: false })).status, 200); + assert.equal((await f.run()).status, 200); + const delivery = (await f.detail()).taskDeliveries!["report"]!; + const turn = (await f.server.ctx.turnStore.reportRecords(f.workspace)) + .find(record => record.turnId === delivery.source.turnId); + assert.equal(turn!.input, "Produce the report"); + assert.equal(turn!.threadContext!.sourceTurnCount, 1); + const captured = JSON.stringify(f.inputs.at(-1)); + assert.match(captured, /Add a reproducible check/); + assert.match(captured, /Human-reviewed repair/); + assert.match(captured, /fake turn output/); + assert.match(captured, /untrusted historical data/); + assert.equal((await f.detail()).goal.workItems![0]!.status, "review"); + assert.equal((await f.run()).status, 409); + assert.equal(f.inputs.length, 2, "unreviewed repair cannot launch another model call"); +}); + +test("three persisted repair attempts exhaust the limit across store restarts", async t => { + const f = await fixture(t); + for (let attempt = 0; attempt < 3; attempt++) { + f.server.ctx.turnStore = new TurnStore(); + f.server.ctx.goalStore = new GoalStore(); + assert.equal((await f.run()).status, 200); + await f.reject(); + } + const stopped = await f.run(); + assert.equal(stopped.status, 409); + assert.match(JSON.stringify(stopped.body), /repair limit/); + assert.equal(f.inputs.length, 4); +}); + +test("changed plan and a foreign personal session cannot consume a rejected delivery", async t => { + const f = await fixture(t); + await assert.rejects(prepareGoalRework(f.server.ctx, f.workspace, + { goalId: f.goalId, branchId: "report", positionId: "other-employee" }, f.sessionId), /assigned employee/); + await assert.rejects(prepareGoalRework(f.server.ctx, f.workspace, + { goalId: f.goalId, branchId: "report", positionId: "repo-owner" }, crypto.randomUUID()), /personal session/); + const current = await f.detail(); + assert.equal((await f.call(`/goals/${f.goalId}`, "PATCH", { + expectedUpdatedAt: current.goal.updatedAt, acceptanceCriteria: ["Different requirement"] })).status, 200); + assert.equal((await f.run()).status, 409); + assert.equal(f.inputs.length, 1); +}); + +test("unavailable execution history fails closed without dispatching a repair", async t => { + const f = await fixture(t); + f.server.ctx.turnStore.reportRecords = async () => { throw new Error("history unavailable"); }; + const response = await f.run(); + assert.equal(response.status, 500); + assert.equal(f.inputs.length, 1); +}); + +test("repair feedback is sanitized by the existing context boundary", async t => { + const f = await fixture(t); + await f.reject("Check authorization: Bearer sample-private-token-1234567890"); + assert.equal((await f.run()).status, 200); + assert.doesNotMatch(JSON.stringify(f.inputs.at(-1)), /sample-private-token/); +}); + +test("concurrent repair requests dispatch once and require a fresh human verdict", async t => { + const f = await fixture(t); + const responses = await Promise.all([f.run(), f.run()]); + assert.deepEqual(responses.map(response => response.status).sort(), [200, 409]); + assert.equal(f.inputs.length, 2); +}); + +test("changed persisted output cannot be supplied under an older rejection digest", async t => { + const f = await fixture(t); + const read = f.server.ctx.turnStore.reportRecords.bind(f.server.ctx.turnStore); + f.server.ctx.turnStore.reportRecords = async workspace => (await read(workspace)).map(turn => + turn.goalId === f.goalId ? { ...turn, output: "A different result" } : turn); + assert.equal((await f.run()).status, 409); + assert.equal(f.inputs.length, 1); +}); + +test("failed attempts remain part of the durable repair ceiling", async t => { + const f = await fixture(t); + const turns = await f.server.ctx.turnStore.reportRecords(f.workspace); + const source = turns.find(turn => turn.goalId === f.goalId)!; + const afterRejection = new Date(Date.parse((await f.detail()).goal.updatedAt) + 1).toISOString(); + f.server.ctx.turnStore.reportRecords = async () => [...turns, ...Array.from({ length: 3 }, () => ({ + ...source, turnId: crypto.randomUUID(), status: "failed" as const, + createdAt: afterRejection, updatedAt: afterRejection, + }))]; + const response = await f.run(); + assert.equal(response.status, 409); + assert.match(JSON.stringify(response.body), /repair limit/); + assert.equal(f.inputs.length, 1); +}); + +test("oversized feedback is rejected instead of silently dropping human criteria", async t => { + const f = await fixture(t); + const goal = (await f.detail()).goal; + goal.acceptanceCriteria = ["c".repeat(4096)]; + const rejection = goal.taskAcceptances!.at(-1)!; + rejection.criteriaSnapshot = [...goal.acceptanceCriteria]; + rejection.note = "n".repeat(4096); + f.server.ctx.goalStore.get = async () => goal; + await assert.rejects(prepareGoalRework(f.server.ctx, f.workspace, + { goalId: f.goalId, branchId: "report", positionId: "repo-owner" }, f.sessionId), /context limit/); + assert.equal(f.inputs.length, 1); +}); diff --git a/docs/thread-context-and-collaboration.md b/docs/thread-context-and-collaboration.md index 802d36ed..ea889c82 100644 --- a/docs/thread-context-and-collaboration.md +++ b/docs/thread-context-and-collaboration.md @@ -32,6 +32,39 @@ Assignment is explicit. This change does not add role-based automatic matching, ## API and compatibility +### Repair a rejected goal task + +After reviewing a completed project task, use its existing acceptance drawer to +return it with criterion verdicts and a concrete reason. Run the task again in +the assigned employee's original personal session. The control plane adds the +saved decision, criteria, acceptance ID, source digest and visible rejected +answer to the existing bounded, sanitized context. The operator's original +request remains unchanged. This feedback is supplied even when ordinary session +history is disabled; it is part of the explicitly requested task repair. + +Each repaired result needs a new human review before another repair. A maximum +of three repair executions is counted from durable task history after its first +rejection, including failed attempts. Restarting the control plane does not +reset that count. Exhaustion requires reassessing the work and creating a +follow-up task. An unavailable ledger, changed plan, reassigned employee, +different session, closed goal or unreviewed newer execution stops dispatch. +Feedback larger than the existing context field limit must be split into +smaller tasks; visible answer excerpts may be truncated and the receipt reports +that limit. A failed or indeterminate repair requires operator investigation; +the system does not replay it automatically. + +This is a human-started repair handoff, not an autonomous verification loop. +Agent completion does not accept a task or approve external actions. Existing +tool permissions, per-turn budgets, cancellation and human acceptance apply. +Tests exercise the real HTTP/session/store boundaries with a deterministic +driver, not a live model or a customer deployment. + +RoleWeave owns general execution and review. Reusable business cases belong in +[Workbench Examples](https://github.com/bytefolk/workbench-examples); enterprise +facts, ontology, evidence and their data authorization belong to the independent +Enterprise Context service. This handoff adds no business schema, case library, +second task store or queue. + - `PATCH /sessions/:sessionId/context` accepts exactly `{ "enabled": true }` or `{ "enabled": false }` and returns the updated `workbench-session.v1`. The optional `threadContextEnabled` field defaults to `true` when absent in older records. - `POST /groups/:conversationRef/turns` accepts optional `mode: "parallel" | "relay"`; omitted mode means parallel. The `mentions` array is the explicit recipient/relay order. - `turn-record.v1` may include a `threadContext` receipt (`thread-context.v1`). The original operator `input` remains separate from the materialized model input. From db96a02ee9389e8c03e4dee6cb75405251cae481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:43:08 +0800 Subject: [PATCH 2/2] fix(goals): isolate personal repairs from group execution Refs #606 (R1). Bind repair eligibility to the saved plan rather than health refresh clocks; retain legacy acceptance reads and count personal repair attempts only. --- CHANGELOG.md | 1 + apps/server/src/goals/rework.ts | 12 ++-- apps/server/src/goals/store.ts | 8 +++ apps/server/test/goal-task-rework.test.ts | 83 ++++++++++++++++++++++- docs/thread-context-and-collaboration.md | 11 ++- packages/shared/src/goals.ts | 7 +- 6 files changed, 113 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b640a36..6475ad6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added - Human-started repairs of rejected goal tasks receive the saved criterion verdicts, feedback and rejected delivery through bounded context. Repairs stop after three attempts or when the plan, owner, session or reviewed delivery changes; an Agent result still requires human acceptance. Refs #606. +- Rejected-task repair checks leave group execution unchanged, exclude group spawns from the personal repair ceiling, and bind new human verdicts to a plan fingerprint so health refreshes do not invalidate a repair. Existing acceptance records remain readable. Refs #606. ## [0.5.0] — 2026-10-16 diff --git a/apps/server/src/goals/rework.ts b/apps/server/src/goals/rework.ts index aadde2ab..783b0406 100644 --- a/apps/server/src/goals/rework.ts +++ b/apps/server/src/goals/rework.ts @@ -2,7 +2,7 @@ import { OrgApiError, errorCodes } from "@roleweave/shared"; import type { ControlPlaneContext } from "../context.js"; import type { SupplementalContext } from "../turns/thread-context.js"; import { compareRfc3339Instants } from "../turns/store.js"; -import { projectTaskDeliveries } from "./store.js"; +import { goalPlanDigest, projectTaskDeliveries } from "./store.js"; export const MAX_TASK_REPAIRS = 3; @@ -13,7 +13,7 @@ export async function prepareGoalRework( request: { goalId?: string; branchId?: string; positionId: string }, sessionId?: string, ): Promise { - if (!request.goalId || !request.branchId) return []; + if (!request.goalId || !request.branchId || sessionId === undefined) return []; const goal = await ctx.goalStore.get(workspace, request.goalId); const decisions = (goal.taskAcceptances ?? []).filter(record => record.taskId === request.branchId); const rejection = decisions.at(-1); @@ -26,7 +26,9 @@ export async function prepareGoalRework( rejection.source.positionId !== request.positionId || rejection.source.sessionId !== sessionId) { conflict("repair must use the rejected delivery's assigned employee and personal session"); } - if (goal.updatedAt !== rejection.decidedAt || goal.status === "completed" || goal.status === "cancelled" || + const samePlan = rejection.planDigest !== undefined + ? goalPlanDigest(goal) === rejection.planDigest : goal.updatedAt === rejection.decidedAt; + if (!samePlan || goal.status === "completed" || goal.status === "cancelled" || JSON.stringify(goal.acceptanceCriteria) !== JSON.stringify(rejection.criteriaSnapshot)) { conflict("repair criteria or goal state changed; review the plan before continuing"); } @@ -35,10 +37,12 @@ export async function prepareGoalRework( const taskTurns = turns.filter(turn => turn.goalId === goal.goalId && turn.branchId === request.branchId); const firstRejection = decisions.find(record => record.decision === "rejected")!; const repairs = taskTurns.filter(turn => + turn.positionId === request.positionId && turn.conversationRef === sessionId && + turn.conversationId === sessionId && !turn.groupRef && turn.turnId !== firstRejection.source.turnId && compareRfc3339Instants(turn.createdAt, firstRejection.decidedAt) >= 0); if (repairs.length >= MAX_TASK_REPAIRS) { - conflict("repair limit reached (3); reassess the task and create a follow-up work item"); + conflict(`repair limit reached (${MAX_TASK_REPAIRS}); reassess the task and create a follow-up work item`); } const delivery = projectTaskDeliveries(goal, taskTurns).taskDeliveries[request.branchId]; if (!delivery) return conflict("the latest execution needs a new human review before another repair"); diff --git a/apps/server/src/goals/store.ts b/apps/server/src/goals/store.ts index 2ce3317f..596184ec 100644 --- a/apps/server/src/goals/store.ts +++ b/apps/server/src/goals/store.ts @@ -185,6 +185,13 @@ function digest(value: unknown): string { return `sha256:${crypto.createHash("sha256").update(canonicalJson(value)).digest("hex")}`; } +/** Bind repairs to the human plan, excluding health and record refresh clocks. */ +export function goalPlanDigest(goal: Goal): string { + return digest({ title: goal.title, description: goal.description, status: goal.status, + acceptanceCriteria: goal.acceptanceCriteria, workItems: goal.workItems ?? [], + branches: goal.branches.map(({ createdAt: _created, updatedAt: _updated, ...plan }) => plan) }); +} + function taskTurns(goal: Goal, taskId: string, turns: readonly TurnRecord[]): TurnRecord[] { return turns.filter(turn => turn.goalId === goal.goalId && turn.branchId === taskId) .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.turnId.localeCompare(right.turnId, "en")); @@ -613,6 +620,7 @@ export class GoalStore { const updated: Goal = { ...goal, workItems: goal.workItems!.map(item => item.taskId === taskId ? { ...item, status: value.decision === "accepted" ? "done" : "review" } : item), taskAcceptances: [...(goal.taskAcceptances ?? []), record], updatedAt: decidedAt }; + record.planDigest = goalPlanDigest(updated); // One fsync+rename transaction is the durable authority for both verdict and status. // No second activity file is required to reconstruct acceptance after a restart. try { diff --git a/apps/server/test/goal-task-rework.test.ts b/apps/server/test/goal-task-rework.test.ts index b33ee147..91fb6dd9 100644 --- a/apps/server/test/goal-task-rework.test.ts +++ b/apps/server/test/goal-task-rework.test.ts @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; import crypto from "node:crypto"; import fs from "node:fs/promises"; +import path from "node:path"; import test from "node:test"; -import type { GoalDetail, TurnRunDriver, TurnRunRequest } from "@roleweave/shared"; -import { GoalStore } from "../src/goals/store.js"; +import { validateGoalTaskAcceptanceRecord, type GoalDetail, type GroupConversation, + type TurnRecord, type TurnRunDriver, type TurnRunRequest } from "@roleweave/shared"; +import { GoalStore, goalPlanDigest } from "../src/goals/store.js"; import { TurnStore } from "../src/turns/store.js"; import { prepareGoalRework } from "../src/goals/rework.js"; import { api, copyExampleWorkspace, DefaultFakeTurnDriver, startTestServer } from "./helpers.js"; @@ -75,6 +77,82 @@ test("three persisted repair attempts exhaust the limit across store restarts", assert.equal(f.inputs.length, 4); }); +test("a group turn bound to a rejected task still dispatches without repair context", async t => { + const f = await fixture(t); + const created = await f.call("/groups", "POST", { memberPositionIds: ["repo-owner", "release-engineer"] }); + assert.equal(created.status, 201); + const group = created.body as GroupConversation; + const response = await f.call(`/groups/${group.conversationRef}/turns`, "POST", { + input: "Discuss this report", engine: "qoder", mentions: ["repo-owner"], goalId: f.goalId, branchId: "report" }); + assert.equal(response.status, 202); + const spawnId = (response.body as { spawns: { turnId: string }[] }).spawns[0]!.turnId; + let spawned: TurnRecord | undefined; + for (let attempt = 0; attempt < 100; attempt++) { + spawned = (await f.server.ctx.turnStore.reportRecords(f.workspace)).find(turn => turn.turnId === spawnId); + if (spawned && spawned.status !== "running") break; + await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.equal(spawned?.status, "completed"); + assert.equal(f.inputs.length, 2); + assert.doesNotMatch(JSON.stringify(f.inputs.at(-1)), /Human-reviewed repair/); +}); + +test("group spawns do not consume the personal repair ceiling", async t => { + const f = await fixture(t); + const firstRejection = (await f.detail()).goal.taskAcceptances![0]!; + assert.equal((await f.run()).status, 200); + await f.reject(); + const turns = await f.server.ctx.turnStore.reportRecords(f.workspace); + const source = turns.find(turn => turn.turnId === firstRejection.source.turnId)!; + const spawns: TurnRecord[] = Array.from({ length: 3 }, () => ({ ...source, + turnId: crypto.randomUUID(), groupRef: "discussion", conversationRef: "discussion", + createdAt: firstRejection.decidedAt, updatedAt: firstRejection.decidedAt })); + f.server.ctx.turnStore.reportRecords = async () => [...turns, ...spawns]; + assert.equal((await f.run()).status, 200); + assert.equal(f.inputs.length, 3); +}); + +test("health refreshes preserve repair eligibility but task plan edits invalidate it", async t => { + const f = await fixture(t); + const goal = await f.server.ctx.goalStore.get(f.workspace, f.goalId); + goal.branches = [{ branchId: "health-branch", title: "Observed execution", status: "open", + createdAt: goal.createdAt, updatedAt: goal.updatedAt }]; + await fs.writeFile(path.join(f.workspace, ".roleweave", "goals", f.goalId, "goal.json"), JSON.stringify(goal)); + await f.reject(); + const before = (await f.detail()).goal; + const read = f.server.ctx.turnStore.reportRecords.bind(f.server.ctx.turnStore); + const turns = await read(f.workspace); + f.server.ctx.turnStore.reportRecords = async workspace => [...await read(workspace), { + ...turns[0]!, turnId: crypto.randomUUID(), goalId: f.goalId, branchId: "health-branch", status: "failed" as const, + }]; + const refreshed = (await f.detail()).goal; + assert.notEqual(refreshed.updatedAt, before.updatedAt); + assert.equal(refreshed.health, "at_risk"); + assert.equal((await f.run()).status, 200); + await f.reject(); + const current = (await f.detail()).goal; + assert.equal((await f.call(`/goals/${f.goalId}`, "PATCH", { expectedUpdatedAt: current.updatedAt, + workItems: current.workItems!.map(item => ({ ...item, title: "Changed task" })) })).status, 200); + assert.equal((await f.run()).status, 409); + assert.equal(f.inputs.length, 2); +}); + +test("legacy rejection records remain readable and retain conservative revision checks", async t => { + const f = await fixture(t); + const goal = await f.server.ctx.goalStore.get(f.workspace, f.goalId); + const rejection = goal.taskAcceptances!.at(-1)!; + assert.match(rejection.planDigest!, /^sha256:[a-f0-9]{64}$/); + assert.equal(validateGoalTaskAcceptanceRecord({ ...rejection, planDigest: "invalid" }).ok, false); + delete rejection.planDigest; + assert.equal(validateGoalTaskAcceptanceRecord(rejection).ok, true); + f.server.ctx.goalStore.get = async () => goal; + assert.equal((await prepareGoalRework(f.server.ctx, f.workspace, + { goalId: f.goalId, branchId: "report", positionId: "repo-owner" }, f.sessionId)).length, 1); + goal.updatedAt = new Date(Date.parse(goal.updatedAt) + 1).toISOString(); + await assert.rejects(prepareGoalRework(f.server.ctx, f.workspace, + { goalId: f.goalId, branchId: "report", positionId: "repo-owner" }, f.sessionId), /goal state changed/); +}); + test("changed plan and a foreign personal session cannot consume a rejected delivery", async t => { const f = await fixture(t); await assert.rejects(prepareGoalRework(f.server.ctx, f.workspace, @@ -141,6 +219,7 @@ test("oversized feedback is rejected instead of silently dropping human criteria const rejection = goal.taskAcceptances!.at(-1)!; rejection.criteriaSnapshot = [...goal.acceptanceCriteria]; rejection.note = "n".repeat(4096); + rejection.planDigest = goalPlanDigest(goal); f.server.ctx.goalStore.get = async () => goal; await assert.rejects(prepareGoalRework(f.server.ctx, f.workspace, { goalId: f.goalId, branchId: "report", positionId: "repo-owner" }, f.sessionId), /context limit/); diff --git a/docs/thread-context-and-collaboration.md b/docs/thread-context-and-collaboration.md index ea889c82..bee6aa17 100644 --- a/docs/thread-context-and-collaboration.md +++ b/docs/thread-context-and-collaboration.md @@ -43,11 +43,18 @@ request remains unchanged. This feedback is supplied even when ordinary session history is disabled; it is part of the explicitly requested task repair. Each repaired result needs a new human review before another repair. A maximum -of three repair executions is counted from durable task history after its first -rejection, including failed attempts. Restarting the control plane does not +of three personal-session repair executions is counted from durable task history +after its first rejection, including failed attempts. Group turns keep their +ordinary execution path without repair context and do not consume this ceiling. +Restarting the control plane does not reset that count. Exhaustion requires reassessing the work and creating a follow-up task. An unavailable ledger, changed plan, reassigned employee, different session, closed goal or unreviewed newer execution stops dispatch. +The saved plan fingerprint ignores health refresh clocks while detecting changes +to criteria, work items and branches. Older decisions without that fingerprint +retain the conservative record-clock check; record a fresh human verdict after +a refresh. A workspace ledger exceeding its bounded read capacity is unavailable +for repair until the operator resolves the history problem. Feedback larger than the existing context field limit must be split into smaller tasks; visible answer excerpts may be truncated and the receipt reports that limit. A failed or indeterminate repair requires operator investigation; diff --git a/packages/shared/src/goals.ts b/packages/shared/src/goals.ts index 94431166..ae0c6a1e 100644 --- a/packages/shared/src/goals.ts +++ b/packages/shared/src/goals.ts @@ -59,6 +59,8 @@ export interface GoalTaskAcceptanceRecord { goalId: string; taskId: string; planUpdatedAt: string; + /** Saved plan after this verdict; absent on records written before repair support. */ + planDigest?: string; workspacePath: string; criteriaSnapshot: string[]; source: GoalTaskAcceptanceSource; @@ -510,7 +512,7 @@ export function validateGoalTaskAcceptanceCreateRequest(raw: unknown): GoalValid } export function validateGoalTaskAcceptanceRecord(raw: unknown): GoalValidationResult { - if (!isRecord(raw) || !keysMatch(raw, ["schemaVersion", "scope", "acceptanceId", "goalId", "taskId", "planUpdatedAt", "workspacePath", "criteriaSnapshot", "source", "decision", "verdicts", "decidedBy", "decidedAt", "idempotencyKey", "requestDigest"], ["note"])) { + if (!isRecord(raw) || !keysMatch(raw, ["schemaVersion", "scope", "acceptanceId", "goalId", "taskId", "planUpdatedAt", "workspacePath", "criteriaSnapshot", "source", "decision", "verdicts", "decidedBy", "decidedAt", "idempotencyKey", "requestDigest"], ["note", "planDigest"])) { return fail("goal_unknown_field", "task acceptance record has unexpected or missing fields"); } if (raw.schemaVersion !== GOAL_TASK_ACCEPTANCE_SCHEMA_VERSION || raw.scope !== "goal-task") { @@ -526,6 +528,9 @@ export function validateGoalTaskAcceptanceRecord(raw: unknown): GoalValidationRe const time = iso8601(raw.decidedAt, "decidedAt"); if (!time.ok) return time; if (typeof raw.requestDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(raw.requestDigest)) return fail("goal_invalid", "requestDigest must be a SHA-256 digest"); + if (raw.planDigest !== undefined && (typeof raw.planDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(raw.planDigest))) { + return fail("goal_invalid", "planDigest must be a SHA-256 digest"); + } const request = validateGoalTaskAcceptanceCreateRequest({ expectedWorkspacePath: raw.workspacePath, expectedUpdatedAt: raw.planUpdatedAt, idempotencyKey: raw.idempotencyKey, source: raw.source, decision: raw.decision, verdicts: raw.verdicts, ...(raw.note !== undefined ? { note: raw.note } : {}) }); if (!request.ok) return request;