fix(engine): no-op finalize veto no longer cleared by mid-execution progressing — only accepted-completion evidence supersedes a failure park (#2263)

## What

FN-8141 follow-up 3. Tightens `deriveExecutorSignalMemory`
(packages/engine/src/overseer-noop-finalize-veto.ts) so a mid-execution
`progressing` overseer observation can no longer clear the executor
no-op-finalize veto.

## Why

The prior derivation took the **newest** executor `observe` entry and
cleared `incompleteWork` whenever it was anything but the canonical
failed reason. But the planner overseer emits a `progressing`
observation ("Task is actively executing in-progress work") the
**moment** a task re-enters execution — long before that execution
finishes. The defeating shape:

> task parks failed-incomplete → requeued → re-execution starts
(overseer observes `progressing`) → execution dies or reverts again
**without** a newer failed observation → newest observation is
`progressing` → `incompleteWork:false` → an empty no-op finalize is
**not** vetoed → the reverted branch launders into `done`.

`progressing` is not "completed green" — the veto's own contract says
the failure must be superseded by a green completion.

## Change

The executor stage in `planner-overseer.ts` emits only
`progressing`/`failed`/`stuck`/`blocked` — **no** green-completion
observation — so the timeline alone cannot distinguish progressing from
completed. Per the follow-up spec, the derivation now:

1. Scans the executor `observe` timeline for the newest
**failed-with-incomplete-work** observation.
2. Keeps `incompleteWork` TRUE unless a durable **clean-completion
task-log marker** is **strictly newer** than that failure park. Reuses
the shared `CLEAN_COMPLETION_MARKERS` set (now exported from
`@fusion/core`, single-sourced with
`evaluateCompletedPromotionFailureProvenance`) so it automatically
tracks sibling follow-up F2's removal of the promotion-output marker.
3. Fails safe on a malformed failure timestamp (stays vetoed).
4. `merger-ai.ts` threads `task.log` into the derivation.

All existing precedence rules are preserved: non-empty merges are never
vetoed; human-control deferral (user-paused / autoMerge:false) still
defers; a missing task fails open.

## Test evidence

- `pnpm --filter @fusion/engine exec vitest run
src/__tests__/overseer-noop-finalize-veto.test.ts
src/__tests__/merger-ai.test.ts` → **59 passed**. New/updated cases:
failed→progressing (no completion) ⇒ still vetoed (the regression this
fixes); progressing between two failed parks (FN-8141 timeline) ⇒
vetoed; clean-completion marker newer than failure ⇒ not vetoed; older
completion marker ⇒ still vetoed; no failure park ⇒ not vetoed; bounded
tail-scan preserved. Integration: empty lane with
failed-then-progressing timeline blocks the finalize; genuinely
re-executed green task finalizes to done.
- `pnpm --filter @fusion/core exec vitest run
src/__tests__/completed-promotion-failure-provenance.test.ts` → 9
passed.
- `pnpm --filter @fusion/engine exec tsc --noEmit` → clean. `pnpm
--filter @fusion/core exec tsc --noEmit` → clean.
- `pnpm verify:fast` → PASS (3 steps green).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved empty-merge finalization safeguards so an in-progress task
cannot incorrectly clear a previously detected incomplete-work failure.
* Finalization can now proceed when a newer clean-completion record
confirms successful completion.
* Added bounded task-history evaluation to ensure completion records are
interpreted safely and consistently.

* **Tests**
* Expanded coverage for progressing, failed, and clean-completion task
timelines.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-16 21:42:44 -07:00
committed by GitHub
parent 46866a5c5a
commit 0753476c0a
7 changed files with 250 additions and 45 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: A task actively re-executing can no longer launder an empty reverted branch into done.
category: fix
dev: FN-8141 follow-up 3. `deriveExecutorSignalMemory` (packages/engine/src/overseer-noop-finalize-veto.ts) no longer lets a mid-execution `progressing` overseer observation clear the no-op-finalize veto. A failure park is superseded only by a clean-completion task-log marker (shared `CLEAN_COMPLETION_MARKERS` exported from @fusion/core) strictly newer than it; the executor stage emits no green-completion observation. `merger-ai.ts` threads `task.log` into the derivation.

View File

@@ -71,7 +71,18 @@ const FAILURE_PARK_MARKERS = [
* - "All steps complete — implicit fn_task_done" (executor.ts ~12095/~12402 — implicit-completion * - "All steps complete — implicit fn_task_done" (executor.ts ~12095/~12402 — implicit-completion
* success when all steps are done without an explicit tool call) * success when all steps are done without an explicit tool call)
*/ */
const CLEAN_COMPLETION_MARKERS = [ /*
* FNXC:Lifecycle 2026-07-16-12:10:
* Exported so the FN-8141 follow-up overseer no-op-finalize veto
* (`deriveExecutorSignalMemory` in packages/engine/src/overseer-noop-finalize-veto.ts)
* consumes the SAME accepted-completion marker set instead of duplicating the
* strings. The executor-stage overseer timeline emits no "completed green"
* observation (only progressing/failed/stuck/blocked), so these durable task-log
* markers are the only evidence that can supersede a failure park. Importing the
* shared list means any change here (e.g. removing the stranded-completion
* promotion-output marker) is picked up by the veto automatically — no drift.
*/
export const CLEAN_COMPLETION_MARKERS = [
"Task marked done by agent", "Task marked done by agent",
"All steps complete — implicit fn_task_done", "All steps complete — implicit fn_task_done",
]; ];

View File

@@ -723,7 +723,7 @@ export {
} from "./no-op-completion-marker.js"; } from "./no-op-completion-marker.js";
export { evaluateNoCommitsNoOpFinalize } from "./no-commits-finalize-guard.js"; export { evaluateNoCommitsNoOpFinalize } from "./no-commits-finalize-guard.js";
export type { NoCommitsNoOpFinalizeEvaluation } from "./no-commits-finalize-guard.js"; export type { NoCommitsNoOpFinalizeEvaluation } from "./no-commits-finalize-guard.js";
export { evaluateCompletedPromotionFailureProvenance } from "./completed-promotion-failure-provenance.js"; export { evaluateCompletedPromotionFailureProvenance, CLEAN_COMPLETION_MARKERS } from "./completed-promotion-failure-provenance.js";
export type { CompletedPromotionFailureProvenanceEvaluation } from "./completed-promotion-failure-provenance.js"; export type { CompletedPromotionFailureProvenanceEvaluation } from "./completed-promotion-failure-provenance.js";
export { evaluateSkipBypassTaint } from "./skip-bypass-taint-guard.js"; export { evaluateSkipBypassTaint } from "./skip-bypass-taint-guard.js";
export type { SkipBypassTaintEvaluation } from "./skip-bypass-taint-guard.js"; export type { SkipBypassTaintEvaluation } from "./skip-bypass-taint-guard.js";

View File

@@ -763,14 +763,22 @@ describe("runAiMerge", () => {
expect(git(dir, "rev-parse main")).toBe(mainBefore); expect(git(dir, "rev-parse main")).toBe(mainBefore);
}); });
it("FN-8141: does NOT veto an empty no-op finalize when a later executor observation was green", async () => { /*
* FN-8141 follow-up 3 regression: a mid-execution `progressing` observation
* newer than the failure park must NOT clear the veto (the overseer emits
* `progressing` the instant a task re-enters execution, long before it
* finishes). The empty no-op finalize is still blocked to todo.
*/
it("FN-8141 follow-up 3: STILL vetoes when a later executor observation was only `progressing` (no completion)", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" }); const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
git(dir, "merge -q fusion/fn-1"); git(dir, "merge -q fusion/fn-1");
const { store, task } = makeStore(dir, { noCommitsExpected: true, steps: [{ name: "Execute", status: "done" }] }); const { store, task } = makeStore(dir, { noCommitsExpected: true, steps: [{ name: "Execute", status: "done" }] });
// Timeline newest-first: a green executor observation supersedes the failure. const mainBefore = git(dir, "rev-parse main");
// Timeline newest-first: progressing sits AFTER the failure park but is not
// "completed green" — it must not supersede the failure.
store.getRunAuditEventsAsync = vi.fn(async () => [ store.getRunAuditEventsAsync = vi.fn(async () => [
{ {
id: "ev-green", taskId: "FN-1", target: "FN-1", timestamp: "2026-07-16T23:10:00.000Z", id: "ev-progressing", taskId: "FN-1", target: "FN-1", timestamp: "2026-07-16T23:10:00.000Z",
domain: "database", mutationType: "overseer:intervention", runId: "r3", agentId: "overseer", domain: "database", mutationType: "overseer:intervention", runId: "r3", agentId: "overseer",
metadata: { stage: "executor", reason: "Task is actively executing in-progress work", action: "observe", outcome: "succeeded" }, metadata: { stage: "executor", reason: "Task is actively executing in-progress work", action: "observe", outcome: "succeeded" },
}, },
@@ -786,6 +794,48 @@ describe("runAiMerge", () => {
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"), reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
}); });
// Vetoed to todo — NOT laundered to done.
expect(result.merged).toBe(false);
expect(result.noOp).toBe(false);
expect(task.column).toBe("todo");
expect(store.moveTask).not.toHaveBeenCalledWith("FN-1", "done", expect.anything());
expect(store.logEntry).toHaveBeenCalledWith(
"FN-1",
expect.stringContaining("Finalize blocked (overseer failed-executor veto)"),
expect.stringContaining("ai-empty-merge"),
);
expect(git(dir, "rev-parse main")).toBe(mainBefore);
});
/*
* The escape hatch stays intact: a GENUINELY re-executed green task (a
* clean-completion task-log marker NEWER than the failure park) is not vetoed
* and finalizes to done.
*/
it("FN-8141 follow-up 3: does NOT veto when a clean-completion task-log marker is newer than the failure park", async () => {
const { dir } = initRepoWithBranch({ branch: "fusion/fn-1" });
git(dir, "merge -q fusion/fn-1");
const { store, task } = makeStore(dir, {
noCommitsExpected: true,
steps: [{ name: "Execute", status: "done" }],
log: [
{ action: "Executor stage parked failed with work incomplete", timestamp: "2026-07-16T22:40:00.000Z" },
{ action: "Task marked done by agent", timestamp: "2026-07-16T23:30:00.000Z" },
],
});
store.getRunAuditEventsAsync = vi.fn(async () => [
{
id: "ev-fail", taskId: "FN-1", target: "FN-1", timestamp: "2026-07-16T22:40:00.000Z",
domain: "database", mutationType: "overseer:intervention", runId: "r2", agentId: "overseer",
metadata: { stage: "executor", reason: EXECUTOR_FAILED_INCOMPLETE_REASON, action: "observe", outcome: "succeeded" },
},
]);
const result = await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: vi.fn(async () => { /* nothing to do */ }),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
expect(result.noOp).toBe(true); expect(result.noOp).toBe(true);
expect(task.column).toBe("done"); expect(task.column).toBe("done");
expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true })); expect(store.moveTask).toHaveBeenCalledWith("FN-1", "done", expect.objectContaining({ moveSource: "engine", preserveProgress: true }));

View File

@@ -33,6 +33,9 @@ function entry(overrides: Partial<PlannerInterventionEntry>): PlannerInterventio
const failedEntry = (overrides: Partial<PlannerInterventionEntry> = {}) => const failedEntry = (overrides: Partial<PlannerInterventionEntry> = {}) =>
entry({ reason: EXECUTOR_FAILED_INCOMPLETE_REASON, ...overrides }); entry({ reason: EXECUTOR_FAILED_INCOMPLETE_REASON, ...overrides });
/** A durable clean-completion task-log marker at `ts` (see @fusion/core CLEAN_COMPLETION_MARKERS). */
const completionLog = (ts: string) => [{ action: "Task marked done by agent", timestamp: ts }];
const okTask: NoOpFinalizeExecutorVetoTask = { const okTask: NoOpFinalizeExecutorVetoTask = {
userPaused: false, userPaused: false,
paused: false, paused: false,
@@ -56,17 +59,55 @@ describe("deriveExecutorSignalMemory", () => {
expect(memory).toEqual({ signal: "failed", incompleteWork: true, observedAt: Date.parse("2026-07-16T22:40:00.000Z") }); expect(memory).toEqual({ signal: "failed", incompleteWork: true, observedAt: Date.parse("2026-07-16T22:40:00.000Z") });
}); });
it("a later green executor observation supersedes an earlier failed one (no subsequent-green derivation)", () => { // THE follow-up-3 regression: a mid-execution `progressing` observation is NOT
// "completed green" and must NOT clear the failure park's veto.
it("a later `progressing` executor observation does NOT supersede an earlier failed one (still vetoed)", () => {
// Timeline is newest-first, as getPlannerInterventionTimeline returns it. // Timeline is newest-first, as getPlannerInterventionTimeline returns it.
const memory = deriveExecutorSignalMemory([ const memory = deriveExecutorSignalMemory([
entry({ id: "green", timestamp: "2026-07-16T23:10:00.000Z", reason: "Task is actively executing in-progress work" }), entry({ id: "progressing", timestamp: "2026-07-16T23:10:00.000Z", reason: "Task is actively executing in-progress work" }),
failedEntry({ id: "fail", timestamp: "2026-07-16T22:40:00.000Z" }), failedEntry({ id: "fail", timestamp: "2026-07-16T22:40:00.000Z" }),
]); ]);
expect(memory?.incompleteWork).toBe(true);
expect(memory?.signal).toBe("failed");
});
// The FN-8141 shape itself: progressing observations existed BETWEEN two failed
// parks; the newest relevant executor signal is failed → stays vetoed.
it("keeps the veto when progressing sits between two failed parks (FN-8141 timeline)", () => {
const memory = deriveExecutorSignalMemory([
failedEntry({ id: "fail-2", timestamp: "2026-07-16T23:20:00.000Z" }),
entry({ id: "progressing", timestamp: "2026-07-16T23:00:00.000Z", reason: "Task is actively executing in-progress work" }),
failedEntry({ id: "fail-1", timestamp: "2026-07-16T22:40:00.000Z" }),
]);
expect(memory?.incompleteWork).toBe(true);
});
it("a clean-completion task-log marker NEWER than the failure park supersedes it (not vetoed)", () => {
const memory = deriveExecutorSignalMemory(
[failedEntry({ id: "fail", timestamp: "2026-07-16T22:40:00.000Z" })],
completionLog("2026-07-16T23:10:00.000Z"),
);
expect(memory?.incompleteWork).toBe(false);
expect(memory?.signal).toBe("progressing");
});
it("a clean-completion task-log marker OLDER than the failure park does NOT supersede it (still vetoed)", () => {
const memory = deriveExecutorSignalMemory(
[failedEntry({ id: "fail", timestamp: "2026-07-16T22:40:00.000Z" })],
completionLog("2026-07-16T22:00:00.000Z"),
);
expect(memory?.incompleteWork).toBe(true);
});
it("returns incompleteWork:false when there is no failure park at all", () => {
const memory = deriveExecutorSignalMemory([
entry({ id: "progressing", timestamp: "2026-07-16T23:10:00.000Z", reason: "Task is actively executing in-progress work" }),
]);
expect(memory?.incompleteWork).toBe(false); expect(memory?.incompleteWork).toBe(false);
expect(memory?.signal).toBe("progressing"); expect(memory?.signal).toBe("progressing");
}); });
it("ignores non-executor stages and non-observe actions when picking the newest signal", () => { it("ignores non-executor stages and non-observe actions when locating the failure park", () => {
const memory = deriveExecutorSignalMemory([ const memory = deriveExecutorSignalMemory([
// Newest overall, but a merger observation — must be ignored. // Newest overall, but a merger observation — must be ignored.
entry({ id: "merger", stage: "merger", timestamp: "2026-07-16T23:40:00.000Z", reason: "Task is in the merge/integration phase" }), entry({ id: "merger", stage: "merger", timestamp: "2026-07-16T23:40:00.000Z", reason: "Task is in the merge/integration phase" }),
@@ -76,6 +117,23 @@ describe("deriveExecutorSignalMemory", () => {
]); ]);
expect(memory?.incompleteWork).toBe(true); expect(memory?.incompleteWork).toBe(true);
}); });
// A completion marker beyond the bounded tail window must not be scanned in —
// preserves the cheap tail-only scan contract.
it("only scans the bounded task-log tail for completion markers", () => {
const padding = Array.from({ length: 300 }, (_, i) => ({
action: "unrelated log line",
timestamp: `2026-07-16T2${(i % 3)}:00:00.000Z`,
}));
// Completion marker is at the HEAD (older than the 250-entry tail window).
const log = [{ action: "Task marked done by agent", timestamp: "2026-07-16T23:59:00.000Z" }, ...padding];
const memory = deriveExecutorSignalMemory(
[failedEntry({ id: "fail", timestamp: "2026-07-16T22:40:00.000Z" })],
log,
);
// The out-of-window completion marker is NOT seen → failure park still stands.
expect(memory?.incompleteWork).toBe(true);
});
}); });
describe("evaluateNoOpFinalizeExecutorVeto", () => { describe("evaluateNoOpFinalizeExecutorVeto", () => {

View File

@@ -1298,7 +1298,10 @@ export async function runAiMerge(
let executorMemory = null as Awaited<ReturnType<typeof deriveExecutorSignalMemory>>; let executorMemory = null as Awaited<ReturnType<typeof deriveExecutorSignalMemory>>;
try { try {
const timeline = await getPlannerInterventionTimeline(store, taskId); const timeline = await getPlannerInterventionTimeline(store, taskId);
executorMemory = deriveExecutorSignalMemory(timeline); // FNXC:Lifecycle 2026-07-16-12:10 (follow-up 3): thread the durable task log
// so a mid-execution `progressing` observation cannot clear the veto — only a
// clean-completion marker newer than the failure park supersedes it.
executorMemory = deriveExecutorSignalMemory(timeline, task.log);
} catch (err) { } catch (err) {
aiMergeLog.warn(`${taskId}: executor overseer-memory derivation failed (skipping veto): ${getErrorMessage(err)}`); aiMergeLog.warn(`${taskId}: executor overseer-memory derivation failed (skipping veto): ${getErrorMessage(err)}`);
} }

View File

@@ -22,12 +22,22 @@
* Two pure, unit-testable pieces (no I/O, never throw), following the FN-7514 * Two pure, unit-testable pieces (no I/O, never throw), following the FN-7514
* `evaluateOverseerHumanControl` precedent (pure predicate + ids/outcomes-only * `evaluateOverseerHumanControl` precedent (pure predicate + ids/outcomes-only
* audit metadata): * audit metadata):
* - `deriveExecutorSignalMemory` — reconstructs the most-recent executor * - `deriveExecutorSignalMemory` — reconstructs the executor signal from the
* signal from the durable `overseer:intervention` timeline the overseer * durable `overseer:intervention` timeline the overseer already writes (no
* already writes (no new persisted column; "the existing oversight state * new persisted column; "the existing oversight state storage the controller
* storage the controller uses"). * uses") PLUS the durable task log for completion supersession.
* - `evaluateNoOpFinalizeExecutorVeto` — the veto decision. * - `evaluateNoOpFinalizeExecutorVeto` — the veto decision.
* *
* FNXC:Lifecycle 2026-07-16-12:10 (follow-up 3):
* A mid-execution `progressing` observation must NOT clear the veto. The overseer
* emits `progressing` ("Task is actively executing in-progress work") the moment
* a task re-enters execution, long before it finishes — so failed-incomplete →
* requeue → re-execution starts → dies/reverts again (no newer failed
* observation) once left `progressing` as the newest signal and defeated the
* veto. A failure park is now superseded ONLY by a clean-completion task-log
* marker STRICTLY newer than it (the executor stage emits no green-completion
* observation), never by an in-flight progressing/stuck/blocked signal.
*
* This composes with, and is independent of, the merger-layer lineage-proof * This composes with, and is independent of, the merger-layer lineage-proof
* guard (a sibling change): both can fire, and EITHER alone must stop FN-8141. * guard (a sibling change): both can fire, and EITHER alone must stop FN-8141.
* *
@@ -41,7 +51,8 @@
* fight user-paused / autoMerge:false semantics; a human owns those tasks. * fight user-paused / autoMerge:false semantics; a human owns those tasks.
*/ */
import type { ExecutorOverseerSignalMemory, PlannerInterventionEntry, Settings, Task } from "@fusion/core"; import type { ExecutorOverseerSignalMemory, PlannerInterventionEntry, Settings, Task, TaskLogEntry } from "@fusion/core";
import { CLEAN_COMPLETION_MARKERS } from "@fusion/core";
import { EXECUTOR_FAILED_INCOMPLETE_REASON } from "./planner-overseer.js"; import { EXECUTOR_FAILED_INCOMPLETE_REASON } from "./planner-overseer.js";
import { import {
evaluateOverseerHumanControl, evaluateOverseerHumanControl,
@@ -77,50 +88,115 @@ export interface NoOpFinalizeExecutorVetoDecision {
export const NO_OP_FINALIZE_EXECUTOR_VETO_REASON = export const NO_OP_FINALIZE_EXECUTOR_VETO_REASON =
"most recent executor-stage signal was failed-with-incomplete-work and no subsequent execution completed green"; "most recent executor-stage signal was failed-with-incomplete-work and no subsequent execution completed green";
/** Minimal task-log shape the supersession check needs — narrowed for testability. */
export type ExecutorSignalMemoryLogEntry = Pick<TaskLogEntry, "action" | "timestamp">;
/** Bound the task-log tail scan; the merger calls this per empty-lane finalize. */
const MAX_LOG_SCAN = 250;
/** /**
* FNXC:Lifecycle 2026-07-16-09:40: * FNXC:Lifecycle 2026-07-16-12:10:
* Pure derivation of the most-recent executor-stage overseer signal from the * Newest (most-recent-timestamp) durable clean-completion marker in the task log,
* durable `overseer:intervention` timeline (newest-first, as * as epoch-ms, or `null` when none is present / parseable. Scans the tail only
* `getPlannerInterventionTimeline` returns it). Considers ONLY passive * (log is append-ordered) and reuses the SHARED `CLEAN_COMPLETION_MARKERS` set
* observations (`action === "observe"`) on the `executor` stage — steering/ * from `evaluateCompletedPromotionFailureProvenance` so the accepted-completion
* retry/escalate entries also carry `stage: "executor"` but their `reason` is a * vocabulary stays single-sourced (no string drift; picks up sibling edits to
* recovery message, not a signal. Returns `null` when there is no executor * that list automatically). Pure; never throws.
* observation to reason about. Never throws. */
function newestCleanCompletionMarkerMs(
taskLog: ReadonlyArray<ExecutorSignalMemoryLogEntry> | null | undefined,
): number | null {
if (!taskLog || taskLog.length === 0) {
return null;
}
const scanFloor = Math.max(0, taskLog.length - MAX_LOG_SCAN);
let newestMs: number | null = null;
for (let i = taskLog.length - 1; i >= scanFloor; i--) {
const action = taskLog[i]?.action ?? "";
if (!CLEAN_COMPLETION_MARKERS.some((marker) => action.includes(marker))) {
continue;
}
const ms = Date.parse(taskLog[i]?.timestamp ?? "");
if (Number.isFinite(ms) && (newestMs === null || ms > newestMs)) {
newestMs = ms;
}
}
return newestMs;
}
/**
* FNXC:Lifecycle 2026-07-16-12:10:
* Pure derivation of the executor-stage overseer signal memory from the durable
* `overseer:intervention` timeline PLUS the durable task log. Considers ONLY
* passive observations (`action === "observe"`) on the `executor` stage —
* steering/retry/escalate entries also carry `stage: "executor"` but their
* `reason` is a recovery message, not a signal. Returns `null` when there is no
* executor observation to reason about. Never throws.
* *
* `incompleteWork` is `true` iff the newest executor observation's reason is the * TIGHTENED (FN-8141 follow-up 3): the earlier version took the NEWEST executor
* canonical `EXECUTOR_FAILED_INCOMPLETE_REASON`; any later observation * observation and cleared `incompleteWork` whenever it was anything but the
* (progressing/stuck/blocked/...) supersedes it, which is how "no subsequent * canonical failed reason. But the overseer emits a `progressing` observation
* execution completed green" is derived. * ("Task is actively executing in-progress work") the moment a task re-enters
* execution — long before that execution finishes. So a shape of
* failed-incomplete → requeue → re-execution starts (progressing observed) →
* execution dies/reverts again with NO newer failed observation left the newest
* observation as `progressing` and DEFEATED the veto, laundering an empty no-op
* finalize to `done`. `progressing` is not "completed green".
*
* New rule — a failure park is superseded ONLY by genuine completion-family
* evidence NEWER than it, never by an in-flight `progressing`/`stuck`/`blocked`
* signal:
* 1. Find the newest executor `observe` whose reason is
* `EXECUTOR_FAILED_INCOMPLETE_REASON` (the failure park). No failure park at
* all ⇒ `incompleteWork: false`.
* 2. `incompleteWork` stays TRUE unless a clean-completion marker in the task
* log is STRICTLY NEWER than that failure park. The executor stage emits no
* "completed green" observation (planner-overseer.ts writes only
* progressing/failed/stuck/blocked for `executor`), so the durable task-log
* `CLEAN_COMPLETION_MARKERS` are the sole supersession evidence.
* 3. A malformed/unparseable failure timestamp fails SAFE (cannot prove a
* completion is newer ⇒ stays vetoed).
*/ */
export function deriveExecutorSignalMemory( export function deriveExecutorSignalMemory(
entries: ReadonlyArray<PlannerInterventionEntry> | null | undefined, entries: ReadonlyArray<PlannerInterventionEntry> | null | undefined,
taskLog?: ReadonlyArray<ExecutorSignalMemoryLogEntry> | null,
): ExecutorOverseerSignalMemory | null { ): ExecutorOverseerSignalMemory | null {
if (!entries || entries.length === 0) { let newestObs: PlannerInterventionEntry | null = null;
return null; let newestFailed: PlannerInterventionEntry | null = null;
} for (const entry of entries ?? []) {
let newest: PlannerInterventionEntry | null = null;
for (const entry of entries) {
if (!entry || entry.stage !== "executor" || entry.action !== "observe") { if (!entry || entry.stage !== "executor" || entry.action !== "observe") {
continue; continue;
} }
if (newest === null || entry.timestamp > newest.timestamp) { if (newestObs === null || entry.timestamp > newestObs.timestamp) {
newest = entry; newestObs = entry;
}
if (entry.reason === EXECUTOR_FAILED_INCOMPLETE_REASON) {
if (newestFailed === null || entry.timestamp > newestFailed.timestamp) {
newestFailed = entry;
}
} }
} }
if (!newest) { // No executor observation at all → no memory to reason about.
if (!newestObs) {
return null; return null;
} }
const incompleteWork = newest.reason === EXECUTOR_FAILED_INCOMPLETE_REASON; // No failure park in the timeline → nothing to veto; the executor never
const observedAt = Date.parse(newest.timestamp); // parked failed-with-incomplete-work.
return { if (!newestFailed) {
// The timeline does not carry the raw signal enum; map the one reason we const observedAt = Date.parse(newestObs.timestamp);
// act on back to its signal and label everything else "progressing" return { signal: "progressing", incompleteWork: false, observedAt: Number.isFinite(observedAt) ? observedAt : 0 };
// (any non-failed executor observation is, for veto purposes, "not }
// failed-with-incomplete-work").
signal: incompleteWork ? "failed" : "progressing", const failedAtMs = Date.parse(newestFailed.timestamp);
incompleteWork, const completionAtMs = newestCleanCompletionMarkerMs(taskLog);
observedAt: Number.isFinite(observedAt) ? observedAt : 0, // Supersession requires a clean completion STRICTLY newer than the failure
}; // park. A NaN failure timestamp cannot be proven older than any completion, so
// it fails safe (stays vetoed).
const superseded = completionAtMs !== null && Number.isFinite(failedAtMs) && completionAtMs > failedAtMs;
if (superseded) {
return { signal: "progressing", incompleteWork: false, observedAt: completionAtMs };
}
return { signal: "failed", incompleteWork: true, observedAt: Number.isFinite(failedAtMs) ? failedAtMs : 0 };
} }
/** /**