omp-conductor 0.3.25 → 0.4.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/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
package/src/worktree.ts
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Checkout provisioning for one run.
|
|
3
3
|
*
|
|
4
|
-
* Every issue gets its own
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Every issue gets its own repository so two workers can never see each other's
|
|
5
|
+
* half-finished edits. Objects come from a per-repo bare mirror that is cloned
|
|
6
|
+
* once and refreshed, rather than a fresh full clone per issue: a module repo's
|
|
7
|
+
* history is fetched one time and every later run pays only for the delta.
|
|
8
|
+
*
|
|
9
|
+
* **A run gets its own repository, not a linked worktree of the mirror, and
|
|
10
|
+
* that is a security property rather than a preference (#125 1b).** A linked
|
|
11
|
+
* worktree shares the mirror's common dir — refs, objects, index locks — so it
|
|
12
|
+
* needs *write* access to it, which hands every run write access to every other
|
|
13
|
+
* run's refs. Here the objects arrive read-only through git's alternates
|
|
14
|
+
* mechanism and the run's refs, index and commits live in its own git dir. The
|
|
15
|
+
* documented residual is that a run can still *read* another run's objects out
|
|
16
|
+
* of the shared store: bounded, same source, no write path, no credential, and
|
|
17
|
+
* the price of not cloning the repo per run.
|
|
18
|
+
*
|
|
19
|
+
* Publishing is therefore the daemon's job, never the worker's — see
|
|
20
|
+
* `pushRunBranch` in `credentials.ts`. The run repo's `origin` deliberately
|
|
21
|
+
* names the real clone URL rather than the mirror, so a worker's
|
|
22
|
+
* `git push origin HEAD` attempts the network and fails on credentials instead
|
|
23
|
+
* of quietly succeeding into the shared object store.
|
|
9
24
|
*/
|
|
10
25
|
|
|
11
26
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
27
|
import { dirname, join } from "node:path";
|
|
13
28
|
|
|
29
|
+
import { credentialedEnv, scrubUserinfo } from "./credentials.ts";
|
|
14
30
|
import type { RepoTarget } from "./types.ts";
|
|
15
31
|
|
|
16
32
|
/**
|
|
@@ -19,15 +35,12 @@ import type { RepoTarget } from "./types.ts";
|
|
|
19
35
|
*/
|
|
20
36
|
const TRACKING_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
21
37
|
|
|
22
|
-
/** Matches the `user:token@` part of any URL, so it can be blanked out. */
|
|
23
|
-
const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^\s/@]+@/g;
|
|
24
|
-
|
|
25
38
|
/**
|
|
26
39
|
* The on-disk layout, in one place. Both are pure functions of config, so a
|
|
27
40
|
* caller that has to clean up *before* provisioning — the dispatcher on a
|
|
28
41
|
* retry, or reconciling orphaned trees at startup — can name a mirror or a
|
|
29
42
|
* tree without a network hop, and cannot drift from what `ensureMirror` and
|
|
30
|
-
* `
|
|
43
|
+
* `addRunRepo` will actually create.
|
|
31
44
|
*/
|
|
32
45
|
export function mirrorPathFor(repo: RepoTarget, mirrorRoot: string): string {
|
|
33
46
|
return join(mirrorRoot, `${repo.name}.git`);
|
|
@@ -48,9 +61,12 @@ async function runGit(
|
|
|
48
61
|
stdin: "ignore",
|
|
49
62
|
stdout: "pipe",
|
|
50
63
|
stderr: "pipe",
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
64
|
+
// Every git call in this module runs on the *privileged* side — the daemon
|
|
65
|
+
// provisioning, salvaging or publishing — so it goes through the one named
|
|
66
|
+
// construction site of credential material (#125). It also carries
|
|
67
|
+
// GIT_TERMINAL_PROMPT=0, because an unattended dispatcher must fail loudly
|
|
68
|
+
// rather than block forever on a prompt nobody is there to answer.
|
|
69
|
+
env: credentialedEnv(),
|
|
54
70
|
});
|
|
55
71
|
|
|
56
72
|
const [stdout, stderr, code] = await Promise.all([
|
|
@@ -75,12 +91,7 @@ async function git(args: string[], cwd?: string): Promise<string> {
|
|
|
75
91
|
const detail = stderr.trim() || stdout.trim() || "no output";
|
|
76
92
|
// A clone URL can carry a token, and it lands in both our argv and git's
|
|
77
93
|
// own error text; this string reaches logs and humans, so scrub it.
|
|
78
|
-
throw new Error(
|
|
79
|
-
`git ${args.join(" ")}${where} exited ${code}: ${detail}`.replace(
|
|
80
|
-
URL_USERINFO,
|
|
81
|
-
"$1***@",
|
|
82
|
-
),
|
|
83
|
-
);
|
|
94
|
+
throw new Error(scrubUserinfo(`git ${args.join(" ")}${where} exited ${code}: ${detail}`));
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
return stdout.trim();
|
|
@@ -267,10 +278,24 @@ export async function ensureMirror(
|
|
|
267
278
|
}
|
|
268
279
|
|
|
269
280
|
/**
|
|
270
|
-
* Provisions `<workspaceRoot>/<issue>` as a
|
|
271
|
-
* and returns the path. On the first attempt the branch is cut
|
|
272
|
-
* upstream tip of the default branch; on a retry the preserved branch
|
|
273
|
-
* reattached (see the comment on the
|
|
281
|
+
* Provisions `<workspaceRoot>/<issue>` as a **repository of its own** for
|
|
282
|
+
* `repo`/`branch`, and returns the path. On the first attempt the branch is cut
|
|
283
|
+
* from the upstream tip of the default branch; on a retry the preserved branch
|
|
284
|
+
* is reattached out of the mirror (see the comment on the fetch below).
|
|
285
|
+
*
|
|
286
|
+
* The objects come from the shared mirror through `objects/info/alternates` —
|
|
287
|
+
* git's own read-only borrowing mechanism — so provisioning still costs one
|
|
288
|
+
* fetch of the delta rather than a full clone, exactly as the linked worktree
|
|
289
|
+
* it replaces did. What changed is who can write what: the run's refs, index
|
|
290
|
+
* and commits are its own, and nothing it does can reach a sibling run's refs
|
|
291
|
+
* or the mirror (#125 1b).
|
|
292
|
+
*
|
|
293
|
+
* `origin` is deliberately the real clone URL and not the mirror path. Pointing
|
|
294
|
+
* it at the mirror would make a worker's ordinary `git push origin HEAD`
|
|
295
|
+
* succeed *into the shared object store*, which is precisely the write access
|
|
296
|
+
* this replacement exists to remove; pointing it upstream makes that push
|
|
297
|
+
* attempt the network and fail on credentials it does not have, while the
|
|
298
|
+
* daemon publishes the branch for it (`pushRunBranch`).
|
|
274
299
|
*
|
|
275
300
|
* ponytail: reattachment is the only retry mode, so attempt 2 always inherits
|
|
276
301
|
* attempt 1's tip — including a half-finished or broken state it might rather
|
|
@@ -279,7 +304,7 @@ export async function ensureMirror(
|
|
|
279
304
|
* path: snapshot the branch to `refs/conductor/attempt/<issue>/<n>` before
|
|
280
305
|
* resetting the run branch to `base`, and surface both refs in the escalation.
|
|
281
306
|
*/
|
|
282
|
-
export async function
|
|
307
|
+
export async function addRunRepo(
|
|
283
308
|
repo: RepoTarget,
|
|
284
309
|
mirrorRoot: string,
|
|
285
310
|
workspaceRoot: string,
|
|
@@ -287,16 +312,19 @@ export async function addWorktree(
|
|
|
287
312
|
branch: string,
|
|
288
313
|
): Promise<{ path: string; reattached: boolean }> {
|
|
289
314
|
const mirrorPath = await ensureMirror(repo, mirrorRoot);
|
|
290
|
-
|
|
315
|
+
// 0711: searchable, so a slot principal can reach its own checkout; not
|
|
316
|
+
// listable, so it cannot enumerate its siblings; not writable, so it cannot
|
|
317
|
+
// create or unlink one. Same reasoning as the socket parent directory.
|
|
318
|
+
mkdirSync(workspaceRoot, { recursive: true, mode: 0o711 });
|
|
291
319
|
|
|
292
|
-
const
|
|
293
|
-
if (existsSync(
|
|
320
|
+
const runRepo = worktreePathFor(workspaceRoot, issue);
|
|
321
|
+
if (existsSync(runRepo)) {
|
|
294
322
|
// Reusing a tree is how one worker silently inherits another attempt's
|
|
295
323
|
// uncommitted edits and pushes them under this issue's name.
|
|
296
324
|
throw new Error(
|
|
297
|
-
`worktree path already exists: ${
|
|
325
|
+
`worktree path already exists: ${runRepo}. Refusing to reuse it — ` +
|
|
298
326
|
`it may hold a previous attempt's uncommitted work. Call ` +
|
|
299
|
-
`removeWorktree(${mirrorPath}, ${
|
|
327
|
+
`removeWorktree(${mirrorPath}, ${runRepo}) first.`,
|
|
300
328
|
);
|
|
301
329
|
}
|
|
302
330
|
|
|
@@ -305,73 +333,98 @@ export async function addWorktree(
|
|
|
305
333
|
// under normal-clone semantics is no longer refreshed at all), so branching
|
|
306
334
|
// off it would silently start a run on a stale tip.
|
|
307
335
|
const base = `refs/remotes/origin/${repo.defaultBranch}`;
|
|
308
|
-
await git(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
// remote-tracking ref: without it git would set the run branch's upstream to
|
|
320
|
-
// the default branch, and the worker's `git push` would then argue with
|
|
321
|
-
// `push.default` instead of publishing the branch. The reattach path must
|
|
322
|
-
// *omit* the flag — git dies with "--[no-]track can only be used if a new
|
|
323
|
-
// branch is created" — and does not need it, since checking out an existing
|
|
324
|
-
// branch writes no tracking config at all.
|
|
325
|
-
//
|
|
326
|
-
// `-b` is only ever correct on the *first* attempt for an issue. Branch
|
|
327
|
-
// names are a deterministic function of the issue number, `removeWorktree`
|
|
328
|
-
// deliberately leaves the branch behind in the mirror, and `git worktree add
|
|
329
|
-
// -b <existing>` is a hard error — so without this probe a second attempt
|
|
330
|
-
// could never provision a tree and `maxAttemptsPerIssue` was fiction.
|
|
331
|
-
//
|
|
332
|
-
// The retry therefore *reattaches* the existing branch rather than doing
|
|
333
|
-
// either of the two easier things. A fresh `<branch>-attempt2` would break
|
|
334
|
-
// the fleet contract of one issue = one branch = one PR. A `branch -D` or a
|
|
335
|
-
// force-reset to the upstream tip would be irreversible: that branch can
|
|
336
|
-
// hold the only copy of work attempt 1 committed but never pushed. Attaching
|
|
337
|
-
// to it hands the new worker attempt 1's commits, so it can continue or
|
|
338
|
-
// recover them, and any destructive call stays a human's to make.
|
|
339
|
-
const branchExists = await gitSucceeds(
|
|
336
|
+
await git(["fetch", "--no-tags", "origin", `+refs/heads/${repo.defaultBranch}:${base}`], mirrorPath);
|
|
337
|
+
|
|
338
|
+
// Branch names are a deterministic function of the issue number and the
|
|
339
|
+
// mirror keeps the branch after a run ends, so its presence is what
|
|
340
|
+
// distinguishes a continuation from a first attempt. The retry *reattaches*
|
|
341
|
+
// rather than doing either of the two easier things: a fresh
|
|
342
|
+
// `<branch>-attempt2` would break the fleet contract of one issue = one
|
|
343
|
+
// branch = one PR, and a force-reset to upstream would be irreversible,
|
|
344
|
+
// because that branch can hold the only copy of work attempt 1 committed but
|
|
345
|
+
// never pushed. Attaching to it hands the new worker attempt 1's commits.
|
|
346
|
+
const reattached = await gitSucceeds(
|
|
340
347
|
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
341
348
|
mirrorPath,
|
|
342
349
|
);
|
|
343
|
-
const
|
|
344
|
-
? ["worktree", "add", worktreePath, branch]
|
|
345
|
-
: ["worktree", "add", "--no-track", "-b", branch, worktreePath, base];
|
|
350
|
+
const start = reattached ? `refs/heads/${branch}` : base;
|
|
346
351
|
|
|
347
352
|
try {
|
|
348
|
-
await git(
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
`branch ${branch} (${branchExists ? "reattaching an existing branch" : "creating a new branch"}), ` +
|
|
366
|
-
`both before and after \`git worktree prune\`: ${second} ` +
|
|
367
|
-
`(first attempt: ${first})`,
|
|
368
|
-
);
|
|
353
|
+
await git(["init", "--quiet", runRepo]);
|
|
354
|
+
// Before the first fetch, or git does not know the mirror's objects are
|
|
355
|
+
// reachable and the fetch below tries to copy the whole history.
|
|
356
|
+
const info = join(runRepo, ".git", "objects", "info");
|
|
357
|
+
mkdirSync(info, { recursive: true });
|
|
358
|
+
writeFileSync(join(info, "alternates"), `${join(mirrorPath, "objects")}\n`);
|
|
359
|
+
|
|
360
|
+
// HEAD is moved onto the run branch *after* the fetch, never before: git
|
|
361
|
+
// refuses to fetch into the branch HEAD is pointing at, even an unborn one,
|
|
362
|
+
// and the error ("refusing to fetch into branch … checked out at …") reads
|
|
363
|
+
// like a stale worktree registration rather than an ordering mistake. A
|
|
364
|
+
// fresh `git init` names its default branch, so the only way to collide is
|
|
365
|
+
// for the run branch to be called that — cheap to rule out, and silent
|
|
366
|
+
// breakage for exactly one issue number if it is not.
|
|
367
|
+
const initialHead = await git(["symbolic-ref", "--quiet", "HEAD"], runRepo);
|
|
368
|
+
if (initialHead === `refs/heads/${branch}`) {
|
|
369
|
+
await git(["symbolic-ref", "HEAD", "refs/heads/omp-conductor-bootstrap"], runRepo);
|
|
369
370
|
}
|
|
371
|
+
|
|
372
|
+
// Fetched by path, from a remote that is never persisted: the run repo must
|
|
373
|
+
// not keep a handle on the mirror it could push back through. No leading
|
|
374
|
+
// `+` — the ref does not exist yet, so a fast-forward is trivially
|
|
375
|
+
// satisfied, and there is no forcing refspec anywhere on a run's own branch.
|
|
376
|
+
await git(["fetch", "--no-tags", mirrorPath, `${start}:refs/heads/${branch}`], runRepo);
|
|
377
|
+
// The upstream tracking refs, so the worker can read `origin/main` and diff
|
|
378
|
+
// against it. These are remote-tracking refs, which git force-updates by
|
|
379
|
+
// definition; the run branch above is the one that must never be forced.
|
|
380
|
+
await git(
|
|
381
|
+
["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
|
|
382
|
+
runRepo,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// Added after the fetches so it cannot capture them: `origin` is upstream,
|
|
386
|
+
// and it exists so an ordinary `git push origin HEAD` fails on credentials
|
|
387
|
+
// rather than succeeding into the mirror. No upstream is configured for the
|
|
388
|
+
// run branch, for the reason the old `--no-track` existed: otherwise the
|
|
389
|
+
// worker's push argues with `push.default` instead of publishing.
|
|
390
|
+
await git(["remote", "add", "origin", repo.cloneUrl], runRepo);
|
|
391
|
+
|
|
392
|
+
await git(["symbolic-ref", "HEAD", `refs/heads/${branch}`], runRepo);
|
|
393
|
+
await git(["reset", "--hard"], runRepo);
|
|
394
|
+
|
|
395
|
+
// The mirror's `info/exclude` is no longer this repo's common dir, so the
|
|
396
|
+
// managed block has to be written here. Without it a worker's own scratch
|
|
397
|
+
// reaches its `git add -A`, and salvage inherits it (#44).
|
|
398
|
+
const exclude = join(runRepo, ".git", "info", "exclude");
|
|
399
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
400
|
+
writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
|
|
401
|
+
} catch (err) {
|
|
402
|
+
// A half-provisioned repo would be seen as "present" by the next attempt
|
|
403
|
+
// and refused as possibly holding work, stranding the issue. It holds
|
|
404
|
+
// nothing yet — the branch's durable copy is in the mirror — so clear it.
|
|
405
|
+
rmSync(runRepo, { recursive: true, force: true });
|
|
406
|
+
throw new Error(
|
|
407
|
+
`failed to provision run repository ${runRepo} for issue ${issue} on branch ${branch} ` +
|
|
408
|
+
`(${reattached ? "reattaching an existing branch" : "creating a new branch"}): ` +
|
|
409
|
+
`${err instanceof Error ? err.message : String(err)}`,
|
|
410
|
+
);
|
|
370
411
|
}
|
|
371
412
|
|
|
372
|
-
return { path:
|
|
413
|
+
return { path: runRepo, reattached };
|
|
373
414
|
}
|
|
374
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Publishes one run branch: run repo → mirror → GitHub, fast-forward only.
|
|
418
|
+
*
|
|
419
|
+
* Declared here as a function type rather than imported as a concrete
|
|
420
|
+
* implementation so this module stays free of the credential path: provisioning
|
|
421
|
+
* and salvage decide *what* to publish, `credentials.ts` is the only place that
|
|
422
|
+
* decides *how*, and the daemon is the only thing that holds both.
|
|
423
|
+
*/
|
|
424
|
+
export type RunPublisher = (
|
|
425
|
+
branch: string,
|
|
426
|
+
) => Promise<{ ok: true; sha: string } | { ok: false; stderr: string }>;
|
|
427
|
+
|
|
375
428
|
/**
|
|
376
429
|
* What one salvage attempt did. A failure is a *value* rather than a throw
|
|
377
430
|
* because the only caller is a run that is already ending badly: it has to log
|
|
@@ -468,10 +521,10 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
|
|
|
468
521
|
* Commits a run's uncommitted work to the run's own branch and pushes it, so
|
|
469
522
|
* that the tree the next attempt destroys is no longer the only copy.
|
|
470
523
|
*
|
|
471
|
-
* This closes a deliberate asymmetry. `
|
|
524
|
+
* This closes a deliberate asymmetry. `addRunRepo` preserves the run branch
|
|
472
525
|
* precisely because "that branch can hold the only copy of work attempt 1
|
|
473
526
|
* committed but never pushed", while `removeWorktree` runs `worktree remove
|
|
474
|
-
* --force` and `
|
|
527
|
+
* --force` and `addRunRepo` refuses to reuse a tree that "may hold a previous
|
|
475
528
|
* attempt's uncommitted work" — committed work is kept by design, uncommitted
|
|
476
529
|
* work was discarded by design.
|
|
477
530
|
*
|
|
@@ -490,18 +543,28 @@ function parseCachedNameStatus(raw: string): { files: string[]; newPaths: string
|
|
|
490
543
|
* Never throws. Every outcome, including its own failure, comes back as a value
|
|
491
544
|
* for the caller to log and to put in front of a human.
|
|
492
545
|
*
|
|
493
|
-
*
|
|
546
|
+
* Publishing is best-effort and deliberately last, after the sha exists: a
|
|
494
547
|
* refused push (diverged branch, no credentials, no network) still leaves the
|
|
495
|
-
* commit in
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* of work outliving its host.
|
|
548
|
+
* commit in the run's own repository, which the daemon can still read, and
|
|
549
|
+
* which is strictly better than nothing. It is a plain fast-forward push —
|
|
550
|
+
* never a force — and if the run already had a PR open, that PR gains the WIP
|
|
551
|
+
* commit and re-runs its checks. That is the price of work outliving its host.
|
|
552
|
+
*
|
|
553
|
+
* `publish` is a **required** parameter rather than an optional one with a
|
|
554
|
+
* no-op default, and that is the whole design. The salvage commit no longer
|
|
555
|
+
* pushes for itself: the run's repository holds no credential (#125), so the
|
|
556
|
+
* network hop is the daemon's, through `pushRunBranch`. A defaulted parameter
|
|
557
|
+
* is how a future call site silently stops publishing salvaged work and nobody
|
|
558
|
+
* finds out until the host that held it is gone — which is #121's failure with
|
|
559
|
+
* one extra step. Passing `undefined` is allowed, and is a visible decision at
|
|
560
|
+
* the call site.
|
|
499
561
|
*/
|
|
500
562
|
export async function salvageWip(
|
|
501
563
|
worktree: string,
|
|
502
564
|
issue: number,
|
|
503
565
|
attempt: number,
|
|
504
566
|
ending: string,
|
|
567
|
+
publish: RunPublisher | undefined,
|
|
505
568
|
): Promise<SalvageOutcome> {
|
|
506
569
|
try {
|
|
507
570
|
// A tree that is not there cannot be holding work. Checked before spawning
|
|
@@ -572,18 +635,17 @@ export async function salvageWip(
|
|
|
572
635
|
};
|
|
573
636
|
}
|
|
574
637
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
};
|
|
638
|
+
if (publish === undefined) {
|
|
639
|
+
return {
|
|
640
|
+
...salvaged,
|
|
641
|
+
pushed: false,
|
|
642
|
+
pushError:
|
|
643
|
+
"no publisher was supplied — the commit exists in the run's own repository and nowhere else",
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
const published = await publish(branch);
|
|
647
|
+
if (published.ok) return { ...salvaged, pushed: true };
|
|
648
|
+
return { ...salvaged, pushed: false, pushError: scrubUserinfo(published.stderr) };
|
|
587
649
|
} catch (err) {
|
|
588
650
|
return { kind: "failed", error: err instanceof Error ? err.message : String(err) };
|
|
589
651
|
}
|
|
@@ -610,13 +672,22 @@ export async function removeWorktree(
|
|
|
610
672
|
}
|
|
611
673
|
|
|
612
674
|
if (existsSync(worktreePath)) {
|
|
675
|
+
// A run repo is a plain directory now, so removal is a plain removal — but
|
|
676
|
+
// the `worktree remove` is still attempted first, because a fleet upgrading
|
|
677
|
+
// into #125 has live trees that ARE linked worktrees of this mirror, and
|
|
678
|
+
// deleting one of those without deregistering it leaves the mirror
|
|
679
|
+
// believing the branch is still checked out. It refuses `worktree add` and,
|
|
680
|
+
// worse, refuses to delete the branch at cleanup. The failure is expected
|
|
681
|
+
// for a run repo and is ignored; what is not tolerated is a path that
|
|
682
|
+
// survives, since the next attempt for this issue would trip over it.
|
|
613
683
|
try {
|
|
614
684
|
await git(["worktree", "remove", "--force", worktreePath], mirrorPath);
|
|
615
|
-
} catch
|
|
616
|
-
//
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
685
|
+
} catch {
|
|
686
|
+
// Not a registered worktree — the normal case from here on.
|
|
687
|
+
}
|
|
688
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
689
|
+
if (existsSync(worktreePath)) {
|
|
690
|
+
throw new Error(`failed to remove run repository ${worktreePath}: the path still exists`);
|
|
620
691
|
}
|
|
621
692
|
}
|
|
622
693
|
|
|
@@ -664,6 +735,28 @@ export async function cleanupRetainedWorktree(
|
|
|
664
735
|
await git(["fetch", "--prune", "origin"], mirrorPath);
|
|
665
736
|
|
|
666
737
|
const ref = `refs/heads/${branch}`;
|
|
738
|
+
|
|
739
|
+
// **Ask the run's own repository first.** Its commits live in its own
|
|
740
|
+
// object store, not the mirror's, so a mirror-only check cannot even see
|
|
741
|
+
// them — it would answer "nothing unpushed" about a branch whose only copy
|
|
742
|
+
// is the directory this function is about to delete. That is the exact
|
|
743
|
+
// data loss #121 exists to prevent, reintroduced by the move to per-run
|
|
744
|
+
// repositories, so it is checked where the objects actually are.
|
|
745
|
+
if (existsSync(worktreePath)) {
|
|
746
|
+
await git(
|
|
747
|
+
["fetch", "--no-tags", mirrorPath, "+refs/remotes/origin/*:refs/remotes/origin/*"],
|
|
748
|
+
worktreePath,
|
|
749
|
+
);
|
|
750
|
+
const runUnique = await git(["rev-list", ref, "--not", "--remotes"], worktreePath);
|
|
751
|
+
if (runUnique !== "") {
|
|
752
|
+
return {
|
|
753
|
+
kind: "retained",
|
|
754
|
+
reason: "unpushed",
|
|
755
|
+
detail: `${branch} has commits in the run repository that are absent from every remote ref`,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
667
760
|
if (await gitSucceeds(["show-ref", "--verify", "--quiet", ref], mirrorPath)) {
|
|
668
761
|
const unique = await git(["rev-list", ref, "--not", "--remotes"], mirrorPath);
|
|
669
762
|
if (unique !== "") {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Example unit for a supervised omp-conductor daemon.
|
|
2
2
|
#
|
|
3
|
-
# Why MemoryMax exists:
|
|
4
|
-
# service
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
3
|
+
# Why MemoryMax exists: worker and orchestrator sessions are child processes of
|
|
4
|
+
# this service (#125 moved them out of the daemon's own process so they can run
|
|
5
|
+
# as a different OS principal). They stay in this unit's cgroup, so MemoryMax
|
|
6
|
+
# still governs the fleet's total footprint — it is just no longer a ceiling on
|
|
7
|
+
# one process. With the default two workers plus the orchestrator session,
|
|
8
|
+
# journald on a reference 7.6 GB host recorded Memory peaks of ~3.2–4.2 GB for
|
|
9
|
+
# this unit (issue #51). That is expected load, not a leak — and on a shared VPS
|
|
10
|
+
# it is enough to thrash swap or OOM the daemon mid-flight (orphan path).
|
|
9
11
|
#
|
|
10
12
|
# Before enabling:
|
|
11
13
|
# 1. Set User=/Group=/HOME=/PATH for the account that owns ~/.omp/conductor.
|
|
@@ -14,6 +16,66 @@
|
|
|
14
16
|
# consider MemoryMax=3G.
|
|
15
17
|
# 4. Do not co-locate ClickHouse + other multi-GB services beside a 2-worker
|
|
16
18
|
# fleet on a ≤8 GB box.
|
|
19
|
+
# 5. For credentials.isolation=per-run, do the one-time provisioning below.
|
|
20
|
+
# Without it the daemon runs unprotected (isolation=none) and says so in
|
|
21
|
+
# `omp-conductor status`.
|
|
22
|
+
#
|
|
23
|
+
# One-time provisioning for credentials.isolation=per-run (#125)
|
|
24
|
+
# --------------------------------------------------------------
|
|
25
|
+
# Model-executed code must not run as the account that holds the GitHub
|
|
26
|
+
# credential. Accounts, groups, a shared root and the daemon's own home mode
|
|
27
|
+
# are what make that true; environment scrubbing is only accident-prevention
|
|
28
|
+
# and does not survive a determined session running as the same uid.
|
|
29
|
+
#
|
|
30
|
+
# Do NOT hand-copy the steps. `omp-conductor boundary-setup` prints the exact
|
|
31
|
+
# idempotent commands, generated from the same constants the startup probe then
|
|
32
|
+
# checks — a hand-maintained copy here would drift from what the daemon demands
|
|
33
|
+
# and fail at first dispatch instead of at provisioning time. It needs no config
|
|
34
|
+
# and must be run BEFORE `setup`, because setup writes worktree and mirror paths
|
|
35
|
+
# into the shared root it creates:
|
|
36
|
+
#
|
|
37
|
+
# omp-conductor boundary-setup --slots 2 # read what it will do
|
|
38
|
+
# omp-conductor boundary-setup --slots 2 | sudo bash
|
|
39
|
+
# sudo systemctl restart omp-conductor.service
|
|
40
|
+
#
|
|
41
|
+
# What it establishes, and why each part is load-bearing:
|
|
42
|
+
#
|
|
43
|
+
# * conductor-agent-<n> per concurrent slot, plus conductor-agent-orch. The
|
|
44
|
+
# orchestrator's is distinct so it cannot reach a run checkout, and it is
|
|
45
|
+
# launched with no supplementary group at all.
|
|
46
|
+
# * conductor-daemon — the daemon account ONLY. Lets it fetch a run branch,
|
|
47
|
+
# salvage a killed run and reclaim the tree. A slot principal must NEVER be
|
|
48
|
+
# in it; that absence is what keeps sibling runs apart, and the probe suite
|
|
49
|
+
# asserts it from a live session.
|
|
50
|
+
# * conductor-runs — every slot. Read-only access to the shared mirror that
|
|
51
|
+
# run repos borrow objects from. A run being able to READ another run's
|
|
52
|
+
# objects there is the documented residual of sharing one object store.
|
|
53
|
+
# * /var/lib/omp-conductor, mode 0711 — worktrees, mirrors, per-run session
|
|
54
|
+
# transcripts and per-run boundary homes. OUTSIDE the state directory,
|
|
55
|
+
# because that stays 0700 (it holds conductor.db and the WAL SQLite keeps
|
|
56
|
+
# recreating, so a searchable parent would publish fleet history to every
|
|
57
|
+
# local account), and outside $HOME for the reason below.
|
|
58
|
+
# * $HOME at 0711 with credential leaves closed (.ssh, .config/gh 0700;
|
|
59
|
+
# .npmrc, .git-credentials 0600). Searchable because the runtime and the
|
|
60
|
+
# installed package live in it — a 0700 home kills every worker before it
|
|
61
|
+
# connects, and no shell-based probe notices — and closed at the leaves
|
|
62
|
+
# because that is where the boundary actually rests. The daemon re-checks
|
|
63
|
+
# this empirically at dispatch and refuses if a slot can read any
|
|
64
|
+
# credential path.
|
|
65
|
+
#
|
|
66
|
+
# The restart is REQUIRED, not tidiness: supplementary group membership is fixed
|
|
67
|
+
# when a process starts, so without it the daemon's live credentials lack
|
|
68
|
+
# conductor-daemon even though `getent` shows it, and it would chown every run
|
|
69
|
+
# repo to a group it cannot itself use.
|
|
70
|
+
#
|
|
71
|
+
# # util-linux, for the privilege-dropping launcher. Without it the probe
|
|
72
|
+
# # reports mechanism `none` — there is deliberately no hand-rolled
|
|
73
|
+
# # spawn({uid,gid}) fallback, because a child launched that way can hold
|
|
74
|
+
# # CAP_SETUID and setuid() straight back to a sibling run or to the daemon.
|
|
75
|
+
# sudo apt-get install -y util-linux
|
|
76
|
+
#
|
|
77
|
+
# Verify with `omp-conductor status`: the `boundary` row names the mechanism
|
|
78
|
+
# that is actually live and lists what it does not close.
|
|
17
79
|
#
|
|
18
80
|
# Install:
|
|
19
81
|
# sudo install -m 0644 omp-conductor.service.example /etc/systemd/system/omp-conductor.service
|
|
@@ -34,8 +96,34 @@ User=fleet
|
|
|
34
96
|
Group=fleet
|
|
35
97
|
Environment=HOME=/home/fleet
|
|
36
98
|
# systemd's default PATH has no user installs; include wherever `omp` /
|
|
37
|
-
# `omp-conductor` and `
|
|
38
|
-
Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
99
|
+
# `omp-conductor`, `gh` and `setpriv` live on this host.
|
|
100
|
+
Environment=PATH=/home/fleet/.local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin
|
|
101
|
+
|
|
102
|
+
# These capabilities exist to be DROPPED INTO run children, never inherited by
|
|
103
|
+
# them (#125). The daemon stays the unprivileged `fleet` account — a capability
|
|
104
|
+
# grant on an existing account is a narrower blast radius than running as root
|
|
105
|
+
# or shipping a setuid binary, both of which widen exactly what this exists to
|
|
106
|
+
# narrow.
|
|
107
|
+
#
|
|
108
|
+
# Every run child is launched through `setpriv`, which sets the group list, then
|
|
109
|
+
# the gid, then the uid, empties the permitted/effective/inheritable/ambient
|
|
110
|
+
# capability sets, drops the bounding set, sets PR_SET_NO_NEW_PRIVS, and only
|
|
111
|
+
# then execs. DO NOT "simplify" this into a raw spawn({uid,gid}): ambient
|
|
112
|
+
# capabilities survive execve for ordinary binaries, so a child launched that
|
|
113
|
+
# way holds CAP_SETUID itself and can setuid() back to another principal —
|
|
114
|
+
# including a sibling run's. That voids the entire boundary while appearing to
|
|
115
|
+
# work, which is the worst possible outcome for a security change.
|
|
116
|
+
#
|
|
117
|
+
# CAP_SETPCAP is present solely so the launcher can empty the child's capability
|
|
118
|
+
# BOUNDING set (PR_CAPBSET_DROP requires it in the caller's own permitted set).
|
|
119
|
+
# It is dropped along with everything else before the session child execs. A
|
|
120
|
+
# host that refuses to grant it takes the documented fallback instead: the
|
|
121
|
+
# launcher omits --bounding-set=-all, the child still ends with every other set
|
|
122
|
+
# empty behind NoNewPrivs, and `status` reports the non-empty CapBnd as a named
|
|
123
|
+
# residual rather than ignoring it.
|
|
124
|
+
AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
|
|
125
|
+
CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP
|
|
126
|
+
|
|
39
127
|
WorkingDirectory=/home/fleet
|
|
40
128
|
|
|
41
129
|
# Foreground daemon so systemd tracks MainPID. `omp-conductor start` backgrounds;
|