@a-t-h-i/bot-lobby 0.6.27 → 0.6.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Carrying on after pi stops unexpectedly. A window running the lobby keeps
3
+ * what it is doing on disk as it changes: its planning panel and quick-fix
4
+ * queue (by session), and a record of the sessions it drives and whether each
5
+ * was mid-turn (src/state/recovery.ts). Quitting on purpose removes the
6
+ * record; a crash, a kill or a closed terminal leaves it.
7
+ *
8
+ * The next window to start the lobby in the project takes the stopped
9
+ * windows over: the planning panel comes back where it was (a round that was
10
+ * cut off runs again), queued quick fixes run (the one cut off runs again),
11
+ * and every session that drove a task still under way is started again from
12
+ * its saved file, its oracle told to pick up where it stopped when it was
13
+ * mid-turn. A window that is that same session again (`pi -c`) carries its
14
+ * own task on itself. Nothing here runs in a subagent or a background session.
15
+ */
16
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
17
+ import { loadTask, ownedTask, saveTask } from "../state/persistence.ts";
18
+ import { resolvePhaseTiming } from "../state/phase-timing.ts";
19
+ import { updateBudget } from "../state/budget.ts";
20
+ import { clearStaleRuns } from "../state/work-time.ts";
21
+ import { livePresence } from "../state/presence.ts";
22
+ import {
23
+ claimStoppedWindows,
24
+ moveSessionState,
25
+ readSessionState,
26
+ removeSessionState,
27
+ removeWindow,
28
+ stoppedWindows,
29
+ writeSessionState,
30
+ writeWindow,
31
+ type BackgroundRecord,
32
+ type WindowRecord,
33
+ } from "../state/recovery.ts";
34
+ import { isPlanningSnapshot, type PlanningSession, type PlanningSnapshot } from "./planner.ts";
35
+ import { keepPlan } from "../state/plan-history.ts";
36
+ import type { QuickFixJob } from "./quickfix.ts";
37
+ import type { BackgroundSession } from "./sessions.ts";
38
+
39
+ /** Writes after a change wait this long, so a burst of changes is one write. */
40
+ export const SAVE_DELAY_MS = 300;
41
+ /** How long after the lobby starts this window's own interrupted turn is picked back up. */
42
+ export const NUDGE_DELAY_MS = 1500;
43
+
44
+ /** What the oracle is told when the session it was working in stopped mid-turn. */
45
+ export function resumeMessage(task: Task): string {
46
+ return [
47
+ `bot-lobby: pi stopped unexpectedly while you were working on ${task.id} (${task.state}${task.paused ? ", paused" : ""}).`,
48
+ "Pick up where you left off: read where the task stands with the orchestrate tool (action=status), run again any step that was cut off (its agent stopped with pi, and its edits may be half done), ask again any question that was not answered, and carry on.",
49
+ ].join(" ");
50
+ }
51
+
52
+ /** A task a stopped session drove that is still under way, or undefined. */
53
+ function liveTask(root: string, configDir: string, sessionId: string): Task | undefined {
54
+ const task = ownedTask(root, configDir, sessionId);
55
+ return task && !TERMINAL_STATES.includes(task.state) ? task : undefined;
56
+ }
57
+
58
+ const INTERRUPTED_DECISION = "pi stopped unexpectedly; the task carried on in the next session (steps that were running stopped with it)";
59
+
60
+ /**
61
+ * Clear what the stopped process left marked as under way on a task: the
62
+ * questions it was waiting on (they no longer wait), the time given to the
63
+ * steps it was running, and the runs it noted as active. A decision records
64
+ * the stop, so the task's history says what happened.
65
+ */
66
+ export function settleInterrupted(root: string, configDir: string, taskId: string, now = new Date(), note = INTERRUPTED_DECISION): void {
67
+ const task = loadTask(root, configDir, taskId);
68
+ if (!task) return;
69
+ const at = now.toISOString();
70
+ for (const id of [...(task.blockingRequestIds ?? task.phaseTiming?.blockingRequestIds ?? [])]) resolvePhaseTiming(task, id, at);
71
+ task.decisions.push({ domain: "master", text: note, createdAt: at });
72
+ task.updatedAt = at;
73
+ saveTask(root, configDir, task);
74
+ updateBudget(root, configDir, taskId, (budget) => {
75
+ for (const allotment of budget.allotments) {
76
+ if (allotment.endedAt) continue;
77
+ allotment.endedAt = at;
78
+ allotment.outcome = "stopped";
79
+ }
80
+ });
81
+ clearStaleRuns(root, configDir, taskId);
82
+ }
83
+
84
+ /** A session to start again from its saved file. */
85
+ export interface Relaunch {
86
+ name: string;
87
+ sessionFile: string;
88
+ taskId: string;
89
+ /** The nudge it gets once up, when it was mid-turn. */
90
+ message?: string;
91
+ }
92
+
93
+ /** What the window starting the lobby can carry on from the windows that stopped. */
94
+ export interface RecoveryPlan {
95
+ windows: WindowRecord[];
96
+ /** Kept state to hand to this session, from the newest stopped window that has it. */
97
+ planner?: { from: string };
98
+ quickfix: string[];
99
+ relaunch: Relaunch[];
100
+ /** This window is the stopped one's session again, and its task is under way. */
101
+ own?: { taskId: string; message?: string };
102
+ }
103
+
104
+ /**
105
+ * Decide what carries on: pure but for reads, so a test can check it. The
106
+ * stopped windows are claimed first (`claimStoppedWindows`), so only one
107
+ * window acts on each.
108
+ */
109
+ export function planRecovery(input: {
110
+ root: string;
111
+ configDir: string;
112
+ sessionId: string;
113
+ windows: readonly WindowRecord[];
114
+ /** Sessions running now in some terminal: never started a second time. */
115
+ running: ReadonlySet<string>;
116
+ }): RecoveryPlan {
117
+ const { root, configDir, sessionId } = input;
118
+ const plan: RecoveryPlan = { windows: [...input.windows], quickfix: [], relaunch: [] };
119
+ const hasOwnPlanner = isPlanningSnapshot(readSessionState(root, configDir, "planner", sessionId));
120
+ const seen = new Set<string>();
121
+ const consider = (session: { sessionId: string; sessionFile?: string; name?: string; working: boolean }) => {
122
+ if (seen.has(session.sessionId) || input.running.has(session.sessionId)) return;
123
+ seen.add(session.sessionId);
124
+ const task = liveTask(root, configDir, session.sessionId);
125
+ if (!task) return;
126
+ const message = session.working && !task.paused ? resumeMessage(task) : undefined;
127
+ if (session.sessionId === sessionId) {
128
+ plan.own = { taskId: task.id, ...(message ? { message } : {}) };
129
+ return;
130
+ }
131
+ if (!session.sessionFile) return;
132
+ plan.relaunch.push({ name: session.name ?? task.title, sessionFile: session.sessionFile, taskId: task.id, ...(message ? { message } : {}) });
133
+ };
134
+ for (const window of input.windows) {
135
+ if (!hasOwnPlanner && !plan.planner && window.sessionId !== sessionId && isPlanningSnapshot(readSessionState(root, configDir, "planner", window.sessionId))) {
136
+ plan.planner = { from: window.sessionId };
137
+ }
138
+ if (window.sessionId !== sessionId && Array.isArray(readSessionState(root, configDir, "quickfix", window.sessionId))) plan.quickfix.push(window.sessionId);
139
+ consider({ sessionId: window.sessionId, ...(window.sessionFile ? { sessionFile: window.sessionFile } : {}), working: window.working });
140
+ for (const background of window.background) consider(background);
141
+ }
142
+ return plan;
143
+ }
144
+
145
+ /** What `recover` needs of the lobby: its paths and session, and how to act. */
146
+ export interface RecoveryHost {
147
+ root: string;
148
+ configDir: string;
149
+ sessionId: string;
150
+ /** Start a stopped session again in the background from its saved file. */
151
+ relaunch(start: Relaunch): BackgroundSession | string;
152
+ /** Restore the planning panel from a snapshot (and run again a round it cut off). */
153
+ restorePlanner(snapshot: PlanningSnapshot): void;
154
+ /** Take up quick-fix jobs a stopped process left; returns how many will run. */
155
+ restoreQuickFixes(jobs: readonly QuickFixJob[]): number;
156
+ /** Send this window's oracle the nudge, once it is idle. */
157
+ nudge(message: string): void;
158
+ }
159
+
160
+ /** What carried on, in words, or undefined when nothing did. */
161
+ export function recoverySummary(parts: { planner: boolean; quickfixes: number; tasks: string[] }): string | undefined {
162
+ const done = [
163
+ parts.planner ? "the planning session" : "",
164
+ parts.quickfixes > 0 ? `${parts.quickfixes} quick fix${parts.quickfixes === 1 ? "" : "es"}` : "",
165
+ parts.tasks.length > 0 ? `${parts.tasks.length === 1 ? "task" : "tasks"} ${parts.tasks.join(", ")}` : "",
166
+ ].filter(Boolean);
167
+ if (done.length === 0) return undefined;
168
+ const list = done.length === 1 ? done[0]! : `${done.slice(0, -1).join(", ")} and ${done.at(-1)}`;
169
+ return `pi stopped unexpectedly — carried on with ${list}`;
170
+ }
171
+
172
+ /**
173
+ * Carry on what the stopped windows were doing, and this session's own kept
174
+ * planning panel and quick-fix queue. Returns the summary to show, if any.
175
+ */
176
+ export function recover(host: RecoveryHost): string | undefined {
177
+ const { root, configDir, sessionId } = host;
178
+ const windows = claimStoppedWindows(root, configDir);
179
+ let running = new Set<string>();
180
+ try {
181
+ running = new Set(livePresence(root, configDir).map((presence) => presence.sessionId));
182
+ } catch {
183
+ // Unreadable heartbeats only mean nothing is ruled out.
184
+ }
185
+ running.delete(sessionId);
186
+ const plan = planRecovery({ root, configDir, sessionId, windows, running });
187
+
188
+ if (plan.planner) moveSessionState(root, configDir, "planner", plan.planner.from, sessionId);
189
+ const snapshot = readSessionState(root, configDir, "planner", sessionId);
190
+ const planner = isPlanningSnapshot(snapshot);
191
+ if (planner) host.restorePlanner(snapshot);
192
+
193
+ const jobs: QuickFixJob[] = [];
194
+ for (const from of [sessionId, ...plan.quickfix]) {
195
+ const kept = readSessionState(root, configDir, "quickfix", from);
196
+ if (Array.isArray(kept)) jobs.push(...(kept as QuickFixJob[]));
197
+ if (from !== sessionId) removeSessionState(root, configDir, "quickfix", from);
198
+ }
199
+ const quickfixes = jobs.length > 0 ? host.restoreQuickFixes(jobs) : 0;
200
+
201
+ const tasks: string[] = [];
202
+ for (const start of plan.relaunch) {
203
+ settleInterrupted(root, configDir, start.taskId);
204
+ const session = host.relaunch(start);
205
+ if (typeof session !== "string") tasks.push(start.taskId);
206
+ }
207
+ if (plan.own) {
208
+ settleInterrupted(root, configDir, plan.own.taskId);
209
+ if (plan.own.message) host.nudge(plan.own.message);
210
+ tasks.push(plan.own.taskId);
211
+ }
212
+ // A restart after a quit restores the panel quietly; only carrying on after a stop is announced.
213
+ return windows.length > 0 ? recoverySummary({ planner, quickfixes, tasks }) : undefined;
214
+ }
215
+
216
+ /** Whether a window in this project stopped without quitting: the lobby then starts on, to carry its work on. */
217
+ export function workToCarryOn(root: string, configDir: string): boolean {
218
+ try {
219
+ return stoppedWindows(root, configDir).length > 0;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ /** A write that waits a moment, so a burst of changes is one write; `flush` writes now. */
226
+ export class Debounced {
227
+ private timer: ReturnType<typeof setTimeout> | undefined;
228
+ private closed = false;
229
+ private readonly write: () => void;
230
+ private readonly delayMs: number;
231
+ constructor(write: () => void, delayMs = SAVE_DELAY_MS) {
232
+ this.write = write;
233
+ this.delayMs = delayMs;
234
+ }
235
+
236
+ schedule(): void {
237
+ if (this.closed || this.timer) return;
238
+ this.timer = setTimeout(() => {
239
+ this.timer = undefined;
240
+ this.run();
241
+ }, this.delayMs);
242
+ this.timer.unref?.();
243
+ }
244
+
245
+ flush(): void {
246
+ if (this.timer) clearTimeout(this.timer);
247
+ this.timer = undefined;
248
+ if (!this.closed) this.run();
249
+ }
250
+
251
+ /** Write once more, then never again (the lobby is stopping). */
252
+ close(): void {
253
+ this.flush();
254
+ this.closed = true;
255
+ }
256
+
257
+ /** Never write again, not even what is waiting. */
258
+ cancel(): void {
259
+ if (this.timer) clearTimeout(this.timer);
260
+ this.timer = undefined;
261
+ this.closed = true;
262
+ }
263
+
264
+ private run(): void {
265
+ try {
266
+ this.write();
267
+ } catch {
268
+ // A read-only project only means the work does not carry on after a crash.
269
+ }
270
+ }
271
+ }
272
+
273
+ /** What a window keeps on disk while the lobby runs, so the next window can carry it on. */
274
+ export interface KeeperSource {
275
+ root: string;
276
+ configDir: string;
277
+ sessionId: () => string;
278
+ sessionFile: () => string | undefined;
279
+ /** This window's oracle is mid-turn. */
280
+ working: () => boolean;
281
+ planner: () => PlanningSession | undefined;
282
+ quickfix: () => readonly QuickFixJob[];
283
+ background: () => readonly BackgroundSession[];
284
+ }
285
+
286
+ /**
287
+ * Keeps this window's record, planning panel and quick-fix queue on disk as
288
+ * they change, for the next window to carry on after a stop.
289
+ */
290
+ export class WindowKeeper {
291
+ private readonly startedAt = new Date().toISOString();
292
+ readonly window: Debounced;
293
+ readonly planner: Debounced;
294
+ readonly quickfix: Debounced;
295
+ /** pi going down on an uncaught error skips the shutdown: what is waiting is written as the process exits. */
296
+ private readonly onExit = () => {
297
+ for (const writer of [this.planner, this.quickfix, this.window]) writer.close();
298
+ };
299
+ private readonly source: KeeperSource;
300
+ constructor(source: KeeperSource) {
301
+ this.source = source;
302
+ process.once("exit", this.onExit);
303
+ this.window = new Debounced(() => writeWindow(source.root, source.configDir, this.record()));
304
+ this.planner = new Debounced(() => {
305
+ const session = source.planner();
306
+ if (session) this.keepPlanner(session, session.snapshot());
307
+ });
308
+ this.quickfix = new Debounced(() => {
309
+ const jobs = source.quickfix();
310
+ if (jobs.length > 0) writeSessionState(source.root, source.configDir, "quickfix", source.sessionId(), jobs);
311
+ else removeSessionState(source.root, source.configDir, "quickfix", source.sessionId());
312
+ });
313
+ }
314
+
315
+ /** The panel as this window's (to carry on) and among the project's plans (to read again later). */
316
+ private keepPlanner(session: PlanningSession, snapshot: PlanningSnapshot): void {
317
+ writeSessionState(this.source.root, this.source.configDir, "planner", this.source.sessionId(), snapshot);
318
+ keepPlan(this.source.root, this.source.configDir, session.id, session.createdAt, snapshot);
319
+ }
320
+
321
+ private record(): Omit<WindowRecord, "pid" | "token" | "updatedAt"> {
322
+ const file = this.source.sessionFile();
323
+ return {
324
+ sessionId: this.source.sessionId(),
325
+ ...(file ? { sessionFile: file } : {}),
326
+ working: this.source.working(),
327
+ background: backgroundRecords(this.source.background(), this.source.root),
328
+ startedAt: this.startedAt,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * The lobby is stopping. `carryOn` (pi stopped on a signal, or is going
334
+ * down unexpectedly) keeps everything as it stands for the next window; a
335
+ * stop on purpose (quit, switching sessions, turning bot-lobby off) records
336
+ * what was running as stopped and removes the window's record.
337
+ */
338
+ stop(carryOn: boolean): void {
339
+ process.off("exit", this.onExit);
340
+ if (carryOn) {
341
+ this.planner.close();
342
+ this.quickfix.close();
343
+ this.window.close();
344
+ return;
345
+ }
346
+ const { root, configDir } = this.source;
347
+ const sessionId = this.source.sessionId();
348
+ try {
349
+ const session = this.source.planner();
350
+ if (session) this.keepPlanner(session, stoppedSnapshot(session.snapshot()));
351
+ const jobs = this.source.quickfix().map(stoppedJob);
352
+ if (jobs.length > 0) writeSessionState(root, configDir, "quickfix", sessionId, jobs);
353
+ else removeSessionState(root, configDir, "quickfix", sessionId);
354
+ removeWindow(root, configDir);
355
+ } catch {
356
+ // A read-only project keeps nothing.
357
+ }
358
+ for (const writer of [this.planner, this.quickfix, this.window]) writer.cancel();
359
+ }
360
+ }
361
+
362
+ /** What a stop on purpose leaves of a planning round: stopped, and retryable. */
363
+ export function stoppedSnapshot(snapshot: PlanningSnapshot): PlanningSnapshot {
364
+ if (!snapshot.running) return snapshot;
365
+ return { ...snapshot, running: false, members: snapshot.members.map((member) => member.status === "thinking" ? { ...member, status: "failed", error: "stopped" } : member), error: STOPPED_ROUND };
366
+ }
367
+
368
+ /** What a stop on purpose leaves of a quick fix that was queued or running: cancelled. */
369
+ export function stoppedJob(job: QuickFixJob): QuickFixJob {
370
+ if (job.status !== "queued" && job.status !== "running") return job;
371
+ return { ...job, status: "cancelled", note: "stopped when the lobby stopped", ...(job.finishedAt ? {} : { finishedAt: Date.now() }) };
372
+ }
373
+
374
+ /** A round stopped by a quit, as the Plan tab shows it. */
375
+ export const STOPPED_ROUND = "stopped when the lobby stopped — retry runs the round again";
376
+
377
+ /** The background sessions worth carrying on: running, saved to a file, in this project. */
378
+ export function backgroundRecords(sessions: readonly BackgroundSession[], root: string): BackgroundRecord[] {
379
+ return sessions
380
+ .filter((session) => session.alive && session.sessionId && session.sessionFile && (!session.projectRoot || session.projectRoot === root))
381
+ .map((session) => ({ sessionId: session.sessionId!, sessionFile: session.sessionFile!, name: session.name, working: session.busy || session.dialogs.length > 0 }));
382
+ }
383
+
384
+ /**
385
+ * Whether pi is stopping on a signal (a closed terminal, a kill) rather than a
386
+ * quit the user asked for: such a stop carries on in the next window. pi turns
387
+ * both into the same `quit` shutdown, so the signal is heard here first. When
388
+ * pi's own handlers are gone, the signal is passed on, so it still stops pi.
389
+ */
390
+ let signalled = false;
391
+ let listening = false;
392
+
393
+ export function stoppingOnSignal(): boolean {
394
+ return signalled;
395
+ }
396
+
397
+ export function listenForSignals(): void {
398
+ if (listening) return;
399
+ listening = true;
400
+ const signals: NodeJS.Signals[] = process.platform === "win32" ? ["SIGTERM"] : ["SIGTERM", "SIGHUP"];
401
+ for (const signal of signals) {
402
+ const handler = () => {
403
+ signalled = true;
404
+ if (process.listenerCount(signal) > 1) return;
405
+ process.off(signal, handler);
406
+ process.kill(process.pid, signal);
407
+ };
408
+ process.on(signal, handler);
409
+ }
410
+ }
411
+
412
+ /** Forget a heard signal (tests). */
413
+ export function resetSignals(): void {
414
+ signalled = false;
415
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Resuming a task from the Tasks screen, without moving this window. A paused
3
+ * task is unpaused where it runs (this window, a background session, another
4
+ * terminal) and its oracle told to carry on. A task no running session drives
5
+ * carries on in a new background session: its old session started again from
6
+ * its file when there is one (the oracle keeps its conversation), else a fresh
7
+ * session that takes the task over (`/bot-lobby carry-on`).
8
+ */
9
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
10
+
11
+ /** The sessions that could be driving a task, as this window sees them. */
12
+ export interface Drivers {
13
+ /** This window's session. */
14
+ me?: string;
15
+ /** Sessions this window runs in the background, alive. */
16
+ background: ReadonlySet<string>;
17
+ /** Tasks a background session of this window was started to carry on (it may not have its id yet). */
18
+ carrying: ReadonlySet<string>;
19
+ /** Sessions running in other terminals. */
20
+ live: ReadonlySet<string>;
21
+ }
22
+
23
+ export type Where = "here" | "background" | "elsewhere";
24
+
25
+ export type ResumeRoute =
26
+ | { kind: "finished" }
27
+ /** Under way and not paused: nothing to resume. */
28
+ | { kind: "running"; where: Where }
29
+ | { kind: "paused"; where: Where }
30
+ /** No running session drives it. */
31
+ | { kind: "stopped" };
32
+
33
+ function drivenFrom(task: Task, drivers: Drivers): Where | undefined {
34
+ const owner = task.ownerSessionId;
35
+ if (owner && owner === drivers.me) return "here";
36
+ if ((owner && drivers.background.has(owner)) || drivers.carrying.has(task.id)) return "background";
37
+ if (owner && drivers.live.has(owner)) return "elsewhere";
38
+ return undefined;
39
+ }
40
+
41
+ export function resumeRoute(task: Task, drivers: Drivers): ResumeRoute {
42
+ if (TERMINAL_STATES.includes(task.state)) return { kind: "finished" };
43
+ const where = drivenFrom(task, drivers);
44
+ if (!where) return { kind: "stopped" };
45
+ return { kind: task.paused ? "paused" : "running", where };
46
+ }
47
+
48
+ /** Whether the Tasks screen offers Resume: the task is paused, or nothing runs it. */
49
+ export function resumable(task: Task, drivers: Drivers): boolean {
50
+ const { kind } = resumeRoute(task, drivers);
51
+ return kind === "paused" || kind === "stopped";
52
+ }
53
+
54
+ /** The decision a task records when it carries on in a new session. */
55
+ export const RESUMED_DECISION = "resumed from the Tasks screen in a background session; the session that drove it had stopped (steps that were running stopped with it)";
56
+
57
+ /** What the oracle is told when its task is resumed. */
58
+ export function carryOnMessage(task: Pick<Task, "id" | "state">, after: "paused" | "stopped"): string {
59
+ if (after === "paused") {
60
+ return `bot-lobby: the user resumed ${task.id} (${task.state}) from the Tasks screen. Carry on: read where it stands with the orchestrate tool (action=status) and go on from there.`;
61
+ }
62
+ return [
63
+ `bot-lobby: the user resumed ${task.id} (${task.state}) from the Tasks screen; the session that drove it had stopped.`,
64
+ "Pick up where it left off: read where the task stands with the orchestrate tool (action=status), run again any step that was cut off (its agent stopped with that session, and its edits may be half done), ask again any question that was not answered, and carry on.",
65
+ ].join(" ");
66
+ }
@@ -22,18 +22,19 @@ import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
22
22
  import { checkThinking } from "../pi/model-support.ts";
23
23
  import { QuickFixQueue } from "./quickfix.ts";
24
24
  import type { Asker } from "../ask/types.ts";
25
- import type { PlanningSession } from "./planner.ts";
26
25
  import { execCommand, IssuesState } from "./issues.ts";
27
26
  import { PullsState } from "./pulls.ts";
28
27
  import { PullReviews } from "./pr-review.ts";
29
28
  import { KnowledgeBook } from "./knowledge.ts";
30
29
  import { ExcalidrawBook } from "../excalidraw/sessions.ts";
31
30
  import type { LobbyService } from "./host.ts";
32
- import { createLobbyService, lobbyNotify, lobbyProfile, reviewProfile, seatProfile, setServiceState, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel } from "./service.ts";
31
+ import { backgroundSessions, createLobbyService, lobbyNotify, lobbyProfile, plannerDeps, resumeSession, reviewProfile, seatProfile, setServiceState, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel } from "./service.ts";
33
32
  import { lobbyTopics } from "./topics.ts";
34
33
  export { backgroundSessions, setSessionLauncher } from "./service.ts";
35
34
  import { deliverComments, onOwnerEvent } from "../pi/owner.ts";
36
35
  import { pushNotice } from "../webui/notices.ts";
36
+ import { listenForSignals, NUDGE_DELAY_MS, recover, stoppingOnSignal, WindowKeeper } from "./recovery.ts";
37
+ import { PlanningSession, type PlanningSnapshot } from "./planner.ts";
37
38
 
38
39
  export { deliverComments };
39
40
 
@@ -57,6 +58,10 @@ export interface Runtime {
57
58
  /** The repository (or folder) and branch the page's title shows, and whether git is being asked now. */
58
59
  workspace: WorkspaceInfo;
59
60
  readingWorkspace: boolean;
61
+ /** Keeps what this window does on disk, so the next window carries it on after a crash. */
62
+ keeper?: WindowKeeper;
63
+ /** What this window carried on from one that stopped, as the page announces it once. */
64
+ recovered?: { at: number; text: string };
60
65
  }
61
66
 
62
67
  let runtime: Runtime | undefined;
@@ -89,12 +94,18 @@ export function lobbyWorkRunning(): string | undefined {
89
94
 
90
95
  /** Stop the lobby's backend (bot-lobby turned off, or the session ended). */
91
96
  export function stopLobbyService(): void {
92
- shutdown();
97
+ shutdown(false);
93
98
  }
94
99
 
95
- function shutdown(): void {
100
+ /**
101
+ * Stop the lobby. `carryOn` (pi is stopping on a signal) keeps what it was
102
+ * doing on disk for the next window; any other stop records it as stopped.
103
+ */
104
+ function shutdown(carryOn: boolean): void {
96
105
  const state = runtime;
97
106
  if (!state) return;
107
+ // Before anything is cancelled, so what is kept is what was running.
108
+ state.keeper?.stop(carryOn);
98
109
  runtime = undefined;
99
110
  lobbyService = undefined;
100
111
  setServiceState(undefined);
@@ -129,7 +140,7 @@ function handToQuickFix(state: Runtime, request: string, builder: Domain | undef
129
140
  * every interactive master session, not a subagent.
130
141
  */
131
142
  export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): Runtime | undefined {
132
- shutdown();
143
+ shutdown(false);
133
144
  if (isSubagentProcess() || !servesPage(ctx)) return undefined;
134
145
  const root = detectProjectRoot(ctx.cwd, configDir);
135
146
  const workflow = loadConfig().workflow;
@@ -175,7 +186,10 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
175
186
  stallTimeoutMs: workflow.stallTimeoutMs,
176
187
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
177
188
  feed: lobbyFeed,
178
- onChange: () => lobbyTopics.bump("quickfix"),
189
+ onChange: () => {
190
+ lobbyTopics.bump("quickfix");
191
+ state.keeper?.quickfix.schedule();
192
+ },
179
193
  notify: (message, level) => lobbyNotify(state, message, level),
180
194
  hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
181
195
  classifier: classifier(),
@@ -196,6 +210,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
196
210
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
197
211
  lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
198
212
  state.unsubscribeFeed = lobbyFeed.onChange(() => lobbyTopics.bump("lobby"));
213
+ carryOn(state);
199
214
  onRunUpdates((runs) => {
200
215
  lobbyFeed.runs(runs);
201
216
  lobbyTopics.bump("tasks");
@@ -212,6 +227,61 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
212
227
  return state;
213
228
  }
214
229
 
230
+ /**
231
+ * Keep this window's work on disk from now on, and carry on what a window
232
+ * that stopped unexpectedly was doing (src/lobby/recovery.ts).
233
+ */
234
+ function carryOn(state: Runtime): void {
235
+ listenForSignals();
236
+ const { root, configDir, ctx } = state;
237
+ state.keeper = new WindowKeeper({
238
+ root,
239
+ configDir,
240
+ sessionId: () => state.ctx.sessionManager.getSessionId(),
241
+ sessionFile: () => state.ctx.sessionManager.getSessionFile?.(),
242
+ working: () => !state.ctx.isIdle(),
243
+ planner: () => state.planner,
244
+ quickfix: () => state.quickfix.snapshot(),
245
+ background: () => backgroundSessions(),
246
+ });
247
+ let summary: string | undefined;
248
+ try {
249
+ summary = recover({
250
+ root,
251
+ configDir,
252
+ sessionId: ctx.sessionManager.getSessionId(),
253
+ relaunch: (start) => resumeSession(state, start),
254
+ restorePlanner: (snapshot) => restorePlanner(state, snapshot),
255
+ restoreQuickFixes: (jobs) => state.quickfix.restore(jobs).length,
256
+ nudge: (message) => nudgeWhenIdle(state, message),
257
+ });
258
+ } catch (error) {
259
+ lobbyFeed.log("LOBBY", `could not carry on what the stopped session was doing: ${(error as Error).message}`, "warning");
260
+ }
261
+ state.keeper.window.flush();
262
+ if (!summary) return;
263
+ state.recovered = { at: Date.now(), text: summary };
264
+ lobbyFeed.log("LOBBY", summary, "success");
265
+ state.ctx.ui.notify(`bot-lobby: ${summary}`, "info");
266
+ lobbyTopics.bump("status");
267
+ }
268
+
269
+ /** The planning panel as a kept snapshot left it; a round pi cut off runs again. */
270
+ function restorePlanner(state: Runtime, snapshot: PlanningSnapshot): void {
271
+ state.planner = PlanningSession.restore(plannerDeps(state, snapshot.seats), snapshot);
272
+ lobbyTopics.bump("planner");
273
+ void state.planner.resume().catch(() => {});
274
+ }
275
+
276
+ /** Pick this window's interrupted turn back up once pi has settled, unless the user has already started one. */
277
+ function nudgeWhenIdle(state: Runtime, message: string): void {
278
+ const timer = setTimeout(() => {
279
+ if (runtime !== state || !state.ctx.isIdle()) return;
280
+ state.pi.sendUserMessage(message);
281
+ }, NUDGE_DELAY_MS);
282
+ timer.unref?.();
283
+ }
284
+
215
285
  /**
216
286
  * Whether this pi process serves the web page: an interactive session. A
217
287
  * one-shot run has nobody to answer, and the background sessions the lobby
@@ -252,7 +322,8 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
252
322
  // Off (the default for a new session) runs no lobby until bot-lobby is turned on (see pi/switch.ts).
253
323
  if (!isMinimized()) startLobbyService(pi, ctx, configDir);
254
324
  });
255
- pi.on("session_shutdown", () => shutdown());
325
+ // A quit on a signal (a closed terminal, a kill) is not one the user asked for: its work carries on in the next window.
326
+ pi.on("session_shutdown", (event) => shutdown(event.reason === "quit" && stoppingOnSignal()));
256
327
  pi.on("ui_prompt_start", () => {
257
328
  lobbyTopics.bump("status");
258
329
  });
@@ -261,6 +332,7 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
261
332
  });
262
333
  pi.on("agent_start", (_event, ctx) => {
263
334
  track(ctx);
335
+ runtime?.keeper?.window.schedule();
264
336
  lobbyTopics.bump("status");
265
337
  const taskId = currentZenTask()?.id;
266
338
  turn = { startedAt: Date.now(), ...(taskId ? { taskId } : {}), ...(ctx.model ? { model: modelRef(ctx.model) } : {}), thinking: pi.getThinkingLevel(), tools: 0, turns: 0, input: 0, output: 0, cost: 0 };
@@ -284,6 +356,7 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
284
356
  });
285
357
  pi.on("agent_end", (event, ctx) => {
286
358
  track(ctx);
359
+ runtime?.keeper?.window.schedule();
287
360
  const finished = turn;
288
361
  turn = undefined;
289
362
  lobbyFeed.replyEnd();