@moretea-labs/forge 1.7.0 → 1.7.1

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 (175) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +13 -0
  3. package/CLAUDE.md +2 -0
  4. package/THIRD_PARTY_NOTICES.md +1 -1
  5. package/agents/openai.yaml +1 -1
  6. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  7. package/assets/plugin-registry.v1.json +3 -3
  8. package/assets/skill-version.json +2 -2
  9. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  10. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  11. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  12. package/assets/templates/helpers/workflow-contract.ts +5 -1
  13. package/docs/architecture/CURRENT.md +12 -4
  14. package/docs/forge-plugin-management.md +1 -1
  15. package/docs/operations/controller-performance-and-502.md +19 -0
  16. package/docs/operations/homebrew.md +2 -2
  17. package/package.json +5 -2
  18. package/scripts/architecture-event.ts +2 -820
  19. package/scripts/architecture-queue.sh +2 -645
  20. package/scripts/archive-architecture-request.sh +2 -242
  21. package/scripts/archive-workflow.sh +1 -291
  22. package/scripts/benchmark-work-efficiency.ts +0 -0
  23. package/scripts/capability-config.ts +2 -392
  24. package/scripts/capability-resolver.ts +2 -503
  25. package/scripts/capture-plan.sh +1 -332
  26. package/scripts/check-agent-tooling.sh +2 -1531
  27. package/scripts/check-architecture-sync.sh +2 -302
  28. package/scripts/check-brain-manifest.sh +2 -253
  29. package/scripts/check-context-files.sh +2 -47
  30. package/scripts/check-deploy-sql-order.sh +2 -115
  31. package/scripts/check-runtime-architecture.mjs +243 -16
  32. package/scripts/check-skill-version.ts +3 -191
  33. package/scripts/check-source-duplication.mjs +61 -0
  34. package/scripts/check-stable-baseline.ts +0 -0
  35. package/scripts/check-task-sync.sh +2 -63
  36. package/scripts/check-task-workflow.sh +2 -1000
  37. package/scripts/codex-handoff-resume.sh +2 -267
  38. package/scripts/context-contract-sync.sh +2 -514
  39. package/scripts/contract-run.ts +2 -463
  40. package/scripts/contract-worktree.sh +1 -702
  41. package/scripts/ensure-task-workflow.sh +1 -1192
  42. package/scripts/inspect-project-state.ts +4 -279
  43. package/scripts/lib/project-init-lib.sh +7 -2
  44. package/scripts/lib/run-forge-helper.sh +19 -0
  45. package/scripts/lib/run-forge-helper.ts +26 -0
  46. package/scripts/migrate-project-template.sh +1 -33
  47. package/scripts/migrate-workflow-docs.ts +4 -412
  48. package/scripts/new-plan.sh +1 -180
  49. package/scripts/new-spec.sh +1 -47
  50. package/scripts/new-sprint.sh +1 -46
  51. package/scripts/plan-to-todo.sh +1 -771
  52. package/scripts/prepare-codex-handoff.sh +2 -201
  53. package/scripts/prepare-handoff.sh +1 -78
  54. package/scripts/refresh-current-status.sh +2 -467
  55. package/scripts/run-governed-gate.ts +3 -0
  56. package/scripts/select-agent-context-blocks.sh +2 -64
  57. package/scripts/ship-worktrees.sh +1 -531
  58. package/scripts/sprint-backlog.sh +1 -834
  59. package/scripts/stage-runtime-release.ts +2 -1
  60. package/scripts/summarize-failures.sh +2 -123
  61. package/scripts/switch-plan.sh +1 -150
  62. package/scripts/sync-brain-docs.sh +2 -379
  63. package/scripts/sync-generated-authority.mjs +175 -0
  64. package/scripts/verify-contract.sh +2 -593
  65. package/scripts/verify-sprint.sh +1 -500
  66. package/scripts/workflow-contract.ts +4 -201
  67. package/scripts/workstream-sync.sh +2 -261
  68. package/src/cli/agent-jobs/executor-health.ts +1 -1
  69. package/src/cli/commands/plugin.ts +12 -3
  70. package/src/cli/controller/check-runner.ts +7 -2
  71. package/src/cli/controller/context-pack.ts +46 -21
  72. package/src/cli/github/contracts.ts +16 -0
  73. package/src/cli/github/github.ts +2 -16
  74. package/src/cli/github/session.ts +2 -1
  75. package/src/cli/local-bridge/facade-api.ts +37 -47
  76. package/src/cli/local-bridge/server.ts +2 -2
  77. package/src/cli/mcp/legacy-tool-service.ts +10 -33
  78. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  79. package/src/cli/mcp/repository-tools.ts +85 -89
  80. package/src/cli/mcp/server.ts +57 -13
  81. package/src/cli/mcp/tool-contract.ts +14 -0
  82. package/src/cli/mcp/toolset.ts +2 -2
  83. package/src/cli/mcp/transports/http.ts +23 -0
  84. package/src/cli/repositories/command-classifier.ts +2 -2
  85. package/src/cli/repositories/command-executor.ts +24 -6
  86. package/src/cli/repositories/repository-snapshot.ts +5 -2
  87. package/src/cli/repositories/structured-git.ts +57 -3
  88. package/src/cli/repository/inspector.ts +11 -3
  89. package/src/runtime/context/generic-lsp-provider.ts +1 -1
  90. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  91. package/src/runtime/context/semantic-navigation.ts +9 -61
  92. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +18 -25
  93. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  94. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  95. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  96. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  97. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  98. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  99. package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
  100. package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
  101. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  102. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  103. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  104. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  105. package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
  106. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
  107. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  108. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  109. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
  110. package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
  111. package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
  112. package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
  113. package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
  114. package/src/runtime/control-plane/facade/index.ts +2 -0
  115. package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
  116. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  117. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  118. package/src/runtime/control-plane/facade/types.ts +68 -5
  119. package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
  120. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  121. package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
  122. package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
  123. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
  124. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
  125. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
  126. package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
  127. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  128. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  129. package/src/runtime/control-plane/routing/route-policy.ts +18 -6
  130. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  131. package/src/runtime/evidence/process-check-execution.ts +18 -0
  132. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  133. package/src/runtime/execution/process-runtime/check-result.ts +53 -18
  134. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  135. package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
  136. package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
  137. package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
  138. package/src/runtime/execution/process-runtime/runtime.ts +10 -0
  139. package/src/runtime/execution/process-runtime/types.ts +2 -12
  140. package/src/runtime/execution/work-activity.ts +21 -0
  141. package/src/runtime/execution/workers/executor.ts +18 -13
  142. package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
  143. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  144. package/src/runtime/gateway/mcp/router.ts +2 -2
  145. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
  146. package/src/runtime/gateway/mcp/runtime-tools.ts +805 -1009
  147. package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
  148. package/src/runtime/plugins/browser-adapter.ts +256 -1083
  149. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  150. package/src/runtime/plugins/browser-handoff.ts +18 -3
  151. package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
  152. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  153. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  154. package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
  155. package/src/runtime/plugins/browser-runtime.ts +10 -0
  156. package/src/runtime/plugins/browser-session-store.ts +121 -0
  157. package/src/runtime/plugins/browser-session-types.ts +98 -0
  158. package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
  159. package/src/runtime/plugins/external-adapter.ts +92 -58
  160. package/src/runtime/plugins/macos-capability-broker.ts +0 -8
  161. package/src/runtime/plugins/store.ts +66 -9
  162. package/src/runtime/plugins/types.ts +3 -1
  163. package/src/runtime/recovery/maintenance-executor.ts +61 -25
  164. package/src/runtime/root/package-connector-service.ts +46 -4
  165. package/src/runtime/root/package-runtime-release.ts +1 -1
  166. package/src/runtime/root/package-runtime-service.ts +33 -2
  167. package/src/runtime/root/release-manifest.ts +137 -16
  168. package/src/runtime/root/release-materialize.ts +109 -35
  169. package/src/runtime/root/types.ts +10 -0
  170. package/src/runtime/standalone-recovery/core.ts +425 -194
  171. package/src/runtime/standalone-recovery/entry.ts +1 -1
  172. package/src/runtime/standalone-recovery/installer.ts +1 -1
  173. package/src/runtime/workflow/schedules/engine.ts +113 -87
  174. package/src/runtime/workflow/schedules/settlement.ts +26 -23
  175. package/src/runtime/workflow/schedules/store.ts +79 -10
@@ -1,4 +1,4 @@
1
- import { createHash } from 'crypto';
1
+ import { createHash, randomUUID } from 'crypto';
2
2
  import { withControllerLock } from '../../../cli/repositories/locks';
3
3
  import { readRequirement } from '../persistence/requirement-store';
4
4
  import {
@@ -7,6 +7,7 @@ import {
7
7
  writeControlPlaneRecord,
8
8
  type ControlPlaneRecord,
9
9
  } from '../persistence/sqlite-store';
10
+ import { workHasActiveExecution } from '../../execution/work-activity';
10
11
  import { controllerSessionBlocksRecovery, getControllerSession } from './controller-session-store';
11
12
  import { getHandoffItem, listHandoffItems } from './handoff-inbox-store';
12
13
  import { getWorkContract, readWorkContractStore } from './work-contract-store';
@@ -25,6 +26,7 @@ export const CONTROLLER_ROUND_DISPOSITIONS = [
25
26
  ] as const;
26
27
  export type ControllerRoundDisposition = (typeof CONTROLLER_ROUND_DISPOSITIONS)[number];
27
28
 
29
+ export const CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR = 'CONTROLLER_RELAY_CLAIM_RELEASED_WITHOUT_DISPOSITION';
28
30
  const CONTROLLER_DISPOSITION_COMPATIBILITY_PREFIX = 'controller.disposition:';
29
31
 
30
32
  export function parseControllerDispositionCompatibilityCapability(
@@ -77,6 +79,8 @@ export interface ControllerRoundRelayRecord {
77
79
  controllerInstanceId: string;
78
80
  sessionId: string;
79
81
  claimGeneration: number;
82
+ /** Opaque per-round capability. Rotates when Forge dispatches a new ChatGPT round. */
83
+ authorityId?: string;
80
84
  stateFingerprint: string;
81
85
  roundCount: number;
82
86
  repeatedStateCount: number;
@@ -90,6 +94,7 @@ export interface ControllerRoundRelayRecord {
90
94
  conversationUrl?: string;
91
95
  blockedReason?: string;
92
96
  lastError?: string;
97
+ nextRecoveryAt?: string;
93
98
  submittedAt: string;
94
99
  updatedAt: string;
95
100
  dispatchedAt?: string;
@@ -147,11 +152,17 @@ const DEFAULT_MAX_REPEATED_STATE = 2;
147
152
  const DEFAULT_MAX_FAILURES = 3;
148
153
  const DEFAULT_UNCLOSED_ROUND_GRACE_MS = 10 * 60_000;
149
154
  const MAX_UNCLOSED_ROUND_GRACE_MS = 60 * 60_000;
155
+ const DEFAULT_STALLED_RECOVERY_BACKOFF_MS = 60_000;
156
+ const MAX_STALLED_RECOVERY_BACKOFF_MS = 15 * 60_000;
150
157
 
151
158
  function nowIso(options: ControllerRoundRelayStoreOptions): string {
152
159
  return options.now?.() ?? new Date().toISOString();
153
160
  }
154
161
 
162
+ function newControllerRoundAuthorityId(): string {
163
+ return `cra_${randomUUID().replace(/-/g, '')}`;
164
+ }
165
+
155
166
  function bounded(value: string | undefined, max: number): string | undefined {
156
167
  const normalized = value?.trim();
157
168
  return normalized ? normalized.slice(0, max) : undefined;
@@ -515,6 +526,7 @@ export function submitControllerRoundDisposition(
515
526
  controllerInstanceId: authority.controllerInstanceId,
516
527
  sessionId: authority.sessionId,
517
528
  claimGeneration: authority.claimGeneration,
529
+ authorityId: existing.value.authorityId,
518
530
  stateFingerprint,
519
531
  roundCount,
520
532
  repeatedStateCount,
@@ -567,6 +579,8 @@ export function beginInitialControllerRoundDispatch(
567
579
  if (previous && ['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(previous.status)) {
568
580
  throw new Error(`CONTROLLER_RELAY_ROUND_ALREADY_OPEN: ${relayScopeId}`);
569
581
  }
582
+ const abandonedReleasedRound = previous?.status === 'failed'
583
+ && previous.lastError === CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR;
570
584
  const requestedMaxRounds = boundedInteger(input.maxRounds, DEFAULT_MAX_ROUNDS, 1, 32);
571
585
  const requestedMaxRepeatedState = boundedInteger(input.maxRepeatedState, DEFAULT_MAX_REPEATED_STATE, 1, 8);
572
586
  const requestedMaxFailures = boundedInteger(input.maxFailures, DEFAULT_MAX_FAILURES, 1, 8);
@@ -574,16 +588,16 @@ export function beginInitialControllerRoundDispatch(
574
588
  const maxRepeatedState = previous ? Math.min(previous.maxRepeatedState, requestedMaxRepeatedState) : requestedMaxRepeatedState;
575
589
  const maxFailures = previous ? Math.min(previous.maxFailures, requestedMaxFailures) : requestedMaxFailures;
576
590
  const stateFingerprint = mechanicalStateFingerprint(options, work, requirementId, relayScopeId);
577
- // beginInitialControllerRoundDispatch is entered only for a fresh external
578
- // wake after any prior round has closed. Mechanical round/repeated-state
579
- // budgets fence one immediate relay chain; carrying them across an explicit
580
- // later schedule/manual occurrence eventually blocks healthy periodic Work
581
- // whose repository state intentionally remains unchanged. Open-round
582
- // duplicate suppression above still rejects a second wake for the same live
583
- // chain, while submit/recovery paths continue to accumulate these budgets.
584
- const roundCount = 1;
585
- const repeatedStateCount = 0;
586
- const consecutiveFailures = previous?.consecutiveFailures ?? 0;
591
+ // Ordinary later schedule/manual occurrences intentionally receive a fresh
592
+ // mechanical budget. A claimed round explicitly released without any semantic
593
+ // disposition is different: launcher_start is recovering the same abandoned
594
+ // relay chain, so it must consume round/repeated-state budget rather than reset
595
+ // it and thereby weaken repeated-state fencing.
596
+ const roundCount = abandonedReleasedRound ? previous!.roundCount + 1 : 1;
597
+ const repeatedStateCount = abandonedReleasedRound
598
+ ? (previous!.stateFingerprint === stateFingerprint ? previous!.repeatedStateCount + 1 : 0)
599
+ : 0;
600
+ const consecutiveFailures = abandonedReleasedRound ? previous!.consecutiveFailures : 0;
587
601
  let blockedReason: string | undefined;
588
602
  if (roundCount > maxRounds) blockedReason = `round_budget_exhausted:${roundCount}>${maxRounds}`;
589
603
  else if (repeatedStateCount >= maxRepeatedState) blockedReason = `repeated_state:${repeatedStateCount}>=${maxRepeatedState}`;
@@ -605,6 +619,7 @@ export function beginInitialControllerRoundDispatch(
605
619
  controllerInstanceId: input.identity.controllerInstanceId.trim().slice(0, 240),
606
620
  sessionId: input.identity.sessionId.trim().slice(0, 240),
607
621
  claimGeneration: 0,
622
+ authorityId: newControllerRoundAuthorityId(),
608
623
  stateFingerprint,
609
624
  roundCount,
610
625
  repeatedStateCount,
@@ -612,7 +627,7 @@ export function beginInitialControllerRoundDispatch(
612
627
  maxRounds,
613
628
  maxRepeatedState,
614
629
  maxFailures,
615
- reason: 'launcher_start_requested_continuation',
630
+ reason: abandonedReleasedRound ? 'launcher_start_recovered_abandoned_claim' : 'launcher_start_requested_continuation',
616
631
  ...(bounded(input.browserSessionId, 500) ? { browserSessionId: bounded(input.browserSessionId, 500) } : previous?.browserSessionId ? { browserSessionId: previous.browserSessionId } : {}),
617
632
  ...(bounded(input.conversationUrl, 2_000) ? { conversationUrl: bounded(input.conversationUrl, 2_000) } : previous?.conversationUrl ? { conversationUrl: previous.conversationUrl } : {}),
618
633
  ...(blockedReason ? { blockedReason } : {}),
@@ -651,7 +666,14 @@ export function beginControllerRoundRelayAfterRelease(
651
666
  throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
652
667
  }
653
668
  const at = nowIso(options);
654
- const dispatching: ControllerRoundRelayRecord = { ...record, status: 'dispatching', updatedAt: at };
669
+ // A released round is semantically closed. The successor dispatch receives a
670
+ // fresh capability so a stale tab/conversation cannot mutate the next round.
671
+ const dispatching: ControllerRoundRelayRecord = {
672
+ ...record,
673
+ authorityId: newControllerRoundAuthorityId(),
674
+ status: 'dispatching',
675
+ updatedAt: at,
676
+ };
655
677
  writeControlPlaneRecord(options.controllerHome, {
656
678
  namespace: NAMESPACE,
657
679
  scope: options.repoId,
@@ -665,22 +687,82 @@ export function beginControllerRoundRelayAfterRelease(
665
687
  });
666
688
  }
667
689
 
690
+ /**
691
+ * Mechanically close a claimed ChatGPT round only after its exact controller
692
+ * epoch has been released and durable state proves no controller lease remains.
693
+ * This never invents a semantic disposition; launcher_start must open a new round.
694
+ */
695
+ export function reconcileControllerRoundAfterAbandonedRelease(
696
+ options: ControllerRoundRelayStoreOptions,
697
+ input: { workId: string; releasedSession: ControllerSession },
698
+ ): ControllerRoundRelayRecord | undefined {
699
+ const initial = readRelayRecord(options, input.workId);
700
+ if (!initial || initial.value.status !== 'claimed') return undefined;
701
+ return relayLock(options, initial.value.relayScopeId, `controller-relay-abandoned-release:${input.releasedSession.controllerId}`, () => {
702
+ const current = readRelayRecord(options, input.workId);
703
+ if (!current || current.value.status !== 'claimed') return undefined;
704
+ const record = current.value;
705
+ const releasedPrincipal = input.releasedSession.principalId?.trim() || input.releasedSession.controllerId;
706
+ const releasedInstanceId = input.releasedSession.controllerInstanceId?.trim() || '';
707
+ if (
708
+ record.claimGeneration !== input.releasedSession.claimGeneration
709
+ || record.controllerId !== input.releasedSession.controllerId
710
+ || record.principalId !== releasedPrincipal
711
+ || record.controllerInstanceId !== releasedInstanceId
712
+ || record.sessionId !== input.releasedSession.sessionId
713
+ ) {
714
+ throw new Error(`CONTROLLER_RELAY_RELEASE_FENCE_MISMATCH: ${input.workId}`);
715
+ }
716
+ if (getControllerSession(options, input.workId)) {
717
+ throw new Error(`CONTROLLER_RELAY_ABANDONED_RELEASE_ACTIVE_CLAIM: ${input.workId}`);
718
+ }
719
+ const work = getWorkContract(options, input.workId);
720
+ if (!work || isTerminalWorkContractStatus(work.status)) return undefined;
721
+
722
+ const at = nowIso(options);
723
+ const abandoned: ControllerRoundRelayRecord = {
724
+ ...record,
725
+ status: 'failed',
726
+ lastError: CONTROLLER_RELAY_ABANDONED_RELEASE_ERROR,
727
+ claimedAt: undefined,
728
+ updatedAt: at,
729
+ };
730
+ writeControlPlaneRecord(options.controllerHome, {
731
+ namespace: NAMESPACE,
732
+ scope: options.repoId,
733
+ key: input.workId,
734
+ schemaVersion: SCHEMA_VERSION,
735
+ value: abandoned,
736
+ action: 'controller_round_relay_claim_abandoned_after_release',
737
+ expectedRevision: current.revision,
738
+ });
739
+ return abandoned;
740
+ });
741
+ }
742
+
668
743
  export function finishControllerRoundRelayDispatch(
669
744
  options: ControllerRoundRelayStoreOptions,
670
- input: { workId: string; ok: boolean; browserSessionId?: string; conversationUrl?: string; error?: string },
745
+ input: { workId: string; ok: boolean; browserSessionId?: string; conversationUrl?: string; error?: string; recovery?: boolean; nowMs?: number },
671
746
  ): ControllerRoundRelayRecord | undefined {
672
747
  const initial = readRelayRecord(options, input.workId);
673
748
  if (!initial || initial.value.status !== 'dispatching') return initial?.value;
674
749
  return relayLock(options, initial.value.relayScopeId, `controller-relay-finish:${input.workId}`, () => {
675
750
  const current = readRelayRecord(options, input.workId);
676
751
  if (!current || current.value.status !== 'dispatching') return current?.value;
677
- const at = nowIso(options);
752
+ const at = typeof input.nowMs === 'number' ? new Date(input.nowMs).toISOString() : nowIso(options);
753
+ const nextFailureCount = current.value.consecutiveFailures + 1;
754
+ const recoveryBlocked = input.recovery === true && nextFailureCount >= current.value.maxFailures;
755
+ const recoveryDelayMs = Math.min(
756
+ MAX_STALLED_RECOVERY_BACKOFF_MS,
757
+ DEFAULT_STALLED_RECOVERY_BACKOFF_MS * 2 ** Math.max(0, nextFailureCount - 1),
758
+ );
678
759
  const next: ControllerRoundRelayRecord = input.ok
679
760
  ? {
680
761
  ...current.value,
681
762
  status: 'dispatched',
682
763
  consecutiveFailures: 0,
683
764
  lastError: undefined,
765
+ nextRecoveryAt: undefined,
684
766
  blockedReason: undefined,
685
767
  tabSettlementStatus: 'round_open',
686
768
  tabSettlementAt: undefined,
@@ -690,20 +772,39 @@ export function finishControllerRoundRelayDispatch(
690
772
  dispatchedAt: at,
691
773
  updatedAt: at,
692
774
  }
693
- : {
694
- ...current.value,
695
- status: 'failed',
696
- consecutiveFailures: current.value.consecutiveFailures + 1,
697
- lastError: bounded(input.error, 2_000) ?? 'CONTROLLER_RELAY_DISPATCH_FAILED',
698
- updatedAt: at,
699
- };
775
+ : input.recovery
776
+ ? {
777
+ ...current.value,
778
+ status: recoveryBlocked ? 'blocked' : 'dispatching',
779
+ consecutiveFailures: nextFailureCount,
780
+ lastError: bounded(input.error, 2_000) ?? 'CHATGPT_CONTROLLER_RELAY_RECOVERY_FAILED',
781
+ blockedReason: recoveryBlocked
782
+ ? `consecutive_failures:${nextFailureCount}>=${current.value.maxFailures}`
783
+ : undefined,
784
+ nextRecoveryAt: recoveryBlocked
785
+ ? undefined
786
+ : new Date(Date.parse(at) + recoveryDelayMs).toISOString(),
787
+ updatedAt: at,
788
+ }
789
+ : {
790
+ ...current.value,
791
+ status: 'failed',
792
+ consecutiveFailures: nextFailureCount,
793
+ nextRecoveryAt: undefined,
794
+ lastError: bounded(input.error, 2_000) ?? 'CONTROLLER_RELAY_DISPATCH_FAILED',
795
+ updatedAt: at,
796
+ };
700
797
  writeControlPlaneRecord(options.controllerHome, {
701
798
  namespace: NAMESPACE,
702
799
  scope: options.repoId,
703
800
  key: input.workId,
704
801
  schemaVersion: SCHEMA_VERSION,
705
802
  value: next,
706
- action: input.ok ? 'controller_round_relay_dispatched' : 'controller_round_relay_failed',
803
+ action: input.ok
804
+ ? 'controller_round_relay_dispatched'
805
+ : input.recovery
806
+ ? recoveryBlocked ? 'controller_round_relay_recovery_blocked' : 'controller_round_relay_recovery_retry_scheduled'
807
+ : 'controller_round_relay_failed',
707
808
  expectedRevision: current.revision,
708
809
  });
709
810
  return next;
@@ -724,7 +825,7 @@ export function acknowledgeControllerRoundClaim(
724
825
  ) return initial.value;
725
826
  } else if (initial.value.status === 'blocked') {
726
827
  if (!initial.value.blockedReason?.startsWith('repeated_state:')) return initial.value;
727
- } else if (initial.value.status !== 'dispatched') return initial.value;
828
+ } else if (!['dispatching', 'dispatched'].includes(initial.value.status)) return initial.value;
728
829
  if (input.session.controllerType !== 'chatgpt') throw new Error(`CONTROLLER_RELAY_CHATGPT_CLAIM_REQUIRED: ${input.workId}`);
729
830
 
730
831
  return relayLock(options, initial.value.relayScopeId, `controller-relay-claim:${input.session.controllerId}`, () => {
@@ -834,7 +935,7 @@ export function acknowledgeControllerRoundClaim(
834
935
  });
835
936
  return rearmed;
836
937
  }
837
- if (current.value.status !== 'dispatched') return current.value;
938
+ if (!['dispatching', 'dispatched'].includes(current.value.status)) return current.value;
838
939
  const owner = getControllerSession(options, input.workId);
839
940
  if (!owner) throw new Error(`CONTROLLER_RELAY_ACTIVE_CLAIM_REQUIRED: ${input.workId}`);
840
941
  if (
@@ -886,26 +987,36 @@ export function claimStalledControllerRoundRelays(
886
987
  for (const candidate of latestRelayRecordsByScope(options)) {
887
988
  if (claimed.length >= limit) break;
888
989
  if (!['pending_release', 'dispatching', 'dispatched', 'claimed'].includes(candidate.status)) continue;
889
- const roundOpenedAtMs = Date.parse(candidate.claimedAt ?? candidate.dispatchedAt ?? candidate.updatedAt);
890
- if (!Number.isFinite(roundOpenedAtMs) || nowMs - roundOpenedAtMs < graceMs) continue;
990
+ const scheduledRecoveryAtMs = candidate.nextRecoveryAt ? Date.parse(candidate.nextRecoveryAt) : Number.NaN;
991
+ if (Number.isFinite(scheduledRecoveryAtMs)) {
992
+ if (nowMs < scheduledRecoveryAtMs) continue;
993
+ } else {
994
+ const roundOpenedAtMs = Date.parse(candidate.claimedAt ?? candidate.dispatchedAt ?? candidate.updatedAt);
995
+ if (!Number.isFinite(roundOpenedAtMs) || nowMs - roundOpenedAtMs < graceMs) continue;
996
+ }
891
997
  const requirement = requirementForRelay(options, candidate.requirementId);
892
998
  if (requirement && !['planned', 'active'].includes(requirement.state)) continue;
893
999
  const candidateWorks = relevantWork(options, candidate);
894
1000
  const activeCandidateWorks = candidateWorks.filter((work) => !isTerminalWorkContractStatus(work.status));
895
1001
  if (activeCandidateWorks.length === 0) continue;
896
- if (activeCandidateWorks.some((work) => controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
1002
+ if (activeCandidateWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) continue;
897
1003
 
898
1004
  const next = relayLock(options, candidate.relayScopeId, `controller-relay-recover:${candidate.originWorkId}`, () => {
899
1005
  const latest = relayHistory(options, candidate.relayScopeId)[0];
900
1006
  if (!latest || latest.originWorkId !== candidate.originWorkId || latest.updatedAt !== candidate.updatedAt || latest.status !== candidate.status) return undefined;
901
- const latestRoundOpenedAtMs = Date.parse(latest.claimedAt ?? latest.dispatchedAt ?? latest.updatedAt);
902
- if (!Number.isFinite(latestRoundOpenedAtMs) || nowMs - latestRoundOpenedAtMs < graceMs) return undefined;
1007
+ const latestRecoveryAtMs = latest.nextRecoveryAt ? Date.parse(latest.nextRecoveryAt) : Number.NaN;
1008
+ if (Number.isFinite(latestRecoveryAtMs)) {
1009
+ if (nowMs < latestRecoveryAtMs) return undefined;
1010
+ } else {
1011
+ const latestRoundOpenedAtMs = Date.parse(latest.claimedAt ?? latest.dispatchedAt ?? latest.updatedAt);
1012
+ if (!Number.isFinite(latestRoundOpenedAtMs) || nowMs - latestRoundOpenedAtMs < graceMs) return undefined;
1013
+ }
903
1014
  const latestRequirement = requirementForRelay(options, latest.requirementId);
904
1015
  if (latestRequirement && !['planned', 'active'].includes(latestRequirement.state)) return undefined;
905
1016
  const works = relevantWork(options, latest);
906
1017
  const activeWorks = works.filter((work) => !isTerminalWorkContractStatus(work.status));
907
1018
  if (activeWorks.length === 0) return undefined;
908
- if (activeWorks.some((work) => controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
1019
+ if (activeWorks.some((work) => workHasActiveExecution(options.controllerHome, options.repoId, work.workId) || controllerSessionBlocksRecovery(options, work.workId, { nowMs, graceMs }))) return undefined;
909
1020
 
910
1021
  const currentRecord = readRelayRecord(options, latest.originWorkId);
911
1022
  if (!currentRecord || currentRecord.value.updatedAt !== latest.updatedAt || currentRecord.value.status !== latest.status) return undefined;
@@ -925,15 +1036,25 @@ export function claimStalledControllerRoundRelays(
925
1036
  const at = new Date(nowMs).toISOString();
926
1037
  const recovered: ControllerRoundRelayRecord = {
927
1038
  ...latest,
1039
+ // Re-dispatching an already dispatched/claimed round creates a new
1040
+ // controller epoch. Retrying an in-flight dispatch keeps its capability.
1041
+ authorityId: blockedReason
1042
+ ? latest.authorityId
1043
+ : latest.status === 'dispatching' && latest.authorityId
1044
+ ? latest.authorityId
1045
+ : newControllerRoundAuthorityId(),
928
1046
  status: blockedReason ? 'blocked' : 'dispatching',
929
1047
  stateFingerprint,
930
1048
  roundCount,
931
1049
  repeatedStateCount,
932
- lastError: latest.status === 'pending_release'
933
- ? 'CONTROLLER_RELAY_RELEASE_TRANSITION_INCOMPLETE'
934
- : latest.status === 'dispatching'
935
- ? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
936
- : latest.status === 'claimed' ? 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED' : 'CONTROLLER_RELAY_ROUND_UNCLOSED',
1050
+ lastError: latest.nextRecoveryAt
1051
+ ? latest.lastError
1052
+ : latest.status === 'pending_release'
1053
+ ? 'CONTROLLER_RELAY_RELEASE_TRANSITION_INCOMPLETE'
1054
+ : latest.status === 'dispatching'
1055
+ ? 'CONTROLLER_RELAY_DISPATCH_TRANSITION_INCOMPLETE'
1056
+ : latest.status === 'claimed' ? 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED' : 'CONTROLLER_RELAY_ROUND_UNCLOSED',
1057
+ nextRecoveryAt: undefined,
937
1058
  claimedAt: undefined,
938
1059
  ...(blockedReason ? { blockedReason } : { blockedReason: undefined }),
939
1060
  updatedAt: at,
@@ -957,6 +1078,7 @@ export function claimStalledControllerRoundRelays(
957
1078
  export function buildControllerRoundRelayPrompt(
958
1079
  options: ControllerRoundRelayStoreOptions,
959
1080
  record: ControllerRoundRelayRecord,
1081
+ promptOptions: { exactOriginWork?: boolean } = {},
960
1082
  ): string {
961
1083
  const requirement = requirementForRelay(options, record.requirementId);
962
1084
  const relevantWorks = relevantWork(options, record);
@@ -980,8 +1102,13 @@ export function buildControllerRoundRelayPrompt(
980
1102
  requirementLine,
981
1103
  `Linked Work snapshot:\n${workLines}`,
982
1104
  `Active Handoff snapshot:\n${handoffLines}`,
983
- `Previous relay origin Work: ${record.originWorkId}. Do not assume the next action must continue that Work; select, start, or claim the appropriate Work from the latest semantic state.`,
1105
+ promptOptions.exactOriginWork
1106
+ ? `This is a Work-bound scheduled round. Claim and advance only origin Work ${record.originWorkId}. Do not select, start, delegate, or resume a sibling Work, create another schedule, or widen scope. If this Work cannot safely advance after one bounded diagnostic or repair attempt, record the exact evidence, submit wait or wait_for_user, release ownership, and end the round.`
1107
+ : `Previous relay origin Work: ${record.originWorkId}. Do not assume the next action must continue that Work; select, start, or claim the appropriate Work from the latest semantic state.`,
984
1108
  `Mechanical relay budget: round=${record.roundCount}/${record.maxRounds}; repeated_state=${record.repeatedStateCount}/${record.maxRepeatedState}; consecutive_failures=${record.consecutiveFailures}/${record.maxFailures}.`,
1109
+ record.authorityId
1110
+ ? `This round's durable controller authority is controller_authority_id=${record.authorityId}. Pass this exact controller_authority_id together with relay_scope_id=${record.relayScopeId} on controller_claim, continue, finalize, stop, and controller_release. The opaque authority survives MCP transport/Runtime rotation within this round and rotates before a successor round; never substitute another Work/conversation's authority.`
1111
+ : `This is a legacy relay record without a controller authority capability. Forge permits only the exact already-claimed MCP/controller epoch to finish it; a replacement transport must reopen/recover the round before mutating Work lifecycle.`,
985
1112
  ...(['CONTROLLER_RELAY_ROUND_UNCLOSED', 'CONTROLLER_RELAY_CLAIMED_ROUND_UNCLOSED'].includes(record.lastError ?? '')
986
1113
  ? ['The previous ChatGPT round did not submit an explicit disposition before its liveness grace elapsed. Forge is only recovering liveness: reread durable state, make the semantic decision in ChatGPT, and close this round explicitly.']
987
1114
  : []),