@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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "a55a45e07d5aa97ca696dcf692ec7c6507882a81"
|
|
62
62
|
}
|