Files
AgentCompany/tests/unit/close-stale-wake-tasks-migration.test.ts
T
ZacharyZhang-NY 2526633bfb Chat-wake tasks live and die with their reply run
Of the 202 tasks stranded in the backlog on July 9, 200 were "Reply to ..."
wake vehicles: every agent @mention creates a task plus a queued run, the
reply goes out, and nothing ever closed the task — a task with a completed
run is never enqueued again, so each one became a permanent zombie row. On
top of that, every message in an active thread minted a fresh wake even when
the target already had one queued, multiplying runs that would each read the
same conversation.

The lifecycle contract, made explicit:
- The reply run IS the wake task's deliverable. Completing a chat_mention
  run now closes its task (backlog/in_progress -> done, via the audited
  status path). Tasks an agent moved to review are left alone.
- One pending wake per (agent, thread): wakeFromMention returns the queued
  run instead of minting a duplicate — that run reads the thread's newest
  messages when it executes anyway.
- Migration 054 heals existing rows: wake tasks whose runs are all spent
  close as done; in-flight wakes keep working. This also stops Fix 1's
  retry-budget healing from resurrecting 31 stale replies on next launch.

The chat-governance loop test now completes each pending wake run between
posts, matching how the exchange actually interleaves in production.
2026-07-09 19:11:02 -04:00

57 lines
2.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, test } from "vitest";
import { closeStaleWakeTasksSchema } from "../../src/main/database/migrations/054-close-stale-wake-tasks";
function createDatabase(): DatabaseSync {
const db = new DatabaseSync(join(tmpdir(), `agentcompany-wake-migration-${randomUUID()}.db`));
db.exec(`
create table schema_migrations(version integer primary key, name text not null, applied_at text not null);
create table tasks(id text primary key, status text not null, updated_at text not null);
create table task_runs(id text primary key, task_id text not null, trigger text not null, state text not null);
insert into schema_migrations(version, name, applied_at) values (53, 'autonomous_pause', datetime('now'));
`);
return db;
}
function seedTask(db: DatabaseSync, taskId: string, status: string, runs: Array<{ trigger: string; state: string }>): void {
db.prepare("insert into tasks(id, status, updated_at) values (?, ?, datetime('now'))").run(taskId, status);
for (const run of runs) {
db.prepare("insert into task_runs(id, task_id, trigger, state) values (?, ?, ?, ?)")
.run(`run_${randomUUID()}`, taskId, run.trigger, run.state);
}
}
describe("close stale wake tasks migration", () => {
test("closes spent chat-wake tasks and leaves everything else alone", () => {
const db = createDatabase();
// Zombie: reply went out but the task was never closed.
seedTask(db, "tsk_replied", "backlog", [{ trigger: "chat_mention", state: "completed" }]);
// Zombie: every attempt failed on the provider limit; the conversation has moved on.
seedTask(db, "tsk_starved", "backlog", [
{ trigger: "chat_mention", state: "failed" },
{ trigger: "heartbeat", state: "failed" },
{ trigger: "heartbeat", state: "failed" },
]);
// In-flight wake: its queued run must keep working.
seedTask(db, "tsk_pending", "backlog", [{ trigger: "chat_mention", state: "queued" }]);
// Real work is not a wake vehicle and is never touched.
seedTask(db, "tsk_real", "backlog", [{ trigger: "heartbeat", state: "completed" }]);
db.exec(closeStaleWakeTasksSchema);
const statuses = Object.fromEntries(
(db.prepare("select id, status from tasks").all() as Array<{ id: string; status: string }>).map((row) => [row.id, row.status]),
);
expect(statuses).toEqual({
tsk_replied: "done",
tsk_starved: "done",
tsk_pending: "backlog",
tsk_real: "backlog",
});
expect(db.prepare("select version from schema_migrations order by version desc limit 1").get()).toEqual({ version: 54 });
});
});