@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
@@ -508,7 +508,7 @@ export async function dispatchRecoveryTool(config: RecoveryConfig, name: string,
508
508
  }
509
509
  case 'restart_primary_connector': {
510
510
  if (!requestId(args.request_id)) throw new Error('RECOVERY_REQUEST_ID_REQUIRED');
511
- return restartPrimaryConnector(config);
511
+ return restartPrimaryConnector(config, { requestId: `recovery-gateway:${args.request_id}` });
512
512
  }
513
513
  case 'recover_primary_runtime': {
514
514
  if (!requestId(args.request_id)) throw new Error('RECOVERY_REQUEST_ID_REQUIRED');
@@ -340,7 +340,7 @@ function recoveryPlist(input: {
340
340
  }): string {
341
341
  const xml = (value: string) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
342
342
  const argumentsList = [
343
- '/usr/bin/env', '-i', 'PATH=/usr/bin:/bin:/usr/sbin:/sbin', `FORGE_BUILD_VERSION=${FORGE_VERSION}`,
343
+ '/usr/bin/env', '-i', 'PATH=/usr/bin:/bin:/usr/sbin:/sbin', `FORGE_BUILD_VERSION=${FORGE_VERSION}`, `FORGE_CONNECTOR_EXECUTABLE=${process.execPath}`,
344
344
  input.executable, input.command, '--controller-home', input.controllerHome,
345
345
  ];
346
346
  return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(input.label)}</string><key>ProgramArguments</key><array>${argumentsList.map((argument) => `<string>${xml(argument)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>5</integer><key>StandardOutPath</key><string>${xml(input.logPath)}</string><key>StandardErrorPath</key><string>${xml(input.logPath)}</string></dict></plist>\n`;
@@ -3,6 +3,7 @@ import { execFile } from 'child_process';
3
3
  import { promisify } from 'util';
4
4
  import { getRepository } from '../../../cli/repositories/registry';
5
5
  import { findExecutionJob, listActiveExecutionJobs, listExecutionJobs } from '../../execution/jobs/store';
6
+ import { executionJobBelongsToWork, workHasActiveExecution } from '../../execution/work-activity';
6
7
  import { readRepositoryProjection } from '../../projections/materialized-view';
7
8
  import {
8
9
  applyRuntimeMaintenance,
@@ -26,8 +27,8 @@ import {
26
27
  recordScheduleOccurrenceHandoff,
27
28
  listSchedules,
28
29
  saveOccurrence,
29
- saveSchedule,
30
30
  saveScheduleDecision,
31
+ updateSchedule,
31
32
  } from './store';
32
33
  import { applyScheduleFailure, applyScheduleRetryableFailure } from './settlement';
33
34
  import { classifyScheduledBrowserObservation, executeScheduledBrowserProbe } from './browser-probe';
@@ -44,6 +45,8 @@ const LIVE_MAINTENANCE_OPERATION = 'runtime_maintenance_apply';
44
45
  const EXTERNAL_CONTROLLER_WAKE_OPERATION = 'external_controller_wake';
45
46
  const BROWSER_PROBE_OPERATION = 'browser_probe';
46
47
  const GITHUB_ISSUE_WATCH_OPERATION = 'github_issue_watch';
48
+ const DEFAULT_EXTERNAL_CONTROLLER_WAKE_TIMEOUT_MS = 60_000;
49
+ const MAX_EXTERNAL_CONTROLLER_WAKE_TIMEOUT_MS = 120_000;
47
50
 
48
51
  function isDeterministicSchedule(schedule: RepositorySchedule): boolean {
49
52
  // Kernel executes only typed, bounded dispatch operations.
@@ -261,13 +264,17 @@ function workBoundScheduleWorkId(schedule: RepositorySchedule): string | undefin
261
264
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
262
265
  }
263
266
 
264
- function executionJobBelongsToWork(job: ReturnType<typeof listExecutionJobs>[number], workId: string): boolean {
265
- if (job.resourceClaims.some((claim) => claim.workId === workId)) return true;
266
- const payload = job.payload as Record<string, unknown>;
267
- const args = payload.arguments && typeof payload.arguments === 'object' && !Array.isArray(payload.arguments)
268
- ? payload.arguments as Record<string, unknown>
269
- : undefined;
270
- return [payload.workId, payload.work_id, args?.workId, args?.work_id].some((value) => value === workId);
267
+ const HUMAN_ONLY_HANDOFF_REASONS = new Set([
268
+ 'policy_approval_required',
269
+ 'missing_authorization',
270
+ 'invalid_objective',
271
+ 'destructive_action_requires_confirmation',
272
+ ]);
273
+
274
+ function handoffRequiresHumanReview(item: ReturnType<typeof listHandoffItems>[number]): boolean {
275
+ if (item.approvalAction) return true;
276
+ if (!item.creationReason) return true;
277
+ return HUMAN_ONLY_HANDOFF_REASONS.has(item.creationReason);
271
278
  }
272
279
 
273
280
  async function stopReason(controllerHome: string, schedule: RepositorySchedule): Promise<string | undefined> {
@@ -277,8 +284,8 @@ async function stopReason(controllerHome: string, schedule: RepositorySchedule):
277
284
  if (schedule.stopConditions.includes('human_review_required')) {
278
285
  if (workId) {
279
286
  const activeHandoff = listHandoffItems({ controllerHome, repoId: schedule.repoId, status: 'active', limit: 100 })
280
- .find((item) => item.workId === workId);
281
- if (activeHandoff) return `Work ${workId} has active Handoff ${activeHandoff.id} requiring review.`;
287
+ .find((item) => item.workId === workId && handoffRequiresHumanReview(item));
288
+ if (activeHandoff) return `Work ${workId} has active Handoff ${activeHandoff.id} requiring human review.`;
282
289
  } else if (projection.currentAttention.length > 0) {
283
290
  return 'Repository has jobs requiring human attention.';
284
291
  }
@@ -367,11 +374,31 @@ function decideOccurrence(
367
374
 
368
375
  export type ChatgptWakeFailureClass = 'retryable_readiness' | 'semantic_wait' | 'user_action_required' | 'ordinary_failure';
369
376
 
377
+ export function externalControllerWakeTimeoutMs(value: unknown): number {
378
+ if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_EXTERNAL_CONTROLLER_WAKE_TIMEOUT_MS;
379
+ return Math.max(5_000, Math.min(Math.trunc(value), MAX_EXTERNAL_CONTROLLER_WAKE_TIMEOUT_MS));
380
+ }
381
+
382
+ export async function awaitExternalControllerWake<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
383
+ let timer: ReturnType<typeof setTimeout> | undefined;
384
+ try {
385
+ return await Promise.race([
386
+ operation,
387
+ new Promise<T>((_, reject) => {
388
+ timer = setTimeout(() => reject(new Error(`CONTROLLER_WAKE_TOTAL_TIMEOUT: external Controller dispatch exceeded ${timeoutMs}ms.`)), timeoutMs);
389
+ }),
390
+ ]);
391
+ } finally {
392
+ if (timer) clearTimeout(timer);
393
+ }
394
+ }
395
+
370
396
  export function classifyChatgptWakeFailure(reason: string): ChatgptWakeFailureClass {
371
397
  const normalized = reason.toUpperCase();
372
398
  if (
373
399
  normalized.includes('CHATGPT_AUTOMATION_LOGIN_REQUIRED')
374
400
  || normalized.includes('PLUGIN_BROWSER_JAVASCRIPT_PERMISSION_REQUIRED')
401
+ || normalized.includes('PLUGIN_MACOS_CAPABILITY_BROKER_CAPABILITY_UNSUPPORTED')
375
402
  || normalized.includes('AUTHENTICATION_REQUIRED')
376
403
  || normalized.includes('CONSENT_REQUIRED')
377
404
  ) return 'user_action_required';
@@ -384,12 +411,15 @@ export function classifyChatgptWakeFailure(reason: string): ChatgptWakeFailureCl
384
411
  'PLUGIN_BROWSER_ACTIVE_TAB_UNAVAILABLE',
385
412
  'PLUGIN_BROWSER_SESSION_STATE_LOST',
386
413
  'PLUGIN_SESSION_NOT_FOUND',
414
+ 'PLUGIN_BROWSER_NATIVE_TAB_IDENTITY_UNPROVEN',
415
+ 'PLUGIN_MACOS_CAPABILITY_BROKER_UNAVAILABLE',
387
416
  'PLUGIN_BROWSER_NATIVE_FOREGROUND_ACTIVATOR_UNAVAILABLE',
388
417
  'CHATGPT_CONTROLLER_BROWSER_ROOT_UNAVAILABLE',
389
418
  'CHATGPT_CONTROLLER_BROWSER_FAILED',
390
419
  'CHATGPT_AUTOMATION_COMPOSER_UNAVAILABLE',
391
420
  'CHATGPT_AUTOMATION_INTELLIGENCE_CONTROL_UNAVAILABLE',
392
421
  'CHATGPT_AUTOMATION_REPLACEMENT_SESSION_NOT_CONFIRMED',
422
+ 'CONTROLLER_WAKE_TOTAL_TIMEOUT',
393
423
  'HTTP 502',
394
424
  'ECONNREFUSED',
395
425
  'ECONNRESET',
@@ -434,24 +464,27 @@ async function executeExternalControllerWake(
434
464
  const workStore = { controllerHome, repoId: schedule.repoId };
435
465
  const work = getWorkContract(workStore, workId);
436
466
  if (!work) {
437
- saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
467
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId }));
438
468
  return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', `EXTERNAL_CONTROLLER_WAKE_WORK_NOT_FOUND:${workId}`);
439
469
  }
440
470
  if (isTerminalWorkContractStatus(work.status)) {
441
- saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
471
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId }));
442
472
  return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} is terminal (${work.status}); automatic continuation stopped.`);
443
473
  }
444
474
  const existingOwner = getControllerSession(workStore, workId);
445
475
  const occurrenceNowMs = Date.parse(timestamp);
446
- if (existingOwner && controllerSessionBlocksRecovery(workStore, workId, {
447
- nowMs: Number.isFinite(occurrenceNowMs) ? occurrenceNowMs : Date.now(),
448
- })) {
449
- saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
450
- return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has an active Controller ${existingOwner.controllerId}.`);
476
+ const nowMs = Number.isFinite(occurrenceNowMs) ? occurrenceNowMs : Date.now();
477
+ if (workHasActiveExecution(controllerHome, schedule.repoId, workId)) {
478
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId }));
479
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} has active execution; duplicate Controller wake suppressed.`);
480
+ }
481
+ if (existingOwner && controllerSessionBlocksRecovery(workStore, workId, { nowMs })) {
482
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId }));
483
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} has a recently active Controller ${existingOwner.controllerId}.`);
451
484
  }
452
485
  const launchReservation = getExternalControllerLaunchReservation(workStore, workId);
453
486
  if (launchReservation) {
454
- saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
487
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId }));
455
488
  return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has a pending external Controller launch ${launchReservation.reservationId}.`);
456
489
  }
457
490
  const wakeDecision = decideOccurrence(
@@ -481,6 +514,7 @@ async function executeExternalControllerWake(
481
514
  if (controllerType === 'chatgpt') {
482
515
  const reasoning = args.reasoning === 'medium' || args.reasoning === 'xhigh' ? args.reasoning : 'high';
483
516
  const tabPolicy = args.tab_policy === 'reuse' || args.tab_policy === 'new' ? args.tab_policy : 'auto';
517
+ const timeoutMs = externalControllerWakeTimeoutMs(args.timeout_ms);
484
518
  let relay;
485
519
  try {
486
520
  relay = beginInitialControllerRoundDispatch(workStore, {
@@ -496,8 +530,8 @@ async function executeExternalControllerWake(
496
530
  conversationUrl: typeof args.conversation_url === 'string' ? args.conversation_url : undefined,
497
531
  });
498
532
  if (relay.status === 'blocked') throw new Error(`CONTROLLER_RELAY_LAUNCH_BLOCKED: ${relay.blockedReason ?? relay.relayScopeId}`);
499
- const relayPrompt = `${buildControllerRoundRelayPrompt(workStore, relay)}\n\nScheduled continuation hint: ${continuationPrompt}`;
500
- const dispatched = await runWorkChatgptContinuation({
533
+ const relayPrompt = `${buildControllerRoundRelayPrompt(workStore, relay, { exactOriginWork: true })}\n\nScheduled continuation hint: ${continuationPrompt}`;
534
+ const dispatched = await awaitExternalControllerWake(runWorkChatgptContinuation({
501
535
  controllerHome,
502
536
  repoId: schedule.repoId,
503
537
  repoRoot: repository.canonicalRoot,
@@ -509,8 +543,8 @@ async function executeExternalControllerWake(
509
543
  model: typeof args.model === 'string' ? args.model : 'gpt-5.6',
510
544
  reasoning,
511
545
  tabPolicy,
512
- timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
513
- });
546
+ timeoutMs,
547
+ }), timeoutMs);
514
548
  if (dispatched.status === 'failed') throw new Error(dispatched.error?.message ?? 'CHATGPT_WORK_CONTINUATION_FAILED');
515
549
  const completedRelay = finishControllerRoundRelayDispatch(workStore, {
516
550
  workId,
@@ -518,15 +552,13 @@ async function executeExternalControllerWake(
518
552
  browserSessionId: dispatched.browserSessionId,
519
553
  conversationUrl: dispatched.conversationUrl,
520
554
  });
521
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
522
- saveSchedule(controllerHome, {
523
- ...latest,
555
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => ({
524
556
  lastTriggeredAt: timestamp,
525
557
  lastOccurrenceId: occurrence.occurrenceId,
526
558
  consecutiveFailures: 0,
527
559
  nextEligibleAt: undefined,
528
- pausedReason: undefined,
529
- });
560
+ ...(current.enabled ? { pausedReason: undefined } : {}),
561
+ }));
530
562
  const succeededOccurrence = saveOccurrence(controllerHome, {
531
563
  ...wakeDecision,
532
564
  status: 'succeeded',
@@ -548,8 +580,7 @@ async function executeExternalControllerWake(
548
580
  });
549
581
  }
550
582
  if (error instanceof Error && error.message.startsWith('CONTROLLER_RELAY_ROUND_ALREADY_OPEN:')) {
551
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
552
- saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
583
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId }));
553
584
  return decideOccurrence(controllerHome, schedule, wakeDecision, 'nothing_to_do', 'skipped', `Work ${workId} already has an open ChatGPT controller round awaiting claim or disposition.`);
554
585
  }
555
586
  throw error;
@@ -570,15 +601,13 @@ async function executeExternalControllerWake(
570
601
  continuationPrompt,
571
602
  cwd: repository.canonicalRoot,
572
603
  });
573
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
574
- saveSchedule(controllerHome, {
575
- ...latest,
604
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => ({
576
605
  lastTriggeredAt: timestamp,
577
606
  lastOccurrenceId: occurrence.occurrenceId,
578
607
  consecutiveFailures: 0,
579
608
  nextEligibleAt: undefined,
580
- pausedReason: undefined,
581
- });
609
+ ...(current.enabled ? { pausedReason: undefined } : {}),
610
+ }));
582
611
  return saveOccurrence(controllerHome, {
583
612
  ...wakeDecision,
584
613
  status: 'succeeded',
@@ -597,12 +626,10 @@ async function executeExternalControllerWake(
597
626
  : `Controller wake deferred by transient readiness; schedule remains active with bounded backoff: ${reason}`,
598
627
  countFailure: !semanticWait,
599
628
  });
600
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
601
- saveSchedule(controllerHome, {
602
- ...latest,
629
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({
603
630
  lastTriggeredAt: timestamp,
604
631
  lastOccurrenceId: occurrence.occurrenceId,
605
- });
632
+ }));
606
633
  return deferred.occurrence ?? saveOccurrence(controllerHome, {
607
634
  ...wakeDecision,
608
635
  status: semanticWait ? 'skipped' : 'failed',
@@ -628,8 +655,7 @@ async function executeExternalControllerWake(
628
655
  attemptedActions: [`schedule:${schedule.scheduleId}`, `work:${workId}`, `controller:${controllerType}`],
629
656
  },
630
657
  });
631
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
632
- saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId });
658
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrence.occurrenceId }));
633
659
  return failed.occurrence ?? saveOccurrence(controllerHome, { ...wakeDecision, status: 'failed', reason });
634
660
  }
635
661
  }
@@ -640,6 +666,10 @@ export async function evaluateSchedule(
640
666
  force = false,
641
667
  triggerContext?: ScheduleTriggerContext,
642
668
  ): Promise<ScheduleOccurrence | undefined> {
669
+ // The persisted Schedule record is the authority. Callers may legitimately
670
+ // retain the object returned by create/list/get across multiple trigger
671
+ // attempts, so never let an older in-memory revision become execution state.
672
+ schedule = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
643
673
  // Preserve retired schedule records as evidence, but do not create a new
644
674
  // occurrence or resume a controller/external action from their old payload.
645
675
  if (retiredScheduleReason(schedule)) return undefined;
@@ -694,7 +724,7 @@ export async function evaluateSchedule(
694
724
  attemptedActions: [`operation:${schedule.action.operation}`],
695
725
  },
696
726
  );
697
- saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
727
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId }));
698
728
  return externalControllerHandoff;
699
729
  }
700
730
 
@@ -731,7 +761,10 @@ export async function evaluateSchedule(
731
761
  return decideOccurrence(controllerHome, schedule, occurrence, 'active_occurrence', 'skipped', 'Maximum active occurrences reached.');
732
762
  }
733
763
  if (schedule.consecutiveFailures >= schedule.policy.maxFailures) {
734
- saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: 'Maximum consecutive failures reached.' });
764
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => ({
765
+ enabled: false,
766
+ pausedReason: current.enabled ? 'Maximum consecutive failures reached.' : current.pausedReason,
767
+ }));
735
768
  return decideOccurrence(controllerHome, schedule, occurrence, 'stopped', 'skipped', 'Schedule paused after repeated failures.');
736
769
  }
737
770
  if (schedule.nextEligibleAt && Date.parse(schedule.nextEligibleAt) > Date.now() && !force) {
@@ -785,14 +818,14 @@ export async function evaluateSchedule(
785
818
  readyForExecution: preview.status.readyForExecution,
786
819
  }));
787
820
  }
788
- saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
821
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId }));
789
822
  return decideOccurrence(controllerHome, schedule, occurrence, 'would_execute', 'shadowed', 'Shadow mode records the live maintenance decision without applying it.', occurrenceDecisionEvidence({
790
823
  actionId: preview.actionId,
791
824
  safeCandidates: preview.selectedCandidateIds.length,
792
825
  typedSafeCandidates: preview.selectedTypedCandidateIds.length,
793
826
  }));
794
827
  }
795
- saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
828
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId }));
796
829
  return decideOccurrence(controllerHome, schedule, occurrence, 'would_execute', 'shadowed', 'Shadow mode records the decision without modifying the repository.', due.evidence);
797
830
  }
798
831
 
@@ -848,28 +881,29 @@ export async function evaluateSchedule(
848
881
  try {
849
882
  const repository = getRepository(schedule.repoId, controllerHome, { includeRemoved: true });
850
883
  const observation = await executeScheduledGithubIssueWatch({ repoRoot: repository.canonicalRoot, args, observedAt: timestamp });
851
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
852
884
  const evidence = occurrenceDecisionEvidence({
853
885
  githubRepository: typeof args.github_repository === 'string' ? args.github_repository : undefined,
854
886
  observationStatus: observation.status,
855
887
  observedIssueCount: observation.issues.length,
856
888
  changedIssueNumbers: observation.changedOpenIssues.map((issue) => issue.number),
857
889
  });
858
- const persistObservation = () => saveSchedule(controllerHome, {
859
- ...getSchedule(controllerHome, schedule.repoId, schedule.scheduleId),
860
- action: {
861
- ...latest.action,
862
- arguments: { ...(latest.action.arguments ?? {}), issue_watch_state: observation.nextState, issue_watch_since: undefined },
863
- },
864
- lastTriggeredAt: timestamp,
865
- lastOccurrenceId: occurrenceId,
866
- lastObservationAt: timestamp,
867
- lastObservationStatus: observation.status,
868
- lastObservationChangedAt: observation.shouldWake ? timestamp : latest.lastObservationChangedAt,
869
- consecutiveFailures: 0,
870
- consecutiveNoops: observation.shouldWake ? 0 : (latest.consecutiveNoops ?? 0) + 1,
871
- nextEligibleAt: undefined,
872
- pausedReason: undefined,
890
+ const persistObservation = () => updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => {
891
+ if (current.action.operation !== GITHUB_ISSUE_WATCH_OPERATION) throw new Error('SCHEDULE_ACTION_CHANGED_DURING_GITHUB_OBSERVATION');
892
+ return {
893
+ action: {
894
+ ...current.action,
895
+ arguments: { ...(current.action.arguments ?? {}), issue_watch_state: observation.nextState, issue_watch_since: undefined },
896
+ },
897
+ lastTriggeredAt: timestamp,
898
+ lastOccurrenceId: occurrenceId,
899
+ lastObservationAt: timestamp,
900
+ lastObservationStatus: observation.status,
901
+ lastObservationChangedAt: observation.shouldWake ? timestamp : current.lastObservationChangedAt,
902
+ consecutiveFailures: 0,
903
+ consecutiveNoops: observation.shouldWake ? 0 : (current.consecutiveNoops ?? 0) + 1,
904
+ nextEligibleAt: undefined,
905
+ ...(current.enabled ? { pausedReason: undefined } : {}),
906
+ };
873
907
  });
874
908
  if (!observation.shouldWake) {
875
909
  const observedSchedule = persistObservation();
@@ -923,11 +957,11 @@ export async function evaluateSchedule(
923
957
  const workStore = { controllerHome, repoId: schedule.repoId };
924
958
  const work = getWorkContract(workStore, workId);
925
959
  if (!work) {
926
- saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
960
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId }));
927
961
  return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', `SCHEDULE_BROWSER_PROBE_WORK_NOT_FOUND:${workId}`);
928
962
  }
929
963
  if (isTerminalWorkContractStatus(work.status)) {
930
- saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
964
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId }));
931
965
  return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} is terminal (${work.status}); browser watcher stopped before probing.`);
932
966
  }
933
967
  }
@@ -945,16 +979,15 @@ export async function evaluateSchedule(
945
979
  });
946
980
 
947
981
  if (probe.status === 'auth_required') {
948
- const observedSchedule = saveSchedule(controllerHome, {
949
- ...latest,
982
+ const observedSchedule = updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => ({
950
983
  lastTriggeredAt: timestamp,
951
984
  lastOccurrenceId: occurrenceId,
952
985
  lastObservationAt: timestamp,
953
986
  lastObservationStatus: 'auth_required',
954
987
  consecutiveFailures: 0,
955
988
  nextEligibleAt: undefined,
956
- pausedReason: undefined,
957
- });
989
+ ...(current.enabled ? { pausedReason: undefined } : {}),
990
+ }));
958
991
  if (args.wake_on_auth_required !== false) {
959
992
  const authPrompt = typeof args.auth_required_prompt === 'string'
960
993
  ? args.auth_required_prompt
@@ -997,8 +1030,7 @@ export async function evaluateSchedule(
997
1030
  }
998
1031
 
999
1032
  if (probe.status === 'keepalive') {
1000
- const observedSchedule = saveSchedule(controllerHome, {
1001
- ...latest,
1033
+ const observedSchedule = updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => ({
1002
1034
  lastTriggeredAt: timestamp,
1003
1035
  lastOccurrenceId: occurrenceId,
1004
1036
  lastObservationAt: timestamp,
@@ -1006,8 +1038,8 @@ export async function evaluateSchedule(
1006
1038
  consecutiveFailures: 0,
1007
1039
  consecutiveNoops: 0,
1008
1040
  nextEligibleAt: undefined,
1009
- pausedReason: undefined,
1010
- });
1041
+ ...(current.enabled ? { pausedReason: undefined } : {}),
1042
+ }));
1011
1043
  return decideOccurrence(controllerHome, observedSchedule, occurrence, 'execute', 'succeeded', 'Browser session keepalive refreshed successfully.', observationEvidence);
1012
1044
  }
1013
1045
 
@@ -1019,19 +1051,18 @@ export async function evaluateSchedule(
1019
1051
  const changed = observation.status === 'changed';
1020
1052
  const shouldWake = args.wake_on_change !== false && observation.shouldWake;
1021
1053
  const observationStatus: RepositorySchedule['lastObservationStatus'] = observation.status;
1022
- const observedSchedule = saveSchedule(controllerHome, {
1023
- ...latest,
1054
+ const observedSchedule = updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => ({
1024
1055
  lastTriggeredAt: timestamp,
1025
1056
  lastOccurrenceId: occurrenceId,
1026
1057
  lastObservationAt: timestamp,
1027
1058
  lastObservationFingerprint: probe.fingerprint,
1028
- lastObservationChangedAt: changed ? timestamp : latest.lastObservationChangedAt,
1059
+ lastObservationChangedAt: changed ? timestamp : current.lastObservationChangedAt,
1029
1060
  lastObservationStatus: observationStatus,
1030
1061
  consecutiveFailures: 0,
1031
- consecutiveNoops: shouldWake ? 0 : (latest.consecutiveNoops ?? 0) + 1,
1062
+ consecutiveNoops: shouldWake ? 0 : (current.consecutiveNoops ?? 0) + 1,
1032
1063
  nextEligibleAt: undefined,
1033
- pausedReason: undefined,
1034
- });
1064
+ ...(current.enabled ? { pausedReason: undefined } : {}),
1065
+ }));
1035
1066
 
1036
1067
  if (!shouldWake) {
1037
1068
  const reason = observation.status === 'baseline'
@@ -1066,8 +1097,7 @@ export async function evaluateSchedule(
1066
1097
  attemptedActions: [`schedule:${schedule.scheduleId}`, `work:${workId}`, 'operation:browser_probe'],
1067
1098
  },
1068
1099
  });
1069
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
1070
- saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
1100
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({ lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId }));
1071
1101
  return failed.occurrence ?? saveOccurrence(controllerHome, { ...occurrence, status: 'failed', decision: 'execute', reason });
1072
1102
  }
1073
1103
  }
@@ -1112,15 +1142,13 @@ export async function evaluateSchedule(
1112
1142
  cancelPendingApprovals: false,
1113
1143
  });
1114
1144
  const appliedCount = result.applied.filter((candidate) => candidate.applied).length;
1115
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
1116
- saveSchedule(controllerHome, {
1117
- ...latest,
1145
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, (current) => ({
1118
1146
  lastTriggeredAt: timestamp,
1119
1147
  lastOccurrenceId: occurrenceId,
1120
1148
  consecutiveFailures: 0,
1121
1149
  nextEligibleAt: undefined,
1122
- pausedReason: undefined,
1123
- });
1150
+ ...(current.enabled ? { pausedReason: undefined } : {}),
1151
+ }));
1124
1152
  return saveOccurrence(controllerHome, {
1125
1153
  ...decision,
1126
1154
  status: 'succeeded',
@@ -1149,12 +1177,10 @@ export async function evaluateSchedule(
1149
1177
  ],
1150
1178
  },
1151
1179
  });
1152
- const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
1153
- saveSchedule(controllerHome, {
1154
- ...latest,
1180
+ updateSchedule(controllerHome, schedule.repoId, schedule.scheduleId, () => ({
1155
1181
  lastTriggeredAt: timestamp,
1156
1182
  lastOccurrenceId: occurrenceId,
1157
- });
1183
+ }));
1158
1184
  return failed.occurrence ?? saveOccurrence(controllerHome, {
1159
1185
  ...decision,
1160
1186
  status: 'failed',
@@ -5,7 +5,7 @@ import {
5
5
  getSchedule,
6
6
  recordScheduleOccurrenceHandoff,
7
7
  saveOccurrence,
8
- saveSchedule,
8
+ updateSchedule,
9
9
  type ScheduleOccurrenceHandoffInput,
10
10
  } from './store';
11
11
 
@@ -27,7 +27,7 @@ function computeScheduleFailureState(schedule: ReturnType<typeof getSchedule>, n
27
27
  consecutiveFailures: nextFailures,
28
28
  nextEligibleAt: computeScheduleBackoff(schedule, nextFailures),
29
29
  enabled: shouldPause ? false : schedule.enabled,
30
- pausedReason: shouldPause ? 'Maximum consecutive failures reached.' : undefined,
30
+ pausedReason: shouldPause ? 'Maximum consecutive failures reached.' : schedule.enabled ? undefined : schedule.pausedReason,
31
31
  };
32
32
  }
33
33
 
@@ -62,16 +62,15 @@ export function applyScheduleFailure(
62
62
 
63
63
  const countFailure = options.countFailure !== false;
64
64
  const nextSchedule = options.pauseReason
65
- ? saveSchedule(controllerHome, {
66
- ...schedule,
65
+ ? updateSchedule(controllerHome, repoId, scheduleId, (current) => ({
67
66
  enabled: false,
68
67
  pausedReason: options.pauseReason,
69
68
  nextEligibleAt: undefined,
70
- consecutiveFailures: countFailure ? schedule.consecutiveFailures + 1 : schedule.consecutiveFailures,
71
- })
69
+ consecutiveFailures: countFailure ? current.consecutiveFailures + 1 : current.consecutiveFailures,
70
+ }))
72
71
  : countFailure
73
- ? saveSchedule(controllerHome, { ...schedule, ...computeScheduleFailureState(schedule, schedule.consecutiveFailures + 1) })
74
- : schedule;
72
+ ? updateSchedule(controllerHome, repoId, scheduleId, (current) => computeScheduleFailureState(current, current.consecutiveFailures + 1))
73
+ : getSchedule(controllerHome, repoId, scheduleId);
75
74
  return { schedule: nextSchedule, occurrence: nextOccurrence };
76
75
  }
77
76
 
@@ -87,7 +86,6 @@ export function applyScheduleRetryableFailure(
87
86
  countFailure?: boolean;
88
87
  },
89
88
  ): { schedule: ReturnType<typeof getSchedule>; occurrence?: ScheduleOccurrence } {
90
- const schedule = getSchedule(controllerHome, repoId, scheduleId);
91
89
  const occurrence = getOccurrence(controllerHome, repoId, occurrenceId);
92
90
  let nextOccurrence = occurrence;
93
91
  if (occurrence && !TERMINAL_OCCURRENCE_STATUSES.has(occurrence.status)) {
@@ -99,15 +97,22 @@ export function applyScheduleRetryableFailure(
99
97
  });
100
98
  }
101
99
  const countFailure = options.countFailure !== false;
102
- const nextFailures = countFailure ? schedule.consecutiveFailures + 1 : schedule.consecutiveFailures;
103
- const nextSchedule = saveSchedule(controllerHome, {
104
- ...schedule,
105
- consecutiveFailures: nextFailures,
106
- nextEligibleAt: computeScheduleBackoff(schedule, Math.max(1, nextFailures)),
107
- // Retryable readiness/semantic-wait failures never turn a durable schedule
108
- // into a manual-resume task. Respect a concurrent explicit pause, however.
109
- enabled: schedule.enabled,
110
- pausedReason: schedule.enabled ? undefined : schedule.pausedReason,
100
+ const nextSchedule = updateSchedule(controllerHome, repoId, scheduleId, (current) => {
101
+ const nextFailures = countFailure ? current.consecutiveFailures + 1 : current.consecutiveFailures;
102
+ const failureState = countFailure && current.enabled
103
+ ? computeScheduleFailureState(current, nextFailures)
104
+ : {
105
+ consecutiveFailures: nextFailures,
106
+ nextEligibleAt: countFailure
107
+ ? current.nextEligibleAt
108
+ : computeScheduleBackoff(current, Math.max(1, nextFailures)),
109
+ enabled: current.enabled,
110
+ pausedReason: current.enabled ? undefined : current.pausedReason,
111
+ };
112
+ // One transient fault backs off and re-arms. Repeated faults have crossed
113
+ // the already-declared failure budget and must stop before they consume
114
+ // further controller sessions or browser interactions.
115
+ return failureState;
111
116
  });
112
117
  return { schedule: nextSchedule, occurrence: nextOccurrence };
113
118
  }
@@ -142,7 +147,6 @@ export function settleScheduledExecution(
142
147
  const occurrenceId = typeof job.payload.occurrenceId === 'string' ? job.payload.occurrenceId : undefined;
143
148
  if (!scheduleId || !occurrenceId) return;
144
149
  try {
145
- const schedule = getSchedule(controllerHome, job.repoId, scheduleId);
146
150
  const occurrence = getOccurrence(controllerHome, job.repoId, occurrenceId);
147
151
  if (occurrence && !['succeeded', 'failed', 'shadowed', 'skipped'].includes(occurrence.status)) {
148
152
  saveOccurrence(controllerHome, {
@@ -175,12 +179,11 @@ export function settleScheduledExecution(
175
179
  });
176
180
  return;
177
181
  }
178
- saveSchedule(controllerHome, {
179
- ...schedule,
182
+ updateSchedule(controllerHome, job.repoId, scheduleId, (current) => ({
180
183
  consecutiveFailures: 0,
181
184
  nextEligibleAt: undefined,
182
- pausedReason: undefined,
183
- });
185
+ ...(current.enabled ? { pausedReason: undefined } : {}),
186
+ }));
184
187
  } catch {
185
188
  // Job terminal state remains authoritative even if an old schedule record
186
189
  // has already been removed. Reconciliation must not be blocked by it.