diff --git a/CHANGELOG.md b/CHANGELOG.md index 83617293..c1cdb5c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ## [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. +- 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. + ### Fixed - #625: Stabilize conversation tooltips with reduced motion, keep attachment removal at the hovered card's top-right corner without covering Send, and show each personal-conversation request timestamp only once. diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index 46d2f2cc..f72a1d7c 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -107,6 +107,7 @@ const SERVER_RUNTIME_FILES = [ "dist/src/experiments/store.js", "dist/src/gemini-binary.js", "dist/src/goals/collaboration.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..783b0406 --- /dev/null +++ b/apps/server/src/goals/rework.ts @@ -0,0 +1,64 @@ +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 { goalPlanDigest, 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 || 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); + 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"); + } + 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"); + } + // 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.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 (${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"); + 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/goals/store.ts b/apps/server/src/goals/store.ts index 865fb3ba..8b0c6db3 100644 --- a/apps/server/src/goals/store.ts +++ b/apps/server/src/goals/store.ts @@ -187,6 +187,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")); @@ -640,6 +647,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/src/routes/turns.ts b/apps/server/src/routes/turns.ts index c6755d84..460bc403 100644 --- a/apps/server/src/routes/turns.ts +++ b/apps/server/src/routes/turns.ts @@ -36,6 +36,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"; import { createTaskBriefing, renderTaskBriefing, taskBriefingBinding } from "../turns/task-briefing.js"; import { projectTaskDeliveries, projectTaskExecutions } from "../goals/store.js"; import { projectTaskCollaboration } from "../goals/collaboration.js"; @@ -297,6 +298,10 @@ export async function executeTurn( throw new OrgApiError(errorCodes.turn_request_invalid, 400, "adding a project briefing requires a fresh task without retryOf"); } } + // A retry may restore its project identity from the saved briefing. + const repairContext = await prepareGoalRework(ctx, workspace.dir, body, group ? undefined : session?.sessionId); + assertTurnWorkspace(ctx, workspace); + if (repairContext.length > 0) supplementalContext = [...(supplementalContext ?? []), ...repairContext]; // The renderer's engine value is only a first-use fallback for legacy // positions. Once a sidecar exists it is authoritative, including for // session and group paths which share this executor. 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..42b76a9d --- /dev/null +++ b/apps/server/test/goal-task-rework.test.ts @@ -0,0 +1,249 @@ +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 { 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"; + +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("briefing retry cannot omit its restored task binding to bypass repair review", async t => { + const f = await fixture(t); + f.server.ctx.turnDriver = { async turnRun(request) { + f.inputs.push(request); + return { status: "indeterminate", events: [], diagnostic: "", code: "turn_timeout" }; + } }; + const started = await f.call(`/sessions/${f.sessionId}/turns`, "POST", { + input: "Produce the report", engine: "qoder", goalId: f.goalId, branchId: "report", + taskBriefing: { expectedWorkspacePath: f.workspace, expectedUpdatedAt: (await f.detail()).goal.updatedAt }, + }); + assert.equal(started.status, 200); + const failed = started.body as TurnRecord; + assert.equal(failed.status, "indeterminate"); + assert.ok(failed.taskBriefing); + const retry = await f.call(`/sessions/${f.sessionId}/turns`, "POST", { + input: failed.input, engine: "qoder", retryOf: failed.turnId, + }); + assert.equal(retry.status, 409); + assert.match(JSON.stringify(retry.body), /new human review/); + assert.equal(f.inputs.length, 2, "restored project identity must be checked before 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("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, + { 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); + 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/); + assert.equal(f.inputs.length, 1); +}); diff --git a/docs/thread-context-and-collaboration.md b/docs/thread-context-and-collaboration.md index 535b46da..5560999a 100644 --- a/docs/thread-context-and-collaboration.md +++ b/docs/thread-context-and-collaboration.md @@ -32,6 +32,48 @@ 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 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. Explicit retries first restore any saved project-task briefing +identity, then revalidate the same repair guards; omitting task IDs cannot bypass +the human-review requirement. 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. diff --git a/packages/shared/src/goals.ts b/packages/shared/src/goals.ts index aa7b767f..fc3f29ee 100644 --- a/packages/shared/src/goals.ts +++ b/packages/shared/src/goals.ts @@ -63,6 +63,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; @@ -528,7 +530,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") { @@ -544,6 +546,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;