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 => typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; 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["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>; export const getAutonomyProject = getProjectRow; const readState = (project: Awaited>, 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>, 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>, 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 }; };