@ashlr/hub 3.2.6 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/CHANGELOG.md +290 -0
  2. package/README.md +4 -1
  3. package/dist/build-identity.json +2 -2
  4. package/dist/cli/activation.d.ts +41 -0
  5. package/dist/cli/activation.js +485 -0
  6. package/dist/cli/activation.js.map +1 -0
  7. package/dist/cli/audit.js +18 -0
  8. package/dist/cli/audit.js.map +1 -1
  9. package/dist/cli/backlog.d.ts +0 -5
  10. package/dist/cli/backlog.js +18 -0
  11. package/dist/cli/backlog.js.map +1 -1
  12. package/dist/cli/comms.js +20 -0
  13. package/dist/cli/comms.js.map +1 -1
  14. package/dist/cli/completions.d.ts +6 -1
  15. package/dist/cli/completions.js +37 -11
  16. package/dist/cli/completions.js.map +1 -1
  17. package/dist/cli/conductor-permit.d.ts +1 -0
  18. package/dist/cli/conductor-permit.js +122 -0
  19. package/dist/cli/conductor-permit.js.map +1 -0
  20. package/dist/cli/daemon.js +10 -3
  21. package/dist/cli/daemon.js.map +1 -1
  22. package/dist/cli/demo.d.ts +0 -27
  23. package/dist/cli/demo.js +17 -0
  24. package/dist/cli/demo.js.map +1 -1
  25. package/dist/cli/goals.d.ts +2 -0
  26. package/dist/cli/goals.js +90 -5
  27. package/dist/cli/goals.js.map +1 -1
  28. package/dist/cli/help.js +21 -3
  29. package/dist/cli/help.js.map +1 -1
  30. package/dist/cli/inbox.d.ts +14 -2
  31. package/dist/cli/inbox.js +137 -23
  32. package/dist/cli/inbox.js.map +1 -1
  33. package/dist/cli/index.d.ts +1 -0
  34. package/dist/cli/index.js +16 -0
  35. package/dist/cli/index.js.map +1 -1
  36. package/dist/cli/loop.js +26 -5
  37. package/dist/cli/loop.js.map +1 -1
  38. package/dist/cli/onboard.d.ts +0 -13
  39. package/dist/cli/onboard.js +20 -0
  40. package/dist/cli/onboard.js.map +1 -1
  41. package/dist/cli/resources.js +15 -0
  42. package/dist/cli/resources.js.map +1 -1
  43. package/dist/cli/run.js +1 -1
  44. package/dist/cli/run.js.map +1 -1
  45. package/dist/cli/sandbox.js +43 -0
  46. package/dist/cli/sandbox.js.map +1 -1
  47. package/dist/cli/tui.d.ts +0 -6
  48. package/dist/cli/tui.js +14 -0
  49. package/dist/cli/tui.js.map +1 -1
  50. package/dist/core/comms/elon-dialogue.js +16 -7
  51. package/dist/core/comms/elon-dialogue.js.map +1 -1
  52. package/dist/core/config.js +70 -0
  53. package/dist/core/config.js.map +1 -1
  54. package/dist/core/daemon/activation-permit.d.ts +357 -33
  55. package/dist/core/daemon/activation-permit.js +1585 -183
  56. package/dist/core/daemon/activation-permit.js.map +1 -1
  57. package/dist/core/daemon/goal-conductor-permit-operator.d.ts +75 -0
  58. package/dist/core/daemon/goal-conductor-permit-operator.js +842 -0
  59. package/dist/core/daemon/goal-conductor-permit-operator.js.map +1 -0
  60. package/dist/core/daemon/loop.d.ts +13 -1
  61. package/dist/core/daemon/loop.js +294 -29
  62. package/dist/core/daemon/loop.js.map +1 -1
  63. package/dist/core/daemon/runtime-activation-resident-start-protocol.d.ts +141 -0
  64. package/dist/core/daemon/runtime-activation-resident-start-protocol.js +462 -0
  65. package/dist/core/daemon/runtime-activation-resident-start-protocol.js.map +1 -0
  66. package/dist/core/daemon/runtime-activation-resident-start-runtime.d.ts +27 -0
  67. package/dist/core/daemon/runtime-activation-resident-start-runtime.js +28 -0
  68. package/dist/core/daemon/runtime-activation-resident-start-runtime.js.map +1 -0
  69. package/dist/core/daemon/runtime-activation-stopped-consumer.d.ts +139 -0
  70. package/dist/core/daemon/runtime-activation-stopped-consumer.js +995 -0
  71. package/dist/core/daemon/runtime-activation-stopped-consumer.js.map +1 -0
  72. package/dist/core/daemon/runtime-activation-stopped-runtime.d.ts +36 -0
  73. package/dist/core/daemon/runtime-activation-stopped-runtime.js +134 -0
  74. package/dist/core/daemon/runtime-activation-stopped-runtime.js.map +1 -0
  75. package/dist/core/daemon/runtime-activation-transaction.d.ts +41 -17
  76. package/dist/core/daemon/runtime-activation-transaction.js +80 -11
  77. package/dist/core/daemon/runtime-activation-transaction.js.map +1 -1
  78. package/dist/core/daemon/runtime-release-dependency-inventory.js +1 -0
  79. package/dist/core/daemon/runtime-release-dependency-inventory.js.map +1 -1
  80. package/dist/core/daemon/service-install-authority.d.ts +0 -4
  81. package/dist/core/daemon/service-install-authority.js +18 -3
  82. package/dist/core/daemon/service-install-authority.js.map +1 -1
  83. package/dist/core/dashboard.d.ts +10 -0
  84. package/dist/core/dashboard.js +103 -28
  85. package/dist/core/dashboard.js.map +1 -1
  86. package/dist/core/effective-config.js +7 -0
  87. package/dist/core/effective-config.js.map +1 -1
  88. package/dist/core/fleet/automerge-pass.js +33 -0
  89. package/dist/core/fleet/automerge-pass.js.map +1 -1
  90. package/dist/core/fleet/decisions-ledger.js +35 -12
  91. package/dist/core/fleet/decisions-ledger.js.map +1 -1
  92. package/dist/core/fleet/external-skill-git-capture.js +9 -1
  93. package/dist/core/fleet/external-skill-git-capture.js.map +1 -1
  94. package/dist/core/fleet/judge-decision-metadata.d.ts +8 -1
  95. package/dist/core/fleet/judge-decision-metadata.js +14 -1
  96. package/dist/core/fleet/judge-decision-metadata.js.map +1 -1
  97. package/dist/core/fleet/manager.d.ts +15 -1
  98. package/dist/core/fleet/manager.js +108 -30
  99. package/dist/core/fleet/manager.js.map +1 -1
  100. package/dist/core/fleet/post-merge-credit.d.ts +134 -9
  101. package/dist/core/fleet/post-merge-credit.js +350 -10
  102. package/dist/core/fleet/post-merge-credit.js.map +1 -1
  103. package/dist/core/fleet/quota.d.ts +74 -7
  104. package/dist/core/fleet/quota.js +688 -34
  105. package/dist/core/fleet/quota.js.map +1 -1
  106. package/dist/core/fleet/self-improve.d.ts +34 -1
  107. package/dist/core/fleet/self-improve.js +106 -1
  108. package/dist/core/fleet/self-improve.js.map +1 -1
  109. package/dist/core/fleet/skill-library.d.ts +21 -2
  110. package/dist/core/fleet/skill-library.js +54 -12
  111. package/dist/core/fleet/skill-library.js.map +1 -1
  112. package/dist/core/fleet/status.js +7 -5
  113. package/dist/core/fleet/status.js.map +1 -1
  114. package/dist/core/fleet/worked-ledger.d.ts +22 -0
  115. package/dist/core/fleet/worked-ledger.js +32 -0
  116. package/dist/core/fleet/worked-ledger.js.map +1 -1
  117. package/dist/core/foundry/automerge-scope-policy.d.ts +2 -2
  118. package/dist/core/foundry/automerge-scope-policy.js +9 -2
  119. package/dist/core/foundry/automerge-scope-policy.js.map +1 -1
  120. package/dist/core/goals/advance.js +34 -5
  121. package/dist/core/goals/advance.js.map +1 -1
  122. package/dist/core/goals/conductor-quota.d.ts +38 -0
  123. package/dist/core/goals/conductor-quota.js +110 -0
  124. package/dist/core/goals/conductor-quota.js.map +1 -0
  125. package/dist/core/goals/conductor-target.d.ts +24 -0
  126. package/dist/core/goals/conductor-target.js +35 -0
  127. package/dist/core/goals/conductor-target.js.map +1 -0
  128. package/dist/core/goals/conductor.d.ts +15 -0
  129. package/dist/core/goals/conductor.js +116 -9
  130. package/dist/core/goals/conductor.js.map +1 -1
  131. package/dist/core/goals/store.d.ts +29 -5
  132. package/dist/core/goals/store.js +197 -30
  133. package/dist/core/goals/store.js.map +1 -1
  134. package/dist/core/goals/timestamp-repair.d.ts +50 -0
  135. package/dist/core/goals/timestamp-repair.js +740 -0
  136. package/dist/core/goals/timestamp-repair.js.map +1 -0
  137. package/dist/core/inbox/apply.js +5 -2
  138. package/dist/core/inbox/apply.js.map +1 -1
  139. package/dist/core/inbox/merge.d.ts +94 -1
  140. package/dist/core/inbox/merge.js +349 -16
  141. package/dist/core/inbox/merge.js.map +1 -1
  142. package/dist/core/integrations/github.d.ts +2 -0
  143. package/dist/core/integrations/github.js +2 -1
  144. package/dist/core/integrations/github.js.map +1 -1
  145. package/dist/core/integrations/pulse-sync.js +13 -4
  146. package/dist/core/integrations/pulse-sync.js.map +1 -1
  147. package/dist/core/learn/reflect.d.ts +42 -4
  148. package/dist/core/learn/reflect.js +56 -5
  149. package/dist/core/learn/reflect.js.map +1 -1
  150. package/dist/core/run/best-of-n.d.ts +18 -0
  151. package/dist/core/run/best-of-n.js +163 -18
  152. package/dist/core/run/best-of-n.js.map +1 -1
  153. package/dist/core/run/engines.d.ts +8 -0
  154. package/dist/core/run/engines.js +9 -1
  155. package/dist/core/run/engines.js.map +1 -1
  156. package/dist/core/run/learned-router.js +28 -3
  157. package/dist/core/run/learned-router.js.map +1 -1
  158. package/dist/core/run/orchestrator.d.ts +7 -2
  159. package/dist/core/run/orchestrator.js +140 -60
  160. package/dist/core/run/orchestrator.js.map +1 -1
  161. package/dist/core/strategy/goal-planner.js +7 -1
  162. package/dist/core/strategy/goal-planner.js.map +1 -1
  163. package/dist/core/swarm/planner.d.ts +2 -2
  164. package/dist/core/swarm/planner.js +52 -5
  165. package/dist/core/swarm/planner.js.map +1 -1
  166. package/dist/core/swarm/rollback.d.ts +11 -0
  167. package/dist/core/swarm/rollback.js +54 -0
  168. package/dist/core/swarm/rollback.js.map +1 -1
  169. package/dist/core/swarm/runner.js +123 -11
  170. package/dist/core/swarm/runner.js.map +1 -1
  171. package/dist/core/types.d.ts +139 -1
  172. package/dist/core/types.js.map +1 -1
  173. package/dist/core/web/api.js +105 -7
  174. package/dist/core/web/api.js.map +1 -1
  175. package/dist/core/web/control.d.ts +20 -0
  176. package/dist/core/web/control.js +49 -34
  177. package/dist/core/web/control.js.map +1 -1
  178. package/dist/core/web/fleet-status-cache.d.ts +68 -0
  179. package/dist/core/web/fleet-status-cache.js +120 -0
  180. package/dist/core/web/fleet-status-cache.js.map +1 -0
  181. package/dist/core/web/public/next/assets/index-CY0U241s.css +1 -0
  182. package/dist/core/web/public/next/assets/index-Ct0mf8n4.js +69 -0
  183. package/dist/core/web/public/next/assets/index-Ct0mf8n4.js.map +1 -0
  184. package/dist/core/web/public/next/index.html +15 -0
  185. package/dist/release-dependency-inventory.json +1 -1
  186. package/dist/tui/app.js +2 -2
  187. package/dist/tui/app.js.map +1 -1
  188. package/dist/tui/render.d.ts +2 -0
  189. package/dist/tui/render.js +162 -4
  190. package/dist/tui/render.js.map +1 -1
  191. package/docs/RUNTIME_ACTIVATION_AUTHORITY.md +126 -21
  192. package/docs/contracts/CONTRACT-M521.md +102 -0
  193. package/package.json +20 -3
  194. package/schema/config.schema.json +800 -5
  195. package/node_modules/which/CHANGELOG.md +0 -166
@@ -45,14 +45,14 @@ import { loadQueuedAutonomyItems } from '../portfolio/queued-autonomy.js';
45
45
  import { acquireDaemonLock, accountDaemonSpendGuard, armDaemonSpendGuard, clearDaemonSpendGuard, DAEMON_SPEND_GUARD_ITEM_CAPACITY, daemonStatePath, loadDaemonState, loadDaemonStateStrict, readDaemonLockOwner, readDaemonSpendGuard, releaseDaemonLock, resetDayIfNeeded, saveDaemonStateResult, upgradeLegacyDaemonSpendGuard, } from './state.js';
46
46
  import { acquireLocalStoreLock, releaseLocalStoreLock, } from '../fleet/local-store-lock.js';
47
47
  import { readDaemonActivity, writeDaemonActivity, } from './activity.js';
48
- import { consumeDaemonActivationPermit, isDaemonActivationCapability, } from './activation-permit.js';
48
+ import { consumeDaemonActivationPermit, daemonActivationScopeGranted, isDaemonActivationCapability, } from './activation-permit.js';
49
49
  import { nullSink } from '../run/streaming.js';
50
50
  import { createOuterAttemptIdentity } from '../fleet/attempt-identity.js';
51
51
  import { runSwarm } from '../swarm/runner.js';
52
52
  import { runGoal } from '../run/orchestrator.js';
53
53
  import { scopeFromWorkItem } from '../run/delegation-scope.js';
54
54
  import { generatedRepairCandidateAllowed, generatedRepairExecutionBackendAllowed, inspectGeneratedRepairRouteFeasibility, routeBackend, } from '../fleet/router.js';
55
- import { withinLimit, recordUse } from '../fleet/quota.js';
55
+ import { recordUse, reserveFleetQuotaUse, reserveFleetQuotaUses, withinLimit, } from '../fleet/quota.js';
56
56
  import { engineTierOf } from '../run/sandboxed-engine.js';
57
57
  import { resolveEngineSpec } from '../run/engine-registry.js';
58
58
  import { subscriptionAllows, isSubscriptionEngine } from '../fleet/subscription-usage.js'; // M80
@@ -93,7 +93,7 @@ import { observeShadowSkills } from '../fleet/skill-shadow-observer.js';
93
93
  import { selectWorkQueueCoordinator } from '../seams/work-queue-coordinator.js';
94
94
  // M220: verdict-feedback sweep — feed judge rejections back to the ledger so
95
95
  // re-clogging items (e.g. "CI is failing") are suppressed for the cooldown window.
96
- import { GENERATED_REPAIR_DISPATCH_BLOCKED_COOLDOWN_MS, latestWorkedEventForKeys, recordOutcome as recordWorkedOutcome, sweepJudgedProposals, workedEventIsCooling, } from '../fleet/worked-ledger.js';
96
+ import { DEFAULT_MAX_REJECTION_ATTEMPTS, GENERATED_REPAIR_DISPATCH_BLOCKED_COOLDOWN_MS, latestWorkedEventForKeys, recordOutcome as recordWorkedOutcome, rejectionAttemptCount, sweepJudgedProposals, workedEventIsCooling, } from '../fleet/worked-ledger.js';
97
97
  import { blockingPendingProposalsForBacklog, pendingProposalItemKeysForBacklog, workItemCoverageKey, } from '../fleet/proposal-matching.js';
98
98
  import { loadConfig } from '../config.js';
99
99
  import { hostname as osHostname } from 'node:os';
@@ -727,6 +727,13 @@ function generatedRepairShouldSkip(workedEvents, policy) {
727
727
  const latest = latestWorkedEventForKeys(workedEvents, policy.itemIds);
728
728
  if (!latest)
729
729
  return false;
730
+ // Attempt cap: cooldown alone only delays a retry — after enough windows
731
+ // elapse the same rejected item comes back forever. Once it has racked up
732
+ // DEFAULT_MAX_REJECTION_ATTEMPTS real judge rejections, park it instead of
733
+ // continuing to re-dispatch (see workItemAttemptCapExceeded/summarizeSelectionBlockers
734
+ // for the operator-visible counter).
735
+ if (workItemAttemptCapExceeded(workedEvents, policy))
736
+ return true;
730
737
  const override = policy.outcomeCooldownMs?.[latest.outcome];
731
738
  if (override !== undefined) {
732
739
  const eventMs = Date.parse(latest.ts);
@@ -734,6 +741,16 @@ function generatedRepairShouldSkip(workedEvents, policy) {
734
741
  }
735
742
  return workedEventIsCooling(latest, policy.cooldownMs);
736
743
  }
744
+ /**
745
+ * True once a work item has racked up DEFAULT_MAX_REJECTION_ATTEMPTS real
746
+ * judge rejections (judged-review/judged-noise/judged-decline). Distinct
747
+ * from the time-based cooldown: cooldown expires and the item comes back;
748
+ * an exceeded attempt cap parks the item for good (until something resets
749
+ * its worked-event history, e.g. the item is meaningfully re-authored).
750
+ */
751
+ function workItemAttemptCapExceeded(workedEvents, policy) {
752
+ return rejectionAttemptCount(workedEvents, policy.itemIds) >= DEFAULT_MAX_REJECTION_ATTEMPTS;
753
+ }
737
754
  const RESOURCE_SNAPSHOT_MAX_AGE_MS = 30_000;
738
755
  const GENERATED_REPAIR_EMPTY_FAST_COOLDOWN_MS = 30 * 60 * 1000;
739
756
  const MAX_GENERATED_REPAIR_DECISION_EVENTS = 20;
@@ -779,6 +796,26 @@ const LOCAL_ONLY_BACKENDS = new Set(['builtin', 'local-coder', 'ashlrcode', 'aw'
779
796
  const DRAIN_MODE_TAG_PREFIX = 'drain:';
780
797
  const MAX_DRAIN_SELECTED_IDS = 12;
781
798
  const MAX_DIAGNOSTIC_RESLICE_DRAIN_LIMIT = 50;
799
+ /**
800
+ * M470: the exact authority the pre-M470 boolean capability check conveyed.
801
+ *
802
+ * Declared here rather than imported from activation-permit.js on purpose:
803
+ * several suites mock that module wholesale, and importing a *value* from it
804
+ * would throw at module load under those mocks. The explicit type annotation
805
+ * still makes tsc fail if `DaemonActivationGrantScope` ever gains a key, so
806
+ * this cannot silently drift into granting more than it should.
807
+ */
808
+ const LEGACY_PROPOSAL_ONLY_SCOPE = Object.freeze({
809
+ once: true,
810
+ resident: false,
811
+ residentStanding: false,
812
+ conductor: false,
813
+ automerge: false,
814
+ repair: false,
815
+ deploy: false,
816
+ install: false,
817
+ proposalOnly: true,
818
+ });
782
819
  /** Schedule advisory post-state work only after a durable resident tick returns. */
783
820
  export function scheduleResolutionObserverAfterTick(tickResult, opts, schedule = scheduleResolutionObserverChild, killIsOn = killSwitchOn) {
784
821
  if (opts.dryRun || opts.once || tickResult.reason === 'state-persistence-failed' ||
@@ -1573,7 +1610,8 @@ function recordQueueSelectionAgentAction(fields) {
1573
1610
  action: 'daemon:selection',
1574
1611
  summary: `${lane}: claimed ${fields.claimedItems.length}/${fields.selectedItems.length} ` +
1575
1612
  `from ${fields.eligibleItems}/${fields.backlogItems} eligible; ` +
1576
- `cooldown ${fields.cooldownBlocked}, pending ${fields.pendingBlocked}, route ${fields.routeBlocked}`,
1613
+ `cooldown ${fields.cooldownBlocked}, pending ${fields.pendingBlocked}, route ${fields.routeBlocked}` +
1614
+ (fields.attemptCapBlocked > 0 ? `, attempt-cap-parked ${fields.attemptCapBlocked}` : ''),
1577
1615
  reason: fields.dryRun ? 'dry-run' : fields.claimedItems.length > 0 ? 'selected' : 'no-claim',
1578
1616
  ...(first?.repo ? { repo: first.repo } : {}),
1579
1617
  ...(first?.id ? { itemId: boundedText(first.id, 120) } : {}),
@@ -1593,6 +1631,7 @@ function recordQueueSelectionAgentAction(fields) {
1593
1631
  cooldownBlocked: fields.cooldownBlocked,
1594
1632
  routeBlocked: fields.routeBlocked,
1595
1633
  fastRepairCooldown: fields.fastRepairCooldown,
1634
+ attemptCapBlocked: fields.attemptCapBlocked,
1596
1635
  rawSelectCount: fields.rawSelectCount,
1597
1636
  selectCount: fields.selectCount,
1598
1637
  selected: fields.selectedItems.length,
@@ -1820,6 +1859,10 @@ function noProposalProductionReason(production) {
1820
1859
  return boundedText(productionReason(production), 160);
1821
1860
  }
1822
1861
  function noProposalOutcomeFromReason(reason) {
1862
+ if (/\bquota-reservation-refused\b/i.test(reason))
1863
+ return 'gate-blocked';
1864
+ if (/\b(provider-dispatch|candidate configuration refused)\b/i.test(reason))
1865
+ return 'gate-blocked';
1823
1866
  if (/\bempty[- ]diff\b/i.test(reason))
1824
1867
  return 'empty-diff';
1825
1868
  if (/\b(trivial-proposal|trivial|completeness-gate|gate-blocked|gate)\b/i.test(reason))
@@ -1855,6 +1898,24 @@ function bestOfNCandidateProduction(candidate, authority) {
1855
1898
  ...(metadata?.diffFiles !== undefined ? { diffFiles: metadata.diffFiles } : {}),
1856
1899
  ...(metadata?.diffLines !== undefined ? { diffLines: metadata.diffLines } : {}),
1857
1900
  };
1901
+ if (candidate.quotaReservation && !candidate.quotaReservation.launchAuthorized) {
1902
+ return {
1903
+ production: {
1904
+ outcome: 'gate-blocked',
1905
+ reason: `quota reservation refused: ${candidate.quotaReservation.kind}`,
1906
+ ...diff,
1907
+ },
1908
+ };
1909
+ }
1910
+ if (candidate.providerDispatchAttempted === false && candidate.error) {
1911
+ return {
1912
+ production: {
1913
+ outcome: noProposalOutcomeFromReason(candidate.error),
1914
+ reason: candidate.error,
1915
+ ...diff,
1916
+ },
1917
+ };
1918
+ }
1858
1919
  switch (candidate.proposalDisposition?.kind) {
1859
1920
  case 'verification-rejected':
1860
1921
  return {
@@ -1992,6 +2053,8 @@ function bestOfNAuthoritativeNoWinnerProduction(result, n, runId, costUsd, autho
1992
2053
  }
1993
2054
  if (error &&
1994
2055
  error !== 'cancelled' &&
2056
+ candidate.providerDispatchAttempted !== false &&
2057
+ candidate.quotaReservation?.launchAuthorized !== false &&
1995
2058
  !candidateErrorDuplicatesProposalOutcome(error, candidate.proposalOutcome)) {
1996
2059
  addAuthority({ outcome: 'engine-failed', reason: error }, `error:${error}`);
1997
2060
  }
@@ -2817,7 +2880,21 @@ function claimCooldownPolicyForSelectionItem(item, baseCooldownMs, repairRecover
2817
2880
  */
2818
2881
  export async function tick(cfg, opts) {
2819
2882
  const now = new Date().toISOString();
2820
- const activationAccepted = !opts.dryRun && isDaemonActivationCapability(opts.activationCapability);
2883
+ // A pre-verified scope (resident path) is accepted WITHOUT consuming, since
2884
+ // runDaemon already consumed the capability once on entry. Otherwise fall back
2885
+ // to consuming the capability here, which is the one-shot path.
2886
+ const preVerifiedScope = !opts.dryRun ? opts.activationScope : undefined;
2887
+ const consumedCapability = !opts.dryRun && preVerifiedScope === undefined
2888
+ ? isDaemonActivationCapability(opts.activationCapability)
2889
+ : false;
2890
+ // When the capability check passes but no scope object is readable (a caller
2891
+ // or test that supplies only the claim), fall back to the legacy proposal-only
2892
+ // scope — the exact authority the pre-M470 boolean check conveyed. Never widen.
2893
+ const activationScope = preVerifiedScope
2894
+ ?? (consumedCapability
2895
+ ? (opts.activationCapability?.scope ?? LEGACY_PROPOSAL_ONLY_SCOPE)
2896
+ : undefined);
2897
+ const activationAccepted = !opts.dryRun && activationScope !== undefined;
2821
2898
  if (!opts.dryRun && !activationAccepted) {
2822
2899
  persistAudit({
2823
2900
  action: 'daemon:activation-refused',
@@ -2834,7 +2911,52 @@ export async function tick(cfg, opts) {
2834
2911
  reason: 'activation-refused',
2835
2912
  };
2836
2913
  }
2837
- const proposalOnlyActivation = activationAccepted && opts.activationCapability !== undefined;
2914
+ // A standing `residentStanding` grant is NOT single-use, unlike the
2915
+ // one-shot WeakMap capability that forces `preVerifiedScope` to be trusted
2916
+ // for the run's whole lifetime (see the TickOptions.activationScope doc).
2917
+ // That means it CAN be safely re-checked on every tick with no consumption
2918
+ // side effect — so a resident daemon that started off a standing grant
2919
+ // gets a real "stop the next tick" guarantee from `ashlr activation
2920
+ // revoke --grant <id>`, not just "blocks the next `daemon start`." This is
2921
+ // strictly stronger than what the one-shot capability path can offer; it
2922
+ // cannot interrupt a tick already in flight.
2923
+ if (!opts.dryRun && preVerifiedScope?.residentStanding === true) {
2924
+ const stillGranted = daemonActivationScopeGranted('residentStanding');
2925
+ if (!stillGranted.granted) {
2926
+ persistAudit({
2927
+ action: 'daemon:activation-refused',
2928
+ repo: null,
2929
+ sandboxId: null,
2930
+ summary: `live tick refused: resident standing grant no longer authorized: ${stillGranted.reason}`,
2931
+ result: 'refused',
2932
+ });
2933
+ return {
2934
+ ts: now,
2935
+ itemsConsidered: 0,
2936
+ proposalsCreated: 0,
2937
+ spentUsd: 0,
2938
+ reason: 'activation-refused',
2939
+ };
2940
+ }
2941
+ }
2942
+ // M470: proposal-only is derived from the GRANTED scope rather than from "an
2943
+ // activation exists at all" — the previous form forced every authorized tick
2944
+ // to proposal-only, so an automerge/repair/deploy grant could never take
2945
+ // effect.
2946
+ //
2947
+ // The `grantedScope` distinction matters: the pre-M470 condition was
2948
+ // `opts.activationCapability !== undefined`, i.e. it keyed off a real permit
2949
+ // object being supplied, NOT off the boolean claim. Callers that pass only a
2950
+ // claim (and the legacy fallback above) must keep the old
2951
+ // `proposalOnlyActivation === false` behavior, otherwise
2952
+ // runAncillaryMaintenance — which early-returns on proposal-only — silently
2953
+ // stops running self-heal, invent, and drain.
2954
+ const grantedScope = preVerifiedScope ?? opts.activationCapability?.scope;
2955
+ const proposalOnlyActivation = activationAccepted &&
2956
+ grantedScope !== undefined &&
2957
+ !grantedScope.automerge &&
2958
+ !grantedScope.repair &&
2959
+ !grantedScope.deploy;
2838
2960
  let ownershipLost = false;
2839
2961
  const stillOwnsTick = () => {
2840
2962
  if (!opts.ownerLock)
@@ -3410,6 +3532,42 @@ export async function tick(cfg, opts) {
3410
3532
  console.warn('[ashlr] daemon:tick outcomeWatcher failed:', err?.message ?? err);
3411
3533
  }
3412
3534
  }
3535
+ // M505: Learning-loop sweeps. Each of these was previously unreachable in a
3536
+ // live tick — rejection learning sat behind `autoMerge.enabled`, post-merge
3537
+ // credit was a stubbed `false`, and reflection was a manual CLI command — so
3538
+ // the fleet observed every run and learned from none of them.
3539
+ if (!stopRequested() && liveCfg.foundry?.['selfImprove'] !== false) {
3540
+ try {
3541
+ const { sweepRejectionLearning } = await import('../fleet/self-improve.js');
3542
+ sweepRejectionLearning(liveCfg);
3543
+ }
3544
+ catch (err) {
3545
+ console.warn('[ashlr] daemon:tick sweepRejectionLearning failed:', err?.message ?? err);
3546
+ }
3547
+ }
3548
+ // Credit is released only by the M505 protocol's own fail-closed checks
3549
+ // (authenticated merge witness, 14-day quiet window, no adverse observation
3550
+ // on the exact event, healthy ledger read). This call only drives the sweep.
3551
+ if (!stopRequested()) {
3552
+ try {
3553
+ const { sweepPostMergeCreditReleases } = await import('../fleet/post-merge-credit.js');
3554
+ sweepPostMergeCreditReleases();
3555
+ }
3556
+ catch (err) {
3557
+ console.warn('[ashlr] daemon:tick sweepPostMergeCreditReleases failed:', err?.message ?? err);
3558
+ }
3559
+ }
3560
+ // Low cadence, matching the counterfactual-replay pattern above: reflection
3561
+ // persists a snapshot and distills playbooks, and does not need every tick.
3562
+ if (!stopRequested() && state.ticks.length % 20 === 0) {
3563
+ try {
3564
+ const { runReflectionCycle } = await import('../learn/reflect.js');
3565
+ await runReflectionCycle(liveCfg);
3566
+ }
3567
+ catch (err) {
3568
+ console.warn('[ashlr] daemon:tick runReflectionCycle failed:', err?.message ?? err);
3569
+ }
3570
+ }
3413
3571
  // M189: Regression sentinel — detect regressions introduced by auto-merge and bisect/revert.
3414
3572
  const regressionSentinel = liveCfg.foundry?.regressionSentinel;
3415
3573
  if (!stopRequested() &&
@@ -4194,11 +4352,19 @@ export async function tick(cfg, opts) {
4194
4352
  let cooldownBlocked = 0;
4195
4353
  let routeBlocked = 0;
4196
4354
  let fastRepairCooldown = 0;
4355
+ let attemptCapBlocked = 0;
4197
4356
  for (const item of items) {
4198
4357
  const pending = pendingItemKeys.has(workItemCoverageKey(item));
4199
4358
  const itemPolicy = claimCooldownPolicies.get(item.id);
4200
4359
  const itemCooldownMs = cooldownMsForSelectionItem(selectionWorkedEvents, itemPolicy);
4201
4360
  const cooling = generatedRepairShouldSkip(selectionWorkedEvents, itemPolicy);
4361
+ // Attempt-cap-parked items are already folded into `cooling` (they
4362
+ // must never be dispatched), but they get their own counter so an
4363
+ // operator can tell "will retry after cooldown" apart from "parked
4364
+ // for good — rejected too many times" instead of it being silently
4365
+ // indistinguishable in the tick summary.
4366
+ if (cooling && workItemAttemptCapExceeded(selectionWorkedEvents, itemPolicy))
4367
+ attemptCapBlocked++;
4202
4368
  if (pending)
4203
4369
  pendingBlocked++;
4204
4370
  if (cooling)
@@ -4214,7 +4380,7 @@ export async function tick(cfg, opts) {
4214
4380
  fastRepairCooldown++;
4215
4381
  }
4216
4382
  }
4217
- return { eligibleItems, pendingBlocked, cooldownBlocked, routeBlocked, fastRepairCooldown };
4383
+ return { eligibleItems, pendingBlocked, cooldownBlocked, routeBlocked, fastRepairCooldown, attemptCapBlocked };
4218
4384
  };
4219
4385
  const selectRoundRobinCandidates = (items, count) => {
4220
4386
  // Group selectable items by repo (score-sorted within each group by buildBacklog).
@@ -4414,6 +4580,7 @@ export async function tick(cfg, opts) {
4414
4580
  cooldownBlocked: selectionBlockers.cooldownBlocked,
4415
4581
  routeBlocked: selectionBlockers.routeBlocked,
4416
4582
  fastRepairCooldown: selectionBlockers.fastRepairCooldown,
4583
+ attemptCapBlocked: selectionBlockers.attemptCapBlocked,
4417
4584
  rawSelectCount: selectionTelemetryRawSelectCount,
4418
4585
  selectCount: selectionTelemetrySelectCount,
4419
4586
  selectedItems: selected,
@@ -4755,6 +4922,33 @@ export async function tick(cfg, opts) {
4755
4922
  skipReason: 'queue-lease-lost',
4756
4923
  }),
4757
4924
  });
4925
+ const quotaRefusalOutcome = (item, attemptId, backend, quota) => {
4926
+ const evidence = [
4927
+ typeof quota.used === 'number' ? `used=${quota.used}` : null,
4928
+ typeof quota.limit === 'number' ? `limit=${quota.limit}` : null,
4929
+ quota.reservationConsumed === true ? 'reservation-consumed=true' : null,
4930
+ ].filter((value) => value !== null).join(', ');
4931
+ const detail = evidence ? `${quota.kind} (${evidence})` : quota.kind;
4932
+ return {
4933
+ item,
4934
+ spentUsd: 0,
4935
+ dispatched: false,
4936
+ dispatch: dispatchTrace(item, {
4937
+ backend,
4938
+ tier: engineTierOf(backend, routingCfg),
4939
+ assignedBy: 'quota-reservation',
4940
+ reason: `provider dispatch refused by durable quota authority: ${detail}`,
4941
+ dispatched: false,
4942
+ runId: attemptId,
4943
+ trajectoryId: `run:${attemptId}`,
4944
+ skipReason: `quota-${quota.kind}`,
4945
+ production: {
4946
+ outcome: 'gate-blocked',
4947
+ reason: `quota reservation refused: ${detail}`,
4948
+ },
4949
+ }),
4950
+ };
4951
+ };
4758
4952
  const tasks = workedSet.map((item, _taskIdx) => {
4759
4953
  const attemptId = attemptIds.get(item.id);
4760
4954
  const leaseController = leaseAbortControllers.get(item.id);
@@ -5520,6 +5714,8 @@ export async function tick(cfg, opts) {
5520
5714
  }
5521
5715
  }
5522
5716
  let bonBillable = null;
5717
+ let bonLaunchMarked = false;
5718
+ let bonDispatchStarted = false;
5523
5719
  let runState;
5524
5720
  if (fanOut) {
5525
5721
  // Route through runBestOfN; use its winner's underlying runState.
@@ -5531,16 +5727,6 @@ export async function tick(cfg, opts) {
5531
5727
  if (!stillOwnsTick())
5532
5728
  throw new Error('daemon lock ownership lost before best-of-n launch');
5533
5729
  beginQueueExecution();
5534
- recordUse(backend);
5535
- recordDispatchStartAgentAction(item, {
5536
- ts: new Date().toISOString(), machineId, runId: attemptId, backend: backend,
5537
- tier: backendTier, model: selectedModel, assignedBy, reason: assignmentReason, mode: 'best-of-n',
5538
- });
5539
- if (!stillOwnsTick())
5540
- throw new Error('daemon lock ownership lost before best-of-n producer start');
5541
- if (!markGeneratedRepairExecutionLaunched(item)) {
5542
- throw new Error('generated repair launch reservation could not be persisted');
5543
- }
5544
5730
  return runBestOfN(item, routingCfg, {
5545
5731
  n: bestOfN, engine: backend, model: selectedModel,
5546
5732
  budget: itemBudget,
@@ -5549,13 +5735,40 @@ export async function tick(cfg, opts) {
5549
5735
  workItemId: item.id, workItemGenerationId, workSource: item.source,
5550
5736
  delegationScope, attemptId, shadowSkillCards, shadowSkillSelectedAt,
5551
5737
  signal: dispatchSignal,
5738
+ reserveQuotaUses: (requests) => reserveFleetQuotaUses(requests, routingCfg),
5739
+ providerDispatchStillAuthorized: () => stillOwnsTick() && !stopRequested() && !dispatchSignal.aborted,
5740
+ beforeCandidateProviderDispatch: () => {
5741
+ if (!stillOwnsTick() || stopRequested() || dispatchSignal.aborted)
5742
+ return false;
5743
+ if (!bonLaunchMarked) {
5744
+ if (!markGeneratedRepairExecutionLaunched(item))
5745
+ return false;
5746
+ bonLaunchMarked = true;
5747
+ }
5748
+ return stillOwnsTick() && !stopRequested() && !dispatchSignal.aborted;
5749
+ },
5750
+ onProviderDispatchStarted: (candidateBackend) => {
5751
+ if (!bonDispatchStarted) {
5752
+ recordDispatchStartAgentAction(item, {
5753
+ ts: new Date().toISOString(), machineId, runId: attemptId, backend: backend,
5754
+ tier: backendTier, model: selectedModel, assignedBy,
5755
+ reason: assignmentReason, mode: 'best-of-n',
5756
+ });
5757
+ bonDispatchStarted = true;
5758
+ }
5759
+ recordUse(candidateBackend);
5760
+ },
5552
5761
  });
5553
5762
  });
5554
5763
  if (!launch.authorized)
5555
5764
  return authorityUnavailableOutcome(item, attemptId);
5556
- dispatched = true;
5557
- backendDispatch[backend] = (backendDispatch[backend] ?? 0) + 1;
5558
5765
  const bonResult = await launch.value;
5766
+ const attemptedCandidates = bonResult.candidates.filter((candidate) => candidate.providerDispatchAttempted === true);
5767
+ dispatched = attemptedCandidates.length > 0;
5768
+ for (const candidate of attemptedCandidates) {
5769
+ const candidateBackend = candidate.engine ?? backend;
5770
+ backendDispatch[candidateBackend] = (backendDispatch[candidateBackend] ?? 0) + 1;
5771
+ }
5559
5772
  bonBillable = bonResult.critique.billableCostUsd ?? 0;
5560
5773
  if (!stillOwnsTick()) {
5561
5774
  swarmSpent = bonBillable;
@@ -5563,14 +5776,14 @@ export async function tick(cfg, opts) {
5563
5776
  return {
5564
5777
  item,
5565
5778
  spentUsd: swarmSpent,
5566
- dispatched: true,
5779
+ dispatched,
5567
5780
  dispatch: dispatchTrace(item, {
5568
5781
  backend,
5569
5782
  tier: backendTier,
5570
5783
  model: selectedModel,
5571
5784
  assignedBy,
5572
5785
  reason: assignmentReason,
5573
- dispatched: true,
5786
+ dispatched,
5574
5787
  spentUsd: swarmSpent,
5575
5788
  runId: attemptId,
5576
5789
  trajectoryId: `run:${attemptId}`,
@@ -5594,6 +5807,10 @@ export async function tick(cfg, opts) {
5594
5807
  : shadowOnly
5595
5808
  ? bestOfNShadowOnlyProduction(bestOfN)
5596
5809
  : bestOfNNoWinnerProduction(bestOfN));
5810
+ const quotaRefusal = bonResult.candidates.find((candidate) => candidate.quotaReservation && !candidate.quotaReservation.launchAuthorized)?.quotaReservation;
5811
+ if (!dispatched && quotaRefusal?.launchAuthorized === false) {
5812
+ return quotaRefusalOutcome(item, attemptId, quotaRefusal.backend ?? backend, quotaRefusal);
5813
+ }
5597
5814
  audit({
5598
5815
  action: 'daemon:no-proposal',
5599
5816
  repo: item.repo,
@@ -5604,14 +5821,14 @@ export async function tick(cfg, opts) {
5604
5821
  return {
5605
5822
  item,
5606
5823
  spentUsd: swarmSpent,
5607
- dispatched: true,
5824
+ dispatched,
5608
5825
  dispatch: dispatchTrace(item, {
5609
5826
  backend,
5610
5827
  tier: backendTier,
5611
5828
  model: selectedModel,
5612
5829
  assignedBy,
5613
5830
  reason: `${assignmentReason}; best-of-${bestOfN}: all candidates empty`,
5614
- dispatched: true,
5831
+ dispatched,
5615
5832
  spentUsd: swarmSpent,
5616
5833
  runId: attemptId,
5617
5834
  trajectoryId: `run:${attemptId}`,
@@ -5633,7 +5850,10 @@ export async function tick(cfg, opts) {
5633
5850
  if (!stillOwnsTick())
5634
5851
  throw new Error('daemon lock ownership lost before direct launch');
5635
5852
  beginQueueExecution();
5636
- recordUse(backend);
5853
+ const quota = reserveFleetQuotaUse(backend, routingCfg, attemptId);
5854
+ if (!quota.launchAuthorized) {
5855
+ return { kind: 'quota-refused', quota };
5856
+ }
5637
5857
  recordDispatchStartAgentAction(item, {
5638
5858
  ts: new Date().toISOString(), machineId, runId: attemptId, backend: backend,
5639
5859
  tier: backendTier, model: selectedModel, assignedBy, reason: assignmentReason, mode: 'single',
@@ -5643,19 +5863,24 @@ export async function tick(cfg, opts) {
5643
5863
  if (!markGeneratedRepairExecutionLaunched(item)) {
5644
5864
  throw new Error('generated repair launch reservation could not be persisted');
5645
5865
  }
5646
- return runGoal(goal, dispatchCfg, {
5866
+ const run = runGoal(goal, dispatchCfg, {
5647
5867
  engine: backend, sandboxEngine: true, requireSandbox: true, cwd: item.repo,
5648
5868
  budget: itemBudget, tools: true, noMemory: false, runId: attemptId,
5649
5869
  ...(selectedModel ? { model: selectedModel } : {}),
5650
5870
  workItemId: item.id, workItemGenerationId, workSource: item.source, delegationScope,
5651
5871
  signal: dispatchSignal,
5652
5872
  });
5873
+ recordUse(backend);
5874
+ return { kind: 'started', run };
5653
5875
  });
5654
5876
  if (!launch.authorized)
5655
5877
  return authorityUnavailableOutcome(item, attemptId);
5878
+ if (launch.value.kind === 'quota-refused') {
5879
+ return quotaRefusalOutcome(item, attemptId, backend, launch.value.quota);
5880
+ }
5656
5881
  dispatched = true;
5657
5882
  backendDispatch[backend] = (backendDispatch[backend] ?? 0) + 1;
5658
- runState = await launch.value;
5883
+ runState = await launch.value.run;
5659
5884
  if (!stillOwnsTick()) {
5660
5885
  swarmSpent = isSubscriptionEngine(backend ?? 'builtin') ? 0 : (runState.usage?.estCostUsd ?? 0);
5661
5886
  tickSpent += swarmSpent;
@@ -7036,6 +7261,20 @@ export async function runDaemon(cfg, opts) {
7036
7261
  process.env['ASHLR_IN_DAEMON'] = '1';
7037
7262
  const activationCfg = activation.configSnapshot ?? cfg;
7038
7263
  const dcfg = resolveCfg(activationCfg);
7264
+ // M470: verify the capability EXACTLY ONCE, here, and carry the resulting
7265
+ // scope into every tick. isDaemonActivationCapability() consumes on first
7266
+ // call, so verifying per-tick would authorize only the first resident tick
7267
+ // and refuse all subsequent ones. Entering the loop is the authorized act;
7268
+ // each tick then reads the scope as plain data.
7269
+ //
7270
+ // `activation.scope` is the standing-`residentStanding`-grant path
7271
+ // (consumeDaemonActivationPermit tried it first): no WeakMap capability
7272
+ // exists for it at all, since standing grants aren't single-use — tick()
7273
+ // re-checks it live every cycle instead (see its `residentStanding` guard).
7274
+ const runActivationScope = activation.scope
7275
+ ?? (!opts.dryRun && activation.capability && isDaemonActivationCapability(activation.capability)
7276
+ ? activation.capability.scope
7277
+ : undefined);
7039
7278
  // -------------------------------------------------------------------------
7040
7279
  // Mark daemon as running.
7041
7280
  // -------------------------------------------------------------------------
@@ -7144,10 +7383,18 @@ export async function runDaemon(cfg, opts) {
7144
7383
  sandboxId: null,
7145
7384
  summary: `daemon started: once=${opts.once}, dryRun=${opts.dryRun}, budget=$${dcfg.dailyBudgetUsd}, ` +
7146
7385
  `intervalMs=${dcfg.intervalMs}${opts.drain ? `, drain=${opts.drain}` : ''}` +
7147
- `${opts.drainLimit ? `, drainLimit=${opts.drainLimit}` : ''}`,
7386
+ `${opts.drainLimit ? `, drainLimit=${opts.drainLimit}` : ''}` +
7387
+ `${activation.grantId ? `, activatedByStandingGrant=${activation.grantId}` : ''}`,
7148
7388
  result: 'ok',
7149
7389
  });
7150
- if (!opts.dryRun && !activation.capability) {
7390
+ // `!activation.scope` preserves this exactly as it behaved before the
7391
+ // standing-grant path existed: a live (non-dry-run) authorized start always
7392
+ // has EITHER `activation.capability` (one-shot path) OR `activation.scope`
7393
+ // (standing-grant path) set, so this was — and remains — unreachable on any
7394
+ // real authorized run. Written this way rather than changed so the
7395
+ // standing-grant path doesn't silently start exercising a branch nothing
7396
+ // has exercised live before.
7397
+ if (!opts.dryRun && !activation.capability && !activation.scope) {
7151
7398
  reconcilePreparedGeneratedRepairReservations();
7152
7399
  }
7153
7400
  // -------------------------------------------------------------------------
@@ -7242,7 +7489,7 @@ export async function runDaemon(cfg, opts) {
7242
7489
  transitionActivity('tick');
7243
7490
  const tickResult = await tick(liveCfg, {
7244
7491
  dryRun: opts.dryRun,
7245
- ...(activation.capability ? { activationCapability: activation.capability } : {}),
7492
+ ...(runActivationScope ? { activationScope: runActivationScope } : {}),
7246
7493
  ...(opts.drain ? { drain: opts.drain } : {}),
7247
7494
  ...(opts.drainLimit ? { drainLimit: opts.drainLimit } : {}),
7248
7495
  signal: shutdown.signal,
@@ -7295,6 +7542,10 @@ export async function runDaemon(cfg, opts) {
7295
7542
  transitionActivity('tick');
7296
7543
  const tickResult = await tick(liveCfg, {
7297
7544
  dryRun: opts.dryRun,
7545
+ // M470: the resident loop previously passed NO activation at all, so
7546
+ // every live resident tick was refused. It now carries the scope
7547
+ // verified once at runDaemon entry.
7548
+ ...(runActivationScope ? { activationScope: runActivationScope } : {}),
7298
7549
  ...(opts.drain ? { drain: opts.drain } : {}),
7299
7550
  ...(opts.drainLimit ? { drainLimit: opts.drainLimit } : {}),
7300
7551
  signal: shutdown.signal,
@@ -7590,6 +7841,20 @@ export function buildItemGoal(item) {
7590
7841
  '(e.g. a platform-gated or intentionally-skipped test, an issue requiring ' +
7591
7842
  'product decisions, or already done), make NO changes and stop — ' +
7592
7843
  'do not force an edit.');
7844
+ // M507: completion contract — derived from real inbox data showing the two
7845
+ // most common ways an otherwise-reasonable run still gets rejected: (1) the
7846
+ // engine "fixes" a narrow task by deleting/regenerating an entire file (seen
7847
+ // repeatedly on dependency bumps that wiped package.json instead of editing
7848
+ // one version string — judged 'harmful' every time), and (2) a run hits its
7849
+ // budget mid-task and files a half-finished '[partial]' diff that fails
7850
+ // verification and is auto-drained after retries. Neither failure mode is
7851
+ // fixed by a more lenient judge — both are avoidable at generation time.
7852
+ parts.push('Do not delete, regenerate, or wholesale-rewrite a file to make this change — ' +
7853
+ 'edit only what the task requires. A diff that removes unrelated dependencies, ' +
7854
+ 'scripts, or content is treated as destructive and rejected outright, even when ' +
7855
+ 'the stated intent was minor. If you cannot produce a complete diff that passes ' +
7856
+ 'this repo’s tests, typecheck, and lint within the available budget, produce NO ' +
7857
+ 'diff and stop — a filed partial/unverified change is worse than no proposal.');
7593
7858
  return parts.join('\n\n');
7594
7859
  }
7595
7860
  //# sourceMappingURL=loop.js.map