@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.
@@ -1,34 +1,245 @@
1
- import { mkdirSync, openSync, closeSync, writeFileSync, existsSync, lstatSync } from "node:fs";
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 { gallivantHome, expand, templatePath } from "../fs.mjs";
4
- import { discoverHarnesses, validate, merge } from "../config.mjs";
5
- import { makeGh } from "../gh.mjs";
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
- // Explicit setup only; never rewrite an existing config or touch global harness
9
- // settings, credentials or repository permissions.
10
- export async function init({ home = gallivantHome(), harness = "codex", login, worktrees,
11
- discover = discoverHarnesses, user = () => makeGh({ log: silentLog }).user() } = {}) {
12
- if (!["codex", "claude"].includes(harness)) throw new Error("init supports --harness codex or claude; configure other adapters explicitly after checking docs/install.md");
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 = join(home, "config.json");
15
- if (existsSync(file)) throw new Error(`Config already exists at ${file}; edit it or run gallivant doctor. Nothing was overwritten.`);
16
- for (let dir = home; ; dir = dirname(dir)) {
17
- if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) throw new Error(`Setup directory must not be a symlink: ${dir}`);
18
- if (dirname(dir) === dir) break;
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 selected = discover(home)[harness];
21
- if (!selected) throw new Error(`${harness} is not on PATH. Install it and sign in, then retry gallivant init --harness ${harness}`);
22
- const ownLogin = login || await user();
23
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(ownLogin || "")) throw new Error("Sign in with gh auth login before running init");
24
- const config = { trusted_authors: [ownLogin], worktrees: expand(worktrees || join(dirname(home), ".gallivant-worktrees")),
25
- harness: { [harness]: selected }, notify: null, github_token_file: null, repos: [] };
26
- const problems = validate(merge(config, home), home);
27
- if (problems.length) throw new Error(problems.join("; "));
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
- mkdirSync(config.worktrees, { recursive: true, mode: 0o700 });
30
- // wx also refuses a dangling symlink and protects concurrent invocations.
31
- const fd = openSync(file, "wx", 0o600);
32
- try { writeFileSync(fd, JSON.stringify(config, null, 2) + "\n"); } finally { closeSync(fd); }
33
- return { file, harness, config, templates: { passport: templatePath("passport.gallivant.json"), sandbox: templatePath("claude-settings.json") } };
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
- return makeExecution({ root, taskId: id, provider, account,
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: capabilitiesFor(provider).transport === "remote" ? null : makeRunner(root), limits: config.execution });
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 = join(root, "execution.json");
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.id === job.id)) return { ...job, ...(s.current ? { execution: s.current.execution,
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; // Legacy local runs retain their exact identity.
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
- export const VERSION = (() => {
18
- try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version || "unknown"; }
19
- catch { return "unknown"; }
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.0";
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 });