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) {