Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/packaging/runtime-layout.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/goals/rework.ts
Original file line number Diff line number Diff line change
@@ -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<SupplementalContext[]> {
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 }];
}
8 changes: 8 additions & 0 deletions apps/server/src/goals/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/routes/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading