From 3bf683cbef7e1f882bbf23b0934396ad09d06cb4 Mon Sep 17 00:00:00 2001 From: Jona Date: Fri, 18 Sep 2026 01:56:22 +0200 Subject: [PATCH] fix(dreamer): advance last_run_at from run start, not completion last_run_at is the cutoff the "changed since" gates compare against the timestamps of the input they govern (maintain-docs: compartments since the last run). advanceAfterRun wrote the run's COMPLETION time, so a message or compartment that arrived during the run was older than the new cutoff and was silently skipped on the next slot. Record the run's start instead: input that lands mid-run is newer than the cutoff and re-triggers the gate next slot (at-least-once). --- .../dreamer/task-executor.test.ts | 11 ++++++-- .../dreamer/task-scheduler.test.ts | 25 +++++++++++++++++++ .../magic-context/dreamer/task-scheduler.ts | 13 ++++++---- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/plugin/src/features/magic-context/dreamer/task-executor.test.ts b/packages/plugin/src/features/magic-context/dreamer/task-executor.test.ts index 64b9592e9..33037fd79 100644 --- a/packages/plugin/src/features/magic-context/dreamer/task-executor.test.ts +++ b/packages/plugin/src/features/magic-context/dreamer/task-executor.test.ts @@ -1241,8 +1241,15 @@ describe("createDreamTaskExecutor — map-memories disposition", () => { }); expect(promptCalls).toBe(1); - expect(getTaskScheduleState(db, project, task.task)).toMatchObject({ - lastRunAt: startedAt + MAP_BATCH_FLOOR_MS - 60_000, + const scheduleState = getTaskScheduleState(db, project, task.task); + // last_run_at records the START of the last successful run — the + // "changed since" cutoff the scheduler's gates compare against — not + // the completion moment. The banked deadline is therefore later than + // the recorded value, and the recorded value is no earlier than the + // run start (`now`, the clock the scheduler was invoked with). + expect(scheduleState?.lastRunAt).toBeGreaterThanOrEqual(startedAt); + expect(scheduleState?.lastRunAt).toBeLessThan(startedAt + MAP_BATCH_FLOOR_MS - 60_000); + expect(scheduleState).toMatchObject({ lastStatus: "completed", retryCount: 0, }); diff --git a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts index 2c302ddf5..a8be24bfd 100644 --- a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts +++ b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.test.ts @@ -324,6 +324,31 @@ describe("task-scheduler — runDueTasksForProject", () => { expect(state?.nextDueAt).toBeGreaterThan(now); }); + it("advances last_run_at from the run START on completion (not completion time)", async () => { + db = freshDb(); + seedActiveMemory(db); + const now = Date.now(); + const tasks = [cfg("verify", "0 3 * * *")]; + planDueTasks(db, PROJECT, tasks, now); + forceDue(db, "verify", now); + + // Slow executor: a long run makes the start↔finish gap visible, so the + // assertion can discriminate run-start from run-completion advancement. + const executor = async (): Promise => { + await new Promise((r) => setTimeout(r, 200)); + return { status: "completed" }; + }; + const before = Date.now(); + await runDueTasksForProject({ db, projectIdentity: PROJECT, tasks, executor, now }); + const after = Date.now(); + const state = getTaskScheduleState(db, PROJECT, "verify"); + expect(state?.lastStatus).toBe("completed"); + // last_run_at reflects the run START (~before), not the completion (~after): + // a message landing mid-run is newer than the cutoff and re-triggers next slot. + expect(state?.lastRunAt).toBeGreaterThanOrEqual(before); + expect(state?.lastRunAt).toBeLessThan(after - 150); + }); + it("skips a due task whose gate fails (no active memories) and advances it", async () => { db = freshDb(); // No memories → verify gate fails. diff --git a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts index a7bad2b05..903a44b4b 100644 --- a/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts +++ b/packages/plugin/src/features/magic-context/dreamer/task-scheduler.ts @@ -206,17 +206,18 @@ function advanceAfterRun( status: "completed" | "failed" | "skipped", error: string | null, schedulePatch?: TaskExecOutcome["schedulePatch"], + startedAt?: number, ): void { writeTaskScheduleState(db, { projectPath: projectIdentity, task: due.config.task, - // last_run_at means "last SUCCESSFUL run" — the cutoff for "changed since" - // gates (maintain-docs). A failed or skipped run did NOT process the - // work, so the cutoff must NOT advance past it (mirrors v1, where - // last_dream_at only advanced when a task succeeded). + // last_run_at = the start of the last SUCCESSFUL run — the cutoff for + // "changed since" gates. A message/compartment that landed DURING the run + // (after the start) is newer than the cutoff, so it re-triggers the gate next + // slot instead of being silently skipped. Failed/skipped runs never advance it. lastRunAt: status === "completed" - ? finishedAt + ? (startedAt ?? finishedAt) : readLastRunAt(db, projectIdentity, due.config.task), nextDueAt: nextDueAtMs(due.config.schedule, finishedAt, due.scheduledAt), schedule: due.config.schedule, @@ -369,6 +370,7 @@ async function runDomainGroup( } let outcome: TaskExecOutcome; + const startedAt = Date.now(); try { outcome = await executor(due.config, { db, @@ -391,6 +393,7 @@ async function runDomainGroup( "completed", null, outcome.schedulePatch, + startedAt, ); cb?.onRan?.(due.config.task, outcome.detail, outcome.backlog); } else if (outcome.transient) {