@yagni-app/code-staging 1.2.6-staging.1882.1 → 1.2.6-staging.1886.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.
@@ -153,6 +153,8 @@ export interface HeadlessGoDeps {
153
153
  now?: () => number;
154
154
  runPipeline?: typeof defaultRunPipeline;
155
155
  readFile?: (path: string) => string;
156
+ /** Writes the gate's `ci-results.json`. Defaults to `writeFileSync`; injected in tests. */
157
+ writeFile?: (path: string, text: string) => void;
156
158
  /**
157
159
  * The verify gate a mission run uses, given its `checks.json` (null when
158
160
  * there is none). Defaults to the real gate; injected in tests.
@@ -58,7 +58,7 @@
58
58
  * cannot finish, rather than being killed partway through one (see budget.ts). Such a run gets the sandbox lane's
59
59
  * ceilings (`SANDBOX_RUN_BUDGET`), not interactive /go's.
60
60
  */
61
- import { readFileSync } from "node:fs";
61
+ import { readFileSync, writeFileSync } from "node:fs";
62
62
  import { dirname, join } from "node:path";
63
63
  import { continuationResumePlan, parseContinuation } from "./continuation.js";
64
64
  import { resolveFanoutMode } from "./fanout.js";
@@ -66,7 +66,7 @@ import { makeFanoutBeats } from "./fanoutBeats.js";
66
66
  import { isBlockingFinding } from "./findings.js";
67
67
  import { MISSION_FIX_ROUND_FILE_NAME, readFixRoundFile } from "./fixRound.js";
68
68
  import { normalizeMission } from "./mission.js";
69
- import { MISSION_CHECKS_FILE_NAME, readMissionChecksFile } from "./missionChecks.js";
69
+ import { MISSION_CHECKS_FILE_NAME, MISSION_CI_RESULTS_FILE_NAME, readMissionChecksFile, renderCiResults, } from "./missionChecks.js";
70
70
  import { startTreeSnapshots } from "./treeSnapshot.js";
71
71
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
72
72
  import { makeRunVerify } from "./verify.js";
@@ -333,7 +333,23 @@ export async function runHeadlessGo(argv, deps = {}) {
333
333
  const read = readMissionChecksFile(checksPath, readFile);
334
334
  if (read.file) {
335
335
  const checks = read.file;
336
- runVerify = (deps.makeRunVerify ?? makeRunVerify)({ repoChecks: () => checks });
336
+ // How the failed CI checks' jobs went, for the host, beside the checks
337
+ // file. A write that fails is said once; the host then trusts the
338
+ // round's own account, as before.
339
+ const resultsPath = join(dirname(args.ticketFile), MISSION_CI_RESULTS_FILE_NAME);
340
+ let resultsWriteFailed = false;
341
+ const onFailedChecks = (results) => {
342
+ try {
343
+ (deps.writeFile ?? writeFileSync)(resultsPath, renderCiResults(results));
344
+ }
345
+ catch (err) {
346
+ if (resultsWriteFailed)
347
+ return;
348
+ resultsWriteFailed = true;
349
+ writeErr(`Could not write ${resultsPath}: ${err instanceof Error ? err.message : String(err)}`);
350
+ }
351
+ };
352
+ runVerify = (deps.makeRunVerify ?? makeRunVerify)({ repoChecks: () => checks, onFailedChecks });
337
353
  }
338
354
  else if (read.problem !== "missing") {
339
355
  const why = read.problem === "malformed" ? "not a version 1 checks file" : "it could not be read";
@@ -3,7 +3,11 @@
3
3
  * run besides its own detected scripts: the plan's approved checks (each with
4
4
  * what the host already knows: it fails on the unchanged code, or the sandbox
5
5
  * cannot start it) and the pull request's CI jobs, read from the repository's
6
- * workflows at build time.
6
+ * workflows at build time. On a fix round, the job the pull request's CI
7
+ * reported failed is marked (`failedCheck`): the gate runs it whatever its
8
+ * `paths` say, and writes how it went to `ci-results.json` beside this file
9
+ * for the host, which reads it with its own parser
10
+ * (`packages/backend/src/agent/runs/mission/roundOutcomes.ts`).
7
11
  *
8
12
  * The format's producer is `renderMissionChecksJson` in
9
13
  * `packages/backend/src/integrations/e2b/missionFiles.ts`; this package does
@@ -35,12 +39,31 @@ export interface MissionCiJob {
35
39
  step: string;
36
40
  reason: string;
37
41
  }>;
42
+ /** The pull request's CI reported this job failed (at `step`): the gate runs it whatever its `paths` say. */
43
+ failedCheck?: {
44
+ name: string;
45
+ step?: string;
46
+ };
38
47
  }
39
48
  export interface MissionChecksFile {
40
49
  version: 1;
41
50
  checks: MissionCheck[];
42
51
  ciJobs: MissionCiJob[];
52
+ /** Failed CI checks the gate cannot run, with why (no job named like it, or a job that cannot run here). */
53
+ failedChecksCannotRun?: Array<{
54
+ name: string;
55
+ reason: string;
56
+ }>;
57
+ }
58
+ export declare const MISSION_CI_RESULTS_FILE_NAME = "ci-results.json";
59
+ /** How one failed CI check's job went the last time the gate ran it. */
60
+ export interface FailedCheckResult {
61
+ name: string;
62
+ status: "passed" | "failed" | "cannot_run" | "not_reached";
63
+ reason?: string;
43
64
  }
65
+ /** `ci-results.json` (version 1), as the host reads it. */
66
+ export declare function renderCiResults(checks: readonly FailedCheckResult[]): string;
44
67
  /** The file, or null when the text is not a well-formed version 1 file. */
45
68
  export declare function parseMissionChecksFile(text: string): MissionChecksFile | null;
46
69
  /**
@@ -3,7 +3,11 @@
3
3
  * run besides its own detected scripts: the plan's approved checks (each with
4
4
  * what the host already knows: it fails on the unchanged code, or the sandbox
5
5
  * cannot start it) and the pull request's CI jobs, read from the repository's
6
- * workflows at build time.
6
+ * workflows at build time. On a fix round, the job the pull request's CI
7
+ * reported failed is marked (`failedCheck`): the gate runs it whatever its
8
+ * `paths` say, and writes how it went to `ci-results.json` beside this file
9
+ * for the host, which reads it with its own parser
10
+ * (`packages/backend/src/agent/runs/mission/roundOutcomes.ts`).
7
11
  *
8
12
  * The format's producer is `renderMissionChecksJson` in
9
13
  * `packages/backend/src/integrations/e2b/missionFiles.ts`; this package does
@@ -13,6 +17,11 @@
13
17
  */
14
18
  import { readFileSync } from "node:fs";
15
19
  export const MISSION_CHECKS_FILE_NAME = "checks.json";
20
+ export const MISSION_CI_RESULTS_FILE_NAME = "ci-results.json";
21
+ /** `ci-results.json` (version 1), as the host reads it. */
22
+ export function renderCiResults(checks) {
23
+ return `${JSON.stringify({ version: 1, checks }, null, 2)}\n`;
24
+ }
16
25
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
26
  const isString = (value) => typeof value === "string";
18
27
  function stringList(value) {
@@ -76,8 +85,27 @@ function parseJob(value) {
76
85
  return null;
77
86
  job[key] = list;
78
87
  }
88
+ if (value.failedCheck !== undefined) {
89
+ const failed = value.failedCheck;
90
+ if (!isRecord(failed) || !isString(failed.name))
91
+ return null;
92
+ if (failed.step !== undefined && !isString(failed.step))
93
+ return null;
94
+ job.failedCheck = { name: failed.name, ...(isString(failed.step) ? { step: failed.step } : {}) };
95
+ }
79
96
  return job;
80
97
  }
98
+ function parseCannotRunChecks(value) {
99
+ if (!Array.isArray(value))
100
+ return null;
101
+ const entries = [];
102
+ for (const raw of value) {
103
+ if (!isRecord(raw) || !isString(raw.name) || !isString(raw.reason))
104
+ return null;
105
+ entries.push({ name: raw.name, reason: raw.reason });
106
+ }
107
+ return entries;
108
+ }
81
109
  /** The file, or null when the text is not a well-formed version 1 file. */
82
110
  export function parseMissionChecksFile(text) {
83
111
  let parsed;
@@ -112,7 +140,12 @@ export function parseMissionChecksFile(text) {
112
140
  return null;
113
141
  ciJobs.push(job);
114
142
  }
115
- return { version: 1, checks, ciJobs };
143
+ if (parsed.failedChecksCannotRun === undefined)
144
+ return { version: 1, checks, ciJobs };
145
+ const failedChecksCannotRun = parseCannotRunChecks(parsed.failedChecksCannotRun);
146
+ if (!failedChecksCannotRun)
147
+ return null;
148
+ return { version: 1, checks, ciJobs, failedChecksCannotRun };
116
149
  }
117
150
  /**
118
151
  * Read the file. `missing` (no such file: a host that predates it, or a run
@@ -47,7 +47,7 @@
47
47
  * findings, handoffs, or run records (§0.9).
48
48
  */
49
49
  import { type ToolchainFile } from "../toolchain/spawnHook.js";
50
- import { type MissionChecksFile } from "./missionChecks.js";
50
+ import { type FailedCheckResult, type MissionChecksFile } from "./missionChecks.js";
51
51
  import type { Finding } from "./types.js";
52
52
  /**
53
53
  * Total wall-clock budget for a single verify run before it is abandoned
@@ -329,6 +329,11 @@ export interface MakeRunVerifyOpts {
329
329
  * jobs). Absent or null: no repository-checks half, as before.
330
330
  */
331
331
  repoChecks?: () => MissionChecksFile | null;
332
+ /**
333
+ * How each failed CI check's job went (the jobs `checks.json` marks), after
334
+ * every repository-checks half that ran: the host reads the last one.
335
+ */
336
+ onFailedChecks?: (results: FailedCheckResult[]) => void;
332
337
  /**
333
338
  * This run's own changed paths (repo-relative), which scope the CI jobs by
334
339
  * their `paths` filters. Default: the same `git status` the scope reads,
@@ -361,6 +361,14 @@ export function classifyVerifyInfraFailure(output, exitCode) {
361
361
  }
362
362
  if (/\bspawn(?:Sync)?\b.*\bENOENT\b/.test(output))
363
363
  return "spawn failed (ENOENT)";
364
+ // uv's and make's words for a tool that is not installed (`uv run ruff` with
365
+ // ruff absent from the environment, a Makefile recipe calling it).
366
+ if (/\bFailed to spawn: `[^`\n]+`\s+Caused by: No such file or directory \(os error 2\)/.test(output)) {
367
+ return "command not found (not installed in the environment)";
368
+ }
369
+ if (/^make(?:\[\d+\])?: \*\*\* \[[^\]\n]*\] Error 127$/m.test(output)) {
370
+ return "command not found (a Makefile recipe's tool is not installed)";
371
+ }
364
372
  return null;
365
373
  }
366
374
  /**
@@ -969,11 +977,31 @@ export function makeRunVerify(opts = {}) {
969
977
  cannotRun.push(`\`${check.command}\`${check.cause ? ` (${check.cause})` : ""}`);
970
978
  }
971
979
  const paths = await changedPaths(cwd, signal, input.baselinePaths);
972
- const jobs = file.ciJobs.filter((job) => ciJobInScope(job, paths));
980
+ // A job the pull request's CI reported failed runs whatever its paths say.
981
+ const jobs = file.ciJobs.filter((job) => job.failedCheck !== undefined || ciJobInScope(job, paths));
973
982
  for (const job of jobs) {
974
983
  for (const entry of job.cannotRun)
975
984
  cannotRun.push(`CI ${job.name}, "${entry.step}" (${entry.reason})`);
976
985
  }
986
+ for (const failed of file.failedChecksCannotRun ?? []) {
987
+ cannotRun.push(`failed CI check "${failed.name}" (${failed.reason})`);
988
+ }
989
+ const failedJobs = new Map();
990
+ for (const job of jobs) {
991
+ if (!job.failedCheck)
992
+ continue;
993
+ failedJobs.set(job, job.steps.length > 0
994
+ ? { name: job.failedCheck.name, status: "not_reached" }
995
+ : { name: job.failedCheck.name, status: "cannot_run", reason: job.cannotRun[0]?.reason ?? "none of its steps can run in the sandbox" });
996
+ }
997
+ const report = () => {
998
+ if (failedJobs.size === 0 && !file.failedChecksCannotRun?.length)
999
+ return;
1000
+ opts.onFailedChecks?.([
1001
+ ...failedJobs.values(),
1002
+ ...(file.failedChecksCannotRun ?? []).map((failed) => ({ name: failed.name, status: "cannot_run", reason: failed.reason })),
1003
+ ]);
1004
+ };
977
1005
  if (cannotRun.length > 0) {
978
1006
  const named = cannotRun.slice(0, MAX_NAMED_CANNOT_RUN).join("; ");
979
1007
  const more = cannotRun.length > MAX_NAMED_CANNOT_RUN ? `; and ${cannotRun.length - MAX_NAMED_CANNOT_RUN} more` : "";
@@ -981,8 +1009,10 @@ export function makeRunVerify(opts = {}) {
981
1009
  }
982
1010
  const approved = file.checks.filter((check) => check.status !== "cannot_run");
983
1011
  const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
984
- if (approved.length === 0 && steps.length === 0)
1012
+ if (approved.length === 0 && steps.length === 0) {
1013
+ report();
985
1014
  return { findings, reasons, labels };
1015
+ }
986
1016
  const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
987
1017
  if (buildLeft < REPO_CHECK_MIN_START_MS) {
988
1018
  reasons.push("approved checks and CI steps not run again: the build's check budget is spent; the host still runs the approved checks after the build");
@@ -999,6 +1029,8 @@ export function makeRunVerify(opts = {}) {
999
1029
  reasons,
1000
1030
  labels,
1001
1031
  notReached: 0,
1032
+ failedJobs,
1033
+ approvedFailed: new Set(),
1002
1034
  };
1003
1035
  const before = await treeStatus(cwd, signal).catch((err) => {
1004
1036
  const msg = err instanceof Error ? err.message : String(err);
@@ -1010,6 +1042,9 @@ export function makeRunVerify(opts = {}) {
1010
1042
  await runCiSteps(run, steps, new Set(file.checks.map((check) => check.command.trim())));
1011
1043
  }
1012
1044
  finally {
1045
+ // Whatever ran (an aborted half included) is what the host reads. A half
1046
+ // skipped for the spent budget, above, writes nothing: the last real run stands.
1047
+ report();
1013
1048
  repoChecksSpentMs += now() - halfStartedAt;
1014
1049
  // What the checks wrote into the tree (a coverage folder, an updated
1015
1050
  // snapshot, a build output) is not the build's change: undone, so the
@@ -1075,6 +1110,8 @@ export function makeRunVerify(opts = {}) {
1075
1110
  }
1076
1111
  const label = check.command.length > 80 ? `${check.command.slice(0, 77)}...` : check.command;
1077
1112
  const verdict = await runRepoCommand(run, check.command, run.cwd, undefined);
1113
+ if (verdict.kind === "failed")
1114
+ run.approvedFailed.add(check.command.trim());
1078
1115
  if (verdict.kind === "note") {
1079
1116
  run.reasons.push(`approved check \`${label}\` ${verdict.reason}`);
1080
1117
  }
@@ -1099,11 +1136,35 @@ export function makeRunVerify(opts = {}) {
1099
1136
  */
1100
1137
  async function runCiSteps(run, steps, approvedCommands) {
1101
1138
  const stoppedJobs = new Set();
1139
+ // A marked job's result: the first step that does not pass decides it;
1140
+ // every step passing (or being an approved check that passed) is a pass.
1141
+ const passedSteps = new Map();
1142
+ const settle = (job, status, reason) => {
1143
+ const result = run.failedJobs.get(job);
1144
+ if (!result || result.status === "failed" || result.status === "cannot_run")
1145
+ return;
1146
+ result.status = status;
1147
+ if (reason)
1148
+ result.reason = reason;
1149
+ };
1150
+ const stepPassed = (job) => {
1151
+ const count = (passedSteps.get(job) ?? 0) + 1;
1152
+ passedSteps.set(job, count);
1153
+ if (count === job.steps.length)
1154
+ settle(job, "passed");
1155
+ };
1102
1156
  for (const { job, step } of steps) {
1103
1157
  if (run.signal?.aborted)
1104
1158
  return;
1105
- if (stoppedJobs.has(job) || approvedCommands.has(step.run.trim()))
1159
+ if (stoppedJobs.has(job))
1106
1160
  continue;
1161
+ if (approvedCommands.has(step.run.trim())) {
1162
+ if (run.approvedFailed.has(step.run.trim()))
1163
+ settle(job, "failed");
1164
+ else
1165
+ stepPassed(job);
1166
+ continue;
1167
+ }
1107
1168
  if (run.left() < REPO_CHECK_MIN_START_MS) {
1108
1169
  run.notReached += 1;
1109
1170
  continue;
@@ -1115,13 +1176,16 @@ export function makeRunVerify(opts = {}) {
1115
1176
  if (verdict.kind === "passed") {
1116
1177
  lastCiFailure.delete(key);
1117
1178
  run.labels.push(`CI ${job.name}: ${step.name}`);
1179
+ stepPassed(job);
1118
1180
  continue;
1119
1181
  }
1120
1182
  stoppedJobs.add(job);
1121
1183
  if (verdict.kind === "note") {
1122
1184
  run.reasons.push(`${where} ${verdict.reason}`);
1185
+ settle(job, "cannot_run", `step "${step.name}" ${verdict.reason}`);
1123
1186
  continue;
1124
1187
  }
1188
+ settle(job, "failed");
1125
1189
  const stepFindings = repoFailureFindings(run, verdict, step.name, where, dir);
1126
1190
  const keys = new Set(stepFindings.map((f) => f.message));
1127
1191
  const previous = lastCiFailure.get(key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.6-staging.1882.1",
3
+ "version": "1.2.6-staging.1886.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": "95cd2d148c903aba47bd97219b68c883f56811e4"
61
+ "yagniSourceSha": "8d2eb70b49e6d282e8587ac8458423f8249a4953"
62
62
  }