@yagni-app/code-staging 1.2.2-staging.1555.1 → 1.2.2-staging.1563.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.
@@ -50,6 +50,7 @@
50
50
  * anchored to this process's clock at start: the run then stops `budget_exceeded` before a review round or fix it
51
51
  * cannot finish, rather than being killed partway through one (see budget.ts).
52
52
  */
53
+ import { type TreeSnapshotDeps } from "./treeSnapshot.js";
53
54
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
54
55
  import { type RunBudget } from "./budget.js";
55
56
  import type { Finding, JsonEvent, ModelTier, PipelineStage, StageId, StageTag, StageUsage, StopReason } from "./types.js";
@@ -151,6 +152,8 @@ export interface HeadlessGoDeps {
151
152
  onEvent?: (ev: JsonEvent, tag: StageTag) => void;
152
153
  /** Additive tap on the pipeline's own log events. */
153
154
  logger?: (event: string, data?: unknown) => void;
155
+ /** Mission runs' working-tree snapshots (see treeSnapshot.ts); injected in tests. */
156
+ startTreeSnapshots?: (cwd: string, snapshotDeps: TreeSnapshotDeps) => () => Promise<void>;
154
157
  }
155
158
  /**
156
159
  * Parse the argv remainder after `go`. Accepts both `--flag value` and
@@ -54,6 +54,7 @@ import { readFileSync } from "node:fs";
54
54
  import { resolveFanoutMode } from "./fanout.js";
55
55
  import { makeFanoutBeats } from "./fanoutBeats.js";
56
56
  import { normalizeMission } from "./mission.js";
57
+ import { startTreeSnapshots } from "./treeSnapshot.js";
57
58
  import { runPipeline as defaultRunPipeline } from "./orchestrator.js";
58
59
  import { loadGroundingEnabled } from "../grounding.js";
59
60
  import { codeStateHome } from "../stateHome.js";
@@ -333,6 +334,13 @@ export async function runHeadlessGo(argv, deps = {}) {
333
334
  emit({ type: "pipeline_log", event, ...(data === undefined ? {} : { data }) });
334
335
  },
335
336
  };
337
+ // A mission's tree is the only copy of its work until the host commits it,
338
+ // so it is snapshotted for the host to recover from (treeSnapshot.ts).
339
+ const stopSnapshots = mission
340
+ ? (deps.startTreeSnapshots ?? startTreeSnapshots)(cwd, {
341
+ onError: (message) => writeErr(`Working-tree checkpoint failed (the run continues without it): ${message}`),
342
+ })
343
+ : null;
336
344
  try {
337
345
  const result = await runPipeline(files.ticket, pipelineDeps);
338
346
  const headless = buildResult(result, {
@@ -353,5 +361,8 @@ export async function runHeadlessGo(argv, deps = {}) {
353
361
  writeErr(message);
354
362
  return { exitCode: HEADLESS_GO_EXIT.unverified, error: message };
355
363
  }
364
+ finally {
365
+ await stopSnapshots?.();
366
+ }
356
367
  }
357
368
  //# sourceMappingURL=headlessGo.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Working-tree snapshots for a headless mission run.
3
+ *
4
+ * In mission mode the pipeline's FINISH stage never runs: the host turns
5
+ * whatever is in the tree when the harness ends into the one candidate commit.
6
+ * That makes the tree the only copy of the work, and a model can empty it in
7
+ * the last minute of a stage (a `git stash` to see whether a test failed before
8
+ * its change, a `git checkout .` to retry an approach) and then be cut off by
9
+ * the wall clock before putting it back. The host then finds the tree at the
10
+ * approved base and the paid build is gone.
11
+ *
12
+ * So, on an interval and once at the end, the run records the tree as a commit
13
+ * under {@link CHECKPOINT_REF}: a side ref the host reads only to recover from
14
+ * exactly that, never a commit on the branch. It goes through a throwaway
15
+ * index (`GIT_INDEX_FILE`), so HEAD, the real index and the files are never
16
+ * touched and the model's own git view is unchanged; `add -A` against that
17
+ * index still picks up new files and still honours `.gitignore`. A clean tree
18
+ * never overwrites the last real snapshot. Hooks are off for every call: the
19
+ * repository is the model's to edit, and a hook it installed must not run.
20
+ *
21
+ * Fail-soft throughout: a snapshot that cannot be taken is just not taken.
22
+ */
23
+ /** The side ref a snapshot lands on. Never pushed: the candidate bundle carries HEAD only. */
24
+ export declare const CHECKPOINT_REF = "refs/yagni/checkpoint";
25
+ /** How often a mission run records its tree. */
26
+ export declare const SNAPSHOT_INTERVAL_MS = 60000;
27
+ /**
28
+ * Record the working tree under {@link CHECKPOINT_REF}. Answers the snapshot
29
+ * commit, or undefined when the tree matches HEAD or anything failed.
30
+ */
31
+ export declare function snapshotWorkingTree(cwd: string, onError?: (message: string) => void): Promise<string | undefined>;
32
+ export interface TreeSnapshotDeps {
33
+ intervalMs?: number;
34
+ /** Bound on the stop-time snapshot; defaults to {@link STOP_SNAPSHOT_BOUND_MS}. */
35
+ stopBoundMs?: number;
36
+ /**
37
+ * Told when snapshots start failing (once per run of failures: a success
38
+ * re-arms it), and always about the final snapshot failing or being cut
39
+ * off, so a recovery that never arms or a stale checkpoint is visible.
40
+ */
41
+ onError?: (message: string) => void;
42
+ snapshot?: (cwd: string, onError?: (message: string) => void) => Promise<string | undefined>;
43
+ setInterval?: (fn: () => void, ms: number) => NodeJS.Timeout;
44
+ clearInterval?: (timer: NodeJS.Timeout) => void;
45
+ }
46
+ /**
47
+ * Snapshot `cwd` every {@link SNAPSHOT_INTERVAL_MS} until the returned stop
48
+ * runs, which takes one last snapshot. Snapshots never overlap.
49
+ */
50
+ export declare function startTreeSnapshots(cwd: string, deps?: TreeSnapshotDeps): () => Promise<void>;
51
+ //# sourceMappingURL=treeSnapshot.d.ts.map
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Working-tree snapshots for a headless mission run.
3
+ *
4
+ * In mission mode the pipeline's FINISH stage never runs: the host turns
5
+ * whatever is in the tree when the harness ends into the one candidate commit.
6
+ * That makes the tree the only copy of the work, and a model can empty it in
7
+ * the last minute of a stage (a `git stash` to see whether a test failed before
8
+ * its change, a `git checkout .` to retry an approach) and then be cut off by
9
+ * the wall clock before putting it back. The host then finds the tree at the
10
+ * approved base and the paid build is gone.
11
+ *
12
+ * So, on an interval and once at the end, the run records the tree as a commit
13
+ * under {@link CHECKPOINT_REF}: a side ref the host reads only to recover from
14
+ * exactly that, never a commit on the branch. It goes through a throwaway
15
+ * index (`GIT_INDEX_FILE`), so HEAD, the real index and the files are never
16
+ * touched and the model's own git view is unchanged; `add -A` against that
17
+ * index still picks up new files and still honours `.gitignore`. A clean tree
18
+ * never overwrites the last real snapshot. Hooks are off for every call: the
19
+ * repository is the model's to edit, and a hook it installed must not run.
20
+ *
21
+ * Fail-soft throughout: a snapshot that cannot be taken is just not taken.
22
+ */
23
+ import { execFile } from "node:child_process";
24
+ import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
25
+ import { tmpdir } from "node:os";
26
+ import { join, resolve as resolvePath } from "node:path";
27
+ /** The side ref a snapshot lands on. Never pushed: the candidate bundle carries HEAD only. */
28
+ export const CHECKPOINT_REF = "refs/yagni/checkpoint";
29
+ /** How often a mission run records its tree. */
30
+ export const SNAPSHOT_INTERVAL_MS = 60_000;
31
+ const GIT_BASE_ARGS = ["-c", "core.hooksPath=/dev/null", "-c", "user.name=YAGNI", "-c", "user.email=worker@yagni.local"];
32
+ function gitOut(args, cwd, env) {
33
+ return new Promise((resolve, reject) => {
34
+ execFile("git", [...GIT_BASE_ARGS, ...args], { cwd, env: { ...process.env, ...env, GIT_TERMINAL_PROMPT: "0" }, maxBuffer: 4 * 1024 * 1024, timeout: 60_000 }, (err, stdout) => (err ? reject(err) : resolve(String(stdout).trim())));
35
+ });
36
+ }
37
+ /**
38
+ * Record the working tree under {@link CHECKPOINT_REF}. Answers the snapshot
39
+ * commit, or undefined when the tree matches HEAD or anything failed.
40
+ */
41
+ export async function snapshotWorkingTree(cwd, onError) {
42
+ let scratch;
43
+ try {
44
+ scratch = mkdtempSync(join(tmpdir(), "yagni-snapshot-"));
45
+ const index = join(scratch, "index");
46
+ const env = { GIT_INDEX_FILE: index };
47
+ // Seed the throwaway index from the real one: its stat cache lets `add -A`
48
+ // skip every unchanged file instead of re-hashing the whole tree each
49
+ // minute. The result is the same tree either way (`add -A` makes the index
50
+ // match the files); a repo with no index yet starts from HEAD.
51
+ const realIndex = resolvePath(cwd, await gitOut(["rev-parse", "--git-path", "index"], cwd));
52
+ try {
53
+ copyFileSync(realIndex, index);
54
+ }
55
+ catch {
56
+ await gitOut(["read-tree", "HEAD"], cwd, env);
57
+ }
58
+ await gitOut(["add", "-A"], cwd, env);
59
+ const tree = await gitOut(["write-tree"], cwd, env);
60
+ const headTree = await gitOut(["rev-parse", "HEAD^{tree}"], cwd);
61
+ if (!tree || tree === headTree)
62
+ return undefined;
63
+ const commit = await gitOut(["commit-tree", tree, "-p", "HEAD", "-m", "yagni: working tree checkpoint"], cwd);
64
+ if (!commit)
65
+ return undefined;
66
+ await gitOut(["update-ref", CHECKPOINT_REF, commit], cwd);
67
+ return commit;
68
+ }
69
+ catch (err) {
70
+ onError?.(err instanceof Error ? err.message : String(err));
71
+ return undefined;
72
+ }
73
+ finally {
74
+ if (scratch)
75
+ rmSync(scratch, { recursive: true, force: true });
76
+ }
77
+ }
78
+ /** The most the final snapshot may hold up the harness's exit. */
79
+ const STOP_SNAPSHOT_BOUND_MS = 15_000;
80
+ /**
81
+ * Snapshot `cwd` every {@link SNAPSHOT_INTERVAL_MS} until the returned stop
82
+ * runs, which takes one last snapshot. Snapshots never overlap.
83
+ */
84
+ export function startTreeSnapshots(cwd, deps = {}) {
85
+ const snapshot = deps.snapshot ?? snapshotWorkingTree;
86
+ let reported = false;
87
+ let running = null;
88
+ const take = (final = false) => {
89
+ if (!running) {
90
+ let failed = false;
91
+ const onError = (message) => {
92
+ failed = true;
93
+ if (final)
94
+ deps.onError?.(`final snapshot: ${message}`);
95
+ else if (!reported)
96
+ deps.onError?.(message);
97
+ reported = true;
98
+ };
99
+ running = snapshot(cwd, onError)
100
+ .catch((err) => onError(err instanceof Error ? err.message : String(err)))
101
+ .finally(() => {
102
+ // A snapshot that worked re-arms the report for the next failure.
103
+ if (!failed)
104
+ reported = false;
105
+ running = null;
106
+ });
107
+ }
108
+ return running;
109
+ };
110
+ const schedule = deps.setInterval ?? ((fn, ms) => globalThis.setInterval(fn, ms));
111
+ const cancel = deps.clearInterval ?? ((handle) => globalThis.clearInterval(handle));
112
+ const timer = schedule(() => {
113
+ void take();
114
+ }, deps.intervalMs ?? SNAPSHOT_INTERVAL_MS);
115
+ timer.unref?.();
116
+ return async () => {
117
+ cancel(timer);
118
+ // The result is already known by now; a wedged filesystem must not hold
119
+ // the harness's exit for minutes. The last interval snapshot still stands.
120
+ const bound = deps.stopBoundMs ?? STOP_SNAPSHOT_BOUND_MS;
121
+ let timeout;
122
+ const limit = new Promise((resolve) => {
123
+ timeout = globalThis.setTimeout(() => resolve("cut_off"), bound);
124
+ });
125
+ const final = (async () => {
126
+ if (running)
127
+ await running;
128
+ await take(true);
129
+ return "taken";
130
+ })();
131
+ const outcome = await Promise.race([final, limit]);
132
+ if (timeout !== undefined)
133
+ globalThis.clearTimeout(timeout);
134
+ if (outcome === "cut_off") {
135
+ deps.onError?.(`final snapshot did not finish within ${bound} ms; the last interval snapshot stands`);
136
+ }
137
+ };
138
+ }
139
+ //# sourceMappingURL=treeSnapshot.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.2.2-staging.1555.1",
3
+ "version": "1.2.2-staging.1563.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": "8e8cae193cda58ced857dd7c5f832e83d29f3e5b"
61
+ "yagniSourceSha": "a55a45e07d5aa97ca696dcf692ec7c6507882a81"
62
62
  }