omp-conductor 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/REFERENCE.md +60 -10
- package/agents/to-spec.md +90 -0
- package/package.json +2 -1
- package/schema/config.schema.json +29 -0
- package/src/admission.ts +204 -75
- package/src/ask.ts +268 -7
- package/src/board.ts +17 -3
- package/src/briefs/orchestrator.md +42 -14
- package/src/briefs/to-spec.md +84 -0
- package/src/briefs/worker.md +2 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +11 -0
- package/src/command-manifest.ts +22 -0
- package/src/commands/context.ts +1 -0
- package/src/commands/drain.ts +176 -0
- package/src/commands/extend.ts +6 -10
- package/src/commands/status.ts +5 -1
- package/src/commands/watch.ts +50 -2
- package/src/commands/worker.ts +9 -10
- package/src/config-schema.ts +24 -0
- package/src/config.ts +42 -1
- package/src/daemon.ts +965 -36
- package/src/dashboard/app.js +4 -1
- package/src/dashboard/server.ts +5 -2
- package/src/decisions.ts +235 -17
- package/src/diff-flags.ts +75 -1
- package/src/doctor.ts +52 -0
- package/src/escalate.ts +9 -3
- package/src/failure-class.ts +28 -2
- package/src/fleet.ts +146 -22
- package/src/gitops.ts +188 -81
- package/src/graph-health.ts +35 -1
- package/src/graph.ts +66 -1
- package/src/harness-loader.ts +59 -0
- package/src/host.ts +567 -2
- package/src/lifecycle.ts +122 -1
- package/src/omp.ts +227 -20
- package/src/orchestrator-tick.ts +1386 -15
- package/src/orchestrator.ts +12 -0
- package/src/privileged.ts +1 -4
- package/src/release-policy.ts +503 -9
- package/src/session-host.ts +99 -5
- package/src/settlement.ts +69 -17
- package/src/setup-host.ts +1205 -6
- package/src/setup-install.ts +28 -0
- package/src/setup-wizard.ts +13 -2
- package/src/setup.ts +29 -13
- package/src/shell.ts +15 -0
- package/src/status-render.ts +78 -11
- package/src/store.ts +443 -42
- package/src/to-spec.ts +387 -0
- package/src/tracker/github.ts +104 -14
- package/src/types.ts +343 -13
- package/src/upgrade-verify.ts +209 -2
- package/src/upgrade.ts +175 -1
- package/src/verbs/protocol.ts +39 -0
- package/src/verbs/server.ts +730 -56
- package/src/verbs/socket.ts +24 -5
- package/src/worker.ts +25 -2
- package/src/worktree.ts +29 -12
package/src/failure-class.ts
CHANGED
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
|
|
18
18
|
import type { Caps, FailureClass, RecoveryAction, RunRecord } from "./types.ts";
|
|
19
19
|
|
|
20
|
-
/** Facts the caller fetched, each only for the rows that need it.
|
|
20
|
+
/** Facts the caller fetched, each only for the rows that need it.
|
|
21
|
+
*
|
|
22
|
+
* `pr` can also be `"missing"`: the tracker has proven the claimed PR
|
|
23
|
+
* definitively does not exist (#779). It is a fact, the same way `"closed"`
|
|
24
|
+
* is — a REST 404 the adapter corroborated with a same-repository pulls-list
|
|
25
|
+
* read — never a transient "could not tell", which stays `undefined`. */
|
|
21
26
|
export interface ClassifyFacts {
|
|
22
|
-
pr?: "open" | "merged" | "closed";
|
|
27
|
+
pr?: "open" | "merged" | "closed" | "missing";
|
|
23
28
|
mergeable?: "conflicting" | "clean" | "unknown";
|
|
24
29
|
checks?: { name: string; state: string; link?: string }[];
|
|
25
30
|
/** Full session error recovered from the transcript. Kept as a fact so a
|
|
@@ -142,6 +147,12 @@ const START_FAILURE_SIGNATURES = [
|
|
|
142
147
|
"invalid api key",
|
|
143
148
|
"authentication failed",
|
|
144
149
|
"could not load its peer dependency",
|
|
150
|
+
// The pre-launch identity gate (#798/#828): the account is missing, or the
|
|
151
|
+
// harness binding a worker resolves through is not established. The daemon
|
|
152
|
+
// refuses before it spawns anything, so no attempt was spent — and the
|
|
153
|
+
// symptom this replaced (the child dying on the peer import) already read as
|
|
154
|
+
// a start failure, so charging one here would be a regression dressed as a fix.
|
|
155
|
+
"worker identity unavailable",
|
|
145
156
|
] as const;
|
|
146
157
|
|
|
147
158
|
/**
|
|
@@ -512,6 +523,21 @@ export function classifyRun(
|
|
|
512
523
|
if (run.state === "failed" && facts.pr === "open") {
|
|
513
524
|
const checks = facts.checks ?? [];
|
|
514
525
|
const unresolved = checks.filter((c) => !SUCCESS_CHECK_STATES[normalise(c.state)] === true);
|
|
526
|
+
if (checks.length > 0 && unresolved.length === 0) {
|
|
527
|
+
// The PR is open and every check is green: the strongest mechanical
|
|
528
|
+
// success evidence there is short of merge. The row's `failed` state is
|
|
529
|
+
// stale — whatever the worker recorded, its work is passing — so this is
|
|
530
|
+
// not a failure of any class. `none` recovery restores it to
|
|
531
|
+
// `pushed-green` in classifyAndRecover instead of escalating an
|
|
532
|
+
// `[unknown]` that charges an attempt (#766). An empty check list is
|
|
533
|
+
// "could not tell", not all-green, and still falls through to `unknown`
|
|
534
|
+
// below.
|
|
535
|
+
return {
|
|
536
|
+
cls: "unknown",
|
|
537
|
+
recovery: "none",
|
|
538
|
+
evidence: `${run.prUrl ?? "the PR"} is open and every check is green`,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
515
541
|
if (checks.length > 0 && unresolved.length > 0) {
|
|
516
542
|
// A failed check whose *log* smells like infrastructure — a registry 429,
|
|
517
543
|
// a runner shutdown, a DNS failure (#177). The check has a verdict, so
|
package/src/fleet.ts
CHANGED
|
@@ -50,8 +50,24 @@ import { inspectBriefLayout } from "./brief-upgrade.ts";
|
|
|
50
50
|
import { dbPath, openStore } from "./store.ts";
|
|
51
51
|
import { renderBriefForProject } from "./setup.ts";
|
|
52
52
|
import { DEFAULT_ARM_PROOF, type ArmProof, type DaemonStop, type ProjectConfig, type Store } from "./types.ts";
|
|
53
|
-
import { probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
53
|
+
import { DEFAULT_DEPS, probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
54
54
|
import { isPaused, setPaused, statusSnapshot, type StatusSnapshot } from "./daemon.ts";
|
|
55
|
+
// The drain surface belongs beside hold/releaseHold on the operator surface:
|
|
56
|
+
// the record is daemon state (implemented next to the pause sentinel), but the
|
|
57
|
+
// later CLI and release #484 children import from here, exactly where they find
|
|
58
|
+
// every other fleet control.
|
|
59
|
+
export {
|
|
60
|
+
cancelDrain,
|
|
61
|
+
consumeDrain,
|
|
62
|
+
createDrain,
|
|
63
|
+
drainPath,
|
|
64
|
+
readDrain,
|
|
65
|
+
type CreateDrainOptions,
|
|
66
|
+
type DrainProblem,
|
|
67
|
+
type DrainRecord,
|
|
68
|
+
type DrainStatus,
|
|
69
|
+
type DrainVerdict,
|
|
70
|
+
} from "./daemon.ts";
|
|
55
71
|
import {
|
|
56
72
|
healthCheck,
|
|
57
73
|
isAlive,
|
|
@@ -59,6 +75,7 @@ import {
|
|
|
59
75
|
probeUnit,
|
|
60
76
|
runSystemctl,
|
|
61
77
|
stopDaemon,
|
|
78
|
+
type HealthCheckResult,
|
|
62
79
|
type StopResult,
|
|
63
80
|
} from "./lifecycle.ts";
|
|
64
81
|
import type { WorkerPausePhase } from "./worker.ts";
|
|
@@ -1053,21 +1070,15 @@ export function parseHerdrAgentList(rawOutput: string): HerdrAgent[] {
|
|
|
1053
1070
|
return out;
|
|
1054
1071
|
}
|
|
1055
1072
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
});
|
|
1066
|
-
if (res.error) throw res.error;
|
|
1067
|
-
if (res.status !== 0) {
|
|
1068
|
-
throw new Error((res.stderr ?? res.stdout ?? `process-info exit ${String(res.status)}`).trim());
|
|
1069
|
-
}
|
|
1070
|
-
const raw = (res.stdout ?? "").trim();
|
|
1073
|
+
/**
|
|
1074
|
+
* The `process_info` document out of `herdr pane process-info` output
|
|
1075
|
+
* (#832): every caller that maps the fleet pane's live omp processes starts
|
|
1076
|
+
* from this schema, whether the output carries herdr's CLI `result` envelope
|
|
1077
|
+
* or the bare document. Throws when the output cannot be read; the caller
|
|
1078
|
+
* decides what an unreadable answer means.
|
|
1079
|
+
*/
|
|
1080
|
+
export function parseHerdrProcessInfo(stdout: string, paneId: string): ProcessInfo {
|
|
1081
|
+
const raw = stdout.trim();
|
|
1071
1082
|
if (raw.length === 0) {
|
|
1072
1083
|
throw new Error(`herdr pane process-info printed nothing for ${paneId}`);
|
|
1073
1084
|
}
|
|
@@ -1083,10 +1094,27 @@ async function herdrOmpForegroundPids(paneId: string, deps: PaneStopDeps): Promi
|
|
|
1083
1094
|
if (info === undefined) {
|
|
1084
1095
|
throw new Error(`herdr pane process-info has no process_info for ${paneId} — unrecognized schema`);
|
|
1085
1096
|
}
|
|
1086
|
-
return
|
|
1097
|
+
return info;
|
|
1087
1098
|
}
|
|
1088
1099
|
|
|
1089
|
-
|
|
1100
|
+
async function herdrOmpForegroundPids(paneId: string, deps: PaneStopDeps): Promise<number[]> {
|
|
1101
|
+
const bin = deps.herdrBin ?? "herdr";
|
|
1102
|
+
const session =
|
|
1103
|
+
deps.herdrSession ??
|
|
1104
|
+
resolveHerdrSessionWithBridge({ env: process.env, herdrBin: bin });
|
|
1105
|
+
const res = spawnSync(bin, ["--session", session, "pane", "process-info", "--pane", paneId], {
|
|
1106
|
+
encoding: "utf8",
|
|
1107
|
+
timeout: 8_000,
|
|
1108
|
+
env: process.env,
|
|
1109
|
+
});
|
|
1110
|
+
if (res.error) throw res.error;
|
|
1111
|
+
if (res.status !== 0) {
|
|
1112
|
+
throw new Error((res.stderr ?? res.stdout ?? `process-info exit ${String(res.status)}`).trim());
|
|
1113
|
+
}
|
|
1114
|
+
return ompPidsFromProcessInfo(parseHerdrProcessInfo(res.stdout ?? "", paneId));
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
export interface ProcessInfo {
|
|
1090
1118
|
shell_pid?: number;
|
|
1091
1119
|
foreground_processes?: ForegroundProc[];
|
|
1092
1120
|
}
|
|
@@ -1121,6 +1149,85 @@ function isOmpProcess(proc: ForegroundProc): boolean {
|
|
|
1121
1149
|
return false;
|
|
1122
1150
|
}
|
|
1123
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* The omp processes the fleet pane claims, resolved to their start times
|
|
1154
|
+
* (#832), or why they could not be read. Structurally identical to
|
|
1155
|
+
* upgrade-verify's `PaneOmpProbe` — the upgrade engine and the post-restart
|
|
1156
|
+
* verifier pass this straight into the pure verdicts, and the type is
|
|
1157
|
+
* repeated here because the verifier leaf must not import this module (fleet
|
|
1158
|
+
* reaches the daemon, which reaches the verifier).
|
|
1159
|
+
*/
|
|
1160
|
+
export type PaneProbeResult = { starts: readonly number[] } | { problem: string };
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Every omp process the fleet pane currently claims by herdr, resolved to
|
|
1164
|
+
* its start time (#832): the pane is where an *external* orchestrator
|
|
1165
|
+
* session lives, and the only fact that proves it reloaded is the live
|
|
1166
|
+
* process's own start — a pane process still running from before the install
|
|
1167
|
+
* began loaded the pre-upgrade extension. Every unreadable answer is a
|
|
1168
|
+
* `problem`: absence of evidence is never a reload.
|
|
1169
|
+
*
|
|
1170
|
+
* `run` is the injected command runner (the upgrade's scripted seam, the
|
|
1171
|
+
* daemon's `runCommand`), and `startTime` resolves one pid to its start —
|
|
1172
|
+
* the /proc read lives with the verifier's other live-process facts.
|
|
1173
|
+
*/
|
|
1174
|
+
export async function herdrPaneOmpStarts(
|
|
1175
|
+
run: (
|
|
1176
|
+
command: string,
|
|
1177
|
+
args: readonly string[],
|
|
1178
|
+
) => Promise<{ code: number; stdout: string; stderr: string }>,
|
|
1179
|
+
session: string,
|
|
1180
|
+
startTime: (pid: number) => number | undefined,
|
|
1181
|
+
): Promise<PaneProbeResult> {
|
|
1182
|
+
const agents = await run("herdr", ["--session", session, "agent", "list"]);
|
|
1183
|
+
if (agents.code !== 0) {
|
|
1184
|
+
return {
|
|
1185
|
+
problem:
|
|
1186
|
+
`herdr agent list failed: ${agents.stderr.trim() || agents.stdout.trim() || `exit ${agents.code}`}`,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
let parsed: HerdrAgent[];
|
|
1190
|
+
try {
|
|
1191
|
+
parsed = parseHerdrAgentList(agents.stdout);
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
return { problem: err instanceof Error ? err.message : String(err) };
|
|
1194
|
+
}
|
|
1195
|
+
const starts: number[] = [];
|
|
1196
|
+
for (const agent of parsed) {
|
|
1197
|
+
// A name without a live omp claim is a leftover label, not a pane process.
|
|
1198
|
+
if (agent.agent === undefined) continue;
|
|
1199
|
+
const info = await run("herdr", [
|
|
1200
|
+
"--session",
|
|
1201
|
+
session,
|
|
1202
|
+
"pane",
|
|
1203
|
+
"process-info",
|
|
1204
|
+
"--pane",
|
|
1205
|
+
agent.paneId,
|
|
1206
|
+
]);
|
|
1207
|
+
if (info.code !== 0) {
|
|
1208
|
+
return {
|
|
1209
|
+
problem:
|
|
1210
|
+
`herdr pane process-info for ${agent.paneId} failed: ` +
|
|
1211
|
+
`${info.stderr.trim() || info.stdout.trim() || `exit ${info.code}`}`,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
let pids: number[];
|
|
1215
|
+
try {
|
|
1216
|
+
pids = ompPidsFromProcessInfo(parseHerdrProcessInfo(info.stdout, agent.paneId));
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
return { problem: err instanceof Error ? err.message : String(err) };
|
|
1219
|
+
}
|
|
1220
|
+
for (const pid of pids) {
|
|
1221
|
+
const startedAt = startTime(pid);
|
|
1222
|
+
if (startedAt === undefined) {
|
|
1223
|
+
return { problem: `cannot read the start time of pane process ${pid} — reload unproven` };
|
|
1224
|
+
}
|
|
1225
|
+
starts.push(startedAt);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return { starts };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1124
1231
|
/**
|
|
1125
1232
|
* The project a bare read means, when the config leaves no doubt.
|
|
1126
1233
|
*
|
|
@@ -1355,7 +1462,7 @@ export function workerPhasesFromHealthz(
|
|
|
1355
1462
|
*/
|
|
1356
1463
|
export function classifyDaemonProjectHealth(
|
|
1357
1464
|
record: { project?: string } | undefined,
|
|
1358
|
-
health:
|
|
1465
|
+
health: HealthCheckResult | undefined,
|
|
1359
1466
|
project: string,
|
|
1360
1467
|
): DaemonProjectHealth {
|
|
1361
1468
|
if (record === undefined) return { kind: "stopped" };
|
|
@@ -1365,7 +1472,13 @@ export function classifyDaemonProjectHealth(
|
|
|
1365
1472
|
if (record.project !== undefined && record.project !== project) {
|
|
1366
1473
|
return { kind: "other-project", serves: record.project };
|
|
1367
1474
|
}
|
|
1368
|
-
|
|
1475
|
+
// #685: a timed-out probe is a probe outcome, never a death verdict — the
|
|
1476
|
+
// pid is up but wedged, so it must not read as `not running`. Only a
|
|
1477
|
+
// refusal or other failure (nothing listening, a torn answer) stays
|
|
1478
|
+
// `unreachable`.
|
|
1479
|
+
if (health?.ok !== true) {
|
|
1480
|
+
return health?.failure === "timeout" ? { kind: "unresponsive" } : { kind: "unreachable" };
|
|
1481
|
+
}
|
|
1369
1482
|
try {
|
|
1370
1483
|
const payload = JSON.parse(health.body ?? "null") as unknown;
|
|
1371
1484
|
if (payload === null || typeof payload !== "object") {
|
|
@@ -1455,7 +1568,12 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
|
|
|
1455
1568
|
};
|
|
1456
1569
|
const healthBody = projectHealth.kind === "ok" ? rawHealth?.body : undefined;
|
|
1457
1570
|
const cached = codeGraphFromHealthz(healthBody, project.name);
|
|
1458
|
-
|
|
1571
|
+
// The runtime half of the code-graph finding (#726): the probe reads the
|
|
1572
|
+
// store-backed per-run observations, so "observed" is always grounded in
|
|
1573
|
+
// dispatched runs, never in the daemon's own process or in mcp.json. The
|
|
1574
|
+
// store stays scoped to the same try/finally as the other reads so a
|
|
1575
|
+
// throwing probe cannot leak its handle.
|
|
1576
|
+
const store = openStore(dbPath());
|
|
1459
1577
|
const workerPhases = [...workerPhasesFromHealthz(healthBody, project.name)].map(
|
|
1460
1578
|
([issue, phase]) => ({ issue, phase }),
|
|
1461
1579
|
);
|
|
@@ -1464,10 +1582,16 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
|
|
|
1464
1582
|
// and rendered identically from either project: the daemon_stops table is
|
|
1465
1583
|
// deliberately not partitioned by project, because the daemon serves every
|
|
1466
1584
|
// project and the uninvolved one must see who stopped it too.
|
|
1467
|
-
|
|
1585
|
+
let codeGraph: CodeGraphHealth;
|
|
1468
1586
|
let lastStop: DaemonStop | undefined;
|
|
1469
1587
|
let siblings: { project: string; live: number }[] = [];
|
|
1470
1588
|
try {
|
|
1589
|
+
codeGraph =
|
|
1590
|
+
cached ??
|
|
1591
|
+
(await probeCodeGraph(project, {
|
|
1592
|
+
...DEFAULT_DEPS,
|
|
1593
|
+
graphToolsObservations: () => store.graphToolsObservationCounts(project.name),
|
|
1594
|
+
}));
|
|
1471
1595
|
lastStop = store.latestDaemonStop();
|
|
1472
1596
|
// Shared-daemon visibility (#545): every configured project other than the
|
|
1473
1597
|
// one being viewed, with its live-run count.
|
package/src/gitops.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* branch cannot disagree.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync } from "node:fs";
|
|
20
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { homedir, tmpdir } from "node:os";
|
|
21
22
|
import { join } from "node:path";
|
|
22
23
|
|
|
23
24
|
import { parseChainSource, type ChainEntry } from "./chain-check.ts";
|
|
@@ -75,6 +76,98 @@ export function credentialedEnv(
|
|
|
75
76
|
return { ...env, ...extra };
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The exact per-run dubious-ownership exemption for a daemon git call against
|
|
81
|
+
* a worker-owned run repository (#816).
|
|
82
|
+
*
|
|
83
|
+
* Every daemon-side invocation whose cwd (or opening) is the run repository
|
|
84
|
+
* carries the run's own path as a command-line `-c safe.directory=<path>` —
|
|
85
|
+
* never a wildcard, and never an entry in the daemon's global config, which
|
|
86
|
+
* would grow one line per run forever. Git 2.35+ refuses to open a repository
|
|
87
|
+
* owned by another uid ("dubious ownership"); the run repository was handed
|
|
88
|
+
* to the worker identity at dispatch, so the daemon's own `rev-parse`,
|
|
89
|
+
* salvage, lane probe and cleanup calls are exactly that shape.
|
|
90
|
+
*/
|
|
91
|
+
export function runRepoSafeDirectoryExemption(runRepoPath: string): [string, string] {
|
|
92
|
+
return ["-c", `safe.directory=${runRepoPath}`];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A daemon-owned, per-call global git config granting the *source-side*
|
|
97
|
+
* ownership exemption for a local-path fetch out of one worker-owned run
|
|
98
|
+
* repository.
|
|
99
|
+
*
|
|
100
|
+
* `git fetch <runRepo>` from the mirror is the one daemon git call whose
|
|
101
|
+
* *source* is worker-owned, and the one shape command-line config cannot
|
|
102
|
+
* reach: git spawns `upload-pack` against the source — `.git` dubious-ownership
|
|
103
|
+
* check included — and that subprocess sees none of the destination process's
|
|
104
|
+
* `-c` options. The only real config file the subprocess reads that this side
|
|
105
|
+
* controls is the global one, so the run's exemption is materialised there,
|
|
106
|
+
* for exactly this call: a temp file (0700, daemon's own) naming precisely the
|
|
107
|
+
* run's repo path and its gitdir, never a wildcard, with the daemon's existing
|
|
108
|
+
* global config replayed through `include.path` so nothing else about the
|
|
109
|
+
* caller's git behavior changes. The file is removed when the call is done.
|
|
110
|
+
*/
|
|
111
|
+
export interface ScopedSafeDirectory {
|
|
112
|
+
/** The env additions for one git call; the caller's own use of it is what
|
|
113
|
+
* scopes the exemption to that call. */
|
|
114
|
+
env: Record<string, string>;
|
|
115
|
+
/** Remove the daemon-owned temp file. The call must not outlive it. */
|
|
116
|
+
close: () => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A config value: git's quoting accepts C escapes in double quotes. */
|
|
120
|
+
function quoteGitConfigValue(value: string): string {
|
|
121
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function scopedSafeDirectoryEnv(...runRepos: readonly string[]): ScopedSafeDirectory {
|
|
125
|
+
const dir = mkdtempSync(join(tmpdir(), "omp-conductor-safe-"));
|
|
126
|
+
try {
|
|
127
|
+
// The global-config chain the daemon normally reads, replayed: setting
|
|
128
|
+
// GIT_CONFIG_GLOBAL would otherwise replace those files rather than
|
|
129
|
+
// augmenting them, dropping e.g. credential helpers and `insteadOf` rules
|
|
130
|
+
// for the duration of this one call.
|
|
131
|
+
const globals: string[] = [];
|
|
132
|
+
const globalOverride = process.env["GIT_CONFIG_GLOBAL"];
|
|
133
|
+
if (globalOverride !== undefined && globalOverride !== "") {
|
|
134
|
+
globals.push(globalOverride);
|
|
135
|
+
} else {
|
|
136
|
+
const xdgHome = process.env["XDG_CONFIG_HOME"];
|
|
137
|
+
const xdgConfig = xdgHome === undefined || xdgHome === ""
|
|
138
|
+
? join(homedir(), ".config", "git", "config")
|
|
139
|
+
: join(xdgHome, "git", "config");
|
|
140
|
+
if (existsSync(xdgConfig)) globals.push(xdgConfig);
|
|
141
|
+
const home = homedir();
|
|
142
|
+
if (home !== "") {
|
|
143
|
+
const gitconfig = join(home, ".gitconfig");
|
|
144
|
+
if (existsSync(gitconfig)) globals.push(gitconfig);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const lines = [
|
|
148
|
+
...globals.flatMap((file) => ["[include]", `\tpath = ${quoteGitConfigValue(file)}`]),
|
|
149
|
+
"[safe]",
|
|
150
|
+
// Both spellings git actually checks (2.43 and later use the gitdir
|
|
151
|
+
// path for a local-path source; a direct open names the worktree path).
|
|
152
|
+
// Both are exact, never a wildcard.
|
|
153
|
+
...runRepos.flatMap((repo) => [
|
|
154
|
+
`\tdirectory = ${quoteGitConfigValue(repo)}`,
|
|
155
|
+
`\tdirectory = ${quoteGitConfigValue(join(repo, ".git"))}`,
|
|
156
|
+
]),
|
|
157
|
+
"",
|
|
158
|
+
];
|
|
159
|
+
const file = join(dir, "gitconfig");
|
|
160
|
+
writeFileSync(file, lines.join("\n"));
|
|
161
|
+
return {
|
|
162
|
+
env: { ...credentialedEnv(), GIT_CONFIG_GLOBAL: file },
|
|
163
|
+
close: () => rmSync(dir, { recursive: true, force: true }),
|
|
164
|
+
};
|
|
165
|
+
} catch (err) {
|
|
166
|
+
rmSync(dir, { recursive: true, force: true });
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
78
171
|
// ------------------------------------------------- the privileged publish path
|
|
79
172
|
|
|
80
173
|
/** One run's own repository, as the daemon addresses it. */
|
|
@@ -163,7 +256,11 @@ export async function probeRunLane(
|
|
|
163
256
|
for (const line of stdout.split("\n")) add(line.trim(), source);
|
|
164
257
|
};
|
|
165
258
|
if (input.worktree !== "") {
|
|
166
|
-
|
|
259
|
+
// The live run's worktree is owned by the worker identity (#798), so
|
|
260
|
+
// every daemon-side read of it carries the exact per-run exemption — no
|
|
261
|
+
// wildcard, no global safe.directory (#816).
|
|
262
|
+
const exemption = runRepoSafeDirectoryExemption(input.worktree);
|
|
263
|
+
const status = await exec(["git", ...exemption, "-C", input.worktree, "status", "--porcelain"], {});
|
|
167
264
|
if (status.code === 0) {
|
|
168
265
|
const { untracked, tracked } = parsePorcelain(status.stdout);
|
|
169
266
|
// Untracked files are authored by construction: a merge stages the files
|
|
@@ -183,12 +280,12 @@ export async function probeRunLane(
|
|
|
183
280
|
// resolution. An unreadable divergence read fails open — no tracked
|
|
184
281
|
// occupancy is claimed while the merge makes the read ambiguous.
|
|
185
282
|
const merge = await exec(
|
|
186
|
-
["git", "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
|
|
283
|
+
["git", ...exemption, "-C", input.worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD"],
|
|
187
284
|
{},
|
|
188
285
|
);
|
|
189
286
|
if (merge.code === 0) {
|
|
190
287
|
const diverged = await exec(
|
|
191
|
-
["git", "-C", input.worktree, "diff", "--name-only", input.baseRef],
|
|
288
|
+
["git", ...exemption, "-C", input.worktree, "diff", "--name-only", input.baseRef],
|
|
192
289
|
{},
|
|
193
290
|
);
|
|
194
291
|
if (diverged.code === 0) {
|
|
@@ -203,7 +300,7 @@ export async function probeRunLane(
|
|
|
203
300
|
}
|
|
204
301
|
}
|
|
205
302
|
const diff = await exec(
|
|
206
|
-
["git", "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
|
|
303
|
+
["git", ...exemption, "-C", input.worktree, "diff", "--name-only", `${input.baseRef}...HEAD`],
|
|
207
304
|
{},
|
|
208
305
|
);
|
|
209
306
|
if (diff.code === 0) addDiff(diff.stdout, "branch");
|
|
@@ -311,91 +408,101 @@ export async function pushRunBranch(
|
|
|
311
408
|
const ref = `refs/heads/${run.branch}`;
|
|
312
409
|
const tracked = `refs/remotes/origin/${run.branch}`;
|
|
313
410
|
const env = credentialedEnv();
|
|
411
|
+
// The run repository is worker-owned (#798): the direct read below carries
|
|
412
|
+
// the exact-path exemption on the command line, and the local-path fetch's
|
|
413
|
+
// source (upload-pack) needs it as a scoped global config for the one call
|
|
414
|
+
// (#816). Both are exact per-run entries, never a wildcard, and the scoped
|
|
415
|
+
// file dies with the call.
|
|
416
|
+
const scoped = scopedSafeDirectoryEnv(run.runRepoPath);
|
|
314
417
|
|
|
315
|
-
|
|
316
|
-
|
|
418
|
+
try {
|
|
419
|
+
// Copy the run's branch into the mirror, fast-forward only.
|
|
420
|
+
const fetched = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `${ref}:${ref}`], { env: scoped.env });
|
|
421
|
+
|
|
422
|
+
// The proposed head is read from the run repo itself: when the copy above
|
|
423
|
+
// was refused, the mirror's copy of the branch is exactly the stale one.
|
|
424
|
+
const proposedRun = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", run.runRepoPath, "rev-parse", ref], { env: scoped.env });
|
|
425
|
+
if (proposedRun.code !== 0) {
|
|
426
|
+
return { ok: false, stderr: scrubUserinfo(proposedRun.stderr.trim() || `git rev-parse ${ref} exited ${String(proposedRun.code)}`) };
|
|
427
|
+
}
|
|
428
|
+
const proposed = proposedRun.stdout.trim();
|
|
429
|
+
|
|
430
|
+
// Reconcile with the live remote before enforcing anything: the ancestry
|
|
431
|
+
// that decides a fast-forward is the live remote's, not the mirror's copy's
|
|
432
|
+
// (see the docstring above). ls-remote answers "is the branch published at
|
|
433
|
+
// all" and "what does GitHub hold" in one call.
|
|
434
|
+
const liveListed = await exec(["git", "-C", mirror, "ls-remote", "origin", ref], { env });
|
|
435
|
+
if (liveListed.code !== 0) {
|
|
436
|
+
return { ok: false, stderr: scrubUserinfo(liveListed.stderr.trim() || `git ls-remote origin ${ref} exited ${String(liveListed.code)}`) };
|
|
437
|
+
}
|
|
438
|
+
const live = liveListed.stdout
|
|
439
|
+
.split("\n")
|
|
440
|
+
.map((line) => line.trimEnd())
|
|
441
|
+
.find((line) => line.endsWith(`\t${ref}`))
|
|
442
|
+
?.split(/\s+/, 1)[0];
|
|
443
|
+
|
|
444
|
+
if (live !== undefined && live !== proposed) {
|
|
445
|
+
// The branch is published and the run proposes something different. Pull
|
|
446
|
+
// the live branch's history into the mirror once (which also keeps the
|
|
447
|
+
// tracked ref a reattach reads fresh), then prove the fast-forward.
|
|
448
|
+
const reconciled = await exec(["git", "-C", mirror, "fetch", "--no-tags", "origin", `+${ref}:${tracked}`], { env });
|
|
449
|
+
if (reconciled.code !== 0) {
|
|
450
|
+
return { ok: false, stderr: scrubUserinfo(reconciled.stderr.trim() || reconciled.stdout.trim() || `git fetch origin exited ${String(reconciled.code)}`) };
|
|
451
|
+
}
|
|
317
452
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const reconciled = await exec(["git", "-C", mirror, "fetch", "--no-tags", "origin", `+${ref}:${tracked}`], { env });
|
|
345
|
-
if (reconciled.code !== 0) {
|
|
346
|
-
return { ok: false, stderr: scrubUserinfo(reconciled.stderr.trim() || reconciled.stdout.trim() || `git fetch origin exited ${String(reconciled.code)}`) };
|
|
453
|
+
const isAncestor = await exec(["git", "-C", mirror, "merge-base", "--is-ancestor", live, proposed], { env });
|
|
454
|
+
if (isAncestor.code === 128) {
|
|
455
|
+
// git could not perform the check at all (an object it was asked to
|
|
456
|
+
// resolve is missing, not merely unrelated). That is a failed
|
|
457
|
+
// verification, not a divergence verdict: refuse with git's own words,
|
|
458
|
+
// still naming both SHAs so the report carries the mismatch.
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
stderr: scrubUserinfo(
|
|
462
|
+
isAncestor.stderr.trim() ||
|
|
463
|
+
isAncestor.stdout.trim() ||
|
|
464
|
+
`git merge-base --is-ancestor ${live} ${proposed} exited ${String(isAncestor.code)}`,
|
|
465
|
+
),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (isAncestor.code !== 0) {
|
|
469
|
+
// A real divergence: no fast-forward exists, and the mirror is left
|
|
470
|
+
// exactly where it was. Both SHAs are named so the mismatch is
|
|
471
|
+
// diagnosable instead of a bare "non-fast-forward".
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
stderr:
|
|
475
|
+
`refusing non-fast-forward push of ${run.branch}: the live remote tip ${live} is not an ancestor of ` +
|
|
476
|
+
`the proposed head ${proposed}. Fetch the live branch and rebase or merge it before pushing again.`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
347
479
|
}
|
|
348
480
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
// still naming both SHAs so the report carries the mismatch.
|
|
355
|
-
return {
|
|
356
|
-
ok: false,
|
|
357
|
-
stderr: scrubUserinfo(
|
|
358
|
-
isAncestor.stderr.trim() ||
|
|
359
|
-
isAncestor.stdout.trim() ||
|
|
360
|
-
`git merge-base --is-ancestor ${live} ${proposed} exited ${String(isAncestor.code)}`,
|
|
361
|
-
),
|
|
362
|
-
};
|
|
481
|
+
if (fetched.code !== 0 && live === undefined) {
|
|
482
|
+
// The mirror refused the plain copy and there is no published branch to
|
|
483
|
+
// validate the run's head against: a rewritten branch that was never
|
|
484
|
+
// published must not rewrite the mirror either. Refuse with git's words.
|
|
485
|
+
return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
|
|
363
486
|
}
|
|
364
|
-
if (
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
};
|
|
487
|
+
if (fetched.code !== 0) {
|
|
488
|
+
// The proposed head is a legitimate fast-forward over the live remote (it
|
|
489
|
+
// equals the live head or the ancestor test above passed), so the only
|
|
490
|
+
// thing the plain copy refused on was the mirror's own stale copy.
|
|
491
|
+
// Refresh it; the push below still re-checks against the live remote.
|
|
492
|
+
const refreshed = await exec(["git", ...runRepoSafeDirectoryExemption(run.runRepoPath), "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `+${ref}:${ref}`], { env: scoped.env });
|
|
493
|
+
if (refreshed.code !== 0) {
|
|
494
|
+
return { ok: false, stderr: scrubUserinfo(refreshed.stderr.trim() || refreshed.stdout.trim() || `git fetch exited ${String(refreshed.code)}`) };
|
|
495
|
+
}
|
|
374
496
|
}
|
|
375
|
-
}
|
|
376
497
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// published must not rewrite the mirror either. Refuse with git's words.
|
|
381
|
-
return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
|
|
382
|
-
}
|
|
383
|
-
if (fetched.code !== 0) {
|
|
384
|
-
// The proposed head is a legitimate fast-forward over the live remote (it
|
|
385
|
-
// equals the live head or the ancestor test above passed), so the only
|
|
386
|
-
// thing the plain copy refused on was the mirror's own stale copy.
|
|
387
|
-
// Refresh it; the push below still re-checks against the live remote.
|
|
388
|
-
const refreshed = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `+${ref}:${ref}`], { env });
|
|
389
|
-
if (refreshed.code !== 0) {
|
|
390
|
-
return { ok: false, stderr: scrubUserinfo(refreshed.stderr.trim() || refreshed.stdout.trim() || `git fetch exited ${String(refreshed.code)}`) };
|
|
498
|
+
const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
|
|
499
|
+
if (pushed.code !== 0) {
|
|
500
|
+
return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
|
|
391
501
|
}
|
|
502
|
+
return { ok: true, sha: proposed };
|
|
503
|
+
} finally {
|
|
504
|
+
scoped.close();
|
|
392
505
|
}
|
|
393
|
-
|
|
394
|
-
const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
|
|
395
|
-
if (pushed.code !== 0) {
|
|
396
|
-
return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
|
|
397
|
-
}
|
|
398
|
-
return { ok: true, sha: proposed };
|
|
399
506
|
}
|
|
400
507
|
|
|
401
508
|
/**
|