@mgiles/perk 3.2.0 → 3.3.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/README.md +5 -0
- package/extension/authoring/gist/draft.ts +198 -0
- package/extension/authoring/gist/prose.ts +46 -0
- package/extension/authoring/gist/review.ts +133 -0
- package/extension/authoring/gist/save.ts +118 -0
- package/extension/authoring/objective/draft.ts +345 -0
- package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
- package/extension/authoring/objective/planning.ts +124 -0
- package/extension/authoring/objective/prose.ts +103 -0
- package/extension/authoring/objective/review.ts +128 -0
- package/extension/authoring/objective/save.ts +224 -0
- package/extension/authoring/plan/draft.ts +84 -0
- package/extension/authoring/plan/prose.ts +41 -0
- package/extension/authoring/plan/review.ts +269 -0
- package/extension/authoring/plan/save.ts +256 -0
- package/extension/authoring/plan/source.ts +82 -0
- package/extension/authoring/refinement/context.ts +468 -0
- package/extension/authoring/refinement/draft.ts +261 -0
- package/extension/authoring/refinement/prose.ts +79 -0
- package/extension/authoring/refinement/review.ts +111 -0
- package/extension/authoring/refinement/save.ts +119 -0
- package/extension/authoring/review/approvalGate.ts +34 -0
- package/extension/authoring/review/draftContext.ts +68 -0
- package/extension/codeReview/automated.ts +352 -0
- package/extension/codeReview/submission.ts +229 -0
- package/extension/delivery/address.ts +295 -0
- package/extension/delivery/ci.ts +355 -0
- package/extension/delivery/commitCompact.ts +93 -0
- package/extension/delivery/conflictResolution.ts +247 -0
- package/extension/delivery/ready.ts +193 -0
- package/extension/delivery/stackConflict.ts +361 -0
- package/extension/delivery/stackObjective.ts +16 -0
- package/extension/delivery/stackReconcile.ts +165 -0
- package/extension/delivery/submit.ts +171 -0
- package/extension/index.ts +365 -380
- package/extension/learning/analystWave.ts +324 -0
- package/extension/learning/audit.ts +667 -0
- package/extension/learning/capture.ts +92 -0
- package/extension/learning/containment.ts +104 -0
- package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
- package/extension/learning/dreamAnalysis.ts +435 -0
- package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
- package/extension/{waves → learning}/dreamReport.ts +35 -31
- package/extension/learning/harvest.ts +491 -0
- package/extension/learning/prose.ts +66 -0
- package/extension/learning/routing.ts +79 -0
- package/extension/pi/v1/bashScanTimeout.ts +64 -0
- package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -311
- package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
- package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
- package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
- package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
- package/extension/pi/v1/codeReview/submit.ts +354 -0
- package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
- package/extension/pi/v1/contextEvidence.ts +80 -0
- package/extension/pi/v1/contextInjection.ts +207 -0
- package/extension/{doors → pi/v1/delivery}/address.ts +154 -267
- package/extension/pi/v1/delivery/ci.ts +570 -0
- package/extension/pi/v1/delivery/commitCompact.ts +201 -0
- package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
- package/extension/{doors → pi/v1/delivery}/land.ts +123 -61
- package/extension/pi/v1/delivery/ready.ts +322 -0
- package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
- package/extension/pi/v1/delivery/stackDrive.ts +120 -0
- package/extension/pi/v1/delivery/stackLand.ts +223 -0
- package/extension/pi/v1/delivery/stackRecover.ts +265 -0
- package/extension/pi/v1/delivery/stackStatus.ts +237 -0
- package/extension/pi/v1/delivery/stackSync.ts +658 -0
- package/extension/pi/v1/delivery/submit.ts +389 -0
- package/extension/pi/v1/delivery/submitConflict.ts +186 -0
- package/extension/pi/v1/draftReview.ts +431 -0
- package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
- package/extension/pi/v1/gist.ts +794 -0
- package/extension/pi/v1/learning/audit.ts +186 -0
- package/extension/pi/v1/learning/dream.ts +207 -0
- package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
- package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
- package/extension/pi/v1/learning/learn.ts +585 -0
- package/extension/pi/v1/lifecycleGates.ts +127 -0
- package/extension/{factories → pi/v1}/objective.ts +53 -33
- package/extension/pi/v1/objectiveAuthoring.ts +672 -0
- package/extension/pi/v1/objectiveDreamGate.ts +160 -0
- package/extension/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
- package/extension/pi/v1/objectiveRefinement.ts +1320 -0
- package/extension/pi/v1/objectiveReview.ts +451 -0
- package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
- package/extension/pi/v1/plan.ts +812 -0
- package/extension/pi/v1/planReview.ts +820 -0
- package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
- package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
- package/extension/pi/v1/providers/plannotator.ts +487 -0
- package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +73 -27
- package/extension/pi/v1/providers/selection.ts +43 -0
- package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
- package/extension/pi/v1/review.ts +538 -0
- package/extension/pi/v1/reviewOutcome.ts +9 -0
- package/extension/pi/v1/scoutWave.ts +318 -0
- package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
- package/extension/session/branchWorkflowSession.ts +60 -0
- package/extension/session/lifecycle.ts +644 -0
- package/extension/session/lifecycleGates.ts +64 -0
- package/extension/session/saveDestination.ts +87 -0
- package/extension/session/workflowSession.ts +971 -0
- package/extension/substrate/agentScratch.ts +27 -54
- package/extension/substrate/bashScanTimeout.ts +181 -0
- package/extension/substrate/bindingDelivery.ts +38 -30
- package/extension/substrate/bindings.ts +4 -5
- package/extension/substrate/cache.ts +64 -12
- package/extension/substrate/childRestrictions.ts +39 -0
- package/extension/substrate/coldDoor.ts +17 -1
- package/extension/substrate/config.ts +157 -21
- package/extension/substrate/git.ts +88 -6
- package/extension/substrate/modelVisible.ts +53 -0
- package/extension/substrate/prompts.ts +22 -0
- package/extension/substrate/registry.ts +2 -0
- package/extension/substrate/resolverLease.ts +5 -4
- package/extension/substrate/sessionData.ts +85 -152
- package/extension/substrate/toolGating.ts +263 -84
- package/extension/substrate/unifiedDiff.ts +1 -1
- package/extension/substrate/workflowState.ts +178 -163
- package/extension/substrate/worktreeResolverLock.ts +261 -0
- package/extension/surfaces/surfaces.ts +79 -27
- package/extension/waves/adversarialReviewWave.ts +87 -46
- package/extension/waves/blockedReports.ts +59 -0
- package/extension/waves/draftReviewWave.ts +42 -42
- package/extension/waves/laneIdentity.ts +77 -0
- package/extension/waves/objectiveExplorerWave.ts +24 -24
- package/extension/waves/prReviewWave.ts +89 -77
- package/extension/waves/reportWave.ts +438 -578
- package/extension/waves/reviewClassifierWave.ts +22 -22
- package/extension/waves/rpcAdapter.ts +100 -15
- package/extension/waves/scoutWave.ts +192 -0
- package/extension/waves/transport.ts +480 -0
- package/extension/worker/sdkAdapter.ts +494 -0
- package/extension/worker/stageExecution.ts +679 -0
- package/extension/workerMain.ts +18 -19
- package/package.json +6 -4
- package/prompts/_fixtures/live.yaml +43 -18
- package/prompts/contexts/adapters/plannotator-gist.md +6 -0
- package/prompts/contexts/adapters/plannotator-objective.md +6 -0
- package/prompts/contexts/adapters/plannotator-plan.md +8 -1
- package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
- package/prompts/contexts/objective-refinement.md +17 -0
- package/prompts/contexts/read-only.md +1 -1
- package/prompts/stages/conflict-resolution-continuation.md +9 -6
- package/prompts/stages/conflict-resolution.md +4 -4
- package/prompts/stages/objective-plan/guidance.md +2 -2
- package/prompts/stages/objective-plan/seed.md +9 -1
- package/prompts/stages/objective-reconcile-ready.md +1 -1
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-refine/seed.md +18 -0
- package/prompts/stages/objective-review-browser.md +4 -4
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/plan-review-browser.md +4 -4
- package/prompts/stages/pr-review-browser/active.md +3 -4
- package/prompts/stages/pr-review-browser/foreign.md +3 -4
- package/prompts/stages/pr-review-terminal/active.md +3 -3
- package/prompts/stages/pr-review-terminal/foreign.md +3 -3
- package/prompts/stages/pr-review.md +3 -3
- package/prompts/stages/stack-review-browser/stack.md +5 -6
- package/shared/README.md +8 -0
- package/shared/bindings.yaml +3 -3
- package/shared/contracts.md +2601 -506
- package/shared/fixtures/issues-table.json +130 -0
- package/shared/registry.yaml +13 -0
- package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
- package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
- package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
- package/extension/adapters/planAdapterPlannotator.ts +0 -362
- package/extension/doors/auditWaveTools.ts +0 -352
- package/extension/doors/ciExecutor.ts +0 -756
- package/extension/doors/commitCompact.ts +0 -251
- package/extension/doors/dreamWaveTools.ts +0 -489
- package/extension/doors/learn.ts +0 -668
- package/extension/doors/lifecycleGates.ts +0 -207
- package/extension/doors/objectiveStack.ts +0 -1543
- package/extension/doors/prReviewDynamic.ts +0 -276
- package/extension/doors/ready.ts +0 -279
- package/extension/doors/submit.ts +0 -373
- package/extension/doors/submitPrReview.ts +0 -505
- package/extension/factories/gistAuthor.ts +0 -94
- package/extension/factories/gistDraft.ts +0 -265
- package/extension/factories/gistSave.ts +0 -251
- package/extension/factories/implementHere.ts +0 -116
- package/extension/factories/objectiveAuthor.ts +0 -98
- package/extension/factories/objectiveDraft.ts +0 -466
- package/extension/factories/objectiveSave.ts +0 -366
- package/extension/factories/planDraft.ts +0 -140
- package/extension/factories/planMode.ts +0 -205
- package/extension/factories/planReview.ts +0 -1237
- package/extension/factories/planSave.ts +0 -604
- package/extension/factories/planTitle.ts +0 -141
- package/extension/substrate/structuredOutput.ts +0 -202
- package/extension/waves/auditWave.ts +0 -312
- package/extension/waves/harvestWave.ts +0 -399
- package/extension/waves/learnWave.ts +0 -155
- package/extension/waves/memoryAdapter.ts +0 -139
- package/extension/waves/prReviewDynamicWave.ts +0 -777
- package/extension/worker/readOnlySession.ts +0 -294
- package/extension/worker/worker.ts +0 -899
- package/prompts/stages/pr-review-dynamic.md +0 -7
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
// The confined stage-execution seam (`runStage`) — the headless stage-drive primitive.
|
|
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
|
+
// Budget semantics: `budget.tokens` counts FRESH WORK only — assistant `input + output` per
|
|
16
|
+
// `turn_end`. Cache reads/writes and the provider `reasoning` breakdown (a subset of `output` in
|
|
17
|
+
// pi-ai's normalization) are excluded by design; see the adapter's `StageEvent.freshTokens`.
|
|
18
|
+
//
|
|
19
|
+
// CONFINEMENT: this seam's caller surface carries no SDK shapes. Every `@earendil-works` import
|
|
20
|
+
// AND the session-drive mechanics (construction, raw events, prompt/abort) live in the private
|
|
21
|
+
// `./sdkAdapter.ts` — the seam drives the session only through the adapter's drive-session
|
|
22
|
+
// handle, whose listener receives the perk-owned `StageEvent` union (the adapter translates raw
|
|
23
|
+
// SDK events at the boundary; ALL policy folding — budget counters, terminal capture, outcome —
|
|
24
|
+
// happens here on perk shapes). The one opaque model input (`StageRunOptions.model`) is the
|
|
25
|
+
// adapter-minted nominal `WorkerModelSelection` (see the adapter header for the exact — narrow —
|
|
26
|
+
// opacity guarantee: an import-edge ban plus nominal minting, nothing stronger). `workerMain.ts`
|
|
27
|
+
// imports ONLY this seam (guard Rule F) and carries zero SDK imports.
|
|
28
|
+
|
|
29
|
+
import { appendFileSync } from "node:fs";
|
|
30
|
+
import { env } from "node:process";
|
|
31
|
+
import { ensureRunScratch, type PlanRef, readPlanRef, runEventsPath } from "../substrate/cache.ts";
|
|
32
|
+
import { capForModel } from "../substrate/modelVisible.ts";
|
|
33
|
+
import { planReadInstruction, render } from "../substrate/prompts.ts";
|
|
34
|
+
import { captureSessionPointer } from "../substrate/sessionPointers.ts";
|
|
35
|
+
import { rebuildWorkflowState } from "../substrate/workflowState.ts";
|
|
36
|
+
import {
|
|
37
|
+
createDriveSession,
|
|
38
|
+
type DriveRuntimeLike,
|
|
39
|
+
type DriveSessionHandle,
|
|
40
|
+
defaultCreateRuntime,
|
|
41
|
+
resolveAuth,
|
|
42
|
+
type StageEvent,
|
|
43
|
+
type WorkerModelSelection,
|
|
44
|
+
} from "./sdkAdapter.ts";
|
|
45
|
+
|
|
46
|
+
// Re-exports so `workerMain.ts` imports ONLY the seam (guard Rule F), plus the transitive type
|
|
47
|
+
// closure of `runStage`'s signature: the `StageRunDeps.createRuntime` fake-construction types and
|
|
48
|
+
// the type-only `StageRunOptions.model` token (`WorkerModelSelection` minting stays
|
|
49
|
+
// adapter-owned — tests that mint import the class from `sdkAdapter.ts`).
|
|
50
|
+
export type {
|
|
51
|
+
DriveEvent,
|
|
52
|
+
DriveRuntimeLike,
|
|
53
|
+
DriveSessionLike,
|
|
54
|
+
WorkerModelSelection,
|
|
55
|
+
} from "./sdkAdapter.ts";
|
|
56
|
+
export { resolveWorkerModel } from "./sdkAdapter.ts";
|
|
57
|
+
|
|
58
|
+
// --- contract types (additive-stable; §B of docs/design/headless-worker.md) ---------------------
|
|
59
|
+
|
|
60
|
+
/** The two read-write stages with `doors.cold_remote: true` (shared/registry.yaml). */
|
|
61
|
+
export type DriveStage = "implement" | "address";
|
|
62
|
+
|
|
63
|
+
/** Terminal run status (audit §B outcome shape). */
|
|
64
|
+
export type RunStatus = "completed" | "failed" | "aborted" | "budget_exhausted";
|
|
65
|
+
|
|
66
|
+
/** The first-of terminal signal that ended the drive (audit §B). */
|
|
67
|
+
export type TerminalSignal =
|
|
68
|
+
| "submit_tool"
|
|
69
|
+
| "address_resolved"
|
|
70
|
+
| "agent_idle_incomplete"
|
|
71
|
+
| "budget"
|
|
72
|
+
| "external_abort"
|
|
73
|
+
| "model_error";
|
|
74
|
+
|
|
75
|
+
/** The budget/timeout watchdog inputs (Gap 2). */
|
|
76
|
+
export interface DriveBudget {
|
|
77
|
+
maxTurns: number;
|
|
78
|
+
maxTokens: number;
|
|
79
|
+
wallClockMs: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The structured run outcome (audit §B). **Additive-stable**: later fields may be added; existing
|
|
84
|
+
* fields keep their meaning. Never thrown — `runStage` always resolves with one of these.
|
|
85
|
+
*/
|
|
86
|
+
export interface RunOutcome {
|
|
87
|
+
run_id: string;
|
|
88
|
+
stage: DriveStage;
|
|
89
|
+
status: RunStatus;
|
|
90
|
+
terminal_signal: TerminalSignal;
|
|
91
|
+
pr: { number: number; url: string } | null;
|
|
92
|
+
budget: { turns: number; tokens: number; elapsed_ms: number };
|
|
93
|
+
error: { type: string; message: string; summary: string } | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- structured run-event stream (§8.12) ----------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The structured run-event stream (contracts §8.12). A small, JSON-serializable,
|
|
100
|
+
* **additive-stable** discriminated union keyed on `kind` (distinct from `DriveEvent.type`). Every
|
|
101
|
+
* event carries a monotonic `seq` (0-based) and `t` (elapsed ms, same basis as
|
|
102
|
+
* `RunOutcome.budget.elapsed_ms`). Future nodes may add variants/fields; existing ones keep
|
|
103
|
+
* meaning — including deprecated variants that are no longer emitted (see `step_marker`).
|
|
104
|
+
*/
|
|
105
|
+
export type RunEvent =
|
|
106
|
+
| { kind: "run_started"; seq: number; t: number; run_id: string; stage: DriveStage }
|
|
107
|
+
// DEPRECATED — never emitted: the `[WIP:n]`/`[DONE:n]` marker protocol died with the
|
|
108
|
+
// checkpoints removal. Kept for additive-stable grammar — historical `events.ndjson` files
|
|
109
|
+
// may carry the variant (contracts §8.12).
|
|
110
|
+
| { kind: "step_marker"; seq: number; t: number; marker: "wip" | "done"; step: number }
|
|
111
|
+
| {
|
|
112
|
+
kind: "tool_outcome";
|
|
113
|
+
seq: number;
|
|
114
|
+
t: number;
|
|
115
|
+
tool: string;
|
|
116
|
+
ok: boolean;
|
|
117
|
+
summary: string | null;
|
|
118
|
+
}
|
|
119
|
+
| { kind: "run_finished"; seq: number; t: number; outcome: RunOutcome };
|
|
120
|
+
|
|
121
|
+
/** The injectable delivery seam: default = a run-scoped NDJSON file sink; tests inject an array. */
|
|
122
|
+
export type RunEventSink = (event: RunEvent) => void;
|
|
123
|
+
|
|
124
|
+
/** Distributive `Omit` so each `RunEvent` variant keeps its own fields when `seq`/`t` are stamped. */
|
|
125
|
+
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
|
|
126
|
+
type RunEventInput = DistributiveOmit<RunEvent, "seq" | "t">;
|
|
127
|
+
|
|
128
|
+
/** Per-event free-text cap (route-don't-relay): events carry the narrative, not raw tool payloads. */
|
|
129
|
+
const EVENT_SUMMARY_CAP = 2 * 1024;
|
|
130
|
+
|
|
131
|
+
export interface StageRunOptions {
|
|
132
|
+
/** Absolute path to the already-positioned worktree (Gap 7). */
|
|
133
|
+
worktree: string;
|
|
134
|
+
stage: DriveStage;
|
|
135
|
+
/** The seeded first prompt (see `initialPromptFor`). */
|
|
136
|
+
initialPrompt: string;
|
|
137
|
+
/**
|
|
138
|
+
* The one opaque model input: an adapter-minted nominal `WorkerModelSelection` (from
|
|
139
|
+
* `resolveWorkerModel`). Absent ⇒ the adapter builds a default-runtime selection and the SDK's
|
|
140
|
+
* own default resolution picks the model at session creation (settings `defaultModel` → pi's
|
|
141
|
+
* per-provider defaults → first available — Gap 5). Never pre-pinned here: `getAvailable()`
|
|
142
|
+
* sorts alphabetically, so `[0]` is the *oldest* model of the first provider (a since-removed
|
|
143
|
+
* `claude-3-5-haiku` date-pin 404'd a whole remote drive).
|
|
144
|
+
*/
|
|
145
|
+
model?: WorkerModelSelection;
|
|
146
|
+
budget: DriveBudget;
|
|
147
|
+
/** External cancellation; OR'd with the budget watchdog. */
|
|
148
|
+
signal?: AbortSignal;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The offline seam. `createRuntime` overrides the production runtime factory so tests drive
|
|
153
|
+
* synthetic sessions; `now` injects the clock for deterministic `elapsed_ms`.
|
|
154
|
+
*/
|
|
155
|
+
export interface StageRunDeps {
|
|
156
|
+
createRuntime?: (opts: StageRunOptions) => Promise<DriveRuntimeLike>;
|
|
157
|
+
now?: () => number;
|
|
158
|
+
/** The structured run-event sink. Absent ⇒ the default run-scoped NDJSON file sink. */
|
|
159
|
+
eventSink?: RunEventSink;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The natural-idle terminal classification (before watchdog/abort overrides). */
|
|
163
|
+
interface TerminalVerdict {
|
|
164
|
+
status: RunStatus;
|
|
165
|
+
terminal_signal: TerminalSignal;
|
|
166
|
+
pr: { number: number; url: string } | null;
|
|
167
|
+
errorType: string | null;
|
|
168
|
+
errorMessage: string | null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- pure helpers (module-local policy fold) -----------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/** Mutable counters/captures the drive's policy fold accumulates over the run. */
|
|
174
|
+
interface DriveCounters {
|
|
175
|
+
turns: number;
|
|
176
|
+
tokens: number;
|
|
177
|
+
/** Latest submit-bearing evidence (standalone submit or the nested finalizer submit). */
|
|
178
|
+
submitDetails: Record<string, unknown> | null;
|
|
179
|
+
finalizeDetails: Record<string, unknown> | null;
|
|
180
|
+
modelError: { message: string } | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function freshCounters(): DriveCounters {
|
|
184
|
+
return { turns: 0, tokens: 0, submitDetails: null, finalizeDetails: null, modelError: null };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Fold one perk-owned drive event into the running counters (pure) — the seam's policy fold over
|
|
189
|
+
* the adapter's translated `StageEvent` union. Counts turns and fresh-work tokens (the sum is
|
|
190
|
+
* adapter-computed; see `StageEvent`'s `freshTokens` doc for the reasoning-subset exclusion),
|
|
191
|
+
* captures the `submit`/`finalize_address` terminal tool details, and records a post-acceptance
|
|
192
|
+
* model error.
|
|
193
|
+
*/
|
|
194
|
+
function applyStageEvent(counters: DriveCounters, event: StageEvent): void {
|
|
195
|
+
if (event.kind === "turn_ended") {
|
|
196
|
+
counters.turns += 1;
|
|
197
|
+
counters.tokens += event.freshTokens;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (event.kind === "tool_ended") {
|
|
201
|
+
if (event.tool === "submit") counters.submitDetails = event.details;
|
|
202
|
+
else if (event.tool === "finalize_address") {
|
|
203
|
+
counters.finalizeDetails = event.details;
|
|
204
|
+
// A finalizer carries the submit that immediately preceded resolution. Recording it into the
|
|
205
|
+
// same latest-evidence slot means a later standalone submit naturally supersedes it after a
|
|
206
|
+
// conflict-resolver re-drive.
|
|
207
|
+
const nestedSubmit = event.details?.submit;
|
|
208
|
+
if (nestedSubmit && typeof nestedSubmit === "object" && !Array.isArray(nestedSubmit)) {
|
|
209
|
+
// The finalizer only exposes this nested block after submit succeeded; restore the
|
|
210
|
+
// success marker stripped from its nested public shape so a later failed standalone
|
|
211
|
+
// submit cannot accidentally satisfy the address completion predicate.
|
|
212
|
+
counters.submitDetails = { ok: true, ...(nestedSubmit as Record<string, unknown>) };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
counters.modelError = { message: event.message };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The one abort verdict — shared by the entry sample, the pre-prompt sample and the mid-drive
|
|
222
|
+
* `classify()` arm so all three abort routes yield a byte-identical outcome.
|
|
223
|
+
*/
|
|
224
|
+
const EXTERNAL_ABORT_VERDICT: TerminalVerdict = {
|
|
225
|
+
status: "aborted",
|
|
226
|
+
terminal_signal: "external_abort",
|
|
227
|
+
pr: null,
|
|
228
|
+
errorType: "external_abort",
|
|
229
|
+
errorMessage: "drive aborted by external signal.",
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** True when the budget watchdog should trip from the current counters. */
|
|
233
|
+
function budgetTripped(counters: DriveCounters, budget: DriveBudget): boolean {
|
|
234
|
+
return counters.turns >= budget.maxTurns || counters.tokens >= budget.maxTokens;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Classify a natural-idle terminal from the captured state (pure). `modelError` wins (post-
|
|
239
|
+
* acceptance error, §B #4); else the stage success predicate:
|
|
240
|
+
* - implement: a successful `submit` carrying a `pr` → completed/submit_tool;
|
|
241
|
+
* - address: `finalize_address` ok, `last_review_batch` appended, and the latest submit-bearing
|
|
242
|
+
* evidence is successful and not definitively unmergeable → completed/address_resolved;
|
|
243
|
+
* - otherwise the agent went idle without completing the stage → failed/agent_idle_incomplete.
|
|
244
|
+
*/
|
|
245
|
+
function evaluateTerminal(args: {
|
|
246
|
+
stage: DriveStage;
|
|
247
|
+
submitDetails: Record<string, unknown> | null;
|
|
248
|
+
finalizeDetails: Record<string, unknown> | null;
|
|
249
|
+
lastReviewBatchPresent: boolean;
|
|
250
|
+
modelError: { message: string } | null;
|
|
251
|
+
}): TerminalVerdict {
|
|
252
|
+
if (args.modelError !== null) {
|
|
253
|
+
return {
|
|
254
|
+
status: "failed",
|
|
255
|
+
terminal_signal: "model_error",
|
|
256
|
+
pr: null,
|
|
257
|
+
errorType: "model_error",
|
|
258
|
+
errorMessage: args.modelError.message,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (args.stage === "implement") {
|
|
263
|
+
const pr = extractPr(args.submitDetails);
|
|
264
|
+
if (args.submitDetails?.ok === true && pr !== null) {
|
|
265
|
+
// Completion additionally requires the submit to be mergeable: a definitively-
|
|
266
|
+
// unmergeable PR (merge conflicts unresolved) is NOT done. `mergeable === true`/`null`/
|
|
267
|
+
// absent all allow completion (fail-open); only a definitive `false` blocks it. On the
|
|
268
|
+
// happy path the resolver follow-up turns re-submit, overwriting submitDetails with a
|
|
269
|
+
// mergeable result, so the natural-idle classification then passes.
|
|
270
|
+
if (args.submitDetails.mergeable === false) {
|
|
271
|
+
return {
|
|
272
|
+
status: "failed",
|
|
273
|
+
terminal_signal: "agent_idle_incomplete",
|
|
274
|
+
pr: null,
|
|
275
|
+
errorType: "incomplete",
|
|
276
|
+
errorMessage:
|
|
277
|
+
"implement drive went idle with an unmergeable PR (merge conflicts unresolved).",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
status: "completed",
|
|
282
|
+
terminal_signal: "submit_tool",
|
|
283
|
+
pr,
|
|
284
|
+
errorType: null,
|
|
285
|
+
errorMessage: null,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
status: "failed",
|
|
290
|
+
terminal_signal: "agent_idle_incomplete",
|
|
291
|
+
pr: null,
|
|
292
|
+
errorType: "incomplete",
|
|
293
|
+
errorMessage: "implement drive went idle without an opened PR (no successful submit).",
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// address. `applyEvent` keeps submitDetails as the latest submit-bearing evidence: the nested
|
|
298
|
+
// finalizer submit first, then a later standalone submit from the conflict-resolution re-drive.
|
|
299
|
+
const nestedSubmit = args.finalizeDetails?.submit;
|
|
300
|
+
const fallbackSubmit: Record<string, unknown> | null =
|
|
301
|
+
nestedSubmit && typeof nestedSubmit === "object" && !Array.isArray(nestedSubmit)
|
|
302
|
+
? { ok: true, ...(nestedSubmit as Record<string, unknown>) }
|
|
303
|
+
: null;
|
|
304
|
+
const effectiveSubmit = args.submitDetails ?? fallbackSubmit;
|
|
305
|
+
if (
|
|
306
|
+
args.finalizeDetails?.ok === true &&
|
|
307
|
+
args.lastReviewBatchPresent &&
|
|
308
|
+
effectiveSubmit?.ok === true &&
|
|
309
|
+
effectiveSubmit.mergeable !== false
|
|
310
|
+
) {
|
|
311
|
+
return {
|
|
312
|
+
status: "completed",
|
|
313
|
+
terminal_signal: "address_resolved",
|
|
314
|
+
pr: null,
|
|
315
|
+
errorType: null,
|
|
316
|
+
errorMessage: null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
status: "failed",
|
|
321
|
+
terminal_signal: "agent_idle_incomplete",
|
|
322
|
+
pr: null,
|
|
323
|
+
errorType: "incomplete",
|
|
324
|
+
errorMessage:
|
|
325
|
+
"address drive went idle without fully finalizing feedback " +
|
|
326
|
+
"(publication, thread resolution, and last_review_batch are required).",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The post-bind preflight rule (pure): the stage's terminating perk tool must be registered —
|
|
332
|
+
* `implement` → `submit`, `address` → `finalize_address`. Returns the required tool name
|
|
333
|
+
* when absent, else `null`. Deliberately does NOT require the `subagent` tool for `address` — the
|
|
334
|
+
* subagent-under-worker live smoke stays the §8.11 carried risk.
|
|
335
|
+
*/
|
|
336
|
+
function missingTerminatingTool(stage: DriveStage, toolNames: string[]): string | null {
|
|
337
|
+
const required = stage === "implement" ? "submit" : "finalize_address";
|
|
338
|
+
return toolNames.includes(required) ? null : required;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Pull a `{ number, url }` PR from a captured `submit` details block; null when malformed. */
|
|
342
|
+
function extractPr(
|
|
343
|
+
details: Record<string, unknown> | null,
|
|
344
|
+
): { number: number; url: string } | null {
|
|
345
|
+
if (!details || typeof details.pr !== "object" || details.pr === null) return null;
|
|
346
|
+
const pr = details.pr as { number?: unknown; url?: unknown };
|
|
347
|
+
if (typeof pr.number === "number" && typeof pr.url === "string") {
|
|
348
|
+
return { number: pr.number, url: pr.url };
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Compose the final `RunOutcome` (pure). `run_id` is read from `PERK_RUN_ID` (inherited from
|
|
355
|
+
* positioning, Gap 7), overridable for tests. On a non-completed status the `error` block carries a
|
|
356
|
+
* capped `error.summary` (route-don't-relay discipline); a completed status has `error: null`.
|
|
357
|
+
*/
|
|
358
|
+
function assembleOutcome(args: {
|
|
359
|
+
stage: DriveStage;
|
|
360
|
+
verdict: TerminalVerdict;
|
|
361
|
+
budget: { turns: number; tokens: number; elapsed_ms: number };
|
|
362
|
+
runId?: string;
|
|
363
|
+
}): RunOutcome {
|
|
364
|
+
const { verdict } = args;
|
|
365
|
+
const error =
|
|
366
|
+
verdict.status === "completed" || verdict.errorMessage === null
|
|
367
|
+
? null
|
|
368
|
+
: {
|
|
369
|
+
type: verdict.errorType ?? "error",
|
|
370
|
+
message: verdict.errorMessage,
|
|
371
|
+
summary: capForModel(verdict.errorMessage).shown,
|
|
372
|
+
};
|
|
373
|
+
return {
|
|
374
|
+
run_id: args.runId ?? env.PERK_RUN_ID ?? "",
|
|
375
|
+
stage: args.stage,
|
|
376
|
+
status: verdict.status,
|
|
377
|
+
terminal_signal: verdict.terminal_signal,
|
|
378
|
+
pr: verdict.pr,
|
|
379
|
+
budget: args.budget,
|
|
380
|
+
error,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// --- run-event helpers (offline-testable) ---------------------------------------------
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Compute a `tool_outcome` `{ tool, ok, summary }` from a translated `tool_ended` event (pure).
|
|
388
|
+
* `summary` is `null` on success and, on failure, a capped (route-don't-relay) rendering of the
|
|
389
|
+
* adapter's pre-cap error text — never the raw tool result. The `errorText` nullish fallback
|
|
390
|
+
* below is defensive only: the adapter always supplies `errorText` for failures (pinned in
|
|
391
|
+
* `sdkAdapter.test.ts`), so it is unreachable through the production path.
|
|
392
|
+
*/
|
|
393
|
+
function toolOutcomeOf(event: Extract<StageEvent, { kind: "tool_ended" }>): {
|
|
394
|
+
tool: string;
|
|
395
|
+
ok: boolean;
|
|
396
|
+
summary: string | null;
|
|
397
|
+
} {
|
|
398
|
+
const summary = event.ok
|
|
399
|
+
? null
|
|
400
|
+
: capForModel(event.errorText ?? `tool ${event.tool} failed`, EVENT_SUMMARY_CAP).shown;
|
|
401
|
+
return { tool: event.tool, ok: event.ok, summary };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The run-event emitter: owns the monotonic `seq` counter and stamps `t = max(0, now() - startMs)`
|
|
406
|
+
* (same basis as `RunOutcome.budget.elapsed_ms`). Fail-soft: a throwing injected sink is caught and
|
|
407
|
+
* swallowed so a broken sink never aborts the drive.
|
|
408
|
+
*/
|
|
409
|
+
function createEventEmitter(sink: RunEventSink, now: () => number, startMs: number) {
|
|
410
|
+
let seq = 0;
|
|
411
|
+
return {
|
|
412
|
+
emit(event: RunEventInput): void {
|
|
413
|
+
const full = { ...event, seq: seq++, t: Math.max(0, now() - startMs) } as RunEvent;
|
|
414
|
+
try {
|
|
415
|
+
sink(full);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
console.error(`perk worker: run-event sink threw — ${String(err)}`);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* The default run-event sink: a fail-soft NDJSON appender to `runEventsPath(worktree, runId)`. A
|
|
425
|
+
* **no-op when `runId` is empty** (keeps the offline drive tests, which set no `PERK_RUN_ID`,
|
|
426
|
+
* write-free). Each append is wrapped so a write error logs and is swallowed.
|
|
427
|
+
*/
|
|
428
|
+
function defaultEventSink(worktree: string, runId: string): RunEventSink {
|
|
429
|
+
if (!runId) return () => {};
|
|
430
|
+
let ensured = false;
|
|
431
|
+
const path = runEventsPath(worktree, runId);
|
|
432
|
+
return (event: RunEvent): void => {
|
|
433
|
+
try {
|
|
434
|
+
if (!ensured) {
|
|
435
|
+
ensureRunScratch(worktree, runId);
|
|
436
|
+
ensured = true;
|
|
437
|
+
}
|
|
438
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.error(`perk worker: run-event sink write failed — ${String(err)}`);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Re-derive the stage's initial prompt from the plan-ref — the TS twin of
|
|
447
|
+
* `perk/run/launch.py._implement_prompt`/`_address_prompt`. INVARIANT: textual parity with the Python
|
|
448
|
+
* plane (asserted reciprocally in `stageExecution.test.ts` + `tests/test_worker_prompt_parity.py`). No
|
|
449
|
+
* skill-binding suffix is appended here: in the driven session the bindings arrive via Mechanism A
|
|
450
|
+
* (bindingDelivery.ts injects the handoff stage's render because this prompt carries no
|
|
451
|
+
* `BINDING_HEADER`) — content byte-identical to the cold door's suffix (contracts.md §8.38).
|
|
452
|
+
* Returns `null` when there is no plan-ref (nothing to prime).
|
|
453
|
+
*
|
|
454
|
+
* The implement primer's wording lives in the canonical template `prompts/stages/implement.md`,
|
|
455
|
+
* rendered by the shared seam (contracts.md §8.31); branching stays in code — only the `read_cmd`
|
|
456
|
+
* var differs. This implement output is byte-identical to the warm `implementHandoffPrompt`.
|
|
457
|
+
*
|
|
458
|
+
* The `address` wording lives in the shared canonical template `prompts/stages/address/action.md`
|
|
459
|
+
* rendered via the cross-plane render seam (contracts.md §8.31); the worker has no preview path
|
|
460
|
+
* (preview is a warm/cold flag only), so it always renders the action body. The classify step is
|
|
461
|
+
* the `classify_review_feedback` tool, which reads the configured classifier model at execute
|
|
462
|
+
* time — nothing model-shaped rides the prompt.
|
|
463
|
+
*/
|
|
464
|
+
function initialPromptFor(stage: DriveStage, planRef: PlanRef | null): string | null {
|
|
465
|
+
if (planRef === null) return null;
|
|
466
|
+
const provider = String(planRef.provider ?? "");
|
|
467
|
+
const prId = String(planRef.pr_id ?? "");
|
|
468
|
+
const url = String(planRef.url ?? "");
|
|
469
|
+
if (stage === "implement") {
|
|
470
|
+
const readCmd = planReadInstruction(provider, prId, url);
|
|
471
|
+
return render("stages/implement.md", { provider, pr_id: prId, url, read_cmd: readCmd });
|
|
472
|
+
}
|
|
473
|
+
// address
|
|
474
|
+
return render("stages/address/action.md", { provider, pr_id: prId, url });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// --- the drive primitive ------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Drive one stage to terminal and return a structured `RunOutcome` — never throws (fail-soft like
|
|
481
|
+
* the delivery submit operation). Seeds `initialPrompt`, races the driving `prompt()` against the budget watchdog and
|
|
482
|
+
* the external `signal`, classifies the terminal at idle, and disposes the adapter handle in
|
|
483
|
+
* `finally` (guarded — a cleanup error can never replace the computed outcome). All session
|
|
484
|
+
* mechanics go through the adapter's drive-session handle; policy stays here.
|
|
485
|
+
*
|
|
486
|
+
* The `run_started` + `run_finished` pair is emitted at entry / at every exit, so each path —
|
|
487
|
+
* pre-aborted, `runtime_init`, `no_model`, the preflight, a pre-prompt abort, the drive — is a
|
|
488
|
+
* well-formed zero-or-more-turn stream. Auth/model resolution, runtime construction and the bind
|
|
489
|
+
* all run inside the outcome boundary: the catch arm keys its `error.type` on the bind boundary
|
|
490
|
+
* (`runtime_init` before the session is bound, `drive_error` after). The external `signal` is
|
|
491
|
+
* SAMPLED at entry and again immediately before `prompt()` (an idle session has nothing to abort,
|
|
492
|
+
* so an aborted sample returns `aborted` directly) and SUBSCRIBED only for the drive — the
|
|
493
|
+
* listener is registered synchronously after the pre-prompt sample because `AbortSignal` never
|
|
494
|
+
* replays an earlier abort to a late listener.
|
|
495
|
+
*/
|
|
496
|
+
export async function runStage(
|
|
497
|
+
opts: StageRunOptions,
|
|
498
|
+
deps: StageRunDeps = {},
|
|
499
|
+
): Promise<RunOutcome> {
|
|
500
|
+
const now = deps.now ?? Date.now;
|
|
501
|
+
const startMs = now();
|
|
502
|
+
const counters = freshCounters();
|
|
503
|
+
const elapsed = (): number => Math.max(0, now() - startMs);
|
|
504
|
+
|
|
505
|
+
// Structured run-event stream: resolve the sink + run_id once, build the emitter, emit
|
|
506
|
+
// `run_started` once at entry, and route every terminal exit through `finish` so exactly one
|
|
507
|
+
// `run_started` + `run_finished` pair is emitted per drive.
|
|
508
|
+
const runId = env.PERK_RUN_ID ?? "";
|
|
509
|
+
const sink = deps.eventSink ?? defaultEventSink(opts.worktree, runId);
|
|
510
|
+
const emitter = createEventEmitter(sink, now, startMs);
|
|
511
|
+
const finish = (verdict: TerminalVerdict): RunOutcome => {
|
|
512
|
+
const outcome = assembleOutcome({
|
|
513
|
+
stage: opts.stage,
|
|
514
|
+
verdict,
|
|
515
|
+
budget: { turns: counters.turns, tokens: counters.tokens, elapsed_ms: elapsed() },
|
|
516
|
+
});
|
|
517
|
+
emitter.emit({ kind: "run_finished", outcome });
|
|
518
|
+
return outcome;
|
|
519
|
+
};
|
|
520
|
+
emitter.emit({ kind: "run_started", run_id: runId, stage: opts.stage });
|
|
521
|
+
|
|
522
|
+
// The entry sample: nothing is resolved, constructed or bound for an already-aborted signal.
|
|
523
|
+
if (opts.signal?.aborted) return finish(EXTERNAL_ABORT_VERDICT);
|
|
524
|
+
|
|
525
|
+
let terminationReason: "natural" | "budget" | "abort" = "natural";
|
|
526
|
+
let settled = false;
|
|
527
|
+
let bound = false;
|
|
528
|
+
let handle: DriveSessionHandle | null = null;
|
|
529
|
+
const listener = (event: StageEvent): void => {
|
|
530
|
+
applyStageEvent(counters, event);
|
|
531
|
+
if (event.kind === "turn_ended") {
|
|
532
|
+
if (budgetTripped(counters, opts.budget)) trip("budget");
|
|
533
|
+
} else if (event.kind === "tool_ended") {
|
|
534
|
+
const o = toolOutcomeOf(event);
|
|
535
|
+
emitter.emit({ kind: "tool_outcome", tool: o.tool, ok: o.ok, summary: o.summary });
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
function trip(reason: "budget" | "abort"): void {
|
|
540
|
+
if (settled) return;
|
|
541
|
+
if (terminationReason === "natural") terminationReason = reason;
|
|
542
|
+
handle?.abort();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const onSignal = (): void => trip("abort");
|
|
546
|
+
|
|
547
|
+
try {
|
|
548
|
+
let runtime: DriveRuntimeLike;
|
|
549
|
+
if (deps.createRuntime) runtime = await deps.createRuntime(opts);
|
|
550
|
+
else {
|
|
551
|
+
// Auth/model resolution is a production-path concern only: an injected runtime factory
|
|
552
|
+
// (tests) never touches the default `ModelRuntime.create` (no host file reads).
|
|
553
|
+
const resolved = await resolveAuth(opts.model);
|
|
554
|
+
if (resolved === null) {
|
|
555
|
+
return finish({
|
|
556
|
+
status: "failed",
|
|
557
|
+
terminal_signal: "model_error",
|
|
558
|
+
pr: null,
|
|
559
|
+
errorType: "no_model",
|
|
560
|
+
errorMessage:
|
|
561
|
+
"no model available — set an API key (e.g. ANTHROPIC_API_KEY) or pass a model.",
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
runtime = await defaultCreateRuntime(opts.worktree, resolved);
|
|
565
|
+
}
|
|
566
|
+
handle = createDriveSession(runtime, listener);
|
|
567
|
+
await handle.bind();
|
|
568
|
+
bound = true;
|
|
569
|
+
|
|
570
|
+
// Terminating-tool preflight (presence-gated on the session's extension runner): disk
|
|
571
|
+
// discovery has a silent-zero arm — a missing/unparseable `.pi/settings.json` or an
|
|
572
|
+
// unresolvable local-path package yields ZERO extension tools without throwing — so fail
|
|
573
|
+
// fast (zero turns) instead of burning the whole budget on a drive that can never call its
|
|
574
|
+
// terminating tool. Reuses the `model_error` terminal signal with a distinct `error.type`
|
|
575
|
+
// (the `no_model` precedent).
|
|
576
|
+
const toolNames = handle.registeredToolNames();
|
|
577
|
+
if (toolNames !== null) {
|
|
578
|
+
const missing = missingTerminatingTool(opts.stage, toolNames);
|
|
579
|
+
if (missing !== null) {
|
|
580
|
+
return finish({
|
|
581
|
+
status: "failed",
|
|
582
|
+
terminal_signal: "model_error",
|
|
583
|
+
pr: null,
|
|
584
|
+
errorType: "no_extension_tools",
|
|
585
|
+
errorMessage:
|
|
586
|
+
`perk extension tools did not register — the ${opts.stage} stage's terminating ` +
|
|
587
|
+
`tool \`${missing}\` is missing. Check the worktree's .pi/settings.json packages ` +
|
|
588
|
+
"list (perk init converges it); construction diagnostics are on stderr.",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Implementation/worker session pointer (contracts.md §8.35): the headless drive records the
|
|
594
|
+
// inner driven session's file under THIS run id into the shared main checkout (the worktree's
|
|
595
|
+
// `mainCheckoutRoot`), labelled `.worker` by capture site. The inner session's own
|
|
596
|
+
// `session_start` records the matching `.main`. Best-effort + non-fatal (carrier warns).
|
|
597
|
+
if (opts.stage === "implement") {
|
|
598
|
+
captureSessionPointer({
|
|
599
|
+
cwd: opts.worktree,
|
|
600
|
+
runId,
|
|
601
|
+
klass: "implementation",
|
|
602
|
+
site: "worker",
|
|
603
|
+
sessionFile: handle.sessionFile(),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// The pre-prompt sample, then the drive-only subscription with no `await` in between (no
|
|
608
|
+
// check-then-subscribe window). Budget/abort wiring (Gap 2): wall-clock timer + external
|
|
609
|
+
// signal both trip → handle.abort().
|
|
610
|
+
if (opts.signal?.aborted) return finish(EXTERNAL_ABORT_VERDICT);
|
|
611
|
+
const timer = setTimeout(() => trip("budget"), opts.budget.wallClockMs);
|
|
612
|
+
opts.signal?.addEventListener("abort", onSignal, { once: true });
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
await handle.prompt(opts.initialPrompt);
|
|
616
|
+
} finally {
|
|
617
|
+
clearTimeout(timer);
|
|
618
|
+
opts.signal?.removeEventListener("abort", onSignal);
|
|
619
|
+
settled = true;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Defensive rebind (Gap 1): the happy path never replaces the session; a replacement is loud.
|
|
623
|
+
if (await handle.rebindIfReplaced()) {
|
|
624
|
+
console.error("perk worker: unexpected mid-drive session replacement — rebinding listener.");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const verdict = classify(opts, counters, terminationReason, () => handle?.workflowBranch());
|
|
628
|
+
return finish(verdict);
|
|
629
|
+
} catch (err) {
|
|
630
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
631
|
+
return finish({
|
|
632
|
+
status: "failed",
|
|
633
|
+
terminal_signal: "model_error",
|
|
634
|
+
pr: null,
|
|
635
|
+
errorType: bound ? "drive_error" : "runtime_init",
|
|
636
|
+
errorMessage: bound
|
|
637
|
+
? `headless drive failed: ${message}`
|
|
638
|
+
: `worker runtime initialization failed: ${message}`,
|
|
639
|
+
});
|
|
640
|
+
} finally {
|
|
641
|
+
if (handle) await handle.dispose();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Pick the terminal verdict: watchdog/abort override the natural-idle classification. `branch` is
|
|
647
|
+
* a thunk so the workflow branch is read only on the natural path (exactly as before the seam).
|
|
648
|
+
*/
|
|
649
|
+
function classify(
|
|
650
|
+
opts: StageRunOptions,
|
|
651
|
+
counters: DriveCounters,
|
|
652
|
+
terminationReason: "natural" | "budget" | "abort",
|
|
653
|
+
branch: () => unknown[] | undefined,
|
|
654
|
+
): TerminalVerdict {
|
|
655
|
+
if (terminationReason === "budget") {
|
|
656
|
+
return {
|
|
657
|
+
status: "budget_exhausted",
|
|
658
|
+
terminal_signal: "budget",
|
|
659
|
+
pr: null,
|
|
660
|
+
errorType: "budget",
|
|
661
|
+
errorMessage: "budget exhausted (turns/tokens/wall-clock) — drive aborted.",
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (terminationReason === "abort") return EXTERNAL_ABORT_VERDICT;
|
|
665
|
+
const lastReviewBatchPresent =
|
|
666
|
+
rebuildWorkflowState((branch() ?? []) as never).last_review_batch != null;
|
|
667
|
+
return evaluateTerminal({
|
|
668
|
+
stage: opts.stage,
|
|
669
|
+
submitDetails: counters.submitDetails,
|
|
670
|
+
finalizeDetails: counters.finalizeDetails,
|
|
671
|
+
lastReviewBatchPresent,
|
|
672
|
+
modelError: counters.modelError,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Convenience: re-derive the initial prompt for a prepared worktree (reads its `cache.plan-ref`). */
|
|
677
|
+
export function initialPromptForWorktree(worktree: string, stage: DriveStage): string | null {
|
|
678
|
+
return initialPromptFor(stage, readPlanRef(worktree));
|
|
679
|
+
}
|