@uchibeke/gallivant 0.0.0-stage → 0.2.0
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/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +201 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +257 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +70 -0
- package/docs/agent-setup.md +214 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +620 -0
- package/docs/config.md +430 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +338 -0
- package/docs/launch.md +42 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +140 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +457 -0
- package/lib/commands/init.mjs +34 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +195 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +100 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +631 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +133 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1635 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- package/templates/tracker/tracker.js +721 -0
package/lib/control.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Local operator requests. UI and CLI enqueue; the daemon is the only consumer.
|
|
2
|
+
// One outstanding request per entry avoids competing resumes and needs no second runner.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { needsAgentResume } from "./network_recovery.mjs";
|
|
5
|
+
import { resolveHandle, STATES } from "./state.mjs";
|
|
6
|
+
import { readDaemon, assertDaemonUsable } from "./daemon.mjs";
|
|
7
|
+
import { nowIso } from "./time.mjs";
|
|
8
|
+
import { NEEDS_LABEL, READY_LABEL } from "./gh.mjs";
|
|
9
|
+
import { adapterFor } from "./harness.mjs";
|
|
10
|
+
import { executionRunner, unsubmittedInitialDispatch, PROVIDER_STATES } from "./execution.mjs";
|
|
11
|
+
import { requestRunCancellation } from "./run.mjs";
|
|
12
|
+
import { executionResultCheckpoint, acquirePublicationLock } from "./execution_result.mjs";
|
|
13
|
+
import { controlAvailability, controlPending } from "./control_contract.mjs";
|
|
14
|
+
export { controlAvailability, controlPending } from "./control_contract.mjs";
|
|
15
|
+
|
|
16
|
+
export const CONTROL_LABELS = { recheck: "Recheck PR", resume: "Resume agent", message: "Send message", cancel: "Cancel task" };
|
|
17
|
+
export const CONTROL_ACTIONS = Object.keys(CONTROL_LABELS);
|
|
18
|
+
export const MAX_NOTE = 4000; // resumePrompt's quote limit; never silently truncate a message.
|
|
19
|
+
export const lastMessage = entry => [entry.control, ...(entry.control_history || []).slice().reverse()].find(c => c?.action === "message" && c.note) ?? null;
|
|
20
|
+
export const cancellationStatus = answer => answer.confirmed
|
|
21
|
+
? { reason: "cancelled", message: "Cancellation confirmed." }
|
|
22
|
+
: answer.terminal
|
|
23
|
+
? { reason: "cancelled", message: "Further dispatch stopped; provider execution had already completed." }
|
|
24
|
+
: { reason: "cancellation-requested", message: "Further dispatch stopped; provider cancellation is unconfirmed. Remote execution may continue." };
|
|
25
|
+
|
|
26
|
+
// Never expose subprocess output (which can contain credentials) in the tracker.
|
|
27
|
+
export function controlFailure(error) {
|
|
28
|
+
const text = error.message || "";
|
|
29
|
+
if (error.code === "AGENT_RESUME_REQUIRED") return "The agent stopped with an execution error. Use Resume agent after fixing connectivity or credentials. Recheck PR cannot resume the agent or clear this failure.";
|
|
30
|
+
if (error.code === "EXECUTION_OWNERSHIP") return "The saved request no longer owns the current run. Inspect its execution identity before submitting a new request.";
|
|
31
|
+
if (error.code === "EXECUTION_RESULT_BLOCKED") return "Result publication is blocked. Inspect result-application.json, result.json and any verification.json, then explicitly resume the same session for correction; PR recheck cannot retry publication.";
|
|
32
|
+
if (/PR is no longer open|closed or merged/.test(text)) return "The PR is closed or merged. Start a follow-up PRD for further changes.";
|
|
33
|
+
if (/resnapshot|carried files differ|brief.*(changed|missing)|snapshot/i.test(text)) return "The brief or carried sources changed. Review gallivant why, then restore them or explicitly use gallivant resume --resnapshot to accept the changes.";
|
|
34
|
+
if (/already has a run|still needs settling/.test(text)) return "The previous run has not settled yet. Wait for it to stop, then retry. Your message is saved below.";
|
|
35
|
+
if (/no session/.test(text)) return "No resumable session or proven unsubmitted launch is available. Inspect execution ownership and the run log before retrying. Your message is saved below.";
|
|
36
|
+
if (/older than/.test(text)) return "The configured harness is too old. Run gallivant doctor --probe, update that binary, then retry.";
|
|
37
|
+
if (error.code === "NO_CREDENTIAL") return error.reason === "configured-token-file-unavailable"
|
|
38
|
+
? "The configured github_token_file is unavailable. Restore that private token file, or set github_token_file to null and restart the daemon before retrying."
|
|
39
|
+
: "GitHub authentication is unavailable. Authenticate with gh auth login, then retry.";
|
|
40
|
+
if (error.code === "NO_IDENTITY") return "Git has no commit identity. Configure your Git name and email, then retry.";
|
|
41
|
+
return "Could not apply the request. Inspect gallivant why and the daemon log. Your last message is saved below for editing and resending.";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function requestControl(ctx, { handle, action, note = "", interrupt = false, requestId = null }) {
|
|
45
|
+
if (!CONTROL_ACTIONS.includes(action)) throw new Error("Unknown run action.");
|
|
46
|
+
if (typeof note !== "string" || note.length > MAX_NOTE) throw new Error(`Messages must be at most ${MAX_NOTE} characters.`);
|
|
47
|
+
if (action === "message" && !note.trim()) throw new Error("Write a message before sending it.");
|
|
48
|
+
if (action !== "message" && note.trim()) throw new Error("Use Send message to deliver a message to the agent.");
|
|
49
|
+
if (typeof interrupt !== "boolean" || (interrupt && action !== "message")) throw new Error("Interrupt is available only when sending a message.");
|
|
50
|
+
const id = resolveHandle(ctx.state.entries(), handle);
|
|
51
|
+
if (requestId !== null && (typeof requestId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(requestId))) throw new Error("Invalid control request identity.");
|
|
52
|
+
const saved = ctx.state.get(id);
|
|
53
|
+
const previous = requestId && [saved?.control, ...(saved?.control_history || [])].find(c => c?.id === requestId);
|
|
54
|
+
if (requestId && previous?.id === requestId) {
|
|
55
|
+
if (previous.action !== action || previous.note !== note.trim() || previous.interrupt !== interrupt) throw new Error("Control identity belongs to different input.");
|
|
56
|
+
return { entry: id, ...previous };
|
|
57
|
+
}
|
|
58
|
+
// UI and CLI must refuse before saving a request an old daemon cannot consume.
|
|
59
|
+
assertDaemonUsable(ctx.coordinatorHealth ? ctx.coordinatorHealth() : readDaemon(ctx.home));
|
|
60
|
+
const request = { id: requestId || randomUUID(), action, note: note.trim(), interrupt, status: "queued", at: nowIso(ctx.clock) };
|
|
61
|
+
const runner = executionRunner(ctx, id);
|
|
62
|
+
const exclusion = action === "cancel" ? acquirePublicationLock(ctx, id, { execution: runner }) : null;
|
|
63
|
+
try { ctx.state.update(id, entry => {
|
|
64
|
+
if (entry.state === STATES.done) throw new Error("This task is already closed or merged.");
|
|
65
|
+
if (controlPending(entry.control)) throw new Error("A request is already queued or being applied. Wait for its result.");
|
|
66
|
+
if (action === "recheck" && !entry.pr) throw new Error("This entry has no PR to recheck.");
|
|
67
|
+
if (action === "recheck" && needsAgentResume(entry)) throw Object.assign(new Error(controlFailure({ code: "AGENT_RESUME_REQUIRED" })), { code: "AGENT_RESUME_REQUIRED" });
|
|
68
|
+
const run = runner.current();
|
|
69
|
+
const available = controlAvailability(entry, run, runner.capabilities, { resumable: Boolean(entry.session || unsubmittedInitialDispatch(entry, runner)) });
|
|
70
|
+
if (["resume", "message"].includes(action) && entry.dispatch_stopped) throw new Error("Further dispatch is stopped; reconcile cancellation before authorizing another task.");
|
|
71
|
+
if (action === "recheck" && executionResultCheckpoint(runner, run)?.phase === "blocked") throw Object.assign(new Error("Result publication needs an explicit corrective resume."), { code: "EXECUTION_RESULT_BLOCKED" });
|
|
72
|
+
if (interrupt && runner.capabilities?.controls?.interrupt === false) throw new Error("This provider cannot safely interrupt; cancel stops further dispatch and requires remote confirmation.");
|
|
73
|
+
if (interrupt && !available.interrupt) throw new Error("This task cannot currently be interrupted.");
|
|
74
|
+
const active = run?.running && run.id === entry.last?.run;
|
|
75
|
+
if (!["recheck", "cancel"].includes(action) && !entry.session && !active && !unsubmittedInitialDispatch(entry, runner)) throw new Error("This entry has no session to resume or proven unsubmitted launch. Inspect its execution ownership before retrying.");
|
|
76
|
+
// A new fixed action must not erase a failed message. Keep a small local history.
|
|
77
|
+
return { control: { ...request, ...(action === "resume" && run?.execution?.providerState === "status-unknown" ? { recoveryRun: run.id } : {}) }, ...(action === "cancel" ? { dispatch_stopped: "cancellation-requested" } : {}), control_history: [...(entry.control_history || []), ...(entry.control ? [entry.control] : [])].slice(-20) };
|
|
78
|
+
}); } finally { exclusion?.release(); }
|
|
79
|
+
ctx.log.info(`${id}: operator ${action} queued (${request.id})`);
|
|
80
|
+
return { entry: id, ...request };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Optional stop-and-resume steering, using the existing runner and session parser. */
|
|
84
|
+
export async function cancelControl(ctx, id) {
|
|
85
|
+
const entry = ctx.state.get(id), request = entry?.control;
|
|
86
|
+
if (request?.status !== "queued" || request.action !== "cancel") return false;
|
|
87
|
+
const runner = executionRunner(ctx, id), run = runner.current();
|
|
88
|
+
let answer = { confirmed: false };
|
|
89
|
+
try { answer = runner.cancel ? await runner.cancel() : run?.running ? requestRunCancellation(runner, run.id) : { confirmed: true }; }
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (/already running|lock recovery/.test(error.message)) {
|
|
92
|
+
ctx.state.update(id, { control: { ...request, message: "Cancellation is queued behind provider reconciliation; further dispatch is stopped." } });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
ctx.log.warn(`${id}: cancellation requested, confirmation unavailable (${error.code || "control failed"})`);
|
|
96
|
+
}
|
|
97
|
+
const status = cancellationStatus(answer);
|
|
98
|
+
ctx.state.update(id, { state: STATES.needsHuman, reason: status.reason, network_retry: null, control: { ...request, status: "applied", stop_run: run?.id ?? null, message: status.message, finished_at: nowIso(ctx.clock) } });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// An explicit resume of an ambiguous remote launch means observing the owned
|
|
103
|
+
// input, not sending a second turn. Persist that meaning before recovery so a
|
|
104
|
+
// crash after adoption cannot turn the same request into a continuation.
|
|
105
|
+
export async function recoverControl(ctx, id) {
|
|
106
|
+
const entry = ctx.state.get(id), request = entry?.control;
|
|
107
|
+
if (request?.status !== "queued" || request.action !== "resume" || entry.dispatch_stopped) return false;
|
|
108
|
+
try {
|
|
109
|
+
const runner = executionRunner(ctx, id), run = runner.current();
|
|
110
|
+
const canRecover = runner.recover && runner.capabilities?.transport === "remote" && run && run.id === entry.last?.run;
|
|
111
|
+
if (request.recoveryRun && (!canRecover || request.recoveryRun !== run.id)) throw Object.assign(new Error("Recovery control no longer owns its saved run"), { code: "EXECUTION_OWNERSHIP" });
|
|
112
|
+
if (!canRecover || !request.recoveryRun && run.execution?.providerState !== "status-unknown") return false;
|
|
113
|
+
let retained = false;
|
|
114
|
+
ctx.state.update(id, current => {
|
|
115
|
+
if (current.control?.id !== request.id || current.control.status !== "queued") return {};
|
|
116
|
+
if (current.dispatch_stopped || current.last?.run !== run.id) throw Object.assign(new Error("Recovery control no longer owns its saved run"), { code: "EXECUTION_OWNERSHIP" });
|
|
117
|
+
retained = true;
|
|
118
|
+
return { control: { ...current.control, recoveryRun: run.id } };
|
|
119
|
+
});
|
|
120
|
+
if (!retained) return true;
|
|
121
|
+
const observed = await runner.recover({ authorized: true, operationId: `control-recovery:${request.id}` });
|
|
122
|
+
const ex = observed?.execution;
|
|
123
|
+
// Earlier input acceptance does not prove this observation recovered access.
|
|
124
|
+
const recovered = ex?.acceptance === "accepted" && PROVIDER_STATES.includes(ex.providerState) && !["status-unknown", "cancellation-requested"].includes(ex.providerState);
|
|
125
|
+
ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...current.control, status: recovered ? "applied" : "failed", finished_at: nowIso(ctx.clock), message: recovered ? "Recovered observation of the existing provider input; no new input was sent." : "The bounded observation retry did not resolve the existing input. Inspect its saved evidence before retrying; no new input was sent." } } : {});
|
|
126
|
+
} catch (error) {
|
|
127
|
+
ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...current.control, status: "failed", finished_at: nowIso(ctx.clock), message: controlFailure(error) } } : {});
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function interruptControl(ctx, id) {
|
|
133
|
+
const entry = ctx.state.get(id), request = entry?.control;
|
|
134
|
+
if (request?.status !== "queued" || !request.interrupt) return;
|
|
135
|
+
const runner = executionRunner(ctx, id), run = runner.current();
|
|
136
|
+
if (!run?.running || run.id !== entry.last?.run) return;
|
|
137
|
+
if (request.stop_run && request.stop_run !== run.id) return;
|
|
138
|
+
const session = entry.session || (runner.sessionFrom ? runner.sessionFrom(run.id) : adapterFor(entry.harness).sessionFrom(runner.lines(run.id)));
|
|
139
|
+
if (!session) return; // Never stop a first turn before it gives us a resumable identity.
|
|
140
|
+
ctx.state.update(id, { session, control: { ...request, stop_run: run.id, message: "Stopping the active run; your files will be preserved before the same session resumes." } });
|
|
141
|
+
try { runner.kill(run.id); }
|
|
142
|
+
catch (error) {
|
|
143
|
+
ctx.log.warn(`${id}: interrupt failed: ${error.message}`);
|
|
144
|
+
ctx.state.update(id, { state: STATES.needsHuman, reason: "run-stop-unverified", control: {
|
|
145
|
+
...request, status: "failed", finished_at: nowIso(ctx.clock),
|
|
146
|
+
message: "Could not safely stop the previous run. Inspect its process group using gallivant why and the daemon log before retrying. Your message is saved below; no replacement was started.",
|
|
147
|
+
} });
|
|
148
|
+
return true; // The daemon completes the central needs-human labels/notification transition.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Run only after the previous harness process has settled. */
|
|
153
|
+
export async function applyControl(ctx, id) {
|
|
154
|
+
const entry = ctx.state.get(id);
|
|
155
|
+
const request = entry?.control;
|
|
156
|
+
if (!controlPending(request)) return false;
|
|
157
|
+
const finish = patch => ctx.state.update(id, current => current.control?.id === request.id
|
|
158
|
+
? { control: { ...request, ...patch, finished_at: nowIso(ctx.clock) } } : {});
|
|
159
|
+
// A crash after spawning is indistinguishable from one just before it. Never auto-replay it.
|
|
160
|
+
if (request.status === "applying") {
|
|
161
|
+
finish({ status: "failed", message: "Request interrupted. Check the latest run before trying again." });
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
const runner = executionRunner(ctx, id);
|
|
165
|
+
if (runner.current()?.running) return false;
|
|
166
|
+
ctx.state.update(id, { control: { ...request, status: "applying" } });
|
|
167
|
+
try {
|
|
168
|
+
if (entry.state === STATES.done) throw new Error("The task was closed or merged before this request ran.");
|
|
169
|
+
if (entry.pr) {
|
|
170
|
+
const pr = await ctx.gh.prView(entry.repo, entry.pr);
|
|
171
|
+
if (pr.state !== "OPEN") throw new Error("The PR is no longer open.");
|
|
172
|
+
}
|
|
173
|
+
let run = null, initialLaunch = false;
|
|
174
|
+
if (request.action === "recheck") {
|
|
175
|
+
if (needsAgentResume(entry)) throw Object.assign(new Error("The agent failure requires resume, not PR recheck."), { code: "AGENT_RESUME_REQUIRED" });
|
|
176
|
+
if (executionResultCheckpoint(runner)?.phase === "blocked") throw Object.assign(new Error("Result publication needs an explicit corrective resume."), { code: "EXECUTION_RESULT_BLOCKED" });
|
|
177
|
+
await ctx.gh.removeLabel(entry.repo, entry.pr, READY_LABEL);
|
|
178
|
+
await ctx.gh.removeLabel(entry.repo, entry.pr, NEEDS_LABEL);
|
|
179
|
+
ctx.state.update(id, {
|
|
180
|
+
state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null,
|
|
181
|
+
last: { ...entry.last, event: "operator requested PR recheck" }, updated_at: nowIso(ctx.clock),
|
|
182
|
+
});
|
|
183
|
+
// Preserve budgets, dispatch markers and the brief. Only the normal tick judges readiness.
|
|
184
|
+
} else {
|
|
185
|
+
const { resume } = await import("./commands/resume.mjs");
|
|
186
|
+
const result = await resume(ctx, { handle: id, note: request.note || null });
|
|
187
|
+
run = result.run;
|
|
188
|
+
initialLaunch = result.initialLaunch === true;
|
|
189
|
+
// A cosmetic label failure after spawning must not invite another resume.
|
|
190
|
+
if (entry.pr) await ctx.gh.removeLabel(entry.repo, entry.pr, NEEDS_LABEL).catch(e => ctx.log.warn(`${id}: could not remove stopped label: ${e.message}`));
|
|
191
|
+
}
|
|
192
|
+
finish({ status: "applied", run, message: initialLaunch ? "Started the initial run after repairing launch prerequisites." : request.action === "message"
|
|
193
|
+
? "Started the next run in the same session with your message."
|
|
194
|
+
: run ? "Resumed the same agent session." : "Watching PR checks and review again." });
|
|
195
|
+
return request.action !== "recheck"; // A recheck continues through normal judgement in this same tick.
|
|
196
|
+
} catch (e) {
|
|
197
|
+
ctx.log.warn(`${id}: operator ${request.action} failed: ${e.message}`);
|
|
198
|
+
// Subprocess errors can carry credentials. Details stay in the local daemon log.
|
|
199
|
+
finish({ status: "failed", message: controlFailure(e) });
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Shared cached control visibility; requestControl remains the validation/writer.
|
|
2
|
+
export const controlPending = control => ["queued", "applying"].includes(control?.status);
|
|
3
|
+
export function controlAvailability(entry, run, capabilities, { resumable = Boolean(entry?.session) } = {}) {
|
|
4
|
+
const pending = controlPending(entry?.control);
|
|
5
|
+
const stopped = !entry || entry.state === "done" || pending;
|
|
6
|
+
const active = run?.running && run.id === entry?.last?.run;
|
|
7
|
+
const canContinue = !stopped && !entry.dispatch_stopped && Boolean(resumable || active);
|
|
8
|
+
return {
|
|
9
|
+
recheck: !stopped && Boolean(entry.pr),
|
|
10
|
+
resume: canContinue,
|
|
11
|
+
message: canContinue,
|
|
12
|
+
cancel: !stopped,
|
|
13
|
+
interrupt: canContinue && capabilities?.controls?.interrupt !== false,
|
|
14
|
+
cancellation: capabilities?.controls?.cancel === false ? "stop-future-dispatch; remote-compute-may-continue" : "requires-provider-confirmation",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Portable wake-up envelope. It adds exclusion/scheduling, never task decisions.
|
|
2
|
+
import { deliveryError } from "./task_delivery_contract.mjs";
|
|
3
|
+
export function createCoordinator({ store, advance, schedule, clock = Date, intervalMs = 10000 }) {
|
|
4
|
+
if (!store?.withOwner || typeof advance !== "function" || typeof schedule !== "function" || !Number.isSafeInteger(intervalMs) || intervalMs < 1) throw deliveryError("COORDINATOR_CONFIG", "Inject durable fenced ownership, canonical advance, clock and wake-up scheduling");
|
|
5
|
+
return {
|
|
6
|
+
wake: async () => store.withOwner(async owner => {
|
|
7
|
+
await owner.assertCurrent();
|
|
8
|
+
// A durable next wake is written BEFORE advance. Eviction during advance
|
|
9
|
+
// must not lose the next reconciliation. Scheduler callbacks may repeat.
|
|
10
|
+
await schedule(clock.now() + intervalMs);
|
|
11
|
+
const result = await advance(owner);
|
|
12
|
+
await owner.assertCurrent();
|
|
13
|
+
// The pre-advance callback may have fired and been consumed while busy.
|
|
14
|
+
// Renew it from completion time so slow advances retain a future wake.
|
|
15
|
+
await schedule(clock.now() + intervalMs);
|
|
16
|
+
return result;
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The one place a GitHub credential for a run is resolved.
|
|
2
|
+
//
|
|
3
|
+
// The defect this exists for. A run is sandboxed: it cannot reach the operator's macOS keyring, their Linux secret
|
|
4
|
+
// service, or `~/.git-credentials`. The credential helper used to be `!gh auth git-credential`, which asks `gh` to
|
|
5
|
+
// look in exactly those places, so inside a run the helper returned nothing and `git push` failed after the work was
|
|
6
|
+
// done and paid for. `runEnv` set `GH_TOKEN` only when `github_token_file` was configured, and the default is null,
|
|
7
|
+
// so the common install got no credential at all.
|
|
8
|
+
//
|
|
9
|
+
// The fix is to resolve the credential in the DAEMON, which is not sandboxed and can reach whatever store the
|
|
10
|
+
// operator actually used, and to pass it to the run in its environment. The helper then reads `$GH_TOKEN` from that
|
|
11
|
+
// environment instead of shelling out to `gh`. The helper itself travels in the environment too, as one of
|
|
12
|
+
// `RUN_GIT_CONFIG`'s pairs in lib/git.mjs, so a run names no file outside its worktree.
|
|
13
|
+
//
|
|
14
|
+
// Resolution order, first answer wins. The order is the contract; it is documented in docs/config.md and asserted
|
|
15
|
+
// in the tests, because changing it silently changes which identity a run acts as.
|
|
16
|
+
//
|
|
17
|
+
// 1. `github_token_file` from the config. Explicit and deliberately narrow, so it keeps winning over everything
|
|
18
|
+
// below. An operator who set it has decided what a run may reach, and nothing here may widen that.
|
|
19
|
+
// 2. `GH_TOKEN` or `GITHUB_TOKEN` already in the daemon's own environment. This is CI, and anyone who exports a
|
|
20
|
+
// token in a shell profile.
|
|
21
|
+
// 3. Whatever `gh auth token` hands over. Asking `gh` is the honest way to cover every store `gh` supports: the
|
|
22
|
+
// macOS keyring, the Linux secret service, the plain `~/.config/gh/hosts.yml` when no keyring is available,
|
|
23
|
+
// and an enterprise host. Parsing `hosts.yml` here would reimplement `gh`'s own resolution and drift from it.
|
|
24
|
+
// 4. Nothing. `resolve` returns a reason, and the caller refuses to start the run.
|
|
25
|
+
//
|
|
26
|
+
// Two rules hold for every path through this file, and both are about where the token must never land:
|
|
27
|
+
//
|
|
28
|
+
// - Never in argv. `gh auth token --hostname <host>` puts the HOST in argv, never the token, which arrives on
|
|
29
|
+
// stdout. `ps` shows argv to every process on the machine.
|
|
30
|
+
// - This resolver writes no token to disk. The runner records argv without env in `cmd.json`. A child can
|
|
31
|
+
// still print its credential into raw `provider.log`; tracker logs redact recognized secrets but are not a
|
|
32
|
+
// complete data-loss boundary. `describe` below reports only the source and length.
|
|
33
|
+
import { execFile } from "node:child_process";
|
|
34
|
+
import { readText } from "./fs.mjs";
|
|
35
|
+
|
|
36
|
+
const GH_TOKEN_TIMEOUT_MS = 20_000;
|
|
37
|
+
|
|
38
|
+
/** Where a resolved credential came from, as a stable key. Printed and asserted on; never the token itself. */
|
|
39
|
+
export const SOURCES = Object.freeze({
|
|
40
|
+
file: "github_token_file",
|
|
41
|
+
env: "daemon environment",
|
|
42
|
+
gh: "gh auth token",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The host a remote URL's credential belongs to. `gh` can be logged in to an enterprise host as well as
|
|
47
|
+
* github.com, and the token for github.com is the wrong one for `github.acme.com`.
|
|
48
|
+
*
|
|
49
|
+
* Only the host is taken, and only from a URL shape we recognise. An SSH remote has no useful host for a token
|
|
50
|
+
* (a token cannot authenticate SSH), so it reads as github.com and the caller's own remote check is what fails.
|
|
51
|
+
*/
|
|
52
|
+
export function hostFromRemote(url) {
|
|
53
|
+
const s = String(url || "").trim();
|
|
54
|
+
const m = s.match(/^https?:\/\/(?:[^@/]*@)?([a-z0-9.-]+)/i);
|
|
55
|
+
if (m) return m[1].toLowerCase();
|
|
56
|
+
const ssh = s.match(/^(?:ssh:\/\/)?(?:[^@/]+@)([a-z0-9.-]+)[:/]/i);
|
|
57
|
+
return ssh ? ssh[1].toLowerCase() : "github.com";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when a host is github.com itself rather than an enterprise install. */
|
|
61
|
+
export const isDotCom = host => String(host || "").toLowerCase() === "github.com";
|
|
62
|
+
|
|
63
|
+
/** The token in a `github_token_file`, or empty when none is configured. */
|
|
64
|
+
export const readTokenFile = file => (file ? readText(file, "").trim() : "");
|
|
65
|
+
|
|
66
|
+
/** A token in the daemon's own environment, `GH_TOKEN` first. Empty when neither is set to anything. */
|
|
67
|
+
export const tokenFromEnv = (env = process.env) => String(env.GH_TOKEN || env.GITHUB_TOKEN || "").trim();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `gh auth token` for one host, or empty when `gh` has nothing for it. Never throws: a machine with no `gh`, or a
|
|
71
|
+
* `gh` that is signed out, is a reason to fall through to the next source, not a crash in the daemon.
|
|
72
|
+
*
|
|
73
|
+
* The host goes in argv. The token comes back on stdout, so it is never visible to `ps`.
|
|
74
|
+
*/
|
|
75
|
+
export function tokenFromGh(host = "github.com", { exec = execFileAsyncQuiet } = {}) {
|
|
76
|
+
const args = ["auth", "token"];
|
|
77
|
+
// `--hostname` is only passed for an enterprise host: an older `gh` without the flag would fail the whole lookup
|
|
78
|
+
// for the overwhelmingly common github.com case, and for github.com the flag changes nothing.
|
|
79
|
+
if (!isDotCom(host)) args.push("--hostname", host);
|
|
80
|
+
return exec("gh", args).then(out => String(out || "").trim()).catch(() => "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** execFile for a token read: stdout only, failures become empty rather than throwing, stderr is never kept. */
|
|
84
|
+
function execFileAsyncQuiet(cmd, args) {
|
|
85
|
+
return new Promise(resolve => {
|
|
86
|
+
execFile(cmd, args, { encoding: "utf8", timeout: GH_TOKEN_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err, stdout) => resolve(err ? "" : stdout));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The message a refusal carries. It names all three sources in the order they are tried, because an operator who
|
|
92
|
+
* sees this has to pick one, and the cheapest fix is nearly always the first line.
|
|
93
|
+
*/
|
|
94
|
+
export function noCredentialMessage(host = "github.com") {
|
|
95
|
+
const lines = [
|
|
96
|
+
"No GitHub credential could be resolved, so a run would do the work and then fail to push.",
|
|
97
|
+
"Nothing was started. Fix one of these, in the order Gallivant tries them:",
|
|
98
|
+
` 1. gh auth login${isDotCom(host) ? "" : ` --hostname ${host}`} (uses whatever store gh has: keyring, secret service, or hosts.yml)`,
|
|
99
|
+
" 2. export GH_TOKEN=<token> (in the shell that starts the daemon, or in CI)",
|
|
100
|
+
" 3. a fine-grained PAT in a file, then set github_token_file to its path in ~/.gallivant/config.json",
|
|
101
|
+
];
|
|
102
|
+
if (!isDotCom(host)) lines.push(`The remote is on ${host}, not github.com. If gh is not logged in to ${host}, use github_token_file: a github.com token will not authenticate ${host}.`);
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a credential for one host. Returns `{ token, source, host }` or `{ token: null, source: null, host, why }`.
|
|
108
|
+
* An unusable explicitly configured token file throws `NO_CREDENTIAL`; it never tries a broader source.
|
|
109
|
+
*
|
|
110
|
+
* `env` and `exec` are injected so a test can drive every branch without a `gh` on the machine and without ever
|
|
111
|
+
* putting a real token anywhere near a fixture.
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveCredential(config, { host = "github.com", env = process.env, exec = execFileAsyncQuiet } = {}) {
|
|
114
|
+
// A configured file is an authority boundary, not merely the first candidate.
|
|
115
|
+
// Missing, unreadable or blank must not silently widen to an ambient login.
|
|
116
|
+
if (config?.github_token_file) {
|
|
117
|
+
const fromFile = readTokenFile(config.github_token_file);
|
|
118
|
+
if (fromFile) return { token: fromFile, source: SOURCES.file, host };
|
|
119
|
+
throw Object.assign(new Error("The configured github_token_file is missing, unreadable or empty. No fallback credential was used. Restore that private token file, or explicitly set github_token_file to null and restart the daemon to use environment/gh authentication."), { code: "NO_CREDENTIAL", reason: "configured-token-file-unavailable" });
|
|
120
|
+
}
|
|
121
|
+
const fromEnv = tokenFromEnv(env);
|
|
122
|
+
if (fromEnv) return { token: fromEnv, source: SOURCES.env, host };
|
|
123
|
+
const fromGh = await tokenFromGh(host, { exec });
|
|
124
|
+
if (fromGh) return { token: fromGh, source: SOURCES.gh, host };
|
|
125
|
+
return { token: null, source: null, host, why: noCredentialMessage(host) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* One line about a resolved credential, safe to log and to print. It names the source and the host and the token's
|
|
130
|
+
* length, never the token: a length is enough to tell "the file has a token in it" from "the file has a newline in
|
|
131
|
+
* it" while being useless to anyone who reads a log.
|
|
132
|
+
*/
|
|
133
|
+
export const describe = ({ token, source, host }) =>
|
|
134
|
+
token ? `${source}${isDotCom(host) ? "" : ` for ${host}`} (${token.length} characters)` : "none";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The host a checkout's credential belongs to, from its `origin` remote. Failure reads as github.com, because a
|
|
138
|
+
* checkout whose remote cannot be read has a bigger problem than which host its token is for and the caller's own
|
|
139
|
+
* git operations will say so.
|
|
140
|
+
*/
|
|
141
|
+
export async function hostForWorktree(git, dir) {
|
|
142
|
+
try { return hostFromRemote(await git.originOf(dir)); } catch { return "github.com"; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The credential for one entry or one request, resolved through the context's own resolver.
|
|
147
|
+
*
|
|
148
|
+
* The host is taken from the checkout's `origin` when there is a checkout, because that is the only thing that knows
|
|
149
|
+
* whether this repository lives on github.com or on an enterprise install. A repository named `owner/name` with no
|
|
150
|
+
* checkout yet (a `start` before its clone) has no host to read, so it reads as github.com, which is right for every
|
|
151
|
+
* install `gh repo clone owner/name` would resolve.
|
|
152
|
+
*
|
|
153
|
+
* `ctx.credential` is what `makeContext` builds. A context without one (every test's hand-built context) resolves
|
|
154
|
+
* from the config alone, so a test that wants a credential sets `github_token_file` and one that wants none sets
|
|
155
|
+
* nothing, and no test needs a `gh` on the machine.
|
|
156
|
+
*/
|
|
157
|
+
export async function resolveCredentialFor(ctx, { worktree = null } = {}) {
|
|
158
|
+
const host = worktree && ctx.git ? await hostForWorktree(ctx.git, worktree) : "github.com";
|
|
159
|
+
const resolve = ctx.credential ?? (opts => resolveCredential(ctx.config, opts));
|
|
160
|
+
return resolve({ host });
|
|
161
|
+
}
|
package/lib/daemon.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Process exclusion is independent of best-effort health reporting.
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { openSync, closeSync, writeFileSync, unlinkSync, mkdirSync, rmdirSync, existsSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { readJson, codeStamp } from "./fs.mjs";
|
|
8
|
+
|
|
9
|
+
export const daemonPath = home => join(home, "daemon.json");
|
|
10
|
+
const lockPath = home => join(home, "daemon.lock");
|
|
11
|
+
const liveOwners = new WeakMap();
|
|
12
|
+
const taskOwners = new AsyncLocalStorage();
|
|
13
|
+
export function withTaskMutationOwner(home, owner, fn) {
|
|
14
|
+
borrowTaskMutation(home, owner);
|
|
15
|
+
return taskOwners.run({ home: resolve(home), owner }, fn);
|
|
16
|
+
}
|
|
17
|
+
export function acquireServiceMutation(home, owner) {
|
|
18
|
+
const inherited = taskOwners.getStore();
|
|
19
|
+
owner ||= inherited?.home === resolve(home) ? inherited.owner : null;
|
|
20
|
+
return owner ? borrowTaskMutation(home, owner) : acquireCliMutation(home);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Borrow only an actual, still-held owner for this namespace.
|
|
24
|
+
export function borrowTaskMutation(home, owner) {
|
|
25
|
+
const held = liveOwners.get(owner);
|
|
26
|
+
if (!held || !((held.file === resolve(home, "task-mutation.lock")) ||
|
|
27
|
+
(held.file === resolve(home, "daemon.lock") && held.purpose === "runtime"))) {
|
|
28
|
+
throw new Error("task mutation requires a live owner for this namespace");
|
|
29
|
+
}
|
|
30
|
+
return { release() {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function processIdentity(pid) {
|
|
34
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
35
|
+
try {
|
|
36
|
+
// ps formats lstart using the caller's locale and timezone. A second CLI
|
|
37
|
+
// must not mistake a formatting change for PID reuse and replace a live lock.
|
|
38
|
+
const identity = execFileSync("ps", ["-o", "lstart=", "-o", "command=", "-p", String(pid)], {
|
|
39
|
+
encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"],
|
|
40
|
+
env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" },
|
|
41
|
+
}).trim();
|
|
42
|
+
return identity ? `v2:${identity}` : null;
|
|
43
|
+
} catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const isStableProcessIdentity = identity => typeof identity === "string" && identity.startsWith("v2:");
|
|
47
|
+
export const matchesProcess = (record, identity = processIdentity(record?.pid)) => Boolean(record?.identity && record.identity === identity);
|
|
48
|
+
|
|
49
|
+
export function pidExists(pid) {
|
|
50
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
51
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clearStaleLock(file, label = "daemon") {
|
|
55
|
+
// Serialize stale-owner recovery too: two contenders must never unlink a replacement owner's lock.
|
|
56
|
+
const recovery = `${file}.recovery`;
|
|
57
|
+
try { mkdirSync(recovery, { mode: 0o700 }); }
|
|
58
|
+
catch (e) {
|
|
59
|
+
if (e.code === "EEXIST") throw new Error(`${label} lock recovery is in progress (${recovery}); retry shortly`);
|
|
60
|
+
throw e;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const previous = readJson(file, null);
|
|
64
|
+
if (!previous) throw new Error(`${label} startup already in progress; retry shortly`);
|
|
65
|
+
const identity = processIdentity(previous.pid);
|
|
66
|
+
// A legacy fingerprint has no recorded locale/timezone. A mismatch cannot
|
|
67
|
+
// prove PID reuse: require that process to exit before replacing its lock.
|
|
68
|
+
if (pidExists(previous.pid) && (!isStableProcessIdentity(previous.identity) || !identity || previous.identity === identity)) throw new Error(`${label} already running (pid ${previous.pid})`);
|
|
69
|
+
unlinkSync(file);
|
|
70
|
+
} finally { rmdirSync(recovery); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function daemonRecord(home) {
|
|
74
|
+
const lock = readJson(lockPath(home), null);
|
|
75
|
+
const health = readJson(daemonPath(home), null);
|
|
76
|
+
if (!lock) return health;
|
|
77
|
+
return { ...(health?.instance === lock.instance ? health : {}), ...lock };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const readDaemon = home => {
|
|
81
|
+
const d = daemonRecord(home);
|
|
82
|
+
if (!d) return null;
|
|
83
|
+
// `stale` is the code on disk being newer than the code this daemon booted with, which means it is running a version
|
|
84
|
+
// of Gallivant that no longer exists. Only meaningful while it is alive, and only when it recorded a stamp at all: a
|
|
85
|
+
// daemon from before this existed has no `code` field and must not be reported as fresh OR stale, because nothing
|
|
86
|
+
// was measured.
|
|
87
|
+
const onDisk = codeStamp();
|
|
88
|
+
const identity = processIdentity(d.pid);
|
|
89
|
+
const live = !d.stoppedAt && matchesProcess(d, identity);
|
|
90
|
+
const stale = Boolean(d.code && onDisk && live && onDisk > d.code);
|
|
91
|
+
const unverified = !d.stoppedAt && !live && pidExists(d.pid) && (!isStableProcessIdentity(d.identity) || !identity);
|
|
92
|
+
return { ...d, alive: live && d.purpose !== "runtime", runtimeBusy: live && d.purpose === "runtime", unverified, onDisk, stale };
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
/** Refuse an incompatible live owner; an absent daemon can still be started later. */
|
|
97
|
+
export function assertDaemonUsable(daemon, { starting = false } = {}) {
|
|
98
|
+
if (starting && daemon?.runtimeBusy) throw new Error("runtime maintenance or delivery owns this namespace; retry daemon startup after it finishes");
|
|
99
|
+
if (daemon?.unverified) throw new Error(`daemon pid ${daemon.pid} has an unverifiable process identity; verify and stop the older daemon before restarting`);
|
|
100
|
+
if (daemon?.stale) throw new Error(`daemon pid ${daemon.pid} is running older code and cannot accept new requests. Restart it: gallivant down && gallivant up. Active agent runs keep going.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Claim before any await. Failure to publish ownership is fatal; health writes may fail later. */
|
|
104
|
+
export function acquireDaemon(home) {
|
|
105
|
+
const owner = acquireProcessLock(lockPath(home));
|
|
106
|
+
try { assertUnpinnedHome(home); return owner; }
|
|
107
|
+
catch (error) { owner.release(); throw error; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Same exclusion boundary, without claiming daemon liveness or shutdown rights.
|
|
111
|
+
export function acquireRuntimeOwner(home) {
|
|
112
|
+
return acquireProcessLock(lockPath(home), "runtime", "runtime");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function acquireTaskMutation(home) {
|
|
116
|
+
return acquireProcessLock(join(home, "task-mutation.lock"), "task mutation");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function assertUnpinnedHome(home) {
|
|
120
|
+
if (existsSync(join(home, "runtime-pin.json"))) throw Object.assign(new Error("Use the manifest-aware task delivery adapter and coordinator for this pinned namespace; ordinary CLI writes and daemon startup require an unpinned local home"), { code: "TASK_RUNTIME_PIN", version: 1 });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// CLI writers share maintenance exclusion and cannot write artifact-pinned homes.
|
|
124
|
+
export function acquireCliMutation(home) {
|
|
125
|
+
const owner = acquireTaskMutation(home);
|
|
126
|
+
try {
|
|
127
|
+
assertUnpinnedHome(home);
|
|
128
|
+
return owner;
|
|
129
|
+
} catch (error) { owner.release(); throw error; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Also used for long-lived intake mutations; elapsed time never expires a live owner. */
|
|
133
|
+
export function acquireProcessLock(file, label = "daemon", purpose) {
|
|
134
|
+
const identity = processIdentity(process.pid);
|
|
135
|
+
if (!identity) throw new Error(`cannot identify this ${label} process; refusing to start without process exclusion`);
|
|
136
|
+
const owner = { ...(purpose ? { purpose } : {}), pid: process.pid, identity, instance: randomUUID(), startedAt: new Date().toISOString() };
|
|
137
|
+
for (;;) {
|
|
138
|
+
let fd;
|
|
139
|
+
try { fd = openSync(file, "wx", 0o600); }
|
|
140
|
+
catch (e) {
|
|
141
|
+
if (e.code !== "EEXIST") throw e;
|
|
142
|
+
clearStaleLock(file, label);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
try { writeFileSync(fd, JSON.stringify(owner), { flush: true }); }
|
|
146
|
+
catch (e) { try { unlinkSync(file); } catch { /* preserve original error */ } throw e; }
|
|
147
|
+
finally { closeSync(fd); }
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
const token = { owner, release: () => {
|
|
151
|
+
liveOwners.delete(token);
|
|
152
|
+
if (readJson(file, null)?.instance === owner.instance) unlinkSync(file);
|
|
153
|
+
} };
|
|
154
|
+
liveOwners.set(token, { file: resolve(file), purpose });
|
|
155
|
+
return token;
|
|
156
|
+
}
|