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.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-09 19:11:02 -04:00
1 parent 927bdc91f2
commit 2526633bfb
9 files changed
+149 -4

No files matched your search

+18 -1
View File
@@ -1,6 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
import type { RunTaskInput } from "../../shared/commands";
import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS } from "../../shared/domain";
import { currentProviderUsageWindow, isTransientProviderFailure } from "../../shared/provider-runtime-domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
@@ -524,6 +524,23 @@ export class TaskExecutionService {
set state = ?, provider_request_id = ?, completed_at = ?, error_message = null
where id = ? and org_id = ?
`).run(TASK_RUN_STATE.COMPLETED, providerRequestId, new Date().toISOString(), runId, orgId);
this.closeChatWakeTask(orgId, runId);
}
/**
* A chat-wake task exists only to carry its reply run — the reply is the
* whole deliverable. The run completing therefore closes the task; left
* open it would strand forever (a task with a completed run is never
* enqueued again) and pile zombie "Reply to ..." rows onto the board.
*/
private closeChatWakeTask(orgId: string, runId: string): void {
const run = this.db.prepare("select task_id, trigger from task_runs where id = ? and org_id = ?")
.get(runId, orgId) as { task_id: string; trigger: string } | undefined;
if (!run || run.trigger !== "chat_mention") return;
const task = this.db.prepare("select status from tasks where id = ? and org_id = ?")
.get(run.task_id, orgId) as { status: string } | undefined;
if (!task || (task.status !== TASK_STATUS.BACKLOG && task.status !== TASK_STATUS.IN_PROGRESS)) return;
this.organization.updateTaskStatus(orgId, run.task_id, TASK_STATUS.DONE);
}
private failRun(orgId: string, runId: string, providerRequestId: string | null, errorMessage: string): void {