@moretea-labs/forge 1.5.0-rc.1 → 1.5.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 (264) hide show
  1. package/AGENTS.md +9 -11
  2. package/CHANGELOG.md +27 -1
  3. package/CLAUDE.md +8 -10
  4. package/DESIGN.md +278 -0
  5. package/README.md +70 -90
  6. package/README.zh-CN.md +52 -55
  7. package/SKILL.md +8 -11
  8. package/THIRD_PARTY_NOTICES.md +9 -3
  9. package/assets/branding/README.md +4 -2
  10. package/assets/hooks/lib/workflow-state.sh +19 -18
  11. package/assets/hooks/post-edit-guard.sh +6 -75
  12. package/assets/hooks/prompt-guard.sh +24 -12
  13. package/assets/hooks/session-start-context.sh +17 -17
  14. package/assets/hooks/stop-orchestrator.sh +2 -2
  15. package/assets/partials/04-project-structure.partial.md +6 -6
  16. package/assets/partials/05-workflow.partial.md +6 -6
  17. package/assets/partials/07-footer.partial.md +1 -1
  18. package/assets/partials/08-orchestration.partial.md +2 -2
  19. package/assets/partials-agents/02-operating-mode.partial.md +5 -5
  20. package/assets/partials-agents/03-orchestration.partial.md +3 -3
  21. package/assets/partials-agents/04-task-protocol.partial.md +3 -3
  22. package/assets/partials-agents/08-deep-docs.partial.md +1 -1
  23. package/assets/reference-configs/agentic-development-flow.md +49 -86
  24. package/assets/reference-configs/external-tooling.md +21 -61
  25. package/assets/reference-configs/handoff-protocol.md +25 -20
  26. package/assets/reference-configs/harness-overview.md +8 -8
  27. package/assets/skill-version.json +2 -2
  28. package/assets/templates/helpers/archive-workflow.sh +0 -1
  29. package/assets/templates/helpers/capture-plan.sh +3 -3
  30. package/assets/templates/helpers/check-task-workflow.sh +2 -2
  31. package/assets/templates/helpers/codex-handoff-resume.sh +5 -5
  32. package/assets/templates/helpers/ensure-task-workflow.sh +37 -35
  33. package/assets/templates/helpers/new-plan.sh +1 -1
  34. package/assets/templates/helpers/plan-to-todo.sh +2 -2
  35. package/assets/templates/helpers/prepare-codex-handoff.sh +2 -2
  36. package/assets/templates/helpers/prepare-handoff.sh +9 -9
  37. package/assets/templates/helpers/refresh-current-status.sh +2 -2
  38. package/assets/templates/helpers/ship-worktrees.sh +1 -1
  39. package/assets/templates/helpers/sprint-backlog.sh +1 -1
  40. package/assets/templates/helpers/verify-sprint.sh +7 -7
  41. package/assets/templates/helpers/workstream-sync.sh +1 -1
  42. package/assets/templates/plan.template.md +1 -1
  43. package/assets/workflow-contract.v1.json +6 -3
  44. package/docs/architecture/current/README.md +10 -9
  45. package/docs/architecture/current/approved-target-architecture.zh-CN.md +2 -2
  46. package/docs/architecture/current/architecture-invariants.md +2 -2
  47. package/docs/architecture/current/automation-and-schedule-engine.md +15 -15
  48. package/docs/architecture/current/codegraph-context-provider.md +2 -2
  49. package/docs/architecture/current/control-plane-authority-inventory.md +12 -0
  50. package/docs/architecture/current/dispatch-and-agent-strategy.md +11 -11
  51. package/docs/architecture/current/entity-model.md +0 -27
  52. package/docs/architecture/current/failure-recovery.md +0 -24
  53. package/docs/architecture/current/governance.md +1 -1
  54. package/docs/architecture/current/implementation-status.md +1 -1
  55. package/docs/architecture/current/legacy-governance.md +58 -0
  56. package/docs/architecture/current/multi-repository-execution.md +1 -1
  57. package/docs/architecture/current/peer-supercontroller-runtime.md +1 -1
  58. package/docs/architecture/current/personal-assistant-plugin-baseline.md +4 -5
  59. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -10
  60. package/docs/architecture/current/target-requirements-traceability.md +2 -2
  61. package/docs/architecture/current/thin-harness-governance-policy.md +1 -1
  62. package/docs/architecture/current/thin-harness-v1.md +9 -11
  63. package/docs/architecture/domains/runtime-harness.md +1 -1
  64. package/docs/forge-autonomous-goal-loop.md +3 -1
  65. package/docs/forge-chatgpt-mcp-setup.md +67 -176
  66. package/docs/images/forge-banner-cn.svg +26 -1
  67. package/docs/images/forge-banner.svg +26 -1
  68. package/docs/operations/features.md +1 -1
  69. package/docs/operations/homebrew.md +3 -3
  70. package/docs/operations/mcp-tool-exposure.md +5 -5
  71. package/docs/operations/platform-support.md +45 -37
  72. package/docs/operations/platform-support.zh-CN.md +40 -38
  73. package/docs/operations/releasing.md +3 -3
  74. package/docs/operations/releasing.zh-CN.md +3 -3
  75. package/docs/operations/troubleshooting.md +11 -6
  76. package/docs/operations/troubleshooting.zh-CN.md +11 -6
  77. package/docs/public-usage-guide.md +28 -21
  78. package/docs/public-usage-guide.zh-CN.md +28 -21
  79. package/docs/tutorials/01-install-and-start.md +58 -35
  80. package/docs/tutorials/01-install-and-start.zh-CN.md +58 -35
  81. package/docs/tutorials/02-connect-chatgpt.md +118 -46
  82. package/docs/tutorials/02-connect-chatgpt.zh-CN.md +119 -45
  83. package/docs/wiki/Architecture.md +1 -1
  84. package/docs/wiki/Installation.md +2 -2
  85. package/docs/wiki/Quick-Start.md +1 -1
  86. package/docs/wiki/Work-Lifecycle.md +1 -1
  87. package/install.ps1 +4 -5
  88. package/install.sh +4 -6
  89. package/package.json +15 -5
  90. package/scripts/archive-workflow.sh +0 -1
  91. package/scripts/benchmark-thin-harness-mcp-e2e.ts +27 -14
  92. package/scripts/build-controller-ui.ts +28 -0
  93. package/scripts/capture-plan.sh +4 -4
  94. package/scripts/check-agent-tooling.sh +12 -1
  95. package/scripts/check-platform-support.mjs +2 -2
  96. package/scripts/check-public-docs.sh +16 -5
  97. package/scripts/check-release-readiness.sh +5 -11
  98. package/scripts/check-runtime-architecture.mjs +14 -22
  99. package/scripts/check-tarball-install-smoke.sh +3 -2
  100. package/scripts/check-task-workflow.sh +2 -2
  101. package/scripts/codex-handoff-resume.sh +5 -5
  102. package/scripts/create-project-dirs.sh +3 -3
  103. package/scripts/ensure-task-workflow.sh +45 -35
  104. package/scripts/init-project.sh +4 -4
  105. package/scripts/install-desktop-operator-registration.ts +2 -2
  106. package/scripts/lib/project-init-lib.sh +46 -44
  107. package/scripts/migrate-project-template.sh +7 -4
  108. package/scripts/new-plan.sh +1 -1
  109. package/scripts/plan-to-todo.sh +2 -2
  110. package/scripts/prepare-codex-handoff.sh +2 -2
  111. package/scripts/prepare-handoff.sh +9 -9
  112. package/scripts/refresh-current-status.sh +2 -2
  113. package/scripts/setup-plugins.sh +3 -3
  114. package/scripts/ship-worktrees.sh +1 -1
  115. package/scripts/sprint-backlog.sh +5 -5
  116. package/scripts/sync-codex-installed-copies.sh +2 -3
  117. package/scripts/verify-sprint.sh +7 -7
  118. package/scripts/workstream-sync.sh +1 -1
  119. package/src/cli/chatgpt-browser/bridge-extension.ts +1 -1
  120. package/src/cli/chatgpt-browser/bridge-provider.ts +29 -0
  121. package/src/cli/chatgpt-browser/file-policy.ts +3 -1
  122. package/src/cli/commands/chatgpt.ts +51 -0
  123. package/src/cli/commands/doctor.ts +1 -1
  124. package/src/cli/commands/init-hook.ts +213 -70
  125. package/src/cli/commands/init.ts +5 -6
  126. package/src/cli/commands/mcp.ts +31 -5
  127. package/src/cli/commands/runtime.ts +61 -8
  128. package/src/cli/commands/setup-profile.ts +472 -0
  129. package/src/cli/controller/check-runner.ts +20 -3
  130. package/src/cli/controller/codex-continuation.ts +4 -7
  131. package/src/cli/controller/context-pack.ts +118 -14
  132. package/src/cli/controller/operational-plan.ts +4 -4
  133. package/src/cli/controller/task-ledger.ts +10 -10
  134. package/src/cli/controller/work-mode.ts +6 -13
  135. package/src/cli/index.ts +14 -35
  136. package/src/cli/local-bridge/connector-freshness.ts +1 -1
  137. package/src/cli/local-bridge/console-assets.ts +17 -0
  138. package/src/cli/local-bridge/console-automations.ts +160 -0
  139. package/src/cli/local-bridge/dashboard.ts +8 -1303
  140. package/src/cli/local-bridge/facade-api.ts +16 -7
  141. package/src/cli/local-bridge/frontend/src/App.tsx +10 -0
  142. package/src/cli/local-bridge/frontend/src/components/Button.tsx +1 -0
  143. package/src/cli/local-bridge/frontend/src/components/DetailPane.tsx +3 -0
  144. package/src/cli/local-bridge/frontend/src/components/SearchField.tsx +1 -0
  145. package/src/cli/local-bridge/frontend/src/components/SectionHeader.tsx +1 -0
  146. package/src/cli/local-bridge/frontend/src/components/Segmented.tsx +1 -0
  147. package/src/cli/local-bridge/frontend/src/components/StatusText.tsx +3 -0
  148. package/src/cli/local-bridge/frontend/src/design/icons.tsx +11 -0
  149. package/src/cli/local-bridge/frontend/src/design/tokens.css +8 -0
  150. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +3 -0
  151. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +4 -0
  152. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +3 -0
  153. package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +2 -0
  154. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +2 -0
  155. package/src/cli/local-bridge/frontend/src/features/system/SystemView.tsx +2 -0
  156. package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +3 -0
  157. package/src/cli/local-bridge/frontend/src/lib/api.ts +23 -0
  158. package/src/cli/local-bridge/frontend/src/lib/format.ts +4 -0
  159. package/src/cli/local-bridge/frontend/src/lib/routes.ts +7 -0
  160. package/src/cli/local-bridge/frontend/src/main.tsx +1 -0
  161. package/src/cli/local-bridge/frontend/src/shell/AppShell.tsx +1 -0
  162. package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +2 -0
  163. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +3 -0
  164. package/src/cli/local-bridge/frontend/src/styles.css +12 -0
  165. package/src/cli/local-bridge/frontend/src/types.ts +14 -0
  166. package/src/cli/local-bridge/frontend/src/vite-env.d.ts +1 -0
  167. package/src/cli/local-bridge/frontend/vite.config.ts +27 -0
  168. package/src/cli/local-bridge/server.ts +45 -0
  169. package/src/cli/local-bridge/ui-dist/app.css +1 -0
  170. package/src/cli/local-bridge/ui-dist/app.js +11094 -0
  171. package/src/cli/mcp/auth.ts +2 -0
  172. package/src/cli/mcp/instructions.ts +3 -3
  173. package/src/cli/mcp/legacy-tool-service.ts +27 -23
  174. package/src/cli/mcp/multi-repository.ts +2 -0
  175. package/src/cli/mcp/policy.ts +7 -5
  176. package/src/cli/mcp/repository-tools.ts +53 -14
  177. package/src/cli/mcp/server.ts +5 -10
  178. package/src/cli/mcp/setup.ts +220 -276
  179. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  180. package/src/cli/mcp/toolset-names.ts +1 -1
  181. package/src/cli/mcp/toolset.ts +0 -1
  182. package/src/cli/mcp/transports/http.ts +51 -4
  183. package/src/cli/repositories/branch-name-policy.ts +0 -7
  184. package/src/cli/repositories/controller-home.ts +110 -3
  185. package/src/cli/repositories/safe-patch.ts +1 -1
  186. package/src/cli/repositories/selected-path-actions.ts +26 -24
  187. package/src/core/adoption/gitignore-plan.ts +2 -3
  188. package/src/core/adoption/plan.ts +4 -1
  189. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +556 -0
  190. package/src/runtime/control-plane/execution/work-handle-store.ts +128 -1
  191. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +277 -0
  192. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +169 -0
  193. package/src/runtime/control-plane/facade/capability-registry.ts +0 -10
  194. package/src/runtime/control-plane/facade/handoff-inbox-store.ts +44 -25
  195. package/src/runtime/control-plane/facade/suggested-actions.ts +7 -1
  196. package/src/runtime/control-plane/facade/types.ts +0 -1
  197. package/src/runtime/control-plane/global-scheduler/scheduler.ts +30 -8
  198. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +114 -0
  199. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +175 -0
  200. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +161 -0
  201. package/src/runtime/control-plane/launcher/provider-mcp-bootstrap.ts +64 -0
  202. package/src/runtime/control-plane/launcher/thin-launcher.ts +117 -24
  203. package/src/runtime/control-plane/routing/route-policy.ts +14 -14
  204. package/src/runtime/control-plane/runtime-cleanup.ts +0 -1
  205. package/src/runtime/evidence/event-ledger.ts +1 -1
  206. package/src/runtime/{workflow/campaigns/workspace.ts → execution/managed-workspace.ts} +41 -43
  207. package/src/runtime/execution/process-runtime/check-facade.ts +6 -0
  208. package/src/runtime/execution/process-runtime/check-result.ts +45 -0
  209. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +42 -16
  210. package/src/runtime/execution/process-runtime/command-facade.ts +4 -2
  211. package/src/runtime/execution/process-runtime/gc.ts +1 -1
  212. package/src/runtime/execution/process-runtime/index.ts +3 -0
  213. package/src/runtime/execution/process-runtime/interactive-admission.ts +14 -5
  214. package/src/runtime/execution/process-runtime/persisted-check.ts +285 -0
  215. package/src/runtime/execution/process-runtime/resource-claims.ts +30 -0
  216. package/src/runtime/execution/process-runtime/runtime.ts +18 -1
  217. package/src/runtime/execution/process-runtime/store.ts +1 -19
  218. package/src/runtime/execution/process-runtime/types.ts +4 -0
  219. package/src/runtime/execution/thin-harness/execution-router.ts +0 -1
  220. package/src/runtime/execution/thin-harness/fast-executor.ts +1 -1
  221. package/src/runtime/execution/thin-harness/lightweight-lanes.ts +1 -2
  222. package/src/runtime/execution/thin-harness/types.ts +0 -1
  223. package/src/runtime/execution/workers/executor.ts +1 -1
  224. package/src/runtime/gateway/mcp/execution-tools.ts +1 -1
  225. package/src/runtime/gateway/mcp/persisted-check-process.ts +3 -255
  226. package/src/runtime/gateway/mcp/process-tools.ts +16 -3
  227. package/src/runtime/gateway/mcp/resource-policy.ts +0 -9
  228. package/src/runtime/gateway/mcp/router.ts +117 -182
  229. package/src/runtime/gateway/mcp/runtime-tools.ts +331 -409
  230. package/src/runtime/gateway/mcp/work-validation-reconciler.ts +1 -219
  231. package/src/runtime/plugins/browser-adapter.ts +5 -0
  232. package/src/runtime/plugins/desktop-operator-registration.ts +1 -1
  233. package/src/runtime/plugins/store.ts +14 -6
  234. package/src/runtime/plugins/types.ts +1 -1
  235. package/src/runtime/projections/materialized-view.ts +1 -23
  236. package/src/runtime/recovery/maintenance-executor.ts +9 -0
  237. package/src/runtime/resources/ownership.ts +0 -1
  238. package/src/runtime/root/gateway-adapter.ts +2 -0
  239. package/src/runtime/root/mcp-transport.ts +10 -2
  240. package/src/runtime/root/package-connector-service.ts +136 -0
  241. package/src/runtime/root/package-runtime-release.ts +161 -0
  242. package/src/runtime/root/package-runtime-service.ts +185 -0
  243. package/src/runtime/root/release-materialize.ts +19 -0
  244. package/src/runtime/root/runtime.ts +2 -1
  245. package/src/runtime/root/scheduler.ts +5 -0
  246. package/src/runtime/root/service.ts +18 -5
  247. package/src/runtime/standalone-recovery/core.ts +65 -16
  248. package/src/runtime/workflow/findings/store.ts +145 -106
  249. package/src/runtime/workflow/findings/types.ts +22 -0
  250. package/src/runtime/workflow/schedules/engine.ts +132 -2
  251. package/src/runtime/workflow/schedules/pending-activation.ts +139 -0
  252. package/src/runtime/workflow/schedules/work-continuation.ts +218 -0
  253. package/docs/CHANGELOG.md +0 -978
  254. package/docs/README.zh-CN.md +0 -58
  255. package/scripts/smoke-chatgpt-supervised-automation.ts +0 -208
  256. package/src/cli/commands/controller.ts +0 -680
  257. package/src/runtime/workflow/campaigns/cleanup.ts +0 -263
  258. package/src/runtime/workflow/campaigns/engine.ts +0 -112
  259. package/src/runtime/workflow/campaigns/normalize.ts +0 -100
  260. package/src/runtime/workflow/campaigns/review.ts +0 -315
  261. package/src/runtime/workflow/campaigns/store.ts +0 -526
  262. package/src/runtime/workflow/campaigns/supervisor.ts +0 -112
  263. package/src/runtime/workflow/campaigns/types.ts +0 -290
  264. /package/src/runtime/{workflow/campaigns → execution}/workspace-agent.ts +0 -0
@@ -1,55 +1,84 @@
1
1
  import { createHash, randomUUID } from 'crypto';
2
- import { existsSync, readdirSync } from 'fs';
2
+ import { readdirSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { repositoryControllerRoot } from '../../../cli/repositories/controller-home';
5
- import { withControllerLock } from '../../../cli/repositories/locks';
5
+ import {
6
+ listControlPlaneRecords,
7
+ mutateControlPlaneRecord,
8
+ readOrImportControlPlaneRecord,
9
+ } from '../../control-plane/persistence/sqlite-store';
6
10
  import { appendRuntimeEvent } from '../../evidence/event-ledger';
7
- import { readJsonFile, sanitizeFileComponent, writeJsonAtomic } from '../../shared/json-files';
11
+ import { readJsonFile, sanitizeFileComponent } from '../../shared/json-files';
8
12
  import type { CandidateFinding, RecordCandidateFindingInput } from './types';
9
13
 
10
- interface FindingIndex {
11
- schemaVersion: 1;
12
- updatedAt: string;
13
- candidates: Array<{ findingId: string; semanticKey: string; severity: CandidateFinding['severity']; lastSeenAt: string }>;
14
- recent: Array<{ findingId: string; semanticKey: string; status: CandidateFinding['status']; lastSeenAt: string }>;
15
- }
14
+ const FINDING_NAMESPACE = 'candidate_finding';
15
+ const FINDING_SCHEMA_VERSION = 1;
16
+ const MAX_EVIDENCE = 100;
17
+ const MAX_FINDINGS = 5_000;
16
18
 
17
- function root(controllerHome: string, repoId: string): string {
19
+ function legacyRoot(controllerHome: string, repoId: string): string {
18
20
  return join(repositoryControllerRoot(controllerHome, repoId), 'candidate-findings');
19
21
  }
20
- function recordPath(controllerHome: string, repoId: string, findingId: string): string {
21
- return join(root(controllerHome, repoId), 'records', `${sanitizeFileComponent(findingId)}.json`);
22
+ function legacyRecordPath(controllerHome: string, repoId: string, findingId: string): string {
23
+ return join(legacyRoot(controllerHome, repoId), 'records', `${sanitizeFileComponent(findingId)}.json`);
24
+ }
25
+ function legacySemanticPath(controllerHome: string, repoId: string, semanticKey: string): string {
26
+ return join(legacyRoot(controllerHome, repoId), 'indexes', 'semantic', `${findingKey(semanticKey)}.json`);
27
+ }
28
+ function findingKey(semanticKey: string): string {
29
+ return createHash('sha256').update(semanticKey).digest('hex');
22
30
  }
23
- function semanticPath(controllerHome: string, repoId: string, semanticKey: string): string {
24
- return join(root(controllerHome, repoId), 'indexes', 'semantic', `${createHash('sha256').update(semanticKey).digest('hex')}.json`);
31
+ function legacyFinding(controllerHome: string, repoId: string, findingId: string): CandidateFinding | undefined {
32
+ try { return readJsonFile<CandidateFinding>(legacyRecordPath(controllerHome, repoId, findingId)); } catch { return undefined; }
25
33
  }
26
- function indexPath(controllerHome: string, repoId: string): string {
27
- return join(root(controllerHome, repoId), 'indexes', 'findings.json');
34
+ function legacyFindingForSemanticKey(controllerHome: string, repoId: string, semanticKey: string): CandidateFinding | undefined {
35
+ try {
36
+ const reference = readJsonFile<{ findingId?: string }>(legacySemanticPath(controllerHome, repoId, semanticKey));
37
+ return typeof reference.findingId === 'string' ? legacyFinding(controllerHome, repoId, reference.findingId) : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
28
41
  }
29
- function emptyIndex(): FindingIndex {
30
- return { schemaVersion: 1, updatedAt: new Date().toISOString(), candidates: [], recent: [] };
42
+ function records(controllerHome: string, repoId: string): CandidateFinding[] {
43
+ return listControlPlaneRecords<CandidateFinding>(controllerHome, {
44
+ namespace: FINDING_NAMESPACE,
45
+ scope: repoId,
46
+ limit: MAX_FINDINGS,
47
+ }).map((record) => record.value);
31
48
  }
32
- function readIndex(controllerHome: string, repoId: string): FindingIndex {
33
- return readJsonFile<FindingIndex>(indexPath(controllerHome, repoId), emptyIndex());
49
+ function importLegacyFinding(controllerHome: string, repoId: string, finding: CandidateFinding): CandidateFinding {
50
+ return readOrImportControlPlaneRecord<CandidateFinding>(controllerHome, {
51
+ namespace: FINDING_NAMESPACE,
52
+ scope: repoId,
53
+ key: findingKey(finding.semanticKey),
54
+ schemaVersion: FINDING_SCHEMA_VERSION,
55
+ readLegacy: () => finding,
56
+ })!.value;
34
57
  }
35
- function upsertIndexUnlocked(controllerHome: string, finding: CandidateFinding): void {
36
- const index = readIndex(controllerHome, finding.repoId);
37
- index.candidates = index.candidates.filter((entry) => entry.findingId !== finding.findingId);
38
- index.recent = index.recent.filter((entry) => entry.findingId !== finding.findingId);
39
- if (finding.status === 'candidate') {
40
- index.candidates.push({ findingId: finding.findingId, semanticKey: finding.semanticKey, severity: finding.severity, lastSeenAt: finding.lastSeenAt });
58
+ function importLegacyFindings(controllerHome: string, repoId: string): CandidateFinding[] {
59
+ const directory = join(legacyRoot(controllerHome, repoId), 'records');
60
+ try {
61
+ for (const name of readdirSync(directory).filter((entry) => entry.endsWith('.json')).slice(0, MAX_FINDINGS)) {
62
+ const finding = legacyFinding(controllerHome, repoId, name.slice(0, -'.json'.length));
63
+ if (finding?.repoId === repoId && finding.semanticKey) importLegacyFinding(controllerHome, repoId, finding);
64
+ }
65
+ } catch {
66
+ // No legacy records is the normal v2 path.
41
67
  }
42
- index.recent.push({ findingId: finding.findingId, semanticKey: finding.semanticKey, status: finding.status, lastSeenAt: finding.lastSeenAt });
43
- index.candidates.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
44
- index.recent.sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt));
45
- index.candidates = index.candidates.slice(0, 5000);
46
- index.recent = index.recent.slice(0, 5000);
47
- index.updatedAt = new Date().toISOString();
48
- writeJsonAtomic(indexPath(controllerHome, finding.repoId), index);
68
+ return records(controllerHome, repoId);
69
+ }
70
+ function existingFinding(controllerHome: string, repoId: string, findingId: string): CandidateFinding | undefined {
71
+ return records(controllerHome, repoId).find((finding) => finding.findingId === findingId)
72
+ ?? (() => {
73
+ const legacy = legacyFinding(controllerHome, repoId, findingId);
74
+ return legacy?.repoId === repoId ? importLegacyFinding(controllerHome, repoId, legacy) : undefined;
75
+ })();
49
76
  }
50
77
 
51
78
  export function getCandidateFinding(controllerHome: string, repoId: string, findingId: string): CandidateFinding {
52
- return readJsonFile<CandidateFinding>(recordPath(controllerHome, repoId, findingId));
79
+ const finding = existingFinding(controllerHome, repoId, findingId);
80
+ if (!finding) throw new Error(`CANDIDATE_FINDING_NOT_FOUND: ${findingId}`);
81
+ return finding;
53
82
  }
54
83
 
55
84
  export function recordCandidateFinding(controllerHome: string, input: RecordCandidateFindingInput): CandidateFinding {
@@ -59,31 +88,38 @@ export function recordCandidateFinding(controllerHome: string, input: RecordCand
59
88
  if (!semanticKey) throw new Error('CANDIDATE_SEMANTIC_KEY_REQUIRED');
60
89
  if (!title) throw new Error('CANDIDATE_TITLE_REQUIRED');
61
90
  if (!requestId) throw new Error('CANDIDATE_REQUEST_ID_REQUIRED');
62
- const lockId = createHash('sha256').update(semanticKey).digest('hex').slice(0, 20);
63
- return withControllerLock(controllerHome, { scope: 'task', repoId: input.repoId, taskId: `candidate-${lockId}` }, `record-candidate:${lockId}`, () => {
64
- const semanticIndex = semanticPath(controllerHome, input.repoId, semanticKey);
65
- const timestamp = input.evidence?.observedAt ?? new Date().toISOString();
66
- let finding: CandidateFinding;
67
- if (existsSync(semanticIndex)) {
68
- const reference = readJsonFile<{ findingId: string }>(semanticIndex);
69
- const current = getCandidateFinding(controllerHome, input.repoId, reference.findingId);
91
+ const timestamp = input.evidence?.observedAt ?? new Date().toISOString();
92
+ const record = mutateControlPlaneRecord<CandidateFinding>(controllerHome, {
93
+ namespace: FINDING_NAMESPACE,
94
+ scope: input.repoId,
95
+ key: findingKey(semanticKey),
96
+ schemaVersion: FINDING_SCHEMA_VERSION,
97
+ action: 'candidate_finding_observed',
98
+ readLegacy: () => legacyFindingForSemanticKey(controllerHome, input.repoId, semanticKey),
99
+ mutate: (current) => {
100
+ const previous = current?.value;
70
101
  const evidence = input.evidence
71
- ? [...current.evidence, { ...input.evidence, observedAt: timestamp }].slice(-100)
72
- : current.evidence;
73
- finding = {
74
- ...current,
75
- revision: current.revision + 1,
102
+ ? [...(previous?.evidence ?? []), { ...input.evidence, observedAt: timestamp }].slice(-MAX_EVIDENCE)
103
+ : previous?.evidence ?? [];
104
+ return previous ? {
105
+ ...previous,
106
+ revision: previous.revision + 1,
76
107
  title,
77
- summary: input.summary ?? current.summary,
78
- severity: input.severity ?? current.severity,
79
- status: current.status === 'dismissed' ? 'candidate' : current.status,
80
- observationCount: current.observationCount + 1,
108
+ summary: input.summary ?? previous.summary,
109
+ severity: input.severity ?? previous.severity,
110
+ status: previous.status === 'dismissed' ? 'candidate' : previous.status,
111
+ observationCount: previous.observationCount + 1,
81
112
  evidence,
82
113
  lastSeenAt: timestamp,
83
- dismissedReason: current.status === 'dismissed' ? undefined : current.dismissedReason,
84
- };
85
- } else {
86
- finding = {
114
+ dismissedReason: previous.status === 'dismissed' ? undefined : previous.dismissedReason,
115
+ requirementId: input.requirementId ?? previous.requirementId,
116
+ kind: input.kind ?? previous.kind,
117
+ sourceRepoId: input.sourceRepoId ?? previous.sourceRepoId,
118
+ sourceWorkId: input.sourceWorkId ?? previous.sourceWorkId,
119
+ sourceProcessId: input.sourceProcessId ?? previous.sourceProcessId,
120
+ sourcePluginId: input.sourcePluginId ?? previous.sourcePluginId,
121
+ externalRefs: input.externalRefs ?? previous.externalRefs,
122
+ } : {
87
123
  schemaVersion: 1,
88
124
  revision: 1,
89
125
  findingId: `FIND-${Date.now()}-${randomUUID().slice(0, 8)}`,
@@ -94,25 +130,30 @@ export function recordCandidateFinding(controllerHome: string, input: RecordCand
94
130
  severity: input.severity ?? 'medium',
95
131
  status: 'candidate',
96
132
  observationCount: 1,
97
- evidence: input.evidence ? [{ ...input.evidence, observedAt: timestamp }] : [],
133
+ evidence,
98
134
  firstSeenAt: timestamp,
99
135
  lastSeenAt: timestamp,
136
+ requirementId: input.requirementId,
137
+ kind: input.kind,
138
+ sourceRepoId: input.sourceRepoId,
139
+ sourceWorkId: input.sourceWorkId,
140
+ sourceProcessId: input.sourceProcessId,
141
+ sourcePluginId: input.sourcePluginId,
142
+ externalRefs: input.externalRefs,
100
143
  };
101
- writeJsonAtomic(semanticIndex, { semanticKey, findingId: finding.findingId, createdAt: timestamp });
102
- }
103
- writeJsonAtomic(recordPath(controllerHome, finding.repoId, finding.findingId), finding);
104
- upsertIndexUnlocked(controllerHome, finding);
105
- appendRuntimeEvent(controllerHome, {
106
- repoId: finding.repoId,
107
- entityType: 'candidate-finding',
108
- entityId: finding.findingId,
109
- eventType: finding.observationCount === 1 ? 'candidate_finding_created' : 'candidate_finding_observed',
110
- requestId,
111
- revision: finding.revision,
112
- data: { semanticKey, observationCount: finding.observationCount, severity: finding.severity },
113
- });
114
- return finding;
115
- }, 10_000);
144
+ },
145
+ });
146
+ const finding = record.value;
147
+ appendRuntimeEvent(controllerHome, {
148
+ repoId: finding.repoId,
149
+ entityType: 'candidate-finding',
150
+ entityId: finding.findingId,
151
+ eventType: finding.observationCount === 1 ? 'candidate_finding_created' : 'candidate_finding_observed',
152
+ requestId,
153
+ revision: finding.revision,
154
+ data: { semanticKey, observationCount: finding.observationCount, severity: finding.severity },
155
+ });
156
+ return finding;
116
157
  }
117
158
 
118
159
  export function updateCandidateFinding(
@@ -123,43 +164,41 @@ export function updateCandidateFinding(
123
164
  requestId: string,
124
165
  eventType: string,
125
166
  ): CandidateFinding {
126
- return withControllerLock(controllerHome, { scope: 'task', repoId, taskId: `candidate-${findingId}` }, `update-candidate:${findingId}`, () => {
127
- const current = getCandidateFinding(controllerHome, repoId, findingId);
128
- const next = updater(structuredClone(current));
129
- if (next.findingId !== current.findingId || next.repoId !== current.repoId || next.semanticKey !== current.semanticKey) {
130
- throw new Error('CANDIDATE_IDENTITY_IMMUTABLE');
131
- }
132
- next.revision = current.revision + 1;
133
- next.lastSeenAt = new Date().toISOString();
134
- writeJsonAtomic(recordPath(controllerHome, repoId, findingId), next);
135
- upsertIndexUnlocked(controllerHome, next);
136
- appendRuntimeEvent(controllerHome, {
137
- repoId,
138
- entityType: 'candidate-finding',
139
- entityId: findingId,
140
- eventType,
141
- requestId,
142
- revision: next.revision,
143
- data: { status: next.status, promotedJobId: next.promotedJobId },
144
- });
145
- return next;
146
- }, 10_000);
167
+ const existing = getCandidateFinding(controllerHome, repoId, findingId);
168
+ const record = mutateControlPlaneRecord<CandidateFinding>(controllerHome, {
169
+ namespace: FINDING_NAMESPACE,
170
+ scope: repoId,
171
+ key: findingKey(existing.semanticKey),
172
+ schemaVersion: FINDING_SCHEMA_VERSION,
173
+ action: eventType,
174
+ mutate: (current) => {
175
+ const previous = current?.value ?? existing;
176
+ const next = updater(structuredClone(previous));
177
+ if (next.findingId !== previous.findingId || next.repoId !== previous.repoId || next.semanticKey !== previous.semanticKey) {
178
+ throw new Error('CANDIDATE_IDENTITY_IMMUTABLE');
179
+ }
180
+ return { ...next, revision: previous.revision + 1, lastSeenAt: new Date().toISOString() };
181
+ },
182
+ });
183
+ const finding = record.value;
184
+ appendRuntimeEvent(controllerHome, {
185
+ repoId,
186
+ entityType: 'candidate-finding',
187
+ entityId: findingId,
188
+ eventType,
189
+ requestId,
190
+ revision: finding.revision,
191
+ data: { status: finding.status, promotedJobId: finding.promotedJobId },
192
+ });
193
+ return finding;
147
194
  }
148
195
 
149
196
  export function listCandidateFindings(controllerHome: string, repoId: string, options: { includeTerminal?: boolean; limit?: number } = {}): CandidateFinding[] {
150
197
  const bounded = Math.max(1, Math.min(options.limit ?? 100, 1000));
151
- const index = readIndex(controllerHome, repoId);
152
- const entries = options.includeTerminal ? index.recent : index.candidates;
153
- const indexed = entries.slice(0, bounded).flatMap((entry) => {
154
- try { return [getCandidateFinding(controllerHome, repoId, entry.findingId)]; } catch { return []; }
155
- });
156
- if (indexed.length > 0) return indexed;
157
- try {
158
- return readdirSync(join(root(controllerHome, repoId), 'records'))
159
- .filter((name) => name.endsWith('.json'))
160
- .map((name) => readJsonFile<CandidateFinding>(join(root(controllerHome, repoId), 'records', name)))
161
- .filter((finding) => options.includeTerminal || finding.status === 'candidate')
162
- .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
163
- .slice(0, bounded);
164
- } catch { return []; }
198
+ const stored = records(controllerHome, repoId);
199
+ const all = stored.length > 0 ? stored : importLegacyFindings(controllerHome, repoId);
200
+ return all
201
+ .filter((finding) => options.includeTerminal || finding.status === 'candidate')
202
+ .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))
203
+ .slice(0, bounded);
165
204
  }
@@ -7,6 +7,13 @@ export interface CandidateFindingEvidence {
7
7
  details?: Record<string, unknown>;
8
8
  }
9
9
 
10
+ export interface CandidateFindingExternalRef {
11
+ kind: string;
12
+ url?: string;
13
+ number?: number;
14
+ label?: string;
15
+ }
16
+
10
17
  export interface CandidateFinding {
11
18
  schemaVersion: 1;
12
19
  revision: number;
@@ -23,6 +30,14 @@ export interface CandidateFinding {
23
30
  lastSeenAt: string;
24
31
  promotedJobId?: string;
25
32
  dismissedReason?: string;
33
+ /** Optional Requirement/operational provenance; repoId remains the owner. */
34
+ requirementId?: string;
35
+ kind?: 'defect' | 'performance' | 'reliability' | 'usability';
36
+ sourceRepoId?: string;
37
+ sourceWorkId?: string;
38
+ sourceProcessId?: string;
39
+ sourcePluginId?: string;
40
+ externalRefs?: CandidateFindingExternalRef[];
26
41
  }
27
42
 
28
43
  export interface RecordCandidateFindingInput {
@@ -32,5 +47,12 @@ export interface RecordCandidateFindingInput {
32
47
  summary?: string;
33
48
  severity?: CandidateFinding['severity'];
34
49
  evidence?: Omit<CandidateFindingEvidence, 'observedAt'> & { observedAt?: string };
50
+ requirementId?: string;
51
+ kind?: CandidateFinding['kind'];
52
+ sourceRepoId?: string;
53
+ sourceWorkId?: string;
54
+ sourceProcessId?: string;
55
+ sourcePluginId?: string;
56
+ externalRefs?: CandidateFindingExternalRef[];
35
57
  requestId: string;
36
58
  }
@@ -10,6 +10,9 @@ import {
10
10
  type RuntimeMaintenanceActionId,
11
11
  } from '../../recovery/maintenance-executor';
12
12
  import { listCandidateFindings } from '../findings/store';
13
+ import { getControllerSession, getWorkContract, isTerminalWorkContractStatus, listHandoffItems } from '../../control-plane/facade';
14
+ import { launchSuperController } from '../../control-plane/launcher/thin-launcher';
15
+ import { getExternalControllerLaunchReservation } from '../../control-plane/launcher/launch-reservation-store';
13
16
  import {
14
17
  getOccurrence,
15
18
  getSchedule,
@@ -31,11 +34,13 @@ import type {
31
34
 
32
35
  const execFileAsync = promisify(execFile);
33
36
  const LIVE_MAINTENANCE_OPERATION = 'runtime_maintenance_apply';
37
+ const EXTERNAL_CONTROLLER_WAKE_OPERATION = 'external_controller_wake';
34
38
 
35
39
  function isDeterministicSchedule(schedule: RepositorySchedule): boolean {
36
40
  // Kernel may execute only fully deterministic, allowlisted schedule operations.
37
41
  // Semantic / model-backed schedules remain external-controller handoffs.
38
- return schedule.action.operation === LIVE_MAINTENANCE_OPERATION;
42
+ return schedule.action.operation === LIVE_MAINTENANCE_OPERATION
43
+ || schedule.action.operation === EXTERNAL_CONTROLLER_WAKE_OPERATION;
39
44
  }
40
45
 
41
46
  function normalizedWindow(minutes: number, at = Date.now()): string {
@@ -229,9 +234,34 @@ async function triggerDue(
229
234
  }
230
235
  }
231
236
 
237
+ function continuationWorkId(schedule: RepositorySchedule): string | undefined {
238
+ if (schedule.action.operation !== EXTERNAL_CONTROLLER_WAKE_OPERATION) return undefined;
239
+ const value = schedule.action.arguments?.work_id;
240
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
241
+ }
242
+
243
+ function executionJobBelongsToWork(job: ReturnType<typeof listExecutionJobs>[number], workId: string): boolean {
244
+ if (job.resourceClaims.some((claim) => claim.workId === workId)) return true;
245
+ const payload = job.payload as Record<string, unknown>;
246
+ const args = payload.arguments && typeof payload.arguments === 'object' && !Array.isArray(payload.arguments)
247
+ ? payload.arguments as Record<string, unknown>
248
+ : undefined;
249
+ return [payload.workId, payload.work_id, args?.workId, args?.work_id].some((value) => value === workId);
250
+ }
251
+
232
252
  async function stopReason(controllerHome: string, schedule: RepositorySchedule): Promise<string | undefined> {
233
253
  const projection = readRepositoryProjection(controllerHome, schedule.repoId);
234
- if (schedule.stopConditions.includes('human_review_required') && projection.currentAttention.length > 0) return 'Repository has jobs requiring human attention.';
254
+ const workId = continuationWorkId(schedule);
255
+ const work = workId ? getWorkContract({ controllerHome, repoId: schedule.repoId }, workId) : undefined;
256
+ if (schedule.stopConditions.includes('human_review_required')) {
257
+ if (workId) {
258
+ const activeHandoff = listHandoffItems({ controllerHome, repoId: schedule.repoId, status: 'active', limit: 100 })
259
+ .find((item) => item.workId === workId);
260
+ if (activeHandoff) return `Work ${workId} has active Handoff ${activeHandoff.id} requiring review.`;
261
+ } else if (projection.currentAttention.length > 0) {
262
+ return 'Repository has jobs requiring human attention.';
263
+ }
264
+ }
235
265
  if (schedule.stopConditions.includes('release_ready') && projection.releaseFrozen) return 'Repository is in release freeze.';
236
266
  if (schedule.stopConditions.includes('external_blocker')) {
237
267
  try {
@@ -242,6 +272,10 @@ async function stopReason(controllerHome: string, schedule: RepositorySchedule):
242
272
  }
243
273
  const recentInfrastructureFailure = listExecutionJobs(controllerHome, schedule.repoId, 20).find((job) => {
244
274
  if (!['failed', 'timed_out', 'orphaned'].includes(job.status) || !job.error) return false;
275
+ if (workId) {
276
+ if (!work || Date.parse(job.createdAt) < Date.parse(work.createdAt)) return false;
277
+ if (!executionJobBelongsToWork(job, workId)) return false;
278
+ }
245
279
  if (job.error.retryable) return true;
246
280
  return /(?:network|connection|upstream|external|remote|github|tunnel|502|503|timeout)/i.test(`${job.error.code} ${job.error.message}`);
247
281
  });
@@ -513,6 +547,102 @@ export async function evaluateSchedule(
513
547
  }
514
548
 
515
549
  const args = schedule.action.arguments ?? {};
550
+ if (schedule.action.operation === EXTERNAL_CONTROLLER_WAKE_OPERATION) {
551
+ const workId = typeof args.work_id === 'string' ? args.work_id.trim() : '';
552
+ const controllerType = typeof args.controller_type === 'string' ? args.controller_type.trim() : 'chatgpt';
553
+ if (!workId) {
554
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_WORK_ID_REQUIRED');
555
+ }
556
+ if (!['chatgpt', 'codex', 'claude', 'grok'].includes(controllerType)) {
557
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', 'EXTERNAL_CONTROLLER_WAKE_TYPE_INVALID');
558
+ }
559
+ const workStore = { controllerHome, repoId: schedule.repoId };
560
+ const work = getWorkContract(workStore, workId);
561
+ if (!work) {
562
+ saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} no longer exists.`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
563
+ return decideOccurrence(controllerHome, schedule, occurrence, 'operation_blocked', 'skipped', `EXTERNAL_CONTROLLER_WAKE_WORK_NOT_FOUND:${workId}`);
564
+ }
565
+ if (isTerminalWorkContractStatus(work.status)) {
566
+ saveSchedule(controllerHome, { ...schedule, enabled: false, pausedReason: `Work ${workId} is terminal (${work.status}).`, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
567
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} is terminal (${work.status}); automatic continuation stopped.`);
568
+ }
569
+ const existingOwner = getControllerSession(workStore, workId);
570
+ if (existingOwner) {
571
+ saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
572
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has an active Controller ${existingOwner.controllerId}.`);
573
+ }
574
+ const launchReservation = getExternalControllerLaunchReservation(workStore, workId);
575
+ if (launchReservation) {
576
+ saveSchedule(controllerHome, { ...schedule, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
577
+ return decideOccurrence(controllerHome, schedule, occurrence, 'nothing_to_do', 'skipped', `Work ${workId} already has a pending external Controller launch ${launchReservation.reservationId}.`);
578
+ }
579
+ const wakeDecision = decideOccurrence(
580
+ controllerHome,
581
+ schedule,
582
+ occurrence,
583
+ 'execute',
584
+ 'running',
585
+ 'Deterministic external Controller wake is starting through Thin Launcher.',
586
+ occurrenceDecisionEvidence({ operation: schedule.action.operation, workId, controllerType }),
587
+ );
588
+ try {
589
+ const repository = getRepository(schedule.repoId, controllerHome, { includeRemoved: true });
590
+ const launched = launchSuperController({
591
+ work: { controllerHome, repoId: schedule.repoId },
592
+ handoff: { controllerHome, repoId: schedule.repoId },
593
+ }, {
594
+ controllerType: controllerType as 'chatgpt' | 'codex' | 'claude' | 'grok',
595
+ executable: typeof args.executable === 'string' ? args.executable : undefined,
596
+ args: Array.isArray(args.launch_args) ? args.launch_args.map(String) : [],
597
+ workId,
598
+ handoffId: typeof args.handoff_id === 'string' ? args.handoff_id : undefined,
599
+ browserSessionId: typeof args.browser_session_id === 'string' ? args.browser_session_id : undefined,
600
+ conversationUrl: typeof args.conversation_url === 'string' ? args.conversation_url : undefined,
601
+ launchReservationMs: typeof args.launch_reservation_ms === 'number' ? args.launch_reservation_ms : typeof args.lease_ms === 'number' ? args.lease_ms : undefined,
602
+ continuationPrompt: typeof args.continuation_prompt === 'string'
603
+ ? args.continuation_prompt
604
+ : `Scheduled continuation ${occurrence.occurrenceId} from ${schedule.scheduleId}. Read current Forge state and continue only the bounded Work objective.`,
605
+ cwd: repository.canonicalRoot,
606
+ });
607
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
608
+ saveSchedule(controllerHome, {
609
+ ...latest,
610
+ lastTriggeredAt: timestamp,
611
+ lastOccurrenceId: occurrenceId,
612
+ consecutiveFailures: 0,
613
+ nextEligibleAt: undefined,
614
+ pausedReason: undefined,
615
+ });
616
+ return saveOccurrence(controllerHome, {
617
+ ...wakeDecision,
618
+ status: 'succeeded',
619
+ reason: `External Controller wake started ${launched.controllerType} pid=${String(launched.pid ?? 'unknown')} for Work ${workId}.`,
620
+ });
621
+ } catch (error) {
622
+ const reason = error instanceof Error ? error.message : String(error);
623
+ const failed = applyScheduleFailure(controllerHome, schedule.scheduleId, schedule.repoId, occurrence.occurrenceId, {
624
+ outcome: 'failed',
625
+ decision: 'execute',
626
+ reason,
627
+ handoff: {
628
+ title: `External Controller wake ${occurrence.occurrenceId} failed`,
629
+ summary: 'Forge recorded the schedule trigger but could not wake the configured external Controller.',
630
+ reason,
631
+ creationReason: 'repeated_infrastructure_failure',
632
+ blockingDecision: 'Repair the external Controller/browser launch path or update the saved session reference.',
633
+ recommendedDecision: 'Inspect launcher/browser readiness, then retrigger this bounded continuation.',
634
+ recommendedPrompt: `Resume Work ${workId} manually, inspect failed wake occurrence ${occurrence.occurrenceId}, and repair the configured external Controller launch path before unattended continuation resumes.`,
635
+ statusSummary: 'Scheduled external Controller wake failed.',
636
+ blockedBy: ['external_controller_wake_failed'],
637
+ attemptedActions: [`schedule:${schedule.scheduleId}`, `work:${workId}`, `controller:${controllerType}`],
638
+ },
639
+ });
640
+ const latest = getSchedule(controllerHome, schedule.repoId, schedule.scheduleId);
641
+ saveSchedule(controllerHome, { ...latest, lastTriggeredAt: timestamp, lastOccurrenceId: occurrenceId });
642
+ return failed.occurrence ?? saveOccurrence(controllerHome, { ...wakeDecision, status: 'failed', reason });
643
+ }
644
+ }
645
+
516
646
  const actionIdRaw = typeof args.action_id === 'string'
517
647
  ? args.action_id
518
648
  : typeof args.actionId === 'string'