Human-triggered wakes claim before queued autonomous work

Wake runs stamp sourceSenderType into their secret scope, and the claim
query orders interactive-first: a wake born from an employee or board
message means a person is waiting at the keyboard, so it outranks any
autonomous backlog; within each lane FIFO holds. Non-chat runs carry no
stamp and stay in the autonomous lane. With wake expiry this closes the
live incident: the founder's CEO DM now claims on the next tick instead
of 13th in line. Red-green tested.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-14 20:46:51 -04:00
1 parent 39aa292d09
commit aa4cfc056d
3 files changed
+120 -4

No files matched your search

+14 -2
View File
@@ -27,10 +27,16 @@ export function isAutonomousPaused(connection: DatabaseConnection): boolean {
}
/**
* Atomically claims the oldest queued task_run by transitioning it
* Atomically claims the next queued task_run by transitioning it
* `queued` -> `running` in one transaction; returns the claimed run or null.
* Runs whose provider sits in a transient-failure cooldown are left queued —
* they would fail on arrival, and waiting costs nothing.
*
* Claim order is interactive-first: a wake triggered by a human message
* (the founder DMing the CEO, a board member asking in a channel) means a
* person is waiting at the keyboard, so it outranks any amount of queued
* autonomous work; within each lane, first come first served. Non-chat runs
* carry no sourceSenderType and fall into the autonomous lane.
*/
export function claimNextQueuedRun(connection: DatabaseConnection, excludedProviderIds: string[] = []): QueuedRun | null {
return connection.transaction(() => {
@@ -38,7 +44,13 @@ export function claimNextQueuedRun(connection: DatabaseConnection, excludedProvi
? `and provider_id not in (${excludedProviderIds.map(() => "?").join(", ")})`
: "";
const row = connection.db
.prepare(`select id, org_id from task_runs where state = 'queued' ${exclusion} order by created_at asc limit 1`)
.prepare(`
select id, org_id from task_runs where state = 'queued' ${exclusion}
order by
case when json_extract(secret_scope_json, '$.sourceSenderType') in ('employee', 'board') then 0 else 1 end asc,
created_at asc
limit 1
`)
.get(...excludedProviderIds) as QueuedRun | undefined;
if (!row) return null;
const result = connection.db
+6 -2
View File
@@ -95,7 +95,7 @@ export class ChatAgentWakeService {
requiresFilesystem: false,
origin: "chat_wake",
});
const runId = this.createQueuedRun({ orgId: input.orgId, taskId, agent, workspace, messageId, threadId, now });
const runId = this.createQueuedRun({ orgId: input.orgId, taskId, agent, workspace, messageId, threadId, senderType: input.senderType, now });
this.recordPresence(agent.id, { command: "mention", args: "" }, now);
return { targetId: agent.id, taskId, runId };
}
@@ -118,7 +118,7 @@ export class ChatAgentWakeService {
requiresFilesystem: false,
origin: "chat_wake",
});
const runId = this.createQueuedRun({ orgId: input.orgId, taskId, agent, workspace, messageId, threadId, now });
const runId = this.createQueuedRun({ orgId: input.orgId, taskId, agent, workspace, messageId, threadId, senderType: input.senderType, now });
this.recordPresence(agent.id, slash, now);
return { targetId: agent.id, taskId, runId };
}
@@ -178,6 +178,7 @@ export class ChatAgentWakeService {
workspace: WorkspaceContext | null;
messageId: string;
threadId: string;
senderType: SendMessageInput["senderType"];
now: string;
}): string {
const providerId = input.agent.model_provider_id;
@@ -199,6 +200,9 @@ export class ChatAgentWakeService {
credentialAccess: "provider_runtime_only",
sourceMessageId: input.messageId,
sourceThreadId: input.threadId,
// Who spoke decides scheduling: the queue claims human-triggered wakes
// before autonomous work (see claimNextQueuedRun).
sourceSenderType: input.senderType,
});
this.db.prepare(`
insert into task_runs(