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
14 changes: 14 additions & 0 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,20 @@ export class SessionManager {
* Rebuilds in-memory session objects and scrollback buffers.
*/
async resumeSessions(): Promise<number> {
// Correct statuses stranded by a daemon that stopped mid-turn. A resumed
// Session always starts `idle` in memory, so a row still claiming
// `thinking` / `tool_running` / `waiting_approval` is pure staleness — it
// made the session list show work that no longer exists (and, for
// `waiting_approval`, an approval whose resolver died with the process).
// Done BEFORE resume so rows and live sessions agree from the first
// broadcast.
const corrected = this.#store.reconcileStaleSessionStatuses();
if (corrected > 0) {
console.log(
`[codeoid] reconciled ${corrected} stale session status(es) left by a previous run`,
);
}

// Reload the durable conductor identity first (design R2): the persisted
// {identityId, wimseUri, apiKey} row is reused instead of re-registering,
// so the conductor keeps ONE stable WIMSE URI across daemon restarts.
Expand Down
27 changes: 26 additions & 1 deletion src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1839,7 +1839,7 @@ export class Session {
// the message is already persisted, the user re-sends after deciding.
if (this.#status === "waiting_approval") {
throw new Error(
"A tool approval is pending — approve or deny it before sending (this backend can't queue mid-turn).",
`${this.#describePendingApprovals()} — approve or deny it before sending (this backend can't queue mid-turn). Your message was saved; send again after deciding.`,
);
}

Expand Down Expand Up @@ -3334,6 +3334,31 @@ export class Session {

// ── Internals ─────────────────────────────────────────────────────────

/**
* Human-readable summary of what is currently blocking a send.
*
* The old rejection just said "A tool approval is pending", which told the
* user that *something* was waiting but not what, and gave them nothing to
* act on — so a message sent after stepping away read as the session simply
* going silent. Tool names come from the same maps the approval bar uses
* (`#approvalIdToMessageId` → `#toolCallMessages`), so no extra bookkeeping.
*
* Falls back to the generic phrasing when the maps have no entry — the
* status is authoritative, the label is best-effort, and a missing name must
* never turn a clear refusal into a crash.
*/
#describePendingApprovals(): string {
const names: string[] = [];
for (const approvalId of this.#pendingApprovals.keys()) {
const msgId = this.#approvalIdToMessageId.get(approvalId);
const name = msgId ? this.#toolCallMessages.get(msgId)?.tool?.name : undefined;
if (name) names.push(name);
}
if (names.length === 0) return "A tool approval is pending";
if (names.length === 1) return `The tool \`${names[0]}\` is waiting for approval`;
return `${names.length} tool approvals are pending (${names.join(", ")})`;
}

#waitForApproval(
approvalId: string,
): Promise<{ approved: boolean; updatedInput?: Record<string, unknown> }> {
Expand Down
27 changes: 27 additions & 0 deletions src/daemon/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,33 @@ export class Store {
.run(status, id);
}

/**
* Reconcile non-terminal statuses left behind by a daemon that stopped
* mid-turn. Returns the number of rows corrected.
*
* `thinking` / `tool_running` / `waiting_approval` all describe work owned by
* a LIVE process: the provider's turn loop, and for approvals the in-memory
* `#pendingApprovals` resolvers. None of that survives a restart — a resumed
* Session starts at `idle` (`#status` is initialised, never restored) — but
* the row was never corrected, so `codeoid ls` and the web UI kept showing
* sessions as busy indefinitely. Observed on a live daemon: rows stuck in
* `tool_running` and `waiting_approval` for 11–19 days across restarts.
*
* `error` is deliberately preserved: it is a terminal state a human may still
* want to see, not an artifact of the process dying.
*
* Call once at boot, BEFORE resumeSessions, so resumed sessions and their
* rows agree from the first broadcast.
*/
reconcileStaleSessionStatuses(): number {
return this.#db
.prepare(
`UPDATE sessions SET status = 'idle'
WHERE status IN ('thinking', 'tool_running', 'waiting_approval')`,
)
.run().changes;
}

/**
* Rename a session (`session.rename`). The `name` column is otherwise
* written ONLY by createSession's INSERT OR REPLACE, so without this a
Expand Down
74 changes: 74 additions & 0 deletions src/tests/stale-status-reconcile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Store } from "../daemon/store.js";
import type { SessionStatus } from "../protocol/types.js";

/**
* `thinking` / `tool_running` / `waiting_approval` all describe work owned by a
* LIVE process — the provider's turn loop, and for approvals the in-memory
* resolver map. None of it survives a restart, and a resumed Session starts at
* `idle` (its `#status` field is initialised, never restored from the row). The
* rows were nonetheless left untouched, so the session list kept advertising
* work that no longer existed — observed stuck for 11-19 days across restarts
* on a live daemon.
*/
describe("reconcileStaleSessionStatuses", () => {
let dir: string;
let store: Store;

const seed = (id: string, status: SessionStatus) =>
store.createSession({
id,
name: `s-${id}`,
workdir: "/tmp",
status,
createdBy: "test",
accountId: "acct",
projectId: "proj",
} as never);

const statusOf = (id: string) => store.getSession(id)?.status;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "codeoid-reconcile-"));
store = new Store(join(dir, "codeoid.db"));
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

test("clears every non-terminal status left by a dead process", () => {
seed("a", "thinking");
seed("b", "tool_running");
seed("c", "waiting_approval");

expect(store.reconcileStaleSessionStatuses()).toBe(3);
for (const id of ["a", "b", "c"]) expect(statusOf(id)).toBe("idle");
});

test("preserves `error` — a terminal state a human may still want to see", () => {
seed("err", "error");
seed("busy", "thinking");

// Only the live-work row is corrected.
expect(store.reconcileStaleSessionStatuses()).toBe(1);
expect(statusOf("err")).toBe("error");
expect(statusOf("busy")).toBe("idle");
});

test("leaves already-idle rows untouched and reports zero", () => {
seed("calm", "idle");
expect(store.reconcileStaleSessionStatuses()).toBe(0);
expect(statusOf("calm")).toBe("idle");
});

test("is idempotent — a second boot corrects nothing", () => {
seed("a", "waiting_approval");
expect(store.reconcileStaleSessionStatuses()).toBe(1);
expect(store.reconcileStaleSessionStatuses()).toBe(0);
expect(statusOf("a")).toBe("idle");
});
});
Loading