omp-conductor 0.18.1 → 0.18.2
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 +1 -1
- package/REFERENCE.md +1 -1
- package/agents/to-spec.md +6 -2
- package/package.json +1 -1
- package/schema/config.schema.json +6 -1
- package/src/arm-challenge.ts +250 -57
- package/src/briefs/orchestrator.md +21 -8
- package/src/briefs/to-spec.md +6 -2
- package/src/cli.ts +122 -1
- package/src/command-manifest.ts +16 -5
- package/src/commands/arm.ts +1 -1
- package/src/commands/intake.ts +4 -19
- package/src/commands/watch.ts +4 -17
- package/src/config-schema.ts +19 -6
- package/src/config.ts +23 -8
- package/src/daemon.ts +150 -241
- package/src/decisions.ts +19 -11
- package/src/doctor.ts +55 -129
- package/src/escalate.ts +22 -11
- package/src/fleet.ts +93 -218
- package/src/host.ts +7 -332
- package/src/omp-settings.ts +19 -0
- package/src/omp.ts +11 -56
- package/src/orchestrator-tick.ts +225 -37
- package/src/session-host.ts +6 -41
- package/src/setup-host.ts +27 -10
- package/src/setup-wizard.ts +141 -1
- package/src/setup.ts +54 -4
- package/src/status-render.ts +138 -1
- package/src/to-spec.ts +23 -2
- package/src/types.ts +62 -6
- package/src/verbs/server.ts +38 -3
- package/src/worker.ts +0 -13
package/src/daemon.ts
CHANGED
|
@@ -36,13 +36,7 @@ import {
|
|
|
36
36
|
import { createEscalator, escalationIssueRef } from "./escalate.ts";
|
|
37
37
|
import { pendingCodeGraph, probeCodeGraph, type CodeGraphHealth } from "./graph-health.ts";
|
|
38
38
|
import { graphHint } from "./graph.ts";
|
|
39
|
-
import {
|
|
40
|
-
hostConstraintsNotice,
|
|
41
|
-
resolveWorkerIdentity,
|
|
42
|
-
WORKER_ACCOUNT,
|
|
43
|
-
type WorkerIdentity,
|
|
44
|
-
type WorkerIdentityResolution,
|
|
45
|
-
} from "./host.ts";
|
|
39
|
+
import { hostConstraintsNotice } from "./host.ts";
|
|
46
40
|
import { acquireOnceLease, healthCheck, livingDaemon, probeUnit, SYSTEMD_UNIT } from "./lifecycle.ts";
|
|
47
41
|
import { requestImmediateTick, resolveTickConfigCwd, STALL_MARKER_FILE } from "./orchestrator-tick.ts";
|
|
48
42
|
import { runDoctor } from "./doctor.ts";
|
|
@@ -286,33 +280,6 @@ interface Deps {
|
|
|
286
280
|
workerControls: WorkerControlRegistry;
|
|
287
281
|
/** Session seam for lifecycle integration tests; production uses the real harness. */
|
|
288
282
|
workerDeps?: RunWorkerDeps;
|
|
289
|
-
/**
|
|
290
|
-
* Resolves the dedicated worker identity (#798) — the account worker sessions
|
|
291
|
-
* run under, with its uid/gid/home, the transition launcher and a live harness
|
|
292
|
-
* binding (#828).
|
|
293
|
-
*
|
|
294
|
-
* Called **at every worker launch**, never cached: every input is host state
|
|
295
|
-
* that can change under a running daemon. A reboot can start this service
|
|
296
|
-
* before systemd has mounted the harness binding, and an operator can install
|
|
297
|
-
* the account or re-run `setup host` at any time — a verdict taken once at
|
|
298
|
-
* startup would hold the whole fleet closed until somebody thought to restart
|
|
299
|
-
* the daemon, which is the outage #828 exists to end rather than relocate.
|
|
300
|
-
* Resolution is a handful of `stat` calls and one `/etc/passwd` read; a
|
|
301
|
-
* dispatch does far more than that before it reaches this gate.
|
|
302
|
-
*
|
|
303
|
-
* Every worker dispatch refuses to launch on an unresolved identity (fail
|
|
304
|
-
* closed — an unbound worker is indistinguishable from an operator shell),
|
|
305
|
-
* and this is how the dispatcher secures the run's sockets and grants the
|
|
306
|
-
* run's paths to the account. Absent entirely, dispatch fails closed naming
|
|
307
|
-
* the missing account.
|
|
308
|
-
*/
|
|
309
|
-
workerIdentity?: () => WorkerIdentityResolution;
|
|
310
|
-
/**
|
|
311
|
-
* Re-owns a run's working paths (worktree + session dir) under the worker
|
|
312
|
-
* identity before the session launches. Wired by `runDaemon` to the
|
|
313
|
-
* recursive chown; a test fixture leaves it unset so no test chowns.
|
|
314
|
-
*/
|
|
315
|
-
grantWorkerPaths?: (identity: WorkerIdentity, worktreePath: string, sessionDir: string) => void;
|
|
316
283
|
integrity: IntegrityGate;
|
|
317
284
|
stall: StallGate;
|
|
318
285
|
/**
|
|
@@ -500,121 +467,6 @@ export function checkStall(gate: StallGate, marker: string, now = Date.now()): S
|
|
|
500
467
|
* could destroy work an operator would rather read first — the same refusal to
|
|
501
468
|
* guess that the recovery plugin is built on.
|
|
502
469
|
*/
|
|
503
|
-
// --------------------------------------------------------- worker identity (#798) --
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* Re-own a run's working paths (worktree, session dir) under the worker
|
|
507
|
-
* identity before the session launches. The daemon runs this as root; the
|
|
508
|
-
* worker identity is granted its own run paths by ownership, never by
|
|
509
|
-
* loosened modes on the daemon's.
|
|
510
|
-
*
|
|
511
|
-
* The walk never re-owns through a path a worker-uid process could
|
|
512
|
-
* re-resolve: every ownership change is `fchownSync` on a descriptor opened
|
|
513
|
-
* with `O_NOFOLLOW`, and every descriptor is verified — via `/proc/self/fd`'s
|
|
514
|
-
* kernel-resolved path — to still sit under the directory the walk opened.
|
|
515
|
-
* A directory entry raced into a symlink therefore either fails `O_NOFOLLOW`
|
|
516
|
-
* at the final component or resolves outside the verified parent and is
|
|
517
|
-
* skipped; it can never carry the chown to an external target. Symlinks
|
|
518
|
-
* within the tree are left untouched (the worker manages entries through its
|
|
519
|
-
* parent directories, and git recreates links on checkout), as are anything
|
|
520
|
-
* unopenable — fifos, sockets, devices — which git never creates. A missing
|
|
521
|
-
* or racing entry is not this dispatch's problem — the next dispatch re-owns
|
|
522
|
-
* whatever survives; a tree already owned by the worker identity — the
|
|
523
|
-
* resume and review-revision re-ownership of a tree a prior dispatch granted
|
|
524
|
-
* — is the only tree a worker-uid process could have modified, and is
|
|
525
|
-
* skipped outright rather than walked.
|
|
526
|
-
*/
|
|
527
|
-
export function chownRecursive(root: string, uid: number, gid: number): void {
|
|
528
|
-
try {
|
|
529
|
-
const current = lstatSync(root);
|
|
530
|
-
if (current.uid === uid && current.gid === gid) return;
|
|
531
|
-
} catch {
|
|
532
|
-
// A missing or racing root is the caller's own existence check; the next
|
|
533
|
-
// dispatch re-owns whatever survives.
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
const rootFd = openNoFollowDir(root);
|
|
537
|
-
try {
|
|
538
|
-
walkDir(rootFd, root, uid, gid);
|
|
539
|
-
} finally {
|
|
540
|
-
try {
|
|
541
|
-
closeSync(rootFd);
|
|
542
|
-
} catch {
|
|
543
|
-
// Already closed by a raced-away walk; nothing to do.
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/** Open a directory without following a final-component symlink. */
|
|
549
|
-
function openNoFollowDir(path: string): number {
|
|
550
|
-
return openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/** The kernel-resolved path of an open descriptor: what the object actually is. */
|
|
554
|
-
function fdRealPath(fd: number): string | undefined {
|
|
555
|
-
try {
|
|
556
|
-
return readlinkSync(`/proc/self/fd/${fd}`);
|
|
557
|
-
} catch {
|
|
558
|
-
return undefined;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function walkDir(dirFd: number, dirPath: string, uid: number, gid: number): void {
|
|
563
|
-
try {
|
|
564
|
-
fchownSync(dirFd, uid, gid);
|
|
565
|
-
} catch {
|
|
566
|
-
// Raced away; the next dispatch re-owns what survives.
|
|
567
|
-
}
|
|
568
|
-
const dirReal = fdRealPath(dirFd);
|
|
569
|
-
if (dirReal === undefined) return;
|
|
570
|
-
let entries: Dirent[];
|
|
571
|
-
try {
|
|
572
|
-
entries = readdirSync(dirPath, { withFileTypes: true });
|
|
573
|
-
} catch {
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
for (const entry of entries) {
|
|
577
|
-
const childPath = join(dirPath, entry.name);
|
|
578
|
-
try {
|
|
579
|
-
if (entry.isDirectory()) {
|
|
580
|
-
const childFd = openNoFollowDir(childPath);
|
|
581
|
-
try {
|
|
582
|
-
// The entry is genuinely beneath the directory this fd owns only
|
|
583
|
-
// when the kernel resolves the opened object to a path under it.
|
|
584
|
-
// Anything else — a name swapped to a symlink, a foreign listing
|
|
585
|
-
// read through a replaced parent — is refused, never re-resolved.
|
|
586
|
-
const childReal = fdRealPath(childFd);
|
|
587
|
-
if (childReal === undefined || !childReal.startsWith(`${dirReal}/`)) continue;
|
|
588
|
-
walkDir(childFd, childPath, uid, gid);
|
|
589
|
-
} finally {
|
|
590
|
-
try {
|
|
591
|
-
closeSync(childFd);
|
|
592
|
-
} catch {
|
|
593
|
-
// Raced away; nothing to close.
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
598
|
-
// Symlinks stay untouched (never followed, never chowned), and so do
|
|
599
|
-
// entries a read-only open cannot name safely (fifos, sockets, devices).
|
|
600
|
-
if (entry.isSymbolicLink() || !entry.isFile()) continue;
|
|
601
|
-
const fd = openSync(childPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
602
|
-
try {
|
|
603
|
-
const real = fdRealPath(fd);
|
|
604
|
-
if (real !== undefined && real.startsWith(`${dirReal}/`)) fchownSync(fd, uid, gid);
|
|
605
|
-
} finally {
|
|
606
|
-
try {
|
|
607
|
-
closeSync(fd);
|
|
608
|
-
} catch {
|
|
609
|
-
// Raced away; nothing to close.
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
} catch {
|
|
613
|
-
// O_NOFOLLOW refusal (a symlink swapped onto the name), or gone: nothing to chown.
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
470
|
export async function watchOrchestrator(d: Deps, now = Date.now()): Promise<void> {
|
|
619
471
|
const marker = join(stateDir(), STALL_MARKER_FILE);
|
|
620
472
|
const repeat = d.stall.paged;
|
|
@@ -1768,17 +1620,147 @@ export function createWorkerControlRegistry(): WorkerControlRegistry {
|
|
|
1768
1620
|
};
|
|
1769
1621
|
}
|
|
1770
1622
|
|
|
1623
|
+
/**
|
|
1624
|
+
* The daemon half of a worker's `pushed-green` claim (#85's contact with
|
|
1625
|
+
* reality; #782's publication attribution).
|
|
1626
|
+
*
|
|
1627
|
+
* A yield is a transport, not proof of GitHub side effects: `claim` is
|
|
1628
|
+
* caller-supplied text and the worker adapter turns it into a green result
|
|
1629
|
+
* without the daemon ever having seen a mediated publication verb for it — the
|
|
1630
|
+
* shape that let the #777 incident guess a non-existent PR. The tracker read
|
|
1631
|
+
* at the end proves the PR is open and green at the exact head; the evidence
|
|
1632
|
+
* gate here proves that PR is *this run's own mediated work* first.
|
|
1633
|
+
*
|
|
1634
|
+
* Two things are asked, in cost order:
|
|
1635
|
+
*
|
|
1636
|
+
* - the claimed URL must equal the one this run's row records, which is never
|
|
1637
|
+
* a worker-supplied string at the moment a live run is verified —
|
|
1638
|
+
* conductor_pr_create (create or adoption) writes it on the run, the daemon
|
|
1639
|
+
* seeds it at claim from a terminal predecessor on the same branch (#434),
|
|
1640
|
+
* and orchestrator-only recovery writes it for settled runs;
|
|
1641
|
+
* - and the claimed head must have a mediated publisher: an allowed
|
|
1642
|
+
* `conductor_push` on this exact run that published it on this run's branch
|
|
1643
|
+
* (the verb only ever pushes `refs/heads/<branch>`), an allowed
|
|
1644
|
+
* `conductor_pr_create` on this run for this exact PR, or — for a run that
|
|
1645
|
+
* published nothing new — the live tip of this run's own branch.
|
|
1646
|
+
*
|
|
1647
|
+
* That last path is the one the row cannot supply. It used to be served by
|
|
1648
|
+
* comparing the claim against `run.prUrl`/`run.headSha`, and both are
|
|
1649
|
+
* worker-tainted upstream: terminal settlement writes a worker's reported pair
|
|
1650
|
+
* onto the row *before* any verification, and continuation inheritance
|
|
1651
|
+
* validates only the predecessor's metadata and open state — so a turn-capped
|
|
1652
|
+
* attempt that reported someone else's real green PR had that pair inherited
|
|
1653
|
+
* and re-presented as its own evidence. Asking the tracker for the branch tip
|
|
1654
|
+
* removes the worker from the loop entirely: `refs/heads/<branch>` is a ref
|
|
1655
|
+
* only a mediated `conductor_push` or a mediated `conductor_pr_update_branch`
|
|
1656
|
+
* can move, so its live commit is daemon provenance no reported string can
|
|
1657
|
+
* forge. It is also why a legitimate mediated base-branch update now passes:
|
|
1658
|
+
* that server-side merge creates a head no `conductor_push` ever published and
|
|
1659
|
+
* the row still carries the older one, which the recorded-pair test rejected.
|
|
1660
|
+
*
|
|
1661
|
+
* Binding to the *exact current run* is what makes an old attempt, a different
|
|
1662
|
+
* branch, or #806's orchestrator-only settled-run recovery invisible here: the
|
|
1663
|
+
* ledger query is run-scoped by `runId`, and recoveries store no runId at all.
|
|
1664
|
+
* That query asks for the run's complete history rather than the ledger's
|
|
1665
|
+
* newest rows — publication evidence sits at the *start* of a run, and a
|
|
1666
|
+
* review-revision round or a burst of refused mutations pushed it past the
|
|
1667
|
+
* default page, turning a verified push into a definitive false failure.
|
|
1668
|
+
*
|
|
1669
|
+
* A claim that fails this gate is a definitive failure — never a retryable
|
|
1670
|
+
* `pushed-pending` — because a guessed URL is not something a later tick is
|
|
1671
|
+
* waiting on. A claim the gate could not *read* is the opposite: an unreadable
|
|
1672
|
+
* branch tip is #781's transient outage, so it stays retryable rather than
|
|
1673
|
+
* burning an attempt on a flaky read.
|
|
1674
|
+
*/
|
|
1771
1675
|
export async function verifyPushedGreenClaim(
|
|
1772
|
-
tracker: Pick<Tracker, "verifyPr">,
|
|
1676
|
+
tracker: Pick<Tracker, "verifyPr" | "branchHead">,
|
|
1773
1677
|
claim: Pick<WorkerResult, "prUrl" | "headSha">,
|
|
1678
|
+
publication: {
|
|
1679
|
+
project: string;
|
|
1680
|
+
issue: number;
|
|
1681
|
+
runId: string;
|
|
1682
|
+
/** The branch conductor routed this run onto. `conductor_push` publishes
|
|
1683
|
+
* exactly `refs/heads/<branch>` and refuses any other ref. */
|
|
1684
|
+
branch: string;
|
|
1685
|
+
/** The identity `tracker.branchHead` reads the live tip with: the
|
|
1686
|
+
* canonical `owner/repo` when the routed clone URL carries one, else the
|
|
1687
|
+
* routed repository name. A tracker that cannot resolve it answers
|
|
1688
|
+
* undefined, which stays retryable rather than definitive. */
|
|
1689
|
+
repo: string;
|
|
1690
|
+
store: Pick<Store, "verbLedger" | "getRun">;
|
|
1691
|
+
},
|
|
1774
1692
|
): Promise<{
|
|
1775
1693
|
state: "pushed-green" | "pushed-pending" | "failed";
|
|
1776
1694
|
reason?: string;
|
|
1777
1695
|
}> {
|
|
1778
|
-
|
|
1696
|
+
const { prUrl, headSha } = claim;
|
|
1697
|
+
if (prUrl === undefined || headSha === undefined) {
|
|
1779
1698
|
return { state: "failed", reason: "Worker did not report a PR URL and observed head SHA" };
|
|
1780
1699
|
}
|
|
1781
|
-
|
|
1700
|
+
// Exact current run, never "some publication for the issue": the query is
|
|
1701
|
+
// scoped to this runId plus project/issue, so a previous attempt's verbs and
|
|
1702
|
+
// the orchestrator's recovery verbs are invisible here. Unbounded on
|
|
1703
|
+
// purpose — see the note above about evidence ageing off the newest page.
|
|
1704
|
+
const ledger = publication.store.verbLedger(publication.project, {
|
|
1705
|
+
runId: publication.runId,
|
|
1706
|
+
issue: publication.issue,
|
|
1707
|
+
limit: Number.MAX_SAFE_INTEGER,
|
|
1708
|
+
});
|
|
1709
|
+
const run = publication.store.getRun(publication.runId);
|
|
1710
|
+
const branchRef = `refs/heads/${publication.branch}`;
|
|
1711
|
+
if (run?.prUrl !== prUrl) {
|
|
1712
|
+
return {
|
|
1713
|
+
state: "failed",
|
|
1714
|
+
reason:
|
|
1715
|
+
"Pushed-green claim has no mediated publication evidence: the claimed PR is not this run's " +
|
|
1716
|
+
"recorded PR",
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
const pushedThisHead = ledger.some(
|
|
1720
|
+
(entry) =>
|
|
1721
|
+
entry.decision === "allowed" &&
|
|
1722
|
+
entry.verb === "conductor_push" &&
|
|
1723
|
+
entry.sha === headSha &&
|
|
1724
|
+
entry.detail.includes(branchRef),
|
|
1725
|
+
);
|
|
1726
|
+
// Bound to the claimed PR, not merely to "a create happened": both allowed
|
|
1727
|
+
// details name the URL they produced (`opened <url> …`, `adopted <url> …`),
|
|
1728
|
+
// so an unrelated create on this run cannot vouch for another PR.
|
|
1729
|
+
const createdHere = ledger.some(
|
|
1730
|
+
(entry) =>
|
|
1731
|
+
entry.decision === "allowed" &&
|
|
1732
|
+
entry.verb === "conductor_pr_create" &&
|
|
1733
|
+
entry.detail.includes(prUrl),
|
|
1734
|
+
);
|
|
1735
|
+
|
|
1736
|
+
if (!pushedThisHead && !createdHere) {
|
|
1737
|
+
// Nothing this run published carries the claimed head, so the only
|
|
1738
|
+
// remaining evidence is the branch itself: a continuation or review round
|
|
1739
|
+
// that pushed nothing, or a head a mediated base-branch update produced.
|
|
1740
|
+
let tip: string | undefined;
|
|
1741
|
+
try {
|
|
1742
|
+
tip = await tracker.branchHead(publication.repo, publication.branch);
|
|
1743
|
+
} catch {
|
|
1744
|
+
tip = undefined;
|
|
1745
|
+
}
|
|
1746
|
+
if (tip === undefined) {
|
|
1747
|
+
return {
|
|
1748
|
+
state: "pushed-pending",
|
|
1749
|
+
reason: `Live head of ${branchRef} unavailable; retrying`,
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
if (tip !== headSha) {
|
|
1753
|
+
return {
|
|
1754
|
+
state: "failed",
|
|
1755
|
+
reason:
|
|
1756
|
+
"Pushed-green claim has no mediated publication evidence: this run's conductor_push / " +
|
|
1757
|
+
`conductor_pr_create ledger does not cover the claimed head, and ${branchRef} is at ${tip}, ` +
|
|
1758
|
+
"not the claimed head",
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const verification = await tracker.verifyPr(prUrl, headSha);
|
|
1782
1764
|
if (verification === undefined) {
|
|
1783
1765
|
return { state: "pushed-pending", reason: "GitHub PR verification unavailable; retrying" };
|
|
1784
1766
|
}
|
|
@@ -1932,33 +1914,6 @@ function orphanResumeVerdict(
|
|
|
1932
1914
|
return { kind: "resume", prior };
|
|
1933
1915
|
}
|
|
1934
1916
|
|
|
1935
|
-
/**
|
|
1936
|
-
* The worker identity for one launch, resolved now — or a throw naming the host
|
|
1937
|
-
* change that is missing (#798/#828).
|
|
1938
|
-
*
|
|
1939
|
-
* Every input is host state a running daemon does not control: the account can
|
|
1940
|
-
* be created after startup, and systemd can bring this service up before it has
|
|
1941
|
-
* mounted the harness binding a worker resolves through. So the verdict is taken
|
|
1942
|
-
* per launch. A daemon that cached one at startup would hold the whole fleet
|
|
1943
|
-
* closed on a boot race until somebody restarted it by hand — the outage #828
|
|
1944
|
-
* exists to end, not to relocate.
|
|
1945
|
-
*
|
|
1946
|
-
* The throw lands in the caller's dispatch catch, which settles the run failed
|
|
1947
|
-
* with this reason and escalates. It classifies as a start failure, so a host
|
|
1948
|
-
* fault charges the issue no implementation attempt.
|
|
1949
|
-
*/
|
|
1950
|
-
function launchIdentity(d: Pick<Deps, "workerIdentity">, launching: string): WorkerIdentity {
|
|
1951
|
-
const resolution: WorkerIdentityResolution = d.workerIdentity?.() ?? {
|
|
1952
|
-
ok: false,
|
|
1953
|
-
reason: `the ${WORKER_ACCOUNT} account is not installed on this host`,
|
|
1954
|
-
};
|
|
1955
|
-
if (resolution.ok) return resolution.identity;
|
|
1956
|
-
throw new Error(
|
|
1957
|
-
`worker identity unavailable: ${resolution.reason} — refusing to launch an unbound ${launching}; ` +
|
|
1958
|
-
"run `omp-conductor setup host` to install the dedicated worker identity",
|
|
1959
|
-
);
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
1917
|
export async function handleIssue(
|
|
1963
1918
|
d: Deps,
|
|
1964
1919
|
r: Routed,
|
|
@@ -2276,13 +2231,6 @@ export async function handleIssue(
|
|
|
2276
2231
|
if (await settleStopBeforeSession()) return;
|
|
2277
2232
|
if (await settleDrainBeforeSession()) return;
|
|
2278
2233
|
|
|
2279
|
-
// The worker identity is this run's launch gate (#798): a worker session
|
|
2280
|
-
// that cannot be launched under the dedicated unprivileged account is
|
|
2281
|
-
// indistinguishable from an operator shell, so dispatch refuses before any
|
|
2282
|
-
// tree or session is created. Resolved here rather than read off a startup
|
|
2283
|
-
// verdict, so a host that gained its account — or its harness binding
|
|
2284
|
-
// (#828) — after the daemon came up dispatches on the next tick.
|
|
2285
|
-
const identity = launchIdentity(d, "worker session");
|
|
2286
2234
|
|
|
2287
2235
|
// A run's tree is <workspaceRoot>/<issue> and addRunRepo refuses to reuse
|
|
2288
2236
|
// an existing path, so a retry — or a tree kept from a failed attempt — has
|
|
@@ -2366,10 +2314,6 @@ export async function handleIssue(
|
|
|
2366
2314
|
},
|
|
2367
2315
|
{
|
|
2368
2316
|
...(d.verbPeerReader === undefined ? {} : { peerReader: d.verbPeerReader }),
|
|
2369
|
-
// A worker-run channel is secured to the worker identity (#798): the
|
|
2370
|
-
// child that must connect to it runs as that uid, and the peer verdict
|
|
2371
|
-
// expects that uid on the wire rather than the daemon's.
|
|
2372
|
-
channelOwner: { uid: identity.uid, gid: identity.gid },
|
|
2373
2317
|
},
|
|
2374
2318
|
);
|
|
2375
2319
|
if (await settleStopBeforeSession()) return;
|
|
@@ -2436,12 +2380,6 @@ export async function handleIssue(
|
|
|
2436
2380
|
|
|
2437
2381
|
const repoSlug = githubRepo(r.repo.cloneUrl);
|
|
2438
2382
|
|
|
2439
|
-
// The run's working paths are granted to the worker identity by ownership
|
|
2440
|
-
// (#798) — the worktree the session edits and the session directory it
|
|
2441
|
-
// writes its transcript and settings into. The chown lands here, after
|
|
2442
|
-
// provisioning and the verb socket, so the session starts on a tree it
|
|
2443
|
-
// owns; the worker identity never inherits anything from root's trees.
|
|
2444
|
-
d.grantWorkerPaths?.(identity, worktreePath, sessionDir);
|
|
2445
2383
|
|
|
2446
2384
|
let result: WorkerResult;
|
|
2447
2385
|
try {
|
|
@@ -2472,7 +2410,6 @@ export async function handleIssue(
|
|
|
2472
2410
|
onChildLog: (line) => {
|
|
2473
2411
|
log(`#${issue} ${line}`);
|
|
2474
2412
|
},
|
|
2475
|
-
workerIdentity: identity,
|
|
2476
2413
|
...(choice.model === undefined ? {} : { model: choice.model }),
|
|
2477
2414
|
// The fleet-owned omp settings overlay (#537): the staged YAML the
|
|
2478
2415
|
// session loads through `Settings.init({ configFiles: [<path>] })` —
|
|
@@ -2530,7 +2467,14 @@ export async function handleIssue(
|
|
|
2530
2467
|
|
|
2531
2468
|
const verified: { state: RunState; reason?: string } =
|
|
2532
2469
|
result.state === "pushed-green"
|
|
2533
|
-
? await verifyPushedGreenClaim(tracker, result
|
|
2470
|
+
? await verifyPushedGreenClaim(tracker, result, {
|
|
2471
|
+
project: project.name,
|
|
2472
|
+
issue,
|
|
2473
|
+
runId,
|
|
2474
|
+
branch,
|
|
2475
|
+
repo: repoSlug ?? r.repo.name,
|
|
2476
|
+
store,
|
|
2477
|
+
})
|
|
2534
2478
|
: { state: result.state };
|
|
2535
2479
|
const state = verified.state;
|
|
2536
2480
|
|
|
@@ -3215,14 +3159,7 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3215
3159
|
if (await settleStopBeforeSession()) return;
|
|
3216
3160
|
if (await settleDrainBeforeSession()) return;
|
|
3217
3161
|
|
|
3218
|
-
// The same launch gate as a fresh claim (#798): a revision worker is a
|
|
3219
|
-
// worker, and an unbound one is an operator shell — refuse before any tree
|
|
3220
|
-
// or session is created. Thrown inside the try so the ordinary dispatch
|
|
3221
|
-
// catch settles it: the run row is returned to terminal `failed` with the
|
|
3222
|
-
// reason, the revision row is settled with it, and the still-green PR stays
|
|
3223
|
-
// open for a healthy retry.
|
|
3224
3162
|
try {
|
|
3225
|
-
const identity = launchIdentity(d, "review-revision worker");
|
|
3226
3163
|
// Reattach the run's own branch at the same per-issue path the run used:
|
|
3227
3164
|
// a `pushed-green` settle removed the worktree, so provisioning is the
|
|
3228
3165
|
// same continuation reattach as a normal re-claim. A capped/failed run's
|
|
@@ -3337,7 +3274,6 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3337
3274
|
},
|
|
3338
3275
|
{
|
|
3339
3276
|
...(d.verbPeerReader === undefined ? {} : { peerReader: d.verbPeerReader }),
|
|
3340
|
-
channelOwner: { uid: identity.uid, gid: identity.gid },
|
|
3341
3277
|
},
|
|
3342
3278
|
);
|
|
3343
3279
|
if (await settleStopBeforeSession()) return;
|
|
@@ -3353,10 +3289,6 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3353
3289
|
|
|
3354
3290
|
log(`#${issue} review round ${revision.round} → resuming ${branch} (${priorSessionFile})`);
|
|
3355
3291
|
|
|
3356
|
-
// The revision worker gets its own tree ownership, exactly as a fresh
|
|
3357
|
-
// claim does (#798): the resumed worktree and session directory belong to
|
|
3358
|
-
// the worker identity before its session starts.
|
|
3359
|
-
d.grantWorkerPaths?.(identity, worktreePath, sessionDir);
|
|
3360
3292
|
|
|
3361
3293
|
let result: WorkerResult;
|
|
3362
3294
|
try {
|
|
@@ -3382,7 +3314,6 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3382
3314
|
onChildLog: (line) => {
|
|
3383
3315
|
log(`#${issue} ${line}`);
|
|
3384
3316
|
},
|
|
3385
|
-
workerIdentity: identity,
|
|
3386
3317
|
// The continuation stays on the model the green run used (#286).
|
|
3387
3318
|
...(run.model === undefined ? {} : { model: run.model }),
|
|
3388
3319
|
...(ompSettingsFile === undefined ? {} : { ompSettingsFile }),
|
|
@@ -3418,7 +3349,14 @@ export async function handleReviewRevision(d: Deps, revision: ReviewRevisionReco
|
|
|
3418
3349
|
|
|
3419
3350
|
const verified: { state: RunState; reason?: string } =
|
|
3420
3351
|
result.state === "pushed-green"
|
|
3421
|
-
? await verifyPushedGreenClaim(tracker, result
|
|
3352
|
+
? await verifyPushedGreenClaim(tracker, result, {
|
|
3353
|
+
project: project.name,
|
|
3354
|
+
issue,
|
|
3355
|
+
runId,
|
|
3356
|
+
branch,
|
|
3357
|
+
repo: repoSlug ?? repo.name,
|
|
3358
|
+
store,
|
|
3359
|
+
})
|
|
3422
3360
|
: { state: result.state };
|
|
3423
3361
|
const state = verified.state;
|
|
3424
3362
|
|
|
@@ -6250,25 +6188,6 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
6250
6188
|
const store = openStore(dbPath());
|
|
6251
6189
|
const verbPeerReader = peerCredentialReader();
|
|
6252
6190
|
const verbDir = ensureVerbSocketDir(stateDir());
|
|
6253
|
-
// The worker identity (#798). Probed once here for the startup banner only —
|
|
6254
|
-
// every dispatch re-resolves it through {@link launchIdentity}, because the
|
|
6255
|
-
// account, setpriv and the harness binding (#828) are all host state that can
|
|
6256
|
-
// arrive after this process did. A host that cannot establish it keeps its
|
|
6257
|
-
// control plane running and fails each worker launch closed with the reason,
|
|
6258
|
-
// so the operator hears a concrete host change instead of a fleet that
|
|
6259
|
-
// silently ran workers as root.
|
|
6260
|
-
const identityAtStartup = resolveWorkerIdentity();
|
|
6261
|
-
if (identityAtStartup.ok) {
|
|
6262
|
-
log(
|
|
6263
|
-
`worker identity: ${identityAtStartup.identity.account} uid=${identityAtStartup.identity.uid} ` +
|
|
6264
|
-
`gid=${identityAtStartup.identity.gid} home=${identityAtStartup.identity.home}`,
|
|
6265
|
-
);
|
|
6266
|
-
} else {
|
|
6267
|
-
log(
|
|
6268
|
-
"worker identity unavailable — worker dispatch fails closed until the host provides it " +
|
|
6269
|
-
`(re-checked at every launch, so no restart is needed once it does): ${identityAtStartup.reason}`,
|
|
6270
|
-
);
|
|
6271
|
-
}
|
|
6272
6191
|
const integrity: IntegrityGate = { baseline: packageManifest(), paged: false };
|
|
6273
6192
|
const usage = sharedUsageSource();
|
|
6274
6193
|
const turnLimits = createTurnLimitRegistry((runId, maxTurns) => {
|
|
@@ -6447,16 +6366,6 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
6447
6366
|
probeCriticalBase: (repo, markers, branch) =>
|
|
6448
6367
|
probeCriticalBase(project, repo, branch, markers),
|
|
6449
6368
|
probeWorktreeLane: (input) => probeRunLane(input),
|
|
6450
|
-
// The live host, per launch — never the startup verdict above.
|
|
6451
|
-
workerIdentity: () => resolveWorkerIdentity(),
|
|
6452
|
-
// Grant each run's paths to the worker identity by ownership, before the
|
|
6453
|
-
// session that must edit them starts (#798). The recursive chown runs as
|
|
6454
|
-
// root from the dispatch path; the worker identity owns its checkout and
|
|
6455
|
-
// transcript and nothing else.
|
|
6456
|
-
grantWorkerPaths: (identity, worktreePath, sessionDir) => {
|
|
6457
|
-
chownRecursive(worktreePath, identity.uid, identity.gid);
|
|
6458
|
-
chownRecursive(sessionDir, identity.uid, identity.gid);
|
|
6459
|
-
},
|
|
6460
6369
|
// A cross-repo Depends-on prerequisite reads through the same GitHub
|
|
6461
6370
|
// credential/accounting seams as the project tracker — a fresh tracker
|
|
6462
6371
|
// scoped to the referenced repo, reusing the daemon's gh hooks so API
|
package/src/decisions.ts
CHANGED
|
@@ -79,14 +79,21 @@ export const PR_LOOKUP_WINDOW_MS = 30 * 24 * 60 * 60_000;
|
|
|
79
79
|
* read by both `conductor_pr_review` and the `pr-review-ready` watch so they
|
|
80
80
|
* cannot drift.
|
|
81
81
|
*
|
|
82
|
-
* Ownership is the
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
82
|
+
* Ownership is the newest attempt of this project that recorded the PR within
|
|
83
|
+
* the recent-history window (`runsForProjectPr`), never the issue's newest
|
|
84
|
+
* row — a requeued continuation must not hide the PR its predecessor opened
|
|
85
|
+
* (#434). A `stopped` row is transparent to that selection (#870): stopping
|
|
86
|
+
* is terminal and the attempt will never touch the PR again, so a stopped
|
|
87
|
+
* duplicate must not shadow the older revisable owner of the same PR — the
|
|
88
|
+
* row ordering that stranded PR #870's blocking findings. Every other
|
|
89
|
+
* non-revisable state still decides as the newest owner: a live row
|
|
90
|
+
* (`running` / `claimed`) hides an older settled one because a worker is in
|
|
91
|
+
* flight (#844 — PR #838 was green while its newest owner was still
|
|
92
|
+
* `running`, and a watch keyed only to checks woke the orchestrator before
|
|
93
|
+
* `conductor_pr_review` was actionable), `pushed-pending` checks are still
|
|
94
|
+
* settling, `blocked` may resume, `orphaned` is reconciled back to live at
|
|
95
|
+
* startup, and `merged` means the PR lifecycle is over. When every row is
|
|
96
|
+
* `stopped`, the newest one answers and the predicate fails closed.
|
|
90
97
|
*
|
|
91
98
|
* `no-owner` and `not-revisable` both fail closed: a review can never act, so
|
|
92
99
|
* a watch must not wake, even when the checks are green.
|
|
@@ -97,9 +104,10 @@ export type PrReviewReadiness =
|
|
|
97
104
|
| { kind: "not-revisable"; run: RunRecord };
|
|
98
105
|
|
|
99
106
|
export function prReviewReadiness(store: Store, project: string, prUrl: string, now: number): PrReviewReadiness {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
107
|
+
const runs = store.runsForProjectPr(project, prUrl, now - PR_LOOKUP_WINDOW_MS);
|
|
108
|
+
const owner = runs.find((attempt) => attempt.state !== "stopped") ?? runs.at(0);
|
|
109
|
+
if (owner === undefined) return { kind: "no-owner" };
|
|
110
|
+
return REVISABLE_RUN_STATES[owner.state] === true ? { kind: "ready", run: owner } : { kind: "not-revisable", run: owner };
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
/**
|