@a-t-h-i/bot-lobby 0.6.18 → 0.6.19
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/package.json +1 -1
- package/src/ask/relay.ts +3 -1
- package/src/ask/tool.ts +18 -3
- package/src/ask/types.ts +12 -0
- package/src/ask/web.ts +2 -1
- package/src/delivery/checks.ts +37 -0
- package/src/delivery/lock.ts +78 -0
- package/src/delivery/merge.ts +44 -0
- package/src/delivery/operations.ts +134 -0
- package/src/delivery/pr.ts +40 -0
- package/src/delivery/review.ts +90 -0
- package/src/delivery/rules.ts +46 -0
- package/src/delivery/source.ts +39 -0
- package/src/delivery/transport.ts +36 -0
- package/src/delivery/types.ts +60 -0
- package/src/execution/agent-runner.ts +1 -23
- package/src/index.ts +1 -1
- package/src/lobby/feed.ts +0 -6
- package/src/lobby/host.ts +3 -0
- package/src/lobby/planner.ts +1 -7
- package/src/lobby/pr-review.ts +1 -7
- package/src/lobby/prompt-hub.ts +7 -1
- package/src/lobby/quickfix.ts +3 -16
- package/src/lobby/service.ts +73 -3
- package/src/lobby/sessions.ts +12 -0
- package/src/lobby/task-rows.ts +5 -0
- package/src/master/master.ts +1 -1
- package/src/master/research.ts +0 -1
- package/src/pi/events.ts +0 -2
- package/src/pi/model-settings.ts +7 -26
- package/src/pi/model-support.ts +4 -19
- package/src/schemas/configuration.ts +8 -38
- package/src/schemas/findings.ts +0 -2
- package/src/schemas/task.ts +14 -0
- package/src/state/blocking-requests.ts +23 -0
- package/src/state/persistence.ts +12 -1
- package/src/state/phase-timing.ts +53 -0
- package/src/state/task-state.ts +2 -0
- package/src/state/timing-merge.ts +25 -0
- package/src/webui/api/index.ts +17 -1
- package/src/webui/api/tasks.ts +52 -0
- package/src/webui/protocol.ts +8 -0
- package/src/workflow/approvals.ts +3 -0
- package/src/workflow/workflow.ts +14 -33
- package/webui/dist/assets/index-BJI9lE5R.js +98 -0
- package/webui/dist/assets/index-Dg0tvkeY.css +2 -0
- package/webui/dist/build.json +83 -43
- package/webui/dist/index.html +2 -2
- package/src/execution/fallback.ts +0 -75
- package/src/pi/master-fallback.ts +0 -61
- package/webui/dist/assets/index-CHLNxlA1.js +0 -98
- package/webui/dist/assets/index-m9XcQvH8.css +0 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve, isAbsolute } from "node:path";
|
|
3
|
+
import type { Task } from "../schemas/task.ts";
|
|
4
|
+
import { command, type DeliveryContext } from "./transport.ts";
|
|
5
|
+
|
|
6
|
+
export interface Source { repository: string; remote: string; github: string; branch: string; commit: string; cwd: string }
|
|
7
|
+
export function sha(value: string): string {
|
|
8
|
+
if (!/^[a-f0-9]{40}$/.test(value)) throw new Error("Missing or invalid commit identity; commit completed work and refresh review.");
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function githubRemote(remote: string): string {
|
|
12
|
+
const match = /^(?:https:\/\/github\.com\/|git@github\.com:)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(remote);
|
|
13
|
+
if (!match?.[1] || match[1].split("/").some((v) => v === "." || v === "..")) throw new Error("Origin must identify a GitHub repository without credentials; configure origin and refresh review.");
|
|
14
|
+
return match[1];
|
|
15
|
+
}
|
|
16
|
+
async function common(ctx: DeliveryContext): Promise<string> {
|
|
17
|
+
const dir = await command(ctx, "git", ["rev-parse", "--git-common-dir"]);
|
|
18
|
+
return realpathSync(resolve(ctx.cwd, dir));
|
|
19
|
+
}
|
|
20
|
+
export async function readSource(task: Task, ctx: DeliveryContext): Promise<Source> {
|
|
21
|
+
if (!task.git) throw new Error("Task has no Git isolation. Preserve the completed work and configure a task branch before delivery.");
|
|
22
|
+
const branch = task.git.branch;
|
|
23
|
+
if (!branch || branch === "main" || branch.startsWith("-") || /[\s\x00-\x1f]/.test(branch)) throw new Error("Invalid task branch; delivery cannot use main as its source.");
|
|
24
|
+
await command(ctx, "git", ["check-ref-format", "--branch", branch]);
|
|
25
|
+
const repository = await common(ctx);
|
|
26
|
+
const path = task.git.mode === "worktree" ? task.git.path : ctx.cwd;
|
|
27
|
+
if (!path || !isAbsolute(path)) throw new Error("Task working directory is missing; restore its isolated worktree.");
|
|
28
|
+
const sourceCtx = { ...ctx, cwd: realpathSync(path) };
|
|
29
|
+
if (await common(sourceCtx) !== repository) throw new Error("Task worktree belongs to a different repository; restore isolation.");
|
|
30
|
+
if (await command(sourceCtx, "git", ["symbolic-ref", "--short", "HEAD"]) !== branch) throw new Error("Task branch is not checked out in its working directory; restore it and refresh review.");
|
|
31
|
+
if (await command(sourceCtx, "git", ["status", "--porcelain"])) throw new Error("Task source has uncommitted changes. Commit only completed task work, then refresh review.");
|
|
32
|
+
const commit = sha(await command(sourceCtx, "git", ["rev-parse", `refs/heads/${branch}^{commit}`]));
|
|
33
|
+
const remote = await command(sourceCtx, "git", ["remote", "get-url", "origin"]);
|
|
34
|
+
return { repository, remote, github: githubRemote(remote), branch, commit, cwd: sourceCtx.cwd };
|
|
35
|
+
}
|
|
36
|
+
export async function refreshMain(ctx: DeliveryContext): Promise<string> {
|
|
37
|
+
await command(ctx, "git", ["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]);
|
|
38
|
+
return sha(await command(ctx, "git", ["rev-parse", "refs/remotes/origin/main^{commit}"]));
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Exec } from "../lobby/issues.ts";
|
|
2
|
+
|
|
3
|
+
export interface DeliveryContext { exec: Exec; cwd: string }
|
|
4
|
+
export async function command(ctx: DeliveryContext, tool: string, args: string[]): Promise<string> {
|
|
5
|
+
const result = await ctx.exec(tool, args, { cwd: ctx.cwd, timeout: 20_000 });
|
|
6
|
+
if (result.code !== 0) throw new Error(`${tool} lookup failed; check remote access and authentication, then refresh review.`);
|
|
7
|
+
return result.stdout.trim();
|
|
8
|
+
}
|
|
9
|
+
export async function api(ctx: DeliveryContext, endpoint: string): Promise<unknown> {
|
|
10
|
+
const text = await command(ctx, "gh", ["api", endpoint]);
|
|
11
|
+
try { return JSON.parse(text); }
|
|
12
|
+
catch { throw new Error("GitHub returned malformed JSON; refresh review after connectivity recovers."); }
|
|
13
|
+
}
|
|
14
|
+
export function object(value: unknown): Record<string, unknown> {
|
|
15
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Malformed GitHub response; refresh review.");
|
|
16
|
+
return value as Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export function strings(value: unknown): string[] {
|
|
19
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) throw new Error("Malformed required check names.");
|
|
20
|
+
return value as string[];
|
|
21
|
+
}
|
|
22
|
+
export async function pages(ctx: DeliveryContext, endpoint: string, key?: string): Promise<unknown[]> {
|
|
23
|
+
const all: unknown[] = [];
|
|
24
|
+
for (let page = 1; page <= 100; page++) {
|
|
25
|
+
const raw = await api(ctx, `${endpoint}?per_page=100&page=${page}`);
|
|
26
|
+
const rows = key ? object(raw)[key] : raw;
|
|
27
|
+
if (!Array.isArray(rows) || rows.length > 100) throw new Error("Malformed GitHub pagination; refresh review.");
|
|
28
|
+
if (key && (!Number.isInteger(object(raw).total_count) || Number(object(raw).total_count) < all.length + rows.length)) throw new Error("Malformed check count.");
|
|
29
|
+
all.push(...rows);
|
|
30
|
+
if (rows.length < 100) {
|
|
31
|
+
if (key && object(raw).total_count !== all.length) throw new Error("Incomplete check pagination; refresh review.");
|
|
32
|
+
return all;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw new Error("GitHub pagination exceeds safety limit; review checks manually.");
|
|
36
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Persisted delivery review contract; completion and publication are separate. */
|
|
2
|
+
export type DeliveryStatus = "pending_approval" | "deferred" | "in_progress" | "successful" | "recoverable_failure";
|
|
3
|
+
export type DeliveryAction = "create_pr" | "merge_main";
|
|
4
|
+
export type DeliveryChecksState = "passing" | "failing" | "pending" | "absent" | "unavailable" | "stale";
|
|
5
|
+
|
|
6
|
+
export interface DeliveryVerification {
|
|
7
|
+
checks: DeliveryChecksState;
|
|
8
|
+
summary: string;
|
|
9
|
+
localVerified: boolean;
|
|
10
|
+
requiredChecks: string[];
|
|
11
|
+
rulesKnown: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DeliveryOperation {
|
|
15
|
+
id: string;
|
|
16
|
+
action: DeliveryAction;
|
|
17
|
+
sourceCommit: string;
|
|
18
|
+
repository?: string;
|
|
19
|
+
remote?: string;
|
|
20
|
+
sourceBranch?: string;
|
|
21
|
+
target?: "main";
|
|
22
|
+
fingerprint?: string;
|
|
23
|
+
reviewId?: string;
|
|
24
|
+
integrationPath?: string;
|
|
25
|
+
targetCommit?: string;
|
|
26
|
+
stage: string;
|
|
27
|
+
mergeCommit?: string;
|
|
28
|
+
pullNumber?: number;
|
|
29
|
+
pullUrl?: string;
|
|
30
|
+
startedAt: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DeliveryResult {
|
|
34
|
+
action: DeliveryAction;
|
|
35
|
+
commit?: string;
|
|
36
|
+
pullNumber?: number;
|
|
37
|
+
pullUrl?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Delivery {
|
|
41
|
+
status: DeliveryStatus;
|
|
42
|
+
reviewId: string;
|
|
43
|
+
repository?: string;
|
|
44
|
+
remote?: string;
|
|
45
|
+
project: string;
|
|
46
|
+
sourceBranch: string;
|
|
47
|
+
sourceCommit?: string;
|
|
48
|
+
target: "main";
|
|
49
|
+
targetCommit?: string;
|
|
50
|
+
reviewedAt: string;
|
|
51
|
+
verification?: DeliveryVerification;
|
|
52
|
+
/** Fingerprint of reviewed rules and check responses; missing means not reviewed. */
|
|
53
|
+
fingerprint?: string;
|
|
54
|
+
blocked: Partial<Record<DeliveryAction, string>>;
|
|
55
|
+
operation?: DeliveryOperation;
|
|
56
|
+
/** Reconciled, non-delivered intents superseded only by explicit fresh approval. */
|
|
57
|
+
previousOperations?: DeliveryOperation[];
|
|
58
|
+
error?: string;
|
|
59
|
+
result?: DeliveryResult;
|
|
60
|
+
}
|
|
@@ -7,7 +7,6 @@ import { shortDuration, truncate } from "../text.ts";
|
|
|
7
7
|
import { EditLog } from "../state/changes.ts";
|
|
8
8
|
import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
|
|
9
9
|
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
|
|
10
|
-
import { isUnavailable, looksUnavailable, markUnavailable, usableFallback } from "./fallback.ts";
|
|
11
10
|
import { ASK_ENV } from "../ask/relay.ts";
|
|
12
11
|
import { ASK_TOOL } from "../ask/types.ts";
|
|
13
12
|
import { agentOfRun, grantOption } from "../excalidraw/sessions.ts";
|
|
@@ -42,10 +41,6 @@ export interface AgentRequest {
|
|
|
42
41
|
context: AgentContext;
|
|
43
42
|
model?: string;
|
|
44
43
|
thinking?: string;
|
|
45
|
-
/** Where the run goes when its model runs out of usage or is unavailable. */
|
|
46
|
-
fallback?: { model: string; thinking: string };
|
|
47
|
-
/** Set by the runner once it has switched: the model that ran out. */
|
|
48
|
-
fellBackFrom?: string;
|
|
49
44
|
timeoutMs: number;
|
|
50
45
|
cwd: string;
|
|
51
46
|
signal?: AbortSignal;
|
|
@@ -141,24 +136,13 @@ function retryable(run: AgentRun): boolean {
|
|
|
141
136
|
*/
|
|
142
137
|
export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
|
|
143
138
|
const startedAt = new Date().toISOString();
|
|
144
|
-
|
|
139
|
+
const attempts = Math.max(1, (request.retries ?? 0) + 1);
|
|
145
140
|
// A failed attempt may have edited files before the retry: the run owns every edit.
|
|
146
141
|
const edits = new EditLog(request.cwd);
|
|
147
142
|
let last: AgentRun | undefined;
|
|
148
|
-
// A model already known to be out of usage goes straight to the fallback.
|
|
149
|
-
const other = usableFallback(request.fallback, request.model);
|
|
150
|
-
if (other && isUnavailable(request.model)) request = switchToFallback(request, other, request.model ?? "the session model");
|
|
151
143
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
152
144
|
last = await runAgentOnce(request, run, attempt, startedAt, edits);
|
|
153
145
|
request.onAttemptEnd?.(last);
|
|
154
|
-
// Out of usage (or the model unavailable): the same model would fail again, so the fallback takes the retry.
|
|
155
|
-
const fallback = usableFallback(request.fallback, request.model);
|
|
156
|
-
if (fallback && !request.fellBackFrom && last.status === "failed" && looksUnavailable(last.error)) {
|
|
157
|
-
if (request.model) markUnavailable(request.model);
|
|
158
|
-
request = switchToFallback(request, fallback, request.model ?? "the session model");
|
|
159
|
-
attempts += 1;
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
146
|
if (!retryable(last)) break;
|
|
163
147
|
// Under a budget a retry only uses what is left of the allotment.
|
|
164
148
|
if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
|
|
@@ -167,11 +151,6 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
|
|
|
167
151
|
return edited.length > 0 ? { ...last!, edited } : last!;
|
|
168
152
|
}
|
|
169
153
|
|
|
170
|
-
/** The request moved to its fallback model and thinking level. */
|
|
171
|
-
function switchToFallback(request: AgentRequest, fallback: { model: string; thinking?: string }, from: string): AgentRequest {
|
|
172
|
-
return { ...request, model: fallback.model, thinking: fallback.thinking ?? request.thinking, fellBackFrom: from };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
154
|
/** Abort every in-flight subagent (session shutdown, user cancel). */
|
|
176
155
|
export function cancelAllRuns(): void {
|
|
177
156
|
for (const controller of activeControllers) controller.abort();
|
|
@@ -190,7 +169,6 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
|
|
|
190
169
|
attempts,
|
|
191
170
|
startedAt,
|
|
192
171
|
...(request.thinking ? { thinking: request.thinking } : {}),
|
|
193
|
-
...(request.fellBackFrom ? { fellBackFrom: request.fellBackFrom } : {}),
|
|
194
172
|
...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
|
|
195
173
|
...(request.route ? { route: request.route } : {}),
|
|
196
174
|
...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
40
40
|
// A new request one agent can do alone: the oracle confirms, and the lobby hands it to the quick-fix agent.
|
|
41
41
|
registerRouteTool(pi, CONFIG_DIR_NAME);
|
|
42
42
|
// The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
|
|
43
|
-
registerAskTool(pi);
|
|
43
|
+
registerAskTool(pi, undefined, CONFIG_DIR_NAME);
|
|
44
44
|
// The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
|
|
45
45
|
registerWebTools(pi);
|
|
46
46
|
// The shared Excalidraw sessions the user assigned to agents: the oracle here, every other agent through its own grant.
|
package/src/lobby/feed.ts
CHANGED
|
@@ -71,7 +71,6 @@ export class LobbyFeed {
|
|
|
71
71
|
private readonly runThoughts = new Map<string, string>();
|
|
72
72
|
private readonly runStatus = new Map<string, AgentRun["status"]>();
|
|
73
73
|
/** Runs already reported as switched to their fallback model. */
|
|
74
|
-
private readonly fellBack = new Set<string>();
|
|
75
74
|
|
|
76
75
|
onChange(listener: () => void): () => void {
|
|
77
76
|
this.listeners.add(listener);
|
|
@@ -202,10 +201,6 @@ export class LobbyFeed {
|
|
|
202
201
|
const before = this.runStatus.get(key);
|
|
203
202
|
this.runStatus.set(key, run.status);
|
|
204
203
|
if (before === undefined) this.log(source, `started as ${run.role}`, "info", at);
|
|
205
|
-
if (run.fellBackFrom && !this.fellBack.has(key)) {
|
|
206
|
-
this.fellBack.add(key);
|
|
207
|
-
this.log(source, `${run.fellBackFrom} is out of usage or unavailable: running on the fallback model`, "warning", at);
|
|
208
|
-
}
|
|
209
204
|
if (run.step && this.runSteps.get(key) !== run.step) {
|
|
210
205
|
this.runSteps.set(key, run.step);
|
|
211
206
|
this.step(source, run.step, key, at);
|
|
@@ -231,7 +226,6 @@ export class LobbyFeed {
|
|
|
231
226
|
this.runSteps.clear();
|
|
232
227
|
this.runThoughts.clear();
|
|
233
228
|
this.runStatus.clear();
|
|
234
|
-
this.fellBack.clear();
|
|
235
229
|
this.touch();
|
|
236
230
|
}
|
|
237
231
|
}
|
package/src/lobby/host.ts
CHANGED
|
@@ -93,6 +93,9 @@ export interface LobbyService {
|
|
|
93
93
|
feed: LobbyFeed;
|
|
94
94
|
masterBusy(): boolean;
|
|
95
95
|
tasks(): Task[];
|
|
96
|
+
deliveryDeliver?(taskId: string, request: import("../delivery/operations.ts").DeliveryRequest): Promise<import("../delivery/types.ts").Delivery>;
|
|
97
|
+
deliveryReview?(taskId: string): Promise<import("../delivery/types.ts").Delivery>;
|
|
98
|
+
deliveryDefer?(taskId: string, reviewId: string): import("../delivery/types.ts").Delivery;
|
|
96
99
|
plans(): PlannedTask[];
|
|
97
100
|
comments(taskId: string): PlanComment[];
|
|
98
101
|
metrics(): MetricRecord[];
|
package/src/lobby/planner.ts
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { loadPrompt } from "../prompts/loader.ts";
|
|
22
22
|
import { compilePrompt } from "../prompts/compiler.ts";
|
|
23
|
-
import { withFallback } from "../execution/fallback.ts";
|
|
24
23
|
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
25
24
|
import { grantOption, type Grant } from "../excalidraw/sessions.ts";
|
|
26
25
|
import { describeToolCall } from "../pi/activity.ts";
|
|
@@ -823,12 +822,7 @@ export class PlanningSession {
|
|
|
823
822
|
},
|
|
824
823
|
this.deps.runProcess ?? spawnPiProcess,
|
|
825
824
|
);
|
|
826
|
-
|
|
827
|
-
const { result, switchedFrom } = await withFallback(profile.model, profile.thinking, profile.fallback, attempt);
|
|
828
|
-
if (switchedFrom && profile.fallback) {
|
|
829
|
-
this.deps.feed?.log(label, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`, "warning");
|
|
830
|
-
profile = { ...profile, model: profile.fallback.model, thinking: profile.fallback.thinking };
|
|
831
|
-
}
|
|
825
|
+
const result = await attempt(profile.model, profile.thinking);
|
|
832
826
|
outcome = { status: result.status, output: result.output, ...(result.error ? { error: result.error } : {}), ...(result.model ? { model: result.model } : {}), usage: result.usage };
|
|
833
827
|
} catch (error) {
|
|
834
828
|
outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
|
package/src/lobby/pr-review.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { loadPrompt } from "../prompts/loader.ts";
|
|
14
|
-
import { withFallback } from "../execution/fallback.ts";
|
|
15
14
|
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
16
15
|
import { describeToolCall } from "../pi/activity.ts";
|
|
17
16
|
import { appendMetrics } from "../state/metrics.ts";
|
|
@@ -284,12 +283,7 @@ export class PullReviews {
|
|
|
284
283
|
},
|
|
285
284
|
this.deps.runProcess ?? spawnPiProcess,
|
|
286
285
|
);
|
|
287
|
-
const
|
|
288
|
-
if (switchedFrom && profile.fallback) {
|
|
289
|
-
review.model = profile.fallback.model;
|
|
290
|
-
review.thinking = profile.fallback.thinking;
|
|
291
|
-
this.step(review, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`);
|
|
292
|
-
}
|
|
286
|
+
const result = await attempt(profile.model, profile.thinking);
|
|
293
287
|
if (result.model) review.model = result.model;
|
|
294
288
|
review.status = result.status === "success" ? "done" : result.status;
|
|
295
289
|
const text = result.output.trim();
|
package/src/lobby/prompt-hub.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* the answer, or with how it was put away. A cancelled caller's `signal`
|
|
6
6
|
* withdraws the prompt.
|
|
7
7
|
*/
|
|
8
|
+
import { blockingRequestContext } from "../state/blocking-requests.ts";
|
|
8
9
|
import { lobbyTopics } from "./topics.ts";
|
|
9
10
|
|
|
10
11
|
export type PromptKind = "questionnaire" | "choose" | "confirm" | "text" | "sessionDialog";
|
|
@@ -16,6 +17,9 @@ export interface WebPrompt {
|
|
|
16
17
|
createdAt: number;
|
|
17
18
|
from: string;
|
|
18
19
|
payload: unknown;
|
|
20
|
+
taskId?: string;
|
|
21
|
+
sessionId?: string;
|
|
22
|
+
blockingRequestId?: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
interface Pending {
|
|
@@ -50,7 +54,9 @@ export class PromptHub {
|
|
|
50
54
|
/** Show a prompt on every surface; the answer settles it later. */
|
|
51
55
|
open(kind: PromptKind, from: string, payload: unknown): WebPrompt {
|
|
52
56
|
this.seq += 1;
|
|
53
|
-
const
|
|
57
|
+
const identity = blockingRequestContext.getStore();
|
|
58
|
+
const prompt: WebPrompt = { id: nextId(this.seq), kind, createdAt: Date.now(), from, payload,
|
|
59
|
+
...(identity ? { taskId: identity.taskId, sessionId: identity.sessionId, blockingRequestId: identity.requestId } : {}) };
|
|
54
60
|
this.pendingMap.set(prompt.id, { prompt });
|
|
55
61
|
lobbyTopics.bump("prompts");
|
|
56
62
|
return prompt;
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* and the lobby feed, and every finished job lands in the metrics log.
|
|
7
7
|
*/
|
|
8
8
|
import { loadPrompt } from "../prompts/loader.ts";
|
|
9
|
-
import { withFallback } from "../execution/fallback.ts";
|
|
10
9
|
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
11
10
|
import { grantOption } from "../excalidraw/sessions.ts";
|
|
12
11
|
import { describeToolCall } from "../pi/activity.ts";
|
|
@@ -77,7 +76,6 @@ export interface QuickFixProfile {
|
|
|
77
76
|
timeoutMs: number;
|
|
78
77
|
instructions?: string;
|
|
79
78
|
/** Where the job goes when its model runs out of usage or is unavailable. */
|
|
80
|
-
fallback?: { model: string; thinking: string };
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
export interface QuickFixDeps {
|
|
@@ -265,17 +263,6 @@ export class QuickFixQueue {
|
|
|
265
263
|
},
|
|
266
264
|
this.deps.runProcess ?? spawnPiProcess,
|
|
267
265
|
);
|
|
268
|
-
// A model that is out of usage hands the job to the fallback the settings name.
|
|
269
|
-
const attemptOrFallback = async (model: string | undefined, thinking: string) => {
|
|
270
|
-
const { result: outcome, switchedFrom } = await withFallback(model, thinking, profile.fallback, attempt);
|
|
271
|
-
if (switchedFrom && profile.fallback) {
|
|
272
|
-
job.model = profile.fallback.model;
|
|
273
|
-
job.thinking = profile.fallback.thinking;
|
|
274
|
-
this.addStep(job, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model} (${profile.fallback.thinking})`, Date.now());
|
|
275
|
-
this.changed();
|
|
276
|
-
}
|
|
277
|
-
return outcome;
|
|
278
|
-
};
|
|
279
266
|
let result;
|
|
280
267
|
if (route) {
|
|
281
268
|
job.route = routeLabel(route);
|
|
@@ -283,7 +270,7 @@ export class QuickFixQueue {
|
|
|
283
270
|
job.thinking = route.thinking;
|
|
284
271
|
if (route.model) job.model = route.model;
|
|
285
272
|
this.changed();
|
|
286
|
-
result = await
|
|
273
|
+
result = await attempt(route.model, route.thinking);
|
|
287
274
|
if (fellShort(result) && !controller.signal.aborted) {
|
|
288
275
|
// The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
|
|
289
276
|
appendMetrics(this.deps.root, this.deps.configDir, [{ ...quickFixMetric({ ...job, status: result.status, finishedAt: Date.now(), usage: result.usage, ...(result.model ? { model: result.model } : {}) }), id: `${job.id}-routed-${job.startedAt}` }]);
|
|
@@ -292,10 +279,10 @@ export class QuickFixQueue {
|
|
|
292
279
|
delete job.routedFrom;
|
|
293
280
|
job.thinking = profile.thinking;
|
|
294
281
|
if (profile.model) job.model = profile.model;
|
|
295
|
-
result = await
|
|
282
|
+
result = await attempt(profile.model, profile.thinking);
|
|
296
283
|
}
|
|
297
284
|
} else {
|
|
298
|
-
result = await
|
|
285
|
+
result = await attempt(profile.model, profile.thinking);
|
|
299
286
|
}
|
|
300
287
|
job.status = result.status;
|
|
301
288
|
job.report = result.output.trim() || undefined;
|
package/src/lobby/service.ts
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { updateBlockingRequest } from "../state/blocking-requests.ts";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { loadTask, ownedTask, peekTasks, saveTask } from "../state/persistence.ts";
|
|
13
|
+
import { refreshReview, deferReview } from "../delivery/review.ts";
|
|
14
|
+
import { deliver, type DeliveryRequest, type DeliveryStore } from "../delivery/operations.ts";
|
|
15
|
+
import type { LockIdentity } from "../delivery/lock.ts";
|
|
16
|
+
import { execCommand } from "./issues.ts";
|
|
9
17
|
import { releaseAttachments } from "../state/attachments.ts";
|
|
10
18
|
import { loadConfig, saveConfig as writeConfig } from "../state/project.ts";
|
|
11
19
|
import { addPlanComment, editComment, readPlanComments, type PlanComment } from "../state/comments.ts";
|
|
@@ -40,6 +48,34 @@ import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
|
|
|
40
48
|
import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
|
|
41
49
|
import type { Runtime } from "./runtime.ts";
|
|
42
50
|
|
|
51
|
+
function deliveryTask(state: Runtime, taskId: string): Task {
|
|
52
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId) || taskId === "." || taskId === "..") throw new Error("Invalid task ID.");
|
|
53
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
54
|
+
if (!task || task.state !== "completed" || !task.delivery) throw new Error("Completed task delivery review is unavailable in this project.");
|
|
55
|
+
return task;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function recoveryDeliveryStore(state: Runtime, identity: LockIdentity): DeliveryStore {
|
|
59
|
+
if (identity.configDir !== resolve(state.configDir) || realpathSync(identity.projectRoot) !== identity.projectRoot) throw new Error("Interrupted owner has an untrusted project/config identity.");
|
|
60
|
+
return deliveryStore({ ...state, root: identity.projectRoot }, identity.taskId);
|
|
61
|
+
}
|
|
62
|
+
function deliveryStore(state: Runtime, taskId: string): DeliveryStore {
|
|
63
|
+
let baseline: string | undefined;
|
|
64
|
+
return {
|
|
65
|
+
identity: { projectRoot: realpathSync(state.root), configDir: resolve(state.configDir) },
|
|
66
|
+
recoveryStore: (identity) => recoveryDeliveryStore(state, identity),
|
|
67
|
+
load: () => { const task = deliveryTask(state, taskId); baseline = JSON.stringify(task.delivery); return task; },
|
|
68
|
+
save: (task) => {
|
|
69
|
+
const latest = deliveryTask(state, taskId);
|
|
70
|
+
if (JSON.stringify(latest.delivery) !== baseline) throw new Error("Delivery changed during operation; reconcile before retrying.");
|
|
71
|
+
latest.delivery = structuredClone(task.delivery);
|
|
72
|
+
saveTask(state.root, state.configDir, latest);
|
|
73
|
+
baseline = JSON.stringify(latest.delivery);
|
|
74
|
+
lobbyTopics.bump("tasks");
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
/** The state background callbacks report to; set while the lobby service runs. */
|
|
44
80
|
let serviceState: Runtime | undefined;
|
|
45
81
|
|
|
@@ -210,6 +246,20 @@ function sessionsChanged(): void {
|
|
|
210
246
|
}
|
|
211
247
|
}
|
|
212
248
|
|
|
249
|
+
function dialogTracker(state: Runtime): (id: string, pending: boolean, sessionId?: string) => void {
|
|
250
|
+
const requests = new Map<string, { taskId: string; sessionId: string; requestId: string }>();
|
|
251
|
+
const prefix = `dialog:${randomUUID()}`;
|
|
252
|
+
return (id, pending, sessionId) => {
|
|
253
|
+
if (!sessionId) return;
|
|
254
|
+
const task = pending ? ownedTask(state.root, state.configDir, sessionId) : undefined;
|
|
255
|
+
if (task && !requests.has(id)) requests.set(id, { taskId: task.id, sessionId, requestId: `${prefix}:${id}` });
|
|
256
|
+
const request = requests.get(id);
|
|
257
|
+
if (!request) return;
|
|
258
|
+
updateBlockingRequest({ root: state.root, configDir: state.configDir, ...request }, request.requestId, pending);
|
|
259
|
+
if (!pending) requests.delete(id);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
213
263
|
/** Start a task in its own new pi session, named after the task; the session, or why not. */
|
|
214
264
|
function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
|
|
215
265
|
const request = start.request?.trim();
|
|
@@ -224,7 +274,7 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
|
|
|
224
274
|
// A request may lead with flags (`--worktree add login`); the session is named after what follows them.
|
|
225
275
|
const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
|
|
226
276
|
try {
|
|
227
|
-
const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
277
|
+
const session = sessionRegistry().start(state.ctx.cwd, { name, projectRoot: state.root, onDialog: dialogTracker(state), ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
228
278
|
lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
|
|
229
279
|
return session;
|
|
230
280
|
} catch (error) {
|
|
@@ -520,6 +570,26 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
520
570
|
feed: lobbyFeed,
|
|
521
571
|
masterBusy: () => !state.ctx.isIdle(),
|
|
522
572
|
tasks: () => peekTasks(state.root, state.configDir),
|
|
573
|
+
deliveryDeliver: (taskId, request: DeliveryRequest) => deliver(request, { cwd: state.root, exec: execCommand }, deliveryStore(state, taskId)),
|
|
574
|
+
deliveryReview: async (taskId) => {
|
|
575
|
+
const task = deliveryTask(state, taskId);
|
|
576
|
+
const baseline = JSON.stringify(task.delivery);
|
|
577
|
+
const delivery = await refreshReview(task, { cwd: state.root, exec: execCommand });
|
|
578
|
+
// An async lookup must not overwrite intervening deferrals or operations.
|
|
579
|
+
const latest = deliveryTask(state, taskId);
|
|
580
|
+
if (JSON.stringify(latest.delivery) !== baseline) throw new Error("Review changed during lookup; refresh again.");
|
|
581
|
+
latest.delivery = delivery;
|
|
582
|
+
saveTask(state.root, state.configDir, latest);
|
|
583
|
+
lobbyTopics.bump("tasks");
|
|
584
|
+
return delivery;
|
|
585
|
+
},
|
|
586
|
+
deliveryDefer: (taskId, reviewId) => {
|
|
587
|
+
const task = deliveryTask(state, taskId);
|
|
588
|
+
const delivery = deferReview(task, reviewId);
|
|
589
|
+
saveTask(state.root, state.configDir, task);
|
|
590
|
+
lobbyTopics.bump("tasks");
|
|
591
|
+
return delivery;
|
|
592
|
+
},
|
|
523
593
|
plans: () => listPlannedTasks(state.root, state.configDir),
|
|
524
594
|
comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
|
|
525
595
|
metrics: () => readMetrics(state.root, state.configDir),
|
|
@@ -584,7 +654,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
584
654
|
workspace: () => state.workspace,
|
|
585
655
|
refreshWorkspace: () => refreshWorkspace(state),
|
|
586
656
|
sessionName: () => state.pi.getSessionName(),
|
|
587
|
-
sessions: () => backgroundSessions(),
|
|
657
|
+
sessions: () => backgroundSessions().filter((session) => session.projectRoot === state.root),
|
|
588
658
|
startSession: (start) => startSession(state, start),
|
|
589
659
|
isAuto: (taskId) => autoFor(state, taskId),
|
|
590
660
|
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -39,6 +39,9 @@ export interface SessionProcess {
|
|
|
39
39
|
export type SessionStatus = "starting" | "idle" | "working" | "exited";
|
|
40
40
|
|
|
41
41
|
export interface SessionStart {
|
|
42
|
+
/** Server-owned task association; never supplied by a browser payload. */
|
|
43
|
+
onDialog?: (id: string, pending: boolean, sessionId?: string) => void;
|
|
44
|
+
projectRoot?: string;
|
|
42
45
|
/** The display name: the task's title. */
|
|
43
46
|
name: string;
|
|
44
47
|
/** What to send once it is up: the request, or a planned task to start. */
|
|
@@ -54,6 +57,7 @@ export const STOP_GRACE_MS = 5000;
|
|
|
54
57
|
|
|
55
58
|
export class BackgroundSession {
|
|
56
59
|
readonly key: string;
|
|
60
|
+
readonly projectRoot?: string;
|
|
57
61
|
readonly name: string;
|
|
58
62
|
/** The planned task it was started from. */
|
|
59
63
|
readonly planId?: string;
|
|
@@ -67,6 +71,7 @@ export class BackgroundSession {
|
|
|
67
71
|
exitCode?: number | null;
|
|
68
72
|
/** Dialogs the session waits on, oldest first. */
|
|
69
73
|
readonly dialogs: SessionDialog[] = [];
|
|
74
|
+
private readonly onDialog: NonNullable<SessionStart["onDialog"]>;
|
|
70
75
|
private readonly proc: SessionProcess;
|
|
71
76
|
private buffer = "";
|
|
72
77
|
private stderr = "";
|
|
@@ -79,7 +84,9 @@ export class BackgroundSession {
|
|
|
79
84
|
counter += 1;
|
|
80
85
|
this.key = `S${counter}`;
|
|
81
86
|
this.name = start.name;
|
|
87
|
+
this.projectRoot = start.projectRoot;
|
|
82
88
|
if (start.planId) this.planId = start.planId;
|
|
89
|
+
this.onDialog = start.onDialog ?? (() => {});
|
|
83
90
|
this.proc = proc;
|
|
84
91
|
this.onChange = onChange;
|
|
85
92
|
proc.stdout?.on("data", (chunk) => this.receive(String(chunk)));
|
|
@@ -102,6 +109,7 @@ export class BackgroundSession {
|
|
|
102
109
|
if (this.status === "exited") return;
|
|
103
110
|
this.status = "exited";
|
|
104
111
|
this.exitCode = code;
|
|
112
|
+
for (const dialog of this.dialogs) this.onDialog(dialog.id, false, this.sessionId);
|
|
105
113
|
this.dialogs.length = 0;
|
|
106
114
|
if (this.killTimer) clearTimeout(this.killTimer);
|
|
107
115
|
this.feed.say("note", reason);
|
|
@@ -148,6 +156,7 @@ export class BackgroundSession {
|
|
|
148
156
|
|
|
149
157
|
/** Stop the oracle's running turn (the session stays up). */
|
|
150
158
|
abort(): void {
|
|
159
|
+
for (const dialog of [...this.dialogs]) this.answer(dialog.id, { cancelled: true });
|
|
151
160
|
if (this.busy) this.write({ type: "abort" });
|
|
152
161
|
}
|
|
153
162
|
|
|
@@ -156,6 +165,7 @@ export class BackgroundSession {
|
|
|
156
165
|
const index = this.dialogs.findIndex((dialog) => dialog.id === id);
|
|
157
166
|
if (index < 0) return;
|
|
158
167
|
this.dialogs.splice(index, 1);
|
|
168
|
+
this.onDialog(id, false, this.sessionId);
|
|
159
169
|
if (this.alive && this.proc.stdin) this.proc.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, ...answer })}\n`);
|
|
160
170
|
this.onChange();
|
|
161
171
|
}
|
|
@@ -213,6 +223,7 @@ export class BackgroundSession {
|
|
|
213
223
|
if (event.command === "get_state" && data) {
|
|
214
224
|
this.sessionId = data.sessionId;
|
|
215
225
|
this.sessionFile = data.sessionFile;
|
|
226
|
+
for (const dialog of this.dialogs) this.onDialog(dialog.id, true, this.sessionId);
|
|
216
227
|
if (this.status === "starting") this.status = "idle";
|
|
217
228
|
} else if (event.success === false && typeof event.error === "string") {
|
|
218
229
|
this.feed.log("LOBBY", `the session refused a request: ${event.error}`, "warning");
|
|
@@ -240,6 +251,7 @@ export class BackgroundSession {
|
|
|
240
251
|
...(typeof event.placeholder === "string" ? { placeholder: event.placeholder } : {}),
|
|
241
252
|
...(typeof event.prefill === "string" ? { prefill: event.prefill } : {}),
|
|
242
253
|
});
|
|
254
|
+
this.onDialog(id, true, this.sessionId);
|
|
243
255
|
this.feed.log("LOBBY", `waiting for you: ${String(event.title ?? "a question").split("\n")[0]}`, "warning");
|
|
244
256
|
} else if (method === "notify") {
|
|
245
257
|
const level = event.notifyType === "error" ? "error" : event.notifyType === "warning" ? "warning" : "info";
|
package/src/lobby/task-rows.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/** The shapes of the Tasks tab's rows, shared by the web API and the page's words. */
|
|
2
2
|
|
|
3
|
+
import type { TimingProjection } from "../state/phase-timing.ts";
|
|
4
|
+
import type { Delivery } from "../delivery/types.ts";
|
|
5
|
+
|
|
3
6
|
export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
|
|
4
7
|
|
|
5
8
|
/** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
|
|
6
9
|
export type CheckState = "open" | "done" | "dropped";
|
|
7
10
|
|
|
8
11
|
export interface TaskRow {
|
|
12
|
+
timing?: TimingProjection;
|
|
13
|
+
delivery?: Pick<Delivery, "status" | "reviewId">;
|
|
9
14
|
/** A task on the list, a saved plan, or a task in the archive. */
|
|
10
15
|
kind: "task" | "plan" | "archived";
|
|
11
16
|
id: string;
|
package/src/master/master.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface ScoutRequest {
|
|
|
52
52
|
/** Model, thinking and time limit for one run, from settings. */
|
|
53
53
|
function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
|
|
54
54
|
const profile = profileFor(config, resolver, domain, role);
|
|
55
|
-
return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs
|
|
55
|
+
return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function scoutInstruction(request: ScoutRequest, domain: Domain): string {
|
package/src/master/research.ts
CHANGED
|
@@ -88,7 +88,6 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
|
|
|
88
88
|
model: profile.model,
|
|
89
89
|
thinking: profile.thinking,
|
|
90
90
|
timeoutMs: profile.timeoutMs,
|
|
91
|
-
...(profile.fallback ? { fallback: profile.fallback } : {}),
|
|
92
91
|
...(request.time ? { time: request.time } : {}),
|
|
93
92
|
cwd: request.cwd,
|
|
94
93
|
signal: request.signal,
|
package/src/pi/events.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { selectKnowledge } from "../knowledge/selector.ts";
|
|
|
7
7
|
import { cancelAllRuns } from "../execution/agent-runner.ts";
|
|
8
8
|
import { describeTask } from "../workflow/workflow.ts";
|
|
9
9
|
import { truncate } from "../text.ts";
|
|
10
|
-
import { registerMasterFallback } from "./master-fallback.ts";
|
|
11
10
|
import { applyStatus, clearStatus, isMinimized, setMinimized } from "./ui.ts";
|
|
12
11
|
import { isSubagentProcess, webToolsFor } from "./quiet.ts";
|
|
13
12
|
import { registerQuietTools } from "./tool-renderers.ts";
|
|
@@ -65,7 +64,6 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
|
|
|
65
64
|
* section rather than replacing the whole prompt.
|
|
66
65
|
*/
|
|
67
66
|
export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
68
|
-
registerMasterFallback(pi);
|
|
69
67
|
// Web tools the oracle put away for the task in hand, given back when it ends.
|
|
70
68
|
let hiddenWeb: string[] = [];
|
|
71
69
|
pi.on("session_start", (_event, ctx) => {
|