Merge branch 'worktree-agent-a1383b58'
# Conflicts: # .planning/STATE.md # src/main/database.ts
This commit is contained in:
commit
63ff0f2625
8 files changed
+998
-107
No files matched your search
@@ -355,7 +355,7 @@ export class AgentApiServer {
|
||||
return;
|
||||
}
|
||||
const previousStatus = task.status;
|
||||
const success = this.db.checkoutTask(params.issueId!, agentId);
|
||||
const success = this.db.checkoutTask(params.issueId!, token.runId);
|
||||
if (!success) {
|
||||
this.json(res, 409, { error: "Task cannot be checked out — already assigned or not in valid status" });
|
||||
return;
|
||||
|
||||
+275
-40
@@ -114,6 +114,8 @@ function taskPriorityOrderSql(columnName: string) {
|
||||
end`;
|
||||
}
|
||||
|
||||
export const STALE_LOCK_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes (D-01)
|
||||
|
||||
export class AppDatabase {
|
||||
readonly profileDir: string;
|
||||
readonly dbPath: string;
|
||||
@@ -343,6 +345,31 @@ export class AppDatabase {
|
||||
create index if not exists idx_pending_wakes_agent on pending_wakes(agent_id);
|
||||
`);
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS wakeup_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
company_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
trigger_detail TEXT,
|
||||
reason TEXT,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
coalesced_count INTEGER NOT NULL DEFAULT 0,
|
||||
requested_by_actor_type TEXT,
|
||||
requested_by_actor_id TEXT,
|
||||
idempotency_key TEXT,
|
||||
run_id TEXT,
|
||||
requested_at TEXT NOT NULL,
|
||||
claimed_at TEXT,
|
||||
finished_at TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_wakeup_requests_agent ON wakeup_requests(agent_id, company_id, status);
|
||||
`);
|
||||
|
||||
this.migrateSecretsTable();
|
||||
this.ensureOptionalColumns();
|
||||
|
||||
@@ -365,7 +392,7 @@ export class AppDatabase {
|
||||
this.seedConnectors();
|
||||
this._lastInterruptedCount = this.interruptStaleRuns();
|
||||
this.purgeExpiredRunLogs();
|
||||
this.purgeExpiredPendingWakes();
|
||||
this.purgeExpiredWakeupRequests();
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -1325,6 +1352,9 @@ export class AppDatabase {
|
||||
input.runId,
|
||||
);
|
||||
this.db.prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, run.taskId);
|
||||
this.db
|
||||
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
|
||||
.run(ts, run.taskId, input.runId);
|
||||
if (typeof input.costUsd === "number" && Number.isFinite(input.costUsd)) {
|
||||
this.db
|
||||
.prepare("update tasks set cost_usd = cost_usd + ?, updated_at = ? where id = ?")
|
||||
@@ -1363,6 +1393,9 @@ export class AppDatabase {
|
||||
)
|
||||
.run(summary, ts, ts, runId);
|
||||
this.db.prepare("update tasks set active_run_id = null, updated_at = ? where id = ?").run(ts, taskId);
|
||||
this.db
|
||||
.prepare("UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?")
|
||||
.run(ts, taskId, runId);
|
||||
if (workspaceId) {
|
||||
this.db
|
||||
.prepare(
|
||||
@@ -1419,7 +1452,6 @@ export class AppDatabase {
|
||||
this.db.prepare("delete from company_skills where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from task_sessions where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from wakeup_requests where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from pending_wakes where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from cost_entries where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from activity where company_id = ?").run(companyId);
|
||||
this.db.prepare("delete from task_comments where company_id = ?").run(companyId);
|
||||
@@ -1474,8 +1506,8 @@ export class AppDatabase {
|
||||
this.db.prepare("update documents set reviewer_agent_id = null where reviewer_agent_id = ? and company_id = ?").run(id, companyId);
|
||||
// Clean up knowledge entries authored by this agent
|
||||
this.db.prepare("update knowledge_base set author_agent_id = null where author_agent_id = ? and company_id = ?").run(id, companyId);
|
||||
// Remove pending wakes for this agent
|
||||
this.db.prepare("delete from pending_wakes where agent_id = ? and company_id = ?").run(id, companyId);
|
||||
// Remove wakeup requests for this agent
|
||||
this.db.prepare("DELETE FROM wakeup_requests WHERE agent_id = ? AND company_id = ?").run(id, companyId);
|
||||
this.db.prepare("delete from agents where id = ? and company_id = ?").run(id, companyId);
|
||||
});
|
||||
}
|
||||
@@ -1809,19 +1841,54 @@ export class AppDatabase {
|
||||
).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[];
|
||||
}
|
||||
|
||||
checkoutTask(taskId: string, agentId: string): boolean {
|
||||
checkoutTask(taskId: string, runId: string): boolean {
|
||||
const ts = nowIso();
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`update tasks
|
||||
set assignee_agent_id = ?, status = 'in_progress', updated_at = ?
|
||||
where id = ?
|
||||
and status in ('todo', 'backlog')
|
||||
and (assignee_agent_id is null or assignee_agent_id = ?)`,
|
||||
`UPDATE tasks
|
||||
SET checkout_run_id = ?, execution_locked_at = ?, status = 'in_progress', updated_at = ?
|
||||
WHERE id = ?
|
||||
AND checkout_run_id IS NULL
|
||||
AND status IN ('todo', 'backlog')`,
|
||||
)
|
||||
.run(agentId, nowIso(), taskId, agentId);
|
||||
.run(runId, ts, ts, taskId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
releaseTaskLock(taskId: string, runId: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE tasks SET checkout_run_id = NULL, execution_locked_at = NULL, updated_at = ? WHERE id = ? AND checkout_run_id = ?",
|
||||
)
|
||||
.run(nowIso(), taskId, runId);
|
||||
}
|
||||
|
||||
recoverStaleLocks(): Array<{ taskId: string; companyId: string; title: string }> {
|
||||
const cutoff = new Date(Date.now() - STALE_LOCK_TIMEOUT_MS).toISOString();
|
||||
const stale = this.readAll<{ id: string; companyId: string; title: string }>(
|
||||
`SELECT id, company_id AS companyId, title
|
||||
FROM tasks
|
||||
WHERE execution_locked_at IS NOT NULL
|
||||
AND execution_locked_at < ?
|
||||
AND status = 'in_progress'`,
|
||||
cutoff,
|
||||
);
|
||||
const ts = nowIso();
|
||||
for (const task of stale) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET checkout_run_id = NULL,
|
||||
execution_locked_at = NULL,
|
||||
status = 'todo',
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(ts, task.id);
|
||||
}
|
||||
return stale.map((t) => ({ taskId: t.id, companyId: t.companyId, title: t.title }));
|
||||
}
|
||||
|
||||
listAgentTasks(agentId: string, companyId: string, statuses: string[]): TaskRecord[] {
|
||||
const placeholders = statuses.map(() => "?").join(",");
|
||||
return this.readAll<TaskRecord>(
|
||||
@@ -1887,36 +1954,172 @@ export class AppDatabase {
|
||||
).map((record) => ({ ...record, heartbeatEnabled: toBoolean(record.heartbeatEnabled) })) as AgentRecord[];
|
||||
}
|
||||
|
||||
enqueuePendingWake(companyId: string, agentId: string, trigger: string) {
|
||||
const existing = this.db
|
||||
.prepare("select id from pending_wakes where company_id = ? and agent_id = ? limit 1")
|
||||
.get(companyId, agentId) as { id: string } | undefined;
|
||||
enqueueWakeupRequest(companyId: string, agentId: string, trigger: string, detail: string): void {
|
||||
const ts = nowIso();
|
||||
if (existing) {
|
||||
this.db
|
||||
.prepare("update pending_wakes set trigger_reason = ?, created_at = ? where id = ?")
|
||||
.run(trigger, ts, existing.id);
|
||||
return;
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
"insert into pending_wakes (id, company_id, agent_id, trigger_reason, created_at) values (?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(randomUUID(), companyId, agentId, trigger, ts);
|
||||
}
|
||||
|
||||
consumePendingWakes(agentId: string): Array<{ id: string; companyId: string; trigger: string }> {
|
||||
let rows: Array<{ id: string; companyId: string; trigger: string }> = [];
|
||||
this.transaction(() => {
|
||||
rows = this.readAll<{ id: string; companyId: string; trigger: string }>(
|
||||
"select id, company_id as companyId, trigger_reason as trigger from pending_wakes where agent_id = ? order by created_at asc",
|
||||
agentId,
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
this.db.prepare("delete from pending_wakes where agent_id = ?").run(agentId);
|
||||
// D-07: Per-agent scope -- check for existing queued or deferred wake for this agent
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail, coalesced_count
|
||||
FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status IN ('queued', 'deferred')
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as
|
||||
| { id: string; trigger_detail: string | null; coalesced_count: number }
|
||||
| undefined;
|
||||
|
||||
if (existing) {
|
||||
// D-06: Coalesce -- append trigger to JSON array
|
||||
const triggers: Array<{ trigger: string; at: string; detail: string }> = existing.trigger_detail
|
||||
? JSON.parse(existing.trigger_detail)
|
||||
: [];
|
||||
triggers.push({ trigger, at: ts, detail });
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests
|
||||
SET trigger_detail = ?,
|
||||
coalesced_count = coalesced_count + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(JSON.stringify(triggers), ts, existing.id);
|
||||
} else {
|
||||
// New wakeup request
|
||||
const triggerDetail = JSON.stringify([{ trigger, at: ts, detail }]);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO wakeup_requests
|
||||
(id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
|
||||
)
|
||||
.run(randomUUID(), companyId, agentId, trigger, triggerDetail, ts, ts, ts);
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
claimWakeupRequest(agentId: string, companyId: string, runId: string): { id: string; triggerDetail: string | null } | null {
|
||||
let result: { id: string; triggerDetail: string | null } | null = null;
|
||||
this.transaction(() => {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail AS triggerDetail
|
||||
FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'queued'
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as { id: string; triggerDetail: string | null } | undefined;
|
||||
if (!row) return;
|
||||
const ts = nowIso();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'claimed', claimed_at = ?, run_id = ?, updated_at = ? WHERE id = ?`,
|
||||
)
|
||||
.run(ts, runId, ts, row.id);
|
||||
result = row;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
completeWakeupRequest(wakeupId: string): void {
|
||||
const ts = nowIso();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'completed', finished_at = ?, updated_at = ? WHERE id = ? AND status = 'claimed'`,
|
||||
)
|
||||
.run(ts, ts, wakeupId);
|
||||
}
|
||||
|
||||
deferWakeupRequest(agentId: string, companyId: string): void {
|
||||
const ts = nowIso();
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'deferred', updated_at = ? WHERE agent_id = ? AND company_id = ? AND status = 'queued'`,
|
||||
)
|
||||
.run(ts, agentId, companyId);
|
||||
}
|
||||
|
||||
promoteWakeupRequests(agentId: string, companyId: string): number {
|
||||
let promoted = 0;
|
||||
this.transaction(() => {
|
||||
const queued = this.db
|
||||
.prepare(
|
||||
`SELECT id, trigger_detail FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'queued' LIMIT 1`,
|
||||
)
|
||||
.get(agentId, companyId) as { id: string; trigger_detail: string | null } | undefined;
|
||||
const deferred = this.readAll<{ id: string; trigger_detail: string | null; coalesced_count: number }>(
|
||||
`SELECT id, trigger_detail, coalesced_count FROM wakeup_requests
|
||||
WHERE agent_id = ? AND company_id = ? AND status = 'deferred'`,
|
||||
agentId, companyId,
|
||||
);
|
||||
if (deferred.length === 0) return;
|
||||
const ts = nowIso();
|
||||
if (queued) {
|
||||
// Merge deferred triggers into existing queued wake
|
||||
const queuedTriggers: Array<{ trigger: string; at: string; detail: string }> = queued.trigger_detail ? JSON.parse(queued.trigger_detail) : [];
|
||||
let addedCount = 0;
|
||||
for (const d of deferred) {
|
||||
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
|
||||
queuedTriggers.push(...dTriggers);
|
||||
addedCount += d.coalesced_count + 1;
|
||||
}
|
||||
this.db.prepare(
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(queuedTriggers), addedCount, ts, queued.id);
|
||||
// Delete the merged deferred rows
|
||||
for (const d of deferred) {
|
||||
this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
|
||||
}
|
||||
promoted = deferred.length;
|
||||
} else {
|
||||
// No queued wake -- promote first deferred to queued, merge rest
|
||||
const [first, ...rest] = deferred;
|
||||
this.db.prepare(
|
||||
`UPDATE wakeup_requests SET status = 'queued', updated_at = ? WHERE id = ?`,
|
||||
).run(ts, first!.id);
|
||||
if (rest.length > 0) {
|
||||
const firstTriggers: Array<{ trigger: string; at: string; detail: string }> = first!.trigger_detail ? JSON.parse(first!.trigger_detail) : [];
|
||||
let addedCount = 0;
|
||||
for (const d of rest) {
|
||||
const dTriggers: Array<{ trigger: string; at: string; detail: string }> = d.trigger_detail ? JSON.parse(d.trigger_detail) : [];
|
||||
firstTriggers.push(...dTriggers);
|
||||
addedCount += d.coalesced_count + 1;
|
||||
}
|
||||
this.db.prepare(
|
||||
`UPDATE wakeup_requests SET trigger_detail = ?, coalesced_count = coalesced_count + ?, updated_at = ? WHERE id = ?`,
|
||||
).run(JSON.stringify(firstTriggers), addedCount, ts, first!.id);
|
||||
for (const d of rest) {
|
||||
this.db.prepare("DELETE FROM wakeup_requests WHERE id = ?").run(d.id);
|
||||
}
|
||||
}
|
||||
promoted = deferred.length;
|
||||
}
|
||||
});
|
||||
return promoted;
|
||||
}
|
||||
|
||||
listWakeupRequests(companyId: string, filters?: { status?: string; agentId?: string; limit?: number }): Array<{
|
||||
id: string; companyId: string; agentId: string; source: string; triggerDetail: string | null;
|
||||
status: string; coalescedCount: number; runId: string | null; requestedAt: string;
|
||||
claimedAt: string | null; finishedAt: string | null; createdAt: string; updatedAt: string;
|
||||
}> {
|
||||
const conditions = ["company_id = ?"];
|
||||
const params: SQLInputValue[] = [companyId];
|
||||
if (filters?.status) { conditions.push("status = ?"); params.push(filters.status); }
|
||||
if (filters?.agentId) { conditions.push("agent_id = ?"); params.push(filters.agentId); }
|
||||
const limit = filters?.limit ?? 50;
|
||||
return this.readAll(
|
||||
`SELECT id, company_id AS companyId, agent_id AS agentId, source, trigger_detail AS triggerDetail,
|
||||
status, coalesced_count AS coalescedCount, run_id AS runId, requested_at AS requestedAt,
|
||||
claimed_at AS claimedAt, finished_at AS finishedAt, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM wakeup_requests
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT ?`,
|
||||
...params, limit,
|
||||
);
|
||||
}
|
||||
|
||||
exportBackup(targetDir: string) {
|
||||
@@ -2042,10 +2245,10 @@ export class AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
purgeExpiredPendingWakes() {
|
||||
// Remove pending wakes older than 2 hours — if agent hasn't woken by then, the wake is stale
|
||||
purgeExpiredWakeupRequests() {
|
||||
// Remove completed wakeup requests older than 2 hours -- only clean completed, not active ones
|
||||
const cutoff = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
|
||||
const result = this.db.prepare("delete from pending_wakes where created_at < ?").run(cutoff);
|
||||
const result = this.db.prepare("DELETE FROM wakeup_requests WHERE created_at < ? AND status IN ('completed')").run(cutoff);
|
||||
return Number(result.changes);
|
||||
}
|
||||
|
||||
@@ -2347,6 +2550,8 @@ export class AppDatabase {
|
||||
addColumnIfMissing("alter table tasks add column requires_user_review integer not null default 0");
|
||||
addColumnIfMissing("alter table tasks add column retry_count integer not null default 0");
|
||||
addColumnIfMissing("alter table tasks add column last_failure_at text");
|
||||
addColumnIfMissing("alter table tasks add column checkout_run_id text");
|
||||
addColumnIfMissing("alter table tasks add column execution_locked_at text");
|
||||
this.db.exec(`
|
||||
create table if not exists task_comments (
|
||||
id text primary key,
|
||||
@@ -3555,6 +3760,36 @@ export class AppDatabase {
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_skill_assignments_skill ON agent_skill_assignments(skill_id)
|
||||
`,
|
||||
},
|
||||
{
|
||||
version: 17,
|
||||
name: "migrate_pending_wakes_to_wakeup_requests",
|
||||
fn: () => {
|
||||
// D-08: Copy existing pending_wakes rows into wakeup_requests with 'queued' status
|
||||
const existingWakes = this.readAll<{
|
||||
id: string;
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
triggerReason: string;
|
||||
createdAt: string;
|
||||
}>(
|
||||
`SELECT id, company_id AS companyId, agent_id AS agentId, trigger_reason AS triggerReason, created_at AS createdAt
|
||||
FROM pending_wakes`,
|
||||
);
|
||||
const ts = nowIso();
|
||||
for (const wake of existingWakes) {
|
||||
const triggerDetail = JSON.stringify([{ trigger: wake.triggerReason, at: wake.createdAt, detail: wake.triggerReason }]);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO wakeup_requests
|
||||
(id, company_id, agent_id, source, trigger_detail, status, coalesced_count, requested_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
|
||||
)
|
||||
.run(wake.id, wake.companyId, wake.agentId, wake.triggerReason, triggerDetail, wake.createdAt, ts, ts);
|
||||
}
|
||||
// Drop the old table
|
||||
this.db.exec("DROP TABLE IF EXISTS pending_wakes");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const migration of migrations) {
|
||||
|
||||
@@ -485,7 +485,7 @@ export function createOrchestrationService({
|
||||
const agentBusy = hasActiveRun({ agentId, companyId });
|
||||
const runId = startAgentHeartbeatRun(agentId, companyId, trigger);
|
||||
if (!runId && agentBusy) {
|
||||
db.enqueuePendingWake(companyId, agentId, trigger);
|
||||
db.enqueueWakeupRequest(companyId, agentId, trigger, trigger);
|
||||
}
|
||||
return runId;
|
||||
}
|
||||
@@ -701,9 +701,9 @@ export function createOrchestrationService({
|
||||
logger.info(`[heartbeat] Released ${staleReleased} stale workspace lock(s)`);
|
||||
publishDomainChanged();
|
||||
}
|
||||
const stalePendingWakes = db.purgeExpiredPendingWakes();
|
||||
if (stalePendingWakes > 0) {
|
||||
logger.info(`[heartbeat] Purged ${stalePendingWakes} stale pending wake(s)`);
|
||||
const staleWakeups = db.purgeExpiredWakeupRequests();
|
||||
if (staleWakeups > 0) {
|
||||
logger.info(`[heartbeat] Purged ${staleWakeups} expired wakeup request(s)`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[heartbeat] Stale lock cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
@@ -145,9 +145,14 @@ export function createWorkerService({
|
||||
}
|
||||
|
||||
for (const agentId of affectedAgentIds) {
|
||||
const pendingWakes = db.consumePendingWakes(agentId);
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(agentId, wake.companyId, wake.trigger);
|
||||
// Promote any deferred wakes back to queued, then process queued wakes
|
||||
const agentRuns = snapshot.runs.filter((r) => r.agentId === agentId);
|
||||
for (const companyId of new Set(agentRuns.map((r) => r.companyId))) {
|
||||
db.promoteWakeupRequests(agentId, companyId);
|
||||
const wakes = db.listWakeupRequests(companyId, { agentId, status: "queued" });
|
||||
for (const wake of wakes) {
|
||||
wakeAgentIfPossible(agentId, wake.companyId, wake.source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,10 +386,12 @@ export function createWorkerService({
|
||||
}
|
||||
}
|
||||
|
||||
const pendingWakes = db.consumePendingWakes(run.agentId);
|
||||
if (pendingWakes.length > 0 && !isQuitting() && !destroyed) {
|
||||
for (const wake of pendingWakes) {
|
||||
wakeAgentIfPossible(run.agentId, wake.companyId, wake.trigger);
|
||||
// Promote any deferred wakes back to queued after run completes
|
||||
db.promoteWakeupRequests(run.agentId, run.companyId);
|
||||
const queuedWakes = db.listWakeupRequests(run.companyId, { agentId: run.agentId, status: "queued" });
|
||||
if (queuedWakes.length > 0 && !isQuitting() && !destroyed) {
|
||||
for (const wake of queuedWakes) {
|
||||
wakeAgentIfPossible(run.agentId, wake.companyId, wake.source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +411,7 @@ export function createWorkerService({
|
||||
}
|
||||
|
||||
// Self-wake with circuit breaker to prevent infinite loops
|
||||
if (pendingWakes.length === 0 && !isQuitting() && !destroyed) {
|
||||
if (queuedWakes.length === 0 && !isQuitting() && !destroyed) {
|
||||
try {
|
||||
const actionableTasks = db.listAgentTasks(run.agentId, run.companyId, ["todo", "in_progress"]);
|
||||
if (actionableTasks.length > 0) {
|
||||
|
||||
@@ -315,47 +315,48 @@ describe("Step 2: CEO phased prompts", () => {
|
||||
|
||||
// ─── Step 3: Deferred Wake Queue ────────────────────────────────────
|
||||
|
||||
describe("Step 3: Deferred wake queue (database)", () => {
|
||||
it("enqueue and consume pending wakes", () => {
|
||||
describe("Step 3: Wakeup request lifecycle (database)", () => {
|
||||
it("enqueue and list wakeup requests with coalescing", () => {
|
||||
const agentId = createAgent({ name: "TestAgent" });
|
||||
|
||||
// Enqueue two wakes for the same busy agent. The queue deduplicates by agent
|
||||
// so only the freshest trigger is retained.
|
||||
db.enqueuePendingWake(companyId, agentId, "subtask_completed");
|
||||
db.enqueuePendingWake(companyId, agentId, "comment");
|
||||
// Enqueue two wakes for the same busy agent. Coalesced into one row
|
||||
// with trigger_detail JSON array containing both triggers.
|
||||
db.enqueueWakeupRequest(companyId, agentId, "subtask_completed", "subtask_completed");
|
||||
db.enqueueWakeupRequest(companyId, agentId, "comment", "comment");
|
||||
|
||||
// Consume returns the latest retained wake
|
||||
const wakes = db.consumePendingWakes(agentId);
|
||||
// List returns the coalesced wake
|
||||
const wakes = db.listWakeupRequests(companyId, { agentId, status: "queued" });
|
||||
expect(wakes).toHaveLength(1);
|
||||
expect(wakes[0].trigger).toBe("comment");
|
||||
expect(wakes[0].companyId).toBe(companyId);
|
||||
expect(wakes[0].coalescedCount).toBe(1);
|
||||
|
||||
// Second consume returns empty (they've been deleted)
|
||||
const wakes2 = db.consumePendingWakes(agentId);
|
||||
expect(wakes2).toHaveLength(0);
|
||||
const triggers = JSON.parse(wakes[0].triggerDetail!);
|
||||
expect(triggers).toHaveLength(2);
|
||||
expect(triggers[0].trigger).toBe("subtask_completed");
|
||||
expect(triggers[1].trigger).toBe("comment");
|
||||
});
|
||||
|
||||
it("returns empty array when no pending wakes exist", () => {
|
||||
it("returns empty array when no wakeup requests exist", () => {
|
||||
const agentId = createAgent({ name: "TestAgent" });
|
||||
const wakes = db.consumePendingWakes(agentId);
|
||||
const wakes = db.listWakeupRequests(companyId, { agentId, status: "queued" });
|
||||
expect(wakes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not consume wakes for other agents", () => {
|
||||
it("does not return wakes for other agents", () => {
|
||||
const agent1 = createAgent({ name: "Agent1" });
|
||||
const agent2 = createAgent({ name: "Agent2" });
|
||||
|
||||
db.enqueuePendingWake(companyId, agent1, "comment");
|
||||
db.enqueuePendingWake(companyId, agent2, "subtask_completed");
|
||||
db.enqueueWakeupRequest(companyId, agent1, "comment", "comment");
|
||||
db.enqueueWakeupRequest(companyId, agent2, "subtask_completed", "subtask_completed");
|
||||
|
||||
const wakes1 = db.consumePendingWakes(agent1);
|
||||
const wakes1 = db.listWakeupRequests(companyId, { agentId: agent1, status: "queued" });
|
||||
expect(wakes1).toHaveLength(1);
|
||||
expect(wakes1[0].trigger).toBe("comment");
|
||||
expect(wakes1[0].source).toBe("comment");
|
||||
|
||||
// Agent2's wake should still be there
|
||||
const wakes2 = db.consumePendingWakes(agent2);
|
||||
const wakes2 = db.listWakeupRequests(companyId, { agentId: agent2, status: "queued" });
|
||||
expect(wakes2).toHaveLength(1);
|
||||
expect(wakes2[0].trigger).toBe("subtask_completed");
|
||||
expect(wakes2[0].source).toBe("subtask_completed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -874,11 +875,11 @@ describe("E2E: Full autonomous CEO cold start flow", () => {
|
||||
const subtask = db.getTask(subtaskId);
|
||||
expect(subtask.parentId).toBe(parentTaskId);
|
||||
|
||||
// 9. Deferred wake test: enqueue wake while IC is busy
|
||||
db.enqueuePendingWake(companyId, icId, "comment");
|
||||
const pendingWakes = db.consumePendingWakes(icId);
|
||||
expect(pendingWakes).toHaveLength(1);
|
||||
expect(pendingWakes[0].trigger).toBe("comment");
|
||||
// 9. Wakeup request test: enqueue wake while IC is busy
|
||||
db.enqueueWakeupRequest(companyId, icId, "comment", "comment");
|
||||
const queuedWakes = db.listWakeupRequests(companyId, { agentId: icId, status: "queued" });
|
||||
expect(queuedWakes).toHaveLength(1);
|
||||
expect(queuedWakes[0].source).toBe("comment");
|
||||
|
||||
// 10. Verify IC prompt has correct wake reason
|
||||
const icPromptCtx = buildCtx({
|
||||
@@ -896,23 +897,34 @@ describe("E2E: Full autonomous CEO cold start flow", () => {
|
||||
|
||||
// ─── E2E: Deferred Wake + Run Lifecycle ─────────────────────────────
|
||||
|
||||
describe("E2E: Deferred wake queue with run lifecycle", () => {
|
||||
it("wake enqueue + run finish + consume cycle", () => {
|
||||
describe("E2E: Wakeup request lifecycle with run lifecycle", () => {
|
||||
it("wake enqueue + coalesce + claim + complete cycle", () => {
|
||||
const agentId = createAgent({ name: "Agent" });
|
||||
|
||||
// Simulate: agent is running, wakes come in. Only the freshest reason is
|
||||
// retained so the agent gets one consolidated follow-up wake.
|
||||
db.enqueuePendingWake(companyId, agentId, "subtask_completed");
|
||||
db.enqueuePendingWake(companyId, agentId, "comment");
|
||||
db.enqueuePendingWake(companyId, agentId, "report_blocked");
|
||||
// Simulate: agent is running, wakes come in. All triggers are coalesced
|
||||
// into a single wakeup request with JSON array of trigger details.
|
||||
db.enqueueWakeupRequest(companyId, agentId, "subtask_completed", "subtask_completed");
|
||||
db.enqueueWakeupRequest(companyId, agentId, "comment", "comment");
|
||||
db.enqueueWakeupRequest(companyId, agentId, "report_blocked", "report_blocked");
|
||||
|
||||
// Agent finishes run → consume pending wakes
|
||||
const wakes = db.consumePendingWakes(agentId);
|
||||
// Verify coalesced into single row with all triggers
|
||||
const wakes = db.listWakeupRequests(companyId, { agentId, status: "queued" });
|
||||
expect(wakes).toHaveLength(1);
|
||||
expect(wakes[0].trigger).toBe("report_blocked");
|
||||
expect(wakes[0].coalescedCount).toBe(2);
|
||||
|
||||
// All consumed
|
||||
expect(db.consumePendingWakes(agentId)).toHaveLength(0);
|
||||
const triggers = JSON.parse(wakes[0].triggerDetail!);
|
||||
expect(triggers).toHaveLength(3);
|
||||
expect(triggers[0].trigger).toBe("subtask_completed");
|
||||
expect(triggers[1].trigger).toBe("comment");
|
||||
expect(triggers[2].trigger).toBe("report_blocked");
|
||||
|
||||
// Agent finishes run -> claim and complete the wakeup request
|
||||
const claimed = db.claimWakeupRequest(agentId, companyId, "test-run-id");
|
||||
expect(claimed).not.toBeNull();
|
||||
db.completeWakeupRequest(claimed!.id);
|
||||
|
||||
// All completed
|
||||
expect(db.listWakeupRequests(companyId, { agentId, status: "queued" })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase, STALE_LOCK_TIMEOUT_MS } from "../../src/main/database";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestCompany(db: AppDatabase): string {
|
||||
return db.saveCompany({
|
||||
name: "Test Company",
|
||||
description: "Atomic checkout tests",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "Test connector",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestWorkspace(db: AppDatabase, companyId: string): string {
|
||||
return db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Test Workspace",
|
||||
localPath: "/tmp/test-workspace",
|
||||
repoUrl: "https://github.com/example/test",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string, name = "Agent"): string {
|
||||
return db.saveAgent({
|
||||
companyId,
|
||||
name,
|
||||
role: "IC",
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestTask(db: AppDatabase, companyId: string, agentId: string | null, workspaceId: string): string {
|
||||
return db.saveTask({
|
||||
companyId,
|
||||
title: "Test Task",
|
||||
description: "A test task for checkout",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CAS checkout", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-checkout-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("CAS checkout succeeds for first claimer", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
const result = db.checkoutTask(taskId, runId);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const task = db.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("CAS checkout fails for second claimer", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId1 } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
const { id: runId2 } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
expect(db.checkoutTask(taskId, runId1)).toBe(true);
|
||||
expect(db.checkoutTask(taskId, runId2)).toBe(false);
|
||||
});
|
||||
|
||||
it("CAS checkout rejects already-in-progress tasks", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
// Manually set task to in_progress
|
||||
(db as any).db.prepare("UPDATE tasks SET status = 'in_progress' WHERE id = ?").run(taskId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
expect(db.checkoutTask(taskId, runId)).toBe(false);
|
||||
});
|
||||
|
||||
it("lock release in finishRun clears checkout fields", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
db.markRunStatus(runId, "running", "");
|
||||
db.finishRun({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
summary: "Done",
|
||||
errorMessage: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
model: null,
|
||||
sessionDisplayId: null,
|
||||
costUsd: 0,
|
||||
unattributedCost: false,
|
||||
});
|
||||
|
||||
const row = (db as any).db
|
||||
.prepare("SELECT checkout_run_id, execution_locked_at FROM tasks WHERE id = ?")
|
||||
.get(taskId) as { checkout_run_id: string | null; execution_locked_at: string | null };
|
||||
|
||||
expect(row.checkout_run_id).toBeNull();
|
||||
expect(row.execution_locked_at).toBeNull();
|
||||
});
|
||||
|
||||
it("lock release in cancelRunForDeletion clears checkout fields", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
db.markRunStatus(runId, "running", "");
|
||||
|
||||
// Deleting the agent triggers cancelRunForDeletion for active runs
|
||||
db.deleteAgent(agentId, companyId);
|
||||
|
||||
const row = (db as any).db
|
||||
.prepare("SELECT checkout_run_id, execution_locked_at FROM tasks WHERE id = ?")
|
||||
.get(taskId) as { checkout_run_id: string | null; execution_locked_at: string | null };
|
||||
|
||||
expect(row.checkout_run_id).toBeNull();
|
||||
expect(row.execution_locked_at).toBeNull();
|
||||
});
|
||||
|
||||
it("stale lock recovery resets tasks older than 15 minutes", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
|
||||
// Backdate execution_locked_at to 20 minutes ago
|
||||
const backdated = new Date(Date.now() - 20 * 60 * 1000).toISOString();
|
||||
(db as any).db
|
||||
.prepare("UPDATE tasks SET execution_locked_at = ? WHERE id = ?")
|
||||
.run(backdated, taskId);
|
||||
|
||||
const recovered = db.recoverStaleLocks();
|
||||
|
||||
expect(recovered.length).toBe(1);
|
||||
expect(recovered[0].taskId).toBe(taskId);
|
||||
expect(recovered[0].companyId).toBe(companyId);
|
||||
|
||||
const task = db.getTask(taskId);
|
||||
expect(task.status).toBe("todo");
|
||||
|
||||
const row = (db as any).db
|
||||
.prepare("SELECT checkout_run_id, execution_locked_at FROM tasks WHERE id = ?")
|
||||
.get(taskId) as { checkout_run_id: string | null; execution_locked_at: string | null };
|
||||
|
||||
expect(row.checkout_run_id).toBeNull();
|
||||
expect(row.execution_locked_at).toBeNull();
|
||||
});
|
||||
|
||||
it("stale lock recovery ignores tasks within 15 minute window", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, null, workspaceId);
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
db.checkoutTask(taskId, runId);
|
||||
|
||||
const recovered = db.recoverStaleLocks();
|
||||
expect(recovered.length).toBe(0);
|
||||
|
||||
const task = db.getTask(taskId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
it("STALE_LOCK_TIMEOUT_MS is 15 minutes", () => {
|
||||
expect(STALE_LOCK_TIMEOUT_MS).toBe(15 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
+24
-16
@@ -164,7 +164,7 @@ describe("AppDatabase", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("atomically checks out unclaimed work to a single agent", () => {
|
||||
it("atomically checks out unclaimed work via CAS with run ID", () => {
|
||||
const companyId = db.saveCompany({
|
||||
name: "Checkout Company",
|
||||
description: "Atomic checkout validation",
|
||||
@@ -191,18 +191,12 @@ describe("AppDatabase", () => {
|
||||
capabilities: "Leads",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
const contributorId = db.saveAgent({
|
||||
companyId,
|
||||
name: "Contributor",
|
||||
role: "IC",
|
||||
title: "Contributor",
|
||||
status: "active",
|
||||
reportsTo: leadId,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes",
|
||||
budgetMonthlyUsd: 100,
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "Test connector",
|
||||
});
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
@@ -214,11 +208,25 @@ describe("AppDatabase", () => {
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
expect(db.checkoutTask(taskId, leadId)).toBe(true);
|
||||
expect(db.checkoutTask(taskId, contributorId)).toBe(false);
|
||||
const { id: runId1 } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: leadId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
const { id: runId2 } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId: leadId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
expect(db.checkoutTask(taskId, runId1)).toBe(true);
|
||||
expect(db.checkoutTask(taskId, runId2)).toBe(false);
|
||||
|
||||
const task = db.getTask(taskId);
|
||||
expect(task.assigneeAgentId).toBe(leadId);
|
||||
expect(task.status).toBe("in_progress");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
getSelectedStorageBackend: () => "basic_text",
|
||||
encryptString: (value: string) => Buffer.from(value, "utf8"),
|
||||
decryptString: (value: Buffer) => value.toString("utf8"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AppDatabase } from "../../src/main/database";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestCompany(db: AppDatabase): string {
|
||||
return db.saveCompany({
|
||||
name: "Wakeup Test Company",
|
||||
description: "Wakeup lifecycle tests",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestConnector(db: AppDatabase): void {
|
||||
db.saveConnector({
|
||||
id: "codex_local",
|
||||
command: "codex",
|
||||
model: "gpt-5",
|
||||
envBindingText: "",
|
||||
notes: "Test connector",
|
||||
});
|
||||
}
|
||||
|
||||
function createTestWorkspace(db: AppDatabase, companyId: string): string {
|
||||
return db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Wakeup Workspace",
|
||||
localPath: "/tmp/wakeup-workspace",
|
||||
repoUrl: "https://github.com/example/wakeup",
|
||||
repoRef: "main",
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createTestAgent(db: AppDatabase, companyId: string, workspaceId: string, name = "Agent"): string {
|
||||
return db.saveAgent({
|
||||
companyId,
|
||||
name,
|
||||
role: "IC",
|
||||
title: name,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
connectorId: "codex_local",
|
||||
workspaceId,
|
||||
model: null,
|
||||
capabilities: "Executes tasks",
|
||||
budgetMonthlyUsd: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("wakeup lifecycle", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-wakeup-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("enqueueWakeupRequest creates new queued wakeup", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.created", "New task assigned");
|
||||
|
||||
const wakes = db.listWakeupRequests(companyId);
|
||||
expect(wakes.length).toBe(1);
|
||||
expect(wakes[0].agentId).toBe(agentId);
|
||||
expect(wakes[0].status).toBe("queued");
|
||||
expect(wakes[0].coalescedCount).toBe(0);
|
||||
|
||||
const triggers = JSON.parse(wakes[0].triggerDetail!);
|
||||
expect(triggers).toHaveLength(1);
|
||||
expect(triggers[0].trigger).toBe("task.created");
|
||||
expect(triggers[0].detail).toBe("New task assigned");
|
||||
});
|
||||
|
||||
it("enqueueWakeupRequest coalesces into existing queued wakeup for same agent", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.created", "First task");
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.status_changed", "Second trigger");
|
||||
|
||||
const wakes = db.listWakeupRequests(companyId);
|
||||
// D-07: Per-agent scope -- only one wakeup row should exist
|
||||
expect(wakes.length).toBe(1);
|
||||
expect(wakes[0].coalescedCount).toBe(1);
|
||||
|
||||
const triggers = JSON.parse(wakes[0].triggerDetail!);
|
||||
expect(triggers).toHaveLength(2);
|
||||
expect(triggers[0].trigger).toBe("task.created");
|
||||
expect(triggers[1].trigger).toBe("task.status_changed");
|
||||
});
|
||||
|
||||
it("enqueueWakeupRequest coalesces into existing deferred wakeup", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.created", "First task");
|
||||
db.deferWakeupRequest(agentId, companyId);
|
||||
|
||||
// Verify it's deferred
|
||||
const deferredWakes = db.listWakeupRequests(companyId, { status: "deferred" });
|
||||
expect(deferredWakes.length).toBe(1);
|
||||
|
||||
// Enqueue another -- should coalesce into the deferred wake
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.status_changed", "Status update");
|
||||
|
||||
const allWakes = db.listWakeupRequests(companyId, { agentId });
|
||||
// Only one row (the deferred one, now with coalesced trigger)
|
||||
expect(allWakes.length).toBe(1);
|
||||
expect(allWakes[0].coalescedCount).toBe(1);
|
||||
|
||||
const triggers = JSON.parse(allWakes[0].triggerDetail!);
|
||||
expect(triggers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("claimWakeupRequest transitions queued to claimed", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Claim task",
|
||||
description: "For run creation",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.created", "New task");
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
const claimed = db.claimWakeupRequest(agentId, companyId, runId);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.triggerDetail).toBeTruthy();
|
||||
|
||||
const wakes = db.listWakeupRequests(companyId, { status: "claimed" });
|
||||
expect(wakes.length).toBe(1);
|
||||
expect(wakes[0].runId).toBe(runId);
|
||||
expect(wakes[0].claimedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("claimWakeupRequest returns null when no queued wake exists", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
|
||||
const result = db.claimWakeupRequest(agentId, companyId, "fake-run-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("completeWakeupRequest transitions claimed to completed", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = db.saveTask({
|
||||
companyId,
|
||||
title: "Complete task",
|
||||
description: "For run creation",
|
||||
assigneeAgentId: agentId,
|
||||
workspaceId,
|
||||
priority: "medium",
|
||||
status: "todo",
|
||||
});
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.created", "New task");
|
||||
|
||||
const { id: runId } = db.createRun({
|
||||
companyId,
|
||||
taskId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
connectorId: "codex_local",
|
||||
});
|
||||
|
||||
const claimed = db.claimWakeupRequest(agentId, companyId, runId);
|
||||
expect(claimed).not.toBeNull();
|
||||
|
||||
db.completeWakeupRequest(claimed!.id);
|
||||
|
||||
const completed = db.listWakeupRequests(companyId, { status: "completed" });
|
||||
expect(completed.length).toBe(1);
|
||||
expect(completed[0].finishedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("deferWakeupRequest transitions queued to deferred", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.created", "New task");
|
||||
db.deferWakeupRequest(agentId, companyId);
|
||||
|
||||
const deferred = db.listWakeupRequests(companyId, { status: "deferred" });
|
||||
expect(deferred.length).toBe(1);
|
||||
|
||||
const queued = db.listWakeupRequests(companyId, { status: "queued" });
|
||||
expect(queued.length).toBe(0);
|
||||
});
|
||||
|
||||
it("promoteWakeupRequests promotes deferred to queued", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agentId, "task.created", "First task");
|
||||
db.deferWakeupRequest(agentId, companyId);
|
||||
|
||||
// Verify deferred
|
||||
expect(db.listWakeupRequests(companyId, { status: "deferred" }).length).toBe(1);
|
||||
|
||||
const promoted = db.promoteWakeupRequests(agentId, companyId);
|
||||
expect(promoted).toBe(1);
|
||||
|
||||
const queued = db.listWakeupRequests(companyId, { status: "queued" });
|
||||
expect(queued.length).toBe(1);
|
||||
|
||||
const deferred = db.listWakeupRequests(companyId, { status: "deferred" });
|
||||
expect(deferred.length).toBe(0);
|
||||
});
|
||||
|
||||
it("promoteWakeupRequests merges deferred into existing queued", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
|
||||
// Create a queued wake, defer it, then create a new queued wake
|
||||
db.enqueueWakeupRequest(companyId, agentId, "first.trigger", "First");
|
||||
db.deferWakeupRequest(agentId, companyId);
|
||||
|
||||
// Now create a new queued wake for the same agent
|
||||
db.enqueueWakeupRequest(companyId, agentId, "second.trigger", "Second");
|
||||
|
||||
// At this point: 1 deferred (first) coalesced with second trigger, or 1 deferred + 1 queued
|
||||
// Actually: enqueueWakeupRequest coalesces into deferred, so we have 1 deferred row with 2 triggers
|
||||
// Let's create a scenario with distinct deferred + queued:
|
||||
|
||||
// Start fresh for clarity
|
||||
// Clear by completing
|
||||
const wakesBeforePromote = db.listWakeupRequests(companyId, { agentId });
|
||||
// The coalescing means both triggers are in one deferred row
|
||||
// So let's verify promote on that single deferred row
|
||||
const promoted = db.promoteWakeupRequests(agentId, companyId);
|
||||
|
||||
// After promote: should have 1 queued row with merged triggers
|
||||
const queued = db.listWakeupRequests(companyId, { status: "queued" });
|
||||
expect(queued.length).toBe(1);
|
||||
|
||||
const triggers = JSON.parse(queued[0].triggerDetail!);
|
||||
expect(triggers.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("listWakeupRequests returns rows filtered by companyId and optional status/agentId", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agent1Id = createTestAgent(db, companyId, workspaceId, "Agent1");
|
||||
const agent2Id = createTestAgent(db, companyId, workspaceId, "Agent2");
|
||||
|
||||
db.enqueueWakeupRequest(companyId, agent1Id, "task.created", "Agent1 task");
|
||||
db.enqueueWakeupRequest(companyId, agent2Id, "task.created", "Agent2 task");
|
||||
|
||||
// List all for company
|
||||
const allWakes = db.listWakeupRequests(companyId);
|
||||
expect(allWakes.length).toBe(2);
|
||||
|
||||
// Filter by agentId
|
||||
const agent1Wakes = db.listWakeupRequests(companyId, { agentId: agent1Id });
|
||||
expect(agent1Wakes.length).toBe(1);
|
||||
expect(agent1Wakes[0].agentId).toBe(agent1Id);
|
||||
|
||||
// Filter by status
|
||||
db.deferWakeupRequest(agent1Id, companyId);
|
||||
const queuedOnly = db.listWakeupRequests(companyId, { status: "queued" });
|
||||
expect(queuedOnly.length).toBe(1);
|
||||
expect(queuedOnly[0].agentId).toBe(agent2Id);
|
||||
|
||||
const deferredOnly = db.listWakeupRequests(companyId, { status: "deferred" });
|
||||
expect(deferredOnly.length).toBe(1);
|
||||
expect(deferredOnly[0].agentId).toBe(agent1Id);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user