pi-workflow-engine 0.11.0 → 0.12.0
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/.pi/extensions/pi-workflow-engine/index.ts +255 -97
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +72 -141
- package/USAGE.md +211 -17
- package/package.json +1 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
5
|
+
import type { WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
6
|
+
import {
|
|
7
|
+
isWorkflowRunRecord,
|
|
8
|
+
transitionWorkflowRun,
|
|
9
|
+
updateWorkflowRunProgress,
|
|
10
|
+
type WorkflowRunRecord,
|
|
11
|
+
type WorkflowRunTransition,
|
|
12
|
+
} from "./workflow-run-record.ts";
|
|
13
|
+
|
|
14
|
+
export const WORKFLOW_RUN_RECORD_KEEP = 50;
|
|
15
|
+
export const WORKFLOW_RUN_RECORD_SUFFIX = ".run.json";
|
|
16
|
+
|
|
17
|
+
const MAX_RECORD_BYTES = 4 << 20;
|
|
18
|
+
|
|
19
|
+
export interface WorkflowRunStore {
|
|
20
|
+
save(record: WorkflowRunRecord): Promise<void>;
|
|
21
|
+
load(runId: string): Promise<WorkflowRunRecord | undefined>;
|
|
22
|
+
list(): Promise<WorkflowRunRecord[]>;
|
|
23
|
+
prune(keep?: number): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class ProjectWorkflowRunStore implements WorkflowRunStore {
|
|
27
|
+
constructor(private readonly cwd: string) {}
|
|
28
|
+
|
|
29
|
+
async save(record: WorkflowRunRecord): Promise<void> {
|
|
30
|
+
const path = workflowRunRecordPath(this.cwd, record.runId);
|
|
31
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
32
|
+
const content = `${JSON.stringify(record)}\n`;
|
|
33
|
+
if (Buffer.byteLength(content) > MAX_RECORD_BYTES) {
|
|
34
|
+
throw new Error(`Workflow run record exceeded ${MAX_RECORD_BYTES} bytes.`);
|
|
35
|
+
}
|
|
36
|
+
await mkdir(dirname(path), { recursive: true });
|
|
37
|
+
try {
|
|
38
|
+
await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
|
|
39
|
+
await rename(temporaryPath, path);
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async load(runId: string): Promise<WorkflowRunRecord | undefined> {
|
|
46
|
+
return await loadWorkflowRunRecord(workflowRunRecordPath(this.cwd, runId));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async list(): Promise<WorkflowRunRecord[]> {
|
|
50
|
+
const dir = join(this.cwd, WORKFLOW_RUNS_DIR);
|
|
51
|
+
let files: string[];
|
|
52
|
+
try {
|
|
53
|
+
files = await readdir(dir);
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const records = await Promise.all(
|
|
58
|
+
files
|
|
59
|
+
.filter((file) => file.endsWith(WORKFLOW_RUN_RECORD_SUFFIX))
|
|
60
|
+
.map((file) => loadWorkflowRunRecord(join(dir, file))),
|
|
61
|
+
);
|
|
62
|
+
return records
|
|
63
|
+
.filter((record): record is WorkflowRunRecord => record !== undefined)
|
|
64
|
+
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async prune(keep = WORKFLOW_RUN_RECORD_KEEP): Promise<void> {
|
|
68
|
+
const dir = join(this.cwd, WORKFLOW_RUNS_DIR);
|
|
69
|
+
let files: string[];
|
|
70
|
+
try {
|
|
71
|
+
files = await readdir(dir);
|
|
72
|
+
} catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const candidates = await Promise.all(
|
|
76
|
+
files
|
|
77
|
+
.filter((file) => file.endsWith(WORKFLOW_RUN_RECORD_SUFFIX))
|
|
78
|
+
.map(async (file) => {
|
|
79
|
+
const path = join(dir, file);
|
|
80
|
+
try {
|
|
81
|
+
const info = await stat(path);
|
|
82
|
+
return { path, mtimeMs: info.mtimeMs };
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
const stale = candidates
|
|
89
|
+
.filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
|
|
90
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
|
91
|
+
.slice(Math.max(0, keep));
|
|
92
|
+
await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Owns one run record and coalesces progress writes while a prior atomic save is in flight. */
|
|
97
|
+
export class DurableWorkflowRun {
|
|
98
|
+
private record: WorkflowRunRecord;
|
|
99
|
+
private pending: WorkflowRunRecord | undefined;
|
|
100
|
+
private draining: Promise<void> | undefined;
|
|
101
|
+
private writeFailure: unknown;
|
|
102
|
+
private writeFailed = false;
|
|
103
|
+
|
|
104
|
+
constructor(
|
|
105
|
+
private readonly store: WorkflowRunStore,
|
|
106
|
+
initial: WorkflowRunRecord,
|
|
107
|
+
private readonly onWriteFailure?: (error: unknown) => void,
|
|
108
|
+
) {
|
|
109
|
+
this.record = initial;
|
|
110
|
+
this.queue(initial);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
updateProgress(progress: WorkflowProgressSnapshot, at = Date.now()): void {
|
|
114
|
+
this.record = updateWorkflowRunProgress(this.record, progress, at);
|
|
115
|
+
this.queue(this.record);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
transition(transition: WorkflowRunTransition): void {
|
|
119
|
+
this.record = transitionWorkflowRun(this.record, transition);
|
|
120
|
+
this.queue(this.record);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async flush(): Promise<void> {
|
|
124
|
+
while (this.draining) await this.draining;
|
|
125
|
+
if (this.writeFailed) throw this.writeFailure;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private queue(record: WorkflowRunRecord): void {
|
|
129
|
+
this.pending = record;
|
|
130
|
+
if (this.draining) return;
|
|
131
|
+
this.draining = this.drain().finally(() => {
|
|
132
|
+
this.draining = undefined;
|
|
133
|
+
if (this.pending) this.queue(this.pending);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private async drain(): Promise<void> {
|
|
138
|
+
while (this.pending) {
|
|
139
|
+
const next = this.pending;
|
|
140
|
+
this.pending = undefined;
|
|
141
|
+
try {
|
|
142
|
+
await this.store.save(next);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
this.writeFailed = true;
|
|
145
|
+
this.writeFailure = error;
|
|
146
|
+
try {
|
|
147
|
+
this.onWriteFailure?.(error);
|
|
148
|
+
} catch {
|
|
149
|
+
// Persistence observers must not create an unhandled rejection.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function workflowRunRecordPath(cwd: string, runId: string): string {
|
|
157
|
+
return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}${WORKFLOW_RUN_RECORD_SUFFIX}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function loadWorkflowRunRecord(path: string): Promise<WorkflowRunRecord | undefined> {
|
|
161
|
+
try {
|
|
162
|
+
const content = await readFile(path, "utf8");
|
|
163
|
+
if (Buffer.byteLength(content) > MAX_RECORD_BYTES) return undefined;
|
|
164
|
+
const parsed = JSON.parse(content) as unknown;
|
|
165
|
+
if (!isWorkflowRunRecord(parsed)) return undefined;
|
|
166
|
+
const file = basename(path);
|
|
167
|
+
const pathRunId = file.slice(0, -WORKFLOW_RUN_RECORD_SUFFIX.length);
|
|
168
|
+
return parsed.runId === pathRunId ? parsed : undefined;
|
|
169
|
+
} catch {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { WorkflowRunRecord } from "./workflow-run-record.ts";
|
|
3
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
4
|
+
|
|
5
|
+
export interface WorkflowUsageLimitSchedulerClock {
|
|
6
|
+
now(): number;
|
|
7
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
8
|
+
clearTimeout(handle: unknown): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ScheduledUsageLimitResume {
|
|
12
|
+
readonly sessionId: string;
|
|
13
|
+
readonly handle: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const defaultWorkflowUsageLimitSchedulerClock: WorkflowUsageLimitSchedulerClock = {
|
|
17
|
+
now: () => Date.now(),
|
|
18
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
19
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** One in-process timer per paused run. Durable records remain the source of truth. */
|
|
23
|
+
export class WorkflowUsageLimitScheduler {
|
|
24
|
+
private readonly scheduled = new Map<string, ScheduledUsageLimitResume>();
|
|
25
|
+
private readonly inactiveSessions = new Set<string>();
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly resume: (ctx: ExtensionContext, runId: string, attempt: number) => Promise<void>,
|
|
29
|
+
private readonly clock: WorkflowUsageLimitSchedulerClock = defaultWorkflowUsageLimitSchedulerClock,
|
|
30
|
+
private readonly log: (message: string) => void = (message) => process.stderr.write(`${message}\n`),
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
arm(ctx: ExtensionContext, record: WorkflowRunRecord): boolean {
|
|
34
|
+
if (record.state !== "paused" || record.pause?.kind !== "provider_usage_limit") return false;
|
|
35
|
+
const pause = record.pause;
|
|
36
|
+
if (!pause.autoResume || pause.attempt >= pause.maxAttempts) return false;
|
|
37
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
38
|
+
if (
|
|
39
|
+
this.inactiveSessions.has(sessionId)
|
|
40
|
+
|| record.background?.origin.sessionId !== sessionId
|
|
41
|
+
|| this.scheduled.has(record.runId)
|
|
42
|
+
) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const delayMs = Math.max(0, pause.nextEligibleAt - this.clock.now());
|
|
47
|
+
const handle = this.clock.setTimeout(() => {
|
|
48
|
+
this.scheduled.delete(record.runId);
|
|
49
|
+
void this.resume(ctx, record.runId, pause.attempt).catch((error: unknown) => {
|
|
50
|
+
this.log(`[workflow:${record.runId}] automatic provider-limit resume failed: ${unknownErrorMessage(error)}`);
|
|
51
|
+
});
|
|
52
|
+
}, delayMs);
|
|
53
|
+
this.scheduled.set(record.runId, { sessionId, handle });
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cancel(runId: string): boolean {
|
|
58
|
+
const scheduled = this.scheduled.get(runId);
|
|
59
|
+
if (!scheduled) return false;
|
|
60
|
+
this.clock.clearTimeout(scheduled.handle);
|
|
61
|
+
this.scheduled.delete(runId);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cancelSession(ctx: Pick<ExtensionContext, "sessionManager">): void {
|
|
66
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
67
|
+
this.inactiveSessions.add(sessionId);
|
|
68
|
+
for (const [runId, scheduled] of this.scheduled) {
|
|
69
|
+
if (scheduled.sessionId === sessionId) this.cancel(runId);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
activateSession(ctx: Pick<ExtensionContext, "sessionManager">): void {
|
|
74
|
+
this.inactiveSessions.delete(ctx.sessionManager.getSessionId());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
has(runId: string): boolean {
|
|
78
|
+
return this.scheduled.has(runId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -3,6 +3,7 @@ import * as codeReview from "../workflows/code-review.ts";
|
|
|
3
3
|
import * as refactorScout from "../workflows/refactor-scout.ts";
|
|
4
4
|
import * as diagnose from "../workflows/diagnose.ts";
|
|
5
5
|
import * as perfReview from "../workflows/perf-review.ts";
|
|
6
|
+
import * as research from "../workflows/research.ts";
|
|
6
7
|
import type { WorkflowModule } from "./types.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -33,6 +34,7 @@ export const BUILTIN_WORKFLOW_DEFINITIONS: readonly BuiltinWorkflowDefinition[]
|
|
|
33
34
|
defineBuiltinWorkflow(refactorScout, "refactor-scout.ts"),
|
|
34
35
|
defineBuiltinWorkflow(diagnose, "diagnose.ts"),
|
|
35
36
|
defineBuiltinWorkflow(perfReview, "perf-review.ts"),
|
|
37
|
+
defineBuiltinWorkflow(research, "research.ts"),
|
|
36
38
|
];
|
|
37
39
|
export const BUILTIN_WORKFLOWS = BUILTIN_WORKFLOW_DEFINITIONS.map(({ module }) => module);
|
|
38
40
|
export const BUILTIN_WORKFLOW_FILES = new Set(BUILTIN_WORKFLOW_DEFINITIONS.map(({ filename }) => filename));
|
|
@@ -266,11 +266,48 @@ export async function addWorktree(options: {
|
|
|
266
266
|
const message = result.error ?? (result.stderr.trim() || "git worktree add failed");
|
|
267
267
|
if (!isInvalidHeadError(message)) return { path, error: message };
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
return await addUnbornRepositoryWorktree({
|
|
270
|
+
repoCwd: options.repoCwd,
|
|
271
|
+
path,
|
|
272
|
+
addError: message,
|
|
273
|
+
runner,
|
|
274
|
+
signal: options.signal,
|
|
275
|
+
snapshotTimeoutMs: options.timeoutMs,
|
|
276
|
+
timeoutMs,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function addUnbornRepositoryWorktree(options: {
|
|
281
|
+
readonly repoCwd: string;
|
|
282
|
+
readonly path: string;
|
|
283
|
+
readonly addError: string;
|
|
284
|
+
readonly runner: WorktreeGitRunner;
|
|
285
|
+
readonly signal?: AbortSignal;
|
|
286
|
+
readonly snapshotTimeoutMs?: number;
|
|
287
|
+
readonly timeoutMs: number;
|
|
288
|
+
}): Promise<WorktreeRef | WorktreeAddFailure> {
|
|
289
|
+
const snapshotError = await createUnbornRepoSnapshot({
|
|
290
|
+
repoCwd: options.repoCwd,
|
|
291
|
+
path: options.path,
|
|
292
|
+
timeoutMs: options.snapshotTimeoutMs,
|
|
293
|
+
signal: options.signal,
|
|
294
|
+
})
|
|
270
295
|
.then(() => undefined)
|
|
271
296
|
.catch((error: unknown) => unknownErrorMessage(error));
|
|
272
|
-
if (snapshotError !== undefined)
|
|
273
|
-
|
|
297
|
+
if (snapshotError !== undefined) {
|
|
298
|
+
return {
|
|
299
|
+
path: options.path,
|
|
300
|
+
snapshot: true,
|
|
301
|
+
error: `git worktree add failed (${options.addError}); unborn-repo snapshot fallback failed: ${snapshotError}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return await finalizeWorktreeRef({
|
|
305
|
+
path: options.path,
|
|
306
|
+
snapshot: true,
|
|
307
|
+
runner: options.runner,
|
|
308
|
+
signal: options.signal,
|
|
309
|
+
timeoutMs: options.timeoutMs,
|
|
310
|
+
});
|
|
274
311
|
}
|
|
275
312
|
|
|
276
313
|
async function finalizeWorktreeRef(options: {
|
|
@@ -12,13 +12,15 @@ import {
|
|
|
12
12
|
formatLocation,
|
|
13
13
|
normalizePath,
|
|
14
14
|
primaryLocation,
|
|
15
|
+
publishVerifiedKeptProgress,
|
|
15
16
|
recordVerdictProgress,
|
|
16
17
|
verdictConfidence,
|
|
17
18
|
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
18
19
|
DEFAULT_ADVISORY_TOOLS,
|
|
19
20
|
} from "../src/workflow-advisory-utils.ts";
|
|
20
21
|
import { compactResults } from "../src/concurrency.ts";
|
|
21
|
-
import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/diff-
|
|
22
|
+
import { formatReviewDiffTarget, parseAllowedDiffCommand } from "../src/review-diff-target.ts";
|
|
23
|
+
import { buildCodeReviewScopeBlock, dedupeCodeReviewCandidates } from "../src/review/code-review-orchestration.ts";
|
|
22
24
|
import type { ReviewContext } from "../src/review/review-report.ts";
|
|
23
25
|
import { captureReviewMaterial, type ReviewMaterialCaptureResult } from "../src/review/review-snapshot.ts";
|
|
24
26
|
import type { WorkflowApi, WorkflowMeta, WorkflowRunStats } from "../src/types.ts";
|
|
@@ -62,7 +64,6 @@ const ANGLES: Angle[] = [
|
|
|
62
64
|
const TOOLS = DEFAULT_ADVISORY_TOOLS;
|
|
63
65
|
const TOOL_HINTS = DEFAULT_ADVISORY_TOOL_HINTS;
|
|
64
66
|
const PER_ANGLE = 6;
|
|
65
|
-
const DIFF_EMBED_CAP = 60_000;
|
|
66
67
|
|
|
67
68
|
/** Parse a unified diff into the set of added/changed new-file line numbers per file. */
|
|
68
69
|
export function changedLines(diff: string): Map<string, Set<number>> {
|
|
@@ -137,7 +138,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
137
138
|
"Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
|
|
138
139
|
"and read any relevant AGENTS.md or project docs noting conventions a reviewer should know.\n" +
|
|
139
140
|
"Return diffCommand exactly as a reviewer should run it. Structured output only.",
|
|
140
|
-
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS,
|
|
141
|
+
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
|
|
141
142
|
);
|
|
142
143
|
|
|
143
144
|
if (!scope) {
|
|
@@ -183,27 +184,14 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
183
184
|
: {}),
|
|
184
185
|
};
|
|
185
186
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const scopeBlock =
|
|
195
|
-
`## Diff command\n${diffCommand}\n\n## Changed files\n${scope.files.map((file) => `- ${file}`).join("\n")}\n\n` +
|
|
196
|
-
`## Summary\n${scope.summary}\n\n## Conventions\n${scope.conventions ?? "(none noted)"}\n` +
|
|
197
|
-
diffBlock +
|
|
198
|
-
(target ? `\n## User instructions (verbatim)\n${target}\n` : "");
|
|
199
|
-
|
|
200
|
-
// Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
|
|
201
|
-
const seen = new Set<string>();
|
|
202
|
-
const dedupKey = (candidate: Candidate): string => {
|
|
203
|
-
const location = primaryLocation(candidate);
|
|
204
|
-
const lineKey = location.line != null ? Math.round(location.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase();
|
|
205
|
-
return `${normalizePath(location.file)}:${lineKey}`;
|
|
206
|
-
};
|
|
187
|
+
const scopeBlock = buildCodeReviewScopeBlock({
|
|
188
|
+
diffCommand,
|
|
189
|
+
files: scope.files,
|
|
190
|
+
summary: scope.summary,
|
|
191
|
+
conventions: scope.conventions,
|
|
192
|
+
diffText,
|
|
193
|
+
target,
|
|
194
|
+
});
|
|
207
195
|
|
|
208
196
|
// ─── Find barrier → dedup → Verify ───
|
|
209
197
|
phase("Find");
|
|
@@ -217,7 +205,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
217
205
|
`Surface up to ${PER_ANGLE} candidates. Use category exactly "${angle.kind}". Each candidate must include a one-line summary, ` +
|
|
218
206
|
"locations with the changed file and a line that appears in the diff, and impact describing the concrete failure or maintenance scenario. " +
|
|
219
207
|
"Pass through anything with a nameable impact — a separate verifier judges them next. Structured output only.",
|
|
220
|
-
{ phase: "Find", label: `find:${angle.label}`, tools: TOOLS, toolHints: TOOL_HINTS,
|
|
208
|
+
{ phase: "Find", label: `find:${angle.label}`, tools: TOOLS, toolHints: TOOL_HINTS, profile: "small", schema: AdvisoryCandidatesSchema },
|
|
221
209
|
);
|
|
222
210
|
const raw = (found?.candidates ?? []).slice(0, PER_ANGLE);
|
|
223
211
|
rawCandidateCount += raw.length;
|
|
@@ -243,16 +231,8 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
243
231
|
}),
|
|
244
232
|
);
|
|
245
233
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
.filter((candidate) => {
|
|
249
|
-
const key = dedupKey(candidate);
|
|
250
|
-
if (seen.has(key)) return false;
|
|
251
|
-
seen.add(key);
|
|
252
|
-
return true;
|
|
253
|
-
})
|
|
254
|
-
.map((candidate) => ({ angle, candidate })),
|
|
255
|
-
);
|
|
234
|
+
// Dedup after all finders complete so verifier agents cannot consume the global cap before full candidate discovery.
|
|
235
|
+
const novel = dedupeCodeReviewCandidates(compactResults(perAngle));
|
|
256
236
|
|
|
257
237
|
phase("Verify");
|
|
258
238
|
const verdicts = await parallel(
|
|
@@ -269,7 +249,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
269
249
|
label: `verify:${location.file.split("/").pop() ?? location.file}`,
|
|
270
250
|
tools: TOOLS,
|
|
271
251
|
toolHints: TOOL_HINTS,
|
|
272
|
-
|
|
252
|
+
profile: "small",
|
|
273
253
|
schema: AdvisoryVerdictSchema,
|
|
274
254
|
},
|
|
275
255
|
);
|
|
@@ -282,11 +262,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
282
262
|
const verified = compactResults(verdicts);
|
|
283
263
|
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
284
264
|
const stats = makeStats(verified.length, surviving.length);
|
|
285
|
-
|
|
286
|
-
progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
|
|
287
|
-
progress({ type: "summary", key: "verified", value: verified.length });
|
|
288
|
-
progress({ type: "summary", key: "kept", value: surviving.length });
|
|
289
|
-
log(`${verified.length} verified → ${surviving.length} kept`);
|
|
265
|
+
publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
|
|
290
266
|
|
|
291
267
|
if (surviving.length === 0) {
|
|
292
268
|
return { summary: "No findings survived verification.", findings: [], nextSteps: ["No code-review action is recommended from this workflow run."], stats, reviewContext };
|
|
@@ -314,7 +290,7 @@ export default async function run(api: WorkflowApi, dependencies: CodeReviewDepe
|
|
|
314
290
|
phase: "Synthesize",
|
|
315
291
|
label: "synthesize",
|
|
316
292
|
tools: [],
|
|
317
|
-
|
|
293
|
+
profile: "medium",
|
|
318
294
|
resume: "read-only",
|
|
319
295
|
schema: AdvisoryReportSchema,
|
|
320
296
|
},
|
|
@@ -4,15 +4,16 @@ import {
|
|
|
4
4
|
AdvisoryReportSchema,
|
|
5
5
|
AdvisoryVerdictSchema,
|
|
6
6
|
type AdvisoryCandidate,
|
|
7
|
-
type AdvisoryReport,
|
|
8
7
|
type AdvisoryVerdict,
|
|
9
8
|
} from "../src/advisory-schema.ts";
|
|
10
9
|
import {
|
|
11
10
|
advisoryDedupKey as dedupKey,
|
|
12
11
|
backfillAdvisoryFindings,
|
|
12
|
+
emptyAdvisoryReport,
|
|
13
13
|
formatEvidence,
|
|
14
14
|
formatLocation,
|
|
15
15
|
primaryLocation,
|
|
16
|
+
publishVerifiedKeptProgress,
|
|
16
17
|
recordVerdictProgress,
|
|
17
18
|
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
18
19
|
DEFAULT_ADVISORY_TOOLS,
|
|
@@ -89,11 +90,11 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
89
90
|
"Inspect relevant files, package/test configuration, and safe diagnostic commands. " +
|
|
90
91
|
"Safe commands are read-only commands such as status, grep, listing files, typecheck/test commands, or commands explicitly requested by the user. " +
|
|
91
92
|
"Do not run mutation, install, commit, network, or destructive commands. Return scoped files, observations, and constraints. Structured output only.",
|
|
92
|
-
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS,
|
|
93
|
+
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
|
|
93
94
|
);
|
|
94
95
|
|
|
95
96
|
if (!scope) {
|
|
96
|
-
return
|
|
97
|
+
return emptyAdvisoryReport(
|
|
97
98
|
"Diagnosis could not establish a scope.",
|
|
98
99
|
["Provide the failing command, error message, or regression description and rerun diagnose."],
|
|
99
100
|
makeStats(0, 0),
|
|
@@ -121,7 +122,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
121
122
|
`Consider ONLY this hypothesis lens:\n${lens.text}\n\n` +
|
|
122
123
|
`Surface up to ${PER_LENS} root-cause hypotheses. Use category exactly "${lens.category}". ` +
|
|
123
124
|
"Each hypothesis must include a one-line summary, locations, impact explaining how it produces the symptom, and an optional recommendation for the next validation step. Structured output only.",
|
|
124
|
-
{ phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, toolHints: TOOL_HINTS,
|
|
125
|
+
{ phase: "Hypothesize", label: `hypothesize:${lens.label}`, tools: TOOLS, toolHints: TOOL_HINTS, profile: "small", schema: AdvisoryCandidatesSchema },
|
|
125
126
|
);
|
|
126
127
|
const candidates = (found?.candidates ?? []).slice(0, PER_LENS).map((candidate) => ({ ...candidate, lens }));
|
|
127
128
|
rawCandidateCount += candidates.length;
|
|
@@ -162,7 +163,7 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
162
163
|
label: `verify:${location.file.split("/").pop() ?? location.file}`,
|
|
163
164
|
tools: TOOLS,
|
|
164
165
|
toolHints: TOOL_HINTS,
|
|
165
|
-
|
|
166
|
+
profile: "small",
|
|
166
167
|
schema: AdvisoryVerdictSchema,
|
|
167
168
|
},
|
|
168
169
|
);
|
|
@@ -178,14 +179,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
178
179
|
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
179
180
|
const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
|
|
180
181
|
const stats = makeStats(verified.length, surviving.length);
|
|
181
|
-
|
|
182
|
-
progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
|
|
183
|
-
progress({ type: "summary", key: "verified", value: verified.length });
|
|
184
|
-
progress({ type: "summary", key: "kept", value: surviving.length });
|
|
185
|
-
log(`${verified.length} verified → ${surviving.length} kept`);
|
|
182
|
+
publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
|
|
186
183
|
|
|
187
184
|
if (surviving.length === 0) {
|
|
188
|
-
return
|
|
185
|
+
return emptyAdvisoryReport(
|
|
189
186
|
"No root-cause hypothesis survived verification.",
|
|
190
187
|
["Capture the exact failing command and error output.", "Rerun diagnose with a narrower symptom or more evidence."],
|
|
191
188
|
stats,
|
|
@@ -216,13 +213,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
216
213
|
phase: "Synthesize",
|
|
217
214
|
label: "synthesize",
|
|
218
215
|
tools: [],
|
|
219
|
-
|
|
216
|
+
profile: "medium",
|
|
220
217
|
resume: "read-only",
|
|
221
218
|
schema: AdvisoryReportSchema,
|
|
222
219
|
},
|
|
223
220
|
);
|
|
224
221
|
|
|
225
|
-
if (!report) return
|
|
222
|
+
if (!report) return emptyAdvisoryReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun diagnose with a narrower symptom."], stats);
|
|
226
223
|
|
|
227
224
|
const findings = backfillAdvisoryFindings(report.findings, ranked, {
|
|
228
225
|
impact: "Impact not restated by synthesis.",
|
|
@@ -231,10 +228,6 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
231
228
|
return { ...report, findings, stats };
|
|
232
229
|
}
|
|
233
230
|
|
|
234
|
-
function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
|
|
235
|
-
return { summary, findings: [], nextSteps, stats };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
231
|
function dedupe(candidates: Hypothesis[], onDropped: (dropped: number) => void): Hypothesis[] {
|
|
239
232
|
const seen = new Set<string>();
|
|
240
233
|
const novel: Hypothesis[] = [];
|
|
@@ -2,13 +2,14 @@ import { Type } from "typebox";
|
|
|
2
2
|
import {
|
|
3
3
|
AdvisoryReportSchema,
|
|
4
4
|
type AdvisoryCandidate,
|
|
5
|
-
type AdvisoryReport,
|
|
6
5
|
type AdvisoryVerdict,
|
|
7
6
|
} from "../src/advisory-schema.ts";
|
|
8
7
|
import {
|
|
9
8
|
backfillAdvisoryFindings,
|
|
9
|
+
emptyAdvisoryReport,
|
|
10
10
|
formatEvidence,
|
|
11
11
|
formatLocation,
|
|
12
|
+
publishVerifiedKeptProgress,
|
|
12
13
|
runLensVerificationPipeline,
|
|
13
14
|
DEFAULT_ADVISORY_TOOL_HINTS,
|
|
14
15
|
DEFAULT_ADVISORY_TOOLS,
|
|
@@ -80,11 +81,11 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
80
81
|
"Inspect repository structure, scripts, likely hot-path files, and any existing benchmark or measurement commands. " +
|
|
81
82
|
"Prefer identifying what to measure before claiming bottlenecks. Return files, commands, summary, and known measurements or the lack of them. " +
|
|
82
83
|
`This workflow will fan out across ${PERF_LENSES.length} lenses with up to ${PER_LENS} candidates per lens. Structured output only.`,
|
|
83
|
-
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS,
|
|
84
|
+
{ phase: "Scope", label: "scope", tools: TOOLS, toolHints: TOOL_HINTS, profile: "medium", schema: ScopeSchema },
|
|
84
85
|
);
|
|
85
86
|
|
|
86
87
|
if (!scope || scope.files.length === 0) {
|
|
87
|
-
return
|
|
88
|
+
return emptyAdvisoryReport(
|
|
88
89
|
"No performance-relevant files were identified.",
|
|
89
90
|
["Provide a slow command, workload, file path, or user-visible latency concern to review."],
|
|
90
91
|
makeStats(0, 0),
|
|
@@ -139,14 +140,10 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
139
140
|
const verified = pipelineResult.verified;
|
|
140
141
|
const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
|
|
141
142
|
const stats = makeStats(verified.length, surviving.length);
|
|
142
|
-
|
|
143
|
-
progress({ type: "counter", key: "kept", label: "kept", value: surviving.length });
|
|
144
|
-
progress({ type: "summary", key: "verified", value: verified.length });
|
|
145
|
-
progress({ type: "summary", key: "kept", value: surviving.length });
|
|
146
|
-
log(`${verified.length} verified → ${surviving.length} kept`);
|
|
143
|
+
publishVerifiedKeptProgress({ progress, log }, verified.length, surviving.length);
|
|
147
144
|
|
|
148
145
|
if (surviving.length === 0) {
|
|
149
|
-
return
|
|
146
|
+
return emptyAdvisoryReport(
|
|
150
147
|
"No performance finding survived verification.",
|
|
151
148
|
["Add or run a focused measurement for the target workload before optimizing.", "Rerun perf-review with benchmark output or a narrower slow path."],
|
|
152
149
|
stats,
|
|
@@ -172,13 +169,13 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
172
169
|
phase: "Synthesize",
|
|
173
170
|
label: "synthesize",
|
|
174
171
|
tools: [],
|
|
175
|
-
|
|
172
|
+
profile: "medium",
|
|
176
173
|
resume: "read-only",
|
|
177
174
|
schema: AdvisoryReportSchema,
|
|
178
175
|
},
|
|
179
176
|
);
|
|
180
177
|
|
|
181
|
-
if (!report) return
|
|
178
|
+
if (!report) return emptyAdvisoryReport("Synthesis produced no output.", ["Inspect verifier evidence manually or rerun perf-review with a narrower target."], stats);
|
|
182
179
|
|
|
183
180
|
const findings = backfillAdvisoryFindings(report.findings, ranked, {
|
|
184
181
|
impact: "Performance impact not restated by synthesis.",
|
|
@@ -187,10 +184,6 @@ export default async function run(api: WorkflowApi): Promise<unknown> {
|
|
|
187
184
|
return { ...report, findings, stats };
|
|
188
185
|
}
|
|
189
186
|
|
|
190
|
-
function emptyReport(summary: string, nextSteps: string[], stats: WorkflowRunStats): AdvisoryReport & { stats: WorkflowRunStats } {
|
|
191
|
-
return { summary, findings: [], nextSteps, stats };
|
|
192
|
-
}
|
|
193
|
-
|
|
194
187
|
function rank(finding: Verified): number {
|
|
195
188
|
const verdictRank = finding.verdict === "CONFIRMED" ? 0 : 1;
|
|
196
189
|
const measurementPenalty = finding.category === "measurement" ? 1 : 0;
|