@mgiles/perk 1.0.1
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 +105 -0
- package/extension/adapters/planAdapterPlannotator.ts +269 -0
- package/extension/adapters/planAdapterTombell.ts +147 -0
- package/extension/adapters/todoAdapterJuicesharp.ts +105 -0
- package/extension/checkpoints/checkpoints.ts +542 -0
- package/extension/checkpoints/planSteps.ts +108 -0
- package/extension/doors/address.ts +360 -0
- package/extension/doors/askUser.ts +194 -0
- package/extension/doors/ciExecutor.ts +583 -0
- package/extension/doors/land.ts +222 -0
- package/extension/doors/learn.ts +235 -0
- package/extension/doors/learnDocs.ts +99 -0
- package/extension/doors/lifecycleGates.ts +171 -0
- package/extension/doors/prReview.ts +339 -0
- package/extension/doors/ready.ts +86 -0
- package/extension/doors/selfcheck.ts +155 -0
- package/extension/doors/submit.ts +253 -0
- package/extension/factories/objective.ts +240 -0
- package/extension/factories/objectiveAuthor.ts +114 -0
- package/extension/factories/objectiveDraft.ts +343 -0
- package/extension/factories/objectivePlan.ts +838 -0
- package/extension/factories/objectiveSave.ts +285 -0
- package/extension/factories/planDraft.ts +140 -0
- package/extension/factories/planMode.ts +214 -0
- package/extension/factories/planReview.ts +644 -0
- package/extension/factories/planSave.ts +589 -0
- package/extension/factories/planTitle.ts +123 -0
- package/extension/index.ts +459 -0
- package/extension/substrate/bindingDelivery.ts +199 -0
- package/extension/substrate/bindings.ts +180 -0
- package/extension/substrate/cache.ts +163 -0
- package/extension/substrate/coldDoor.ts +226 -0
- package/extension/substrate/config.ts +339 -0
- package/extension/substrate/miniYaml.ts +262 -0
- package/extension/substrate/prompts.ts +35 -0
- package/extension/substrate/providers.ts +177 -0
- package/extension/substrate/registry.ts +62 -0
- package/extension/substrate/resources.ts +41 -0
- package/extension/substrate/result.ts +72 -0
- package/extension/substrate/runId.ts +49 -0
- package/extension/substrate/sessionData.ts +229 -0
- package/extension/substrate/structuredOutput.ts +141 -0
- package/extension/substrate/toolGating.ts +400 -0
- package/extension/substrate/toolParams.ts +106 -0
- package/extension/substrate/workflowState.ts +233 -0
- package/extension/surfaces/footerProvider.ts +43 -0
- package/extension/surfaces/report.ts +34 -0
- package/extension/surfaces/surfaces.ts +460 -0
- package/extension/vendor/btw/btw.ts +964 -0
- package/extension/vendor/btw/core.ts +153 -0
- package/extension/vendor/whimsical/whimsical.ts +485 -0
- package/extension/worker/readOnlySession.ts +282 -0
- package/extension/worker/worker.ts +765 -0
- package/extension/workerMain.ts +150 -0
- package/package.json +55 -0
- package/prompts/README.md +15 -0
- package/prompts/_fixtures/cases.yaml +140 -0
- package/prompts/_fixtures/golden/address-action-model.txt +10 -0
- package/prompts/_fixtures/golden/address-action.txt +10 -0
- package/prompts/_fixtures/golden/address-preview-model.txt +6 -0
- package/prompts/_fixtures/golden/address-preview.txt +6 -0
- package/prompts/_fixtures/golden/hello.txt +1 -0
- package/prompts/_fixtures/golden/implement-github.txt +8 -0
- package/prompts/_fixtures/golden/learn-docs.txt +8 -0
- package/prompts/_fixtures/golden/learn-github.txt +11 -0
- package/prompts/_fixtures/golden/learn-linear.txt +11 -0
- package/prompts/_fixtures/golden/learn-no-ref.txt +8 -0
- package/prompts/_fixtures/golden/learn-other.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance-linear.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-guidance.txt +8 -0
- package/prompts/_fixtures/golden/objective-plan-seed-linear.txt +20 -0
- package/prompts/_fixtures/golden/objective-plan-seed.txt +15 -0
- package/prompts/_fixtures/golden/objective-read-linear-nourl.txt +1 -0
- package/prompts/_fixtures/golden/objective-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-github.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-linear.txt +1 -0
- package/prompts/_fixtures/golden/plan-read-other.txt +1 -0
- package/prompts/_fixtures/golden/with_include.txt +4 -0
- package/prompts/_fixtures/templates/_greeting.md +1 -0
- package/prompts/_fixtures/templates/hello.md +1 -0
- package/prompts/_fixtures/templates/with_include.md +4 -0
- package/prompts/common/objective-read/linear.md +1 -0
- package/prompts/common/plan-read/github.md +1 -0
- package/prompts/common/plan-read/linear.md +1 -0
- package/prompts/common/plan-read/other.md +1 -0
- package/prompts/stages/address/action.md +10 -0
- package/prompts/stages/address/preview.md +6 -0
- package/prompts/stages/implement.md +8 -0
- package/prompts/stages/learn-docs.md +8 -0
- package/prompts/stages/learn.md +21 -0
- package/prompts/stages/objective-plan/guidance.md +12 -0
- package/prompts/stages/objective-plan/seed.md +20 -0
- package/shared/README.md +29 -0
- package/shared/bindings.yaml +64 -0
- package/shared/contracts-history.md +403 -0
- package/shared/contracts.md +4172 -0
- package/shared/providers.yaml +221 -0
- package/shared/registry.yaml +199 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
// The headless stage-drive primitive (`driveStage`).
|
|
2
|
+
//
|
|
3
|
+
// Drives ONE read-write stage (`implement`/`address`) end-to-end on an already-prepared worktree,
|
|
4
|
+
// running the SAME `@mgiles/perk` extension package, with a locked resource set, auto-compaction and
|
|
5
|
+
// auto-retry off, and a budget/timeout watchdog. It seeds the stage's initial prompt, lets the
|
|
6
|
+
// model work (calling perk's real tools), detects the stage's terminal signal, and returns a
|
|
7
|
+
// structured `RunOutcome`. This implements the contract locked in
|
|
8
|
+
// `docs/design/headless-worker.md` §B — the event-stream substrate and the e2e
|
|
9
|
+
// harness consume.
|
|
10
|
+
//
|
|
11
|
+
// Scope here is the in-process drive primitive only. Positioning (worktree create, handoff/plan-ref
|
|
12
|
+
// /plan-body materialization, `run_id` mint) is the cold-door/runner's job and is a PREPARED-
|
|
13
|
+
// WORKTREE input (audit Gap 7): the worker inherits `PERK_RUN_ID` from the env and never re-mints.
|
|
14
|
+
//
|
|
15
|
+
// Inverse of `extension/worker/readOnlySession.ts`: that builds a fully-isolated READ-ONLY child (loads
|
|
16
|
+
// nothing, `["read","grep","find","ls"]`); the worker is the OPPOSITE — read-write defaults + the
|
|
17
|
+
// real perk extension loaded from the worktree's `.pi/settings.json` (cwd-discovery), with the
|
|
18
|
+
// user-global tier locked out via a throwaway `agentDir`.
|
|
19
|
+
|
|
20
|
+
import { appendFileSync, mkdtempSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { env } from "node:process";
|
|
24
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
25
|
+
import {
|
|
26
|
+
AuthStorage,
|
|
27
|
+
type CreateAgentSessionRuntimeFactory,
|
|
28
|
+
type CreateAgentSessionServicesOptions,
|
|
29
|
+
createAgentSessionFromServices,
|
|
30
|
+
createAgentSessionRuntime,
|
|
31
|
+
createAgentSessionServices,
|
|
32
|
+
ModelRegistry,
|
|
33
|
+
SessionManager,
|
|
34
|
+
SettingsManager,
|
|
35
|
+
} from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import { planReadInstruction } from "../doors/lifecycleGates.ts";
|
|
37
|
+
import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
|
|
38
|
+
import { loadPerkConfig } from "../substrate/config.ts";
|
|
39
|
+
import { render } from "../substrate/prompts.ts";
|
|
40
|
+
import { rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
41
|
+
import { capForModel } from "./readOnlySession.ts";
|
|
42
|
+
|
|
43
|
+
// --- contract types (additive-stable; §B of docs/design/headless-worker.md) ---------------------
|
|
44
|
+
|
|
45
|
+
/** The two read-write stages with `doors.cold_remote: true` (shared/registry.yaml). */
|
|
46
|
+
export type DriveStage = "implement" | "address";
|
|
47
|
+
|
|
48
|
+
/** Terminal run status (audit §B outcome shape). */
|
|
49
|
+
export type RunStatus = "completed" | "failed" | "aborted" | "budget_exhausted";
|
|
50
|
+
|
|
51
|
+
/** The first-of terminal signal that ended the drive (audit §B). */
|
|
52
|
+
export type TerminalSignal =
|
|
53
|
+
| "submit_tool"
|
|
54
|
+
| "address_resolved"
|
|
55
|
+
| "agent_idle_incomplete"
|
|
56
|
+
| "budget"
|
|
57
|
+
| "external_abort"
|
|
58
|
+
| "model_error";
|
|
59
|
+
|
|
60
|
+
/** The budget/timeout watchdog inputs (Gap 2). */
|
|
61
|
+
export interface DriveBudget {
|
|
62
|
+
maxTurns: number;
|
|
63
|
+
maxTokens: number;
|
|
64
|
+
wallClockMs: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The structured run outcome (audit §B). **Additive-stable**: later fields may be added; existing
|
|
69
|
+
* fields keep their meaning. Never thrown — `driveStage` always resolves with one of these.
|
|
70
|
+
*/
|
|
71
|
+
export interface RunOutcome {
|
|
72
|
+
run_id: string;
|
|
73
|
+
stage: DriveStage;
|
|
74
|
+
status: RunStatus;
|
|
75
|
+
terminal_signal: TerminalSignal;
|
|
76
|
+
pr: { number: number; url: string } | null;
|
|
77
|
+
budget: { turns: number; tokens: number; elapsed_ms: number };
|
|
78
|
+
error: { type: string; message: string; summary: string } | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- structured run-event stream (§8.12) ----------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The structured run-event stream (contracts §8.12). A small, JSON-serializable,
|
|
85
|
+
* **additive-stable** discriminated union keyed on `kind` (distinct from `DriveEvent.type`). Every
|
|
86
|
+
* event carries a monotonic `seq` (0-based) and `t` (elapsed ms, same basis as
|
|
87
|
+
* `RunOutcome.budget.elapsed_ms`). Future nodes may add variants/fields; existing ones keep meaning.
|
|
88
|
+
*/
|
|
89
|
+
export type RunEvent =
|
|
90
|
+
| { kind: "run_started"; seq: number; t: number; run_id: string; stage: DriveStage }
|
|
91
|
+
| { kind: "step_marker"; seq: number; t: number; marker: "wip" | "done"; step: number }
|
|
92
|
+
| {
|
|
93
|
+
kind: "tool_outcome";
|
|
94
|
+
seq: number;
|
|
95
|
+
t: number;
|
|
96
|
+
tool: string;
|
|
97
|
+
ok: boolean;
|
|
98
|
+
summary: string | null;
|
|
99
|
+
}
|
|
100
|
+
| { kind: "run_finished"; seq: number; t: number; outcome: RunOutcome };
|
|
101
|
+
|
|
102
|
+
/** The injectable delivery seam: default = a run-scoped NDJSON file sink; tests inject an array. */
|
|
103
|
+
export type RunEventSink = (event: RunEvent) => void;
|
|
104
|
+
|
|
105
|
+
/** Distributive `Omit` so each `RunEvent` variant keeps its own fields when `seq`/`t` are stamped. */
|
|
106
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
107
|
+
type RunEventInput = DistributiveOmit<RunEvent, "seq" | "t">;
|
|
108
|
+
|
|
109
|
+
/** Per-event free-text cap (route-don't-relay): events carry the narrative, not raw tool payloads. */
|
|
110
|
+
export const EVENT_SUMMARY_CAP = 2 * 1024;
|
|
111
|
+
|
|
112
|
+
export interface DriveStageOptions {
|
|
113
|
+
/** Absolute path to the already-positioned worktree (Gap 7). */
|
|
114
|
+
worktree: string;
|
|
115
|
+
stage: DriveStage;
|
|
116
|
+
/** The seeded first prompt (see `initialPromptFor`). */
|
|
117
|
+
initialPrompt: string;
|
|
118
|
+
/** Explicit model; else the first available from the registry (Gap 5). */
|
|
119
|
+
model?: Model<Api>;
|
|
120
|
+
authStorage?: AuthStorage;
|
|
121
|
+
modelRegistry?: ModelRegistry;
|
|
122
|
+
budget: DriveBudget;
|
|
123
|
+
/** External cancellation; OR'd with the budget watchdog. */
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The offline seam (mirrors `readOnlySession.test.ts`'s `runTask` injection). `createRuntime`
|
|
129
|
+
* overrides the production runtime factory so tests drive synthetic sessions; `resourceLoaderOptions`
|
|
130
|
+
* tunes the project-tier load; `now` injects the clock for deterministic `elapsed_ms`.
|
|
131
|
+
*/
|
|
132
|
+
export interface DriveStageDeps {
|
|
133
|
+
createRuntime?: (opts: DriveStageOptions) => Promise<DriveRuntimeLike>;
|
|
134
|
+
resourceLoaderOptions?: CreateAgentSessionServicesOptions["resourceLoaderOptions"];
|
|
135
|
+
now?: () => number;
|
|
136
|
+
/** The structured run-event sink. Absent ⇒ the default run-scoped NDJSON file sink. */
|
|
137
|
+
eventSink?: RunEventSink;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- structural shapes (kept minimal so pure helpers stay offline-testable) ---------------------
|
|
141
|
+
|
|
142
|
+
/** The slice of an agent session event the worker reads (structural — see agent-session.d.ts). */
|
|
143
|
+
export interface DriveEvent {
|
|
144
|
+
type: string;
|
|
145
|
+
toolName?: string;
|
|
146
|
+
result?: unknown;
|
|
147
|
+
isError?: boolean;
|
|
148
|
+
message?: {
|
|
149
|
+
role?: string;
|
|
150
|
+
stopReason?: string;
|
|
151
|
+
errorMessage?: string;
|
|
152
|
+
usage?: { input?: number; output?: number };
|
|
153
|
+
/** Assistant text/content blocks (where `[WIP:n]`/`[DONE:n]` markers live). */
|
|
154
|
+
content?: unknown;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The session surface the worker drives (structurally satisfied by pi's `AgentSession`). */
|
|
159
|
+
export interface DriveSessionLike {
|
|
160
|
+
bindExtensions(bindings: unknown): Promise<void>;
|
|
161
|
+
subscribe(listener: (event: DriveEvent) => void): () => void;
|
|
162
|
+
prompt(text: string): Promise<void>;
|
|
163
|
+
abort(): Promise<void>;
|
|
164
|
+
dispose(): void;
|
|
165
|
+
sessionManager: { getBranch(): unknown[] };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The runtime surface (structurally satisfied by pi's `AgentSessionRuntime`). */
|
|
169
|
+
export interface DriveRuntimeLike {
|
|
170
|
+
readonly session: DriveSessionLike;
|
|
171
|
+
dispose(): Promise<void> | void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Mutable counters/captures the subscribe listener accumulates over the drive. */
|
|
175
|
+
export interface DriveCounters {
|
|
176
|
+
turns: number;
|
|
177
|
+
tokens: number;
|
|
178
|
+
submitDetails: Record<string, unknown> | null;
|
|
179
|
+
resolveDetails: Record<string, unknown> | null;
|
|
180
|
+
modelError: { message: string } | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function freshCounters(): DriveCounters {
|
|
184
|
+
return { turns: 0, tokens: 0, submitDetails: null, resolveDetails: null, modelError: null };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The natural-idle terminal classification (before watchdog/abort overrides). */
|
|
188
|
+
export interface TerminalVerdict {
|
|
189
|
+
status: RunStatus;
|
|
190
|
+
terminal_signal: TerminalSignal;
|
|
191
|
+
pr: { number: number; url: string } | null;
|
|
192
|
+
errorType: string | null;
|
|
193
|
+
errorMessage: string | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- pure helpers (offline-testable) ------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** Extract a tool's `details` object from a captured `tool_execution_end.result`; null if absent. */
|
|
199
|
+
function detailsOf(result: unknown): Record<string, unknown> | null {
|
|
200
|
+
if (result && typeof result === "object" && "details" in result) {
|
|
201
|
+
const details = (result as { details: unknown }).details;
|
|
202
|
+
if (details && typeof details === "object") return details as Record<string, unknown>;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Fold one agent-session event into the running counters (pure). Counts `turn_end` turns, sums
|
|
209
|
+
* assistant token usage (the `sumAssistantTokens` pattern in objective.ts), captures the `submit`
|
|
210
|
+
* /`resolve_review_threads` terminal tool details, and records a post-acceptance model error
|
|
211
|
+
* (assistant `message_end` with `stopReason:"error"`, surfaced with retry off — audit §B #4).
|
|
212
|
+
*/
|
|
213
|
+
export function applyEvent(counters: DriveCounters, event: DriveEvent): void {
|
|
214
|
+
if (event.type === "turn_end") {
|
|
215
|
+
counters.turns += 1;
|
|
216
|
+
const usage = event.message?.usage;
|
|
217
|
+
if (usage) counters.tokens += Math.max(0, usage.input ?? 0) + Math.max(0, usage.output ?? 0);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (event.type === "tool_execution_end") {
|
|
221
|
+
if (event.toolName === "submit") counters.submitDetails = detailsOf(event.result);
|
|
222
|
+
else if (event.toolName === "resolve_review_threads") {
|
|
223
|
+
counters.resolveDetails = detailsOf(event.result);
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (event.type === "message_end") {
|
|
228
|
+
if (event.message?.role === "assistant" && event.message.stopReason === "error") {
|
|
229
|
+
counters.modelError = { message: event.message.errorMessage ?? "model error" };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** True when the budget watchdog should trip from the current counters. */
|
|
235
|
+
export function budgetTripped(counters: DriveCounters, budget: DriveBudget): boolean {
|
|
236
|
+
return counters.turns >= budget.maxTurns || counters.tokens >= budget.maxTokens;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Classify a natural-idle terminal from the captured state (pure). `modelError` wins (post-
|
|
241
|
+
* acceptance error, §B #4); else the stage success predicate:
|
|
242
|
+
* - implement: a successful `submit` carrying a `pr` → completed/submit_tool;
|
|
243
|
+
* - address: `resolve_review_threads` ok AND `last_review_batch` appended → completed/address_resolved;
|
|
244
|
+
* - otherwise the agent went idle without completing the stage → failed/agent_idle_incomplete.
|
|
245
|
+
*/
|
|
246
|
+
export function evaluateTerminal(args: {
|
|
247
|
+
stage: DriveStage;
|
|
248
|
+
submitDetails: Record<string, unknown> | null;
|
|
249
|
+
resolveSucceeded: boolean;
|
|
250
|
+
lastReviewBatchPresent: boolean;
|
|
251
|
+
modelError: { message: string } | null;
|
|
252
|
+
}): TerminalVerdict {
|
|
253
|
+
if (args.modelError !== null) {
|
|
254
|
+
return {
|
|
255
|
+
status: "failed",
|
|
256
|
+
terminal_signal: "model_error",
|
|
257
|
+
pr: null,
|
|
258
|
+
errorType: "model_error",
|
|
259
|
+
errorMessage: args.modelError.message,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (args.stage === "implement") {
|
|
264
|
+
const pr = extractPr(args.submitDetails);
|
|
265
|
+
if (args.submitDetails?.ok === true && pr !== null) {
|
|
266
|
+
// Completion additionally requires the submit to be mergeable: a definitively-
|
|
267
|
+
// unmergeable PR (merge conflicts unresolved) is NOT done. `mergeable === true`/`null`/
|
|
268
|
+
// absent all allow completion (fail-open); only a definitive `false` blocks it. On the
|
|
269
|
+
// happy path the resolver follow-up turns re-submit, overwriting submitDetails with a
|
|
270
|
+
// mergeable result, so the natural-idle classification then passes.
|
|
271
|
+
if (args.submitDetails.mergeable === false) {
|
|
272
|
+
return {
|
|
273
|
+
status: "failed",
|
|
274
|
+
terminal_signal: "agent_idle_incomplete",
|
|
275
|
+
pr: null,
|
|
276
|
+
errorType: "incomplete",
|
|
277
|
+
errorMessage:
|
|
278
|
+
"implement drive went idle with an unmergeable PR (merge conflicts unresolved).",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
status: "completed",
|
|
283
|
+
terminal_signal: "submit_tool",
|
|
284
|
+
pr,
|
|
285
|
+
errorType: null,
|
|
286
|
+
errorMessage: null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
status: "failed",
|
|
291
|
+
terminal_signal: "agent_idle_incomplete",
|
|
292
|
+
pr: null,
|
|
293
|
+
errorType: "incomplete",
|
|
294
|
+
errorMessage: "implement drive went idle without an opened PR (no successful submit).",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// address
|
|
299
|
+
if (args.resolveSucceeded && args.lastReviewBatchPresent) {
|
|
300
|
+
return {
|
|
301
|
+
status: "completed",
|
|
302
|
+
terminal_signal: "address_resolved",
|
|
303
|
+
pr: null,
|
|
304
|
+
errorType: null,
|
|
305
|
+
errorMessage: null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
status: "failed",
|
|
310
|
+
terminal_signal: "agent_idle_incomplete",
|
|
311
|
+
pr: null,
|
|
312
|
+
errorType: "incomplete",
|
|
313
|
+
errorMessage: "address drive went idle without resolving feedback (no last_review_batch).",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Pull a `{ number, url }` PR from a captured `submit` details block; null when malformed. */
|
|
318
|
+
function extractPr(
|
|
319
|
+
details: Record<string, unknown> | null,
|
|
320
|
+
): { number: number; url: string } | null {
|
|
321
|
+
if (!details || typeof details.pr !== "object" || details.pr === null) return null;
|
|
322
|
+
const pr = details.pr as { number?: unknown; url?: unknown };
|
|
323
|
+
if (typeof pr.number === "number" && typeof pr.url === "string") {
|
|
324
|
+
return { number: pr.number, url: pr.url };
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Compose the final `RunOutcome` (pure). `run_id` is read from `PERK_RUN_ID` (inherited from
|
|
331
|
+
* positioning, Gap 7), overridable for tests. On a non-completed status the `error` block carries a
|
|
332
|
+
* capped `error.summary` (route-don't-relay discipline); a completed status has `error: null`.
|
|
333
|
+
*/
|
|
334
|
+
export function assembleOutcome(args: {
|
|
335
|
+
stage: DriveStage;
|
|
336
|
+
verdict: TerminalVerdict;
|
|
337
|
+
budget: { turns: number; tokens: number; elapsed_ms: number };
|
|
338
|
+
runId?: string;
|
|
339
|
+
}): RunOutcome {
|
|
340
|
+
const { verdict } = args;
|
|
341
|
+
const error =
|
|
342
|
+
verdict.status === "completed" || verdict.errorMessage === null
|
|
343
|
+
? null
|
|
344
|
+
: {
|
|
345
|
+
type: verdict.errorType ?? "error",
|
|
346
|
+
message: verdict.errorMessage,
|
|
347
|
+
summary: capForModel(verdict.errorMessage).shown,
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
run_id: args.runId ?? env.PERK_RUN_ID ?? "",
|
|
351
|
+
stage: args.stage,
|
|
352
|
+
status: verdict.status,
|
|
353
|
+
terminal_signal: verdict.terminal_signal,
|
|
354
|
+
pr: verdict.pr,
|
|
355
|
+
budget: args.budget,
|
|
356
|
+
error,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// --- run-event helpers (offline-testable) ---------------------------------------------
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Extract `[WIP:n]`/`[DONE:n]` markers from assistant text in **textual appearance order** (pure).
|
|
364
|
+
* A single combined, case-insensitive regex so interleaved markers (`[WIP:2]` before `[DONE:1]` in
|
|
365
|
+
* the same message) emit in that order — unlike checkpoints.ts's separate `extractWip/DoneSteps`
|
|
366
|
+
* lists, which lose cross-marker order. Returns `[]` when there are no markers.
|
|
367
|
+
*/
|
|
368
|
+
export function extractStepMarkers(text: string): { marker: "wip" | "done"; step: number }[] {
|
|
369
|
+
const out: { marker: "wip" | "done"; step: number }[] = [];
|
|
370
|
+
for (const m of text.matchAll(/\[(WIP|DONE):(\d+)\]/gi)) {
|
|
371
|
+
const step = Number(m[2]);
|
|
372
|
+
if (Number.isFinite(step)) {
|
|
373
|
+
out.push({ marker: (m[1] ?? "").toLowerCase() === "done" ? "done" : "wip", step });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Flatten a `DriveEvent`'s assistant `message.content` (string | `{type:'text',text}[]`) to text. */
|
|
380
|
+
export function assistantText(event: DriveEvent): string {
|
|
381
|
+
const content = event.message?.content;
|
|
382
|
+
if (typeof content === "string") return content;
|
|
383
|
+
if (Array.isArray(content)) {
|
|
384
|
+
return content
|
|
385
|
+
.map((b) => {
|
|
386
|
+
const block = b as { type?: string; text?: string };
|
|
387
|
+
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
|
388
|
+
})
|
|
389
|
+
.filter(Boolean)
|
|
390
|
+
.join("\n");
|
|
391
|
+
}
|
|
392
|
+
return "";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Compute a `tool_outcome` `{ tool, ok, summary }` from a `tool_execution_end` `DriveEvent` (pure).
|
|
397
|
+
* `ok` = `details.ok === true` when the result carries a `details.ok` boolean, else `!isError`.
|
|
398
|
+
* `summary` is `null` on success and, on failure, a capped (route-don't-relay) synthesis of the
|
|
399
|
+
* tool's error message — never the raw tool result.
|
|
400
|
+
*/
|
|
401
|
+
export function toolOutcomeOf(event: DriveEvent): {
|
|
402
|
+
tool: string;
|
|
403
|
+
ok: boolean;
|
|
404
|
+
summary: string | null;
|
|
405
|
+
} {
|
|
406
|
+
const details = detailsOf(event.result);
|
|
407
|
+
const ok = typeof details?.ok === "boolean" ? details.ok === true : !event.isError;
|
|
408
|
+
let summary: string | null = null;
|
|
409
|
+
if (!ok) {
|
|
410
|
+
const raw = toolErrorMessage(event);
|
|
411
|
+
summary = capForModel(raw, EVENT_SUMMARY_CAP).shown;
|
|
412
|
+
}
|
|
413
|
+
return { tool: event.toolName ?? "", ok, summary };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Best-effort error text for a failed tool (details.error | result string | a generic fallback). */
|
|
417
|
+
function toolErrorMessage(event: DriveEvent): string {
|
|
418
|
+
const details = detailsOf(event.result);
|
|
419
|
+
if (details && typeof details.error === "string" && details.error) return details.error;
|
|
420
|
+
if (typeof event.result === "string" && event.result) return event.result;
|
|
421
|
+
return `tool ${event.toolName ?? ""} failed`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The run-event emitter: owns the monotonic `seq` counter and stamps `t = max(0, now() - startMs)`
|
|
426
|
+
* (same basis as `RunOutcome.budget.elapsed_ms`). Fail-soft: a throwing injected sink is caught and
|
|
427
|
+
* swallowed so a broken sink never aborts the drive.
|
|
428
|
+
*/
|
|
429
|
+
export function createEventEmitter(sink: RunEventSink, now: () => number, startMs: number) {
|
|
430
|
+
let seq = 0;
|
|
431
|
+
return {
|
|
432
|
+
emit(event: RunEventInput): void {
|
|
433
|
+
const full = { ...event, seq: seq++, t: Math.max(0, now() - startMs) } as RunEvent;
|
|
434
|
+
try {
|
|
435
|
+
sink(full);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
console.error(`perk worker: run-event sink threw — ${String(err)}`);
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The default run-event sink: a fail-soft NDJSON appender to `runEventsPath(worktree, runId)`. A
|
|
445
|
+
* **no-op when `runId` is empty** (keeps the offline drive tests, which set no `PERK_RUN_ID`,
|
|
446
|
+
* write-free). Each append is wrapped so a write error logs and is swallowed.
|
|
447
|
+
*/
|
|
448
|
+
export function defaultEventSink(worktree: string, runId: string): RunEventSink {
|
|
449
|
+
if (!runId) return () => {};
|
|
450
|
+
let ensured = false;
|
|
451
|
+
const path = runEventsPath(worktree, runId);
|
|
452
|
+
return (event: RunEvent): void => {
|
|
453
|
+
try {
|
|
454
|
+
if (!ensured) {
|
|
455
|
+
ensureRunScratch(worktree, runId);
|
|
456
|
+
ensured = true;
|
|
457
|
+
}
|
|
458
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.error(`perk worker: run-event sink write failed — ${String(err)}`);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Re-derive the stage's initial prompt from the plan-ref — the TS twin of
|
|
467
|
+
* `perk/run/launch.py._implement_prompt`/`_address_prompt`. INVARIANT: textual parity with the Python
|
|
468
|
+
* plane (asserted reciprocally in `worker.test.ts` + `tests/test_worker_prompt_parity.py`); the
|
|
469
|
+
* resolved skill-binding suffix is delivered by the cold door, not here. Returns
|
|
470
|
+
* `null` when there is no plan-ref (nothing to prime).
|
|
471
|
+
*
|
|
472
|
+
* The implement primer's wording lives in the canonical template `prompts/stages/implement.md`,
|
|
473
|
+
* rendered by the shared seam (contracts.md §8.31); branching stays in code — only the `read_cmd`
|
|
474
|
+
* var differs. This implement output is byte-identical to the warm `implementHandoffPrompt`.
|
|
475
|
+
*
|
|
476
|
+
* The `address` wording lives in the shared canonical template `prompts/stages/address/action.md`
|
|
477
|
+
* rendered via the cross-plane render seam (contracts.md §8.31); the worker has no preview path
|
|
478
|
+
* (preview is a warm/cold flag only), so it always renders the action body. The classifier
|
|
479
|
+
* present/absent split builds the `model_clause` render var in code.
|
|
480
|
+
*/
|
|
481
|
+
export function initialPromptFor(
|
|
482
|
+
stage: DriveStage,
|
|
483
|
+
planRef: PlanRef | null,
|
|
484
|
+
classifierModel?: string,
|
|
485
|
+
): string | null {
|
|
486
|
+
if (planRef === null) return null;
|
|
487
|
+
const provider = String(planRef.provider ?? "");
|
|
488
|
+
const prId = String(planRef.pr_id ?? "");
|
|
489
|
+
const url = String(planRef.url ?? "");
|
|
490
|
+
if (stage === "implement") {
|
|
491
|
+
const readCmd = planReadInstruction(provider, prId, url);
|
|
492
|
+
return render("stages/implement.md", { provider, pr_id: prId, url, read_cmd: readCmd });
|
|
493
|
+
}
|
|
494
|
+
// address
|
|
495
|
+
const modelClause = classifierModel
|
|
496
|
+
? `, passing \`model: "${classifierModel}"\` on that call (the configured [subagents] review-classifier model)`
|
|
497
|
+
: "";
|
|
498
|
+
return render("stages/address/action.md", {
|
|
499
|
+
provider,
|
|
500
|
+
pr_id: prId,
|
|
501
|
+
url,
|
|
502
|
+
model_clause: modelClause,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// --- bind / subscribe management (Gap 1) --------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
/** The binding the worker applies to every (re)bound session: headless (`hasUI === false`). */
|
|
509
|
+
export function headlessBinding(): {
|
|
510
|
+
uiContext: undefined;
|
|
511
|
+
mode: "json";
|
|
512
|
+
onError: (err: unknown) => void;
|
|
513
|
+
} {
|
|
514
|
+
return {
|
|
515
|
+
uiContext: undefined,
|
|
516
|
+
mode: "json",
|
|
517
|
+
onError: (err: unknown) => console.error(`perk worker: extension error — ${String(err)}`),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Manage the bind+subscribe lifecycle across session replacement (Gap 1). `bind(target)` binds the
|
|
523
|
+
* perk extension and attaches the terminal/budget listener; calling it again (after a runtime
|
|
524
|
+
* replacement) unsubscribes the prior listener first so events are never double-counted. A mid-drive
|
|
525
|
+
* replacement is not expected on the happy path (the prompt instructs `/submit`, never `/implement`;
|
|
526
|
+
* `lifecycleGates.newSession` is `hasUI`-guarded; objective compaction is inert with no active
|
|
527
|
+
* objective) — so an observed `rebind()` is a loud structured-log error.
|
|
528
|
+
*/
|
|
529
|
+
export function createBindManager(binding: unknown, listener: (event: DriveEvent) => void) {
|
|
530
|
+
let unsubscribe: (() => void) | null = null;
|
|
531
|
+
return {
|
|
532
|
+
async bind(target: DriveSessionLike): Promise<void> {
|
|
533
|
+
if (unsubscribe) unsubscribe();
|
|
534
|
+
await target.bindExtensions(binding);
|
|
535
|
+
unsubscribe = target.subscribe(listener);
|
|
536
|
+
},
|
|
537
|
+
dispose(): void {
|
|
538
|
+
if (unsubscribe) {
|
|
539
|
+
unsubscribe();
|
|
540
|
+
unsubscribe = null;
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --- the production runtime factory -------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Build the asymmetric runtime: `cwd = worktree` (project tier — perk's `@mgiles/perk` extension via the
|
|
550
|
+
* managed `.pi/settings.json`, the managed `AGENTS.md`/`APPEND_SYSTEM.md`) and `agentDir = throwaway`
|
|
551
|
+
* (user-global tier OUT), compaction-off + retry-off settings, env-var/registry auth+model (Gap 5).
|
|
552
|
+
* No `tools` allowlist — read-write defaults + extension tools. The `createAgentSessionServices`
|
|
553
|
+
* factory builds the `DefaultResourceLoader` internally from `cwd`/`agentDir` (recipe correction #1).
|
|
554
|
+
*/
|
|
555
|
+
async function defaultCreateRuntime(
|
|
556
|
+
opts: DriveStageOptions,
|
|
557
|
+
deps: DriveStageDeps,
|
|
558
|
+
resolved: { authStorage: AuthStorage; modelRegistry: ModelRegistry; model: Model<Api> },
|
|
559
|
+
): Promise<DriveRuntimeLike> {
|
|
560
|
+
const agentDir = mkdtempSync(join(tmpdir(), "perk-worker-agent-"));
|
|
561
|
+
const settingsManager = SettingsManager.inMemory({
|
|
562
|
+
compaction: { enabled: false },
|
|
563
|
+
retry: { enabled: false },
|
|
564
|
+
});
|
|
565
|
+
const factory: CreateAgentSessionRuntimeFactory = async (factoryOpts) => {
|
|
566
|
+
const services = await createAgentSessionServices({
|
|
567
|
+
cwd: factoryOpts.cwd,
|
|
568
|
+
agentDir: factoryOpts.agentDir,
|
|
569
|
+
authStorage: resolved.authStorage,
|
|
570
|
+
settingsManager,
|
|
571
|
+
modelRegistry: resolved.modelRegistry,
|
|
572
|
+
resourceLoaderOptions: deps.resourceLoaderOptions,
|
|
573
|
+
});
|
|
574
|
+
const result = await createAgentSessionFromServices({
|
|
575
|
+
services,
|
|
576
|
+
sessionManager: factoryOpts.sessionManager,
|
|
577
|
+
sessionStartEvent: factoryOpts.sessionStartEvent,
|
|
578
|
+
model: resolved.model,
|
|
579
|
+
});
|
|
580
|
+
return { ...result, services, diagnostics: services.diagnostics };
|
|
581
|
+
};
|
|
582
|
+
const runtime = await createAgentSessionRuntime(factory, {
|
|
583
|
+
cwd: opts.worktree,
|
|
584
|
+
agentDir,
|
|
585
|
+
sessionManager: SessionManager.create(opts.worktree),
|
|
586
|
+
});
|
|
587
|
+
return runtime as unknown as DriveRuntimeLike;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// --- model/auth resolution (Gap 5) --------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
interface ResolvedAuth {
|
|
593
|
+
authStorage: AuthStorage;
|
|
594
|
+
modelRegistry: ModelRegistry;
|
|
595
|
+
model: Model<Api>;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Resolve auth + model; returns null (never throws) when no model is available. */
|
|
599
|
+
function resolveAuth(opts: DriveStageOptions): ResolvedAuth | null {
|
|
600
|
+
const authStorage = opts.authStorage ?? AuthStorage.create();
|
|
601
|
+
const modelRegistry = opts.modelRegistry ?? ModelRegistry.create(authStorage);
|
|
602
|
+
const model = opts.model ?? modelRegistry.getAvailable()[0];
|
|
603
|
+
if (!model) return null;
|
|
604
|
+
return { authStorage, modelRegistry, model };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// --- the drive primitive ------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Drive one stage to terminal and return a structured `RunOutcome` — never throws (fail-soft like
|
|
611
|
+
* `submitPr`). Seeds `initialPrompt`, races the driving `prompt()` against the budget watchdog and
|
|
612
|
+
* the external `signal`, classifies the terminal at idle, and disposes the runtime in `finally`.
|
|
613
|
+
*/
|
|
614
|
+
export async function driveStage(
|
|
615
|
+
opts: DriveStageOptions,
|
|
616
|
+
deps: DriveStageDeps = {},
|
|
617
|
+
): Promise<RunOutcome> {
|
|
618
|
+
const now = deps.now ?? Date.now;
|
|
619
|
+
const startMs = now();
|
|
620
|
+
const counters = freshCounters();
|
|
621
|
+
const elapsed = (): number => Math.max(0, now() - startMs);
|
|
622
|
+
|
|
623
|
+
// Structured run-event stream: resolve the sink + run_id once, build the emitter, and
|
|
624
|
+
// route every terminal exit through `finish` so exactly one `run_finished` is emitted per drive.
|
|
625
|
+
const runId = env.PERK_RUN_ID ?? "";
|
|
626
|
+
const sink = deps.eventSink ?? defaultEventSink(opts.worktree, runId);
|
|
627
|
+
const emitter = createEventEmitter(sink, now, startMs);
|
|
628
|
+
const finish = (verdict: TerminalVerdict): RunOutcome => {
|
|
629
|
+
const outcome = assembleOutcome({
|
|
630
|
+
stage: opts.stage,
|
|
631
|
+
verdict,
|
|
632
|
+
budget: { turns: counters.turns, tokens: counters.tokens, elapsed_ms: elapsed() },
|
|
633
|
+
});
|
|
634
|
+
emitter.emit({ kind: "run_finished", outcome });
|
|
635
|
+
return outcome;
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const resolved = resolveAuth(opts);
|
|
639
|
+
if (resolved === null && !deps.createRuntime) {
|
|
640
|
+
// A zero-turn run is still observable: emit a `run_started` + `run_finished` pair.
|
|
641
|
+
emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
|
|
642
|
+
return finish({
|
|
643
|
+
status: "failed",
|
|
644
|
+
terminal_signal: "model_error",
|
|
645
|
+
pr: null,
|
|
646
|
+
errorType: "no_model",
|
|
647
|
+
errorMessage: "no model available — set an API key (e.g. ANTHROPIC_API_KEY) or pass a model.",
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
let terminationReason: "natural" | "budget" | "abort" = "natural";
|
|
652
|
+
let settled = false;
|
|
653
|
+
let runtime: DriveRuntimeLike | null = null;
|
|
654
|
+
const bindManager = createBindManager(headlessBinding(), (event) => {
|
|
655
|
+
applyEvent(counters, event);
|
|
656
|
+
if (event.type === "turn_end") {
|
|
657
|
+
// Emit this turn's `[WIP:n]`/`[DONE:n]` markers in textual appearance order (one event each).
|
|
658
|
+
for (const m of extractStepMarkers(assistantText(event))) {
|
|
659
|
+
emitter.emit({ kind: "step_marker", marker: m.marker, step: m.step });
|
|
660
|
+
}
|
|
661
|
+
if (budgetTripped(counters, opts.budget)) trip("budget");
|
|
662
|
+
} else if (event.type === "tool_execution_end") {
|
|
663
|
+
const o = toolOutcomeOf(event);
|
|
664
|
+
emitter.emit({ kind: "tool_outcome", tool: o.tool, ok: o.ok, summary: o.summary });
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
function trip(reason: "budget" | "abort"): void {
|
|
669
|
+
if (settled) return;
|
|
670
|
+
if (terminationReason === "natural") terminationReason = reason;
|
|
671
|
+
if (runtime) void runtime.session.abort();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const onSignal = (): void => trip("abort");
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
runtime = deps.createRuntime
|
|
678
|
+
? await deps.createRuntime(opts)
|
|
679
|
+
: // biome-ignore lint/style/noNonNullAssertion: resolved is non-null on the production path.
|
|
680
|
+
await defaultCreateRuntime(opts, deps, resolved!);
|
|
681
|
+
|
|
682
|
+
let boundSession = runtime.session;
|
|
683
|
+
await bindManager.bind(boundSession);
|
|
684
|
+
emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
|
|
685
|
+
|
|
686
|
+
// Budget/abort wiring (Gap 2): wall-clock timer + external signal both trip → session.abort().
|
|
687
|
+
const timer = setTimeout(() => trip("budget"), opts.budget.wallClockMs);
|
|
688
|
+
if (opts.signal) {
|
|
689
|
+
if (opts.signal.aborted) onSignal();
|
|
690
|
+
else opts.signal.addEventListener("abort", onSignal, { once: true });
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
await runtime.session.prompt(opts.initialPrompt);
|
|
695
|
+
} finally {
|
|
696
|
+
clearTimeout(timer);
|
|
697
|
+
opts.signal?.removeEventListener("abort", onSignal);
|
|
698
|
+
settled = true;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Defensive rebind (Gap 1): the happy path never replaces the session; a replacement is loud.
|
|
702
|
+
if (runtime.session !== boundSession) {
|
|
703
|
+
console.error("perk worker: unexpected mid-drive session replacement — rebinding listener.");
|
|
704
|
+
boundSession = runtime.session;
|
|
705
|
+
await bindManager.bind(boundSession);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const verdict = classify(opts, counters, terminationReason, boundSession);
|
|
709
|
+
return finish(verdict);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
712
|
+
return finish({
|
|
713
|
+
status: "failed",
|
|
714
|
+
terminal_signal: "model_error",
|
|
715
|
+
pr: null,
|
|
716
|
+
errorType: "drive_error",
|
|
717
|
+
errorMessage: `headless drive failed: ${message}`,
|
|
718
|
+
});
|
|
719
|
+
} finally {
|
|
720
|
+
bindManager.dispose();
|
|
721
|
+
if (runtime) await runtime.dispose();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Pick the terminal verdict: watchdog/abort override the natural-idle classification. */
|
|
726
|
+
function classify(
|
|
727
|
+
opts: DriveStageOptions,
|
|
728
|
+
counters: DriveCounters,
|
|
729
|
+
terminationReason: "natural" | "budget" | "abort",
|
|
730
|
+
session: DriveSessionLike,
|
|
731
|
+
): TerminalVerdict {
|
|
732
|
+
if (terminationReason === "budget") {
|
|
733
|
+
return {
|
|
734
|
+
status: "budget_exhausted",
|
|
735
|
+
terminal_signal: "budget",
|
|
736
|
+
pr: null,
|
|
737
|
+
errorType: "budget",
|
|
738
|
+
errorMessage: "budget exhausted (turns/tokens/wall-clock) — drive aborted.",
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (terminationReason === "abort") {
|
|
742
|
+
return {
|
|
743
|
+
status: "aborted",
|
|
744
|
+
terminal_signal: "external_abort",
|
|
745
|
+
pr: null,
|
|
746
|
+
errorType: "external_abort",
|
|
747
|
+
errorMessage: "drive aborted by external signal.",
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const lastReviewBatchPresent =
|
|
751
|
+
rebuildWorkflowState(session.sessionManager.getBranch() as never).last_review_batch != null;
|
|
752
|
+
return evaluateTerminal({
|
|
753
|
+
stage: opts.stage,
|
|
754
|
+
submitDetails: counters.submitDetails,
|
|
755
|
+
resolveSucceeded: counters.resolveDetails?.ok === true,
|
|
756
|
+
lastReviewBatchPresent,
|
|
757
|
+
modelError: counters.modelError,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Convenience: re-derive the initial prompt for a prepared worktree (reads its `cache.plan-ref`). */
|
|
762
|
+
export function initialPromptForWorktree(worktree: string, stage: DriveStage): string | null {
|
|
763
|
+
const classifierModel = loadPerkConfig(worktree).subagents["review-classifier"];
|
|
764
|
+
return initialPromptFor(stage, readPlanRef(worktree), classifierModel);
|
|
765
|
+
}
|