@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
|
@@ -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
|
+
}
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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();
|
package/src/lobby/service.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { livePresence } from "../state/presence.ts";
|
|
|
24
24
|
import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
|
|
25
25
|
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
26
26
|
import { readClassifierMetrics, readMetrics } from "../state/metrics.ts";
|
|
27
|
-
import { currentZenTask, taskSnapshot } from "../pi/ui.ts";
|
|
27
|
+
import { applyStatus, currentZenTask, taskSnapshot } from "../pi/ui.ts";
|
|
28
28
|
import { taskName } from "../text.ts";
|
|
29
29
|
import { describeWorkspace } from "../execution/workspace.ts";
|
|
30
30
|
import { stripStartFlags } from "../pi/start-flags.ts";
|
|
@@ -40,7 +40,10 @@ import { SessionChats } from "./session-files.ts";
|
|
|
40
40
|
import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
|
|
41
41
|
import { askUser } from "../ask/web.ts";
|
|
42
42
|
import type { Asker } from "../ask/types.ts";
|
|
43
|
-
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
43
|
+
import { PlanningSession, type PlannerDeps, type PlannerSeed } from "./planner.ts";
|
|
44
|
+
import { settleInterrupted, type Relaunch } from "./recovery.ts";
|
|
45
|
+
import { carryOnMessage, resumeRoute, RESUMED_DECISION, type Drivers } from "./resume.ts";
|
|
46
|
+
import { archivePlan as archivePlanOnDisk, deletePlan as deletePlanOnDisk, isPlanId, previousPlans, readPlan, type PlanRecord, type PlanSummary } from "../state/plan-history.ts";
|
|
44
47
|
import { applyMasterModel } from "../pi/model-settings.ts";
|
|
45
48
|
import { lobbyTopics } from "./topics.ts";
|
|
46
49
|
import { checkSession } from "../excalidraw/check.ts";
|
|
@@ -93,7 +96,13 @@ export function setServiceState(state: Runtime | undefined): void {
|
|
|
93
96
|
export function lobbyNotify(state: Runtime, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
94
97
|
lobbyTopics.bump("notices");
|
|
95
98
|
pushNotice(message, level as NoticeLevel);
|
|
96
|
-
|
|
99
|
+
// Work the lobby started may finish after pi replaced the session: its terminal is the new session's then.
|
|
100
|
+
if (level === "info" || state !== serviceState) return;
|
|
101
|
+
try {
|
|
102
|
+
state.ctx.ui.notify(message, level);
|
|
103
|
+
} catch {
|
|
104
|
+
// A replaced session's terminal: the page has the notice.
|
|
105
|
+
}
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
function sessionModel(ctx: ExtensionContext): string | undefined {
|
|
@@ -235,6 +244,7 @@ export function backgroundSessions(): readonly BackgroundSession[] {
|
|
|
235
244
|
function sessionsChanged(): void {
|
|
236
245
|
lobbyTopics.bump("sessions");
|
|
237
246
|
const state = serviceState;
|
|
247
|
+
state?.keeper?.window.schedule();
|
|
238
248
|
for (const session of registry?.sessions ?? []) {
|
|
239
249
|
if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
|
|
240
250
|
const seen = announced.get(session.key) ?? 0;
|
|
@@ -284,6 +294,117 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
|
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
296
|
|
|
297
|
+
/** Start again, in the background, a session a stopped window drove; the session, or why not. */
|
|
298
|
+
export function resumeSession(state: Runtime, start: Relaunch): BackgroundSession | string {
|
|
299
|
+
try {
|
|
300
|
+
const session = sessionRegistry().start(state.ctx.cwd, {
|
|
301
|
+
name: start.name,
|
|
302
|
+
projectRoot: state.root,
|
|
303
|
+
onDialog: dialogTracker(state),
|
|
304
|
+
sessionFile: start.sessionFile,
|
|
305
|
+
...(start.message ? { message: start.message } : {}),
|
|
306
|
+
});
|
|
307
|
+
lobbyFeed.log("LOBBY", `carried "${start.name}" (${start.taskId}) on in the background`, "success");
|
|
308
|
+
return session;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return `could not carry ${start.taskId} on — ${(error as Error).message}`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The sessions that could drive a task, as this window sees them. */
|
|
315
|
+
function taskDrivers(state: Runtime): Drivers {
|
|
316
|
+
const mine = backgroundSessions().filter((session) => session.alive && session.projectRoot === state.root);
|
|
317
|
+
return {
|
|
318
|
+
me: state.ctx.sessionManager.getSessionId(),
|
|
319
|
+
background: new Set(mine.flatMap((session) => (session.sessionId ? [session.sessionId] : []))),
|
|
320
|
+
carrying: new Set(mine.flatMap((session) => (session.taskId ? [session.taskId] : []))),
|
|
321
|
+
live: new Set(liveSessions(state).map((session) => session.sessionId)),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Tasks between a Resume click and their background session starting, so a second click starts nothing. */
|
|
326
|
+
const resuming = new Set<string>();
|
|
327
|
+
|
|
328
|
+
function unpause(state: Runtime, taskId: string): void {
|
|
329
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
330
|
+
if (!task?.paused) return;
|
|
331
|
+
task.paused = false;
|
|
332
|
+
task.updatedAt = new Date().toISOString();
|
|
333
|
+
saveTask(state.root, state.configDir, task);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resume a task from the Tasks screen, leaving this window where it is. A
|
|
338
|
+
* paused task is unpaused where it runs and its oracle told to carry on; one
|
|
339
|
+
* no running session drives carries on in a background session: its own
|
|
340
|
+
* session started again from its file when there is one, else a fresh one
|
|
341
|
+
* that takes it over.
|
|
342
|
+
*/
|
|
343
|
+
async function resumeTask(state: Runtime, taskId: string): Promise<{ notice: string; key?: string }> {
|
|
344
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
345
|
+
if (!task) return { notice: `no task ${taskId}` };
|
|
346
|
+
if (resuming.has(taskId)) return { notice: `${taskId} is already being resumed` };
|
|
347
|
+
const route = resumeRoute(task, taskDrivers(state));
|
|
348
|
+
const background = () => backgroundSessions().find((session) => session.alive && session.projectRoot === state.root && ((task.ownerSessionId && session.sessionId === task.ownerSessionId) || session.taskId === taskId));
|
|
349
|
+
switch (route.kind) {
|
|
350
|
+
case "finished":
|
|
351
|
+
return { notice: `${taskId} is ${task.state}; there is nothing to resume` };
|
|
352
|
+
case "running": {
|
|
353
|
+
const session = route.where === "background" ? background() : undefined;
|
|
354
|
+
const where = route.where === "here" ? "in this window" : session ? `in ${session.name}` : "in another terminal";
|
|
355
|
+
return { notice: `${taskId} is already under way ${where}`, ...(session ? { key: session.key } : {}) };
|
|
356
|
+
}
|
|
357
|
+
case "paused": {
|
|
358
|
+
unpause(state, taskId);
|
|
359
|
+
lobbyTopics.bump("tasks");
|
|
360
|
+
const message = carryOnMessage(task, "paused");
|
|
361
|
+
if (route.where === "here") {
|
|
362
|
+
state.pi.sendUserMessage(message, state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
363
|
+
try { applyStatus(state.ctx, state.root, state.configDir); } catch {}
|
|
364
|
+
lobbyFeed.log("LOBBY", `resumed ${taskId}`, "success");
|
|
365
|
+
return { notice: `resumed ${taskId} in this window` };
|
|
366
|
+
}
|
|
367
|
+
const session = route.where === "background" ? background() : undefined;
|
|
368
|
+
if (session) {
|
|
369
|
+
session.send(message);
|
|
370
|
+
lobbyFeed.log("LOBBY", `resumed ${taskId} in ${session.name}`, "success");
|
|
371
|
+
return { notice: `resumed ${taskId} in ${session.name}`, key: session.key };
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
sendToInbox(state.root, state.configDir, taskId, message, state.ctx.sessionManager.getSessionId());
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return { notice: `unpaused ${taskId}, but could not tell its oracle — ${(error as Error).message}` };
|
|
377
|
+
}
|
|
378
|
+
lobbyFeed.log("LOBBY", `resumed ${taskId} in another terminal`, "success");
|
|
379
|
+
return { notice: `resumed ${taskId} — the session driving it in another terminal carries on` };
|
|
380
|
+
}
|
|
381
|
+
case "stopped":
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
resuming.add(taskId);
|
|
385
|
+
try {
|
|
386
|
+
const file = task.ownerSessionId ? await chats.locate(task.ownerSessionId) : undefined;
|
|
387
|
+
settleInterrupted(state.root, state.configDir, taskId, new Date(), RESUMED_DECISION);
|
|
388
|
+
// `/bot-lobby carry-on` unpauses it too; done here as well, the Tasks screen shows it at once.
|
|
389
|
+
unpause(state, taskId);
|
|
390
|
+
const session = sessionRegistry().start(state.ctx.cwd, {
|
|
391
|
+
name: task.title,
|
|
392
|
+
projectRoot: state.root,
|
|
393
|
+
onDialog: dialogTracker(state),
|
|
394
|
+
taskId,
|
|
395
|
+
...(file ? { sessionFile: file } : {}),
|
|
396
|
+
message: `/bot-lobby carry-on ${taskId}`,
|
|
397
|
+
}, file ? undefined : sessionModel(state.ctx));
|
|
398
|
+
lobbyFeed.log("LOBBY", `resumed "${task.title}" (${taskId}) in the background`, "success");
|
|
399
|
+
lobbyTopics.bump("tasks");
|
|
400
|
+
return { notice: `resumed ${taskId} in a background session`, key: session.key };
|
|
401
|
+
} catch (error) {
|
|
402
|
+
return { notice: `could not resume ${taskId} — ${(error as Error).message}` };
|
|
403
|
+
} finally {
|
|
404
|
+
resuming.delete(taskId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
287
408
|
/** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
|
|
288
409
|
function sendToTask(state: Runtime, taskId: string, text: string): string {
|
|
289
410
|
const task = loadTask(state.root, state.configDir, taskId);
|
|
@@ -452,16 +573,74 @@ export function seatProfile(state: Runtime, member: PanelMember) {
|
|
|
452
573
|
}
|
|
453
574
|
|
|
454
575
|
function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession {
|
|
576
|
+
// The session left behind stays among the project's previous plans, as it stood.
|
|
577
|
+
state.keeper?.planner.flush();
|
|
578
|
+
state.planner?.cancel();
|
|
579
|
+
state.planner = new PlanningSession(plannerDeps(state, seats ?? loadConfig().lobby.planningPanel), seed);
|
|
580
|
+
state.keeper?.planner.schedule();
|
|
581
|
+
return state.planner;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Previous plans: the project's planning sessions never saved as a task, other than the one on screen. */
|
|
585
|
+
function plansBefore(state: Runtime, archived: boolean): PlanSummary[] {
|
|
586
|
+
return previousPlans(state.root, state.configDir, { archived, except: state.planner?.id });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** A previous plan to read: never the one on screen, and never one saved as a task. */
|
|
590
|
+
function previousPlan(state: Runtime, id: string): PlanRecord {
|
|
591
|
+
const record = isPlanId(id) ? readPlan(state.root, state.configDir, id) : undefined;
|
|
592
|
+
if (!record) throw new Error(`no plan ${id}`);
|
|
593
|
+
if (record.savedAs?.length) throw new Error(`${id} was saved as ${record.savedAs.join(", ")}`);
|
|
594
|
+
return record;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function onScreen(state: Runtime, id: string): void {
|
|
598
|
+
if (state.planner?.id === id) throw new Error("that is the plan on screen — start a new plan first");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function archivePrevious(state: Runtime, id: string, archived: boolean): string {
|
|
602
|
+
onScreen(state, id);
|
|
603
|
+
const record = archivePlanOnDisk(state.root, state.configDir, id, archived);
|
|
604
|
+
lobbyTopics.bump("planner");
|
|
605
|
+
return archived ? `archived "${record.title}" — show archived plans to bring it back` : `"${record.title}" is back among the previous plans`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function deletePrevious(state: Runtime, id: string): string {
|
|
609
|
+
onScreen(state, id);
|
|
610
|
+
const record = previousPlan(state, id);
|
|
611
|
+
deletePlanOnDisk(state.root, state.configDir, id);
|
|
612
|
+
lobbyTopics.bump("planner");
|
|
613
|
+
return `deleted "${record.title}" for good`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Carry a previous plan on: it becomes the plan on screen, and the one there joins the previous plans. */
|
|
617
|
+
function reopenPlan(state: Runtime, id: string): string {
|
|
618
|
+
if (state.planner?.busy) return "the panel is still thinking — wait for the round to finish";
|
|
619
|
+
onScreen(state, id);
|
|
620
|
+
const record = previousPlan(state, id);
|
|
621
|
+
state.keeper?.planner.flush();
|
|
455
622
|
state.planner?.cancel();
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
state.planner
|
|
623
|
+
state.planner = PlanningSession.restore(plannerDeps(state, record.snapshot.seats), { ...record.snapshot, id: record.id, running: false });
|
|
624
|
+
if (record.archivedAt) archivePlanOnDisk(state.root, state.configDir, id, false);
|
|
625
|
+
state.keeper?.planner.schedule();
|
|
626
|
+
lobbyTopics.bump("planner");
|
|
627
|
+
return `carrying on "${record.title}"`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** What a planning session in this window runs with; every change is kept on disk, so it carries on after a crash. */
|
|
631
|
+
export function plannerDeps(state: Runtime, seats: readonly PanelMember[]): PlannerDeps {
|
|
632
|
+
const workflow = loadConfig().workflow;
|
|
633
|
+
const changed = () => {
|
|
634
|
+
lobbyTopics.bump("planner");
|
|
635
|
+
state.keeper?.planner.schedule();
|
|
636
|
+
};
|
|
637
|
+
return {
|
|
459
638
|
cwd: state.ctx.cwd,
|
|
460
639
|
root: state.root,
|
|
461
640
|
configDir: state.configDir,
|
|
462
641
|
profile: () => lobbyProfile(state, "planner"),
|
|
463
642
|
memberProfile: (member) => seatProfile(state, member),
|
|
464
|
-
panel: seats
|
|
643
|
+
panel: seats,
|
|
465
644
|
maxRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
466
645
|
classifier: classifier(),
|
|
467
646
|
hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
|
|
@@ -469,10 +648,9 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
|
|
|
469
648
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
470
649
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
471
650
|
feed: lobbyFeed,
|
|
472
|
-
onChange:
|
|
473
|
-
onRound:
|
|
474
|
-
}
|
|
475
|
-
return state.planner;
|
|
651
|
+
onChange: changed,
|
|
652
|
+
onRound: changed,
|
|
653
|
+
};
|
|
476
654
|
}
|
|
477
655
|
|
|
478
656
|
/** The questionnaire in the page; tests swap it. */
|
|
@@ -610,6 +788,11 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
610
788
|
quickfix: state.quickfix,
|
|
611
789
|
planner: () => state.planner,
|
|
612
790
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
791
|
+
previousPlans: (archived) => plansBefore(state, archived),
|
|
792
|
+
previousPlan: (id) => previousPlan(state, id),
|
|
793
|
+
archivePlan: (id, archived) => archivePrevious(state, id, archived),
|
|
794
|
+
deletePlan: (id) => deletePrevious(state, id),
|
|
795
|
+
reopenPlan: (id) => reopenPlan(state, id),
|
|
613
796
|
answerPanel: () => answerPanel(state),
|
|
614
797
|
savePlan: () => savePlan(state),
|
|
615
798
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
@@ -638,6 +821,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
638
821
|
}
|
|
639
822
|
},
|
|
640
823
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
824
|
+
recovered: () => state.recovered,
|
|
641
825
|
seatLabel: (member) => {
|
|
642
826
|
const profile = seatProfile(state, member);
|
|
643
827
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
@@ -663,6 +847,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
663
847
|
isAuto: (taskId) => autoFor(state, taskId),
|
|
664
848
|
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
665
849
|
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
850
|
+
resumeTask: (taskId) => resumeTask(state, taskId),
|
|
666
851
|
sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
|
|
667
852
|
liveSessions: () => liveSessions(state),
|
|
668
853
|
switchTo: (target) => switchTo(state, target),
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -48,6 +48,12 @@ export interface SessionStart {
|
|
|
48
48
|
request?: string;
|
|
49
49
|
planId?: string;
|
|
50
50
|
auto?: boolean;
|
|
51
|
+
/** Carry on this saved session instead of starting a new one (after the window that drove it stopped). */
|
|
52
|
+
sessionFile?: string;
|
|
53
|
+
/** Sent once it is up, as the user's message (the nudge that picks an interrupted turn back up). */
|
|
54
|
+
message?: string;
|
|
55
|
+
/** The task it was started to carry on (resumed from the Tasks screen). */
|
|
56
|
+
taskId?: string;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
let counter = 0;
|
|
@@ -61,6 +67,8 @@ export class BackgroundSession {
|
|
|
61
67
|
readonly name: string;
|
|
62
68
|
/** The planned task it was started from. */
|
|
63
69
|
readonly planId?: string;
|
|
70
|
+
/** The task it was started to carry on. */
|
|
71
|
+
readonly taskId?: string;
|
|
64
72
|
readonly feed = new LobbyFeed();
|
|
65
73
|
readonly startedAt = Date.now();
|
|
66
74
|
status: SessionStatus = "starting";
|
|
@@ -86,6 +94,7 @@ export class BackgroundSession {
|
|
|
86
94
|
this.name = start.name;
|
|
87
95
|
this.projectRoot = start.projectRoot;
|
|
88
96
|
if (start.planId) this.planId = start.planId;
|
|
97
|
+
if (start.taskId) this.taskId = start.taskId;
|
|
89
98
|
this.onDialog = start.onDialog ?? (() => {});
|
|
90
99
|
this.proc = proc;
|
|
91
100
|
this.onChange = onChange;
|
|
@@ -103,6 +112,7 @@ export class BackgroundSession {
|
|
|
103
112
|
this.write({ type: "get_state" });
|
|
104
113
|
if (start.planId) this.prompt(`/bot-lobby start-plan ${start.planId}${start.auto ? " auto" : ""}`);
|
|
105
114
|
else if (start.request) this.prompt(`/bot-lobby --task ${start.auto ? "--auto " : ""}${start.request}`);
|
|
115
|
+
else if (start.message) this.prompt(start.message);
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
private ended(code: number | null, reason: string): void {
|
|
@@ -277,8 +287,8 @@ export function extensionArgs(argv: readonly string[] = process.argv): string[]
|
|
|
277
287
|
return forwarded;
|
|
278
288
|
}
|
|
279
289
|
|
|
280
|
-
export function sessionArgs(name: string, model?: string, argv: readonly string[] = process.argv): string[] {
|
|
281
|
-
return ["--mode", "rpc", "--name", name, ...(model ? ["--model", model] : []), ...extensionArgs(argv)];
|
|
290
|
+
export function sessionArgs(name: string, model?: string, argv: readonly string[] = process.argv, sessionFile?: string): string[] {
|
|
291
|
+
return ["--mode", "rpc", ...(sessionFile ? ["--session", sessionFile] : []), "--name", name, ...(model ? ["--model", model] : []), ...extensionArgs(argv)];
|
|
282
292
|
}
|
|
283
293
|
|
|
284
294
|
const POSIX = process.platform !== "win32";
|
|
@@ -307,7 +317,7 @@ export class SessionRegistry {
|
|
|
307
317
|
}
|
|
308
318
|
|
|
309
319
|
start(cwd: string, start: SessionStart, model?: string): BackgroundSession {
|
|
310
|
-
const proc = this.launcher(sessionArgs(start.name, model), cwd);
|
|
320
|
+
const proc = this.launcher(sessionArgs(start.name, model, process.argv, start.sessionFile), cwd);
|
|
311
321
|
const session = new BackgroundSession(proc, start, this.onChange);
|
|
312
322
|
this.sessions.push(session);
|
|
313
323
|
this.prune();
|
package/src/lobby/task-rows.ts
CHANGED
package/src/master/decisions.ts
CHANGED
|
@@ -7,14 +7,17 @@ import { builtSomething, onFastTrack, qaRequired, qaTookPart } from "../workflow
|
|
|
7
7
|
/**
|
|
8
8
|
* §19: everything the engine requires before completion may be declared. The
|
|
9
9
|
* fast track asks for a finished worker step instead of a QA pass, and for
|
|
10
|
-
* QA's part only when the change needs tests
|
|
10
|
+
* QA's part only when the change needs tests; so does a planned task whose
|
|
11
|
+
* QA sat out the planning panel.
|
|
11
12
|
*/
|
|
12
13
|
export function completionBlockers(task: Task, pendingCount: number): string[] {
|
|
13
14
|
const blockers: string[] = [];
|
|
14
15
|
if (!task.plan) blockers.push("no approved plan is recorded");
|
|
15
16
|
if (onFastTrack(task)) {
|
|
16
17
|
if (!builtSomething(task)) blockers.push("no worker step has finished yet");
|
|
17
|
-
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (
|
|
18
|
+
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (run the QA gate: action=qa)");
|
|
19
|
+
} else if (!qaRequired(task)) {
|
|
20
|
+
if (!builtSomething(task)) blockers.push("no worker step has finished yet");
|
|
18
21
|
} else if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
|
|
19
22
|
if (pendingCount > 0) blockers.push(`${pendingCount} unresolved approval request(s)`);
|
|
20
23
|
if (task.blockers.length > 0) blockers.push(`${task.blockers.length} unresolved blocker(s)`);
|
package/src/master/master.ts
CHANGED
|
@@ -238,6 +238,8 @@ export interface WorkerRequest {
|
|
|
238
238
|
time?: AgentTime;
|
|
239
239
|
/** The worker may ask the user (the designer): its questions are relayed, and its images saved in `previews`. */
|
|
240
240
|
ask?: { onAsk: RelayAsk; previews: string };
|
|
241
|
+
/** A QA step's verification budget: Jev's QA risk read of the change so far. */
|
|
242
|
+
risk?: string;
|
|
241
243
|
}
|
|
242
244
|
|
|
243
245
|
/** What a worker that may ask the user is told about it. */
|
|
@@ -264,6 +266,7 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
|
|
|
264
266
|
? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
|
|
265
267
|
: "No scout findings were collected for your domain; verify the repository yourself.",
|
|
266
268
|
likely,
|
|
269
|
+
request.risk ?? "",
|
|
267
270
|
request.time?.note ?? "",
|
|
268
271
|
request.ask ? askGuidance(request.ask.previews) : "",
|
|
269
272
|
].filter(Boolean).join("\n\n");
|
|
@@ -344,6 +347,8 @@ export interface ReviewerRequest {
|
|
|
344
347
|
previousRound?: string;
|
|
345
348
|
/** The engine's lint result on the touched files and the suppressions the task added. */
|
|
346
349
|
lint?: string;
|
|
350
|
+
/** Jev's QA risk read of the change: the gate's depth, test budget and focus. */
|
|
351
|
+
risk?: string;
|
|
347
352
|
/** Under a task time budget: the gate's time. */
|
|
348
353
|
time?: AgentTime;
|
|
349
354
|
instruction?: string;
|
|
@@ -365,6 +370,7 @@ function reviewerContext(request: ReviewerRequest): string {
|
|
|
365
370
|
return [
|
|
366
371
|
`Task state: reviewing. Domain: ${request.domain}.`,
|
|
367
372
|
"You may not modify implementation. Report required changes instead.",
|
|
373
|
+
request.risk ?? "",
|
|
368
374
|
request.previousRound ?? "",
|
|
369
375
|
request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
|
|
370
376
|
owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
|