Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/src/main/db/ddl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ export const SCHEMA_DDL = `
source_id TEXT,
prompt TEXT NOT NULL,
background INTEGER NOT NULL,
fired_at INTEGER NOT NULL
fired_at INTEGER NOT NULL,
outcome TEXT,
finished_at INTEGER,
failure_reason TEXT,
failure_category TEXT
);
CREATE INDEX IF NOT EXISTS wakes_agent_fired ON wakes (agent_id, fired_at);
CREATE TABLE IF NOT EXISTS recent_notifications (
Expand Down
28 changes: 28 additions & 0 deletions app/src/main/db/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("db migrations", () => {
expect(columns(db, "wakes")).toContain("source_id"); // v4
expect(columns(db, "agents")).toContain("last_turn_at"); // v5
expect(columns(db, "schedules")).toContain("timezone"); // v6
expect(columns(db, "wakes")).toContain("outcome"); // v7
// Whole new tables ride the DDL — no migration, no version bump (§6.3).
expect(columns(db, "recent_notifications")).toContain("at");
});
Expand Down Expand Up @@ -141,6 +142,33 @@ describe("db migrations", () => {
expect(row.timezone).toBeNull(); // the scheduler backfills this at its next boot
});

test("v7 adds the wake outcome columns to an existing (v6) DB, preserving rows as NULL", () => {
const db = new Database(":memory:");
const m = wrap(db);
// A pre-v7 wakes table (through source_id) with a recorded fire in it, stamped at v6.
db.exec(`CREATE TABLE wakes (
id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, source_kind TEXT NOT NULL,
source_id TEXT, prompt TEXT NOT NULL, background INTEGER NOT NULL,
fired_at INTEGER NOT NULL
);`);
db.exec(
`INSERT INTO wakes (id, agent_id, source_kind, source_id, prompt, background, fired_at)
VALUES ('w1', 'a1', 'cron', 's1', 'run', 1, 1000)`,
);
db.exec("PRAGMA user_version = 6");
db.exec(SCHEMA_DDL);
migrate(m, { fresh: false });

expect(userVersion(m)).toBe(SCHEMA_VERSION);
for (const c of ["outcome", "finished_at", "failure_reason", "failure_category"]) {
expect(columns(db, "wakes")).toContain(c);
}
const row = db.query("SELECT * FROM wakes WHERE id = 'w1'").get() as Record<string, unknown>;
expect(row.source_id).toBe("s1"); // data survived
expect(row.outcome).toBeNull(); // pre-v7 rows have no known outcome
expect(row.finished_at).toBeNull();
});

test("refuses to open a DB stamped newer than this build (downgrade guard)", () => {
const db = new Database(":memory:");
const m = wrap(db);
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export interface MigrationDb {
}

/** Bump on every schema change, with a matching entry in MIGRATIONS. */
export const SCHEMA_VERSION = 6;
export const SCHEMA_VERSION = 7;

const MIGRATIONS: Record<number, (db: MigrationDb) => void> = {
// v2 — headless turn limit: per-agent unattended-turn counter + on/off toggle.
Expand Down Expand Up @@ -63,6 +63,14 @@ const MIGRATIONS: Record<number, (db: MigrationDb) => void> = {
6: (db) => {
addColumnIfMissing(db, "schedules", "timezone", "TEXT");
},
// v7 — per-wake outcome for the Monitor tab's History (§12.2): how the run ended,
// when, and why it failed. All nullable: pre-v7 rows read NULL (no outcome known).
7: (db) => {
addColumnIfMissing(db, "wakes", "outcome", "TEXT");
addColumnIfMissing(db, "wakes", "finished_at", "INTEGER");
addColumnIfMissing(db, "wakes", "failure_reason", "TEXT");
addColumnIfMissing(db, "wakes", "failure_category", "TEXT");
},
};

export function userVersion(db: MigrationDb): number {
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,17 @@ export const wakes = sqliteTable(
prompt: text("prompt").notNull(),
/** True if delivered headlessly (no live interactive session); false if warm via the channel. */
background: integer("background", { mode: "boolean" }).notNull(),
/** When the run/turn actually started — a row exists only for a wake that did. */
firedAt: integer("fired_at").notNull(),
/** `WakeOutcome`: "running" | "succeeded" | "failed" | "stopped". Nullable: rows
* written before v7 read NULL. */
outcome: text("outcome"),
/** When `outcome` settled; NULL while running (and on pre-v7 rows). */
finishedAt: integer("finished_at"),
/** `WakeFailureReason` (see `@shared/schedule`), set only on a failed run. */
failureReason: text("failure_reason"),
/** `WakeFailureCategory` classified from the failure text, when recognized. */
failureCategory: text("failure_category"),
},
(t) => [index("wakes_agent_fired").on(t.agentId, t.firedAt)],
);
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/services/analytics/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,11 @@ const DIAG: FeedbackDiagnostics = {
agents_active_24h: 2,
schedules_enabled: 0,
monitors_enabled: 0,
wakes_7d: 0,
wakes_failed_7d: 0,
wakes_stopped_7d: 0,
wakes_failed_api_7d: 0,
wakes_top_failure_7d: null,
broker_status: "connected",
broker_authorized: true,
broker_portfolio_age_sec: 5,
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/services/feedback/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const VALUES: DiagnosticsValues = {
],
crons: 4,
monitors: 1,
wakes: { total: 12, failed: 3, stopped: 1, apiErrors: 2, topFailureCategory: "billing" },
brokerStatus: "connected",
brokerAuthorized: true,
portfolioFetchedAt: NOW - 12_500,
Expand Down Expand Up @@ -63,6 +64,11 @@ describe("buildDiagnostics", () => {
agents_active_24h: 1,
schedules_enabled: 4,
monitors_enabled: 1,
wakes_7d: 12,
wakes_failed_7d: 3,
wakes_stopped_7d: 1,
wakes_failed_api_7d: 2,
wakes_top_failure_7d: "billing",
broker_status: "connected",
broker_authorized: true,
broker_portfolio_age_sec: 12,
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/services/feedback/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import type { Agent } from "@shared/agent";
import type { BrokerConnectionStatus } from "@shared/broker";
import type { FeedbackDiagnostics } from "@shared/feedback";
import type { AppSettings } from "@shared/settings";
import type { WakeStats } from "../scheduler";

/** The wake window the diagnostics block summarizes. */
export const WAKE_STATS_WINDOW_MS = 7 * 86_400_000;

export interface DiagnosticsValues {
agents: Agent[];
/** Enabled cron schedules / monitors across every agent. */
crons: number;
monitors: number;
/** Wake outcomes over the last `WAKE_STATS_WINDOW_MS`, every agent. */
wakes: WakeStats;
brokerStatus: BrokerConnectionStatus;
brokerAuthorized: boolean;
/** `fetchedAt` of the cached portfolio snapshot, or null before the first fetch. */
Expand Down Expand Up @@ -61,6 +67,12 @@ export function buildDiagnostics(v: DiagnosticsValues, now = Date.now()): Feedba
schedules_enabled: v.crons,
monitors_enabled: v.monitors,

wakes_7d: v.wakes.total,
wakes_failed_7d: v.wakes.failed,
wakes_stopped_7d: v.wakes.stopped,
wakes_failed_api_7d: v.wakes.apiErrors,
wakes_top_failure_7d: v.wakes.topFailureCategory,

broker_status: v.brokerStatus,
broker_authorized: v.brokerAuthorized,
broker_portfolio_age_sec:
Expand Down
12 changes: 11 additions & 1 deletion app/src/main/services/harness/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const execFileAsync = promisify(execFile);
/**
* The agent-dir `.claude/settings.json` that wires Claude Code's order gate: the
* PreToolUse hook on the money-moving tools (→ `approval-gate.sh`, the approval card),
* the PostToolUse order-result capture, and the Notification/Stop status hooks, plus
* the PostToolUse order-result capture, and the Notification/Stop/StopFailure status hooks, plus
* the allowlist for reads and cosmetic writes. The matcher and allowlist derive from
* the classification table in `@shared/robinhood-tools` — the single place the gated
* set is maintained. Generated IN CODE (not copied from the template): the
Expand Down Expand Up @@ -75,6 +75,16 @@ const CLAUDE_SETTINGS_JSON = `${JSON.stringify(
],
},
],
// Fires INSTEAD of Stop when the turn ends in an API error (billing, auth, rate
// limit, server/connection failure). Same forwarder: the host reads
// `hook_event_name` + `error` and settles the outstanding wake as failed (§12.2).
StopFailure: [
{
hooks: [
{ type: "command", command: "$CLAUDE_PROJECT_DIR/.claude/hooks/status-notify.sh" },
],
},
],
},
},
null,
Expand Down
12 changes: 10 additions & 2 deletions app/src/main/services/local-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AgentRegistry } from "../agents/registry";
import type { ApprovalService } from "../approvals";
import type { BrokerService } from "../broker";
import type { Scheduler } from "../scheduler";
import { categoryForStopFailure } from "../scheduler/wake/failure-category";
import type { WakeTransport } from "../scheduler/wake/types";
import type { StatusArbiter } from "../status/arbiter";

Expand Down Expand Up @@ -233,13 +234,20 @@ export class LocalApiServer {
// which is exactly what "last active" should track — a user message produces
// neither, so typing at an agent never moves its timestamp (§12.6). Codex
// fires Stop only (it has no Notification event); claude fires both.
if (event === "Notification" || event === "Stop") {
// StopFailure (claude-only) fires INSTEAD of Stop when the turn ends in an API
// error — same "turn is over" bookkeeping as Stop, but the outstanding wake failed.
const turnEnded = event === "Stop" || event === "StopFailure";
if (event === "Notification" || turnEnded) {
this.registry.markAgentTurn(agentId);
}
if (event === "Notification") {
this.deps.arbiter.setNeedsInput(agentId, true);
} else if (event === "Stop") {
} else if (turnEnded) {
this.deps.arbiter.setNeedsInput(agentId, false);
// A wake delivered into this session has now been consumed: settle its History
// row (§12.2). No-op when no wake is outstanding (a plain user turn).
if (event === "Stop") this.wake?.onTurnEnded(agentId);
else this.wake?.onTurnFailed(agentId, categoryForStopFailure(String(body?.error ?? "")));
// Session capture is claude-only: for codex, `lastSessionId` is the
// app-server THREAD id (minted by thread/start and adopted from the TUI,
// §13) — codex's hook `session_id` is not verified to match it, and an
Expand Down
85 changes: 85 additions & 0 deletions app/src/main/services/local-api/status-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import type { AgentRegistry } from "../agents/registry";
import type { WakeTransport } from "../scheduler/wake/types";
import { LocalApiServer } from "./index";

/** Every side effect the status route can have, in call order. */
const calls: string[] = [];

const registry = {
get: (id: string) => (id === "a1" ? ({ id: "a1", harness: "claude" } as never) : undefined),
markAgentTurn: (id: string) => calls.push(`turn:${id}`),
setLastSessionId: (id: string, sid: string) => calls.push(`session:${id}:${sid}`),
} as unknown as AgentRegistry;
const arbiter = {
setNeedsInput: (id: string, on: boolean) => calls.push(`needsInput:${id}:${on}`),
};
const wake = {
onTurnEnded: (id: string) => calls.push(`ended:${id}`),
onTurnFailed: (id: string, category: string) => calls.push(`failed:${id}:${category}`),
} as unknown as WakeTransport;

// biome-ignore lint/suspicious/noExplicitAny: stubs stand in for the real services
const server = new LocalApiServer({ registry, arbiter } as any);
server.setWake(wake);
let base = "";
const hdrs = {
"x-opentrade-token": "",
"x-opentrade-agent": "a1",
"content-type": "application/json",
};

beforeAll(async () => {
await server.start();
base = `http://127.0.0.1:${server.port}`;
hdrs["x-opentrade-token"] = server.token;
});
afterAll(() => server.stop());
beforeEach(() => {
calls.length = 0;
});

/** POST a Claude Code hook payload as the agent's status hook script would. */
async function hook(body: Record<string, unknown>): Promise<number> {
const res = await fetch(`${base}/hook/status`, {
method: "POST",
headers: hdrs,
body: JSON.stringify(body),
});
return res.status;
}

describe("/hook/status", () => {
test("Notification: needs-input on, last-active stamped, no wake settlement", async () => {
expect(await hook({ hook_event_name: "Notification", session_id: "s1" })).toBe(200);
expect(calls).toEqual(["turn:a1", "needsInput:a1:true"]);
});

test("Stop: clears needs-input, settles the wake succeeded, captures the session", async () => {
expect(await hook({ hook_event_name: "Stop", session_id: "s1" })).toBe(200);
expect(calls).toEqual(["turn:a1", "needsInput:a1:false", "ended:a1", "session:a1:s1"]);
});

test("StopFailure: same turn-ended bookkeeping, wake settles failed with the mapped category", async () => {
// The payload's `error` field is Claude Code's enum; the route maps it (billing_error → billing).
expect(
await hook({ hook_event_name: "StopFailure", error: "billing_error", session_id: "s1" }),
).toBe(200);
expect(calls).toEqual(["turn:a1", "needsInput:a1:false", "failed:a1:billing", "session:a1:s1"]);
});

test("StopFailure without an error field still settles the wake (category other)", async () => {
expect(await hook({ hook_event_name: "StopFailure" })).toBe(200);
expect(calls).toContain("failed:a1:other");
});

test("an unknown agent is rejected before any bookkeeping", async () => {
const res = await fetch(`${base}/hook/status`, {
method: "POST",
headers: { ...hdrs, "x-opentrade-agent": "nope" },
body: JSON.stringify({ hook_event_name: "Stop" }),
});
expect(res.status).toBe(200); // the route answers ok to the hook regardless…
expect(calls).toEqual([]); // …but touches nothing for an agent it doesn't know
});
});
Loading