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:
7
.changeset/veto-progressing-does-not-clear.md
Normal file
7
.changeset/veto-progressing-does-not-clear.md
Normal 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.
|
||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user