@a-t-h-i/bot-lobby 0.6.26 → 0.6.28
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.
- package/README.md +36 -11
- package/package.json +1 -1
- package/prompts/master.md +24 -3
- package/prompts/qa.md +139 -77
- package/prompts/reviewer.md +4 -0
- package/src/classifier/qa-risk.ts +537 -0
- package/src/lobby/host.ts +19 -0
- package/src/lobby/planner.ts +145 -0
- package/src/lobby/quickfix.ts +40 -1
- package/src/lobby/recovery.ts +415 -0
- package/src/lobby/resume.ts +66 -0
- package/src/lobby/runtime.ts +80 -7
- package/src/lobby/service.ts +196 -11
- package/src/lobby/sessions.ts +13 -3
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/decisions.ts +5 -2
- package/src/master/master.ts +6 -0
- package/src/pi/commands.ts +38 -2
- package/src/pi/events.ts +10 -6
- package/src/pi/start-task.ts +31 -4
- package/src/pi/switch.ts +7 -2
- package/src/pi/tools.ts +3 -0
- package/src/pi/ui.ts +9 -2
- package/src/schemas/configuration.ts +2 -2
- package/src/schemas/task.ts +32 -0
- package/src/state/backlog.ts +19 -3
- package/src/state/plan-history.ts +142 -0
- package/src/state/recovery.ts +170 -0
- package/src/state/themes.ts +127 -0
- package/src/webui/api/index.ts +24 -2
- package/src/webui/api/planner.ts +47 -1
- package/src/webui/api/status.ts +2 -0
- package/src/webui/api/tasks.ts +36 -5
- package/src/webui/api/themes.ts +37 -0
- package/src/webui/command.ts +26 -1
- package/src/webui/dev/fake-service.ts +60 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/palette-core.ts +260 -0
- package/src/webui/protocol.ts +59 -0
- package/src/webui/server.ts +10 -0
- package/src/workflow/track.ts +46 -7
- package/src/workflow/workflow.ts +196 -34
- package/webui/dist/assets/index-BzCxEueR.css +2 -0
- package/webui/dist/assets/index-qisGNoCh.js +108 -0
- package/webui/dist/build.json +51 -35
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-C1IwecNS.css +0 -2
- package/webui/dist/assets/index-C4Ww36TW.js +0 -105
package/src/lobby/planner.ts
CHANGED
|
@@ -522,6 +522,49 @@ export function memberPrompt(member: PanelMember, instructions?: string): string
|
|
|
522
522
|
return [base, loadPrompt("panel.md"), `## Your seat\n\n${MEMBER_SEATS[member]}`].join("\n\n---\n\n");
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
/** A planning session as JSON: what `PlanningSession.snapshot` writes and `restore` reads. */
|
|
526
|
+
export interface PlanningSnapshot {
|
|
527
|
+
version: 1;
|
|
528
|
+
/** Absent on snapshots kept before sessions had ids: a restore gives them one. */
|
|
529
|
+
id?: string;
|
|
530
|
+
createdAt?: number;
|
|
531
|
+
messages: PlannerMessage[];
|
|
532
|
+
seed?: PlannerSeed;
|
|
533
|
+
reply?: PlannerReply;
|
|
534
|
+
questions: PanelQuestion[];
|
|
535
|
+
notes: PanelNote[];
|
|
536
|
+
members: MemberState[];
|
|
537
|
+
seats: PanelMember[];
|
|
538
|
+
pins: PanelMember[];
|
|
539
|
+
satOut: Array<[PanelMember, number]>;
|
|
540
|
+
lastStatus: Array<[PanelMember, "ready" | "open"]>;
|
|
541
|
+
sat: PanelMember[];
|
|
542
|
+
memberNotes: Array<[PanelMember, string[]]>;
|
|
543
|
+
turns: number;
|
|
544
|
+
mode: RoundMode;
|
|
545
|
+
answered: AskResult[];
|
|
546
|
+
lineComments: LineComment[];
|
|
547
|
+
saved?: PlannedTask;
|
|
548
|
+
savedParts: PlannedTask[];
|
|
549
|
+
error?: string;
|
|
550
|
+
autoContinued: boolean;
|
|
551
|
+
attempts: number;
|
|
552
|
+
/** A round was running when it was taken. */
|
|
553
|
+
running: boolean;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** What a seat that was thinking when pi stopped shows until its round runs again. */
|
|
557
|
+
export const STOPPED_WITH_PI = "pi stopped during the round";
|
|
558
|
+
|
|
559
|
+
/** Whether a parsed file is a snapshot this version reads; a torn or foreign file is not. */
|
|
560
|
+
export function isPlanningSnapshot(value: unknown): value is PlanningSnapshot {
|
|
561
|
+
const snapshot = value as PlanningSnapshot;
|
|
562
|
+
return Boolean(snapshot) && snapshot.version === 1 && Array.isArray(snapshot.messages) && Array.isArray(snapshot.questions) && Array.isArray(snapshot.seats)
|
|
563
|
+
&& Array.isArray(snapshot.members) && Array.isArray(snapshot.pins) && Array.isArray(snapshot.satOut) && Array.isArray(snapshot.lastStatus)
|
|
564
|
+
&& Array.isArray(snapshot.sat) && Array.isArray(snapshot.memberNotes) && Array.isArray(snapshot.answered) && Array.isArray(snapshot.lineComments)
|
|
565
|
+
&& Array.isArray(snapshot.savedParts) && Array.isArray(snapshot.notes) && typeof snapshot.turns === "number";
|
|
566
|
+
}
|
|
567
|
+
|
|
525
568
|
interface RunOutcome {
|
|
526
569
|
status: MetricRecord["status"];
|
|
527
570
|
output: string;
|
|
@@ -530,7 +573,15 @@ interface RunOutcome {
|
|
|
530
573
|
usage: { input: number; output: number; cost: number; turns: number };
|
|
531
574
|
}
|
|
532
575
|
|
|
576
|
+
/** A new planning session's id: `PS-` and the time, so ids sort by when they began. */
|
|
577
|
+
export function planningId(now = Date.now()): string {
|
|
578
|
+
return `PS-${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
533
581
|
export class PlanningSession {
|
|
582
|
+
/** Names the session among the project's plans (src/state/plan-history.ts). */
|
|
583
|
+
id = planningId();
|
|
584
|
+
createdAt = Date.now();
|
|
534
585
|
messages: PlannerMessage[] = [];
|
|
535
586
|
seed?: PlannerSeed;
|
|
536
587
|
/** The oracle's latest reply; its plan is the current draft. */
|
|
@@ -563,6 +614,8 @@ export class PlanningSession {
|
|
|
563
614
|
satOut = new Map<PanelMember, number>();
|
|
564
615
|
/** How each seat left the last round it sat. */
|
|
565
616
|
private readonly lastStatus = new Map<PanelMember, "ready" | "open">();
|
|
617
|
+
/** Seats that answered in at least one round: who took part in the plan. */
|
|
618
|
+
private readonly sat = new Set<PanelMember>();
|
|
566
619
|
/** The last round was started by the classifier settling every question; the next one waits for the user. */
|
|
567
620
|
private autoContinued = false;
|
|
568
621
|
private controller?: AbortController;
|
|
@@ -707,6 +760,95 @@ export class PlanningSession {
|
|
|
707
760
|
this.controller?.abort();
|
|
708
761
|
}
|
|
709
762
|
|
|
763
|
+
/**
|
|
764
|
+
* The seats that took part in the plan: each answered in a round and is
|
|
765
|
+
* still on the panel. One the classifier always left out, or the user
|
|
766
|
+
* unseated, sat the plan out, and the task it becomes runs without it.
|
|
767
|
+
*/
|
|
768
|
+
get panel(): PanelMember[] {
|
|
769
|
+
return PANEL_MEMBERS.filter((member) => this.sat.has(member) && this.seats.has(member));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Everything the session needs to carry on in another pi process, as JSON. */
|
|
773
|
+
snapshot(): PlanningSnapshot {
|
|
774
|
+
return {
|
|
775
|
+
version: 1,
|
|
776
|
+
id: this.id,
|
|
777
|
+
createdAt: this.createdAt,
|
|
778
|
+
messages: this.messages,
|
|
779
|
+
...(this.seed ? { seed: this.seed } : {}),
|
|
780
|
+
...(this.reply ? { reply: this.reply } : {}),
|
|
781
|
+
questions: this.questions,
|
|
782
|
+
notes: this.notes,
|
|
783
|
+
members: this.members,
|
|
784
|
+
seats: [...this.seats],
|
|
785
|
+
pins: [...this.pins],
|
|
786
|
+
satOut: [...this.satOut],
|
|
787
|
+
lastStatus: [...this.lastStatus],
|
|
788
|
+
sat: [...this.sat],
|
|
789
|
+
memberNotes: [...this.memberNotes],
|
|
790
|
+
turns: this.turns,
|
|
791
|
+
mode: this.mode,
|
|
792
|
+
answered: this.answered,
|
|
793
|
+
lineComments: this.lineComments,
|
|
794
|
+
...(this.saved ? { saved: this.saved } : {}),
|
|
795
|
+
savedParts: this.savedParts,
|
|
796
|
+
...(this.error ? { error: this.error } : {}),
|
|
797
|
+
autoContinued: this.autoContinued,
|
|
798
|
+
attempts: this.attempts,
|
|
799
|
+
running: this.busy,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* A session as a snapshot left it. A round that was running when pi
|
|
805
|
+
* stopped is marked interrupted: `resume` runs it again.
|
|
806
|
+
*/
|
|
807
|
+
static restore(deps: PlannerDeps, snapshot: PlanningSnapshot): PlanningSession {
|
|
808
|
+
const session = new PlanningSession({ ...deps, panel: snapshot.seats }, snapshot.seed);
|
|
809
|
+
if (snapshot.id) session.id = snapshot.id;
|
|
810
|
+
if (typeof snapshot.createdAt === "number") session.createdAt = snapshot.createdAt;
|
|
811
|
+
session.messages = snapshot.messages;
|
|
812
|
+
if (snapshot.reply) session.reply = snapshot.reply;
|
|
813
|
+
session.questions = snapshot.questions;
|
|
814
|
+
session.notes = snapshot.notes;
|
|
815
|
+
session.members = snapshot.members.map((member) => member.status === "thinking" ? { ...member, status: "failed", error: STOPPED_WITH_PI, step: undefined } : member);
|
|
816
|
+
for (const member of snapshot.pins) session.pins.add(member);
|
|
817
|
+
session.satOut = new Map(snapshot.satOut);
|
|
818
|
+
for (const [member, status] of snapshot.lastStatus) session.lastStatus.set(member, status);
|
|
819
|
+
for (const member of snapshot.sat) session.sat.add(member);
|
|
820
|
+
for (const [member, notes] of snapshot.memberNotes) session.memberNotes.set(member, notes);
|
|
821
|
+
session.turns = snapshot.turns;
|
|
822
|
+
session.mode = snapshot.mode;
|
|
823
|
+
session.answered = snapshot.answered;
|
|
824
|
+
session.lineComments = snapshot.lineComments;
|
|
825
|
+
if (snapshot.saved) session.saved = snapshot.saved;
|
|
826
|
+
session.savedParts = snapshot.savedParts;
|
|
827
|
+
if (snapshot.error) session.error = snapshot.error;
|
|
828
|
+
session.autoContinued = snapshot.autoContinued;
|
|
829
|
+
session.attempts = snapshot.attempts;
|
|
830
|
+
session.interrupted = snapshot.running;
|
|
831
|
+
return session;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** A round was running when pi stopped, and has not run again yet. */
|
|
835
|
+
interrupted = false;
|
|
836
|
+
|
|
837
|
+
/** Run again the round pi stopped in the middle of; nothing when none was. */
|
|
838
|
+
async resume(): Promise<void> {
|
|
839
|
+
if (!this.interrupted || this.busy) return;
|
|
840
|
+
this.interrupted = false;
|
|
841
|
+
const last = this.messages.at(-1);
|
|
842
|
+
if (last?.role === "you") return this.retry();
|
|
843
|
+
// The seed alone had started the first round.
|
|
844
|
+
if (this.messages.length === 0 && this.seed) {
|
|
845
|
+
this.turns = Math.max(0, this.turns - 1);
|
|
846
|
+
return this.open();
|
|
847
|
+
}
|
|
848
|
+
// The round had already written its reply: only its last checks were cut off.
|
|
849
|
+
this.deps.onChange?.();
|
|
850
|
+
}
|
|
851
|
+
|
|
710
852
|
/** Save the latest draft as a pending task. */
|
|
711
853
|
save(now = new Date()): PlannedTask {
|
|
712
854
|
const plan = this.reply?.plan;
|
|
@@ -715,6 +857,7 @@ export class PlanningSession {
|
|
|
715
857
|
title: this.title ?? this.messages[0]?.text.split("\n")[0] ?? "planned task",
|
|
716
858
|
brief: plan,
|
|
717
859
|
...(this.seed ? { issue: this.seed.issue } : {}),
|
|
860
|
+
panel: this.panel,
|
|
718
861
|
}, now);
|
|
719
862
|
this.deps.feed?.log(ORACLE_LABEL, `saved ${this.saved.id} to pending tasks`, "success");
|
|
720
863
|
this.deps.onChange?.();
|
|
@@ -733,6 +876,7 @@ export class PlanningSession {
|
|
|
733
876
|
brief: partBrief({ plan, steps, proposal, index }),
|
|
734
877
|
...(this.seed ? { issue: this.seed.issue } : {}),
|
|
735
878
|
split: partInfo(proposal, index, group),
|
|
879
|
+
panel: this.panel,
|
|
736
880
|
}, new Date(now.getTime() + (proposal.tasks.length - index))));
|
|
737
881
|
this.savedParts = parts;
|
|
738
882
|
this.saved = parts[0];
|
|
@@ -966,6 +1110,7 @@ export class PlanningSession {
|
|
|
966
1110
|
const transcript = [plannerTranscript(this.messages, this.seed, this.reply?.plan, ""), likely].filter(Boolean).join("\n\n");
|
|
967
1111
|
const outcomes = await Promise.all(this.members.map((state) => this.runMember(state, transcript, controller.signal, effort)));
|
|
968
1112
|
if (controller.signal.aborted) throw new Error("stopped");
|
|
1113
|
+
for (const outcome of outcomes) if (outcome.reply) this.sat.add(outcome.member);
|
|
969
1114
|
this.notes = PANEL_MEMBERS.flatMap((member) => (this.memberNotes.get(member) ?? []).map((text) => ({ from: MEMBER_LABELS[member], text })));
|
|
970
1115
|
this.step = "writing the plan";
|
|
971
1116
|
this.deps.onChange?.();
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -65,6 +65,8 @@ export interface QuickFixJob {
|
|
|
65
65
|
profile?: QuickFixProfile;
|
|
66
66
|
/** The oracle sent it here from a request typed to the lobby. */
|
|
67
67
|
routed?: boolean;
|
|
68
|
+
/** An earlier run was cut off when pi stopped: some of its edits may already be in the tree. */
|
|
69
|
+
interrupted?: boolean;
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/** How a job enters the queue besides its prompt. */
|
|
@@ -111,6 +113,10 @@ export interface QuickFixDeps {
|
|
|
111
113
|
|
|
112
114
|
export const MAX_JOBS = 30;
|
|
113
115
|
export const MAX_STEPS = 200;
|
|
116
|
+
/** Steps a snapshot keeps per job: enough to read what it did. */
|
|
117
|
+
const MAX_KEPT_STEPS = 40;
|
|
118
|
+
/** What a job pi stopped in the middle of is told when it runs again. */
|
|
119
|
+
export const INTERRUPTED_NOTE = "An earlier run of this request was cut off when pi stopped, so some of its edits may already be in the tree: look at the files before you change them, and finish the request.";
|
|
114
120
|
|
|
115
121
|
function isActive(job: QuickFixJob): boolean {
|
|
116
122
|
return job.status === "queued" || job.status === "running";
|
|
@@ -202,6 +208,39 @@ export class QuickFixQueue {
|
|
|
202
208
|
return true;
|
|
203
209
|
}
|
|
204
210
|
|
|
211
|
+
/** The queue as JSON, for the next pi process to carry on from. */
|
|
212
|
+
snapshot(): QuickFixJob[] {
|
|
213
|
+
return this.jobs.map((job) => ({ ...job, steps: job.steps.slice(-MAX_KEPT_STEPS) }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Take up a queue an earlier pi process left: finished jobs stay as they
|
|
218
|
+
* ended, and the one that was running when pi stopped goes back to the front
|
|
219
|
+
* of the queue, told that it was cut off. Returns the jobs that will run.
|
|
220
|
+
*/
|
|
221
|
+
restore(jobs: readonly QuickFixJob[]): QuickFixJob[] {
|
|
222
|
+
const kept = jobs.map((job): QuickFixJob => job.status !== "running" ? { ...job } : {
|
|
223
|
+
...job,
|
|
224
|
+
status: "queued",
|
|
225
|
+
interrupted: true,
|
|
226
|
+
note: "pi stopped while this ran; it runs again from where the tree is",
|
|
227
|
+
steps: job.steps.map((step) => ({ ...step, pending: false })),
|
|
228
|
+
});
|
|
229
|
+
// Queues from more than one stopped window may share ids: a repeated one is numbered on.
|
|
230
|
+
const number = (job: QuickFixJob) => Number(/^QF-(\d+)$/.exec(job.id)?.[1] ?? 0);
|
|
231
|
+
this.counter = Math.max(this.counter, ...this.jobs.map(number), ...kept.map(number));
|
|
232
|
+
const taken = new Set(this.jobs.map((job) => job.id));
|
|
233
|
+
for (const job of kept) {
|
|
234
|
+
if (taken.has(job.id)) job.id = `QF-${++this.counter}`;
|
|
235
|
+
taken.add(job.id);
|
|
236
|
+
}
|
|
237
|
+
const resumed = kept.filter((job) => job.interrupted && job.status === "queued");
|
|
238
|
+
this.jobs = [...resumed, ...kept.filter((job) => !resumed.includes(job)), ...this.jobs];
|
|
239
|
+
this.changed();
|
|
240
|
+
this.pump();
|
|
241
|
+
return this.jobs.filter(isActive);
|
|
242
|
+
}
|
|
243
|
+
|
|
205
244
|
/** Abort everything (session shutdown). */
|
|
206
245
|
cancelAll(): void {
|
|
207
246
|
for (const job of this.jobs) if (job.status === "queued") job.status = "cancelled";
|
|
@@ -259,7 +298,7 @@ export class QuickFixQueue {
|
|
|
259
298
|
const attempt = (model: string | undefined, thinking: string) => runPiAgent(
|
|
260
299
|
{
|
|
261
300
|
cwd: this.deps.cwd,
|
|
262
|
-
task:
|
|
301
|
+
task: [job.prompt, job.interrupted ? INTERRUPTED_NOTE : "", likely].filter(Boolean).join("\n\n"),
|
|
263
302
|
systemPrompt: quickFixPrompt(profile.instructions),
|
|
264
303
|
tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
|
|
265
304
|
...grantOption("quickfix"),
|
|
@@ -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
|
+
}
|