toolroll 0.9.76 → 0.9.78

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 (144) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +11 -4
  3. package/THIRD_PARTY_NOTICES.md +5 -5
  4. package/dist/agent-fence.js +46 -10
  5. package/dist/assignment.js +3 -0
  6. package/dist/backup-ui.d.ts +9 -1
  7. package/dist/backup-ui.js +28 -3
  8. package/dist/backup.js +4 -3
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
  10. package/dist/browser/workspace.css +2 -2
  11. package/dist/browser/workspace.js +31 -31
  12. package/dist/browser-workspace.d.ts +2 -0
  13. package/dist/builder.d.ts +18 -3
  14. package/dist/builder.js +152 -47
  15. package/dist/chat-decide.js +4 -0
  16. package/dist/check-failure.d.ts +92 -0
  17. package/dist/check-failure.js +295 -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 +67 -6
  21. package/dist/check-report.js +254 -9
  22. package/dist/checkout-cleanup.js +2 -2
  23. package/dist/claim.d.ts +9 -0
  24. package/dist/claim.js +9 -1
  25. package/dist/cli.js +8 -5
  26. package/dist/coding-handoff.d.ts +1 -0
  27. package/dist/coding-handoff.js +20 -11
  28. package/dist/commit-extract.d.ts +6 -0
  29. package/dist/commit-extract.js +63 -8
  30. package/dist/console-chrome.d.ts +41 -0
  31. package/dist/console-chrome.js +166 -0
  32. package/dist/contracts/console-api.d.ts +1 -1
  33. package/dist/contracts/console-api.js +1 -1
  34. package/dist/contracts/release-check.d.ts +12 -0
  35. package/dist/contracts/release-check.js +17 -0
  36. package/dist/contracts/release-terms.d.ts +41 -0
  37. package/dist/contracts/release-terms.js +53 -0
  38. package/dist/contracts/verification-receipt.d.ts +20 -2
  39. package/dist/contracts/verification-receipt.js +15 -3
  40. package/dist/contracts/verification-view.d.ts +8 -0
  41. package/dist/controller-service.js +4 -1
  42. package/dist/controller-supervisor.d.ts +8 -0
  43. package/dist/controller-supervisor.js +33 -1
  44. package/dist/demo.js +3 -2
  45. package/dist/deploy-gate.js +5 -0
  46. package/dist/deploy-rescue.d.ts +41 -0
  47. package/dist/deploy-rescue.js +94 -0
  48. package/dist/desktop-update.d.ts +1 -1
  49. package/dist/dispose.d.ts +3 -0
  50. package/dist/dispose.js +9 -5
  51. package/dist/doctor.d.ts +7 -0
  52. package/dist/doctor.js +10 -2
  53. package/dist/envelope.js +2 -1
  54. package/dist/evidence.d.ts +9 -0
  55. package/dist/evidence.js +19 -4
  56. package/dist/exec.d.ts +9 -0
  57. package/dist/exec.js +22 -2
  58. package/dist/git-floor.d.ts +16 -0
  59. package/dist/git-floor.js +36 -0
  60. package/dist/grant.js +5 -1
  61. package/dist/guides.js +22 -11
  62. package/dist/latency-timeline.d.ts +46 -0
  63. package/dist/latency-timeline.js +120 -0
  64. package/dist/live-backup.d.ts +39 -0
  65. package/dist/live-backup.js +271 -0
  66. package/dist/operate.d.ts +13 -0
  67. package/dist/operate.js +969 -328
  68. package/dist/planner.js +2 -2
  69. package/dist/prepared-evidence.d.ts +2 -1
  70. package/dist/prepared-evidence.js +8 -3
  71. package/dist/project-knowledge.js +6 -2
  72. package/dist/project-learning.js +2 -2
  73. package/dist/proof.js +6 -0
  74. package/dist/read-executor-worker.js +8 -4
  75. package/dist/read-executor.d.ts +18 -1
  76. package/dist/read-executor.js +8 -0
  77. package/dist/release-adapters.d.ts +80 -2
  78. package/dist/release-adapters.js +441 -16
  79. package/dist/release-authority.js +6 -2
  80. package/dist/release-auto.d.ts +38 -7
  81. package/dist/release-auto.js +131 -15
  82. package/dist/release-card.d.ts +15 -3
  83. package/dist/release-card.js +25 -9
  84. package/dist/release-carry.d.ts +21 -1
  85. package/dist/release-carry.js +22 -0
  86. package/dist/release-ci-hold.d.ts +41 -0
  87. package/dist/release-ci-hold.js +53 -0
  88. package/dist/release-cli.d.ts +3 -0
  89. package/dist/release-cli.js +95 -17
  90. package/dist/release-coverage.js +5 -2
  91. package/dist/release-gate.d.ts +5 -0
  92. package/dist/release-gate.js +1 -1
  93. package/dist/release-recheck.d.ts +1 -1
  94. package/dist/release-recheck.js +8 -2
  95. package/dist/release.d.ts +144 -14
  96. package/dist/release.js +421 -81
  97. package/dist/review-context.js +8 -6
  98. package/dist/runner-process.d.ts +67 -0
  99. package/dist/runner-process.js +219 -0
  100. package/dist/runner.d.ts +48 -6
  101. package/dist/runner.js +73 -9
  102. package/dist/sandbox-runtime.d.ts +8 -0
  103. package/dist/sandbox-runtime.js +14 -2
  104. package/dist/scope.d.ts +19 -1
  105. package/dist/scope.js +26 -3
  106. package/dist/scout.js +2 -2
  107. package/dist/secret-mint.d.ts +1 -0
  108. package/dist/secret-mint.js +14 -0
  109. package/dist/serve.js +112 -51
  110. package/dist/server/chat.js +5 -5
  111. package/dist/server/http.d.ts +2 -0
  112. package/dist/server/pages.js +7 -3
  113. package/dist/server/remote-hooks.d.ts +1 -1
  114. package/dist/server/remote-hooks.js +5 -3
  115. package/dist/server/render-results.d.ts +2 -0
  116. package/dist/server/render-results.js +1 -0
  117. package/dist/server/render-tasks.d.ts +2 -0
  118. package/dist/server/render-tasks.js +2 -1
  119. package/dist/server/request-context.d.ts +8 -0
  120. package/dist/server/runtime.d.ts +2 -0
  121. package/dist/server/settings.js +22 -2
  122. package/dist/server/task-views.js +7 -2
  123. package/dist/server/tasks.js +4 -1
  124. package/dist/setup-guide.d.ts +2 -2
  125. package/dist/storage-sweep.js +1 -1
  126. package/dist/storage.d.ts +5 -0
  127. package/dist/storage.js +28 -1
  128. package/dist/store.d.ts +135 -2
  129. package/dist/store.js +275 -13
  130. package/dist/surface.js +3 -1
  131. package/dist/task-base.d.ts +4 -0
  132. package/dist/task-base.js +22 -2
  133. package/dist/teardown-git.d.ts +52 -3
  134. package/dist/teardown-git.js +145 -33
  135. package/dist/test-infra.js +1 -1
  136. package/dist/toolroll-update.js +2 -2
  137. package/dist/up-processes.d.ts +137 -0
  138. package/dist/up-processes.js +165 -0
  139. package/dist/up-role-child.d.ts +1 -0
  140. package/dist/up-role-child.js +81 -0
  141. package/dist/verification-evidence.d.ts +1 -0
  142. package/dist/verification-evidence.js +13 -1
  143. package/dist/workspace-revision.js +3 -1
  144. package/package.json +2 -1
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";
@@ -83,7 +85,7 @@ import { authenticate as runnerAuthenticate } from "./runner.js";
83
85
  import { stampNewProjectLevel } from "./check-levels.js";
84
86
  import { parseCheckProgressSnapshot } from "./check-progress.js";
85
87
  import { authorityStandsSql } from "./approval-authority.js";
86
- import { releasePolicyDigest, storedReleaseTerms, readReleaseTerms, readReleasePolicy } from "./contracts/release-terms.js";
88
+ import { releasePolicyDigest, serializeReleaseCheckPurpose, storedReleaseCheckPurpose, storedReleaseTerms, readReleaseTerms, readReleasePolicy } from "./contracts/release-terms.js";
87
89
  import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
88
90
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
89
91
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
@@ -457,6 +459,49 @@ CREATE TABLE IF NOT EXISTS run_live_detail (
457
459
  CREATE INDEX IF NOT EXISTS run_live_open ON run (id) WHERE outcome IS NULL AND id > 0;
458
460
  CREATE INDEX IF NOT EXISTS run_live_recent ON run (finished_at) WHERE outcome IS NOT NULL AND id > 0;
459
461
  `;
462
+ /** v137 (docs/plans/actors-lessons.md): explicit lifecycle, latency timelines and the continuous database copy. New
463
+ * tables only. A lifecycle row's `holder` binds it to one registration (a worker's registered_at) or one console
464
+ * process; a row whose holder no longer matches says nothing. Timelines are bounded and pruned as they are written. */
465
+ export const V137_SCHEMA = `
466
+ CREATE TABLE IF NOT EXISTS service_lifecycle (
467
+ component TEXT PRIMARY KEY,
468
+ state TEXT NOT NULL CHECK (state IN ('started','draining','stopped')),
469
+ holder TEXT NOT NULL,
470
+ pid INTEGER,
471
+ changed_at TEXT NOT NULL,
472
+ beat_at TEXT NOT NULL,
473
+ drain_until TEXT
474
+ );
475
+ CREATE TABLE IF NOT EXISTS latency_timeline (
476
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
477
+ kind TEXT NOT NULL CHECK (kind IN ('console','release-check','deploy')),
478
+ subject TEXT NOT NULL,
479
+ task_id TEXT,
480
+ run_id INTEGER,
481
+ started_at TEXT NOT NULL,
482
+ total_ms INTEGER NOT NULL,
483
+ milestones_json TEXT NOT NULL
484
+ );
485
+ CREATE INDEX IF NOT EXISTS latency_timeline_by_kind ON latency_timeline (kind, id);
486
+ CREATE INDEX IF NOT EXISTS latency_timeline_by_run ON latency_timeline (run_id) WHERE run_id IS NOT NULL;
487
+ CREATE INDEX IF NOT EXISTS latency_timeline_by_task ON latency_timeline (task_id) WHERE task_id IS NOT NULL;
488
+ CREATE TABLE IF NOT EXISTS live_backup (
489
+ id INTEGER PRIMARY KEY CHECK (id = 1),
490
+ enabled INTEGER NOT NULL DEFAULT 1,
491
+ every_minutes INTEGER NOT NULL DEFAULT 5,
492
+ keep INTEGER NOT NULL DEFAULT 24,
493
+ folder TEXT,
494
+ holder TEXT,
495
+ held_until TEXT,
496
+ latest_file TEXT,
497
+ latest_sha256 TEXT,
498
+ latest_at TEXT,
499
+ latest_schema INTEGER,
500
+ latest_bytes INTEGER,
501
+ tried_at TEXT,
502
+ problem TEXT
503
+ );
504
+ `;
460
505
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
461
506
  export const LIVE_DETAIL_SCHEMA = `
462
507
  CREATE TABLE IF NOT EXISTS live_detail_setting (
@@ -754,7 +799,14 @@ const REGISTERED_AT_FILING = "again, as it was filed";
754
799
  // v136 adds, and changes no saved row: each run's own "Show commands in the live view" answer, captured once before its
755
800
  // live file opens (run_live_detail; docs/plans/agents-view.md §2.4). Saved runs have none, which every reader takes as
756
801
  // commands off: permission is never recovered from a transcript or the project's current setting.
757
- export const SCHEMA_VERSION = 136;
802
+ // v137 adds, and changes no saved row (docs/plans/actors-lessons.md): each service component's lifecycle
803
+ // (service_lifecycle: started, draining or stopped, with a short heartbeat), bounded per-operation latency timelines
804
+ // (latency_timeline: slow console requests, release check parts, deploy phases) and the continuous database copy's
805
+ // settings and latest verified copy (live_backup, one row; absent reads every 5 minutes, keep 24, beside the database).
806
+ // v138 adds, and changes no saved row: a release check's purpose (task_scope.release_check_json; docs/plans/
807
+ // pipeline-speed.md, P0) — this exact candidate is a release's check, for a release that can't carry. Saved rows read
808
+ // NULL: no purpose (a termsless release task stays exactly as it was approved).
809
+ export const SCHEMA_VERSION = 138;
758
810
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
759
811
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
760
812
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -887,7 +939,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
887
939
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
888
940
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
889
941
  */
890
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136]);
942
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138]);
891
943
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
892
944
  export const V119_ADDED_COLUMNS = Object.freeze({
893
945
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -1008,6 +1060,11 @@ export const V134_ADDED_COLUMNS = Object.freeze({
1008
1060
  export const V135_ADDED_COLUMNS = Object.freeze({
1009
1061
  task_scope: { release_terms_json: { definition: "TEXT", saved: "NULL" } },
1010
1062
  });
1063
+ /** v138 (docs/plans/pipeline-speed.md, P0): a scope's release check purpose, part of its digest when present. Saved
1064
+ * rows read NULL. */
1065
+ export const V138_ADDED_COLUMNS = Object.freeze({
1066
+ task_scope: { release_check_json: { definition: "TEXT", saved: "NULL" } },
1067
+ });
1011
1068
  /**
1012
1069
  * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
1013
1070
  * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
@@ -4238,6 +4295,13 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4238
4295
  }
4239
4296
  // v136: each run's own live-view answer (a new table only; saved runs have none: commands off).
4240
4297
  db.exec(RUN_LIVE_DETAIL_SCHEMA);
4298
+ // v137: lifecycle, latency timelines and the continuous copy (new tables only).
4299
+ db.exec(V137_SCHEMA);
4300
+ // v138: a scope's release check purpose (V138_ADDED_COLUMNS: saved rows NULL, no purpose).
4301
+ for (const [table, columns] of Object.entries(V138_ADDED_COLUMNS)) {
4302
+ for (const [column, { definition }] of Object.entries(columns))
4303
+ addColumn(db, table, column, definition);
4304
+ }
4241
4305
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4242
4306
  // nothing to reclaim.
4243
4307
  if (!fresh)
@@ -6667,7 +6731,7 @@ export class Store {
6667
6731
  saveScope(scope, mutation = {}, options = {}) {
6668
6732
  this.once(mutation, "saveScope", () => this.transact(() => {
6669
6733
  // The text's current author mark, read before this write (approval policy §1.4).
6670
- const prior = this.db.prepare("SELECT proposed_via, release_terms_json FROM task_scope WHERE task_id = ?").get(scope.taskId);
6734
+ const prior = this.db.prepare("SELECT proposed_via, release_terms_json, release_check_json FROM task_scope WHERE task_id = ?").get(scope.taskId);
6671
6735
  // THE filing invariant (foundations findings 5/13/19): every scope
6672
6736
  // row leaves this method either RESOLVED (working profile stamped,
6673
6737
  // digest recomputed to bind it, version 2) or UNRESOLVED with its
@@ -6831,6 +6895,12 @@ export class Store {
6831
6895
  // v135: release terms the caller names, else the ones this scope already carries (a refile keeps them bound).
6832
6896
  const priorTerms = storedReleaseTerms(prior?.["release_terms_json"]);
6833
6897
  const releaseTerms = options.releaseTerms !== undefined ? options.releaseTerms : priorTerms.ok ? priorTerms.terms : null;
6898
+ // v138: the release check purpose likewise; terms named now replace a kept purpose (a scope carries one or the other).
6899
+ const priorPurpose = storedReleaseCheckPurpose(prior?.["release_check_json"]);
6900
+ const releaseCheck = options.releaseCheck !== undefined ? options.releaseCheck
6901
+ : options.releaseTerms != null ? null : priorPurpose.ok ? priorPurpose.purpose : null;
6902
+ if (releaseTerms !== null && releaseCheck !== null)
6903
+ throw new Error("A scope carries release terms or a release check purpose, never both.");
6834
6904
  const digestInput = {
6835
6905
  goal: scope.goal,
6836
6906
  outOfScope: scope.outOfScope,
@@ -6840,6 +6910,7 @@ export class Store {
6840
6910
  qualityMode,
6841
6911
  candidate: scope.candidate ?? null,
6842
6912
  releaseTerms,
6913
+ releaseCheck,
6843
6914
  };
6844
6915
  // EVERY route filed since v47 binds into the digest — routine-shaped
6845
6916
  // ones included — so approval freezes exactly which agent runs each
@@ -6869,8 +6940,8 @@ export class Store {
6869
6940
  .prepare(`INSERT INTO task_scope
6870
6941
  (task_id, goal, out_of_scope, touches, budget_microusd, proposed_at, digest, approved_at, approved_by, approved_digest,
6871
6942
  profile_json, profile_state, unresolved_reason, digest_version, profile_provenance, proposed_chain_json, acceptance_json, quality_mode,
6872
- risk_level, proposed_route_json, route_era, candidate, release_terms_json)
6873
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6943
+ risk_level, proposed_route_json, route_era, candidate, release_terms_json, release_check_json)
6944
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6874
6945
  ON CONFLICT (task_id) DO UPDATE SET
6875
6946
  goal = excluded.goal, out_of_scope = excluded.out_of_scope,
6876
6947
  touches = excluded.touches, budget_microusd = excluded.budget_microusd,
@@ -6882,7 +6953,7 @@ export class Store {
6882
6953
  profile_provenance = excluded.profile_provenance, proposed_chain_json = excluded.proposed_chain_json,
6883
6954
  acceptance_json = excluded.acceptance_json, quality_mode = excluded.quality_mode,
6884
6955
  risk_level = excluded.risk_level, proposed_route_json = excluded.proposed_route_json, route_era = excluded.route_era,
6885
- candidate = excluded.candidate, release_terms_json = excluded.release_terms_json`)
6956
+ candidate = excluded.candidate, release_terms_json = excluded.release_terms_json, release_check_json = excluded.release_check_json`)
6886
6957
  .run(scope.taskId, scope.goal, scope.outOfScope, JSON.stringify(scope.touches), scope.budgetMicrousd ?? null, scope.proposedAt, boundDigest, scope.approvedAt, scope.approvedBy, scope.approvedDigest, profile === null ? null : canonicalProfileJson(profile), profile === null ? "unresolved" : "resolved", unresolvedReason, profile === null ? 1 : 2, provenance,
6887
6958
  // Fallback chains (v30-v114) are gone: no filing names one.
6888
6959
  null, scope.acceptance.length === 0 ? null : JSON.stringify(scope.acceptance), qualityMode,
@@ -6890,7 +6961,7 @@ export class Store {
6890
6961
  // routine risk, the value its signed terms always carried.
6891
6962
  "routine", route === null ? null : canonicalRouteJson(route),
6892
6963
  // The durable era: every row this method writes is a routed row.
6893
- ROUTE_ERA, scope.candidate ?? null, releaseTerms === null ? null : serializeReleaseTerms(releaseTerms));
6964
+ ROUTE_ERA, scope.candidate ?? null, releaseTerms === null ? null : serializeReleaseTerms(releaseTerms), releaseCheck === null ? null : serializeReleaseCheckPurpose(releaseCheck));
6894
6965
  // Who wrote THIS text (mate arc, ruling 2): set per write, so a human
6895
6966
  // rewrite clears the lead's mark and a mate rewrite sets it — but only a
6896
6967
  // person's write clears one (approval policy §1.4): the lead (its token)
@@ -6932,6 +7003,10 @@ export class Store {
6932
7003
  // road it came by.
6933
7004
  if (basis === undefined && machineActing())
6934
7005
  return false;
7006
+ // A RELEASE WAITING FOR CI (Codex review round 1, finding 4): filed while its pull request's checks still ran,
7007
+ // only the automatic road (which proves CI itself) seals it until the plane read exact-head CI passing.
7008
+ if (basis?.kind !== "auto-check" && basis?.kind !== "auto-green" && releaseCiHoldOf(this, taskId) !== null)
7009
+ return false;
6935
7010
  // `carried` is written by the carry transaction alone (carryReleaseApproval), never through a seal.
6936
7011
  if (basis?.kind === "carried")
6937
7012
  return false;
@@ -7011,7 +7086,14 @@ export class Store {
7011
7086
  // A DEPLOY GATE FROM CHAT (chat-management.md §0a.5): a signed candidate or release terms seal from chat only
7012
7087
  // with the release card's evidence (its act and card digest), never through the plain plan card or a generic
7013
7088
  // one. Read from the raw row, so a terms column that doesn't parse is a deploy gate too.
7014
- const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null;
7089
+ const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null || working["release_check_json"] != null;
7090
+ // A RELEASE CHECK PURPOSE (v138) seals, like terms, only on a person's own yes: the ceremony or a paired-chat tap
7091
+ // through the release card — never a mode, a grant, a carry or an automatic seal (those need terms).
7092
+ const purpose = storedReleaseCheckPurpose(working["release_check_json"]);
7093
+ if (!purpose.ok)
7094
+ return false;
7095
+ if (purpose.purpose !== null && basis !== undefined && basis.kind !== "chat")
7096
+ return false;
7015
7097
  if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
7016
7098
  return false;
7017
7099
  // AUTOMATIC RELEASE SEALS (auto-approve-green.md): only on a release task (a candidate and release terms signed
@@ -8018,7 +8100,7 @@ export class Store {
8018
8100
  /** A single-use door into the instance (U2): 128-bit token, sha256
8019
8101
  * stored, role pinned at mint, 72 hours. Only the caller's own
8020
8102
  * authentication gates this — an approver mints; a viewer cannot. */
8021
- mintInvite(role, mintedBy, now, token = () => randomBytes(16).toString("base64url"), projects = null) {
8103
+ mintInvite(role, mintedBy, now, token = () => mintSecret(16), projects = null) {
8022
8104
  const access = normalizeProjectAccess(projects);
8023
8105
  return this.transact(() => {
8024
8106
  if (!this.isInstanceOperator(mintedBy))
@@ -11646,7 +11728,7 @@ export class Store {
11646
11728
  if (route === null) {
11647
11729
  return { ok: false, reason: "unreadable", detail: scope.approvedRouteJson == null ? "the approval sealed no agent route — re-file the scope and approve it again" : "the approval's sealed agent route cannot be read — re-file the scope and approve it again" };
11648
11730
  }
11649
- const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null };
11731
+ const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null, releaseCheck: scope.releaseCheck ?? null };
11650
11732
  const chain = scope.approvalKind === "chain" ? chainFromJson(scope.approvedChainJson ?? null) : null;
11651
11733
  const target = chain !== null ? { chain } : (scope.approvedProfile ?? null);
11652
11734
  if (target === null)
@@ -17502,7 +17584,8 @@ export class Store {
17502
17584
  acquireWatchLease(runner, repo, owner, ttlMs, now) {
17503
17585
  return this.transact(() => {
17504
17586
  const stamp = now.toISOString();
17505
- const expires = new Date(now.getTime() + ttlMs).toISOString();
17587
+ // Never longer than MAX_WATCH_LEASE_MS, whatever a caller asks: a crashed holder's project is free within a minute.
17588
+ const expires = new Date(now.getTime() + Math.min(ttlMs, MAX_WATCH_LEASE_MS)).toISOString();
17506
17589
  const row = this.db
17507
17590
  .prepare("SELECT * FROM watch_lease WHERE runner = ? AND repo = ?")
17508
17591
  .get(runner, repo);
@@ -17539,7 +17622,7 @@ export class Store {
17539
17622
  const { changes } = this.db
17540
17623
  .prepare(`UPDATE watch_lease SET expires_at = ?, heartbeat_at = ?
17541
17624
  WHERE runner = ? AND repo = ? AND owner = ? AND expires_at > ?`)
17542
- .run(new Date(now.getTime() + ttlMs).toISOString(), now.toISOString(), runner, repo, owner, now.toISOString());
17625
+ .run(new Date(now.getTime() + Math.min(ttlMs, MAX_WATCH_LEASE_MS)).toISOString(), now.toISOString(), runner, repo, owner, now.toISOString());
17543
17626
  return Number(changes) > 0;
17544
17627
  }
17545
17628
  releaseWatchLease(runner, repo, owner, now) {
@@ -17547,6 +17630,12 @@ export class Store {
17547
17630
  .prepare("UPDATE watch_lease SET expires_at = ? WHERE runner = ? AND repo = ? AND owner = ?")
17548
17631
  .run(now.toISOString(), runner, repo, owner);
17549
17632
  }
17633
+ /** Lapse every watch lease a runner holds now: its holder was proved gone (runner.ts registerRunnerIfIdle). The rows
17634
+ * stay, so the next acquire takes them over and recovers the superseded incarnation as after any crash. */
17635
+ expireWatchLeasesOf(runner, now) {
17636
+ const stamp = now.toISOString();
17637
+ return Number(this.db.prepare("UPDATE watch_lease SET expires_at = ? WHERE runner = ? AND expires_at > ?").run(stamp, runner, stamp).changes);
17638
+ }
17550
17639
  /** The first still-unexpired watch lease this runner holds, if any (arc 2:
17551
17640
  * the takeover door refuses while one exists). */
17552
17641
  liveWatchLeaseOf(runner, now) {
@@ -20183,6 +20272,14 @@ export function scopeTermsProblem(row) {
20183
20272
  if (!terms.ok)
20184
20273
  return terms.problem;
20185
20274
  }
20275
+ // v138: likewise the release check purpose, and never beside terms.
20276
+ if ("release_check_json" in row) {
20277
+ const purpose = storedReleaseCheckPurpose(row["release_check_json"]);
20278
+ if (!purpose.ok)
20279
+ return purpose.problem;
20280
+ if (purpose.purpose !== null && row["release_terms_json"] != null)
20281
+ return "the scope carries both release terms and a release check purpose";
20282
+ }
20186
20283
  for (const column of ["approved_at", "approved_by", "approved_digest", "profile_json", "approved_profile_json", "proposed_chain_json", "approved_chain_json", "proposed_route_json", "approved_route_json", "unresolved_reason"]) {
20187
20284
  if (row[column] !== null && row[column] !== undefined && typeof row[column] !== "string")
20188
20285
  return `${column.replace(/_/g, " ")} is not stored as text`;
@@ -20228,6 +20325,7 @@ function readScope(row) {
20228
20325
  routeEra: row["route_era"] === null || row["route_era"] === undefined ? null : Number(row["route_era"]),
20229
20326
  candidate: typeof row["candidate"] === "string" && row["candidate"] !== "" ? row["candidate"] : null,
20230
20327
  releaseTerms: (() => { const read = storedReleaseTerms(row["release_terms_json"]); return read.ok ? read.terms : null; })(),
20328
+ releaseCheck: (() => { const read = storedReleaseCheckPurpose(row["release_check_json"]); return read.ok ? read.purpose : null; })(),
20231
20329
  proposedVia: row["proposed_via"] === "mate" || row["proposed_via"] === "coordinator" || row["proposed_via"] === "scout" || row["proposed_via"] === "lead" ? row["proposed_via"] : null,
20232
20330
  };
20233
20331
  }
@@ -20507,6 +20605,9 @@ export function reserveRelease(store, input) {
20507
20605
  const a = releaseAuthority(store, input.authority);
20508
20606
  if (a === null)
20509
20607
  return { ok: false, why: `No release ${input.authority}.` };
20608
+ // Early approval authorizes the check, never an irreversible operation before the plane confirms exact-head CI.
20609
+ if (releaseCiRunningOf(store, input.taskId) !== null)
20610
+ return { ok: false, why: "This release still waits for GitHub CI. Resume it with `toolroll release`." };
20510
20611
  const at = input.now.toISOString();
20511
20612
  // Expiry stops every live state; an operation already reserved settles only through reconciliation.
20512
20613
  if (a.expiresAt <= at)
@@ -20548,6 +20649,8 @@ export function runReleaseOperation(store, id, epoch, now, issuer = currentRelea
20548
20649
  const a = op === null ? null : releaseAuthority(store, op.authority);
20549
20650
  if (op === null || a === null || op.state !== "reserved" || op.epoch !== epoch || a.leaseEpoch !== epoch)
20550
20651
  return false;
20652
+ if (releaseCiRunningOf(store, op.taskId) !== null)
20653
+ return false;
20551
20654
  // Past the expiry nothing new starts; the reserved operation is settled by reconciliation instead.
20552
20655
  if (a.expiresAt <= now.toISOString())
20553
20656
  return false;
@@ -20858,3 +20961,162 @@ export function agentsLiveRuns(store, endedSince) {
20858
20961
  repo: row["live_task_repo"] === null || row["live_task_repo"] === undefined ? null : String(row["live_task_repo"]),
20859
20962
  }));
20860
20963
  }
20964
+ /**
20965
+ * This runner's watches a predecessor left needing recovery (Codex review round 1, finding 2): a watch lease still
20966
+ * live, or one whose holder left a claim open (or a run open under a claim a reaper already released). A replacement
20967
+ * worker takes each over — recovering the holder's incarnation in the same transaction — before it admits new work,
20968
+ * whichever projects its start names (a project being removed when the predecessor died is in none of them).
20969
+ */
20970
+ export function predecessorWatches(store, runner, now) {
20971
+ return store.handle.prepare(`SELECT w.repo AS repo, w.owner AS owner FROM watch_lease w
20972
+ WHERE w.runner = ? AND (w.expires_at > ? OR EXISTS (
20973
+ SELECT 1 FROM claim c WHERE c.runner = w.runner AND c.incarnation = w.owner
20974
+ 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))))
20975
+ ORDER BY w.repo`).all(runner, now.toISOString()).map(row => ({ repo: String(row["repo"]), owner: String(row["owner"]) }));
20976
+ }
20977
+ // ---- v137: lifecycle, latency timelines, the continuous copy (docs/plans/actors-lessons.md) ----------------------
20978
+ /** The longest a watch lease is taken or renewed for (durable-actors' MAX_HOST_LEASE_DURATION_MS). */
20979
+ export const MAX_WATCH_LEASE_MS = 60_000;
20980
+ function lifecycleRowOf(row) {
20981
+ return { component: String(row["component"]), state: String(row["state"]), holder: String(row["holder"]), pid: row["pid"] == null ? null : Number(row["pid"]),
20982
+ changedAt: String(row["changed_at"]), beatAt: String(row["beat_at"]), drainUntil: row["drain_until"] == null ? null : String(row["drain_until"]) };
20983
+ }
20984
+ /** One component's lifecycle row, or null (never reported: a component from before v137 says nothing). */
20985
+ export function lifecycleOf(store, component) {
20986
+ const row = store.handle.prepare("SELECT * FROM service_lifecycle WHERE component = ?").get(component);
20987
+ return row === undefined ? null : lifecycleRowOf(row);
20988
+ }
20989
+ /** Every reported component, by name. */
20990
+ export function lifecycles(store) {
20991
+ return store.handle.prepare("SELECT * FROM service_lifecycle ORDER BY component").all().map(lifecycleRowOf);
20992
+ }
20993
+ /**
20994
+ * Report a component's state. `started` replaces whatever an earlier holder left; `draining` and `stopped` only move the
20995
+ * current holder's row (a stale process can't mark its successor draining), and never back from stopped. Returns
20996
+ * whether the row now says what was asked.
20997
+ */
20998
+ export function reportLifecycle(store, component, state, holder, now, extra = {}) {
20999
+ const stamp = now.toISOString();
21000
+ const until = extra.drainUntil == null ? null : extra.drainUntil.toISOString();
21001
+ if (state === "started") {
21002
+ store.handle.prepare(`INSERT INTO service_lifecycle (component, state, holder, pid, changed_at, beat_at, drain_until) VALUES (?, 'started', ?, ?, ?, ?, NULL)
21003
+ ON CONFLICT(component) DO UPDATE SET state = 'started', holder = excluded.holder, pid = excluded.pid, changed_at = excluded.changed_at, beat_at = excluded.beat_at, drain_until = NULL`)
21004
+ .run(component, holder, extra.pid ?? null, stamp, stamp);
21005
+ return true;
21006
+ }
21007
+ const changed = store.handle.prepare(`UPDATE service_lifecycle SET state = ?, changed_at = ?, beat_at = ?, drain_until = CASE WHEN ? = 'draining' THEN ? ELSE drain_until END
21008
+ WHERE component = ? AND holder = ? AND state <> 'stopped'`).run(state, stamp, stamp, state, until, component, holder).changes;
21009
+ return Number(changed) === 1;
21010
+ }
21011
+ /** The short heartbeat: the current holder's row, while it is not stopped. False means another holder took it. */
21012
+ export function beatLifecycle(store, component, holder, now) {
21013
+ return Number(store.handle.prepare("UPDATE service_lifecycle SET beat_at = ? WHERE component = ? AND holder = ? AND state <> 'stopped'")
21014
+ .run(now.toISOString(), component, holder).changes) === 1;
21015
+ }
21016
+ /** At most this many milestones per timeline (the rest are dropped, the last kept), and timelines kept per kind. */
21017
+ export const MAX_MILESTONES = 40;
21018
+ export const TIMELINES_KEPT = 200;
21019
+ /** Timelines older than this go whatever their count. */
21020
+ export const TIMELINE_DAYS = 14;
21021
+ const plainText = (value, max) => value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, max);
21022
+ /** Milestones made safe to keep: bounded in number and length, offsets whole non-negative milliseconds, in order. */
21023
+ export function boundedMilestones(milestones) {
21024
+ const clean = milestones.filter(one => typeof one.name === "string" && Number.isFinite(one.ms)).map(one => ({
21025
+ name: plainText(one.name, 80), ms: Math.max(0, Math.round(one.ms)),
21026
+ ...(typeof one.detail === "string" && one.detail.trim() !== "" ? { detail: plainText(one.detail, 160) } : {}),
21027
+ })).filter(one => one.name !== "");
21028
+ return clean.length <= MAX_MILESTONES ? clean : [...clean.slice(0, MAX_MILESTONES - 1), clean[clean.length - 1]];
21029
+ }
21030
+ /** Keep one timeline, then prune its kind to the newest TIMELINES_KEPT within TIMELINE_DAYS. */
21031
+ export function recordLatencyTimeline(store, entry, now) {
21032
+ return store.transact(() => {
21033
+ const id = Number(store.handle.prepare(`INSERT INTO latency_timeline (kind, subject, task_id, run_id, started_at, total_ms, milestones_json) VALUES (?, ?, ?, ?, ?, ?, ?)`)
21034
+ .run(entry.kind, plainText(entry.subject, 120), entry.taskId ?? null, entry.runId ?? null, entry.startedAt.toISOString(), Math.max(0, Math.round(entry.totalMs)), JSON.stringify(boundedMilestones(entry.milestones))).lastInsertRowid);
21035
+ const cutoff = new Date(now.getTime() - TIMELINE_DAYS * 86_400_000).toISOString();
21036
+ store.handle.prepare(`DELETE FROM latency_timeline WHERE kind = ? AND (started_at < ? OR id <= (SELECT id FROM latency_timeline WHERE kind = ? ORDER BY id DESC LIMIT 1 OFFSET ?))`)
21037
+ .run(entry.kind, cutoff, entry.kind, TIMELINES_KEPT);
21038
+ return id;
21039
+ });
21040
+ }
21041
+ /** Timelines, newest first: of one kind, one run's, or one task's. A row that doesn't read is left out. */
21042
+ export function latencyTimelines(store, filter) {
21043
+ const where = [], args = [];
21044
+ if (filter.kind !== undefined) {
21045
+ where.push("kind = ?");
21046
+ args.push(filter.kind);
21047
+ }
21048
+ if (filter.runId !== undefined && filter.taskId !== undefined) {
21049
+ where.push("(run_id = ? OR task_id = ?)");
21050
+ args.push(filter.runId, filter.taskId);
21051
+ }
21052
+ else if (filter.runId !== undefined) {
21053
+ where.push("run_id = ?");
21054
+ args.push(filter.runId);
21055
+ }
21056
+ else if (filter.taskId !== undefined) {
21057
+ where.push("task_id = ?");
21058
+ args.push(filter.taskId);
21059
+ }
21060
+ const rows = store.handle.prepare(`SELECT * FROM latency_timeline${where.length === 0 ? "" : ` WHERE ${where.join(" AND ")}`} ORDER BY id DESC LIMIT ?`).all(...args, Math.min(200, Math.max(1, filter.limit ?? 20)));
21061
+ return rows.flatMap(row => {
21062
+ let milestones;
21063
+ try {
21064
+ const parsed = JSON.parse(String(row["milestones_json"]));
21065
+ milestones = Array.isArray(parsed) ? boundedMilestones(parsed) : [];
21066
+ }
21067
+ catch {
21068
+ return [];
21069
+ }
21070
+ return [{ id: Number(row["id"]), kind: String(row["kind"]), subject: String(row["subject"]), taskId: row["task_id"] == null ? null : String(row["task_id"]),
21071
+ runId: row["run_id"] == null ? null : Number(row["run_id"]), startedAt: String(row["started_at"]), totalMs: Number(row["total_ms"]), milestones }];
21072
+ });
21073
+ }
21074
+ export const DEFAULT_LIVE_BACKUP = { enabled: true, everyMinutes: 5, keep: 24, folder: null };
21075
+ export function liveBackupState(store) {
21076
+ const row = store.handle.prepare("SELECT * FROM live_backup WHERE id = 1").get();
21077
+ if (row === undefined)
21078
+ return { settings: { ...DEFAULT_LIVE_BACKUP }, latest: null, triedAt: null, problem: null };
21079
+ const latest = row["latest_file"] == null || row["latest_sha256"] == null || row["latest_at"] == null || row["latest_schema"] == null ? null
21080
+ : { file: String(row["latest_file"]), sha256: String(row["latest_sha256"]), madeAt: String(row["latest_at"]), schemaVersion: Number(row["latest_schema"]), bytes: Number(row["latest_bytes"] ?? 0) };
21081
+ return { settings: { enabled: Number(row["enabled"]) === 1, everyMinutes: Number(row["every_minutes"]), keep: Number(row["keep"]), folder: row["folder"] == null ? null : String(row["folder"]) },
21082
+ latest, triedAt: row["tried_at"] == null ? null : String(row["tried_at"]), problem: row["problem"] == null ? null : String(row["problem"]) };
21083
+ }
21084
+ const liveBackupWords = (one) => `${one.enabled ? `every ${one.everyMinutes} min` : "off"}, keep ${one.keep}, ${one.folder ?? "the default folder"}`;
21085
+ /** Change the continuous copy's settings; the ledger keeps before → after. A new folder forgets the latest copy (it is
21086
+ * in the old one). */
21087
+ export function setLiveBackupSettings(store, next, by, now) {
21088
+ return store.transact(() => {
21089
+ const before = liveBackupState(store).settings;
21090
+ store.handle.prepare(`INSERT INTO live_backup (id, enabled, every_minutes, keep, folder) VALUES (1, ?, ?, ?, ?)
21091
+ ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, every_minutes = excluded.every_minutes, keep = excluded.keep, folder = excluded.folder`)
21092
+ .run(next.enabled ? 1 : 0, next.everyMinutes, next.keep, next.folder);
21093
+ if (before.folder !== next.folder)
21094
+ store.handle.prepare("UPDATE live_backup SET latest_file = NULL, latest_sha256 = NULL, latest_at = NULL, latest_schema = NULL, latest_bytes = NULL WHERE id = 1").run();
21095
+ if (liveBackupWords(before) !== liveBackupWords(next)) {
21096
+ store.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "continuous backup settings changed", outcome: "changed", source: "policy", detail: `${liveBackupWords(before)} → ${liveBackupWords(next)}` });
21097
+ }
21098
+ return liveBackupState(store).settings;
21099
+ });
21100
+ }
21101
+ /** Take (or keep) the continuous copy's lease until `until`: one process copies at a time. */
21102
+ export function holdLiveBackup(store, holder, now, until) {
21103
+ return store.transact(() => {
21104
+ store.handle.prepare("INSERT OR IGNORE INTO live_backup (id) VALUES (1)").run();
21105
+ return Number(store.handle.prepare("UPDATE live_backup SET holder = ?, held_until = ? WHERE id = 1 AND (holder IS NULL OR holder = ? OR held_until IS NULL OR held_until < ?)")
21106
+ .run(holder, until.toISOString(), holder, now.toISOString()).changes) === 1;
21107
+ });
21108
+ }
21109
+ export function releaseLiveBackup(store, holder) {
21110
+ store.handle.prepare("UPDATE live_backup SET holder = NULL, held_until = NULL WHERE id = 1 AND holder = ?").run(holder);
21111
+ }
21112
+ /** A copy's outcome: a verified copy becomes the latest; a failure is kept as the problem (the latest stays). */
21113
+ export function recordLiveBackup(store, outcome, now) {
21114
+ store.handle.prepare("INSERT OR IGNORE INTO live_backup (id) VALUES (1)").run();
21115
+ if (outcome.ok) {
21116
+ store.handle.prepare("UPDATE live_backup SET latest_file = ?, latest_sha256 = ?, latest_at = ?, latest_schema = ?, latest_bytes = ?, tried_at = ?, problem = NULL WHERE id = 1")
21117
+ .run(outcome.copy.file, outcome.copy.sha256, outcome.copy.madeAt, outcome.copy.schemaVersion, outcome.copy.bytes, now.toISOString());
21118
+ }
21119
+ else {
21120
+ store.handle.prepare("UPDATE live_backup SET tried_at = ?, problem = ? WHERE id = 1").run(now.toISOString(), plainText(outcome.problem, 300));
21121
+ }
21122
+ }
package/dist/surface.js CHANGED
@@ -110,7 +110,9 @@ const GUIDE_ROWS = [
110
110
  { name: "limit", takesValue: true, meaning: "<step>=<minutes>: one waiting step's time limit" },
111
111
  { name: "tap", takesValue: true, meaning: "the Homebrew tap, owner/name" },
112
112
  { name: "release-gate", takesValue: true, meaning: "<id>: show why an update pause left before a deployment's swap is safe to lift" },
113
- { name: "yes", takesValue: false, meaning: "with --release-gate: lift that one pause once it is proved" }],
113
+ { name: "yes", takesValue: false, meaning: "with --release-gate: lift that one pause once it is proved; with --recover-deploy: recover" },
114
+ { name: "recover-deploy", takesValue: false, meaning: "put back a deployment that stopped half way: stop only its own proved leftovers, restart the previous service, lift the pause" },
115
+ { name: "stage", takesValue: true, meaning: "with --recover-deploy: the deployment's staging folder" }],
114
116
  notableReasons: ["usage", "ambiguous", "drift", "timeout", "check-failed", "ci-failed", "tree-mismatch", "conflict", "mixed-runtime", "gate-held", "after-swap"] },
115
117
  operator("demo", "a seeded throwaway sandbox"),
116
118
  ...KNOWLEDGE_DESCRIPTORS.map(spec => ({ invocation: `knowledge ${spec.action}`, synopsis: spec.synopsis, audience: "audience" in spec ? spec.audience : "agent", agentMayInvoke: !("audience" in spec), mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text or source file for impact" }] } : {}) })),
@@ -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", "--no-lazy-fetch", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", ...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
+ }
@@ -38,7 +38,7 @@ export declare function teardownGit(runner?: Runner, binding?: GitBinding): Git;
38
38
  * git at any file, a pipe that never ends among them. Each file is read only as the regular file it is, bounded; one
39
39
  * that can't be read so counts as including.
40
40
  */
41
- export declare function checkoutIncludes(checkout: string): boolean;
41
+ export declare function checkoutIncludes(checkout: string, gitFile?: string): boolean;
42
42
  export type GitBinding = {
43
43
  repo?: string | null;
44
44
  };
@@ -50,7 +50,7 @@ export declare const WRITE_VIEW_FLAGS: readonly string[];
50
50
  /** Whether the checkout's .lfsconfig (read as git parses it, that file only) sets any git-lfs key that could name a
51
51
  * program (anything but INERT_LFS). A file that
52
52
  * isn't a plain one, or doesn't parse, counts as naming one. */
53
- export declare function lfsExtensionIn(checkout: string): boolean;
53
+ export declare function lfsExtensionIn(checkout: string, gitFile?: string): boolean;
54
54
  /** Every command runs against a view of the checkout's repository (gitView) whose config and info are copies the
55
55
  * plane made, inspected there, so neither can change between the inspection and the run. `mode` "read" is for reading
56
56
  * a checkout an agent may still be writing (the code page): every command also takes readFlags(), and the view holds
@@ -69,7 +69,7 @@ export declare const PAGE_READ_FLAGS: readonly string[];
69
69
  * its config as it starts, so the flags alone don't stop a filter planted in both after the inspection: every read
70
70
  * also runs against a view of the checkout's repository (gitView; checkoutGitSync, checkoutGit "read"), whose config and info are the plane's. The cost is cosmetic: a checkout's line-ending or encoding rules are not applied, so
71
71
  * a file they would normalize can read as changed (fail closed: shipping then asks for a clean commit). */
72
- export declare function readFlags(): readonly string[];
72
+ export declare function readFlags(gitFile?: string): readonly string[];
73
73
  /**
74
74
  * checkoutGit, synchronously, for reads a page shows from a checkout an agent writes (docs/plans/get-side-effects.md):
75
75
  * the same refusals (an include, an .lfsconfig extension, a config key that can run a command), no system or global
@@ -81,6 +81,30 @@ export declare function checkoutGitSync(cwd: string, args: readonly string[], op
81
81
  maxBuffer?: number;
82
82
  binding?: GitBinding;
83
83
  }): string;
84
+ /**
85
+ * checkoutGitSync's run, answering git's exit status instead of throwing on a non-zero one (a refusal, or a repository
86
+ * that changed while git ran, still throws): for reads whose "no" is an exit code (`merge-base --is-ancestor`), and,
87
+ * with `buffer`, for blobs read as bytes (their output is never rewritten).
88
+ */
89
+ export declare function viewedGitSync(cwd: string, args: readonly string[], options: {
90
+ timeoutMs?: number;
91
+ maxBuffer?: number;
92
+ binding?: GitBinding;
93
+ }): {
94
+ status: number | null;
95
+ stdout: string;
96
+ };
97
+ export declare function viewedGitSync(cwd: string, args: readonly string[], options: {
98
+ timeoutMs?: number;
99
+ maxBuffer?: number;
100
+ binding?: GitBinding;
101
+ buffer: true;
102
+ input?: Buffer;
103
+ git?: string;
104
+ }): {
105
+ status: number | null;
106
+ stdout: Buffer;
107
+ };
84
108
  /**
85
109
  * checkoutGit ("build": the repository written through its view) for callers that name the repository with a leading
86
110
  * `git -C <dir>` instead of a cwd: worktree add and remove in a project's repository, a merge in a checkout of it.
@@ -88,3 +112,28 @@ export declare function checkoutGitSync(cwd: string, args: readonly string[], op
88
112
  * what they name. Anything but git goes to `inner` as given.
89
113
  */
90
114
  export declare function hardenedGit(inner?: Runner, mode?: "build" | "read", binding?: GitBinding): Runner;
115
+ /** Asked once per git executable (eval-harness review 2, follow-up): a fixed git given to viewedGitSync is probed
116
+ * itself, never answered for by the one PATH names. */
117
+ export declare function lazyFetchSupported(gitFile?: string): boolean;
118
+ /** For tests: forget the probes' answers, or set one for every executable (null forgets). */
119
+ export declare function setLazyFetchSupportedForTest(value: boolean | null): void;
120
+ /** The words for a partial clone refused on a git without `--no-lazy-fetch`. */
121
+ export declare const PARTIAL_CLONE_REFUSAL = "this project is a partial clone; protected reads need git 2.45 or newer";
122
+ /** `--no-lazy-fetch` where the installed git has it, else nothing (and lazyFetchSetting refuses partial clones). */
123
+ export declare function noLazyFetch(): readonly string[];
124
+ /**
125
+ * The repository setting (local or worktree scope of a `git config --list --show-scope -z` listing) that makes lazy
126
+ * fetching possible, or null: `extensions.partialClone` set at all, any `remote.<name>.promisor` not plainly false, or
127
+ * any `remote.<name>.partialCloneFilter` set (git makes that remote a promisor). Fails closed: a listing that can't be
128
+ * read names a setting. `remote.<name>.vcs` (a foreign-VCS helper) is named too, though the classifier already refuses
129
+ * it (commandSetting: not in the remote allowlist), as it does every `protocol.*` key and every include.
130
+ */
131
+ export declare function lazyFetchSetting(listing: string): string | null;
132
+ /**
133
+ * For the reads that run git directly on a repository rather than through a view: with `--no-lazy-fetch` they run as
134
+ * they always have (the flag stops any fetch). Without it, they must not check a repository's config and then run git
135
+ * on it (the config could change in between, or an include could set what the check never saw): `inner`, wrapped so
136
+ * every git command runs through a frozen read view (checkoutGit "read"), whose own config listing (includes refused,
137
+ * command and protocol settings refused, a partial clone refused) is the config git then runs with.
138
+ */
139
+ export declare function lazyFetchSafeGit(inner?: Runner): Runner;