Slice 4 hardening: bounded resolver, terminal states, reconciliation
Addresses review findings before Slice 5 sandbox work. Resolver primitives: - resolveOutcome maps an attempt outcome to retry-or-terminal; removed the no-op isTerminalClassification. - WORK_STATUS_FOR_OUTCOME preserves real terminal Work status instead of collapsing everything back to ready. - Lifecycle: completed is terminal, cancelled reopens only to ready, and the invalid awaiting-design-approval -> awaiting-definition-approval reopening is gone. Execution (workExecution.ts): - finishAttempt replaces completeAttempt: auto-retries within the kit budget and settles Work at the real classification (Succeeded->completed, PermanentFailure/BudgetExhausted->failed, NeedsInput->needs-input, Blocked->blocked). No Work stays silently runnable after a failure. - startSimulatedExecution validates sliceId against the current approved Design (foreign ids rejected; defaults to the first slice) and advances slice status running -> completed/ready. - retrySimulatedExecution is now an explicit user restart with a retryable- state guard, not the automatic resolver path. - reconcileExpiredAttempts is bounded: a dead lease terminates as Blocked, resumes within budget or settles the Run/Work so nothing runs forever. - crons.ts runs the reconciler every 30s, string-referenced so it does not depend on the stale codegen. Planning (workPlanning.ts): - saveDefinition/saveDesign reject revision while a Run is executing. - submitQuestion validates and persists a durable workQuestions row instead of discarding planner questions. - Design revision invalidates active design approvals. Verification: primitives 69 tests (+4), backend 22 tests (+10) covering retry/cancel/failure/restart, slice validation, reconciliation, the revision guard, and question persistence. Root typecheck, web+agents build, and the configured Ultracite gate (53 files) all pass. package.json: the recurring check gate now covers workExecution, workPlanning, crons, resolver, and work-lifecycle.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { Schema } from "effect";
|
||||
|
||||
import type { WorkStatus } from "./work-lifecycle";
|
||||
|
||||
const Text = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
@@ -48,9 +50,6 @@ export const CodingKitV0 = Schema.Struct({
|
||||
});
|
||||
export type CodingKitV0 = typeof CodingKitV0.Type;
|
||||
|
||||
export const isTerminalClassification = (_: AttemptClassification): true =>
|
||||
true;
|
||||
|
||||
export const shouldRetry = (
|
||||
outcome: AttemptOutcome,
|
||||
attemptNumber: number,
|
||||
@@ -60,6 +59,39 @@ export const shouldRetry = (
|
||||
attemptNumber < policy.maxAttempts &&
|
||||
policy.retryableClassifications.includes(outcome.classification);
|
||||
|
||||
// Terminal Work status the resolver settles on once it will run no further
|
||||
// attempt. RetryableFailure lands here only after the retry budget is gone.
|
||||
export const WORK_STATUS_FOR_OUTCOME: Readonly<
|
||||
Record<AttemptClassification, WorkStatus>
|
||||
> = {
|
||||
Blocked: "blocked",
|
||||
BudgetExhausted: "failed",
|
||||
Cancelled: "ready",
|
||||
NeedsInput: "needs-input",
|
||||
PermanentFailure: "failed",
|
||||
RetryableFailure: "failed",
|
||||
Succeeded: "completed",
|
||||
VerificationFailed: "blocked",
|
||||
};
|
||||
|
||||
export type Resolution =
|
||||
| { readonly kind: "retry" }
|
||||
| { readonly kind: "terminal"; readonly workStatus: WorkStatus };
|
||||
|
||||
// Resolver decision after an attempt finishes: retry within policy, or settle
|
||||
// Work at the terminal status mapped from the attempt classification.
|
||||
export const resolveOutcome = (
|
||||
outcome: AttemptOutcome,
|
||||
attemptNumber: number,
|
||||
policy: RetryPolicy
|
||||
): Resolution =>
|
||||
shouldRetry(outcome, attemptNumber, policy)
|
||||
? { kind: "retry" }
|
||||
: {
|
||||
kind: "terminal",
|
||||
workStatus: WORK_STATUS_FOR_OUTCOME[outcome.classification],
|
||||
};
|
||||
|
||||
export const defaultCodingKitV0: CodingKitV0 = {
|
||||
harness: "fake",
|
||||
id: "coding-kit-v0",
|
||||
|
||||
@@ -20,14 +20,10 @@ export const WORK_TRANSITIONS: Readonly<
|
||||
Record<WorkStatus, readonly WorkStatus[]>
|
||||
> = {
|
||||
"awaiting-definition-approval": ["designing", "defining"],
|
||||
"awaiting-design-approval": [
|
||||
"ready",
|
||||
"designing",
|
||||
"awaiting-definition-approval",
|
||||
],
|
||||
"awaiting-design-approval": ["ready", "designing"],
|
||||
blocked: ["ready", "executing", "cancelled"],
|
||||
cancelled: ["ready", "proposed"],
|
||||
completed: ["proposed", "defining"],
|
||||
cancelled: ["ready"],
|
||||
completed: [],
|
||||
defining: ["awaiting-definition-approval", "proposed"],
|
||||
designing: ["awaiting-design-approval", "awaiting-definition-approval"],
|
||||
executing: [
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { FakeHarnessLive } from "./harness-runtime";
|
||||
import { defaultCodingKitV0, shouldRetry } from "./resolver";
|
||||
import { defaultCodingKitV0, resolveOutcome, shouldRetry } from "./resolver";
|
||||
import { validateDefinition } from "./work-definition";
|
||||
import { validateDesignPacket } from "./work-design";
|
||||
import { canTransitionWork } from "./work-lifecycle";
|
||||
@@ -30,6 +30,11 @@ const definition = {
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const probeOutcome = (
|
||||
classification: Parameters<typeof resolveOutcome>[0]["classification"],
|
||||
retryable = false
|
||||
) => ({ classification, retryable, summary: "probe" });
|
||||
|
||||
describe("work resolution contracts", () => {
|
||||
test("high-impact open questions block definition approval", async () => {
|
||||
await expect(
|
||||
@@ -103,3 +108,53 @@ describe("work resolution contracts", () => {
|
||||
expect(canTransitionWork("ready", "executing")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolver decisions", () => {
|
||||
const policy = defaultCodingKitV0.retryPolicy;
|
||||
|
||||
test("retries a retryable failure while the budget remains", () => {
|
||||
expect(
|
||||
resolveOutcome(probeOutcome("RetryableFailure", true), 1, policy)
|
||||
).toEqual({ kind: "retry" });
|
||||
});
|
||||
|
||||
test("settles RetryableFailure as failed once the budget is exhausted", () => {
|
||||
expect(
|
||||
resolveOutcome(probeOutcome("RetryableFailure", true), 3, policy)
|
||||
).toEqual({
|
||||
kind: "terminal",
|
||||
workStatus: "failed",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps each terminal classification to its Work status", () => {
|
||||
expect(resolveOutcome(probeOutcome("Succeeded"), 1, policy)).toEqual({
|
||||
kind: "terminal",
|
||||
workStatus: "completed",
|
||||
});
|
||||
expect(resolveOutcome(probeOutcome("PermanentFailure"), 1, policy)).toEqual(
|
||||
{
|
||||
kind: "terminal",
|
||||
workStatus: "failed",
|
||||
}
|
||||
);
|
||||
expect(resolveOutcome(probeOutcome("NeedsInput"), 1, policy)).toEqual({
|
||||
kind: "terminal",
|
||||
workStatus: "needs-input",
|
||||
});
|
||||
expect(resolveOutcome(probeOutcome("Blocked"), 1, policy)).toEqual({
|
||||
kind: "terminal",
|
||||
workStatus: "blocked",
|
||||
});
|
||||
expect(resolveOutcome(probeOutcome("Cancelled"), 1, policy)).toEqual({
|
||||
kind: "terminal",
|
||||
workStatus: "ready",
|
||||
});
|
||||
});
|
||||
|
||||
test("completed and cancelled are not silently reopened", () => {
|
||||
expect(canTransitionWork("completed", "defining")).toBe(false);
|
||||
expect(canTransitionWork("cancelled", "proposed")).toBe(false);
|
||||
expect(canTransitionWork("ready", "executing")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user