Files
projectbrain-Public/backend/src/repositories/autonomyRepository.ts
T

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 };
};