@yagni-app/code-staging 1.1.8-staging.1462.1 → 1.1.8-staging.1467.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/dist/extension/pipeline/fanoutBeats.d.ts +60 -8
- package/dist/extension/pipeline/fanoutBeats.js +9 -7
- package/dist/extension/pipeline/goCommand.d.ts +11 -13
- package/dist/extension/pipeline/goCommand.js +33 -210
- package/dist/extension/pipeline/headlessGo.js +3 -3
- package/dist/extension/pipeline/orchestrator.js +7 -7
- package/dist/extension/pipeline/runState.d.ts +8 -10
- package/dist/extension/pipeline/runState.js +4 -4
- package/dist/extension/pipeline/ticketResolution.d.ts +2 -2
- package/dist/extension/pipeline/ticketResolution.js +2 -2
- package/dist/extension/sessionRuns.d.ts +3 -2
- package/dist/extension/sessionRuns.js +3 -2
- package/package.json +2 -2
- package/dist/extension/pipeline/runSession.d.ts +0 -202
- package/dist/extension/pipeline/runSession.js +0 -202
|
@@ -1,27 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The implement diamond's RECORDING thread: pipeline progress in,
|
|
3
|
-
*
|
|
2
|
+
* The implement diamond's RECORDING thread: pipeline progress in, stage beats
|
|
3
|
+
* out (spec "Recording and the run surface").
|
|
4
4
|
*
|
|
5
5
|
* The pipeline already says everything the run surface needs — the partition
|
|
6
6
|
* verdict, each builder starting and finishing, each bounded fix turn — on the
|
|
7
7
|
* `onProgress` channel. This folds that stream into the additive
|
|
8
|
-
* `fanout` / `children` / `fixTurns` payloads
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* `fanout` / `children` / `fixTurns` payloads that headless /go (the mission
|
|
9
|
+
* sandbox's `yagni go --headless`) emits as `pipeline_fanout` records on its
|
|
10
|
+
* stream, so the mission executor can render per-workstream rows without the
|
|
11
|
+
* pipeline growing a second reporting path. Interactive /go does not use it:
|
|
12
|
+
* it records no backend run.
|
|
11
13
|
*
|
|
12
14
|
* PURE and I/O-free, like `findings.ts` and `fanout.ts`: `apply` returns the beat
|
|
13
15
|
* to send (or null when the signal is not the diamond's), and the caller decides
|
|
14
|
-
* whether to
|
|
16
|
+
* whether to emit it. Two properties are load-bearing:
|
|
15
17
|
*
|
|
16
18
|
* 1. **Recorded states only.** A child row exists only once its `stage_start`
|
|
17
19
|
* arrives, and it is `failed` only when the pipeline said the child died.
|
|
18
20
|
* There is no invented "pending", and no tick nobody earned.
|
|
19
21
|
* 2. **Every beat is a copy.** The roster keeps mutating as children finish, so
|
|
20
|
-
* a beat already handed to the (async
|
|
22
|
+
* a beat already handed to the (async) stream writer must never change
|
|
21
23
|
* underneath it.
|
|
22
24
|
*/
|
|
23
|
-
import type { StageArgs } from "./runSession.js";
|
|
24
25
|
import type { PipelineProgress } from "./types.js";
|
|
26
|
+
/**
|
|
27
|
+
* The implement diamond's partition verdict as the run records it: the fan and
|
|
28
|
+
* its width, or the single-writer degrade, always with the reason that produced
|
|
29
|
+
* it. Rides one implement stage beat (see {@link StageArgs}).
|
|
30
|
+
*/
|
|
31
|
+
export interface StageFanoutDecision {
|
|
32
|
+
mode: "fan" | "single";
|
|
33
|
+
width?: number;
|
|
34
|
+
reason: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One fan-out child on a stage beat: the workstream's name, the tier it runs on,
|
|
38
|
+
* how many paths it claimed, and the state the run has actually OBSERVED. There
|
|
39
|
+
* is no "pending" state on purpose: a child appears the moment it starts, so a
|
|
40
|
+
* surface can only ever paint children that really ran.
|
|
41
|
+
*/
|
|
42
|
+
export interface StageChildRow {
|
|
43
|
+
name: string;
|
|
44
|
+
tier?: "standard" | "efficient";
|
|
45
|
+
state: "running" | "done" | "failed";
|
|
46
|
+
/** How many path claims the partition gave this workstream; absent when unrecorded. */
|
|
47
|
+
files?: number;
|
|
48
|
+
note?: string;
|
|
49
|
+
}
|
|
50
|
+
/** One bounded fix turn: which findings it answered and which children it re-engaged. */
|
|
51
|
+
export interface StageFixTurn {
|
|
52
|
+
turn: number;
|
|
53
|
+
findings: number;
|
|
54
|
+
reengaged: string[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A pipeline stage boundary event.
|
|
58
|
+
*
|
|
59
|
+
* `fanout` / `children` / `fixTurns` are the implement diamond's additive
|
|
60
|
+
* payloads (spec "Recording and the run surface"). They ride INTERIM implement
|
|
61
|
+
* beats and are append-only: an older CLI simply never sends them, and a backend
|
|
62
|
+
* that does not know them ignores them, so the contract stays backwards
|
|
63
|
+
* compatible in both directions. Every beat carries at most one of them.
|
|
64
|
+
*/
|
|
65
|
+
export interface StageArgs {
|
|
66
|
+
stage: string;
|
|
67
|
+
phase: "start" | "finish";
|
|
68
|
+
round?: number;
|
|
69
|
+
findings?: number;
|
|
70
|
+
/** The diamond's verdict, sent once per implement stage the moment it resolves. */
|
|
71
|
+
fanout?: StageFanoutDecision;
|
|
72
|
+
/** The whole per-workstream roster, re-sent on every child state change. */
|
|
73
|
+
children?: StageChildRow[];
|
|
74
|
+
/** Every fix turn recorded so far, re-sent as each new one lands. */
|
|
75
|
+
fixTurns?: StageFixTurn[];
|
|
76
|
+
}
|
|
25
77
|
/** Folds the diamond's progress signals into the stage beats that record them. */
|
|
26
78
|
export interface FanoutBeats {
|
|
27
79
|
/** The beat this signal produces, or null when it is not the diamond's. */
|
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The implement diamond's RECORDING thread: pipeline progress in,
|
|
3
|
-
*
|
|
2
|
+
* The implement diamond's RECORDING thread: pipeline progress in, stage beats
|
|
3
|
+
* out (spec "Recording and the run surface").
|
|
4
4
|
*
|
|
5
5
|
* The pipeline already says everything the run surface needs — the partition
|
|
6
6
|
* verdict, each builder starting and finishing, each bounded fix turn — on the
|
|
7
7
|
* `onProgress` channel. This folds that stream into the additive
|
|
8
|
-
* `fanout` / `children` / `fixTurns` payloads
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* `fanout` / `children` / `fixTurns` payloads that headless /go (the mission
|
|
9
|
+
* sandbox's `yagni go --headless`) emits as `pipeline_fanout` records on its
|
|
10
|
+
* stream, so the mission executor can render per-workstream rows without the
|
|
11
|
+
* pipeline growing a second reporting path. Interactive /go does not use it:
|
|
12
|
+
* it records no backend run.
|
|
11
13
|
*
|
|
12
14
|
* PURE and I/O-free, like `findings.ts` and `fanout.ts`: `apply` returns the beat
|
|
13
15
|
* to send (or null when the signal is not the diamond's), and the caller decides
|
|
14
|
-
* whether to
|
|
16
|
+
* whether to emit it. Two properties are load-bearing:
|
|
15
17
|
*
|
|
16
18
|
* 1. **Recorded states only.** A child row exists only once its `stage_start`
|
|
17
19
|
* arrives, and it is `failed` only when the pipeline said the child died.
|
|
18
20
|
* There is no invented "pending", and no tick nobody earned.
|
|
19
21
|
* 2. **Every beat is a copy.** The roster keeps mutating as children finish, so
|
|
20
|
-
* a beat already handed to the (async
|
|
22
|
+
* a beat already handed to the (async) stream writer must never change
|
|
21
23
|
* underneath it.
|
|
22
24
|
*/
|
|
23
25
|
/** The stage every fan-out beat belongs to: the diamond lives INSIDE implement. */
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* `--pr` — push the run branch and open a PR under the user's own gh auth. A
|
|
21
21
|
* FINISH failure NEVER flips a clean run to failed and never fakes a commit
|
|
22
22
|
* (§0.1): every skip/failure is an honest note on the handoff. The commit/PR
|
|
23
|
-
* land on `PipelineResult.finish`,
|
|
23
|
+
* land on `PipelineResult.finish`, the registry row, the handoff, a
|
|
24
24
|
* `finish_done` checkpoint record, and a `finish` row in the activity feed.
|
|
25
25
|
*
|
|
26
26
|
* WORKTREE BY DEFAULT (spec §3b): the run gets its own worktree + branch FIRST
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* before mutating the user's tree; `--here` is the explicit in-place escape
|
|
37
37
|
* hatch.
|
|
38
38
|
*
|
|
39
|
-
* DETACHED BY DEFAULT: /go returns the prompt as soon as the run
|
|
39
|
+
* DETACHED BY DEFAULT: /go returns the prompt as soon as the run is registered;
|
|
40
40
|
* the pipeline promise is tracked in the module-scoped run registry (mirrored to
|
|
41
41
|
* ~/.yagni-code/registry.ndjson) and completion delivers the existing handoff
|
|
42
42
|
* via `pi.sendUserMessage(handoff, { deliverAs: "followUp" })` (try/catch with a
|
|
@@ -52,17 +52,17 @@
|
|
|
52
52
|
* optional-chained so headless runs degrade gracefully. `runPipeline` is
|
|
53
53
|
* injected so the handler is unit-testable without spawning.
|
|
54
54
|
*
|
|
55
|
-
*
|
|
56
|
-
* the
|
|
57
|
-
*
|
|
58
|
-
* is
|
|
55
|
+
* No backend run: interactive /go records its run LOCALLY only (the registry
|
|
56
|
+
* row, the checkpoint journal, and the handoff). It opens no `runs` row, so its
|
|
57
|
+
* spend is attributed to the driver session. The only /go that carries a
|
|
58
|
+
* backend run id is headless (`yagni go --headless --run-id`, run by the
|
|
59
|
+
* mission sandbox), which passes it in through `checkpointMeta.runId`.
|
|
59
60
|
*/
|
|
60
61
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
61
62
|
import type { SpendResponse } from "../costHud.js";
|
|
62
63
|
import { type CrashReporter } from "../crashReport.js";
|
|
63
64
|
import { runFinish as defaultRunFinish } from "./finish.js";
|
|
64
65
|
import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
65
|
-
import { makeRunSession as defaultMakeRunSession } from "./runSession.js";
|
|
66
66
|
import { bootstrapWorktree as defaultBootstrapWorktree, createRunWorktree as defaultCreateRunWorktree } from "./worktree.js";
|
|
67
67
|
import type { CheckpointRecord, CheckpointStore, WorkspaceSnapshot } from "./types.js";
|
|
68
68
|
/** Best-effort git context for the current worktree (all fields optional). */
|
|
@@ -73,8 +73,6 @@ export interface RepoContext {
|
|
|
73
73
|
}
|
|
74
74
|
export interface RegisterGoDeps {
|
|
75
75
|
runPipeline?: typeof defaultRunPipeline;
|
|
76
|
-
/** Injectable so the handler can be unit-tested without a network. */
|
|
77
|
-
makeRunSession?: typeof defaultMakeRunSession;
|
|
78
76
|
/** Injectable so the handler can be unit-tested without spawning git. */
|
|
79
77
|
resolveRepoContext?: (cwd: string | undefined, signal?: AbortSignal) => Promise<RepoContext>;
|
|
80
78
|
/** Injectable durable journal (default = local file + pi-journal combined). */
|
|
@@ -114,10 +112,10 @@ export interface RegisterGoDeps {
|
|
|
114
112
|
* (`GET /api/yagni-code/spend?runId=`, wired in index.ts reusing Task 7's
|
|
115
113
|
* fetch machinery). Absent (no dep wired), throwing, or resolving null all
|
|
116
114
|
* fall back to the local `runCostNote` client estimate — see
|
|
117
|
-
* `resolveCostText` below.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
115
|
+
* `resolveCostText` below. Interactive /go opens no backend run, so today
|
|
116
|
+
* there is no run id to query and the labeled local estimate is what
|
|
117
|
+
* renders; the seam stays for a run id that arrives some other way. No
|
|
118
|
+
* default here: index.ts is the ONLY real wiring.
|
|
121
119
|
*/
|
|
122
120
|
fetchRunSpend?: (runId: string, signal?: AbortSignal) => Promise<SpendResponse | null>;
|
|
123
121
|
/**
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* `--pr` — push the run branch and open a PR under the user's own gh auth. A
|
|
21
21
|
* FINISH failure NEVER flips a clean run to failed and never fakes a commit
|
|
22
22
|
* (§0.1): every skip/failure is an honest note on the handoff. The commit/PR
|
|
23
|
-
* land on `PipelineResult.finish`,
|
|
23
|
+
* land on `PipelineResult.finish`, the registry row, the handoff, a
|
|
24
24
|
* `finish_done` checkpoint record, and a `finish` row in the activity feed.
|
|
25
25
|
*
|
|
26
26
|
* WORKTREE BY DEFAULT (spec §3b): the run gets its own worktree + branch FIRST
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* before mutating the user's tree; `--here` is the explicit in-place escape
|
|
37
37
|
* hatch.
|
|
38
38
|
*
|
|
39
|
-
* DETACHED BY DEFAULT: /go returns the prompt as soon as the run
|
|
39
|
+
* DETACHED BY DEFAULT: /go returns the prompt as soon as the run is registered;
|
|
40
40
|
* the pipeline promise is tracked in the module-scoped run registry (mirrored to
|
|
41
41
|
* ~/.yagni-code/registry.ndjson) and completion delivers the existing handoff
|
|
42
42
|
* via `pi.sendUserMessage(handoff, { deliverAs: "followUp" })` (try/catch with a
|
|
@@ -52,10 +52,11 @@
|
|
|
52
52
|
* optional-chained so headless runs degrade gracefully. `runPipeline` is
|
|
53
53
|
* injected so the handler is unit-testable without spawning.
|
|
54
54
|
*
|
|
55
|
-
*
|
|
56
|
-
* the
|
|
57
|
-
*
|
|
58
|
-
* is
|
|
55
|
+
* No backend run: interactive /go records its run LOCALLY only (the registry
|
|
56
|
+
* row, the checkpoint journal, and the handoff). It opens no `runs` row, so its
|
|
57
|
+
* spend is attributed to the driver session. The only /go that carries a
|
|
58
|
+
* backend run id is headless (`yagni go --headless --run-id`, run by the
|
|
59
|
+
* mission sandbox), which passes it in through `checkpointMeta.runId`.
|
|
59
60
|
*/
|
|
60
61
|
import { execFile } from "node:child_process";
|
|
61
62
|
import { randomUUID } from "node:crypto";
|
|
@@ -64,7 +65,6 @@ import { join } from "node:path";
|
|
|
64
65
|
import { eventToLine } from "./activity.js";
|
|
65
66
|
import { ActivityFeed, SPINNER_FRAMES } from "./activityFeed.js";
|
|
66
67
|
import { resolveFanoutMode } from "./fanout.js";
|
|
67
|
-
import { makeFanoutBeats } from "./fanoutBeats.js";
|
|
68
68
|
import { RunState } from "./runState.js";
|
|
69
69
|
import { aggregateRunUsage } from "./budget.js";
|
|
70
70
|
import { formatRunCostTable } from "./runCostTable.js";
|
|
@@ -72,7 +72,6 @@ import { makeCombinedCheckpointStore, makeFileCheckpointStore, makePiJournalChec
|
|
|
72
72
|
import { getToken as defaultGetToken, resolveBaseUrl } from "../config.js";
|
|
73
73
|
import { makeCrashReporter } from "../crashReport.js";
|
|
74
74
|
import { logEvent } from "../errorSink.js";
|
|
75
|
-
import { scrubSecrets } from "./scrubSecrets.js";
|
|
76
75
|
import { isDesktopSurface } from "../surface.js";
|
|
77
76
|
import { runFinish as defaultRunFinish, verifyTrailerValue, } from "./finish.js";
|
|
78
77
|
import { GO_USAGE, parseGoArgs } from "./goFlags.js";
|
|
@@ -81,8 +80,6 @@ import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
|
|
|
81
80
|
import { composeAbortSignal } from "./resilience.js";
|
|
82
81
|
import { planResume } from "./resume.js";
|
|
83
82
|
import { activeRunCount, beginRun, classifyRunLiveness, findActiveRunByTicket, isRunInFlight, isTerminalStatus, lastJournalTs, loadRegistryRows, resolveMaxConcurrentRuns, settleRun, trackRunAbort, trackRunPromise, worktreesDir, } from "./runRegistry.js";
|
|
84
|
-
import { makeRunSession as defaultMakeRunSession, } from "./runSession.js";
|
|
85
|
-
import { recordSessionRun } from "../sessionRuns.js";
|
|
86
83
|
import { resolveTicketBrief as defaultResolveTicketBrief } from "./ticketResolution.js";
|
|
87
84
|
import { snapshotWorkspace as defaultSnapshotWorkspace } from "./workspace.js";
|
|
88
85
|
import { parseChangedPaths } from "./verify.js";
|
|
@@ -215,15 +212,6 @@ const PAINT_DEBOUNCE_MS = 80;
|
|
|
215
212
|
*/
|
|
216
213
|
export const ANIMATION_INTERVAL_MS = 120;
|
|
217
214
|
const SPINNER_TICK_MS = 120;
|
|
218
|
-
/**
|
|
219
|
-
* Wall-clock liveness interval. /go pings POST /runs/:id/heartbeat on this
|
|
220
|
-
* cadence for the whole run so the server-side reaper (which fails a run idle
|
|
221
|
-
* past STALE_RUN_MS = 30 min) never reaps a legitimately-long run mid-flight —
|
|
222
|
-
* even a single quiet stage that emits no NDJSON for many minutes. Comfortably
|
|
223
|
-
* under the reaper window; fail-soft and unref'd so it never holds the process
|
|
224
|
-
* (or a test worker) open.
|
|
225
|
-
*/
|
|
226
|
-
const HEARTBEAT_INTERVAL_MS = 5 * 60_000;
|
|
227
215
|
const STOP_REASON_COPY = {
|
|
228
216
|
clean: "clean, no blocking findings remain",
|
|
229
217
|
round_cap: "round cap reached, blocking findings remain",
|
|
@@ -233,17 +221,7 @@ const STOP_REASON_COPY = {
|
|
|
233
221
|
budget_exceeded: "the run hit its budget ceiling (turns, cost, or tokens) and stopped before finishing. Review the partial change, then re-run /go to continue",
|
|
234
222
|
partial: "a build or fix stage stopped before finishing (a timeout or transient error once it had already begun editing). The partial changes are in your working tree, uncommitted. Review them, or re-run /go to retry",
|
|
235
223
|
};
|
|
236
|
-
/**
|
|
237
|
-
function outcomeFor(stopReason) {
|
|
238
|
-
// `partial` is a preserved-but-incomplete run (work is in the tree), so it is a
|
|
239
|
-
// `stopped`, never a `failed` (which would imply nothing usable was produced).
|
|
240
|
-
if (stopReason === "aborted" || stopReason === "budget_exceeded" || stopReason === "partial")
|
|
241
|
-
return "stopped";
|
|
242
|
-
if (stopReason === "failed")
|
|
243
|
-
return "failed";
|
|
244
|
-
return "completed"; // clean | round_cap are both real completions
|
|
245
|
-
}
|
|
246
|
-
/** Stop reason + the review tally, with NO cost segment and no prefix — the shared core of `recapBody`/`recapBodyForChat` below. */
|
|
224
|
+
/** Stop reason + the review tally, with NO cost segment and no prefix — the core of `recapBodyForChat` below. */
|
|
247
225
|
function tallyLine(result) {
|
|
248
226
|
const rounds = result.rounds.length;
|
|
249
227
|
const total = result.findings.length;
|
|
@@ -254,27 +232,9 @@ function tallyLine(result) {
|
|
|
254
232
|
`${total} finding${total === 1 ? "" : "s"} (${blocking} blocking).`);
|
|
255
233
|
}
|
|
256
234
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* `outcomeSummary`), which must stay synchronous: it is recorded as part of
|
|
261
|
-
* ending the run and must never wait on the Task 8 server spend fetch. The
|
|
262
|
-
* chat-facing summary (`recapBodyForChat`, below) is the one that prefers the
|
|
263
|
-
* server-priced table when it is available. The Work-page timeline already
|
|
264
|
-
* labels this beat "Run outcome", so prefixing here would double up into "Run
|
|
265
|
-
* outcome: YAGNI Code /go finished: ...". `formatSummaryForChat` re-adds the
|
|
266
|
-
* prefix for the standalone toast + scrollback line, where the context is
|
|
267
|
-
* useful on its own.
|
|
268
|
-
*/
|
|
269
|
-
function recapBody(result) {
|
|
270
|
-
return `${tallyLine(result)}${runCostNote(result)}`;
|
|
271
|
-
}
|
|
272
|
-
/**
|
|
273
|
-
* Chat-facing recap: the same tally as `recapBody`, but the cost segment is
|
|
274
|
-
* `costText` — precomputed by `resolveCostText` (Task 8), which is either the
|
|
275
|
-
* multi-line server-priced table or the labeled local-estimate fallback.
|
|
276
|
-
* Kept distinct from `recapBody` because `costText` requires an async fetch
|
|
277
|
-
* that `recapBody`'s caller (session.finish's outcomeSummary) cannot wait on.
|
|
235
|
+
* Chat-facing recap: the stop reason + review tally, then `costText`,
|
|
236
|
+
* precomputed by `resolveCostText` (Task 8): either the multi-line
|
|
237
|
+
* server-priced table or the labeled local-estimate fallback.
|
|
278
238
|
*/
|
|
279
239
|
function recapBodyForChat(result, costText) {
|
|
280
240
|
return `${tallyLine(result)}${costText}`;
|
|
@@ -299,21 +259,18 @@ function runCostNote(result) {
|
|
|
299
259
|
/**
|
|
300
260
|
* Task 8: resolve the cost segment for the CHAT-FACING summary (the toast +
|
|
301
261
|
* the durable handoff) — the server-priced per-stage table
|
|
302
|
-
* (`formatRunCostTable`) when
|
|
303
|
-
* else
|
|
262
|
+
* (`formatRunCostTable`) when a run id is known and the fetch succeeds,
|
|
263
|
+
* else the local `runCostNote` estimate, explicitly labeled "(client
|
|
304
264
|
* estimate)" so it is never mistaken for the priced total. Mirrors
|
|
305
265
|
* `registerCostCommand`'s own fail-soft preference order in costHud.ts.
|
|
266
|
+
* Interactive /go opens no backend run, so it passes no run id and always
|
|
267
|
+
* takes the labeled local-estimate path.
|
|
306
268
|
*
|
|
307
269
|
* Never throws and never blocks long: `fetchRunSpend` (index.ts) already
|
|
308
270
|
* carries its own request timeout (same 5s ceiling as /cost's fetches), and
|
|
309
|
-
* this wraps the ENTIRE body — not just the fetch — in try/catch
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* never waits on it, so a throw ANYWHERE in here between promise creation and
|
|
313
|
-
* that later `await` — including from `runCostNote`/`formatRunCostTable` on a
|
|
314
|
-
* malformed result, not just from the fetch itself — would otherwise surface
|
|
315
|
-
* as a guaranteed unhandled rejection during that gap, independent of
|
|
316
|
-
* whatever the caller does with the eventual value.
|
|
271
|
+
* this wraps the ENTIRE body — not just the fetch — in try/catch, so a throw
|
|
272
|
+
* from `runCostNote`/`formatRunCostTable` on a malformed result can never
|
|
273
|
+
* surface as a rejection that breaks the handoff.
|
|
317
274
|
*
|
|
318
275
|
* An empty-rows response is treated the same as "unavailable" (mirrors
|
|
319
276
|
* registerCostCommand's `serverLooksStale` guard): a run that billed real
|
|
@@ -354,19 +311,6 @@ async function resolveCostText(result, runId, fetchRunSpend, signal) {
|
|
|
354
311
|
function formatSummaryForChat(result, costText) {
|
|
355
312
|
return `YAGNI Code /go finished: ${recapBodyForChat(result, costText)}`;
|
|
356
313
|
}
|
|
357
|
-
/**
|
|
358
|
-
* User-facing notice when a /go run is NOT being recorded on the Work page.
|
|
359
|
-
* Without this, a flag-off / unauthorized / unreachable backend fails silently
|
|
360
|
-
* (the run-session swallows it) and the run vanishes with no explanation.
|
|
361
|
-
*/
|
|
362
|
-
const UNTRACKED_NOTICE = {
|
|
363
|
-
disabled: "Run tracking is off for this workspace, so this /go run won't appear on the Work page.",
|
|
364
|
-
unauthorized: "Run tracking is unauthorized. Run `yagni login` to refresh your token so runs appear on the Work page.",
|
|
365
|
-
// A refusal always arrives with the server's own sentence; this is the line
|
|
366
|
-
// for the rare body that carries none.
|
|
367
|
-
refused: "YAGNI declined to record this run, so it won't appear on the Work page. The pipeline ran anyway.",
|
|
368
|
-
error: "Couldn't reach YAGNI to record this run, so it won't appear on the Work page. The pipeline ran anyway.",
|
|
369
|
-
};
|
|
370
314
|
/** Toast severity for an outcome: clean is good, round_cap warns, abort/fail are errors. */
|
|
371
315
|
function summaryTone(stopReason) {
|
|
372
316
|
if (stopReason === "clean")
|
|
@@ -417,7 +361,6 @@ async function defaultResolveRepoContext(cwd, signal) {
|
|
|
417
361
|
}
|
|
418
362
|
export function registerGoCommand(pi, deps = {}) {
|
|
419
363
|
const runPipeline = deps.runPipeline ?? defaultRunPipeline;
|
|
420
|
-
const makeRunSession = deps.makeRunSession ?? defaultMakeRunSession;
|
|
421
364
|
const resolveRepoContext = deps.resolveRepoContext ?? defaultResolveRepoContext;
|
|
422
365
|
const makeCheckpointStore = deps.makeCheckpointStore ?? defaultMakeCheckpointStore;
|
|
423
366
|
const resolveSnapshot = deps.resolveSnapshot ?? defaultSnapshotWorkspace;
|
|
@@ -577,14 +520,9 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
577
520
|
const feed = ctx.hasUI && !desktop ? new ActivityFeed(ticket) : undefined;
|
|
578
521
|
const startedAt = Date.now();
|
|
579
522
|
const run = ctx.hasUI && desktop ? new RunState({ runId: runShortId, ticket, startedAt }) : undefined;
|
|
580
|
-
// The implement diamond's recording thread. Per RUN (never module-scoped):
|
|
581
|
-
// it holds this run's child roster and fix turns. Independent of `feed` /
|
|
582
|
-
// `run`, because the Work page's run surface is fed on headless runs too.
|
|
583
|
-
const fanoutBeats = makeFanoutBeats();
|
|
584
523
|
const stateKey = `${STATE_KEY_PREFIX}${runShortId}`;
|
|
585
524
|
let paintTimer;
|
|
586
525
|
let animationTimer;
|
|
587
|
-
let heartbeatTimer;
|
|
588
526
|
const elapsed = () => `${Math.max(0, Math.round((Date.now() - startedAt) / 1000))}s`;
|
|
589
527
|
const doPaint = () => {
|
|
590
528
|
if (!ctx.hasUI)
|
|
@@ -640,10 +578,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
640
578
|
clearInterval(animationTimer);
|
|
641
579
|
animationTimer = undefined;
|
|
642
580
|
}
|
|
643
|
-
if (heartbeatTimer) {
|
|
644
|
-
clearInterval(heartbeatTimer);
|
|
645
|
-
heartbeatTimer = undefined;
|
|
646
|
-
}
|
|
647
581
|
if (paintTimer) {
|
|
648
582
|
clearTimeout(paintTimer);
|
|
649
583
|
paintTimer = undefined;
|
|
@@ -656,26 +590,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
656
590
|
ctx.ui.setStatus?.(uiKey, undefined);
|
|
657
591
|
}
|
|
658
592
|
};
|
|
659
|
-
// Open the tracked run session (fail-soft — never blocks /go).
|
|
660
|
-
const session = makeRunSession({
|
|
661
|
-
baseUrl: deps.baseUrl ?? resolveBaseUrl(),
|
|
662
|
-
getToken: deps.getToken ?? defaultGetToken,
|
|
663
|
-
});
|
|
664
|
-
// The diamond's own recording channel is STRICTLY ORDERED, and it is the
|
|
665
|
-
// only one that has to be. Every roster beat carries the WHOLE roster and
|
|
666
|
-
// the run surface derives it last-one-wins (FanoutSection's `readFanout`),
|
|
667
|
-
// so two beats in flight at once can land in either order and a "running"
|
|
668
|
-
// that overtakes its own "done" paints a finished builder as running for
|
|
669
|
-
// the rest of the run. Chaining them through one promise makes the order
|
|
670
|
-
// the surface reads the order the pipeline produced. Still fire-and-forget
|
|
671
|
-
// (nothing on the run's critical path waits on it) and still fail-soft: a
|
|
672
|
-
// rejected beat never breaks the chain. The other stage beats stay
|
|
673
|
-
// unserialized on purpose — each names its own boundary, so their arrival
|
|
674
|
-
// order carries no state a later beat can undo.
|
|
675
|
-
let fanBeatChain = Promise.resolve();
|
|
676
|
-
const recordFanBeat = (b) => {
|
|
677
|
-
fanBeatChain = fanBeatChain.then(() => session.stage(b)).catch(() => { });
|
|
678
|
-
};
|
|
679
593
|
const repoCtx = await resolveRepoContext(runCwd, ctx.signal);
|
|
680
594
|
// Durable resilience journal, keyed by the RUN tree (the worktree path for
|
|
681
595
|
// a default run; ctx.cwd for --here). Detect an interrupted prior /go for
|
|
@@ -730,36 +644,11 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
730
644
|
notify(notice, "warning");
|
|
731
645
|
await pi.sendUserMessage(notice);
|
|
732
646
|
}
|
|
733
|
-
// Open or re-adopt the backend run row. A fresh run calls start(); a resume
|
|
734
|
-
// re-binds the original run (no duplicate row) when it was tracked.
|
|
735
|
-
const openFreshRun = async () => {
|
|
736
|
-
const started = await session.start({ arg: ticket, repo: repoCtx.repo, branch: repoCtx.branch });
|
|
737
|
-
if (started.tracked) {
|
|
738
|
-
if (started.gated) {
|
|
739
|
-
notify("This ticket needs approval in the YAGNI app before it shows as executing.", "warning");
|
|
740
|
-
}
|
|
741
|
-
return { runId: started.runId, workItemId: started.workItemId };
|
|
742
|
-
}
|
|
743
|
-
// Recording skipped (flag off) or failed (unauthorized / refused /
|
|
744
|
-
// unreachable): tell the user the run won't show on the Work page
|
|
745
|
-
// rather than swallowing it. A refusal carries the server's own
|
|
746
|
-
// sentence, which says what to do about it, so it wins over our copy.
|
|
747
|
-
notify(started.message ?? UNTRACKED_NOTICE[started.reason], "warning");
|
|
748
|
-
return {};
|
|
749
|
-
};
|
|
750
647
|
let resumeFrom;
|
|
751
|
-
let handle = {};
|
|
752
648
|
if (plan.mode === "resume_loop") {
|
|
753
649
|
notify(`Resuming /go for "${ticket}" from review round ${plan.round} — picking up the interrupted run instead of starting over.`, "info");
|
|
754
650
|
await pi.sendUserMessage(`Resuming an interrupted /go for "${ticket}" at review round ${plan.round}. The build is already in ${worktreeMode ? "the run worktree" : "your working tree"}; continuing the review loop.`);
|
|
755
651
|
resumeFrom = plan;
|
|
756
|
-
// Open a FRESH run attempt rather than re-binding the original handle.
|
|
757
|
-
// An interrupted run that slept past the reaper window is already a
|
|
758
|
-
// terminal 'failed' server-side, and binding it would strand THIS
|
|
759
|
-
// completed resume as a permanent phantom failure (failed is terminal —
|
|
760
|
-
// finish can't promote it). A fresh attempt records the resumed pass
|
|
761
|
-
// honestly; the original crashed attempt settles on its own (reaper).
|
|
762
|
-
handle = await openFreshRun();
|
|
763
652
|
}
|
|
764
653
|
else {
|
|
765
654
|
if (plan.mode === "warn_partial") {
|
|
@@ -783,31 +672,15 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
783
672
|
}
|
|
784
673
|
// Fresh run: drop any stale/partial journal so the new run writes clean.
|
|
785
674
|
store.clear();
|
|
786
|
-
handle = await openFreshRun();
|
|
787
675
|
}
|
|
676
|
+
// No backend run id: interactive /go opens no `runs` row, so its child
|
|
677
|
+
// dispatches carry no run attribution and bill to this driver session.
|
|
678
|
+
// (Headless /go passes the mission's --run-id through its own meta.)
|
|
788
679
|
const checkpointMeta = {
|
|
789
680
|
sessionKey,
|
|
790
|
-
...(handle.runId ? { runId: handle.runId } : {}),
|
|
791
|
-
...(handle.workItemId ? { workItemId: handle.workItemId } : {}),
|
|
792
681
|
...(repoCtx.repo ? { repo: repoCtx.repo } : {}),
|
|
793
682
|
...(repoCtx.branch ? { branch: repoCtx.branch } : {}),
|
|
794
683
|
};
|
|
795
|
-
// YAG-383: this run's dispatches bill under handle.runId, not this
|
|
796
|
-
// driver session's id (see attributionHeaders in config.ts), so /cost's
|
|
797
|
-
// server-authoritative spend fetch would never see them without being
|
|
798
|
-
// told this run id explicitly. Record it the moment it is known (same
|
|
799
|
-
// gate as the attribution header above: only when the run is tracked).
|
|
800
|
-
if (handle.runId)
|
|
801
|
-
recordSessionRun(handle.runId);
|
|
802
|
-
// Desktop parity (Task 9): the desktop's /spend join needs this same
|
|
803
|
-
// server UUID (`run.runId` on the wire record is the LOCAL 8-char short
|
|
804
|
-
// id used for widget/status keys, never the join key). Stamp it the
|
|
805
|
-
// moment it is known and paint IMMEDIATELY — not the trailing-edge
|
|
806
|
-
// debounce — so a fast-finishing run's last few paints still carry it.
|
|
807
|
-
if (handle.runId && run) {
|
|
808
|
-
run.setServerRunId(handle.runId);
|
|
809
|
-
paint(true);
|
|
810
|
-
}
|
|
811
684
|
// Mark the journal terminal (fail-soft). A run_finish makes the key read as
|
|
812
685
|
// a COMPLETED run (so the next /go starts fresh, not a bogus resume); its
|
|
813
686
|
// absence is exactly what marks a crashed run resumable.
|
|
@@ -819,8 +692,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
819
692
|
sessionKey,
|
|
820
693
|
ts: Date.now(),
|
|
821
694
|
ticket,
|
|
822
|
-
...(handle.runId ? { runId: handle.runId } : {}),
|
|
823
|
-
...(handle.workItemId ? { workItemId: handle.workItemId } : {}),
|
|
824
695
|
stopReason: stopReason,
|
|
825
696
|
});
|
|
826
697
|
}
|
|
@@ -828,9 +699,9 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
828
699
|
/* fail-soft */
|
|
829
700
|
}
|
|
830
701
|
};
|
|
831
|
-
// The run registry row (the LOCAL run identity
|
|
832
|
-
//
|
|
833
|
-
//
|
|
702
|
+
// The run registry row (the LOCAL run identity; interactive /go has no
|
|
703
|
+
// backend run). Registered before the run detaches so the in-flight
|
|
704
|
+
// guards and /go-status see it immediately.
|
|
834
705
|
const registryRow = {
|
|
835
706
|
runId,
|
|
836
707
|
ticket,
|
|
@@ -841,11 +712,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
841
712
|
status: "running",
|
|
842
713
|
startedAt: now(),
|
|
843
714
|
};
|
|
844
|
-
// Wall-clock liveness heartbeat for the whole run; cleared in clearUI. Each
|
|
845
|
-
// tick is fail-soft and no-ops until the run is bound. Unref'd so it never
|
|
846
|
-
// keeps the process (or a test worker) alive.
|
|
847
|
-
heartbeatTimer = setInterval(() => void session.heartbeat(), HEARTBEAT_INTERVAL_MS);
|
|
848
|
-
heartbeatTimer.unref?.();
|
|
849
715
|
// Deliver the durable end-of-run handoff. A detached run completes while
|
|
850
716
|
// the agent may be mid-turn, so pi requires deliverAs; a delivery failure
|
|
851
717
|
// falls back to a notify (and /go-status remains the pull path).
|
|
@@ -926,13 +792,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
926
792
|
onProgress: (p) => {
|
|
927
793
|
feed?.applyProgress(p);
|
|
928
794
|
run?.applyProgress(p, Date.now());
|
|
929
|
-
// The implement diamond's own beats (the partition verdict, each
|
|
930
|
-
// child's state, each fix turn) ride the SAME fail-soft stage
|
|
931
|
-
// channel as every other boundary, additively: a signal that is not
|
|
932
|
-
// the diamond's produces no beat at all.
|
|
933
|
-
const fanBeat = fanoutBeats.apply(p);
|
|
934
|
-
if (fanBeat)
|
|
935
|
-
recordFanBeat(fanBeat);
|
|
936
795
|
// The ribbon replaces the status chip on the desktop, so the chip is
|
|
937
796
|
// only set for the terminal's status-line fallback.
|
|
938
797
|
if (ctx.hasUI && !desktop) {
|
|
@@ -960,14 +819,10 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
960
819
|
paint(false);
|
|
961
820
|
},
|
|
962
821
|
onStage: (s) => {
|
|
963
|
-
// The plan text rides ONLY the plan-finish boundary;
|
|
964
|
-
//
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
void session.stage(stageArgs);
|
|
968
|
-
if (s.stage === "plan" && s.phase === "finish" && output) {
|
|
969
|
-
planText = output;
|
|
970
|
-
void session.recordPlan({ stage: "plan", body: output });
|
|
822
|
+
// The plan text rides ONLY the plan-finish boundary; keep it for
|
|
823
|
+
// the FINISH commit message's summary.
|
|
824
|
+
if (s.stage === "plan" && s.phase === "finish" && s.output) {
|
|
825
|
+
planText = s.output;
|
|
971
826
|
}
|
|
972
827
|
},
|
|
973
828
|
checkpoint: store,
|
|
@@ -1001,7 +856,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1001
856
|
if (ctx.hasUI && !desktop)
|
|
1002
857
|
ctx.ui.setStatus?.(uiKey, "finish…");
|
|
1003
858
|
paint(true);
|
|
1004
|
-
void session.stage({ stage: "finish", phase: "start" });
|
|
1005
859
|
try {
|
|
1006
860
|
finish = await runFinishFn({
|
|
1007
861
|
cwd: runCwd,
|
|
@@ -1034,7 +888,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1034
888
|
notes: [`FINISH failed: ${msg}. The run itself is still clean; the changes are left in ${runCwd}.`],
|
|
1035
889
|
};
|
|
1036
890
|
}
|
|
1037
|
-
void session.stage({ stage: "finish", phase: "finish" });
|
|
1038
891
|
feed?.applyProgress({ kind: "stage_done", stageId: "finish" });
|
|
1039
892
|
run?.applyProgress({ kind: "stage_done", stageId: "finish" }, Date.now());
|
|
1040
893
|
paint(true);
|
|
@@ -1046,8 +899,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1046
899
|
sessionKey,
|
|
1047
900
|
ts: Date.now(),
|
|
1048
901
|
ticket,
|
|
1049
|
-
...(handle.runId ? { runId: handle.runId } : {}),
|
|
1050
|
-
...(handle.workItemId ? { workItemId: handle.workItemId } : {}),
|
|
1051
902
|
...(finish.commitSha ? { commitSha: finish.commitSha } : {}),
|
|
1052
903
|
...(finish.prUrl ? { prUrl: finish.prUrl } : {}),
|
|
1053
904
|
});
|
|
@@ -1077,46 +928,22 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1077
928
|
}
|
|
1078
929
|
: undefined;
|
|
1079
930
|
const finalResult = { ...result, ...(finishInfo ? { finish: finishInfo } : {}) };
|
|
1080
|
-
const outcome = outcomeFor(finalResult.stopReason);
|
|
1081
|
-
// The end-of-run recap: "what was done" (rounds, findings, stop reason).
|
|
1082
|
-
// The bare `recapBody` rides `finish` as the run_outcome activity (the
|
|
1083
|
-
// timeline labels that beat "Run outcome"); the prefixed
|
|
1084
|
-
// `formatSummaryForChat` is the standalone toast + scrollback line
|
|
1085
|
-
// shown to the user below.
|
|
1086
931
|
// Non-clean terminal outcome in worktree mode: preserve the work as a
|
|
1087
932
|
// WIP commit so it is never lost and the worktree becomes removable.
|
|
1088
933
|
// (A clean outcome's commit is the FINISH stage's, captured above.)
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
// both misattribute the commit and spuriously advance a work-less run).
|
|
934
|
+
// The registry row reports the sha that actually holds the run's
|
|
935
|
+
// work, never the pre-run base HEAD.
|
|
1092
936
|
const wipSha = finalResult.stopReason === "clean" ? undefined : await preserveWip(finalResult.stopReason);
|
|
1093
937
|
const runCommitSha = finishInfo?.commitSha ?? wipSha;
|
|
1094
|
-
// Task 8: START the server-priced fetch here but do NOT await it —
|
|
1095
|
-
// session.finish (the durable finish POST) and everything gated on
|
|
1096
|
-
// it below (recordRunFinish's journal write, settleRun clearing the
|
|
1097
|
-
// in-flight guard) must land regardless of how long this fetch
|
|
1098
|
-
// takes. Only the chat-facing summary/handoff, built after, needs
|
|
1099
|
-
// the resolved value (awaited just below, right before it is used).
|
|
1100
|
-
const costTextPromise = resolveCostText(finalResult, handle.runId, fetchRunSpend, ctx.signal);
|
|
1101
|
-
await session.finish({
|
|
1102
|
-
outcome,
|
|
1103
|
-
stopReason: outcome === "completed" ? undefined : STOP_REASON_COPY[finalResult.stopReason],
|
|
1104
|
-
branch: finishInfo?.branch ?? repoCtx.branch,
|
|
1105
|
-
...(runCommitSha ? { commitSha: runCommitSha } : {}),
|
|
1106
|
-
...(finishInfo?.prUrl ? { prUrl: finishInfo.prUrl } : {}),
|
|
1107
|
-
...(finishInfo?.prNumber != null ? { prNumber: finishInfo.prNumber } : {}),
|
|
1108
|
-
rounds: finalResult.rounds.length,
|
|
1109
|
-
outcomeSummary: recapBody(finalResult),
|
|
1110
|
-
});
|
|
1111
938
|
recordRunFinish(finalResult.stopReason);
|
|
1112
939
|
settleRun(runId, {
|
|
1113
940
|
status: finalResult.stopReason,
|
|
1114
941
|
...(runCommitSha ? { commitSha: runCommitSha } : {}),
|
|
1115
942
|
...(finishInfo?.prUrl ? { prUrl: finishInfo.prUrl } : {}),
|
|
1116
943
|
});
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
const costText = await
|
|
944
|
+
// The chat-facing cost segment. Interactive /go has no backend run id,
|
|
945
|
+
// so this is the labeled local client estimate.
|
|
946
|
+
const costText = await resolveCostText(finalResult, undefined, fetchRunSpend, ctx.signal);
|
|
1120
947
|
const summary = formatSummaryForChat(finalResult, costText);
|
|
1121
948
|
notify(summary, summaryTone(finalResult.stopReason));
|
|
1122
949
|
// The DURABLE record surfaces the unresolved blocking findings IN FULL
|
|
@@ -1131,10 +958,6 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
1131
958
|
// reporter never rejects; the catch guards an injected one.
|
|
1132
959
|
void reportCrash(err, "go", runCwd).catch(() => { });
|
|
1133
960
|
logEvent({ source: "go", level: "error", event: "go_failed", fields: { runId } });
|
|
1134
|
-
// The stopReason travels to the backend run record; scrub it like
|
|
1135
|
-
// every other captured text (a raw error can echo a connection
|
|
1136
|
-
// string or key).
|
|
1137
|
-
await session.finish({ outcome: "failed", stopReason: scrubSecrets(message) });
|
|
1138
961
|
recordRunFinish("failed");
|
|
1139
962
|
const wipSha = await preserveWip("failed");
|
|
1140
963
|
settleRun(runId, { status: "failed", ...(wipSha ? { commitSha: wipSha } : {}) });
|
|
@@ -267,9 +267,9 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
267
267
|
// A blank plan or memo file is NOT mission context: it must never read as an
|
|
268
268
|
// approved plan (which would skip the pipeline's planning on nothing at all).
|
|
269
269
|
const mission = normalizeMission({ plan: files.plan, memo: files.memo });
|
|
270
|
-
// The implement diamond's recording thread, per RUN.
|
|
271
|
-
//
|
|
272
|
-
//
|
|
270
|
+
// The implement diamond's recording thread, per RUN. The beats ride the
|
|
271
|
+
// NDJSON stream as `pipeline_fanout` records, and the mission collector
|
|
272
|
+
// folds them onto the Run.
|
|
273
273
|
const fanoutBeats = makeFanoutBeats();
|
|
274
274
|
const pipelineDeps = {
|
|
275
275
|
cwd,
|
|
@@ -214,13 +214,13 @@ export async function runPipeline(ticket, deps) {
|
|
|
214
214
|
// stay attributed to review/<lens>. The tap is ALWAYS installed (the
|
|
215
215
|
// decisionsRecorded count must not depend on a caller wiring onEvent);
|
|
216
216
|
// forwarding to the caller's onEvent stays optional.
|
|
217
|
-
// YAG-471 attribution: the
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// backend
|
|
223
|
-
// never blocking the pipeline.
|
|
217
|
+
// YAG-471 attribution: the backend run's id (a UUID) rides every child spawn
|
|
218
|
+
// from here on, so every stage's completions attribute to this run in
|
|
219
|
+
// `llm_usage` — the stage loop, the review lens fan-out, AND the
|
|
220
|
+
// format-recovery reask (it goes through this same seam). It arrives only
|
|
221
|
+
// from headless /go's `--run-id` (the mission sandbox's run). Interactive /go
|
|
222
|
+
// opens no backend run, so it is absent there and attribution degrades to
|
|
223
|
+
// the driver session plus the caller label, never blocking the pipeline.
|
|
224
224
|
const runId = deps.checkpointMeta?.runId;
|
|
225
225
|
// `callerLabel` is set only by the implement diamond's children (the
|
|
226
226
|
// partitioner's `go:implement:partition` and each builder's
|
|
@@ -113,14 +113,12 @@ export interface GoRunState {
|
|
|
113
113
|
stopReason?: StopReason;
|
|
114
114
|
agents: AgentNode[];
|
|
115
115
|
/**
|
|
116
|
-
*
|
|
117
|
-
* `openFreshRun`) — NOT `runId` above, which is the local 8-char short id
|
|
116
|
+
* A backend run UUID — NOT `runId` above, which is the local 8-char short id
|
|
118
117
|
* (`runId.slice(0, 8)`) used for widget/status keys. `/api/yagni-code/spend`
|
|
119
118
|
* joins on the server UUID, so the desktop needs this to ever widen its
|
|
120
|
-
* spend query with a /go run's own dispatches.
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* `UNTRACKED_NOTICE` in goCommand.ts): there is no server id to give it.
|
|
119
|
+
* spend query with a /go run's own dispatches. Interactive /go no longer
|
|
120
|
+
* opens a backend run (P0.12), so nothing sets it today and it stays absent;
|
|
121
|
+
* the field is kept for the desktop reader until that follow-up lands.
|
|
124
122
|
*/
|
|
125
123
|
serverRunId?: string;
|
|
126
124
|
/**
|
|
@@ -186,10 +184,10 @@ export declare class RunState {
|
|
|
186
184
|
/** Stamp the run's end. Separate from `done` because FINISH runs after the pipeline returns. */
|
|
187
185
|
markEnded(at: number): void;
|
|
188
186
|
/**
|
|
189
|
-
* Record
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
187
|
+
* Record a backend run UUID for the desktop's spend join. Interactive /go
|
|
188
|
+
* no longer opens a backend run (P0.12), so nothing calls this today; it
|
|
189
|
+
* stays until the inert run-id readers are removed in a follow-up. A caller
|
|
190
|
+
* must repaint right after it so the desktop learns the id immediately.
|
|
193
191
|
*/
|
|
194
192
|
setServerRunId(id: string): void;
|
|
195
193
|
/**
|
|
@@ -246,10 +246,10 @@ export class RunState {
|
|
|
246
246
|
this.endedAt = at;
|
|
247
247
|
}
|
|
248
248
|
/**
|
|
249
|
-
* Record
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
249
|
+
* Record a backend run UUID for the desktop's spend join. Interactive /go
|
|
250
|
+
* no longer opens a backend run (P0.12), so nothing calls this today; it
|
|
251
|
+
* stays until the inert run-id readers are removed in a follow-up. A caller
|
|
252
|
+
* must repaint right after it so the desktop learns the id immediately.
|
|
253
253
|
*/
|
|
254
254
|
setServerRunId(id) {
|
|
255
255
|
this.serverRunId = id;
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* formatted brief carrying the VERBATIM ticket title + description (the
|
|
9
9
|
* acceptance criteria live inside the body) for the {ticket} slot.
|
|
10
10
|
*
|
|
11
|
-
* It
|
|
12
|
-
* injectable getToken + fetchImpl
|
|
11
|
+
* It uses the same fetch seam as the other resilientFetch callers (baseUrl +
|
|
12
|
+
* injectable getToken + fetchImpl). The server resolves the
|
|
13
13
|
* ticket with a live, workspace-scoped connector READ, never an LLM call, so
|
|
14
14
|
* this never bills the ask compose path and never risks a model-rewritten body.
|
|
15
15
|
*
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* formatted brief carrying the VERBATIM ticket title + description (the
|
|
9
9
|
* acceptance criteria live inside the body) for the {ticket} slot.
|
|
10
10
|
*
|
|
11
|
-
* It
|
|
12
|
-
* injectable getToken + fetchImpl
|
|
11
|
+
* It uses the same fetch seam as the other resilientFetch callers (baseUrl +
|
|
12
|
+
* injectable getToken + fetchImpl). The server resolves the
|
|
13
13
|
* ticket with a live, workspace-scoped connector READ, never an LLM call, so
|
|
14
14
|
* this never bills the ask compose path and never risks a model-rewritten body.
|
|
15
15
|
*
|
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
* trailing off into a 400 from the server).
|
|
23
23
|
*/
|
|
24
24
|
/**
|
|
25
|
-
* Record a `/go` run id once it is known
|
|
26
|
-
*
|
|
25
|
+
* Record a `/go` run id once it is known. Interactive /go no longer opens a
|
|
26
|
+
* backend run (P0.12), so nothing calls this today; it stays until the inert
|
|
27
|
+
* run-id readers are removed in a follow-up. Ignores anything that is not UUID-shaped
|
|
27
28
|
* and de-dupes a repeat call for the same run (a resumed/re-adopted run can
|
|
28
29
|
* report the same id twice) — both defensive, since a malformed value here
|
|
29
30
|
* must never corrupt /cost's query string.
|
|
@@ -36,8 +36,9 @@ let runIds = [];
|
|
|
36
36
|
*/
|
|
37
37
|
let dropped = 0;
|
|
38
38
|
/**
|
|
39
|
-
* Record a `/go` run id once it is known
|
|
40
|
-
*
|
|
39
|
+
* Record a `/go` run id once it is known. Interactive /go no longer opens a
|
|
40
|
+
* backend run (P0.12), so nothing calls this today; it stays until the inert
|
|
41
|
+
* run-id readers are removed in a follow-up. Ignores anything that is not UUID-shaped
|
|
41
42
|
* and de-dupes a repeat call for the same run (a resumed/re-adopted run can
|
|
42
43
|
* report the same id twice) — both defensive, since a malformed value here
|
|
43
44
|
* must never corrupt /cost's query string.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.8-staging.
|
|
3
|
+
"version": "1.1.8-staging.1467.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "405d3059a5defa8759713338e4d43322ea3c8c0f"
|
|
62
62
|
}
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `/go` run-session adapter.
|
|
3
|
-
*
|
|
4
|
-
* Wraps the device-token `/api/yagni-code/runs*` endpoints so a YAGNI Code
|
|
5
|
-
* pipeline run becomes a tracked `runs` row visible on the Work page: open the
|
|
6
|
-
* run (`start`), stream each pipeline stage (`stage`), bank an in-loop
|
|
7
|
-
* correction (`correction`), and close it with an honest outcome (`finish`).
|
|
8
|
-
*
|
|
9
|
-
* CRITICAL invariant: recording must NEVER break the pipeline. Every call is
|
|
10
|
-
* fail-soft — it never throws. `start` resolves to a typed outcome that
|
|
11
|
-
* distinguishes a tracked run from an untracked one (flag off, unauthorized, or
|
|
12
|
-
* the backend unreachable/erroring) so the caller can surface a notice instead
|
|
13
|
-
* of failing silently; the post-start verbs (`stage`/`correction`/`finish`) stay
|
|
14
|
-
* silent no-ops. `/go` runs to completion regardless of the recording outcome.
|
|
15
|
-
*
|
|
16
|
-
* Mirrors the fetch-seam shape of {@link makeRecordEngineeringContextTool}
|
|
17
|
-
* (`baseUrl` + injectable `getToken` + `fetchImpl`) so it is unit-testable
|
|
18
|
-
* without a network and reuses the same auth convention.
|
|
19
|
-
*/
|
|
20
|
-
/** Options for {@link makeRunSession}. */
|
|
21
|
-
export interface RunSessionOptions {
|
|
22
|
-
baseUrl: string;
|
|
23
|
-
getToken: () => string | undefined;
|
|
24
|
-
fetchImpl?: typeof fetch;
|
|
25
|
-
/**
|
|
26
|
-
* Backoff sleep + jitter seams, threaded into {@link resilientFetch} so the
|
|
27
|
-
* retry path is deterministic and instant under test. Default to the real
|
|
28
|
-
* timer / Math.random in production.
|
|
29
|
-
*/
|
|
30
|
-
sleep?: (ms: number) => Promise<void>;
|
|
31
|
-
random?: () => number;
|
|
32
|
-
/**
|
|
33
|
-
* Opt-in debug sink for diagnosing a recording failure (the fail-soft path
|
|
34
|
-
* otherwise collapses 402/5xx/2xx-bad-body/unreachable into one silent
|
|
35
|
-
* "error"). Only ever receives a token-free line: the path, the numeric HTTP
|
|
36
|
-
* status, a capped slice of the SERVER response body, or a network error
|
|
37
|
-
* message. NEVER the request headers (the bearer token) or the request body.
|
|
38
|
-
* Injected for deterministic tests; defaults to an env-gated stderr writer.
|
|
39
|
-
*/
|
|
40
|
-
debug?: (line: string) => void;
|
|
41
|
-
/**
|
|
42
|
-
* Environment seam for the debug gate, matching `config.ts`/`verify.ts`. The
|
|
43
|
-
* debug sink only fires when `YAGNI_CODE_DEBUG` is set here (default
|
|
44
|
-
* `process.env`), so existing callers that inject neither `debug` nor `env`
|
|
45
|
-
* stay silent unless the operator opts in.
|
|
46
|
-
*/
|
|
47
|
-
env?: NodeJS.ProcessEnv;
|
|
48
|
-
}
|
|
49
|
-
/** The handle returned by a successful `start` (or supplied via `bind`). */
|
|
50
|
-
export interface RunSessionHandle {
|
|
51
|
-
runId: string;
|
|
52
|
-
workItemId: string;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* The result of a successful `start`: the handle plus the server's governance
|
|
56
|
-
* verdict (`gated` = the work item needs approval in the app before it shows as
|
|
57
|
-
* executing, so /go should warn rather than silently proceed).
|
|
58
|
-
*/
|
|
59
|
-
export interface RunStartResult extends RunSessionHandle {
|
|
60
|
-
gated: boolean;
|
|
61
|
-
gatedReason?: string;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Why a run could not be recorded, derived from the endpoint's HTTP status so
|
|
65
|
-
* the caller can show an actionable notice:
|
|
66
|
-
* - `disabled` — 404: the `yagni_code.work_tracking` flag is off for this
|
|
67
|
-
* workspace (the write endpoints 404 to hide themselves).
|
|
68
|
-
* - `unauthorized` — 401/403: the device token is missing/expired or lacks the
|
|
69
|
-
* `yagni_code:write` scope (re-run `yagni login`).
|
|
70
|
-
* - `refused` — 400: the server declined for a reason a person can act on
|
|
71
|
-
* and said so in one sentence (this repository belongs to
|
|
72
|
-
* no Team). The sentence rides `message` and is what the
|
|
73
|
-
* caller shows.
|
|
74
|
-
* - `error` — anything else: an over-budget 402, a 5xx, a 2xx with an
|
|
75
|
-
* unrecognized body, or an unreachable backend / thrown
|
|
76
|
-
* network error.
|
|
77
|
-
*/
|
|
78
|
-
export type RunTrackingFailure = "disabled" | "unauthorized" | "refused" | "error";
|
|
79
|
-
/**
|
|
80
|
-
* The outcome of opening a run. `tracked: true` carries the handle + gate
|
|
81
|
-
* verdict; `tracked: false` carries the reason so `/go` can warn that this run
|
|
82
|
-
* won't appear on the Work page, plus the server's own sentence when it sent
|
|
83
|
-
* one. `start` never returns null and never throws.
|
|
84
|
-
*/
|
|
85
|
-
export type RunStartOutcome = ({
|
|
86
|
-
tracked: true;
|
|
87
|
-
} & RunStartResult) | {
|
|
88
|
-
tracked: false;
|
|
89
|
-
reason: RunTrackingFailure;
|
|
90
|
-
message?: string;
|
|
91
|
-
};
|
|
92
|
-
/** Arguments to open a run. */
|
|
93
|
-
export interface StartArgs {
|
|
94
|
-
arg: string;
|
|
95
|
-
repo?: string;
|
|
96
|
-
branch?: string;
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* The implement diamond's partition verdict as the run records it: the fan and
|
|
100
|
-
* its width, or the single-writer degrade, always with the reason that produced
|
|
101
|
-
* it. Rides one implement stage beat (see {@link StageArgs}).
|
|
102
|
-
*/
|
|
103
|
-
export interface StageFanoutDecision {
|
|
104
|
-
mode: "fan" | "single";
|
|
105
|
-
width?: number;
|
|
106
|
-
reason: string;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* One fan-out child on a stage beat: the workstream's name, the tier it runs on,
|
|
110
|
-
* how many paths it claimed, and the state the run has actually OBSERVED. There
|
|
111
|
-
* is no "pending" state on purpose: a child appears the moment it starts, so a
|
|
112
|
-
* surface can only ever paint children that really ran.
|
|
113
|
-
*/
|
|
114
|
-
export interface StageChildRow {
|
|
115
|
-
name: string;
|
|
116
|
-
tier?: "standard" | "efficient";
|
|
117
|
-
state: "running" | "done" | "failed";
|
|
118
|
-
/** How many path claims the partition gave this workstream; absent when unrecorded. */
|
|
119
|
-
files?: number;
|
|
120
|
-
note?: string;
|
|
121
|
-
}
|
|
122
|
-
/** One bounded fix turn: which findings it answered and which children it re-engaged. */
|
|
123
|
-
export interface StageFixTurn {
|
|
124
|
-
turn: number;
|
|
125
|
-
findings: number;
|
|
126
|
-
reengaged: string[];
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* A pipeline stage boundary event.
|
|
130
|
-
*
|
|
131
|
-
* `fanout` / `children` / `fixTurns` are the implement diamond's additive
|
|
132
|
-
* payloads (spec "Recording and the run surface"). They ride INTERIM implement
|
|
133
|
-
* beats and are append-only: an older CLI simply never sends them, and a backend
|
|
134
|
-
* that does not know them ignores them, so the contract stays backwards
|
|
135
|
-
* compatible in both directions. Every beat carries at most one of them.
|
|
136
|
-
*/
|
|
137
|
-
export interface StageArgs {
|
|
138
|
-
stage: string;
|
|
139
|
-
phase: "start" | "finish";
|
|
140
|
-
round?: number;
|
|
141
|
-
findings?: number;
|
|
142
|
-
/** The diamond's verdict, sent once per implement stage the moment it resolves. */
|
|
143
|
-
fanout?: StageFanoutDecision;
|
|
144
|
-
/** The whole per-workstream roster, re-sent on every child state change. */
|
|
145
|
-
children?: StageChildRow[];
|
|
146
|
-
/** Every fix turn recorded so far, re-sent as each new one lands. */
|
|
147
|
-
fixTurns?: StageFixTurn[];
|
|
148
|
-
}
|
|
149
|
-
/** An in-loop correction captured during the run. */
|
|
150
|
-
export interface CorrectionArgs {
|
|
151
|
-
stage?: string;
|
|
152
|
-
question: string;
|
|
153
|
-
decision: string;
|
|
154
|
-
rationale?: string;
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* The /go PLAN, recorded onto the work item (a `plan_recorded` activity beat) so
|
|
158
|
-
* it is clear what the run set out to do. `body` is the plan markdown; the server
|
|
159
|
-
* scrubs secrets + caps it before persisting.
|
|
160
|
-
*/
|
|
161
|
-
export interface RecordPlanArgs {
|
|
162
|
-
stage: string;
|
|
163
|
-
body: string;
|
|
164
|
-
}
|
|
165
|
-
/** How a run ended; maps to the run-status enum server-side. */
|
|
166
|
-
export interface FinishArgs {
|
|
167
|
-
outcome: "completed" | "stopped" | "failed";
|
|
168
|
-
stopReason?: string;
|
|
169
|
-
prUrl?: string;
|
|
170
|
-
prNumber?: number;
|
|
171
|
-
branch?: string;
|
|
172
|
-
commitSha?: string;
|
|
173
|
-
rounds?: number;
|
|
174
|
-
/**
|
|
175
|
-
* Short "what was done" recap; the server records it as a `run_outcome`
|
|
176
|
-
* activity (the end-of-run beat on the Work-page timeline). Optional + additive:
|
|
177
|
-
* `finish` already spreads the whole args object, so it flows through untouched.
|
|
178
|
-
*/
|
|
179
|
-
outcomeSummary?: string;
|
|
180
|
-
}
|
|
181
|
-
/** The run-session object returned by {@link makeRunSession}. */
|
|
182
|
-
export interface RunSession {
|
|
183
|
-
/** Adopt an externally-known handle (used by tests + cloud callers). */
|
|
184
|
-
bind(h: RunSessionHandle): void;
|
|
185
|
-
/** Open the run; resolves to a tracked outcome (handle + gate verdict) or an untracked reason. Never throws. */
|
|
186
|
-
start(b: StartArgs): Promise<RunStartOutcome>;
|
|
187
|
-
/** Stream a stage boundary; no-op until a handle exists. */
|
|
188
|
-
stage(b: StageArgs): Promise<void>;
|
|
189
|
-
/** Bank an in-loop correction; no-op until a handle exists. */
|
|
190
|
-
correction(b: CorrectionArgs): Promise<void>;
|
|
191
|
-
/** Record the /go plan onto the work item; no-op until a handle exists. */
|
|
192
|
-
recordPlan(b: RecordPlanArgs): Promise<void>;
|
|
193
|
-
/**
|
|
194
|
-
* Bump the run's server-side last-activity heartbeat so a long-but-alive run is
|
|
195
|
-
* not reaped as stale. No-op until a handle exists; never throws.
|
|
196
|
-
*/
|
|
197
|
-
heartbeat(): Promise<void>;
|
|
198
|
-
/** Close the run with an outcome; no-op until a handle exists. */
|
|
199
|
-
finish(b: FinishArgs): Promise<void>;
|
|
200
|
-
}
|
|
201
|
-
export declare function makeRunSession(opts: RunSessionOptions): RunSession;
|
|
202
|
-
//# sourceMappingURL=runSession.d.ts.map
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `/go` run-session adapter.
|
|
3
|
-
*
|
|
4
|
-
* Wraps the device-token `/api/yagni-code/runs*` endpoints so a YAGNI Code
|
|
5
|
-
* pipeline run becomes a tracked `runs` row visible on the Work page: open the
|
|
6
|
-
* run (`start`), stream each pipeline stage (`stage`), bank an in-loop
|
|
7
|
-
* correction (`correction`), and close it with an honest outcome (`finish`).
|
|
8
|
-
*
|
|
9
|
-
* CRITICAL invariant: recording must NEVER break the pipeline. Every call is
|
|
10
|
-
* fail-soft — it never throws. `start` resolves to a typed outcome that
|
|
11
|
-
* distinguishes a tracked run from an untracked one (flag off, unauthorized, or
|
|
12
|
-
* the backend unreachable/erroring) so the caller can surface a notice instead
|
|
13
|
-
* of failing silently; the post-start verbs (`stage`/`correction`/`finish`) stay
|
|
14
|
-
* silent no-ops. `/go` runs to completion regardless of the recording outcome.
|
|
15
|
-
*
|
|
16
|
-
* Mirrors the fetch-seam shape of {@link makeRecordEngineeringContextTool}
|
|
17
|
-
* (`baseUrl` + injectable `getToken` + `fetchImpl`) so it is unit-testable
|
|
18
|
-
* without a network and reuses the same auth convention.
|
|
19
|
-
*/
|
|
20
|
-
import { randomUUID } from "node:crypto";
|
|
21
|
-
import { DEFAULT_FETCH_POLICY, METERED_POST_FETCH_POLICY, resilientFetch, } from "../resilientFetch.js";
|
|
22
|
-
/** Map a failed POST's HTTP status onto a user-facing tracking-failure reason. */
|
|
23
|
-
function failureReason(status) {
|
|
24
|
-
if (status === 404)
|
|
25
|
-
return "disabled";
|
|
26
|
-
if (status === 401 || status === 403)
|
|
27
|
-
return "unauthorized";
|
|
28
|
-
if (status === 400)
|
|
29
|
-
return "refused";
|
|
30
|
-
return "error";
|
|
31
|
-
}
|
|
32
|
-
/** Cap a debug body excerpt so a giant HTML/JSON error never floods the channel. */
|
|
33
|
-
const MAX_DEBUG_BODY = 200;
|
|
34
|
-
/** Cap the sentence a refusal is allowed to print, so a stray body stays one line. */
|
|
35
|
-
const MAX_REFUSAL_MESSAGE = 240;
|
|
36
|
-
/**
|
|
37
|
-
* The server's one-sentence refusal, if the body is the shape our own routes
|
|
38
|
-
* send (`{ error }`). Anything else, including HTML from a proxy, reads as no
|
|
39
|
-
* sentence at all, so the caller falls back to its own copy. Newlines are
|
|
40
|
-
* folded so a notice is never a stack.
|
|
41
|
-
*/
|
|
42
|
-
function refusalMessage(body) {
|
|
43
|
-
try {
|
|
44
|
-
const parsed = JSON.parse(body);
|
|
45
|
-
const text = typeof parsed.error === "string" ? parsed.error.trim() : "";
|
|
46
|
-
if (!text)
|
|
47
|
-
return undefined;
|
|
48
|
-
const oneLine = text.replace(/\s+/g, " ");
|
|
49
|
-
return oneLine.length > MAX_REFUSAL_MESSAGE
|
|
50
|
-
? `${oneLine.slice(0, MAX_REFUSAL_MESSAGE)}...`
|
|
51
|
-
: oneLine;
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* A token-free, capped `: <body>` suffix for a failed-response debug line, from
|
|
59
|
-
* a body already read once by the `!res.ok` branch.
|
|
60
|
-
*/
|
|
61
|
-
function bodyTail(body) {
|
|
62
|
-
const t = body.trim();
|
|
63
|
-
if (!t)
|
|
64
|
-
return "";
|
|
65
|
-
return `: ${t.length > MAX_DEBUG_BODY ? `${t.slice(0, MAX_DEBUG_BODY)} ...` : t}`;
|
|
66
|
-
}
|
|
67
|
-
export function makeRunSession(opts) {
|
|
68
|
-
const f = opts.fetchImpl ?? fetch;
|
|
69
|
-
let handle = null;
|
|
70
|
-
// Opt-in observability: the default sink writes to stderr, and the `YAGNI_CODE_DEBUG`
|
|
71
|
-
// gate governs an injected sink too, so callers that pass neither stay silent. The
|
|
72
|
-
// resolved sink is wrapped so it can NEVER throw out of `debug`: an injected sink
|
|
73
|
-
// that throws must not breach the fail-soft never-throw contract (in the `!res.ok`
|
|
74
|
-
// branch an unguarded throw would land in the surrounding catch and re-fire debug,
|
|
75
|
-
// escaping `post` and rejecting the recording call).
|
|
76
|
-
const sink = opts.debug ??
|
|
77
|
-
((line) => {
|
|
78
|
-
try {
|
|
79
|
-
process.stderr.write(`${line}\n`);
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
/* never break recording */
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
const debug = (line) => {
|
|
86
|
-
try {
|
|
87
|
-
sink(line);
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
/* observability never breaks recording */
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
const debugOn = !!(opts.env ?? process.env).YAGNI_CODE_DEBUG;
|
|
94
|
-
// A run-open (`start`) is the one recording call worth retrying: if it is lost,
|
|
95
|
-
// the WHOLE run goes untracked. It is made replay-safe by an idempotency key (the
|
|
96
|
-
// server dedups a re-sent create), so it uses the retrying policy. Every other
|
|
97
|
-
// verb is append-only / terminal and stays single-attempt (METERED) so a recorded
|
|
98
|
-
// judgment (a correction) is never duplicated by a client replay.
|
|
99
|
-
const post = async (path, body, postOpts = {}) => {
|
|
100
|
-
try {
|
|
101
|
-
const res = await resilientFetch(`${opts.baseUrl}/api/yagni-code/${path}`, {
|
|
102
|
-
method: "POST",
|
|
103
|
-
headers: {
|
|
104
|
-
"content-type": "application/json",
|
|
105
|
-
authorization: `Bearer ${opts.getToken() ?? ""}`,
|
|
106
|
-
},
|
|
107
|
-
body: JSON.stringify(body),
|
|
108
|
-
}, {
|
|
109
|
-
fetchImpl: f,
|
|
110
|
-
policy: postOpts.retry ? DEFAULT_FETCH_POLICY : METERED_POST_FETCH_POLICY,
|
|
111
|
-
...(opts.sleep ? { sleep: opts.sleep } : {}),
|
|
112
|
-
...(opts.random ? { random: opts.random } : {}),
|
|
113
|
-
});
|
|
114
|
-
if (!res.ok) {
|
|
115
|
-
// Read the body ONCE on the failure branch (the ok path reads
|
|
116
|
-
// `res.json()` instead, so there is no double-consume): the debug line
|
|
117
|
-
// and the refusal sentence both come from this one read, and a body
|
|
118
|
-
// that cannot be read costs neither.
|
|
119
|
-
let body = "";
|
|
120
|
-
try {
|
|
121
|
-
body = await res.text();
|
|
122
|
-
}
|
|
123
|
-
catch {
|
|
124
|
-
/* a body we cannot read is a body we do not quote */
|
|
125
|
-
}
|
|
126
|
-
if (debugOn)
|
|
127
|
-
debug(`[yagni-code] run-record ${path} -> HTTP ${res.status}${bodyTail(body)}`);
|
|
128
|
-
const message = refusalMessage(body);
|
|
129
|
-
return { ok: false, status: res.status, ...(message ? { failureText: message } : {}) };
|
|
130
|
-
}
|
|
131
|
-
return { ok: true, data: (await res.json()) };
|
|
132
|
-
}
|
|
133
|
-
catch (err) {
|
|
134
|
-
// fail-soft: recording must never break the pipeline (no status = unreachable
|
|
135
|
-
// after retries).
|
|
136
|
-
if (debugOn) {
|
|
137
|
-
debug(`[yagni-code] run-record ${path} unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
138
|
-
}
|
|
139
|
-
return { ok: false };
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
return {
|
|
143
|
-
bind(h) {
|
|
144
|
-
handle = h;
|
|
145
|
-
},
|
|
146
|
-
async start(b) {
|
|
147
|
-
// One idempotency key per open attempt: resilientFetch replays the SAME
|
|
148
|
-
// request body across retries, so a create whose 201 was lost to a blip is
|
|
149
|
-
// deduped by the server (startCodeRun.findRunByIdempotencyKey) instead of
|
|
150
|
-
// opening a second run + a second free-text work item.
|
|
151
|
-
const r = await post("runs", { ...b, idempotencyKey: randomUUID() }, { retry: true });
|
|
152
|
-
if (!r.ok) {
|
|
153
|
-
return {
|
|
154
|
-
tracked: false,
|
|
155
|
-
reason: failureReason(r.status),
|
|
156
|
-
...(r.failureText ? { message: r.failureText } : {}),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
const data = r.data;
|
|
160
|
-
if (typeof data.runId === "string" && typeof data.workItemId === "string") {
|
|
161
|
-
handle = { runId: data.runId, workItemId: data.workItemId };
|
|
162
|
-
return {
|
|
163
|
-
tracked: true,
|
|
164
|
-
runId: handle.runId,
|
|
165
|
-
workItemId: handle.workItemId,
|
|
166
|
-
gated: data.gated === true,
|
|
167
|
-
gatedReason: typeof data.gatedReason === "string" ? data.gatedReason : undefined,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
// A 2xx with an unrecognized body: we can't bind a handle, so it's untracked.
|
|
171
|
-
if (debugOn)
|
|
172
|
-
debug("[yagni-code] run-record runs -> 2xx but body had no runId/workItemId");
|
|
173
|
-
return { tracked: false, reason: "error" };
|
|
174
|
-
},
|
|
175
|
-
async stage(b) {
|
|
176
|
-
if (!handle)
|
|
177
|
-
return;
|
|
178
|
-
await post(`runs/${handle.runId}/stages`, { ...b, workItemId: handle.workItemId });
|
|
179
|
-
},
|
|
180
|
-
async correction(b) {
|
|
181
|
-
if (!handle)
|
|
182
|
-
return;
|
|
183
|
-
await post(`runs/${handle.runId}/correction`, { ...b, workItemId: handle.workItemId });
|
|
184
|
-
},
|
|
185
|
-
async recordPlan(b) {
|
|
186
|
-
if (!handle)
|
|
187
|
-
return;
|
|
188
|
-
await post(`runs/${handle.runId}/plan`, { ...b, workItemId: handle.workItemId });
|
|
189
|
-
},
|
|
190
|
-
async heartbeat() {
|
|
191
|
-
if (!handle)
|
|
192
|
-
return;
|
|
193
|
-
await post(`runs/${handle.runId}/heartbeat`, { workItemId: handle.workItemId });
|
|
194
|
-
},
|
|
195
|
-
async finish(b) {
|
|
196
|
-
if (!handle)
|
|
197
|
-
return;
|
|
198
|
-
await post(`runs/${handle.runId}/finish`, { ...b, workItemId: handle.workItemId });
|
|
199
|
-
},
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
//# sourceMappingURL=runSession.js.map
|