@uchibeke/gallivant 0.2.0 → 0.2.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.
- package/README.md +93 -55
- package/bin/gallivant.mjs +8 -4
- package/docs/README.md +4 -2
- package/docs/agent-setup.md +55 -41
- package/docs/commands.md +38 -9
- package/docs/config.md +10 -3
- package/docs/execution.md +1 -1
- package/docs/install.md +123 -63
- package/docs/launch.md +7 -4
- package/docs/openai-agents.md +1 -1
- package/docs/preparation.md +1 -1
- package/docs/releasing.md +31 -22
- package/docs/task-delivery.md +1 -1
- package/lib/commands/doctor.mjs +15 -6
- package/lib/commands/init.mjs +238 -27
- package/lib/commands/register.mjs +56 -2
- package/lib/context.mjs +5 -3
- package/lib/execution.mjs +84 -8
- package/lib/fs.mjs +10 -3
- package/lib/task_delivery_contract.mjs +1 -1
- package/lib/tick.mjs +21 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/lib/commands/init.mjs
CHANGED
|
@@ -1,34 +1,245 @@
|
|
|
1
|
-
|
|
1
|
+
// `gallivant init`: set up the repository you are standing in, then hand over to `doctor`.
|
|
2
|
+
//
|
|
3
|
+
// Explicit setup only. It never signs in for you, never reads a password or token, never touches global harness
|
|
4
|
+
// settings, credentials or repository permissions, and never rewrites a key the operator wrote. Repeating it is safe:
|
|
5
|
+
// an existing config keeps every value as written and gains at most one `repos` entry, the one for this checkout.
|
|
6
|
+
//
|
|
7
|
+
// Every prerequisite is checked before anything is written, so a signed-out `gh`, a signed-out harness or a checkout
|
|
8
|
+
// that is not a GitHub repository stops the command with the exact action and leaves no partial state behind.
|
|
9
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, lstatSync, realpathSync } from "node:fs";
|
|
2
10
|
import { join, dirname } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { gallivantHome, expand, templatePath, readJson, writeJsonAtomic, contract, VERSION, PACKAGE_NAME } from "../fs.mjs";
|
|
13
|
+
import { discoverHarnesses, validate, merge, configPath, repoEntry } from "../config.mjs";
|
|
14
|
+
import { makeGh, repoFromRemote } from "../gh.mjs";
|
|
6
15
|
import { silentLog } from "../log.mjs";
|
|
16
|
+
import { checkHarness } from "../probe.mjs";
|
|
17
|
+
import { resolveCredential, describe as describeCredential } from "../credential.mjs";
|
|
18
|
+
import { doctor as runDoctor, repositoryWriteScopeCheck } from "./doctor.mjs";
|
|
19
|
+
import { acquireCliMutation } from "../daemon.mjs";
|
|
7
20
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
export const INIT_HARNESSES = ["codex", "claude"];
|
|
22
|
+
|
|
23
|
+
/** Every message that stops setup ends with this, so nobody reads a stopped run as a half-finished one. */
|
|
24
|
+
const NOTHING_WRITTEN = "Setup did not complete and nothing was written.";
|
|
25
|
+
|
|
26
|
+
const firstLine = e => String(e?.message || e).split("\n")[0];
|
|
27
|
+
const isSymlink = p => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A remote URL with its userinfo removed. `https://x-access-token:SECRET@github.com/...` is a remote git and
|
|
31
|
+
* `repoFromRemote` both accept, and every message here that names the origin would otherwise print the secret into
|
|
32
|
+
* a terminal or a CI log. Only scheme URLs carry userinfo; `git@github.com:owner/name` names a login, not a secret.
|
|
33
|
+
*/
|
|
34
|
+
export const safeOrigin = url => String(url || "").replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, "$1");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The repository a checkout belongs to, read from its `origin` remote. Local only: no network, no `gh`. Throws a
|
|
38
|
+
* message naming the unmet prerequisite, so a directory that is not a GitHub checkout is refused before anything else
|
|
39
|
+
* runs. `owner/name` is lowercased as `repoFromRemote` does, which is also how `rememberRepo` and `doctor` spell it.
|
|
40
|
+
*/
|
|
41
|
+
export function targetRepository(dir = process.cwd(), exec = execFileSync) {
|
|
42
|
+
const git = args => {
|
|
43
|
+
try { return exec("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim(); }
|
|
44
|
+
catch (e) {
|
|
45
|
+
if (e?.code === "ENOENT") throw new Error(`git is not on PATH. Install Git, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const checkout = git(["rev-parse", "--show-toplevel"]);
|
|
50
|
+
if (!checkout) throw new Error(`${dir} is not inside a Git checkout. Run gallivant init from the repository you want to set up (cd into it first). ${NOTHING_WRITTEN}`);
|
|
51
|
+
const origin = git(["remote", "get-url", "origin"]);
|
|
52
|
+
if (!origin) throw new Error(`${checkout} has no origin remote. Create the repository on GitHub and add it (git remote add origin https://github.com/<owner>/<name>.git), then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
53
|
+
const repo = repoFromRemote(origin);
|
|
54
|
+
if (!repo) throw new Error(`origin remote ${safeOrigin(origin)} is not a github.com repository URL. Gallivant opens pull requests on github.com; point origin at the GitHub repository, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
55
|
+
// The redacted form is the only one returned: nothing downstream needs the credential, and nothing may print it.
|
|
56
|
+
return { checkout, origin: safeOrigin(origin), repo };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Infer a harness only when there is one local choice; otherwise setup requires an explicit selection. */
|
|
60
|
+
const onlyLocalHarness = raw => {
|
|
61
|
+
const names = Object.keys(raw?.harness || {}).filter(n => INIT_HARNESSES.includes(n));
|
|
62
|
+
return names.length === 1 ? names[0] : null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** `checkHarness` reads expanded paths, as `load` would hand them over; a raw config may still spell them with `~`. */
|
|
66
|
+
const expandedFor = (config, name) => {
|
|
67
|
+
const h = config.harness[name];
|
|
68
|
+
return { ...config, harness: { ...config.harness, [name]: { ...h, bin: expand(h.bin), ...(h.settings ? { settings: expand(h.settings) } : {}) } } };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Options:
|
|
73
|
+
* home the Gallivant home; `config.json` lives here
|
|
74
|
+
* harness `codex` or `claude`; null keeps an existing config's harnesses and defaults a new one to codex
|
|
75
|
+
* worktrees where task checkouts go; refused when an existing config already chose a different directory
|
|
76
|
+
* dir the target checkout; the repository it belongs to is what gets recorded
|
|
77
|
+
* The rest are injected for tests: harness discovery, the operator's own `gh` (used only to name the login), the
|
|
78
|
+
* credential resolver and the `gh` built from what it resolves, repository resolution, the readiness check and `doctor`.
|
|
79
|
+
*/
|
|
80
|
+
export async function init(options = {}) {
|
|
81
|
+
for (let attempt = 0; ; attempt++) {
|
|
82
|
+
try { return await initAttempt(options); }
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== "INIT_CONFIG_CHANGED" || attempt >= 2) throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function initAttempt({ home = gallivantHome(), harness = null, worktrees = null, dir = process.cwd(),
|
|
90
|
+
discover = discoverHarnesses, gh = makeGh({ log: silentLog }), repository = targetRepository, harnessCheck = checkHarness, doctor = runDoctor,
|
|
91
|
+
resolve = resolveCredential, ghFor = credential => makeGh({ credential: async () => credential, log: silentLog }) } = {}) {
|
|
92
|
+
if (harness != null && !INIT_HARNESSES.includes(harness)) throw new Error("init supports --harness codex or claude; configure other adapters explicitly after checking docs/install.md");
|
|
13
93
|
home = expand(home);
|
|
14
|
-
const file =
|
|
15
|
-
|
|
16
|
-
for (let
|
|
17
|
-
if (
|
|
18
|
-
if (dirname(
|
|
94
|
+
const file = configPath(home);
|
|
95
|
+
// `lstat` rather than `exists`: a dangling symlink does not exist and is still a symlink.
|
|
96
|
+
for (let d = home; ; d = dirname(d)) {
|
|
97
|
+
if (isSymlink(d)) throw new Error(`Setup directory must not be a symlink: ${d}. ${NOTHING_WRITTEN}`);
|
|
98
|
+
if (dirname(d) === d) break;
|
|
19
99
|
}
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
100
|
+
const missing = Symbol("missing config");
|
|
101
|
+
const raw = readJson(file, missing);
|
|
102
|
+
const existing = raw !== missing;
|
|
103
|
+
if (existing && (raw === null || typeof raw !== "object" || Array.isArray(raw))) throw new Error(`${file} is not a JSON object. Fix or move it, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
104
|
+
|
|
105
|
+
// 1. The repository, before any network call: a directory that is not a GitHub checkout has nothing to set up.
|
|
106
|
+
const target = repository(dir);
|
|
107
|
+
|
|
108
|
+
// 2. GitHub sign-in, by asking `gh` who it is. The fix is `gh`'s own login flow; nothing here reads a credential.
|
|
109
|
+
let login = null, ghError = null;
|
|
110
|
+
try { login = await gh.user(); } catch (e) { ghError = firstLine(e); }
|
|
111
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(login || "")) throw new Error(`GitHub CLI is not signed in${ghError ? ` (${ghError})` : ""}.\nRun: gh auth login\nThen rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
112
|
+
|
|
113
|
+
// 3. The config this run would leave behind, built but not yet written.
|
|
114
|
+
const selected = harness ?? (existing ? onlyLocalHarness(raw) : "codex");
|
|
115
|
+
const candidate = existing ? structuredClone(raw) : {
|
|
116
|
+
trusted_authors: [login], worktrees: expand(worktrees || join(dirname(home), ".gallivant-worktrees")),
|
|
117
|
+
harness: {}, notify: null, github_token_file: null, repos: [],
|
|
118
|
+
};
|
|
119
|
+
if (existing && worktrees && expand(worktrees) !== expand(raw.worktrees)) throw new Error(`config already sets worktrees to ${raw.worktrees}; edit ${contract(file)} to move it rather than passing --worktrees. ${NOTHING_WRITTEN}`);
|
|
120
|
+
if (selected && !candidate.harness?.[selected]) {
|
|
121
|
+
const found = discover(home)[selected];
|
|
122
|
+
if (!found) throw new Error(`${selected} is not on PATH. Install it and sign in, then retry gallivant init --harness ${selected}. ${NOTHING_WRITTEN}`);
|
|
123
|
+
candidate.harness = { ...(candidate.harness || {}), [selected]: found };
|
|
124
|
+
}
|
|
125
|
+
const merged = merge(candidate, home);
|
|
126
|
+
const problems = validate(merged, home);
|
|
127
|
+
if (problems.length) throw new Error(`${existing ? `Config problems in ${contract(file)}: ` : ""}${problems.join("; ")}. ${NOTHING_WRITTEN}`);
|
|
128
|
+
if (!selected) throw new Error(`Choose a harness: rerun gallivant init --harness codex or gallivant init --harness claude. ${NOTHING_WRITTEN}`);
|
|
129
|
+
|
|
130
|
+
// 4. The selected harness has to be installed at a supported version and signed in. The adapter owns the fix line
|
|
131
|
+
// (`codex login`, `claude auth login`); init only repeats it. Version and sign-in say nothing about model access.
|
|
132
|
+
if (selected) {
|
|
133
|
+
const ready = await harnessCheck(selected, expandedFor(merged, selected));
|
|
134
|
+
if (!ready.ok) throw new Error(`${selected} is not ready (${ready.detail}).\nRun: ${ready.fix}\nThen rerun gallivant init --harness ${selected}. ${NOTHING_WRITTEN}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. The repository has to be one a RUN can push to, asked of GitHub with the credential this config resolves
|
|
138
|
+
// (`github_token_file` first, then the environment, then `gh`), exactly as `start` and `doctor` resolve it. The
|
|
139
|
+
// operator's ambient login is the wrong identity to ask: an explicit PAT that cannot reach the repository is the
|
|
140
|
+
// failure this check exists to catch, and an ambient login that can would hide it.
|
|
141
|
+
const runConfig = { ...merged, github_token_file: merged.github_token_file ? expand(merged.github_token_file) : null };
|
|
142
|
+
let credential;
|
|
143
|
+
try { credential = await resolve(runConfig); } catch (e) { throw new Error(`${firstLine(e)}\n${NOTHING_WRITTEN}`); }
|
|
144
|
+
if (!credential?.token) throw new Error(`${credential?.why || "No GitHub credential could be resolved for a run."}\n${NOTHING_WRITTEN}`);
|
|
145
|
+
const identity = describeCredential(credential);
|
|
146
|
+
const runGh = ghFor(credential);
|
|
147
|
+
const perm = await runGh.repoPermissions(target.repo).catch(e => ({ error: firstLine(e) }));
|
|
148
|
+
if (!perm || perm.error) throw new Error(`GitHub cannot resolve ${target.repo} with the run credential (${identity})${perm?.error ? `: ${perm.error}` : ""}. Check that origin (${safeOrigin(target.origin)}) names a repository that credential can see, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
149
|
+
if (perm.archived) throw new Error(`${target.repo} is archived, so nothing can be pushed to it. Unarchive it or run gallivant init in another repository. ${NOTHING_WRITTEN}`);
|
|
150
|
+
if (!perm.push) throw new Error(`the run credential (${identity}) has no push permission on ${target.repo}. ${runConfig.github_token_file ? `Give the token in ${contract(runConfig.github_token_file)} Contents: write on ${target.repo}` : "Ask for write access, or fork it and point origin at the fork"}, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
151
|
+
// Repository role alone does not grant a classic token write scope, even when that account can push.
|
|
152
|
+
const scope = await repositoryWriteScopeCheck(runGh, credential);
|
|
153
|
+
if (!scope.ok) throw new Error(`${scope.detail}.\nFix: ${scope.fix}\n${NOTHING_WRITTEN}`);
|
|
154
|
+
|
|
155
|
+
// 6. Write. The repository is deduplicated by lowercased name whichever form each entry takes, so a second run,
|
|
156
|
+
// or a run from a clone whose remote spells the owner differently, never appends a duplicate. The string form is
|
|
157
|
+
// what a first use writes; a base branch is chosen with `start --base` or by hand, not here.
|
|
158
|
+
const repos = Array.isArray(candidate.repos) ? candidate.repos : [];
|
|
159
|
+
const known = repos.some(r => repoEntry(r)?.repo === target.repo);
|
|
160
|
+
if (!known) candidate.repos = [...repos, target.repo];
|
|
161
|
+
const changed = !existing || JSON.stringify(candidate) !== JSON.stringify(raw);
|
|
28
162
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
163
|
+
const owner = acquireCliMutation(home);
|
|
164
|
+
try {
|
|
165
|
+
// Probes await external tools. Another CLI writer may have changed the config meanwhile. Compare under the
|
|
166
|
+
// shared mutation lock, then retry all probes against the new settings rather than overwriting or trusting them.
|
|
167
|
+
const current = readJson(file, missing);
|
|
168
|
+
if (JSON.stringify(current) !== JSON.stringify(raw)) throw Object.assign(new Error(`Config changed during setup; rerun gallivant init. ${NOTHING_WRITTEN}`), { code: "INIT_CONFIG_CHANGED" });
|
|
169
|
+
mkdirSync(expand(merged.worktrees), { recursive: true, mode: 0o700 });
|
|
170
|
+
if (!existing) {
|
|
171
|
+
// wx also refuses a dangling symlink.
|
|
172
|
+
const fd = openSync(file, "wx", 0o600);
|
|
173
|
+
try { writeFileSync(fd, JSON.stringify(candidate, null, 2) + "\n"); } finally { closeSync(fd); }
|
|
174
|
+
} else if (changed) {
|
|
175
|
+
writeJsonAtomic(file, candidate, 0o600);
|
|
176
|
+
}
|
|
177
|
+
} finally { owner.release(); }
|
|
178
|
+
|
|
179
|
+
// 7. The same report `doctor` prints, so the operator reads one checklist rather than two.
|
|
180
|
+
const results = await doctor({ home });
|
|
181
|
+
return {
|
|
182
|
+
file, created: !existing, changed, login,
|
|
183
|
+
repo: target.repo, checkout: target.checkout, recorded: !known,
|
|
184
|
+
harness: selected, harnesses: Object.keys(merged.harness),
|
|
185
|
+
worktrees: expand(merged.worktrees),
|
|
186
|
+
preserved: existing ? Object.keys(raw).filter(k => k !== "repos") : [],
|
|
187
|
+
config: merged, doctor: results,
|
|
188
|
+
templates: { passport: templatePath("passport.gallivant.json"), sandbox: templatePath("claude-settings.json") },
|
|
189
|
+
};
|
|
34
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether this process was started by `npx` / `npm exec` rather than from an installed `gallivant` command. npm
|
|
194
|
+
* marks the child's environment; the `_npx` cache directory in the script path is the fallback for a shell that
|
|
195
|
+
* stripped the environment.
|
|
196
|
+
*/
|
|
197
|
+
export const viaNpx = (env = process.env, script = process.argv[1] || "") =>
|
|
198
|
+
env.npm_command === "exec" || env.npm_lifecycle_event === "npx" || /[\\/]_npx[\\/]/.test(script);
|
|
199
|
+
|
|
200
|
+
/** The command that runs this package again, spelled for how it was started this time. */
|
|
201
|
+
export const commandPrefix = npx => (npx ? `npx --yes ${PACKAGE_NAME}` : "gallivant");
|
|
202
|
+
|
|
203
|
+
export const FIRST_TASK = "Document how to run the existing tests in CONTRIBUTING.md. Use the commands already defined in the repository. Change documentation only.";
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What `gallivant init` prints: what it did, doctor's report, and what to run next. `report` is `renderDoctor`'s
|
|
207
|
+
* result. The `npx` paragraph says what `npx` did and did not do, because a one-command setup that silently looked
|
|
208
|
+
* like a global installation would send the operator looking for a `gallivant` command that is not there.
|
|
209
|
+
*/
|
|
210
|
+
export function renderInit(r, report, { npx = viaNpx(), script = process.argv[1] } = {}) {
|
|
211
|
+
const cmd = commandPrefix(npx);
|
|
212
|
+
const failed = r.doctor.filter(x => !x.ok).length;
|
|
213
|
+
const lines = [
|
|
214
|
+
`Package: ${PACKAGE_NAME} ${VERSION}${npx ? " (run by npx)" : ""}`,
|
|
215
|
+
`Running: ${script ? safeRealpath(script) : "(unknown)"}`,
|
|
216
|
+
`Config: ${contract(r.file)} (${r.created ? "created, mode 600" : r.changed ? `kept; ${r.preserved.length} existing key(s) preserved` : "unchanged"})`,
|
|
217
|
+
`Harness: ${r.harness ? `${r.harness} (${r.config.harness[r.harness]?.bin || "configured"})` : r.harnesses.join(", ") || "none"}`,
|
|
218
|
+
`Repository: ${r.repo} (${r.recorded ? "recorded in repos" : "already in repos; not duplicated"})`,
|
|
219
|
+
`Worktrees: ${r.worktrees}`,
|
|
220
|
+
"",
|
|
221
|
+
"Doctor:",
|
|
222
|
+
report.text,
|
|
223
|
+
"",
|
|
224
|
+
"Next:",
|
|
225
|
+
];
|
|
226
|
+
if (failed) lines.push(` Fix the ${failed} failing check(s) above, then run: ${cmd} doctor`);
|
|
227
|
+
else lines.push(` All checks passed. Confirm the reviewer setting in ${contract(r.file)} (default: the Codex GitHub review integration), then start a small task.`);
|
|
228
|
+
lines.push(
|
|
229
|
+
` First task (uses your ${r.harness} account for inference; --run authorizes implementation once the brief is ready):`,
|
|
230
|
+
` ${cmd} task ${JSON.stringify(FIRST_TASK)} --harness ${r.harness} --verify 'git diff --check' --run`,
|
|
231
|
+
` ${cmd} start`,
|
|
232
|
+
" Setup did not create a GitHub ruleset, install harness hooks, start a task or merge anything; doctor names each remaining step.",
|
|
233
|
+
` Guide: https://github.com/uchibeke/gallivant/blob/main/docs/install.md`,
|
|
234
|
+
);
|
|
235
|
+
if (npx) {
|
|
236
|
+
lines.push("",
|
|
237
|
+
`npx ran ${PACKAGE_NAME} on demand from npm's cache. Nothing was installed globally and no gallivant command was added to your PATH.`,
|
|
238
|
+
` Run it again any time: ${cmd} doctor`,
|
|
239
|
+
` Optional persistent install: npm install -g ${PACKAGE_NAME} # then use plain: gallivant doctor`);
|
|
240
|
+
}
|
|
241
|
+
lines.push("", "Packaged templates:", ` ${r.templates.passport}`, ` ${r.templates.sandbox}`);
|
|
242
|
+
return lines.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const safeRealpath = p => { try { return realpathSync(p); } catch { return p; } };
|
|
@@ -8,7 +8,9 @@ import { parseBlock, normalizePublicBody } from "../block.mjs";
|
|
|
8
8
|
import { repoFromUrl, remoteMatches, RESET_MARKER } from "../gh.mjs";
|
|
9
9
|
import { receiptHash } from "../deliverable.mjs";
|
|
10
10
|
import { rememberRepo, defaultHarness } from "../config.mjs";
|
|
11
|
-
import { isSessionFor } from "../harness.mjs";
|
|
11
|
+
import { isSessionFor, capabilitiesFor } from "../harness.mjs";
|
|
12
|
+
import { executionEpoch, reserveExecutionEpoch } from "../execution.mjs";
|
|
13
|
+
import { acquireRuntimeOwner } from "../daemon.mjs";
|
|
12
14
|
import { ensurePrdSnapshot, toPrdId } from "../prd.mjs";
|
|
13
15
|
import { nowIso, floorSecond } from "../time.mjs";
|
|
14
16
|
|
|
@@ -39,6 +41,33 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
39
41
|
if (existing && existing.pr !== pr.number && existing.pr !== null) throw new Error(`${id} already watches PR #${existing.pr}`);
|
|
40
42
|
const chosenHarness = harness || existing?.harness || block?.harness || defaultHarness(config);
|
|
41
43
|
if (!config.harness[chosenHarness]) throw new Error(`harness "${chosenHarness}" is not configured`);
|
|
44
|
+
const switching = existing && chosenHarness !== existing.harness;
|
|
45
|
+
// A daemon holds this lock for its entire lifetime. Claim it through the
|
|
46
|
+
// GitHub reset and registry write so an old-provider tick cannot dispatch
|
|
47
|
+
// after the settlement check, nor can a daemon boot midway through it.
|
|
48
|
+
const owner = switching ? (ctx.acquireRuntimeOwner ?? acquireRuntimeOwner)(ctx.home) : null;
|
|
49
|
+
let fenced = false, remoteMayBeNew = false, reservationAttempted = false;
|
|
50
|
+
try {
|
|
51
|
+
if (switching && JSON.stringify(state.get(id)) !== JSON.stringify(existing)) {
|
|
52
|
+
throw new Error("the watched entry changed while stopping the coordinator; inspect its latest run and retry the harness reset");
|
|
53
|
+
}
|
|
54
|
+
let nextEpoch = existing?.execution_epoch ?? 0;
|
|
55
|
+
if (switching) {
|
|
56
|
+
if (existing.handoff_pending && (existing.dispatch_stopped !== "harness-handoff-pending" || existing.handoff_pending.to !== chosenHarness || existing.handoff_pending.pr !== pr.number)) {
|
|
57
|
+
throw new Error("another provider handoff is pending; inspect and finish that reset before choosing a different harness");
|
|
58
|
+
}
|
|
59
|
+
if (existing.task || capabilitiesFor(existing.harness).transport !== "cli" || capabilitiesFor(chosenHarness).transport !== "cli") throw new Error("changing harness on an owned task or remote execution requires an explicit provider handoff; nothing was registered");
|
|
60
|
+
if (session) throw new Error("a cross-harness reset requires a fresh provider session; --session cannot prove ownership in the new execution ledger");
|
|
61
|
+
if (existing.last?.run && existing.last.exit == null) throw new Error("the previous run is not settled; wait for its outcome before changing harness");
|
|
62
|
+
const previous = ctx.executionFor?.(id, existing.harness);
|
|
63
|
+
const ledger = previous?.state?.();
|
|
64
|
+
const job = previous?.current?.();
|
|
65
|
+
if (job?.running || ledger?.stopped || ledger?.current && (ledger.current.id !== existing.last?.run || ledger.current.exit == null || ledger.current.execution?.collected !== true)) {
|
|
66
|
+
throw new Error("the previous provider still owns unsettled execution; reconcile it before changing harness");
|
|
67
|
+
}
|
|
68
|
+
const root = previous?.root;
|
|
69
|
+
nextEpoch = root ? executionEpoch({ root, taskId: id, entry: existing, provider: existing.harness, account: previous.account, runner: ctx.runnerFor?.(id) }) + 1 : nextEpoch + 1;
|
|
70
|
+
}
|
|
42
71
|
if (session && !worktree && !existing?.worktree) throw new Error("--session needs --worktree: both harnesses key sessions by the directory they ran in");
|
|
43
72
|
// The same rule the block is held to, so the flag and the block agree on what a session is. A value typed here
|
|
44
73
|
// reaches argv position 2 on the next fix round, right after `--resume` or `--name`, where `assertSafeArgv` matches
|
|
@@ -77,6 +106,7 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
77
106
|
const identity = { prd: blockPrd, harness: chosenHarness, session: chosenSession, branch: pr.headRefName };
|
|
78
107
|
const writesRecovery = isOurBranch(pr.headRefName) && blockPrd !== null;
|
|
79
108
|
const stale = writesRecovery && block && (block.harness !== chosenHarness || block.session !== chosenSession || normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body);
|
|
109
|
+
remoteMayBeNew = switching && block?.harness === chosenHarness && block?.session === chosenSession;
|
|
80
110
|
|
|
81
111
|
// Two durable GitHub writes, and the ORDER between them is the whole of this. Both are preconditions of the local
|
|
82
112
|
// reset, so either failing throws and leaves the registry untouched; what the order decides is what GitHub is left
|
|
@@ -104,6 +134,12 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
104
134
|
// it, so a recovery after `state.json` is lost rebuilds the iteration this command had just zeroed. An entry restored
|
|
105
135
|
// at `max_iterations` then stops with `reason: "iterations"` on its next event, having done no work in this round at
|
|
106
136
|
// all, and the operator is told the budget is spent on a PR they had just reset.
|
|
137
|
+
if (switching) {
|
|
138
|
+
state.put(id, { ...existing, state: STATES.needsHuman, reason: "harness-handoff-pending",
|
|
139
|
+
dispatch_stopped: "harness-handoff-pending",
|
|
140
|
+
handoff_pending: { from: existing.harness, to: chosenHarness, epoch: nextEpoch, pr: pr.number, at: nowIso() } });
|
|
141
|
+
fenced = true;
|
|
142
|
+
}
|
|
107
143
|
if (existing) {
|
|
108
144
|
try { await gh.comment(repo, pr.number, `Watching again from here.\n\n${RESET_MARKER}`); }
|
|
109
145
|
catch (e) { throw new Error(`cannot post the reset comment on ${pr.url}: ${e.message}. Nothing was reset; the entry keeps its ${existing.iteration} round${existing.iteration === 1 ? "" : "s"} and its state, and its recorded harness and session are unchanged. Fix the cause and run register again.`); }
|
|
@@ -111,14 +147,22 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
111
147
|
// The identity of record, written last of the two and still before anything reaches the registry, so a failure here
|
|
112
148
|
// leaves GitHub and the registry agreeing on the OLD identity rather than disagreeing.
|
|
113
149
|
if (writesRecovery && (!block || stale)) {
|
|
150
|
+
if (switching) remoteMayBeNew = true; // A failed reply cannot prove that the body write did not land.
|
|
114
151
|
try { const updated = await gh.setRecoveryMetadata(repo, pr.number, identity, { taskId: id }); pr.body = updated.body; }
|
|
115
152
|
// The message names the identity that survived, and only claims the PR agrees when the PR actually had a block to
|
|
116
153
|
// agree with: an existing entry on a PR with no block at all is a case where the registry has an identity and
|
|
117
154
|
// GitHub has none, which is what this write was about to fix and is not the same as the two of them agreeing.
|
|
118
|
-
catch (e) { throw new Error(`cannot write the gallivant block to ${pr.url}: ${e.message}. Nothing was registered${existing ? `: the entry keeps harness ${existing.harness} and session ${existing.session ?? "none"}${block ? ", which is still what the PR records" : ", and the PR still records no identity at all"}` : ""}. Fix the cause and run register again.`); }
|
|
155
|
+
catch (e) { throw new Error(`cannot write the gallivant block to ${pr.url}: ${e.message}. Nothing was registered${existing ? `: the entry keeps harness ${existing.harness} and session ${existing.session ?? "none"}${!switching ? block ? ", which is still what the PR records" : ", and the PR still records no identity at all" : ""}` : ""}.${switching ? " The old entry remains fenced because the remote write may have landed." : ""} Fix the cause and run register again.`); }
|
|
119
156
|
if (stale) log.info(`${id}: PR block updated to harness ${chosenHarness}, session ${chosenSession ?? "none"}`);
|
|
120
157
|
}
|
|
121
158
|
|
|
159
|
+
if (switching && ctx.executionFor) {
|
|
160
|
+
const previous = ctx.executionFor(id, existing.harness);
|
|
161
|
+
reservationAttempted = true;
|
|
162
|
+
(ctx.reserveExecutionEpoch ?? reserveExecutionEpoch)({ root: previous.root, taskId: id, provider: chosenHarness,
|
|
163
|
+
account: config.harness[chosenHarness]?.account || "operator", epoch: nextEpoch, pr: pr.number });
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
// `base` on the entry is the branch this PR targets, and it comes from the PR itself. A registered PR is one a person
|
|
123
167
|
// opened, so the operator's config has no say in where it was aimed: `baseRefName` is the truth, and it is what the
|
|
124
168
|
// daemon merges in before every later round. An existing entry keeps whatever it had unless the PR says otherwise,
|
|
@@ -134,6 +178,8 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
134
178
|
const since = floorSecond(existing ? nowIso() : pr.createdAt);
|
|
135
179
|
const entry = {
|
|
136
180
|
...base, public_since: existing?.public_since || publicSince, harness: chosenHarness, session: chosenSession, worktree: dir, pr: pr.number, url: pr.url, base: prBase ?? base.base ?? null,
|
|
181
|
+
...(switching ? { execution_epoch: nextEpoch, execution_handoff: { from: existing.harness, to: chosenHarness, previousRun: existing.last?.run ?? null, at: nowIso() },
|
|
182
|
+
dispatch_stopped: null, handoff_pending: null } : {}),
|
|
137
183
|
// `retry` goes with `reason`: it counts the consecutive failures of one GitHub write the tick declines a step for,
|
|
138
184
|
// and a reset is the operator saying the cause is dealt with. Left behind, an entry reset after a `stuck` stop would
|
|
139
185
|
// carry four failures into its next one and stop again on the first write that failed.
|
|
@@ -165,6 +211,7 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
165
211
|
updated_at: nowIso(),
|
|
166
212
|
};
|
|
167
213
|
state.put(id, entry);
|
|
214
|
+
fenced = false;
|
|
168
215
|
// The snapshot the completion gate reads, taken the same way `reconcile` takes it. Without it `prdFor` returns
|
|
169
216
|
// nothing, the gate logs "no PRD snapshot" and the PR can be marked ready with no acceptance criterion evaluated.
|
|
170
217
|
// After the entry is stored, so a GitHub read that fails leaves a watched PR rather than an aborted registration.
|
|
@@ -174,6 +221,13 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
174
221
|
if (rememberRepo(repo, config.home)) await gh.ensureLabels(repo).catch(e => log.warn(`${repo}: labels not created: ${e.message}`));
|
|
175
222
|
log.info(`${id}: ${existing ? "reset and " : ""}watching ${pr.url} with ${chosenHarness}${chosenSession ? " (resume)" : " (fresh session on first fix)"}`);
|
|
176
223
|
return { id, pr: pr.number, worktree: dir, harness: chosenHarness, session: chosenSession };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// Before any possible identity write or ledger reservation, a failed reset
|
|
226
|
+
// may restore the exact prior entry. Once either may have landed, retain
|
|
227
|
+
// the dispatch fence so a restarted daemon cannot launch the old harness.
|
|
228
|
+
if (fenced && !remoteMayBeNew && !reservationAttempted) state.put(id, existing);
|
|
229
|
+
throw error;
|
|
230
|
+
} finally { owner?.release(); }
|
|
177
231
|
}
|
|
178
232
|
|
|
179
233
|
/**
|
package/lib/context.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { makeLog } from "./log.mjs";
|
|
|
14
14
|
import { makeRunner } from "./run.mjs";
|
|
15
15
|
import { ensureDir, gallivantHome, readText, writeText } from "./fs.mjs";
|
|
16
16
|
import { aportDirFor, guardrailMode } from "./aport.mjs";
|
|
17
|
-
import { makeExecution, assertExecutionDependency } from "./execution.mjs";
|
|
17
|
+
import { makeExecution, executionEpoch, assertExecutionDependency } from "./execution.mjs";
|
|
18
18
|
import { makeAgentsTransport } from "./openai_agents.mjs";
|
|
19
19
|
import { capabilitiesFor } from "./harness.mjs";
|
|
20
20
|
import { createLearningService } from "./learning.mjs";
|
|
@@ -67,7 +67,9 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
|
|
|
67
67
|
executionFor: (id, provider) => {
|
|
68
68
|
const root = join(home, "runs", idSlug(id)), selected = config.harness[provider];
|
|
69
69
|
const account = selected?.account || "operator";
|
|
70
|
-
|
|
70
|
+
const entry = state.get(id), runner = capabilitiesFor(provider).transport === "remote" ? null : makeRunner(root);
|
|
71
|
+
const epoch = executionEpoch({ root, taskId: id, entry, provider, account, runner });
|
|
72
|
+
return makeExecution({ root, taskId: id, provider, account, ledgerEpoch: epoch, ledgerFile: join(root, epoch ? `execution-${epoch}.json` : "execution.json"),
|
|
71
73
|
...(provider === "openai-agents" ? { transport: makeAgentsTransport({ root, taskId: id, account, config: selected }), capabilities: { ...capabilitiesFor(provider), models: [selected?.model] } } : {}),
|
|
72
74
|
...(provider === "codex-cloud" ? { transport: makeCloudTransport({ root, taskId: id, account, config: selected, verifyRemote: async input => {
|
|
73
75
|
if (await gh.refSha(input.repo, input.sources.inputBranch) !== input.inputHead || await gh.refSha(input.repo, input.branch) !== input.inputHead) throw new Error("Cloud remote input revision moved; reconcile before dispatch");
|
|
@@ -76,7 +78,7 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
|
|
|
76
78
|
}
|
|
77
79
|
} }), capabilities: { ...capabilitiesFor(provider), environments: [selected?.environment] } } : {}),
|
|
78
80
|
legacySessionId: state.get(id)?.harness === provider ? state.get(id).session : null,
|
|
79
|
-
runner
|
|
81
|
+
runner, limits: config.execution });
|
|
80
82
|
},
|
|
81
83
|
// The APort API key the Claude install recorded, if any, so hosted gate calls count against the same account.
|
|
82
84
|
aportApiKey: guardrailMode(aportDirFor(config.harness?.claude?.settings)).apiKey,
|
package/lib/execution.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Versioned durable transport boundary. No transport implements a review loop.
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { existsSync, rmSync } from "node:fs";
|
|
3
|
+
import { existsSync, readdirSync, rmSync } from "node:fs";
|
|
4
4
|
import { randomUUID, createHash } from "node:crypto";
|
|
5
5
|
import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
|
|
6
6
|
import { acquireProcessLock } from "./daemon.mjs";
|
|
@@ -31,6 +31,82 @@ const preparationFingerprint = input => {
|
|
|
31
31
|
};
|
|
32
32
|
export const executionRunner = (ctx, id, provider = ctx.state.get(id)?.harness) => ctx.executionFor && provider ? ctx.executionFor(id, provider) : ctx.runnerFor(id);
|
|
33
33
|
|
|
34
|
+
const epochFile = (root, epoch) => join(root, epoch ? `execution-${epoch}.json` : "execution.json");
|
|
35
|
+
const validEpoch = epoch => Number.isSafeInteger(epoch) && epoch >= 0 && epoch <= 1000;
|
|
36
|
+
const latestLedger = root => {
|
|
37
|
+
const epochs = readdirSync(root, { withFileTypes: true }).filter(file => file.isFile() && /^execution-[1-9][0-9]*\.json$/.test(file.name))
|
|
38
|
+
.map(file => Number(file.name.slice(10, -5)));
|
|
39
|
+
if (!epochs.length) return null;
|
|
40
|
+
const epoch = Math.max(...epochs);
|
|
41
|
+
if (!validEpoch(epoch)) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch; restore its matching ledger backup");
|
|
42
|
+
return { epoch, ledger: readJson(epochFile(root, epoch)) };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Reserve a new provider epoch before changing the registry or starting a run.
|
|
46
|
+
// Its empty ledger is also the durable route recovered after state.json loss.
|
|
47
|
+
export function reserveExecutionEpoch({ root, taskId, provider, account, epoch, pr = null }) {
|
|
48
|
+
if (!validEpoch(epoch) || epoch === 0) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch");
|
|
49
|
+
const file = epochFile(root, epoch);
|
|
50
|
+
if (existsSync(file)) {
|
|
51
|
+
const saved = readJson(file);
|
|
52
|
+
if (saved?.version !== EXECUTION_VERSION || saved.taskId !== taskId || saved.provider !== provider || saved.account !== account ||
|
|
53
|
+
saved.handoff && (saved.handoff.epoch !== epoch || pr != null && saved.handoff.pr !== pr)) {
|
|
54
|
+
throw executionError("EXECUTION_OWNERSHIP", "reserved handoff ledger belongs to a different execution; restore its matching backup");
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
writeJsonAtomic(file, { version: EXECUTION_VERSION, taskId, provider, account, operations: {}, current: null, stopped: null,
|
|
59
|
+
...(pr != null ? { handoff: { epoch, pr } } : {}) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A registered PR may change local harnesses only after its previous run has
|
|
63
|
+
// settled. Keep that provider's execution.json intact and give the new harness
|
|
64
|
+
// a separate ledger while run directories remain under the same task root.
|
|
65
|
+
// The legacy inference repairs registrations made before the epoch was saved:
|
|
66
|
+
// it is deliberately limited to the exact settled reset, not an arbitrary
|
|
67
|
+
// provider mismatch or a remote task whose ownership remains unresolved.
|
|
68
|
+
export function executionEpoch({ root, taskId, entry, provider, account, runner }) {
|
|
69
|
+
if (entry?.execution_epoch != null) {
|
|
70
|
+
if (!validEpoch(entry.execution_epoch)) throw executionError("EXECUTION_VERSION", "invalid execution handoff epoch; restore its matching registry backup");
|
|
71
|
+
}
|
|
72
|
+
const latest = existsSync(root) ? latestLedger(root) : null;
|
|
73
|
+
if (latest) {
|
|
74
|
+
const pending = entry?.handoff_pending;
|
|
75
|
+
const oldEpoch = entry?.execution_epoch ?? 0;
|
|
76
|
+
// A reset interrupted after reserving its new empty ledger keeps the old
|
|
77
|
+
// registry entry fenced. Let a retry inspect that exact old ledger, while
|
|
78
|
+
// `dispatch_stopped` prevents a daemon from launching the old provider.
|
|
79
|
+
if (entry?.dispatch_stopped === "harness-handoff-pending" && pending?.from === provider &&
|
|
80
|
+
pending.to === latest.ledger?.provider && pending.epoch === latest.epoch && pending.pr === entry.pr &&
|
|
81
|
+
oldEpoch === latest.epoch - 1 && latest.ledger?.taskId === taskId && latest.ledger.handoff?.pr === entry.pr &&
|
|
82
|
+
latest.ledger.current === null && !latest.ledger.stopped && Object.keys(latest.ledger.operations ?? {}).length === 0) {
|
|
83
|
+
return oldEpoch;
|
|
84
|
+
}
|
|
85
|
+
if (entry?.execution_epoch != null && entry.execution_epoch !== latest.epoch) throw executionError("EXECUTION_OWNERSHIP", "registry and latest handoff ledger disagree; reconcile the interrupted reset before dispatch");
|
|
86
|
+
if (latest.ledger?.version !== EXECUTION_VERSION || latest.ledger.taskId !== taskId || latest.ledger.provider !== provider || latest.ledger.account !== account ||
|
|
87
|
+
latest.ledger.handoff && (latest.ledger.handoff.epoch !== latest.epoch || latest.ledger.handoff.pr !== entry?.pr)) {
|
|
88
|
+
throw executionError("EXECUTION_OWNERSHIP", "latest handoff ledger belongs to another provider/account; do not reopen an older session");
|
|
89
|
+
}
|
|
90
|
+
return latest.epoch;
|
|
91
|
+
}
|
|
92
|
+
if (entry?.execution_epoch != null) {
|
|
93
|
+
if (entry.execution_epoch > 0) throw executionError("EXECUTION_OWNERSHIP", "recorded handoff ledger is missing; restore its matching backup before dispatch");
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
const old = readJson(epochFile(root, 0));
|
|
97
|
+
if (!old || old.provider === provider && old.account === account) return 0;
|
|
98
|
+
const prior = old.current?.execution;
|
|
99
|
+
const current = runner?.current();
|
|
100
|
+
const exactReset = entry?.pr && entry.session === null &&
|
|
101
|
+
entry.last?.run === old.current?.id && entry.last?.exit === old.current?.exit &&
|
|
102
|
+
entry.execution?.provider === old.provider && entry.execution?.account === old.account &&
|
|
103
|
+
old.taskId === taskId && old.account === "operator" && account === "operator" &&
|
|
104
|
+
prior?.transport === "cli" && prior.provider === old.provider && prior.collected === true &&
|
|
105
|
+
["succeeded", "failed"].includes(prior.providerState) && !old.stopped &&
|
|
106
|
+
current?.id === old.current.id && current.running === false;
|
|
107
|
+
return exactReset ? 1 : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
34
110
|
// A sessionless resume is allowed only for an owned, proven unsubmitted first
|
|
35
111
|
// launch. Older refusal entries can recover from the private transport proof;
|
|
36
112
|
// a registry reason or an absent session alone never authorizes a new session.
|
|
@@ -53,9 +129,9 @@ export function assertExecutionDependency(taskService) {
|
|
|
53
129
|
* Methods accept scoped immutable input plus AbortSignal and return bounded JSON.
|
|
54
130
|
* A remote adapter never needs a local command, PID, or inherited environment.
|
|
55
131
|
*/
|
|
56
|
-
export function makeExecution({ root, taskId, provider, account = "operator", transport = null, runner = null, legacySessionId = null, capabilities = capabilitiesFor(provider), clock = Date, limits = {}, secrets = [], jitter = Math.random }) {
|
|
132
|
+
export function makeExecution({ root, taskId, provider, account = "operator", transport = null, runner = null, legacySessionId = null, ledgerFile = join(root, "execution.json"), ledgerEpoch = 0, capabilities = capabilitiesFor(provider), clock = Date, limits = {}, secrets = [], jitter = Math.random }) {
|
|
57
133
|
const bounds = { ...EXECUTION_LIMITS, ...limits };
|
|
58
|
-
const file =
|
|
134
|
+
const file = ledgerFile;
|
|
59
135
|
const collectionRejection = s => {
|
|
60
136
|
const job = s.current, ex = job?.execution;
|
|
61
137
|
if (!transport || !ownedRemote(ex) || !["succeeded", "failed"].includes(ex.terminalState)) return null;
|
|
@@ -246,7 +322,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
246
322
|
if (s.learningContinuity?.pending && (method !== "submit" || input.sessionId)) throw executionError("EXECUTION_CONTINUATION", "retired advice requires a new provider session, not continuation");
|
|
247
323
|
if (s.stopped) throw executionError("EXECUTION_STOPPED", `dispatch stopped: ${s.stopped}; late results remain quarantined`);
|
|
248
324
|
if (s.current && executionLive(s.current)) throw executionError("BUSY", "previous provider work is not settled");
|
|
249
|
-
const legacy = !transport && !s.current ? runner.current() : null;
|
|
325
|
+
const legacy = !transport && !s.current && ledgerEpoch === 0 ? runner.current() : null;
|
|
250
326
|
if (legacy?.running) throw executionError("BUSY", "legacy local writer is still running");
|
|
251
327
|
const ownedSession = transport ? s.current?.execution.sessionId : s.current
|
|
252
328
|
? localSession(s, s.current.id, s.current.execution.sessionId)
|
|
@@ -313,9 +389,9 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
313
389
|
const s = read();
|
|
314
390
|
if (!transport) {
|
|
315
391
|
const job = runner.current();
|
|
316
|
-
if (job && (!s.current || s.current
|
|
392
|
+
if (job && ((ledgerEpoch === 0 && !s.current) || s.current?.id === job.id)) return { ...job, ...(s.current ? { execution: s.current.execution,
|
|
317
393
|
...(s.current.execution.quarantined ? { exit: s.current.exit, endedAt: s.current.endedAt } : {}) } : {}) };
|
|
318
|
-
if (!s.current) return job; //
|
|
394
|
+
if (!s.current) return ledgerEpoch === 0 ? job : null; // Only the original epoch may adopt a legacy local run.
|
|
319
395
|
}
|
|
320
396
|
return s.current ? { ...s.current, running: executionLive(s.current) } : null;
|
|
321
397
|
};
|
|
@@ -470,7 +546,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
470
546
|
if (!s.stopped || reason !== "timeout") s.stopped = reason;
|
|
471
547
|
// An upgrade may have an owned local writer but no boundary submission.
|
|
472
548
|
// Preserve its identity for stop recovery without inventing a paid intent.
|
|
473
|
-
if (!s.current && !transport) {
|
|
549
|
+
if (!s.current && !transport && ledgerEpoch === 0) {
|
|
474
550
|
const job = runner.current();
|
|
475
551
|
// Completed pre-boundary runs need only the future-dispatch fence.
|
|
476
552
|
// Adopt live/unfinished writers for the existing shutdown recovery.
|
|
@@ -519,7 +595,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
|
|
|
519
595
|
});
|
|
520
596
|
}
|
|
521
597
|
return {
|
|
522
|
-
root, provider, account, capabilities, state: read, current, reconcile, collect, cancel,
|
|
598
|
+
root, provider, account, ledgerEpoch, capabilities, state: read, current, reconcile, collect, cancel,
|
|
523
599
|
recover: async ({ operationId, authorized = false } = {}) => {
|
|
524
600
|
const pending=await locked(async s => {
|
|
525
601
|
const job=s.current,ex=job?.execution;
|
package/lib/fs.mjs
CHANGED
|
@@ -14,10 +14,17 @@ export const HOME = homedir();
|
|
|
14
14
|
* so a decision can be traced to the release that asked for it. Unknown rather than fatal if the file is missing:
|
|
15
15
|
* a version string is never worth failing a run over.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
|
-
try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"))
|
|
19
|
-
catch { return
|
|
17
|
+
const PACKAGE = (() => {
|
|
18
|
+
try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")); }
|
|
19
|
+
catch { return {}; }
|
|
20
20
|
})();
|
|
21
|
+
export const VERSION = PACKAGE.version || "unknown";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The published npm name, read from the same package.json. `init` prints the `npx` and `npm install -g` commands
|
|
25
|
+
* that run this package again, and a name typed into a command module would drift from the one npm publishes.
|
|
26
|
+
*/
|
|
27
|
+
export const PACKAGE_NAME = PACKAGE.name || "@uchibeke/gallivant";
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* When the code a process is running was last changed, as the newest mtime across `lib/`. Paired with `VERSION`
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { COMPLETION_SCHEMA } from "./task_completion.mjs";
|
|
3
3
|
export { COMPLETION_SCHEMA, COMPLETION_CAPABILITIES, coordinatorEntryActive } from "./task_completion.mjs";
|
|
4
4
|
export const DELIVERY_VERSION = 1;
|
|
5
|
-
export const CORE_VERSION = "0.2.
|
|
5
|
+
export const CORE_VERSION = "0.2.1";
|
|
6
6
|
// One byte limit for the built APort MCP resource, package and runtime reader.
|
|
7
7
|
export const MAX_TASK_UI_BYTES = 2_000_000;
|
|
8
8
|
export const deliveryError = (code, message) => Object.assign(new Error(message), { code, version: DELIVERY_VERSION });
|