Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
8.7 KiB
TypeScript
136 lines
8.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, expect, test } from "vitest";
|
|
import { DatabaseConnection } from "../../src/main/database/connection";
|
|
import { OnboardingService } from "../../src/main/services/onboarding-service";
|
|
import { ProviderService } from "../../src/main/services/provider-service";
|
|
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
|
import { TaskExecutionService } from "../../src/main/services/task-execution-service";
|
|
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
|
import { OrganizationService } from "../../src/main/services/organization-service";
|
|
import { currentProviderUsageWindow } from "../../src/shared/provider-runtime-domain";
|
|
import type { AgentTurnResult } from "../../src/main/services/provider-tool-turn";
|
|
|
|
const testVault = {
|
|
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
|
|
decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"),
|
|
};
|
|
|
|
/** A company whose kickoff task is open with no run in flight. */
|
|
function company() {
|
|
const turn = async (): Promise<AgentTurnResult> => ({ text: "Done.", toolCalls: [], rawResponseJson: "{}" });
|
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-task-holds-${randomUUID()}.db`));
|
|
const providers = new ProviderService(connection.db, providerAdapters, testVault, turn);
|
|
const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
|
const execution = new TaskExecutionService(connection.db, providers);
|
|
let orgId = "";
|
|
let ceoId = "";
|
|
connection.transaction(() => {
|
|
const summary = onboarding.bootstrap({
|
|
companyName: "FlowPilot", companyDescription: "CSM analytics.", employeeName: "Yang", workspacePath: "",
|
|
executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false,
|
|
goalTitle: "Ship the MVP.", goalDescription: "A working product.",
|
|
providerKey: "openai", providerSecret: "sk-test-key", providerModel: "gpt-5.4",
|
|
});
|
|
orgId = summary.orgId;
|
|
ceoId = summary.ceoAgentId;
|
|
});
|
|
const run = connection.db.prepare("select id, task_id, provider_id from task_runs").get() as { id: string; task_id: string; provider_id: string };
|
|
connection.db.prepare("update task_runs set state = 'failed' where id = ?").run(run.id);
|
|
const hold = () => new SnapshotService(connection.db).load().tasks.find((task) => task.id === run.task_id)!.hold;
|
|
return { connection, providers, execution, orgId, ceoId, taskId: run.task_id, providerId: run.provider_id, hold };
|
|
}
|
|
|
|
describe("why an open task is not moving", () => {
|
|
test("an open task with nothing in its way has no hold", () => {
|
|
const h = company();
|
|
expect(h.hold()).toBeNull();
|
|
h.connection.close();
|
|
});
|
|
|
|
test("a provider cooldown says when the task resumes on its own", () => {
|
|
const h = company();
|
|
const until = Date.now() + 90_000;
|
|
h.connection.db.prepare("insert into provider_cooldowns(provider_id, label, until_ms, strikes) values (?, 'OpenAI', ?, 2)").run(h.providerId, until);
|
|
expect(h.hold()).toEqual({ reason: "OpenAI reported a passing failure; its work waits out a cooldown.", resumesAt: new Date(until).toISOString(), waitingOn: "provider" });
|
|
h.connection.close();
|
|
});
|
|
|
|
test("a spent monthly request limit waits on the operator or the next window, and a zero limit only on the operator", () => {
|
|
const h = company();
|
|
const window = currentProviderUsageWindow();
|
|
h.connection.db.prepare("update providers set monthly_request_limit = 2 where id = ?").run(h.providerId);
|
|
h.connection.db.prepare(`
|
|
insert into provider_usage_windows(id, org_id, provider_id, window_start, window_end, request_count, input_char_count, output_char_count, created_at, updated_at)
|
|
values ('puw_t', ?, ?, ?, ?, 2, 0, 0, ?, ?)
|
|
`).run(h.orgId, h.providerId, window.start, window.end, new Date().toISOString(), new Date().toISOString());
|
|
expect(h.hold()).toEqual({ reason: "OpenAI used its monthly request limit (2). Raise the limit or wait for the next window.", resumesAt: window.end, waitingOn: "operator" });
|
|
h.connection.db.prepare("update providers set monthly_request_limit = 0 where id = ?").run(h.providerId);
|
|
expect(h.hold()).toEqual({ reason: "OpenAI has a monthly request limit of 0. Raise the limit to let its work run.", resumesAt: null, waitingOn: "operator" });
|
|
h.connection.close();
|
|
});
|
|
|
|
test("a spent money budget waits on the operator or next month, and a zero budget only on the operator", () => {
|
|
const h = company();
|
|
h.providers.setPrices({ orgId: h.orgId, providerId: h.providerId, inputDollarsPerMtok: 2.5, cachedInputDollarsPerMtok: null, outputDollarsPerMtok: 10 });
|
|
h.connection.db.prepare("update agents set budget_cents = 100 where id = ?").run(h.ceoId);
|
|
h.connection.db.prepare(`
|
|
insert into provider_requests(id, org_id, provider_id, provider_key, agent_id, model, prompt_sha256, state, created_at, cost_cents)
|
|
values ('preq_t', ?, ?, 'openai', ?, 'gpt-5.4', 'x', 'succeeded', ?, 150)
|
|
`).run(h.orgId, h.providerId, h.ceoId, new Date().toISOString());
|
|
expect(h.hold()).toEqual({
|
|
reason: "CEO has spent its monthly budget ($1.50 of $1.00). Raise the budget or wait for next month.",
|
|
resumesAt: currentProviderUsageWindow().end,
|
|
waitingOn: "operator",
|
|
});
|
|
h.connection.db.prepare("update agents set budget_cents = 0 where id = ?").run(h.ceoId);
|
|
expect(h.hold()).toEqual({ reason: "CEO has spent its monthly budget ($1.50 of $0.00). Raise the budget to let it run.", resumesAt: null, waitingOn: "operator" });
|
|
h.connection.close();
|
|
});
|
|
|
|
test("a heartbeat that could not queue the task records why, until it can", () => {
|
|
const h = company();
|
|
h.connection.db.prepare("update tasks set risk = 'high' where id = ?").run(h.taskId);
|
|
expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toEqual([]);
|
|
expect(h.hold()).toEqual({ reason: "Could not start: Approved task execution approval is required.", resumesAt: null, waitingOn: "operator" });
|
|
h.connection.db.prepare("update tasks set risk = 'low' where id = ?").run(h.taskId);
|
|
expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toHaveLength(1);
|
|
expect(h.hold()).toBeNull();
|
|
h.connection.close();
|
|
});
|
|
|
|
test("a stalled task waits on the operator with no resume time", () => {
|
|
const h = company();
|
|
h.connection.db.prepare("update tasks set stalled_reason = 'Stalled: 3 sessions made no progress.' where id = ?").run(h.taskId);
|
|
expect(h.hold()).toEqual({ reason: "Stalled: 3 sessions made no progress.", resumesAt: null, waitingOn: "operator" });
|
|
h.connection.close();
|
|
});
|
|
});
|
|
|
|
describe("blocking a task", () => {
|
|
test("stops it until unblocked: no new session, queued ones superseded, manual runs refused, and the board says why", async () => {
|
|
const h = company();
|
|
const organization = new OrganizationService(h.connection.db);
|
|
expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toHaveLength(1);
|
|
h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: "Waiting for the legal review." }));
|
|
expect(h.connection.db.prepare("select state from task_runs where task_id = ? order by rowid desc limit 1").get(h.taskId)).toEqual({ state: "superseded" });
|
|
expect(h.hold()).toEqual({ reason: "Blocked: Waiting for the legal review.", resumesAt: null, waitingOn: "operator" });
|
|
expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toEqual([]);
|
|
await expect(h.execution.runTask({ orgId: h.orgId, taskId: h.taskId })).rejects.toThrow("is blocked: Waiting for the legal review. Unblock it to run it.");
|
|
expect(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: " " })).toThrow("Say why the task is blocked.");
|
|
|
|
h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: null }));
|
|
expect(h.hold()).toBeNull();
|
|
// Work in review can be blocked too, and says so.
|
|
h.connection.db.prepare("update tasks set status = 'review' where id = ?").run(h.taskId);
|
|
h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: "Spec changed." }));
|
|
expect(h.hold()).toEqual({ reason: "Blocked: Spec changed.", resumesAt: null, waitingOn: "operator" });
|
|
h.connection.transaction(() => organization.setTaskBlocked({ orgId: h.orgId, taskId: h.taskId, reason: null }));
|
|
expect(h.hold()).toBeNull();
|
|
h.connection.db.prepare("update tasks set status = 'backlog' where id = ?").run(h.taskId);
|
|
expect(h.connection.transaction(() => h.execution.enqueueHeartbeatRuns())).toHaveLength(1);
|
|
h.connection.close();
|
|
});
|
|
});
|