@yagni-app/code-staging 1.2.6-staging.1884.1 → 1.2.6-staging.1892.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,
@@ -977,11 +977,31 @@ export function makeRunVerify(opts = {}) {
977
977
  cannotRun.push(`\`${check.command}\`${check.cause ? ` (${check.cause})` : ""}`);
978
978
  }
979
979
  const paths = await changedPaths(cwd, signal, input.baselinePaths);
980
- 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));
981
982
  for (const job of jobs) {
982
983
  for (const entry of job.cannotRun)
983
984
  cannotRun.push(`CI ${job.name}, "${entry.step}" (${entry.reason})`);
984
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
+ };
985
1005
  if (cannotRun.length > 0) {
986
1006
  const named = cannotRun.slice(0, MAX_NAMED_CANNOT_RUN).join("; ");
987
1007
  const more = cannotRun.length > MAX_NAMED_CANNOT_RUN ? `; and ${cannotRun.length - MAX_NAMED_CANNOT_RUN} more` : "";
@@ -989,8 +1009,10 @@ export function makeRunVerify(opts = {}) {
989
1009
  }
990
1010
  const approved = file.checks.filter((check) => check.status !== "cannot_run");
991
1011
  const steps = jobs.flatMap((job) => job.steps.map((step) => ({ job, step })));
992
- if (approved.length === 0 && steps.length === 0)
1012
+ if (approved.length === 0 && steps.length === 0) {
1013
+ report();
993
1014
  return { findings, reasons, labels };
1015
+ }
994
1016
  const buildLeft = (opts.repoChecksBuildBudgetMs ?? REPO_CHECKS_BUILD_BUDGET_MS) - repoChecksSpentMs;
995
1017
  if (buildLeft < REPO_CHECK_MIN_START_MS) {
996
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");
@@ -1007,6 +1029,8 @@ export function makeRunVerify(opts = {}) {
1007
1029
  reasons,
1008
1030
  labels,
1009
1031
  notReached: 0,
1032
+ failedJobs,
1033
+ approvedFailed: new Set(),
1010
1034
  };
1011
1035
  const before = await treeStatus(cwd, signal).catch((err) => {
1012
1036
  const msg = err instanceof Error ? err.message : String(err);
@@ -1018,6 +1042,9 @@ export function makeRunVerify(opts = {}) {
1018
1042
  await runCiSteps(run, steps, new Set(file.checks.map((check) => check.command.trim())));
1019
1043
  }
1020
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();
1021
1048
  repoChecksSpentMs += now() - halfStartedAt;
1022
1049
  // What the checks wrote into the tree (a coverage folder, an updated
1023
1050
  // snapshot, a build output) is not the build's change: undone, so the
@@ -1083,6 +1110,8 @@ export function makeRunVerify(opts = {}) {
1083
1110
  }
1084
1111
  const label = check.command.length > 80 ? `${check.command.slice(0, 77)}...` : check.command;
1085
1112
  const verdict = await runRepoCommand(run, check.command, run.cwd, undefined);
1113
+ if (verdict.kind === "failed")
1114
+ run.approvedFailed.add(check.command.trim());
1086
1115
  if (verdict.kind === "note") {
1087
1116
  run.reasons.push(`approved check \`${label}\` ${verdict.reason}`);
1088
1117
  }
@@ -1107,11 +1136,35 @@ export function makeRunVerify(opts = {}) {
1107
1136
  */
1108
1137
  async function runCiSteps(run, steps, approvedCommands) {
1109
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
+ };
1110
1156
  for (const { job, step } of steps) {
1111
1157
  if (run.signal?.aborted)
1112
1158
  return;
1113
- if (stoppedJobs.has(job) || approvedCommands.has(step.run.trim()))
1159
+ if (stoppedJobs.has(job))
1114
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
+ }
1115
1168
  if (run.left() < REPO_CHECK_MIN_START_MS) {
1116
1169
  run.notReached += 1;
1117
1170
  continue;
@@ -1123,13 +1176,16 @@ export function makeRunVerify(opts = {}) {
1123
1176
  if (verdict.kind === "passed") {
1124
1177
  lastCiFailure.delete(key);
1125
1178
  run.labels.push(`CI ${job.name}: ${step.name}`);
1179
+ stepPassed(job);
1126
1180
  continue;
1127
1181
  }
1128
1182
  stoppedJobs.add(job);
1129
1183
  if (verdict.kind === "note") {
1130
1184
  run.reasons.push(`${where} ${verdict.reason}`);
1185
+ settle(job, "cannot_run", `step "${step.name}" ${verdict.reason}`);
1131
1186
  continue;
1132
1187
  }
1188
+ settle(job, "failed");
1133
1189
  const stepFindings = repoFailureFindings(run, verdict, step.name, where, dir);
1134
1190
  const keys = new Set(stepFindings.map((f) => f.message));
1135
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.1884.1",
3
+ "version": "1.2.6-staging.1892.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": "3fd5655cbe158b9d97f82dc443dcadd7354c9978"
61
+ "yagniSourceSha": "f786e1dcc19af7fdd50dcb8acbac4cc44f60920d"
62
62
  }