Initial curated projectbrain source from 8a7b1e0651dbdab97d744cbd2e653ab3970ac6c5
This commit is contained in:
commit
5aa3e8d685
700 files changed
+132801
No files matched your search
@@ -0,0 +1,797 @@
|
||||
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 };
|
||||
};
|
||||
Reference in new issue
Block a user