798 lines
42 KiB
TypeScript
798 lines
42 KiB
TypeScript
import { autonomyProfileSettings, type AutonomyProfile } from "@projectbrain/contracts";
|
|
import { randomUUID } from "node:crypto";
|
|
import type { Prisma } from "@prisma/client";
|
|
import { prisma } from "../db/prisma.js";
|
|
import { autonomyRetryDelayMinutes, classifyAutonomyBlocker, recoverStoredBlockerKind } from "../services/autonomyRecovery.js";
|
|
import { addDiscoveredTasks, hasOpenAutonomyExecutionItems, isAutonomyItemInExecutionScope, orderAutonomyRepairs, workPrompt } from "../services/autonomyWork.js";
|
|
import { getFeatureDelivery } from "./featureDeliveryRepository.js";
|
|
import { createBrainstormForProject } from "./brainstormRepository.js";
|
|
import { parseRoadmapMarkdown } from "../services/autonomyRoadmap.js";
|
|
import { decideRecovery } from "../services/recoveryPolicy.js";
|
|
import { hasGiteaAutonomyCompletionEvidence, type AutonomyExecutionTarget } from "./autonomyExecutionEvidence.js";
|
|
export { parseRoadmapMarkdown };
|
|
export { autonomyRetryDelayMinutes, classifyAutonomyBlocker };
|
|
export { hasGiteaAutonomyCompletionEvidence };
|
|
export type { AutonomyExecutionTarget };
|
|
export type AutonomyStatus = "off" | "waiting_roadmap" | "running" | "blocked" | "completed";
|
|
export type AutonomyWorkKind = "brainstorm" | "implement" | "validate_phase";
|
|
export type AutonomyWorkOutcome = "success" | "failed" | "blocked";
|
|
export type RoadmapItemStatus = "pending" | "in_progress" | "completed" | "blocked";
|
|
export type RoadmapPhaseStatus = "pending" | "active" | "brainstorming" | "executing" | "validating" | "completed" | "blocked";
|
|
|
|
export type AutonomyRoadmapItem = {
|
|
id: string;
|
|
title: string;
|
|
instruction?: string;
|
|
source?: "roadmap" | "user_assignment" | "brainstorm" | "recovery" | "finding" | "delivery" | "release" | "remediation" | "feature" | "bug";
|
|
createdAt?: string;
|
|
status: RoadmapItemStatus;
|
|
attempts: number;
|
|
priority?: number;
|
|
scope?: "required" | "committed" | "stretch" | "future";
|
|
risk?: "low" | "normal" | "medium" | "high" | "critical";
|
|
estimatedScope?: number;
|
|
releaseBlocking?: boolean;
|
|
waitingExternal?: boolean;
|
|
touchedPaths?: string[];
|
|
domain?: string;
|
|
deliveryId?: string;
|
|
dependsOn?: string[];
|
|
lastResult?: string;
|
|
lastError?: string;
|
|
completionEvidence?: string[];
|
|
supersededBy?: string;
|
|
};
|
|
|
|
export type AutonomyRoadmapPhase = {
|
|
id: string;
|
|
title: string;
|
|
objective: string;
|
|
status: RoadmapPhaseStatus;
|
|
brainstormIterations: number;
|
|
brainstormConverged: boolean;
|
|
validationFailures: number;
|
|
items: AutonomyRoadmapItem[];
|
|
};
|
|
|
|
export type AutonomyRoadmap = {
|
|
id: string;
|
|
title: string;
|
|
sourceMarkdown: string;
|
|
status: "active" | "completed" | "blocked";
|
|
currentPhaseIndex: number;
|
|
phases: AutonomyRoadmapPhase[];
|
|
importedAt: string;
|
|
completedAt?: string;
|
|
};
|
|
|
|
export type AutonomyLease = {
|
|
token: string;
|
|
kind: AutonomyWorkKind;
|
|
actor: string;
|
|
runId?: string;
|
|
roadmapId: string;
|
|
phaseId: string;
|
|
itemId?: string;
|
|
createdAt: string;
|
|
expiresAt: string;
|
|
};
|
|
|
|
export type AutonomyCompletedLease = {
|
|
token: string;
|
|
outcome: AutonomyWorkOutcome;
|
|
summary: string;
|
|
addedTasks: string[];
|
|
completedAt: string;
|
|
};
|
|
|
|
export type AutonomyRun = {
|
|
id: string;
|
|
actor: string;
|
|
startedAt: string;
|
|
reportedWorkItems: number;
|
|
};
|
|
|
|
export type AutonomyEvent = {
|
|
id: string;
|
|
at: string;
|
|
type: string;
|
|
actor: string;
|
|
summary: string;
|
|
};
|
|
|
|
export type AutonomyState = {
|
|
version: 1;
|
|
profile: AutonomyProfile;
|
|
enabled: boolean;
|
|
status: AutonomyStatus;
|
|
autoBrainstorm: boolean;
|
|
maxBrainstormIterations: number;
|
|
maxConsecutiveFailures: number;
|
|
leaseMinutes: number;
|
|
maxWorkItemsPerRun: number;
|
|
maxRunMinutes: number;
|
|
pauseAfterCurrent: boolean;
|
|
schedulingPolicy: "balanced" | "downstream-impact";
|
|
roadmap?: AutonomyRoadmap;
|
|
activeLease?: AutonomyLease;
|
|
blocker?: { at: string; phaseId?: string; itemId?: string; reason: string; kind: "human" | "retryable"; retryAt?: string; recoveryStage?: "retry" | "cooldown" | "alternate_recovery" | "escalated"; recoveryAttempts?: number; recoveryStartedAt?: string };
|
|
recoveryStartedAt?: string;
|
|
recoveryAttempts?: number;
|
|
maxRecoveryMinutes?: number;
|
|
runs: AutonomyRun[];
|
|
completedLeases: AutonomyCompletedLease[];
|
|
events: AutonomyEvent[];
|
|
updatedAt: string;
|
|
updatedBy: string;
|
|
};
|
|
|
|
export type AutonomyWork = {
|
|
token: string;
|
|
kind: AutonomyWorkKind;
|
|
projectId: string;
|
|
roadmapId: string;
|
|
roadmapTitle: string;
|
|
phaseId: string;
|
|
phaseTitle: string;
|
|
phaseObjective: string;
|
|
itemId?: string;
|
|
itemTitle?: string;
|
|
deliveryId?: string;
|
|
execution?: AutonomyExecutionTarget;
|
|
prompt: string;
|
|
expiresAt: string;
|
|
};
|
|
|
|
export type AutonomyReportInput = {
|
|
token: string;
|
|
outcome: AutonomyWorkOutcome;
|
|
summary: string;
|
|
deliveryId?: string;
|
|
discoveredTasks?: string[];
|
|
brainstorm?: {
|
|
contextSummary?: string;
|
|
ideas?: Array<{ id: string; title: string; description: string; score?: number }>;
|
|
outcomes?: string[];
|
|
selectedIdeaIds?: string[];
|
|
nextActions?: string[];
|
|
};
|
|
blockerType?: "human" | "retryable";
|
|
retryAfterMinutes?: number;
|
|
};
|
|
|
|
export type EnqueueAutonomyTaskInput = { title?: string; instruction: string };
|
|
|
|
const DEFAULTS = {
|
|
profile: "normal" as const,
|
|
autoBrainstorm: true,
|
|
maxBrainstormIterations: 5,
|
|
maxConsecutiveFailures: 3,
|
|
leaseMinutes: 30,
|
|
maxWorkItemsPerRun: 8,
|
|
maxRunMinutes: 45,
|
|
maxRecoveryMinutes: 120,
|
|
} as const;
|
|
|
|
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
const asString = (value: unknown, fallback = "") => typeof value === "string" ? value : fallback;
|
|
const asBoolean = (value: unknown, fallback: boolean) => typeof value === "boolean" ? value : fallback;
|
|
const asInt = (value: unknown, fallback: number, min: number, max: number) => {
|
|
const parsed = typeof value === "number" && Number.isInteger(value) ? value : fallback;
|
|
return Math.max(min, Math.min(max, parsed));
|
|
};
|
|
const json = (value: unknown): Prisma.InputJsonValue => JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
const event = (type: string, actor: string, summary: string): AutonomyEvent => ({ id: randomUUID(), at: nowIso(), type, actor, summary });
|
|
const appendEvent = (state: AutonomyState, next: AutonomyEvent): AutonomyState => ({ ...state, events: [...state.events, next].slice(-100) });
|
|
|
|
const defaultState = (actor = "system"): AutonomyState => ({
|
|
version: 1,
|
|
enabled: false,
|
|
status: "off",
|
|
profile: DEFAULTS.profile,
|
|
autoBrainstorm: DEFAULTS.autoBrainstorm,
|
|
maxBrainstormIterations: DEFAULTS.maxBrainstormIterations,
|
|
maxConsecutiveFailures: DEFAULTS.maxConsecutiveFailures,
|
|
leaseMinutes: DEFAULTS.leaseMinutes,
|
|
maxWorkItemsPerRun: DEFAULTS.maxWorkItemsPerRun,
|
|
maxRunMinutes: DEFAULTS.maxRunMinutes,
|
|
pauseAfterCurrent: false,
|
|
schedulingPolicy: "balanced",
|
|
maxRecoveryMinutes: DEFAULTS.maxRecoveryMinutes,
|
|
recoveryAttempts: 0,
|
|
runs: [],
|
|
completedLeases: [],
|
|
events: [],
|
|
updatedAt: nowIso(),
|
|
updatedBy: actor,
|
|
});
|
|
|
|
const normalizeItem = (value: unknown): AutonomyRoadmapItem | undefined => {
|
|
const row = asRecord(value);
|
|
if (!asString(row.id) || !asString(row.title)) return undefined;
|
|
const status = ["pending", "in_progress", "completed", "blocked"].includes(asString(row.status)) ? asString(row.status) as RoadmapItemStatus : "pending";
|
|
return {
|
|
id: asString(row.id), title: asString(row.title), status, attempts: asInt(row.attempts, 0, 0, 100),
|
|
...(asString(row.instruction) ? { instruction: asString(row.instruction) } : {}),
|
|
...(["roadmap", "user_assignment", "brainstorm", "recovery", "finding", "delivery", "release", "remediation", "feature", "bug"].includes(asString(row.source)) ? { source: asString(row.source) as AutonomyRoadmapItem["source"] } : {}),
|
|
...(asString(row.createdAt) ? { createdAt: asString(row.createdAt) } : {}),
|
|
...(asString(row.deliveryId) ? { deliveryId: asString(row.deliveryId) } : {}),
|
|
...(typeof row.priority === "number" ? { priority: asInt(row.priority, 50, 0, 100) } : {}),
|
|
...(["required", "committed", "stretch", "future"].includes(asString(row.scope)) ? { scope: asString(row.scope) as AutonomyRoadmapItem["scope"] } : {}),
|
|
...(["low", "normal", "medium", "high", "critical"].includes(asString(row.risk)) ? { risk: asString(row.risk) as AutonomyRoadmapItem["risk"] } : {}),
|
|
...(typeof row.estimatedScope === "number" ? { estimatedScope: asInt(row.estimatedScope, 0, 0, 10) } : {}),
|
|
...(typeof row.releaseBlocking === "boolean" ? { releaseBlocking: row.releaseBlocking } : {}),
|
|
...(typeof row.waitingExternal === "boolean" ? { waitingExternal: row.waitingExternal } : {}),
|
|
...(Array.isArray(row.touchedPaths) ? { touchedPaths: row.touchedPaths.filter((path): path is string => typeof path === "string").slice(0, 100) } : {}),
|
|
...(asString(row.domain) ? { domain: asString(row.domain) } : {}),
|
|
...(Array.isArray(row.dependsOn) ? { dependsOn: row.dependsOn.filter((id): id is string => typeof id === "string") } : {}),
|
|
...(asString(row.lastResult) ? { lastResult: asString(row.lastResult) } : {}),
|
|
...(asString(row.lastError) ? { lastError: asString(row.lastError) } : {}),
|
|
...(Array.isArray(row.completionEvidence) ? { completionEvidence: row.completionEvidence.filter((value): value is string => typeof value === "string").slice(0, 50) } : {}),
|
|
...(asString(row.supersededBy) ? { supersededBy: asString(row.supersededBy) } : {}),
|
|
};
|
|
};
|
|
|
|
const normalizePhase = (value: unknown): AutonomyRoadmapPhase | undefined => {
|
|
const row = asRecord(value);
|
|
if (!asString(row.id) || !asString(row.title)) return undefined;
|
|
const statuses = ["pending", "active", "brainstorming", "executing", "validating", "completed", "blocked"];
|
|
return {
|
|
id: asString(row.id),
|
|
title: asString(row.title),
|
|
objective: asString(row.objective, asString(row.title)),
|
|
status: statuses.includes(asString(row.status)) ? asString(row.status) as RoadmapPhaseStatus : "pending",
|
|
brainstormIterations: asInt(row.brainstormIterations, 0, 0, 100),
|
|
brainstormConverged: asBoolean(row.brainstormConverged, false),
|
|
validationFailures: asInt(row.validationFailures, 0, 0, 100),
|
|
items: Array.isArray(row.items) ? row.items.map(normalizeItem).filter((item): item is AutonomyRoadmapItem => Boolean(item)) : [],
|
|
};
|
|
};
|
|
|
|
const normalizeCompletedLease = (value: unknown): AutonomyCompletedLease | undefined => {
|
|
const row = asRecord(value);
|
|
const outcome = asString(row.outcome);
|
|
if (!asString(row.token) || !["success", "failed", "blocked"].includes(outcome)) return undefined;
|
|
return {
|
|
token: asString(row.token),
|
|
outcome: outcome as AutonomyWorkOutcome,
|
|
summary: asString(row.summary),
|
|
addedTasks: Array.isArray(row.addedTasks) ? row.addedTasks.filter((item): item is string => typeof item === "string") : [],
|
|
completedAt: asString(row.completedAt, nowIso()),
|
|
};
|
|
};
|
|
|
|
const normalizeRun = (value: unknown): AutonomyRun | undefined => {
|
|
const row = asRecord(value);
|
|
if (!asString(row.id) || !asString(row.actor) || !asString(row.startedAt)) return undefined;
|
|
return {
|
|
id: asString(row.id),
|
|
actor: asString(row.actor),
|
|
startedAt: asString(row.startedAt),
|
|
reportedWorkItems: asInt(row.reportedWorkItems, 0, 0, 10_000),
|
|
};
|
|
};
|
|
|
|
export const normalizeAutonomyState = (raw: unknown, actor = "system"): AutonomyState => {
|
|
const row = asRecord(raw);
|
|
if (!Object.keys(row).length) return defaultState(actor);
|
|
const roadmapRow = asRecord(row.roadmap);
|
|
const phases = Array.isArray(roadmapRow.phases) ? roadmapRow.phases.map(normalizePhase).filter((phase): phase is AutonomyRoadmapPhase => Boolean(phase)) : [];
|
|
const roadmap: AutonomyRoadmap | undefined = asString(roadmapRow.id) && phases.length ? {
|
|
id: asString(roadmapRow.id), title: asString(roadmapRow.title, "Roadmap"), sourceMarkdown: asString(roadmapRow.sourceMarkdown),
|
|
status: ["active", "completed", "blocked"].includes(asString(roadmapRow.status)) ? asString(roadmapRow.status) as AutonomyRoadmap["status"] : "active",
|
|
currentPhaseIndex: asInt(roadmapRow.currentPhaseIndex, 0, 0, Math.max(0, phases.length - 1)), phases,
|
|
importedAt: asString(roadmapRow.importedAt, nowIso()), ...(asString(roadmapRow.completedAt) ? { completedAt: asString(roadmapRow.completedAt) } : {}),
|
|
} : undefined;
|
|
const leaseRow = asRecord(row.activeLease);
|
|
const leaseKind = asString(leaseRow.kind);
|
|
const activeLease: AutonomyLease | undefined = asString(leaseRow.token) && asString(leaseRow.phaseId) && ["brainstorm", "implement", "validate_phase"].includes(leaseKind) ? {
|
|
token: asString(leaseRow.token), kind: leaseKind as AutonomyWorkKind, actor: asString(leaseRow.actor),
|
|
...(asString(leaseRow.runId) ? { runId: asString(leaseRow.runId) } : {}),
|
|
roadmapId: asString(leaseRow.roadmapId), phaseId: asString(leaseRow.phaseId), ...(asString(leaseRow.itemId) ? { itemId: asString(leaseRow.itemId) } : {}),
|
|
createdAt: asString(leaseRow.createdAt), expiresAt: asString(leaseRow.expiresAt),
|
|
} : undefined;
|
|
const blockerRow = asRecord(row.blocker);
|
|
const enabled = asBoolean(row.enabled, false);
|
|
const blockerKind = recoverStoredBlockerKind(asString(blockerRow.reason), asString(blockerRow.kind));
|
|
const blockedAt = Date.parse(asString(blockerRow.at));
|
|
const recoveryStage = ["retry", "cooldown", "alternate_recovery", "escalated"].includes(asString(blockerRow.recoveryStage)) ? asString(blockerRow.recoveryStage) as NonNullable<AutonomyState["blocker"]>["recoveryStage"] : undefined;
|
|
const blocker = asString(blockerRow.reason) ? {
|
|
at: asString(blockerRow.at, nowIso()),
|
|
reason: asString(blockerRow.reason),
|
|
kind: blockerKind,
|
|
...(blockerKind === "retryable" ? { retryAt: asString(blockerRow.retryAt) || new Date((Number.isFinite(blockedAt) ? blockedAt : Date.now()) + 15 * 60_000).toISOString() } : {}),
|
|
...(asString(blockerRow.phaseId) ? { phaseId: asString(blockerRow.phaseId) } : {}),
|
|
...(asString(blockerRow.itemId) ? { itemId: asString(blockerRow.itemId) } : {}),
|
|
...(recoveryStage ? { recoveryStage } : {}),
|
|
...(typeof blockerRow.recoveryAttempts === "number" ? { recoveryAttempts: asInt(blockerRow.recoveryAttempts, 0, 0, 100) } : {}),
|
|
...(asString(blockerRow.recoveryStartedAt) ? { recoveryStartedAt: asString(blockerRow.recoveryStartedAt) } : {}),
|
|
} : undefined;
|
|
const status: AutonomyStatus = !enabled
|
|
? "off"
|
|
: !roadmap
|
|
? "waiting_roadmap"
|
|
: blocker || roadmap.status === "blocked"
|
|
? "blocked"
|
|
: roadmap.status === "completed"
|
|
? "completed"
|
|
: "running";
|
|
const leaseMatchesRoadmap = Boolean(enabled && activeLease && roadmap && activeLease.roadmapId === roadmap.id && roadmap.phases.some((phase) => phase.id === activeLease.phaseId && (!activeLease.itemId || phase.items.some((item) => item.id === activeLease.itemId))));
|
|
return {
|
|
version: 1,
|
|
enabled, status,
|
|
profile: ["conservative", "normal", "autonomous", "custom"].includes(asString(row.profile)) ? asString(row.profile) as AutonomyProfile : DEFAULTS.profile,
|
|
autoBrainstorm: asBoolean(row.autoBrainstorm, DEFAULTS.autoBrainstorm),
|
|
maxBrainstormIterations: asInt(row.maxBrainstormIterations, DEFAULTS.maxBrainstormIterations, 1, 10),
|
|
maxConsecutiveFailures: asInt(row.maxConsecutiveFailures, DEFAULTS.maxConsecutiveFailures, 1, 10),
|
|
leaseMinutes: asInt(row.leaseMinutes, DEFAULTS.leaseMinutes, 5, 120),
|
|
maxWorkItemsPerRun: asInt(row.maxWorkItemsPerRun, DEFAULTS.maxWorkItemsPerRun, 1, 20),
|
|
maxRunMinutes: asInt(row.maxRunMinutes, DEFAULTS.maxRunMinutes, 5, 55),
|
|
pauseAfterCurrent: asBoolean(row.pauseAfterCurrent, false),
|
|
schedulingPolicy: row.schedulingPolicy === "downstream-impact" ? "downstream-impact" : "balanced",
|
|
maxRecoveryMinutes: asInt(row.maxRecoveryMinutes, DEFAULTS.maxRecoveryMinutes, 15, 1_440),
|
|
recoveryAttempts: asInt(row.recoveryAttempts, 0, 0, 100),
|
|
...(roadmap ? { roadmap } : {}), ...(leaseMatchesRoadmap && activeLease ? { activeLease } : {}),
|
|
...(blocker ? { blocker } : {}),
|
|
runs: Array.isArray(row.runs) ? row.runs.map(normalizeRun).filter((entry): entry is AutonomyRun => Boolean(entry)).slice(-50) : [],
|
|
completedLeases: Array.isArray(row.completedLeases) ? row.completedLeases.map(normalizeCompletedLease).filter((entry): entry is AutonomyCompletedLease => Boolean(entry)).slice(-50) : [],
|
|
events: Array.isArray(row.events) ? row.events.map((entry) => {
|
|
const e = asRecord(entry); return { id: asString(e.id, randomUUID()), at: asString(e.at, nowIso()), type: asString(e.type, "event"), actor: asString(e.actor, "system"), summary: asString(e.summary) };
|
|
}).slice(-100) : [],
|
|
updatedAt: asString(row.updatedAt, nowIso()), updatedBy: asString(row.updatedBy, actor),
|
|
};
|
|
};
|
|
|
|
const getProjectRow = async (projectIdOrSlug: string) => {
|
|
const project = await prisma.project.findFirst({ where: { deletedAt: null, OR: [{ id: projectIdOrSlug }, { slug: projectIdOrSlug }, { readableId: projectIdOrSlug }] }, include: { workspaceState: true } });
|
|
if (!project) throw new Error("PROJECT_NOT_FOUND");
|
|
return project;
|
|
};
|
|
|
|
export type AutonomyProjectRow = Awaited<ReturnType<typeof getProjectRow>>;
|
|
export const getAutonomyProject = getProjectRow;
|
|
|
|
const readState = (project: Awaited<ReturnType<typeof getProjectRow>>, actor = "system") => {
|
|
const notes = asRecord(project.workspaceState?.notesJson);
|
|
return normalizeAutonomyState(notes.autonomy, actor);
|
|
};
|
|
export const readAutonomyState = readState;
|
|
|
|
export const releaseActiveAutonomyLease = (state: AutonomyState, actor: string) => {
|
|
if (!state.activeLease || !state.roadmap) return state;
|
|
const next = structuredClone(state) as AutonomyState;
|
|
const lease = next.activeLease!;
|
|
const phase = next.roadmap!.phases.find((entry) => entry.id === lease.phaseId);
|
|
const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined;
|
|
if (item?.status === "in_progress") item.status = "pending";
|
|
if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active";
|
|
delete next.activeLease;
|
|
return appendEvent(next, event("autonomy.lease.released", actor, `Released ${lease.kind} lease because Auto Mode was disabled.`));
|
|
};
|
|
|
|
// Update only autonomy with a monotonic CAS version; preserve unrelated notes.
|
|
const saveState = async (project: Awaited<ReturnType<typeof getProjectRow>>, state: AutonomyState, actor: string) => {
|
|
const storedAutonomy = asRecord(asRecord(project.workspaceState?.notesJson).autonomy);
|
|
const storedUpdatedAt = asString(storedAutonomy.updatedAt);
|
|
const priorTimestamp = Date.parse(storedUpdatedAt);
|
|
const savedAt = new Date(Math.max(Date.now(), Number.isFinite(priorTimestamp) ? priorTimestamp + 1 : 0));
|
|
const next: AutonomyState = { ...state, updatedAt: savedAt.toISOString(), updatedBy: actor };
|
|
const serialized = JSON.stringify(next);
|
|
|
|
if (project.workspaceState) {
|
|
const updated = storedUpdatedAt
|
|
? await prisma.$executeRaw`
|
|
UPDATE "WorkspaceState"
|
|
SET
|
|
"notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true),
|
|
"updatedBy" = ${actor},
|
|
"sourceType" = 'autonomy',
|
|
"updatedAt" = ${savedAt}
|
|
WHERE "projectId" = ${project.id}
|
|
AND "notesJson"->'autonomy'->>'updatedAt' = ${storedUpdatedAt}
|
|
`
|
|
: await prisma.$executeRaw`
|
|
UPDATE "WorkspaceState"
|
|
SET
|
|
"notesJson" = jsonb_set(COALESCE("notesJson"::jsonb, '{}'::jsonb), '{autonomy}', ${serialized}::jsonb, true),
|
|
"updatedBy" = ${actor},
|
|
"sourceType" = 'autonomy',
|
|
"updatedAt" = ${savedAt}
|
|
WHERE "projectId" = ${project.id}
|
|
AND COALESCE("notesJson"->'autonomy'->>'updatedAt', '') = ''
|
|
`;
|
|
if (updated !== 1) throw new Error("AUTO_MODE_CONFLICT");
|
|
} else {
|
|
try {
|
|
await prisma.workspaceState.create({
|
|
data: { projectId: project.id, primaryObjective: project.description, activeSection: "workspace", notesJson: json({ autonomy: next }), createdBy: actor, updatedBy: actor, sourceType: "autonomy", updatedAt: savedAt },
|
|
});
|
|
} catch (error) {
|
|
if (asString(asRecord(error).code) === "P2002") throw new Error("AUTO_MODE_CONFLICT", { cause: error });
|
|
throw error;
|
|
}
|
|
}
|
|
return next;
|
|
};
|
|
export const saveAutonomyState = saveState;
|
|
export const appendAutonomyEvent = appendEvent;
|
|
export const createAutonomyEvent = event;
|
|
|
|
const assignmentTitle = (input: EnqueueAutonomyTaskInput) => {
|
|
const explicit = input.title?.trim();
|
|
if (explicit) return explicit.slice(0, 200);
|
|
const firstLine = input.instruction.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "Nieuwe ProjectBrain-opdracht";
|
|
return firstLine.slice(0, 160);
|
|
};
|
|
|
|
export const enqueueAutonomyTaskInState = (current: AutonomyState, input: EnqueueAutonomyTaskInput, actor = "owner") => {
|
|
const next = structuredClone(current) as AutonomyState;
|
|
const createdAt = nowIso();
|
|
const task: AutonomyRoadmapItem = {
|
|
id: `assignment-${randomUUID()}`,
|
|
title: assignmentTitle(input),
|
|
instruction: input.instruction.trim(),
|
|
source: "user_assignment",
|
|
createdAt,
|
|
status: "pending",
|
|
attempts: 0,
|
|
};
|
|
const createAssignmentPhase = (): AutonomyRoadmapPhase => ({
|
|
id: `projectbrain-opdrachten-${randomUUID()}`,
|
|
title: "ProjectBrain-opdrachten",
|
|
objective: "Werk de opdrachten uit de interne ProjectBrain-takenlijst veilig, controleerbaar en in volgorde af.",
|
|
status: "active",
|
|
brainstormIterations: 0,
|
|
brainstormConverged: false,
|
|
validationFailures: 0,
|
|
items: [task],
|
|
});
|
|
|
|
if (!next.roadmap || next.roadmap.status === "completed") {
|
|
next.roadmap = {
|
|
id: `roadmap-${randomUUID()}`,
|
|
title: "ProjectBrain-opdrachten",
|
|
sourceMarkdown: `# ProjectBrain-opdrachten\n\n## Opdrachten\n- [ ] ${task.title}`,
|
|
status: "active",
|
|
currentPhaseIndex: 0,
|
|
phases: [createAssignmentPhase()],
|
|
importedAt: createdAt,
|
|
};
|
|
delete next.blocker;
|
|
delete next.activeLease;
|
|
} else {
|
|
const currentPhase = next.roadmap.phases[next.roadmap.currentPhaseIndex];
|
|
if (currentPhase && currentPhase.status !== "completed") {
|
|
currentPhase.items.unshift(task);
|
|
if (!["blocked", "executing", "brainstorming", "validating"].includes(currentPhase.status)) currentPhase.status = "active";
|
|
} else {
|
|
const phase = createAssignmentPhase();
|
|
next.roadmap.phases.splice(Math.min(next.roadmap.currentPhaseIndex + 1, next.roadmap.phases.length), 0, phase);
|
|
}
|
|
next.roadmap.sourceMarkdown = `${next.roadmap.sourceMarkdown.trim()}\n- [ ] ${task.title}`.trim();
|
|
}
|
|
|
|
if (next.enabled && next.roadmap.status !== "blocked" && !next.blocker) next.status = "running";
|
|
else if (!next.enabled) next.status = "off";
|
|
const state = appendEvent(next, event("autonomy.task.enqueued", actor, `Queued ProjectBrain assignment '${task.title}' with user priority.`));
|
|
return { state, task };
|
|
};
|
|
|
|
const expireLeaseIfNeeded = (state: AutonomyState) => {
|
|
if (!state.activeLease || new Date(state.activeLease.expiresAt).getTime() > Date.now()) return state;
|
|
const next = structuredClone(state) as AutonomyState;
|
|
const lease = next.activeLease!;
|
|
const phase = next.roadmap?.phases.find((entry) => entry.id === lease.phaseId);
|
|
const item = lease.itemId ? phase?.items.find((entry) => entry.id === lease.itemId) : undefined;
|
|
if (item?.status === "in_progress") item.status = "pending";
|
|
if (phase && !["completed", "blocked"].includes(phase.status)) phase.status = "active";
|
|
delete next.activeLease;
|
|
return appendEvent(next, event("autonomy.lease.expired", "system", `Expired ${lease.kind} lease for ${phase?.title ?? lease.phaseId}.`));
|
|
};
|
|
|
|
export const retryAutonomyBlockerIfDue = (state: AutonomyState, now = Date.now()) => {
|
|
if (!state.blocker || state.blocker.kind !== "retryable" || state.blocker.recoveryStage === "escalated" || !state.blocker.retryAt || Date.parse(state.blocker.retryAt) > now || !state.roadmap) return state;
|
|
const next = structuredClone(state) as AutonomyState;
|
|
const blocker = next.blocker!;
|
|
const recoveryStartedAt = Date.parse(blocker.recoveryStartedAt ?? next.recoveryStartedAt ?? blocker.at);
|
|
const recoveryLimit = Math.max(15, next.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes) * 60_000;
|
|
if (Number.isFinite(recoveryStartedAt) && now - recoveryStartedAt >= recoveryLimit) {
|
|
const phase = blocker.phaseId ? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId) : undefined;
|
|
if (phase) phase.status = "blocked";
|
|
next.roadmap!.status = "blocked";
|
|
next.status = "blocked";
|
|
next.blocker = {
|
|
...blocker,
|
|
recoveryStage: "escalated",
|
|
recoveryAttempts: Math.max(blocker.recoveryAttempts ?? 0, next.recoveryAttempts ?? 0),
|
|
recoveryStartedAt: new Date(recoveryStartedAt).toISOString(),
|
|
reason: `${blocker.reason} Automatic recovery exceeded its wall-clock limit and needs escalation.`,
|
|
};
|
|
delete next.blocker.retryAt;
|
|
return appendEvent(next, event("autonomy.recovery.escalated", "system", "Automatic recovery exceeded its wall-clock limit."));
|
|
}
|
|
const phase = blocker.phaseId
|
|
? next.roadmap!.phases.find((entry) => entry.id === blocker.phaseId)
|
|
: next.roadmap!.phases[next.roadmap!.currentPhaseIndex];
|
|
const item = blocker.itemId ? phase?.items.find((entry) => entry.id === blocker.itemId) : undefined;
|
|
if (item?.status === "blocked") item.status = "pending";
|
|
if (phase?.status === "blocked") phase.status = "active";
|
|
next.roadmap!.status = "active";
|
|
next.status = next.enabled ? "running" : "off";
|
|
delete next.blocker;
|
|
delete next.activeLease;
|
|
return appendEvent(next, event("autonomy.blocker.retry_ready", "system", "Retryable blocker cooldown elapsed; work is claimable again."));
|
|
};
|
|
|
|
export const evaluateAutonomyRunBudget = (state: AutonomyState, actor: string, runId: string, now = Date.now()) => {
|
|
const run = state.runs.find((entry) => entry.id === runId);
|
|
if (run && run.actor !== actor) return { allowed: false, reason: "owner_mismatch" as const, run };
|
|
if (!run) return { allowed: true, reason: undefined, run: { id: runId, actor, startedAt: new Date(now).toISOString(), reportedWorkItems: 0 } satisfies AutonomyRun };
|
|
if (run.reportedWorkItems >= state.maxWorkItemsPerRun) return { allowed: false, reason: "item_limit" as const, run };
|
|
if (Date.parse(run.startedAt) + state.maxRunMinutes * 60_000 <= now) return { allowed: false, reason: "time_limit" as const, run };
|
|
return { allowed: true, reason: undefined, run };
|
|
};
|
|
|
|
export const getAutonomyState = async (projectIdOrSlug: string) => {
|
|
const project = await getProjectRow(projectIdOrSlug);
|
|
const original = readState(project);
|
|
const state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(original));
|
|
if (original.activeLease?.token === state.activeLease?.token && original.blocker?.retryAt === state.blocker?.retryAt) return state;
|
|
return saveState(project, state, "system");
|
|
};
|
|
|
|
export const configureAutonomy = async (projectIdOrSlug: string, patch: Partial<Pick<AutonomyState, "enabled" | "profile" | "autoBrainstorm" | "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes" | "pauseAfterCurrent" | "schedulingPolicy">>, actor = "owner") => {
|
|
const project = await getProjectRow(projectIdOrSlug);
|
|
let state = retryAutonomyBlockerIfDue(expireLeaseIfNeeded(readState(project, actor)));
|
|
const requestedProfile = patch.profile && Object.hasOwn(autonomyProfileSettings, patch.profile) ? patch.profile : undefined;
|
|
const selectedProfileSettings = requestedProfile ? autonomyProfileSettings[requestedProfile] : undefined;
|
|
state = {
|
|
...state,
|
|
...(requestedProfile ? { profile: requestedProfile } : {}),
|
|
...(selectedProfileSettings ?? {}),
|
|
...(typeof patch.autoBrainstorm === "boolean" ? { autoBrainstorm: patch.autoBrainstorm } : {}),
|
|
...(typeof patch.maxBrainstormIterations === "number" ? { maxBrainstormIterations: Math.max(1, Math.min(10, Math.trunc(patch.maxBrainstormIterations))) } : {}),
|
|
...(typeof patch.maxConsecutiveFailures === "number" ? { maxConsecutiveFailures: Math.max(1, Math.min(10, Math.trunc(patch.maxConsecutiveFailures))) } : {}),
|
|
...(typeof patch.leaseMinutes === "number" ? { leaseMinutes: Math.max(5, Math.min(120, Math.trunc(patch.leaseMinutes))) } : {}),
|
|
...(typeof patch.maxWorkItemsPerRun === "number" ? { maxWorkItemsPerRun: Math.max(1, Math.min(20, Math.trunc(patch.maxWorkItemsPerRun))) } : {}),
|
|
...(typeof patch.maxRunMinutes === "number" ? { maxRunMinutes: Math.max(5, Math.min(55, Math.trunc(patch.maxRunMinutes))) } : {}),
|
|
...(typeof patch.maxRecoveryMinutes === "number" ? { maxRecoveryMinutes: Math.max(15, Math.min(1_440, Math.trunc(patch.maxRecoveryMinutes))) } : {}),
|
|
...(typeof patch.pauseAfterCurrent === "boolean" ? { pauseAfterCurrent: patch.pauseAfterCurrent } : {}),
|
|
...(patch.schedulingPolicy === "balanced" || patch.schedulingPolicy === "downstream-impact" ? { schedulingPolicy: patch.schedulingPolicy } : {}),
|
|
};
|
|
if (typeof patch.enabled === "boolean") {
|
|
if (!patch.enabled && state.activeLease && state.roadmap) state = releaseActiveAutonomyLease(state, actor);
|
|
state.enabled = patch.enabled;
|
|
if (!patch.enabled) state.status = "off";
|
|
else if (state.roadmap?.status === "completed") state.status = "completed";
|
|
else if (state.blocker || state.roadmap?.status === "blocked") state.status = "blocked";
|
|
else state.status = state.roadmap ? "running" : "waiting_roadmap";
|
|
state = appendEvent(state, event(patch.enabled ? "autonomy.enabled" : "autonomy.disabled", actor, patch.enabled ? "Auto Mode enabled." : "Auto Mode disabled."));
|
|
}
|
|
return saveState(project, state, actor);
|
|
};
|
|
|
|
export const importAutonomyRoadmap = async (projectIdOrSlug: string, input: { title?: string; markdown: string }, actor = "owner") => {
|
|
const project = await getProjectRow(projectIdOrSlug);
|
|
let state = expireLeaseIfNeeded(readState(project, actor));
|
|
if (state.roadmap?.sourceMarkdown.trim() === input.markdown.trim()) {
|
|
if (input.title?.trim() && input.title.trim() !== state.roadmap.title) {
|
|
state.roadmap.title = input.title.trim();
|
|
return saveState(project, state, actor);
|
|
}
|
|
return state;
|
|
}
|
|
if (state.activeLease) throw new Error("AUTO_MODE_WORK_ACTIVE");
|
|
const roadmap = parseRoadmapMarkdown(input.markdown, input.title);
|
|
state = { ...state, roadmap, activeLease: undefined, blocker: undefined, completedLeases: [], status: state.enabled ? "running" : "off" };
|
|
state = appendEvent(state, event("autonomy.roadmap.imported", actor, `Imported '${roadmap.title}' with ${roadmap.phases.length} phase(s) and ${roadmap.phases.reduce((count, phase) => count + phase.items.length, 0)} item(s).`));
|
|
return saveState(project, state, actor);
|
|
};
|
|
|
|
export const resumeAutonomy = async (projectIdOrSlug: string, actor = "owner") => {
|
|
const project = await getProjectRow(projectIdOrSlug);
|
|
let state = expireLeaseIfNeeded(readState(project, actor));
|
|
if (!state.roadmap || state.roadmap.status === "completed") return state;
|
|
if (state.status !== "blocked" && state.roadmap.status !== "blocked" && !state.blocker) return state;
|
|
const blockedPhase = state.blocker?.phaseId ? state.roadmap.phases.find((phase) => phase.id === state.blocker?.phaseId) : state.roadmap.phases[state.roadmap.currentPhaseIndex];
|
|
if (blockedPhase) {
|
|
if (blockedPhase.status === "blocked") blockedPhase.status = "active";
|
|
blockedPhase.validationFailures = 0;
|
|
if (state.blocker?.itemId) {
|
|
const blockedItem = blockedPhase.items.find((item) => item.id === state.blocker?.itemId);
|
|
if (blockedItem?.status === "blocked") { blockedItem.status = "pending"; delete blockedItem.lastError; }
|
|
}
|
|
}
|
|
state.roadmap.status = "active";
|
|
delete state.blocker;
|
|
delete state.activeLease;
|
|
state.recoveryAttempts = 0;
|
|
delete state.recoveryStartedAt;
|
|
state.status = state.enabled ? "running" : "off";
|
|
state = appendEvent(state, event("autonomy.resumed", actor, "Auto Mode blocker cleared; roadmap is resumable."));
|
|
return saveState(project, state, actor);
|
|
};
|
|
|
|
export const canResumeAutonomyLease = (lease: AutonomyLease, actor: string, runId?: string) => {
|
|
if (lease.actor !== actor) return false;
|
|
if (lease.runId || runId) return Boolean(lease.runId && runId && lease.runId === runId);
|
|
return true;
|
|
};
|
|
|
|
export const isAutonomyLeaseOwner = (lease: AutonomyLease, actor: string) => lease.actor === actor;
|
|
|
|
const nextLease = (state: AutonomyState, actor: string, kind: AutonomyWorkKind, phase: AutonomyRoadmapPhase, item?: AutonomyRoadmapItem, runId?: string): AutonomyLease => {
|
|
const createdAt = new Date();
|
|
return { token: randomUUID(), kind, actor, ...(runId ? { runId } : {}), roadmapId: state.roadmap!.id, phaseId: phase.id, ...(item ? { itemId: item.id } : {}), createdAt: createdAt.toISOString(), expiresAt: new Date(createdAt.getTime() + state.leaseMinutes * 60_000).toISOString() };
|
|
};
|
|
export const createAutonomyLease = nextLease;
|
|
|
|
const applyRetryableBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, failureCount: number, item?: AutonomyRoadmapItem, requestedDelay?: number) => {
|
|
const blockedAt = Date.now();
|
|
const recoveryStartedAt = state.recoveryStartedAt ?? new Date(blockedAt).toISOString();
|
|
const recoveryAttempts = (state.recoveryAttempts ?? 0) + 1;
|
|
const decision = decideRecovery({
|
|
failureCount,
|
|
recoveryAttempts,
|
|
recoveryStartedAt,
|
|
maxRecoveryMinutes: state.maxRecoveryMinutes ?? DEFAULTS.maxRecoveryMinutes,
|
|
now: blockedAt,
|
|
});
|
|
state.recoveryStartedAt = recoveryStartedAt;
|
|
state.recoveryAttempts = recoveryAttempts;
|
|
if (decision.stage === "alternate_recovery") {
|
|
if (item) { item.status = "pending"; item.lastError = `${summary} Alternate recovery is being attempted.`; }
|
|
phase.status = "active";
|
|
state.roadmap!.status = "active";
|
|
state.status = state.enabled ? "running" : "off";
|
|
delete state.blocker;
|
|
return decision;
|
|
}
|
|
if (item) { item.status = "blocked"; item.lastError = summary; }
|
|
phase.status = "blocked";
|
|
state.roadmap!.status = "blocked";
|
|
state.status = "blocked";
|
|
const retryAfterMinutes = decision.exhausted
|
|
? undefined
|
|
: Math.max(5, Math.min(1_440, requestedDelay ?? decision.retryAfterMinutes ?? autonomyRetryDelayMinutes(failureCount)));
|
|
state.blocker = {
|
|
at: new Date(blockedAt).toISOString(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary,
|
|
kind: "retryable", ...(retryAfterMinutes ? { retryAt: new Date(blockedAt + retryAfterMinutes * 60_000).toISOString() } : {}),
|
|
recoveryStage: decision.stage, recoveryAttempts, recoveryStartedAt,
|
|
};
|
|
return decision;
|
|
};
|
|
|
|
const applyHumanBlocker = (state: AutonomyState, phase: AutonomyRoadmapPhase, summary: string, item?: AutonomyRoadmapItem) => {
|
|
if (item) { item.status = "blocked"; item.lastError = summary; }
|
|
phase.status = "blocked";
|
|
state.roadmap!.status = "blocked";
|
|
state.status = "blocked";
|
|
state.blocker = { at: nowIso(), phaseId: phase.id, ...(item ? { itemId: item.id } : {}), reason: summary, kind: "human" };
|
|
};
|
|
|
|
export const reportAutonomyWork = async (projectIdOrSlug: string, input: AutonomyReportInput, actor = "chatgpt") => {
|
|
const project = await getProjectRow(projectIdOrSlug);
|
|
let state = readState(project, actor);
|
|
const replay = state.completedLeases.find((entry) => entry.token === input.token);
|
|
if (replay) return { state, addedTasks: replay.addedTasks, replayed: true, runId: undefined };
|
|
const lease = state.activeLease;
|
|
if (!lease || lease.token !== input.token) throw new Error("AUTO_MODE_LEASE_STALE");
|
|
if (!isAutonomyLeaseOwner(lease, actor)) throw new Error("AUTO_MODE_LEASE_OWNER_MISMATCH");
|
|
if (!state.roadmap || lease.roadmapId !== state.roadmap.id) throw new Error("AUTO_MODE_ROADMAP_CHANGED");
|
|
const phase = state.roadmap.phases.find((entry) => entry.id === lease.phaseId);
|
|
if (!phase) throw new Error("AUTO_MODE_PHASE_NOT_FOUND");
|
|
const item = lease.itemId ? phase.items.find((entry) => entry.id === lease.itemId) : undefined;
|
|
const deliveryId = input.deliveryId ?? item?.deliveryId;
|
|
const reconciledGiteaCompletion = item ? hasGiteaAutonomyCompletionEvidence(item.completionEvidence) : false;
|
|
if (lease.kind === "implement" && input.outcome === "success" && !deliveryId && !reconciledGiteaCompletion) throw new Error("AUTO_MODE_DELIVERY_REQUIRED");
|
|
if (deliveryId) {
|
|
const delivery = await getFeatureDelivery(project.slug, deliveryId);
|
|
if (item?.deliveryId && item.deliveryId !== delivery.id) throw new Error("AUTO_MODE_DELIVERY_ALREADY_LINKED");
|
|
if (input.outcome === "success" && lease.kind === "implement" && (!['merged', 'verified', 'released'].includes(delivery.spec.status) || !delivery.spec.pullRequest?.mergeCommitSha || !delivery.spec.validationEvidence)) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED");
|
|
if (item) item.deliveryId = delivery.id;
|
|
}
|
|
if (lease.kind === "validate_phase" && input.outcome === "success") {
|
|
for (const completed of phase.items.filter((entry) => entry.deliveryId && isAutonomyItemInExecutionScope(entry))) {
|
|
const delivery = await getFeatureDelivery(project.slug, completed.deliveryId!);
|
|
if (!["merged", "verified", "released"].includes(delivery.spec.status) || !delivery.spec.validationEvidence || !delivery.spec.pullRequest?.mergeCommitSha) throw new Error("AUTO_MODE_DELIVERY_NOT_MERGED");
|
|
}
|
|
}
|
|
const repairTasks = input.outcome === "success" ? [] : input.discoveredTasks ?? [];
|
|
const brainstormTasks = input.brainstorm?.nextActions ?? [];
|
|
const newTasks = [
|
|
...addDiscoveredTasks(phase, repairTasks, { scope: "committed", source: "recovery" }),
|
|
...addDiscoveredTasks(phase, brainstormTasks),
|
|
];
|
|
const hasRepairs = item && repairTasks.length > 0 && input.outcome !== "success" && classifyAutonomyBlocker(input.summary, input.blockerType) !== "human" && orderAutonomyRepairs(phase, item, repairTasks);
|
|
|
|
if (hasRepairs) {
|
|
item.status = "pending"; item.lastError = input.summary;
|
|
phase.status = "active"; state.status = "running"; state.roadmap.status = "active";
|
|
delete state.blocker;
|
|
} else if (input.outcome === "blocked") {
|
|
if (!item) phase.validationFailures += 1;
|
|
const failureCount = Math.max(1, item ? item.attempts : phase.validationFailures);
|
|
const blockerKind = classifyAutonomyBlocker(input.summary, input.blockerType);
|
|
if (blockerKind === "human") applyHumanBlocker(state, phase, input.summary, item);
|
|
else applyRetryableBlocker(state, phase, input.summary, failureCount, item, input.retryAfterMinutes);
|
|
} else if (lease.kind === "brainstorm") {
|
|
phase.brainstormIterations += 1;
|
|
if (input.outcome === "failed") {
|
|
phase.validationFailures += 1;
|
|
if (phase.validationFailures >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes);
|
|
else phase.status = "active";
|
|
} else if (newTasks.length && hasOpenAutonomyExecutionItems(phase)) {
|
|
phase.validationFailures = 0;
|
|
phase.brainstormConverged = false;
|
|
phase.status = "executing";
|
|
} else {
|
|
phase.validationFailures = 0;
|
|
phase.brainstormConverged = true;
|
|
phase.status = "validating";
|
|
}
|
|
await createBrainstormForProject(project.slug, {
|
|
title: `Auto brainstorm · ${phase.title} · round ${phase.brainstormIterations}`,
|
|
prompt: workPrompt("brainstorm", phase), scope: "roadmap_phase", mode: "autonomy_loop",
|
|
status: input.outcome === "failed" ? "failed" : hasOpenAutonomyExecutionItems(phase) ? "selected" : "converged", targetEntityId: phase.id, targetEntityType: "roadmap_phase", generatedBy: actor,
|
|
contextSummary: input.brainstorm?.contextSummary?.trim() || input.summary,
|
|
ideas: input.brainstorm?.ideas ?? [], clusters: [],
|
|
outcomes: (input.brainstorm?.outcomes ?? [input.summary]).map((summary, index) => ({ id: `outcome-${index + 1}`, summary })),
|
|
selectedIdeaIds: input.brainstorm?.selectedIdeaIds ?? [], nextActions: newTasks,
|
|
});
|
|
} else if (lease.kind === "implement") {
|
|
if (!item) throw new Error("AUTO_MODE_ITEM_NOT_FOUND");
|
|
if (input.outcome === "success") {
|
|
phase.validationFailures = 0;
|
|
item.status = "completed";
|
|
item.lastResult = input.summary;
|
|
delete item.lastError;
|
|
if (input.deliveryId) item.deliveryId = input.deliveryId;
|
|
phase.status = "active";
|
|
} else {
|
|
item.lastError = input.summary;
|
|
if (item.attempts >= state.maxConsecutiveFailures) applyRetryableBlocker(state, phase, input.summary, item.attempts, item, input.retryAfterMinutes);
|
|
else { item.status = "pending"; phase.status = "active"; }
|
|
}
|
|
} else {
|
|
if (input.outcome === "success" && !hasOpenAutonomyExecutionItems(phase)) {
|
|
phase.status = "completed";
|
|
phase.brainstormConverged = true;
|
|
phase.validationFailures = 0;
|
|
if (state.roadmap.currentPhaseIndex < state.roadmap.phases.length - 1) {
|
|
state.roadmap.currentPhaseIndex += 1;
|
|
const next = state.roadmap.phases[state.roadmap.currentPhaseIndex];
|
|
if (next.status !== "completed") next.status = "active";
|
|
} else {
|
|
state.roadmap.status = "completed";
|
|
state.roadmap.completedAt = nowIso();
|
|
state.status = "completed";
|
|
}
|
|
} else {
|
|
phase.validationFailures += 1;
|
|
phase.brainstormConverged = !hasOpenAutonomyExecutionItems(phase);
|
|
if (phase.validationFailures >= state.maxConsecutiveFailures && !hasOpenAutonomyExecutionItems(phase)) applyRetryableBlocker(state, phase, input.summary, phase.validationFailures, undefined, input.retryAfterMinutes);
|
|
else phase.status = hasOpenAutonomyExecutionItems(phase) ? "executing" : "active";
|
|
}
|
|
}
|
|
|
|
if (input.outcome === "success") {
|
|
state.recoveryAttempts = 0;
|
|
delete state.recoveryStartedAt;
|
|
}
|
|
delete state.activeLease;
|
|
if (lease.runId) {
|
|
const run = state.runs.find((entry) => entry.id === lease.runId);
|
|
if (run) run.reportedWorkItems += 1;
|
|
else state.runs = [...state.runs, { id: lease.runId, actor: lease.actor, startedAt: lease.createdAt, reportedWorkItems: 1 }].slice(-50);
|
|
}
|
|
state.completedLeases = [...state.completedLeases, { token: lease.token, outcome: input.outcome, summary: input.summary, addedTasks: newTasks, completedAt: nowIso() }].slice(-50);
|
|
state = appendEvent(state, event(`autonomy.work.${input.outcome}`, actor, `${lease.kind} · ${phase.title}: ${input.summary}${newTasks.length ? ` Added ${newTasks.length} new task(s).` : ""}`));
|
|
const completedRoadmap = state.roadmap;
|
|
if (completedRoadmap?.status === "completed") state = appendEvent(state, event("autonomy.roadmap.completed", actor, `Roadmap '${completedRoadmap.title}' completed.`));
|
|
state = await saveState(project, state, actor);
|
|
return { state, addedTasks: newTasks, replayed: false, runId: lease.runId };
|
|
};
|