toolroll 0.9.75 → 0.9.77

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +36 -8
  5. package/dist/approval-authority.js +6 -2
  6. package/dist/approval-rules-ui.d.ts +11 -0
  7. package/dist/approval-rules-ui.js +13 -1
  8. package/dist/assignment.js +3 -0
  9. package/dist/batch-checks.js +3 -1
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +31 -31
  13. package/dist/builder.d.ts +14 -0
  14. package/dist/builder.js +141 -42
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +267 -0
  18. package/dist/check-progress.d.ts +28 -1
  19. package/dist/check-progress.js +105 -6
  20. package/dist/check-report.d.ts +52 -6
  21. package/dist/check-report.js +193 -8
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/cli.js +8 -5
  24. package/dist/coding-handoff.d.ts +1 -0
  25. package/dist/coding-handoff.js +20 -11
  26. package/dist/coding-workspace.js +7 -5
  27. package/dist/commit-extract.d.ts +6 -0
  28. package/dist/commit-extract.js +63 -8
  29. package/dist/console-chrome.d.ts +41 -0
  30. package/dist/console-chrome.js +166 -0
  31. package/dist/contracts/console-api.d.ts +1 -0
  32. package/dist/contracts/console-api.js +1 -0
  33. package/dist/contracts/release-check.d.ts +12 -0
  34. package/dist/contracts/release-check.js +17 -0
  35. package/dist/contracts/verification-receipt.d.ts +20 -2
  36. package/dist/contracts/verification-receipt.js +15 -3
  37. package/dist/contracts/verification-view.d.ts +8 -0
  38. package/dist/controller-supervisor.d.ts +8 -0
  39. package/dist/controller-supervisor.js +33 -1
  40. package/dist/demo.js +3 -2
  41. package/dist/deploy-gate.d.ts +3 -0
  42. package/dist/deploy-gate.js +8 -2
  43. package/dist/desktop-update.d.ts +1 -1
  44. package/dist/discover.js +6 -1
  45. package/dist/dispose.d.ts +3 -0
  46. package/dist/dispose.js +6 -3
  47. package/dist/doctor.d.ts +7 -0
  48. package/dist/doctor.js +10 -2
  49. package/dist/envelope.js +2 -1
  50. package/dist/evidence.js +4 -1
  51. package/dist/exec.d.ts +7 -0
  52. package/dist/exec.js +7 -1
  53. package/dist/first-run.js +3 -1
  54. package/dist/git-floor.d.ts +16 -0
  55. package/dist/git-floor.js +36 -0
  56. package/dist/git-incident.d.ts +49 -0
  57. package/dist/git-incident.js +69 -0
  58. package/dist/git-views.d.ts +20 -0
  59. package/dist/git-views.js +120 -0
  60. package/dist/grant.js +7 -1
  61. package/dist/guides.js +4 -3
  62. package/dist/operate.d.ts +13 -0
  63. package/dist/operate.js +779 -319
  64. package/dist/planner.js +5 -1
  65. package/dist/prepared-evidence.d.ts +2 -1
  66. package/dist/prepared-evidence.js +8 -3
  67. package/dist/project-knowledge.js +6 -2
  68. package/dist/project-learning.js +23 -3
  69. package/dist/proof.js +6 -0
  70. package/dist/pull-request-flow.js +4 -0
  71. package/dist/read-executor-worker.js +8 -4
  72. package/dist/read-executor.d.ts +18 -1
  73. package/dist/read-executor.js +8 -0
  74. package/dist/release-adapters.d.ts +71 -2
  75. package/dist/release-adapters.js +471 -12
  76. package/dist/release-authority.d.ts +46 -0
  77. package/dist/release-authority.js +68 -0
  78. package/dist/release-auto.d.ts +184 -0
  79. package/dist/release-auto.js +612 -0
  80. package/dist/release-card.d.ts +15 -3
  81. package/dist/release-card.js +25 -9
  82. package/dist/release-ci-hold.d.ts +41 -0
  83. package/dist/release-ci-hold.js +53 -0
  84. package/dist/release-cli.js +77 -14
  85. package/dist/release-coverage.js +5 -2
  86. package/dist/release-recheck.d.ts +1 -1
  87. package/dist/release-recheck.js +6 -1
  88. package/dist/release.d.ts +207 -6
  89. package/dist/release.js +495 -20
  90. package/dist/repository-context.js +4 -6
  91. package/dist/result-follow-ups.js +3 -1
  92. package/dist/review-context.js +8 -6
  93. package/dist/runner.js +2 -1
  94. package/dist/sandbox-runtime.d.ts +8 -0
  95. package/dist/sandbox-runtime.js +14 -2
  96. package/dist/scope.d.ts +31 -2
  97. package/dist/scope.js +51 -4
  98. package/dist/scout.js +5 -1
  99. package/dist/secret-mint.d.ts +1 -0
  100. package/dist/secret-mint.js +14 -0
  101. package/dist/serve.js +101 -51
  102. package/dist/server/chat.js +5 -5
  103. package/dist/server/render-tasks.d.ts +12 -0
  104. package/dist/server/render-tasks.js +25 -14
  105. package/dist/server/request-context.d.ts +6 -0
  106. package/dist/server/route-table.js +3 -0
  107. package/dist/server/runtime.d.ts +2 -0
  108. package/dist/server/settings.js +29 -2
  109. package/dist/server/task-views.js +15 -2
  110. package/dist/setup-guide.d.ts +2 -2
  111. package/dist/storage.d.ts +5 -0
  112. package/dist/storage.js +28 -1
  113. package/dist/store.d.ts +11 -1
  114. package/dist/store.js +44 -5
  115. package/dist/task-base.d.ts +4 -0
  116. package/dist/task-base.js +22 -2
  117. package/dist/teardown-git.d.ts +90 -13
  118. package/dist/teardown-git.js +1027 -129
  119. package/dist/test-infra.d.ts +26 -0
  120. package/dist/test-infra.js +321 -0
  121. package/dist/up-processes.d.ts +137 -0
  122. package/dist/up-processes.js +165 -0
  123. package/dist/up-role-child.d.ts +1 -0
  124. package/dist/up-role-child.js +77 -0
  125. package/dist/verification-evidence.d.ts +1 -0
  126. package/dist/verification-evidence.js +13 -1
  127. package/dist/worktree.d.ts +4 -0
  128. package/dist/worktree.js +13 -7
  129. package/package.json +2 -1
@@ -44,7 +44,7 @@ import {} from "./render-pages.js";
44
44
  import { completionForm, hardBlockerOf, homePhaseWords, owedAcceptanceOf, resultPanelHtml } from "./render-results.js";
45
45
  import { browserCaller, oneLineUa } from "./session.js";
46
46
  export function createChatHandlers(runtime) {
47
- const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, chatCatalogKnown, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
47
+ const { firstRunStepsNow, managedRepos, leadWords, store, options, phoneSetup, visible, liveTurns, leadSaid, evidenceRoot, clock, sessions, CHAT_CANDIDATE_TTL_MS, chatFetcher, CHAT_CANDIDATES_PER_APPROVER, chatCeilingDigest, workAccess, familyOf, firstTasks, leadPrincipal, taskChatFocus, chatScopeOf, leadConversationRows, demoLeadHere, sendScreen, chromeFor, teamBrowserReply, team, teamChatProvider, runVisible, runIsLive, resultDetailOf, pullRequestTargetOf, chatEnablement, sharedIndexPage, startLeadConversation, projectFamilyPeek, needsYouBadge, liveRefreshSeconds, chatKeyFor, chatCatalog, chatCatalogKnown, providerHome, mintApprovalNonce, checkLocalAgents, agentSignInCommand, bustBadge, authenticateApprover, revisionDestination, armTaskResume, consumeApprovalNonce } = runtime;
48
48
  // The read-only refresh (package 2): the same JSON status as before —
49
49
  // session binding, the send receipt, the live turn — plus a version
50
50
  // of the DISPLAYED facts and, only when the caller's version differs,
@@ -273,7 +273,7 @@ export function createChatHandlers(runtime) {
273
273
  if (repos.length > 0) {
274
274
  try {
275
275
  fleetSnapshot = store.chatSnapshot(repos, now);
276
- const summaries = workIndexPage(store, now, { principal: 'operator', repos, includeUnplaced: false, viewer: who.name }, { limit: 100, root: evidenceRoot }).items;
276
+ const summaries = sharedIndexPage({ principal: 'operator', repos, includeUnplaced: false, viewer: who.name }, { limit: 100, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) }).items;
277
277
  fleetSnapshot.assignmentStates = Object.fromEntries(summaries.flatMap(value => [value.rootId, value.activeTaskId].map(id => [id, { state: value.assignmentState, label: value.status.label, detail: value.status.detail }])));
278
278
  fleetSnapshot.attentionCount = needsYouBadge(null);
279
279
  }
@@ -1452,9 +1452,9 @@ export function createChatHandlers(runtime) {
1452
1452
  agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt,
1453
1453
  activity: runActivityOf(store, run, now) };
1454
1454
  });
1455
- const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot });
1456
- const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot });
1457
- const done = workIndexPage(store, now, access, { view: "completed", limit: WORK_INDEX_MAX_LIMIT, root: evidenceRoot });
1455
+ // The three whole views, read at most once per workspace revision (and on a read worker first when one is attached).
1456
+ const view = (name) => sharedIndexPage(access, { view: name, limit: WORK_INDEX_MAX_LIMIT, ...(evidenceRoot === undefined ? {} : { root: evidenceRoot }) });
1457
+ const all = view("all"), needs = view("needs-you"), done = view("completed");
1458
1458
  const weekAgo = new Date(now.getTime() - 7 * 86_400_000).toISOString();
1459
1459
  const counts = [
1460
1460
  { key: "working", label: "Working now", value: agents.length, href: "/work?view=running" },
@@ -232,6 +232,11 @@ export declare function agentsStripHtml(view: RouteView | null, taskId: string):
232
232
  /** A ceremony nonce as stored: the sha256 hex of the value the form carried. */
233
233
  export declare function nonceHashOf(value: string): string;
234
234
  /** The approve POST's refusal, in words a person can act on. */
235
+ /** A release task's automatic approval, in words (docs/plans/auto-approve-green.md). */
236
+ export declare function autoReleaseWords(auto: {
237
+ kind: "checking" | "approved";
238
+ candidate: string;
239
+ }): string;
235
240
  /** " (password remembered from 10:42)" when the approval stood on a remembered password or sign-in; nothing otherwise. */
236
241
  export declare function approvalProofWords(proof: {
237
242
  method: string;
@@ -523,6 +528,13 @@ export declare function taskBodyParts(data: {
523
528
  method: string;
524
529
  at: string;
525
530
  } | null;
531
+ /** A release task the plane approved (or is checking) itself (docs/plans/auto-approve-green.md): its candidate. */
532
+ autoRelease?: {
533
+ kind: "checking" | "approved";
534
+ candidate: string;
535
+ } | null;
536
+ /** A release task filed while its pull request's CI still runs (a person may approve now; nothing deploys before CI passes). */
537
+ releaseCiRunning?: boolean;
526
538
  /** One approval per release (release-once.md): the release this task belongs to, and whether this reader may stop it. */
527
539
  release?: ReleaseView | null;
528
540
  canStopRelease?: boolean;
@@ -1,6 +1,7 @@
1
1
  /** Task pages: approval, agents, plans, controls, decisions and the task body. */
2
2
  import { networkWords } from "../egress.js";
3
3
  import { releaseAsksAgainWords, releaseTermsSentence } from "../scope.js";
4
+ import { RELEASE_CI_RUNNING_WORDS } from "../release-ci-hold.js";
4
5
  import { writesWords } from "../writes-mode.js";
5
6
  import { checkAccessWords } from "../check-access.js";
6
7
  import { sandboxTermsWords } from "../project-sandbox.js";
@@ -746,6 +747,11 @@ export function nonceHashOf(value) {
746
747
  return createHash("sha256").update(value).digest("hex");
747
748
  }
748
749
  /** The approve POST's refusal, in words a person can act on. */
750
+ /** A release task's automatic approval, in words (docs/plans/auto-approve-green.md). */
751
+ export function autoReleaseWords(auto) {
752
+ return auto.kind === "approved" ? `Approved automatically: CI and release check passed on ${auto.candidate.slice(0, 7)}`
753
+ : `Checking automatically: approves itself once CI and the release check pass on ${auto.candidate.slice(0, 7)}`;
754
+ }
749
755
  /** " (password remembered from 10:42)" when the approval stood on a remembered password or sign-in; nothing otherwise. */
750
756
  export function approvalProofWords(proof) {
751
757
  if (proof == null)
@@ -1496,12 +1502,14 @@ export function taskBodyParts(data) {
1496
1502
  scope.touches.length === 0 ? "" : html `<p class="scope-paths"><strong>Touches</strong> ${scope.touches.join(", ")}</p>`,
1497
1503
  html `<p><strong>Quality</strong> ${qualityModeTitle(scope.qualityMode ?? "default")}</p>`,
1498
1504
  acceptanceCeremonyHtml(scope.acceptance),
1499
- scope.releaseTerms == null ? "" : html `<p><strong>Release</strong></p><p class="recap">${releaseTermsSentence(scope.releaseTerms)}</p>${scope.releaseTerms.policy.carry ? html `<details><summary>What asks again</summary><ul>${joinHtml(releaseAsksAgainWords(scope.releaseTerms).map(line => html `<li>${line}</li>`), "")}</ul></details>` : ""}`,
1500
- approval.approved && data.release?.kind === "carried"
1501
- ? html `<p class="meta scope-seal">Carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a> · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
1502
- : approval.approved
1503
- ? html `<p class="meta scope-seal">Approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
1504
- : html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
1505
+ scope.releaseTerms == null ? "" : html `<p><strong>Release</strong></p><p class="recap">${releaseTermsSentence(scope.releaseTerms)}</p>${data.releaseCiRunning === true ? html `<p class="meta">${RELEASE_CI_RUNNING_WORDS}</p>` : ""}${scope.releaseTerms.policy.carry ? html `<details><summary>What asks again</summary><ul>${joinHtml(releaseAsksAgainWords(scope.releaseTerms).map(line => html `<li>${line}</li>`), "")}</ul></details>` : ""}`,
1506
+ approval.approved && data.autoRelease != null
1507
+ ? html `<p class="meta scope-seal">${autoReleaseWords(data.autoRelease)} · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
1508
+ : approval.approved && data.release?.kind === "carried"
1509
+ ? html `<p class="meta scope-seal">Carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a> · <span class="seal">signs ${shortDigest(scope.digest)}</span></p>`
1510
+ : approval.approved
1511
+ ? html `<p class="meta scope-seal">Approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)} · <span class="seal">signs ${shortDigest(scope.digest)}</span><span class="so-sr-only"> — approval binds to this exact wording</span></p>`
1512
+ : html `<p class="meta"><span class="seal">signs ${shortDigest(scope.digest)}</span> — approval binds to this exact wording</p><p class="meta">Not approved${approval.reason === "changed" ? " — approved once, then rewritten" : ""}</p>`,
1505
1513
  html `</div>`,
1506
1514
  ], "\n");
1507
1515
  // The scout's report (mate arc §10): title, summary, the document inert,
@@ -1792,11 +1800,13 @@ export function taskBodyParts(data) {
1792
1800
  : "";
1793
1801
  const scopeRow = scope === null
1794
1802
  ? prop("scope", "none yet")
1795
- : approval.approved && data.release?.kind === "carried"
1796
- ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a>`)
1797
- : approval.approved
1798
- ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)}`)
1799
- : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
1803
+ : approval.approved && data.autoRelease != null
1804
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${autoReleaseWords(data.autoRelease)}`)
1805
+ : approval.approved && data.release?.kind === "carried"
1806
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · carried from ${data.release.approver ?? "a person"}'s release approval · <a href="${taskHref(data.release.anchorTask)}">Release ${data.release.version}</a>`)
1807
+ : approval.approved
1808
+ ? prop("approved scope", html `<span class="seal">signs ${shortDigest(scope.digest)}</span> · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)} · ${whenTime(approval.at)}`)
1809
+ : prop("scope", approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved");
1800
1810
  const strikesRow = data.strikes > 0 ? prop("strikes", `${data.strikes} failed attempt(s)`) : "";
1801
1811
  const propsCard = html `<div class="card props">${workerRow}${queueRow}${scopeRow}${publishesRow}${economics}${strikesRow}</div>`;
1802
1812
  // The same facts for the rebuilt page, row for row.
@@ -1811,9 +1821,10 @@ export function taskBodyParts(data) {
1811
1821
  facts.push({ label: "Queue", parts: [`${data.position.position} of ${data.position.total}${data.position.column === null ? " in the shared queue" : ` in ${data.position.column}'s queue`} · `, { label: "Reorder", href: "/board?view=order" }] });
1812
1822
  }
1813
1823
  facts.push(scope === null ? { label: "Scope", parts: ["none yet"] }
1814
- : approval.approved && data.release?.kind === "carried" ? { label: "Approved scope", parts: [`Carried from ${data.release.approver ?? "a person"}'s release approval · `, { label: `Release ${data.release.version}`, href: taskHref(data.release.anchorTask) }] }
1815
- : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
1816
- : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
1824
+ : approval.approved && data.autoRelease != null ? { label: "Approved scope", parts: [autoReleaseWords(data.autoRelease)] }
1825
+ : approval.approved && data.release?.kind === "carried" ? { label: "Approved scope", parts: [`Carried from ${data.release.approver ?? "a person"}'s release approval · `, { label: `Release ${data.release.version}`, href: taskHref(data.release.anchorTask) }] }
1826
+ : approval.approved ? { label: "Approved scope", parts: [{ seal: `signs ${scope.digest.length <= 12 ? scope.digest : `${scope.digest.slice(0, 12)}…`}` }, ` · ${qualityModeTitle(scope.qualityMode ?? "default")} · approved by ${approval.by}${approvalProofWords(data.approvalProof)}`, ...(approval.at === null ? [] : [" · ", { at: approval.at }])] }
1827
+ : { label: "Scope", parts: [approval.reason === "changed" ? "rewritten since its approval — needs a new yes" : "not approved"] });
1817
1828
  if (data.release?.kind === "anchor")
1818
1829
  facts.push({ label: "Release", parts: [`Approved for release ${data.release.version}`, ...(data.release.landed.length === 0 ? [] : [` · later commits: ${data.release.landed.map(one => one.sha.slice(0, 7)).join(", ")}`]), ...(data.release.carrying ? [] : [` · later commits ask again`])] });
1819
1830
  facts.push({ label: "Publishes as", parts: [publishesAs] });
@@ -20,12 +20,18 @@ export type RequestFacts = {
20
20
  createdTask?: string;
21
21
  browser?: boolean;
22
22
  workspaceRead?: boolean;
23
+ /** Receipt reconciliation, task and result reads (`request`, `task`, `result`): work is read afresh, never cached. */
24
+ freshWork?: boolean;
25
+ /** A workspace re-read's If-None-Match: the content validator it holds from its last read. */
26
+ workspaceIfNoneMatch?: string;
23
27
  workspaceRequest?: string | null;
24
28
  workCounts?: ReturnType<typeof workCountsByProject>;
25
29
  workCrew?: {
26
30
  project: string | null;
27
31
  page: WorkIndexPage;
28
32
  };
33
+ /** The chrome this request already read (console-chrome.ts keys): one snapshot per request, however often it asks. */
34
+ chromeReads?: Map<string, unknown>;
29
35
  lens?: string | null;
30
36
  workspaceValidator?: {
31
37
  key: string;
@@ -303,6 +303,9 @@ post("tasks", "sweep.cancel-rest", "^\\/sweeps\\/[1-9][0-9]{0,9}\\/cancel-rest$"
303
303
  // (the handler proves it as a credential act); turning it off is one click, so the row itself is not a step-up.
304
304
  get("settings", "settings.passwordless", "/settings/passwordless", { ...free, ...operator("An instance operator sets Passwordless.", "/settings") });
305
305
  post("settings", "settings.passwordless-send", "/settings/passwordless", { callers: ["cookie"], ...operator("An instance operator sets Passwordless.", "/settings") });
306
+ // Release approval (docs/plans/auto-approve-green.md): an approver of the project, from a browser. Approving releases
307
+ // automatically takes a typed password (the handler proves it as a credential act); asking again is one click.
308
+ post("settings", "settings.release-approval-send", "/settings/release-approval", { callers: ["cookie"], ...approver("An approver sets how releases are approved.", "/settings/approval") });
306
309
  post("pages", "ledger.checkpoint", "/ledger/checkpoint", operator("An instance operator makes ledger checkpoints.", "/ledger"));
307
310
  const SETTINGS_ACT_ROLES = {
308
311
  policy: operator("An instance operator sets the policy.", "/settings/policy"),
@@ -153,6 +153,8 @@ export interface ServerRuntime {
153
153
  saturated: boolean;
154
154
  };
155
155
  liveRefreshSeconds: () => number;
156
+ /** One work index page for an already-admitted reader, read at most once per workspace revision (console-chrome.ts). */
157
+ sharedIndexPage: (access: import("../work-summary.js").WorkSummaryAccess, options: import("../work-index.js").WorkIndexOptions) => import("../work-index.js").WorkIndexPage;
156
158
  chatKeyFor: (provider: DirectChatProviderId) => {
157
159
  key: string;
158
160
  source: "environment" | "stored";
@@ -39,7 +39,8 @@ import { browserCaller } from "./session.js";
39
39
  import { DEFAULT_ACCENT, normalHex } from "../accent-colors.js";
40
40
  import { resolvePhaseAgent } from "../agentconfig.js";
41
41
  import { parseProtectedPaths } from "../approval-policy.js";
42
- import { approvalRulesHtml } from "../approval-rules-ui.js";
42
+ import { approvalRulesHtml, releaseApprovalHtml } from "../approval-rules-ui.js";
43
+ import { releaseApprovalSetting, setReleaseApproval } from "../release-auto.js";
43
44
  import { backupHtml } from "../backup-ui.js";
44
45
  import { BACKUP_EVERY_HOURS, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder, MAX_KEEP } from "../backup.js";
45
46
  import { projectBatchChecks, setProjectBatchChecks } from '../batch-policy.js';
@@ -366,7 +367,8 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
366
367
  return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
367
368
  const selector = projects.length > 1 ? html `<form class="approval-project" method="get" action="/settings/approval"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? " selected" : ""}>${projectName(p)}</option>`)}</select></label><button>Show</button></form>` : "";
368
369
  const approvers = chosen === "" ? 0 : store.listApprovers().filter(one => { const account = store.accountOf(one.name); return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(one.name, chosen); }).length;
369
- const content = chosen === "" ? html `<p>Add a project to set its approval rules.</p>` : (approvalRulesHtml({ repo: chosen, name: projectName(chosen), rules: store.approvalRules(chosen), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), approvers }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") }));
370
+ const release = chosen === "" ? null : releaseApprovalSetting(store, chosen);
371
+ const content = chosen === "" ? html `<p>Add a project to set its approval rules.</p>` : html `${approvalRulesHtml({ repo: chosen, name: projectName(chosen), rules: store.approvalRules(chosen), canChange: who.via === "cookie" && store.isInstanceOperator(who.name), approvers }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}${releaseApprovalHtml({ repo: chosen, auto: release.auto, setBy: release.setBy, at: release.at, canChange: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, chosen) })}`;
370
372
  return sendScreen(response, 200, screen("Approval rules", html `<p><a href="/settings">Settings</a></p><h1>Approval rules</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
371
373
  }
372
374
  // Sprint 8: Settings → Policy, the organisation policy and its history. Anyone signed in reads it; an instance operator changes it.
@@ -2538,6 +2540,7 @@ ${modeWords(terms)
2538
2540
  "settings.sign-in": signInPage,
2539
2541
  "settings.passwordless": passwordlessPage,
2540
2542
  "settings.passwordless-send": passwordlessSave,
2543
+ "settings.release-approval-send": releaseApprovalSave,
2541
2544
  "settings.lead": leadPage,
2542
2545
  "settings.teams": teamsPage,
2543
2546
  "settings.discord": discordPage,
@@ -2725,5 +2728,29 @@ ${modeWords(terms)
2725
2728
  return back("problem", "An approver for this project changes its sandbox. Nothing changed.");
2726
2729
  return back("said", JSON.stringify(changed.before) === JSON.stringify(changed.after) ? "Nothing changed." : "Saved. Tasks scoped from now on use it; tasks already approved keep what they were approved with.");
2727
2730
  }
2731
+ // Release approval (docs/plans/auto-approve-green.md): an approver of the project. Approving automatically is a
2732
+ // credential act (a password typed now, never a session); asking again is one click and takes back automatic seals.
2733
+ async function releaseApprovalSave(ctx) {
2734
+ const { response, now, posted } = ctx;
2735
+ const who = browserCaller(ctx.who);
2736
+ const body = readForm(posted, CONSOLE_FORMS.releaseApproval);
2737
+ const repo = body.get("repo") ?? "";
2738
+ if (!consoleProjects().includes(repo) || !visible(repo))
2739
+ return refuse(response, who, 403, "That project is outside your access.", "/settings/approval");
2740
+ const back = sayTo(response, `/settings/approval?repo=${encodeURIComponent(repo)}`);
2741
+ if (who.role !== "approver" || store.isDemo() || !store.accountCanAccess(who.name, repo))
2742
+ return back("problem", "An approver of this project sets how its releases are approved.");
2743
+ const action = body.get("action");
2744
+ if (action === "ask") {
2745
+ const changed = setReleaseApproval(store, repo, false, who.name, now);
2746
+ return back("said", !changed.changed ? "Saved." : `Releases ask you again.${changed.withdrawn.length === 0 ? "" : ` ${changed.withdrawn.length} automatic approval${changed.withdrawn.length === 1 ? " was" : "s were"} taken back.`}`);
2747
+ }
2748
+ if (action !== "auto")
2749
+ return back("problem", "Choose Ask me or Approve automatically when checks pass.");
2750
+ if (!authenticateCredential(who, body.get("password") ?? "", repo).ok)
2751
+ return back("problem", "Enter your Toolroll password to approve releases automatically.");
2752
+ const changed = setReleaseApproval(store, repo, true, who.name, now);
2753
+ return back("said", changed.changed ? "Releases approve themselves once CI and the release check pass." : "Saved.");
2754
+ }
2728
2755
  return { registrations, modelsView };
2729
2756
  }
@@ -1,9 +1,10 @@
1
1
  import { releaseViewOf } from "../release-view.js";
2
+ import { releaseCiRunningOf } from "../release-ci-hold.js";
2
3
  import { releaseObligationsOf } from "../release-recheck.js";
3
4
  import { approvalProofOf, freshlyApprovable, freshnessFor, withFreshness } from "../scope.js";
4
5
  import { liveAttemptOf, steerControlsFor } from "../steer-controls.js";
5
6
  import { originOf } from "../task-source.js";
6
- import { taskBaseOf } from "../task-base.js";
7
+ import { recentGitReads, taskBaseOf } from "../task-base.js";
7
8
  import { assignmentPresentationOf, historicalAssessmentReason } from '../assignment-presentation.js';
8
9
  import { html, joinHtml } from "../html.js";
9
10
  import { assignmentStatusOf, assignmentWithEvidence } from '../assignment-ui.js';
@@ -53,6 +54,9 @@ import {} from "../store.js";
53
54
  import { taskControlOf } from "../task-control.js";
54
55
  import { lastErrorLineOf, missedRequirementOf, workStatusOf } from "../workspace-ui.js";
55
56
  export function createTaskViews(runtime) {
57
+ // The task page's base line reads Git through a frozen view of the project (~0.2-0.5 s on a large repository) and
58
+ // the page renders it twice per view and again on every live refresh: its answers are kept for a moment.
59
+ const baseGit = recentGitReads();
56
60
  /** The first task's way to Ready shows until the first Ready result, and stays on the installation's first task. */
57
61
  function guided(rootId, now) {
58
62
  if (runtime.store.isDemo())
@@ -491,11 +495,13 @@ export function createTaskViews(runtime) {
491
495
  return { rows, decisions: decisionsFor(runtime.store, rows), now };
492
496
  })(),
493
497
  steering: ref === null ? [] : runtime.store.listSteerNotes(ref.id),
494
- base: taskBaseOf(runtime.store, taskId),
498
+ base: taskBaseOf(runtime.store, taskId, baseGit),
495
499
  steeringLive: ref !== null && runtime.store.steeringLiveBuild(ref.id, now) !== null,
496
500
  steeringRun: ref === null ? null : runtime.store.steeringLiveBuild(ref.id, now),
497
501
  release: ref === null ? null : releaseViewOf(runtime.store, ref.externalId, now, authority => releaseObligationsOf(runtime.store, runtime.evidenceRoot, authority)),
498
502
  canStopRelease: who.role === "approver" && who.via === "cookie",
503
+ autoRelease: ref === null ? null : autoReleaseOf(runtime.store.getScope(ref.externalId)),
504
+ releaseCiRunning: ref !== null && releaseCiRunningOf(runtime.store, ref.externalId) !== null,
499
505
  approvalProof: (() => {
500
506
  // The proof of the approval shown: its approver, its exact sealed digest (approvedDigest === digest when approved).
501
507
  const scope = ref === null ? null : runtime.store.getScope(ref.externalId);
@@ -1139,3 +1145,10 @@ export function createTaskViews(runtime) {
1139
1145
  }
1140
1146
  return { guided, projectFamilyPeek, familiesInView, familyTasksInView, revisionDestination, earlierLiveVersions, familyHistory, freshAssignment, workRowOf, taskListPane, runIsLive, runIsTaskResult, revisionViewOf, taskViewData, routeViewOf, taskChatFocus, pullRequestTargetOf, taskPullRequestOf, failureOf, explainAttempt, runChecksHere, taskScreen, armTaskResume, planViewOf, planContractViewOf, revisionDocOf, revisionLedgerOf, progressOf, resultDetailOf };
1141
1147
  }
1148
+ /** A release task the plane approved (or is checking) itself, for the task page (docs/plans/auto-approve-green.md). */
1149
+ function autoReleaseOf(scope) {
1150
+ if (scope === null || scope.approvedDigest !== scope.digest || (scope.candidate ?? null) === null)
1151
+ return null;
1152
+ return scope.approvalBasis === "auto-green" ? { kind: "approved", candidate: scope.candidate }
1153
+ : scope.approvalBasis === "auto-check" ? { kind: "checking", candidate: scope.candidate } : null;
1154
+ }
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/storage.d.ts CHANGED
@@ -36,3 +36,8 @@ export declare function storageReport(store: Store, databaseFile: string, checko
36
36
  lines: StorageLine[];
37
37
  };
38
38
  export declare function bytesWords(bytes: number): string;
39
+ /** diskBytes without holding the event loop: the same one `du` per 200 paths and the same bounds, awaited. A console
40
+ * measuring its checkouts (tens of GB) used to stall every page for as long as `du` took. */
41
+ export declare function diskBytesAsync(paths: readonly string[], run?: typeof duOnce): Promise<Map<string, number>>;
42
+ /** `du -sk` over a batch, its output whatever it managed (a slow disk or a failure answers what it has, or nothing). */
43
+ declare function duOnce(batch: readonly string[]): Promise<string>;
package/dist/storage.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * what slips through: stale test temp folders, leftover processes, extra
8
8
  * staged runtimes and dependency installs unused for a week.
9
9
  */
10
- import { spawnSync } from "node:child_process";
10
+ import { execFile, spawnSync } from "node:child_process";
11
11
  import { lstatSync, readdirSync } from "node:fs";
12
12
  import { basename, dirname, join } from "node:path";
13
13
  import { DEPS_FOLDER, sharedCopies } from "./shared-deps.js";
@@ -190,3 +190,30 @@ export function bytesWords(bytes) {
190
190
  return `${Math.round(bytes / 1024 ** 2)} MB`;
191
191
  return `${Math.round(bytes / 1024)} KB`;
192
192
  }
193
+ /** diskBytes without holding the event loop: the same one `du` per 200 paths and the same bounds, awaited. A console
194
+ * measuring its checkouts (tens of GB) used to stall every page for as long as `du` took. */
195
+ export async function diskBytesAsync(paths, run = duOnce) {
196
+ const sizes = new Map();
197
+ if (paths.length === 0)
198
+ return sizes;
199
+ if (process.platform !== "win32") {
200
+ for (let at = 0; at < paths.length; at += 200) {
201
+ const batch = paths.slice(at, at + 200);
202
+ for (const line of (await run(batch)).split("\n")) {
203
+ const match = /^(\d+)\t(.+)$/.exec(line);
204
+ if (match !== null && batch.includes(match[2]))
205
+ sizes.set(match[2], Number(match[1]) * 1024);
206
+ }
207
+ }
208
+ }
209
+ for (const path of paths)
210
+ if (!sizes.has(path))
211
+ sizes.set(path, treeBytes(path));
212
+ return sizes;
213
+ }
214
+ /** `du -sk` over a batch, its output whatever it managed (a slow disk or a failure answers what it has, or nothing). */
215
+ function duOnce(batch) {
216
+ return new Promise(resolve => {
217
+ execFile("du", ["-sk", ...batch], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 60_000 }, (_error, stdout) => resolve(typeof stdout === "string" ? stdout : ""));
218
+ });
219
+ }
package/dist/store.d.ts CHANGED
@@ -2355,7 +2355,7 @@ export declare class Store {
2355
2355
  * exactly what the signed digest was bound to. Returns false when the
2356
2356
  * scope vanished mid-ceremony. */
2357
2357
  sealScopeApproval(taskId: string, by: string, now: Date, mutation?: Mutation, basis?: {
2358
- kind: "mode" | "chat" | "grant" | "schedule";
2358
+ kind: "mode" | "chat" | "grant" | "schedule" | "auto-check" | "auto-green";
2359
2359
  modeDigest: string;
2360
2360
  /** A chat seal of a deploy gate: the release card it came from (chat-management.md §0a.5). */
2361
2361
  card?: "release";
@@ -7780,3 +7780,13 @@ export type AgentsLiveRun = Run & {
7780
7780
  * Visibility is the caller's: it filters each by the console's ceiling and the viewer's access.
7781
7781
  */
7782
7782
  export declare function agentsLiveRuns(store: Store, endedSince: Date): AgentsLiveRun[];
7783
+ /**
7784
+ * This runner's watches a predecessor left needing recovery (Codex review round 1, finding 2): a watch lease still
7785
+ * live, or one whose holder left a claim open (or a run open under a claim a reaper already released). A replacement
7786
+ * worker takes each over — recovering the holder's incarnation in the same transaction — before it admits new work,
7787
+ * whichever projects its start names (a project being removed when the predecessor died is in none of them).
7788
+ */
7789
+ export declare function predecessorWatches(store: Store, runner: string, now: Date): {
7790
+ repo: string;
7791
+ owner: string;
7792
+ }[];
package/dist/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mintSecret } from "./secret-mint.js";
1
2
  import { WRITES_DEFAULT } from "./writes-mode.js";
2
3
  import { checkAccessOf, widerThan } from "./check-access.js";
3
4
  import { PROJECT_SANDBOX_DEFAULT, sandboxOf } from "./project-sandbox.js";
@@ -72,6 +73,7 @@ import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision
72
73
  import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
73
74
  import { foldReview, manualReviewOnly } from "./proof.js";
74
75
  import { liveReleaseOf } from "./scope.js";
76
+ import { releaseCiHoldOf, releaseCiRunningOf } from "./release-ci-hold.js";
75
77
  import { APPROVAL_BASES, approvalOf, machineActing, personProof, proofDetailOf, quarantinedAuthor, sealOriginOf, digestOf, canonicalProfileJson, chainFromJson, profileDigestOf, profileFromJson, scopeAuthorityOf, routeParityProblem, parseAcceptanceCriteria, exactAcceptance, exactStringList, exactSafeIntegerOrNull } from "./scope.js";
76
78
  import { resolveScopeProfile, resolveRouteCandidates, exactPinOf, routeOfTask, agentChoicesFor } from "./agentconfig.js";
77
79
  import { canonicalOverridesJson, canonicalRouteJson, isRiskLevel, isTaskSize, headroomFrom, makesNoPlan, legOf, overridesFromJson, phaseOfRole, recommendRoute, routeDigestOf, routeFromJson, routeProblems, routeStampProblem, ROUTE_ERA, PROVIDER_ID_LIST, } from "./phase-routing.js";
@@ -6932,6 +6934,10 @@ export class Store {
6932
6934
  // road it came by.
6933
6935
  if (basis === undefined && machineActing())
6934
6936
  return false;
6937
+ // A RELEASE WAITING FOR CI (Codex review round 1, finding 4): filed while its pull request's checks still ran,
6938
+ // only the automatic road (which proves CI itself) seals it until the plane read exact-head CI passing.
6939
+ if (basis?.kind !== "auto-check" && basis?.kind !== "auto-green" && releaseCiHoldOf(this, taskId) !== null)
6940
+ return false;
6935
6941
  // `carried` is written by the carry transaction alone (carryReleaseApproval), never through a seal.
6936
6942
  if (basis?.kind === "carried")
6937
6943
  return false;
@@ -7014,7 +7020,17 @@ export class Store {
7014
7020
  const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null;
7015
7021
  if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
7016
7022
  return false;
7017
- if (terms.terms !== null && basis !== undefined && basis.kind !== "chat")
7023
+ // AUTOMATIC RELEASE SEALS (auto-approve-green.md): only on a release task (a candidate and release terms signed
7024
+ // in), only naming the evidence row the plane wrote for exactly this task, kind, digest and candidate.
7025
+ const automatic = basis?.kind === "auto-check" || basis?.kind === "auto-green";
7026
+ if (automatic) {
7027
+ if (terms.terms === null || typeof working["candidate"] !== "string" || working["candidate"] === "")
7028
+ return false;
7029
+ const evidence = autoEvidenceOf(this, taskId, basis.modeDigest, basis.kind);
7030
+ if (evidence === null || evidence.digest !== String(working["digest"]) || evidence.candidate !== working["candidate"] || evidence.by !== by)
7031
+ return false;
7032
+ }
7033
+ if (terms.terms !== null && basis !== undefined && basis.kind !== "chat" && !automatic)
7018
7034
  return false;
7019
7035
  if (terms.terms !== null && releaseSealProblem(this, terms.terms) !== null)
7020
7036
  return false;
@@ -7033,7 +7049,8 @@ export class Store {
7033
7049
  approved_route_json = proposed_route_json
7034
7050
  WHERE task_id = ?`)
7035
7051
  .run(now.toISOString(), by, basis === undefined ? "password" : basis.kind, basis === undefined ? null : basis.modeDigest, taskId);
7036
- if (changed.changes > 0 && terms.ok && terms.terms !== null) {
7052
+ // `auto-check` only lets the check run: it opens no release authority (only `auto-green` and a person's yes do).
7053
+ if (changed.changes > 0 && terms.ok && terms.terms !== null && basis?.kind !== "auto-check") {
7037
7054
  if (openReleaseAuthority(this, { taskId, digest: String(working["digest"]), terms: terms.terms, by, now }) === null)
7038
7055
  throw new ReleaseSealRefused();
7039
7056
  }
@@ -7066,7 +7083,11 @@ export class Store {
7066
7083
  ? "Approved automatically: its flow runs on its own. A connected worker can take it next."
7067
7084
  : basis.kind === "schedule"
7068
7085
  ? "Approved by its standing order. A connected worker can take it next."
7069
- : "Approved automatically under the operating mode. A connected worker can take it next.",
7086
+ : basis.kind === "auto-check"
7087
+ ? "Checking automatically: it approves itself once CI and the release check pass."
7088
+ : basis.kind === "auto-green"
7089
+ ? "Approved automatically: CI and the release check passed."
7090
+ : "Approved automatically under the operating mode. A connected worker can take it next.",
7070
7091
  link: chatControlHref("task", taskId),
7071
7092
  }, now);
7072
7093
  }
@@ -8003,7 +8024,7 @@ export class Store {
8003
8024
  /** A single-use door into the instance (U2): 128-bit token, sha256
8004
8025
  * stored, role pinned at mint, 72 hours. Only the caller's own
8005
8026
  * authentication gates this — an approver mints; a viewer cannot. */
8006
- mintInvite(role, mintedBy, now, token = () => randomBytes(16).toString("base64url"), projects = null) {
8027
+ mintInvite(role, mintedBy, now, token = () => mintSecret(16), projects = null) {
8007
8028
  const access = normalizeProjectAccess(projects);
8008
8029
  return this.transact(() => {
8009
8030
  if (!this.isInstanceOperator(mintedBy))
@@ -20422,7 +20443,7 @@ export function serializeReleaseTerms(terms) {
20422
20443
  }
20423
20444
  // ---- one approval per release: the authority record (release-once.md, part 1) --------------------------------------
20424
20445
  export { LIVE_RELEASE_STATES, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseAuthorityOfTask, releaseCarries, releaseCarryTo, releaseOperation, releaseOperations, releaseSealProblem, } from "./release-authority.js";
20425
- import { LIVE_RELEASE_STATES, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseCarryTo, releaseOperation, releaseSealProblem, } from "./release-authority.js";
20446
+ import { LIVE_RELEASE_STATES, autoEvidenceOf, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseCarryTo, releaseOperation, releaseSealProblem, } from "./release-authority.js";
20426
20447
  /**
20427
20448
  * Open a release's authority for a person's seal of its terms (inside the seal's transaction): the next generation,
20428
20449
  * superseding the live one. False when it can't (releaseSealProblem), which refuses the seal.
@@ -20492,6 +20513,9 @@ export function reserveRelease(store, input) {
20492
20513
  const a = releaseAuthority(store, input.authority);
20493
20514
  if (a === null)
20494
20515
  return { ok: false, why: `No release ${input.authority}.` };
20516
+ // Early approval authorizes the check, never an irreversible operation before the plane confirms exact-head CI.
20517
+ if (releaseCiRunningOf(store, input.taskId) !== null)
20518
+ return { ok: false, why: "This release still waits for GitHub CI. Resume it with `toolroll release`." };
20495
20519
  const at = input.now.toISOString();
20496
20520
  // Expiry stops every live state; an operation already reserved settles only through reconciliation.
20497
20521
  if (a.expiresAt <= at)
@@ -20533,6 +20557,8 @@ export function runReleaseOperation(store, id, epoch, now, issuer = currentRelea
20533
20557
  const a = op === null ? null : releaseAuthority(store, op.authority);
20534
20558
  if (op === null || a === null || op.state !== "reserved" || op.epoch !== epoch || a.leaseEpoch !== epoch)
20535
20559
  return false;
20560
+ if (releaseCiRunningOf(store, op.taskId) !== null)
20561
+ return false;
20536
20562
  // Past the expiry nothing new starts; the reserved operation is settled by reconciliation instead.
20537
20563
  if (a.expiresAt <= now.toISOString())
20538
20564
  return false;
@@ -20843,3 +20869,16 @@ export function agentsLiveRuns(store, endedSince) {
20843
20869
  repo: row["live_task_repo"] === null || row["live_task_repo"] === undefined ? null : String(row["live_task_repo"]),
20844
20870
  }));
20845
20871
  }
20872
+ /**
20873
+ * This runner's watches a predecessor left needing recovery (Codex review round 1, finding 2): a watch lease still
20874
+ * live, or one whose holder left a claim open (or a run open under a claim a reaper already released). A replacement
20875
+ * worker takes each over — recovering the holder's incarnation in the same transaction — before it admits new work,
20876
+ * whichever projects its start names (a project being removed when the predecessor died is in none of them).
20877
+ */
20878
+ export function predecessorWatches(store, runner, now) {
20879
+ return store.handle.prepare(`SELECT w.repo AS repo, w.owner AS owner FROM watch_lease w
20880
+ WHERE w.runner = ? AND (w.expires_at > ? OR EXISTS (
20881
+ SELECT 1 FROM claim c WHERE c.runner = w.runner AND c.incarnation = w.owner
20882
+ AND (c.released_at IS NULL OR EXISTS (SELECT 1 FROM run r WHERE r.lease_id = c.lease_id AND r.outcome IS NULL))))
20883
+ ORDER BY w.repo`).all(runner, now.toISOString()).map(row => ({ repo: String(row["repo"]), owner: String(row["owner"]) }));
20884
+ }
@@ -33,3 +33,7 @@ export declare function freshBaseFor(store: Store, taskId: string, branch: strin
33
33
  };
34
34
  } | Record<string, never>;
35
35
  export declare function taskBaseOf(store: Store, taskId: string, git?: GitRead): TaskBaseView | null;
36
+ /** `git`'s answers kept for `ttlMs` (at most `entries` of them), for a page that shows the base line on every refresh:
37
+ * each read still goes through the same frozen view when it runs; only its answer is reused for a moment. Display only —
38
+ * never for a decision. */
39
+ export declare function recentGitReads(git?: GitRead, ttlMs?: number, entries?: number, now?: () => number): GitRead;
package/dist/task-base.js CHANGED
@@ -4,10 +4,13 @@
4
4
  * branch's pinned origin, or the newest plan's base — and the behind count is
5
5
  * read from Git now. Reads only.
6
6
  */
7
- import { execFileSync } from "node:child_process";
7
+ import { viewedGitSync } from "./teardown-git.js";
8
8
  export const readGit = (cwd, args) => {
9
9
  try {
10
- return execFileSync("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 65_536 }).trim();
10
+ // Every git version through a frozen read view, bounded (eval-harness review 1, finding 7): a partial clone, an
11
+ // include or a command-naming key is refused from the config git then runs with.
12
+ const ran = viewedGitSync(cwd, args, { timeoutMs: 5_000, maxBuffer: 65_536 });
13
+ return ran.status === 0 ? ran.stdout.trim() : null;
11
14
  }
12
15
  catch {
13
16
  return null;
@@ -49,3 +52,20 @@ export function taskBaseOf(store, taskId, git = readGit) {
49
52
  const view = { base, branch, main: behind === null ? null : main, behind };
50
53
  return { ...view, words: `Base ${baseStandingWords(view)}` };
51
54
  }
55
+ /** `git`'s answers kept for `ttlMs` (at most `entries` of them), for a page that shows the base line on every refresh:
56
+ * each read still goes through the same frozen view when it runs; only its answer is reused for a moment. Display only —
57
+ * never for a decision. */
58
+ export function recentGitReads(git = readGit, ttlMs = 15_000, entries = 64, now = Date.now) {
59
+ const kept = new Map();
60
+ return (cwd, args) => {
61
+ const key = JSON.stringify([cwd, args]), at = now(), hit = kept.get(key);
62
+ if (hit !== undefined && at - hit.at < ttlMs && at >= hit.at)
63
+ return hit.value;
64
+ const value = git(cwd, args);
65
+ kept.delete(key);
66
+ if (kept.size >= entries)
67
+ kept.delete(kept.keys().next().value);
68
+ kept.set(key, { at, value });
69
+ return value;
70
+ };
71
+ }