@yagni-app/code-staging 1.2.2-staging.1528.1 → 1.2.2-staging.1530.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.
@@ -29,6 +29,14 @@ export interface RunBudget {
29
29
  maxCost: number;
30
30
  /** Max aggregate input+output tokens. */
31
31
  maxTokens: number;
32
+ /**
33
+ * Absolute wall-clock deadline (epoch ms) the run must be done by. Absent on
34
+ * every interactive run, where only the usage ceilings apply. The sandbox
35
+ * harness sets it from `YAGNI_PIPELINE_TIME_LEFT_MS`, anchored to this
36
+ * process's own clock, so the run stops before a step it cannot finish
37
+ * instead of being killed partway through one.
38
+ */
39
+ deadlineMs?: number;
32
40
  }
33
41
  /**
34
42
  * Generous default ceilings: a 3-round /go fans out review across 3 lenses on top
@@ -41,8 +49,33 @@ export declare function addRunUsage(a: RunUsage, b: RunUsage): RunUsage;
41
49
  /** Sum usage across the build/fix stages AND every review lens result. */
42
50
  export declare function aggregateRunUsage(stages: StageResult[], rounds: ReviewRound[]): RunUsage;
43
51
  /**
44
- * Return an honest reason string when an aggregate usage crosses any ceiling, else
45
- * null. Checked at each stage boundary so the run stops at the first crossing.
52
+ * Return an honest reason string when an aggregate usage crosses any ceiling (or
53
+ * the run's deadline has passed), else null. Checked at each stage boundary so
54
+ * the run stops at the first crossing.
46
55
  */
47
- export declare function exceedsBudget(usage: RunUsage, budget: RunBudget): string | null;
56
+ export declare function exceedsBudget(usage: RunUsage, budget: RunBudget, nowMs?: number): string | null;
57
+ /**
58
+ * Env var the sandbox host hands the run's time left (ms, relative) on. Relative,
59
+ * not an epoch: the host's clock and the sandbox VM's clock can disagree, and a
60
+ * skew would shift an absolute deadline one-for-one. The run anchors it to its
61
+ * own clock at start.
62
+ */
63
+ export declare const PIPELINE_TIME_LEFT_ENV = "YAGNI_PIPELINE_TIME_LEFT_MS";
64
+ /** A positive integer millisecond count, or undefined for anything else (absent, blank, garbage). */
65
+ export declare function parsePipelineTimeLeft(raw: string | undefined): number | undefined;
66
+ /** The stage kinds whose START is gated on the time left. */
67
+ export type TimedStageKind = "review" | "fix";
68
+ /**
69
+ * What a stage is assumed to take before one has been observed this run. Set
70
+ * from a production run (2026-09-29): a review round took 2.5 to 3 minutes and
71
+ * a fix 4.5 to more than 5, so each default sits just above what was seen.
72
+ */
73
+ export declare const DEFAULT_STAGE_ESTIMATE_MS: Record<TimedStageKind, number>;
74
+ /**
75
+ * Null when a stage expected to take `expectedMs` fits before the deadline (or
76
+ * there is no deadline); else an honest reason. The check is at the START of a
77
+ * stage: a stage already running cannot be stopped at a clean edit boundary,
78
+ * so the only safe place to stop is before it begins.
79
+ */
80
+ export declare function stageWontFit(budget: RunBudget, kind: TimedStageKind, expectedMs: number, nowMs?: number): string | null;
48
81
  //# sourceMappingURL=budget.d.ts.map
@@ -49,10 +49,14 @@ export function aggregateRunUsage(stages, rounds) {
49
49
  return acc;
50
50
  }
51
51
  /**
52
- * Return an honest reason string when an aggregate usage crosses any ceiling, else
53
- * null. Checked at each stage boundary so the run stops at the first crossing.
52
+ * Return an honest reason string when an aggregate usage crosses any ceiling (or
53
+ * the run's deadline has passed), else null. Checked at each stage boundary so
54
+ * the run stops at the first crossing.
54
55
  */
55
- export function exceedsBudget(usage, budget) {
56
+ export function exceedsBudget(usage, budget, nowMs = Date.now()) {
57
+ if (budget.deadlineMs !== undefined && nowMs >= budget.deadlineMs) {
58
+ return `time budget reached (deadline passed ${Math.round((nowMs - budget.deadlineMs) / 1000)}s ago)`;
59
+ }
56
60
  if (usage.turns > budget.maxTurns) {
57
61
  return `turn ceiling reached (${usage.turns} of ${budget.maxTurns} model turns)`;
58
62
  }
@@ -65,4 +69,42 @@ export function exceedsBudget(usage, budget) {
65
69
  }
66
70
  return null;
67
71
  }
72
+ /**
73
+ * Env var the sandbox host hands the run's time left (ms, relative) on. Relative,
74
+ * not an epoch: the host's clock and the sandbox VM's clock can disagree, and a
75
+ * skew would shift an absolute deadline one-for-one. The run anchors it to its
76
+ * own clock at start.
77
+ */
78
+ export const PIPELINE_TIME_LEFT_ENV = "YAGNI_PIPELINE_TIME_LEFT_MS";
79
+ /** A positive integer millisecond count, or undefined for anything else (absent, blank, garbage). */
80
+ export function parsePipelineTimeLeft(raw) {
81
+ const text = raw?.trim();
82
+ if (!text || !/^\d+$/.test(text))
83
+ return undefined;
84
+ const value = Number(text);
85
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
86
+ }
87
+ /**
88
+ * What a stage is assumed to take before one has been observed this run. Set
89
+ * from a production run (2026-09-29): a review round took 2.5 to 3 minutes and
90
+ * a fix 4.5 to more than 5, so each default sits just above what was seen.
91
+ */
92
+ export const DEFAULT_STAGE_ESTIMATE_MS = {
93
+ review: 4 * 60_000,
94
+ fix: 6 * 60_000,
95
+ };
96
+ /**
97
+ * Null when a stage expected to take `expectedMs` fits before the deadline (or
98
+ * there is no deadline); else an honest reason. The check is at the START of a
99
+ * stage: a stage already running cannot be stopped at a clean edit boundary,
100
+ * so the only safe place to stop is before it begins.
101
+ */
102
+ export function stageWontFit(budget, kind, expectedMs, nowMs = Date.now()) {
103
+ if (budget.deadlineMs === undefined)
104
+ return null;
105
+ const leftMs = budget.deadlineMs - nowMs;
106
+ if (leftMs >= expectedMs)
107
+ return null;
108
+ return `not enough time left for a ${kind} stage (${Math.max(0, Math.round(leftMs / 1000))}s left, expected ${Math.round(expectedMs / 1000)}s)`;
109
+ }
68
110
  //# sourceMappingURL=budget.js.map
@@ -46,9 +46,12 @@
46
46
  * result object and forwarded on the child env; the clamp itself is applied
47
47
  * centrally in `runStage` (see tierCap.ts). `YAGNI_GO_FANOUT` is read the same
48
48
  * way and pins the implement diamond for a benchmark lane (see fanout.ts).
49
+ * `YAGNI_PIPELINE_TIME_LEFT_MS` (the sandbox host) is the run's time left,
50
+ * anchored to this process's clock at start: the run then stops `budget_exceeded` before a review round or fix it
51
+ * cannot finish, rather than being killed partway through one (see budget.ts).
49
52
  */
50
53
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
51
- import type { RunBudget } from "./budget.js";
54
+ import { type RunBudget } from "./budget.js";
52
55
  import type { Finding, JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
53
56
  /** One-line usage copy, shared by every argument error. */
54
57
  export declare const HEADLESS_GO_USAGE = "Usage: yagni go --headless --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
@@ -130,6 +133,8 @@ export interface HeadlessGoDeps {
130
133
  env?: NodeJS.ProcessEnv;
131
134
  /** Environment for spawned stage children. Defaults to `env`. */
132
135
  childEnv?: NodeJS.ProcessEnv;
136
+ /** Wall clock (epoch ms) the host's time left is anchored to. Defaults to `Date.now`; injected in tests. */
137
+ now?: () => number;
133
138
  runPipeline?: typeof defaultRunPipeline;
134
139
  readFile?: (path: string) => string;
135
140
  /** stdout sink; one call per NDJSON line, newline added by the caller. */
@@ -46,6 +46,9 @@
46
46
  * result object and forwarded on the child env; the clamp itself is applied
47
47
  * centrally in `runStage` (see tierCap.ts). `YAGNI_GO_FANOUT` is read the same
48
48
  * way and pins the implement diamond for a benchmark lane (see fanout.ts).
49
+ * `YAGNI_PIPELINE_TIME_LEFT_MS` (the sandbox host) is the run's time left,
50
+ * anchored to this process's clock at start: the run then stops `budget_exceeded` before a review round or fix it
51
+ * cannot finish, rather than being killed partway through one (see budget.ts).
49
52
  */
50
53
  import { readFileSync } from "node:fs";
51
54
  import { resolveFanoutMode } from "./fanout.js";
@@ -55,6 +58,7 @@ import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
55
58
  import { loadGroundingEnabled } from "../grounding.js";
56
59
  import { codeStateHome } from "../stateHome.js";
57
60
  import { parseTierCap, TIER_CAP_ENV } from "./tierCap.js";
61
+ import { DEFAULT_RUN_BUDGET, parsePipelineTimeLeft, PIPELINE_TIME_LEFT_ENV } from "./budget.js";
58
62
  /** One-line usage copy, shared by every argument error. */
59
63
  export const HEADLESS_GO_USAGE = "Usage: yagni go --headless --ticket-file <path> [--plan-file <path>] [--memo-file <path>] [--run-id <id>] [--cwd <path>] [--json]";
60
64
  /**
@@ -257,6 +261,18 @@ export async function runHeadlessGo(argv, deps = {}) {
257
261
  if (rawCap?.trim() && !tierCap) {
258
262
  writeErr(`Ignoring ${TIER_CAP_ENV}="${rawCap}": not a tier (peak, advanced, standard, efficient). Running uncapped.`);
259
263
  }
264
+ // The sandbox host's time left for this run, anchored to THIS process's clock
265
+ // (the host's may be skewed from the VM's). Absent (every local run, and a
266
+ // host that predates it) leaves the budget exactly as it was.
267
+ const rawTimeLeft = env[PIPELINE_TIME_LEFT_ENV];
268
+ const timeLeftMs = parsePipelineTimeLeft(rawTimeLeft);
269
+ if (rawTimeLeft?.trim() && timeLeftMs === undefined) {
270
+ writeErr(`Ignoring ${PIPELINE_TIME_LEFT_ENV}="${rawTimeLeft}": not a millisecond count. Running without a deadline.`);
271
+ }
272
+ const deadlineMs = timeLeftMs === undefined ? undefined : (deps.now ?? Date.now)() + timeLeftMs;
273
+ const budget = deadlineMs === undefined
274
+ ? deps.budget
275
+ : { ...(deps.budget ?? DEFAULT_RUN_BUDGET), deadlineMs: deps.budget?.deadlineMs ?? deadlineMs };
260
276
  const cwd = args.cwd ?? deps.cwd ?? process.cwd();
261
277
  const emit = (obj) => write(JSON.stringify(obj));
262
278
  const stream = args.json;
@@ -284,7 +300,7 @@ export async function runHeadlessGo(argv, deps = {}) {
284
300
  ...(args.runId ? { runId: args.runId } : {}),
285
301
  },
286
302
  ...(deps.signal ? { signal: deps.signal } : {}),
287
- ...(deps.budget ? { budget: deps.budget } : {}),
303
+ ...(budget ? { budget } : {}),
288
304
  ...(deps.stages ? { stages: deps.stages } : {}),
289
305
  grounded: deps.grounded ?? grounding,
290
306
  ...(mission ? { mission } : {}),
@@ -65,6 +65,8 @@ export interface RunPipelineDeps {
65
65
  * with `budget_exceeded` rather than spending on. Tunable per lane / per caller.
66
66
  */
67
67
  budget?: RunBudget;
68
+ /** Wall clock for the budget's deadline checks (epoch ms). Defaults to `Date.now`; injected in tests. */
69
+ now?: () => number;
68
70
  /**
69
71
  * M6 eval: the stage list to run. Defaults to selectStages("full"); the blind
70
72
  * lane passes a grounding-tool-stripped copy (see eval.ts#blindStages).
@@ -24,7 +24,7 @@
24
24
  * (an aborted/crashed child has empty output, which otherwise parses to zero
25
25
  * findings — i.e. a false "reviewed and clean").
26
26
  */
27
- import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, EMPTY_RUN_USAGE, exceedsBudget } from "./budget.js";
27
+ import { addRunUsage, aggregateRunUsage, DEFAULT_RUN_BUDGET, DEFAULT_STAGE_ESTIMATE_MS, EMPTY_RUN_USAGE, exceedsBudget, stageWontFit, } from "./budget.js";
28
28
  import { attributeFindings, checkerFindings, composeFixNote, composeResidue, composeSynthesizerInput, mergeFindings, parseOpenFindings, renderFindings, stripOpenFindings, } from "./checker.js";
29
29
  import { auditClaims, parsePartition } from "./fanout.js";
30
30
  import { extractFindingsBlock, hasBlockingFindings, parseFindings, shouldStop, unionFindings } from "./findings.js";
@@ -329,7 +329,14 @@ export async function runPipeline(ticket, deps) {
329
329
  let baselinePaths;
330
330
  const priorUsage = resume?.priorUsage ?? EMPTY_RUN_USAGE;
331
331
  const currentUsage = () => addRunUsage(priorUsage, aggregateRunUsage(stages, rounds));
332
- const overBudget = () => exceedsBudget(currentUsage(), budget);
332
+ const clock = deps.now ?? Date.now;
333
+ const overBudget = () => exceedsBudget(currentUsage(), budget, clock());
334
+ // How long each timed stage kind last took THIS run. With a deadline, a review
335
+ // round or fix is only started when its last observed duration (or the
336
+ // conservative default, before one has run) still fits: a stage killed partway
337
+ // through by the harness leaves a half-edited tree and no verdict at all.
338
+ const observedMs = {};
339
+ const wontFit = (kind) => stageWontFit(budget, kind, observedMs[kind] ?? DEFAULT_STAGE_ESTIMATE_MS[kind], clock());
333
340
  // The map stage's brief, kept for the partitioner's repo context. Absent on a
334
341
  // mission run (map is skipped there — the injected memo stands in) and on any
335
342
  // run whose map stage did not produce one; the partition contract is explicit
@@ -848,6 +855,13 @@ export async function runPipeline(ticket, deps) {
848
855
  writeCheckpoint(mkRecord("build_done", { snapshot: afterBuild, reviewInput, usage: currentUsage() }));
849
856
  }
850
857
  while (true) {
858
+ const reviewWontFit = wontFit("review");
859
+ if (reviewWontFit) {
860
+ stopReason = "budget_exceeded";
861
+ log("pipeline_stop", { reason: "budget_exceeded", phase: "review", round, detail: reviewWontFit });
862
+ break;
863
+ }
864
+ const reviewStartedAt = clock();
851
865
  progress({ kind: "stage_start", stageId: "review", round });
852
866
  stageEvent("review", "start", { round });
853
867
  // P4 verify runs CONCURRENTLY with the lens fan-out: it is deterministic,
@@ -1003,6 +1017,7 @@ export async function runPipeline(ticket, deps) {
1003
1017
  if (degradedLenses)
1004
1018
  log("review_degraded", { round, degradedLenses });
1005
1019
  progress({ kind: "stage_done", stageId: "review", round });
1020
+ observedMs.review = clock() - reviewStartedAt;
1006
1021
  // R3-b: stop honestly if the review fan-out pushed the run over budget, before
1007
1022
  // spending another fix + round. The round's findings are already recorded.
1008
1023
  const overAfterReview = overBudget();
@@ -1016,9 +1031,19 @@ export async function runPipeline(ticket, deps) {
1016
1031
  stopReason = decision.reason;
1017
1032
  break;
1018
1033
  }
1034
+ // The review's findings are recorded; a fix that cannot finish in time would
1035
+ // only leave them half-applied.
1036
+ const fixWontFit = wontFit("fix");
1037
+ if (fixWontFit) {
1038
+ stopReason = "budget_exceeded";
1039
+ log("pipeline_stop", { reason: "budget_exceeded", phase: "fix", round, detail: fixWontFit });
1040
+ break;
1041
+ }
1042
+ const fixStartedAt = clock();
1019
1043
  progress({ kind: "stage_start", stageId: "fix", round });
1020
1044
  stageEvent("fix", "start", { round });
1021
1045
  const fixResult = await runStageFn(fix, { ticket: promptTicket, previous: serializeBlocking(findings), grounded }, stageDepsFor({ stageId: "fix", round }));
1046
+ observedMs.fix = clock() - fixStartedAt;
1022
1047
  stages.push(fixResult);
1023
1048
  log("stage_done", { stageId: "fix", exitCode: fixResult.exitCode });
1024
1049
  if (deps.signal?.aborted) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.2-staging.1528.1",
3
+ "version": "1.2.2-staging.1530.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": "ba1fbd6d109af460fa18b44f41f33b432465e25e"
61
+ "yagniSourceSha": "cbe4409f1c4a9b1916438a3899957168c5c30ac5"
62
62
  }