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/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
- if (claim.prUrl === undefined || claim.headSha === undefined) {
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
- const verification = await tracker.verifyPr(claim.prUrl, claim.headSha);
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 same newest-first resolution the review verb uses: the
83
- * newest attempt of this project that recorded the PR within the
84
- * recent-history window (`runsForProjectPr`), never the issue's newest row
85
- * a requeued continuation must not hide the PR its predecessor opened
86
- * (#434), and a newer live owner hides an older settled one. That older
87
- * settled row is exactly the shape dogfood #844 started from: PR #838 was
88
- * green while its newest owner was still `running`, and a watch keyed only to
89
- * checks woke the orchestrator before `conductor_pr_review` was actionable.
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 run = store.runsForProjectPr(project, prUrl, now - PR_LOOKUP_WINDOW_MS)[0];
101
- if (run === undefined) return { kind: "no-owner" };
102
- return REVISABLE_RUN_STATES[run.state] === true ? { kind: "ready", run } : { kind: "not-revisable", run };
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
  /**