@pellux/goodvibes-sdk 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/contracts/artifacts/operator-contract.json +1 -1
  2. package/dist/events/communication.d.ts +21 -1
  3. package/dist/events/communication.d.ts.map +1 -1
  4. package/dist/platform/agents/message-bus-core.d.ts +7 -0
  5. package/dist/platform/agents/message-bus-core.d.ts.map +1 -1
  6. package/dist/platform/agents/message-bus-core.js +2 -1
  7. package/dist/platform/agents/orchestrator-runner.d.ts +37 -1
  8. package/dist/platform/agents/orchestrator-runner.d.ts.map +1 -1
  9. package/dist/platform/agents/orchestrator-runner.js +158 -7
  10. package/dist/platform/agents/orchestrator.d.ts +40 -0
  11. package/dist/platform/agents/orchestrator.d.ts.map +1 -1
  12. package/dist/platform/agents/orchestrator.js +29 -0
  13. package/dist/platform/agents/turn-knowledge-injection.d.ts +107 -0
  14. package/dist/platform/agents/turn-knowledge-injection.d.ts.map +1 -0
  15. package/dist/platform/agents/turn-knowledge-injection.js +152 -0
  16. package/dist/platform/agents/wrfc-controller.d.ts +23 -0
  17. package/dist/platform/agents/wrfc-controller.d.ts.map +1 -1
  18. package/dist/platform/agents/wrfc-controller.js +55 -0
  19. package/dist/platform/config/manager.d.ts +9 -0
  20. package/dist/platform/config/manager.d.ts.map +1 -1
  21. package/dist/platform/config/manager.js +15 -0
  22. package/dist/platform/control-plane/approval-broker.d.ts +1 -0
  23. package/dist/platform/control-plane/approval-broker.d.ts.map +1 -1
  24. package/dist/platform/control-plane/approval-broker.js +2 -0
  25. package/dist/platform/core/adaptive-planner.d.ts +50 -0
  26. package/dist/platform/core/adaptive-planner.d.ts.map +1 -1
  27. package/dist/platform/core/adaptive-planner.js +49 -0
  28. package/dist/platform/core/index.d.ts +1 -0
  29. package/dist/platform/core/index.d.ts.map +1 -1
  30. package/dist/platform/core/index.js +1 -0
  31. package/dist/platform/core/orchestrator-runtime.d.ts +11 -0
  32. package/dist/platform/core/orchestrator-runtime.d.ts.map +1 -1
  33. package/dist/platform/core/orchestrator-turn-loop.d.ts +28 -1
  34. package/dist/platform/core/orchestrator-turn-loop.d.ts.map +1 -1
  35. package/dist/platform/core/orchestrator-turn-loop.js +115 -3
  36. package/dist/platform/core/orchestrator.d.ts +61 -0
  37. package/dist/platform/core/orchestrator.d.ts.map +1 -1
  38. package/dist/platform/core/orchestrator.js +74 -1
  39. package/dist/platform/core/plan-proposal.d.ts +165 -0
  40. package/dist/platform/core/plan-proposal.d.ts.map +1 -0
  41. package/dist/platform/core/plan-proposal.js +296 -0
  42. package/dist/platform/knowledge/knowledge-api.d.ts +26 -0
  43. package/dist/platform/knowledge/knowledge-api.d.ts.map +1 -1
  44. package/dist/platform/knowledge/knowledge-api.js +11 -0
  45. package/dist/platform/orchestration/budget.d.ts +22 -0
  46. package/dist/platform/orchestration/budget.d.ts.map +1 -0
  47. package/dist/platform/orchestration/budget.js +35 -0
  48. package/dist/platform/orchestration/cancellation.d.ts +31 -0
  49. package/dist/platform/orchestration/cancellation.d.ts.map +1 -0
  50. package/dist/platform/orchestration/cancellation.js +25 -0
  51. package/dist/platform/orchestration/controller-compat.d.ts +32 -0
  52. package/dist/platform/orchestration/controller-compat.d.ts.map +1 -0
  53. package/dist/platform/orchestration/controller-compat.js +13 -0
  54. package/dist/platform/orchestration/dirty-guard.d.ts +32 -0
  55. package/dist/platform/orchestration/dirty-guard.d.ts.map +1 -0
  56. package/dist/platform/orchestration/dirty-guard.js +147 -0
  57. package/dist/platform/orchestration/engine.d.ts +57 -0
  58. package/dist/platform/orchestration/engine.d.ts.map +1 -0
  59. package/dist/platform/orchestration/engine.js +418 -0
  60. package/dist/platform/orchestration/index.d.ts +18 -0
  61. package/dist/platform/orchestration/index.d.ts.map +1 -0
  62. package/dist/platform/orchestration/index.js +10 -0
  63. package/dist/platform/orchestration/persistence.d.ts +24 -0
  64. package/dist/platform/orchestration/persistence.d.ts.map +1 -0
  65. package/dist/platform/orchestration/persistence.js +176 -0
  66. package/dist/platform/orchestration/phase-runner.d.ts +87 -0
  67. package/dist/platform/orchestration/phase-runner.d.ts.map +1 -0
  68. package/dist/platform/orchestration/phase-runner.js +274 -0
  69. package/dist/platform/orchestration/scheduler.d.ts +45 -0
  70. package/dist/platform/orchestration/scheduler.d.ts.map +1 -0
  71. package/dist/platform/orchestration/scheduler.js +57 -0
  72. package/dist/platform/orchestration/types.d.ts +250 -0
  73. package/dist/platform/orchestration/types.d.ts.map +1 -0
  74. package/dist/platform/orchestration/types.js +15 -0
  75. package/dist/platform/runtime/emitters/communication.d.ts +11 -0
  76. package/dist/platform/runtime/emitters/communication.d.ts.map +1 -1
  77. package/dist/platform/runtime/emitters/communication.js +9 -0
  78. package/dist/platform/runtime/feature-flags/flags.d.ts.map +1 -1
  79. package/dist/platform/runtime/feature-flags/flags.js +17 -0
  80. package/dist/platform/runtime/fleet/adapters/agent.d.ts +56 -0
  81. package/dist/platform/runtime/fleet/adapters/agent.d.ts.map +1 -0
  82. package/dist/platform/runtime/fleet/adapters/agent.js +143 -0
  83. package/dist/platform/runtime/fleet/adapters/automation.d.ts +30 -0
  84. package/dist/platform/runtime/fleet/adapters/automation.d.ts.map +1 -0
  85. package/dist/platform/runtime/fleet/adapters/automation.js +41 -0
  86. package/dist/platform/runtime/fleet/adapters/background-process.d.ts +12 -0
  87. package/dist/platform/runtime/fleet/adapters/background-process.d.ts.map +1 -0
  88. package/dist/platform/runtime/fleet/adapters/background-process.js +46 -0
  89. package/dist/platform/runtime/fleet/adapters/code-index.d.ts +21 -0
  90. package/dist/platform/runtime/fleet/adapters/code-index.d.ts.map +1 -0
  91. package/dist/platform/runtime/fleet/adapters/code-index.js +44 -0
  92. package/dist/platform/runtime/fleet/adapters/orchestration.d.ts +47 -0
  93. package/dist/platform/runtime/fleet/adapters/orchestration.d.ts.map +1 -0
  94. package/dist/platform/runtime/fleet/adapters/orchestration.js +225 -0
  95. package/dist/platform/runtime/fleet/adapters/schedule.d.ts +14 -0
  96. package/dist/platform/runtime/fleet/adapters/schedule.d.ts.map +1 -0
  97. package/dist/platform/runtime/fleet/adapters/schedule.js +28 -0
  98. package/dist/platform/runtime/fleet/adapters/trigger.d.ts +12 -0
  99. package/dist/platform/runtime/fleet/adapters/trigger.d.ts.map +1 -0
  100. package/dist/platform/runtime/fleet/adapters/trigger.js +22 -0
  101. package/dist/platform/runtime/fleet/adapters/watcher.d.ts +13 -0
  102. package/dist/platform/runtime/fleet/adapters/watcher.d.ts.map +1 -0
  103. package/dist/platform/runtime/fleet/adapters/watcher.js +47 -0
  104. package/dist/platform/runtime/fleet/adapters/workflow.d.ts +11 -0
  105. package/dist/platform/runtime/fleet/adapters/workflow.d.ts.map +1 -0
  106. package/dist/platform/runtime/fleet/adapters/workflow.js +34 -0
  107. package/dist/platform/runtime/fleet/adapters/wrfc.d.ts +22 -0
  108. package/dist/platform/runtime/fleet/adapters/wrfc.d.ts.map +1 -0
  109. package/dist/platform/runtime/fleet/adapters/wrfc.js +228 -0
  110. package/dist/platform/runtime/fleet/index.d.ts +10 -0
  111. package/dist/platform/runtime/fleet/index.d.ts.map +1 -0
  112. package/dist/platform/runtime/fleet/index.js +6 -0
  113. package/dist/platform/runtime/fleet/registry.d.ts +113 -0
  114. package/dist/platform/runtime/fleet/registry.d.ts.map +1 -0
  115. package/dist/platform/runtime/fleet/registry.js +630 -0
  116. package/dist/platform/runtime/fleet/types.d.ts +199 -0
  117. package/dist/platform/runtime/fleet/types.d.ts.map +1 -0
  118. package/dist/platform/runtime/fleet/types.js +2 -0
  119. package/dist/platform/runtime/runtime-knowledge-api.d.ts +1 -1
  120. package/dist/platform/runtime/runtime-knowledge-api.d.ts.map +1 -1
  121. package/dist/platform/runtime/runtime-knowledge-api.js +1 -0
  122. package/dist/platform/runtime/services.d.ts +46 -0
  123. package/dist/platform/runtime/services.d.ts.map +1 -1
  124. package/dist/platform/runtime/services.js +78 -0
  125. package/dist/platform/state/code-index-chunking.d.ts +49 -0
  126. package/dist/platform/state/code-index-chunking.d.ts.map +1 -0
  127. package/dist/platform/state/code-index-chunking.js +111 -0
  128. package/dist/platform/state/code-index-db.d.ts +53 -0
  129. package/dist/platform/state/code-index-db.d.ts.map +1 -0
  130. package/dist/platform/state/code-index-db.js +101 -0
  131. package/dist/platform/state/code-index-store.d.ts +175 -0
  132. package/dist/platform/state/code-index-store.d.ts.map +1 -0
  133. package/dist/platform/state/code-index-store.js +612 -0
  134. package/dist/platform/state/index.d.ts +4 -2
  135. package/dist/platform/state/index.d.ts.map +1 -1
  136. package/dist/platform/state/index.js +2 -1
  137. package/dist/platform/state/knowledge-injection.d.ts +23 -0
  138. package/dist/platform/state/knowledge-injection.d.ts.map +1 -1
  139. package/dist/platform/state/knowledge-injection.js +34 -19
  140. package/dist/platform/state/memory-vector-store.d.ts +1 -13
  141. package/dist/platform/state/memory-vector-store.d.ts.map +1 -1
  142. package/dist/platform/state/memory-vector-store.js +6 -38
  143. package/dist/platform/state/sqlite-vec-loader.d.ts +25 -0
  144. package/dist/platform/state/sqlite-vec-loader.d.ts.map +1 -0
  145. package/dist/platform/state/sqlite-vec-loader.js +43 -0
  146. package/dist/platform/tools/agent/manager.d.ts +128 -1
  147. package/dist/platform/tools/agent/manager.d.ts.map +1 -1
  148. package/dist/platform/tools/agent/manager.js +137 -1
  149. package/dist/platform/tools/exec/runtime.d.ts.map +1 -1
  150. package/dist/platform/tools/exec/runtime.js +139 -44
  151. package/dist/platform/tools/exec/schema.d.ts +2 -0
  152. package/dist/platform/tools/exec/schema.d.ts.map +1 -1
  153. package/dist/platform/tools/fetch/runtime.d.ts +7 -0
  154. package/dist/platform/tools/fetch/runtime.d.ts.map +1 -1
  155. package/dist/platform/tools/fetch/runtime.js +8 -6
  156. package/dist/platform/tools/registry.d.ts +9 -3
  157. package/dist/platform/tools/registry.d.ts.map +1 -1
  158. package/dist/platform/tools/registry.js +9 -3
  159. package/dist/platform/types/tools.d.ts +14 -1
  160. package/dist/platform/types/tools.d.ts.map +1 -1
  161. package/dist/platform/version.js +1 -1
  162. package/dist/platform/workspace/checkpoint/side-git.d.ts +20 -8
  163. package/dist/platform/workspace/checkpoint/side-git.d.ts.map +1 -1
  164. package/dist/platform/workspace/checkpoint/side-git.js +21 -11
  165. package/package.json +17 -9
@@ -0,0 +1,418 @@
1
+ /** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
2
+ /**
3
+ * OrchestrationEngine (Wave 4, wo701) — owns Workstream state and drives the
4
+ * pipeline. WRAPS a canned/authored workstream; does not rewrite or touch
5
+ * WrfcController (stage 1 of the 3-stage migration — see controller-compat.ts
6
+ * and the work-order report).
7
+ *
8
+ * The tick loop is reactive, not timer-driven: `start()` runs one tick;
9
+ * every phase-run completion re-runs tick() for its workstream. Because JS
10
+ * is single-threaded, computeClaims()'s synchronous read of in-flight counts
11
+ * is never racing a concurrent claim — items are marked 'in-phase'
12
+ * synchronously before any `await` inside the claim loop, so a re-entrant
13
+ * tick() (triggered by an earlier completion resolving on a later turn of
14
+ * the microtask queue) always sees up-to-date state.
15
+ */
16
+ import { checkBudget } from './budget.js';
17
+ import { createCancellationRegistry } from './cancellation.js';
18
+ import { runPhase } from './phase-runner.js';
19
+ import { snapshotDirtyTree } from './dirty-guard.js';
20
+ import { deserializeWorkstream as deserializeWorkstreamModel, deserializeWorkstreamSnapshot, attachDebouncedWriter, listSnapshotWorkstreamIds, loadWorkstreamSnapshot, serializeWorkstreamSnapshot, } from './persistence.js';
21
+ import { computeClaims, firstPhase, nextPhaseAfter, reviewPhaseBefore, sortedPhases } from './scheduler.js';
22
+ import { CURRENT_WORKSTREAM_SCHEMA_VERSION, emptyWorkItemUsage, } from './types.js';
23
+ import { logger } from '../utils/logger.js';
24
+ import { summarizeError } from '../utils/error-display.js';
25
+ function generateId(prefix) {
26
+ return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
27
+ }
28
+ export function createOrchestrationEngine(deps) {
29
+ const now = deps.now ?? (() => Date.now());
30
+ const sessionId = deps.sessionId ?? crypto.randomUUID().slice(0, 8);
31
+ const maxPhaseVisits = deps.maxPhaseVisits ?? 5;
32
+ const workstreams = new Map();
33
+ const completedResults = new Map();
34
+ const cancellation = createCancellationRegistry();
35
+ const listeners = new Set();
36
+ let disposed = false;
37
+ function emit(event) {
38
+ for (const listener of listeners) {
39
+ try {
40
+ listener(event);
41
+ }
42
+ catch (error) {
43
+ logger.warn('orchestration engine: listener threw', { error: summarizeError(error) });
44
+ }
45
+ }
46
+ }
47
+ function on(listener) {
48
+ listeners.add(listener);
49
+ return () => listeners.delete(listener);
50
+ }
51
+ // Dirty-residue guard (Wave 6, wo-F item 4): snapshot the working tree's
52
+ // dirty paths + content hashes ONCE, right at engine launch (synchronous —
53
+ // see dirty-guard.ts's doc comment for why this must not be a promise
54
+ // backed by real subprocess I/O), before any phase of this run has had a
55
+ // chance to touch anything. A previously killed run sharing this same
56
+ // projectRoot can leave uncommitted residue behind; this snapshot is what
57
+ // lets a later scoped commit tell "residue from before this run" apart
58
+ // from "this run's own changes" (see dirty-guard.ts and phase-runner.ts's
59
+ // commitPhaseWork).
60
+ const launchDirtySnapshot = snapshotDirtyTree(deps.projectRoot);
61
+ if (launchDirtySnapshot.size > 0) {
62
+ emit({ type: 'dirty-tree-at-launch', paths: [...launchDirtySnapshot.keys()] });
63
+ }
64
+ function getWorkstream(id) {
65
+ return workstreams.get(id) ?? null;
66
+ }
67
+ function listWorkstreams() {
68
+ return Array.from(workstreams.values());
69
+ }
70
+ function getPhaseResults(workstreamId) {
71
+ return completedResults.get(workstreamId) ?? [];
72
+ }
73
+ const unsubscribeWriter = deps.persist === false
74
+ ? () => undefined
75
+ : attachDebouncedWriter(deps.projectRoot, getWorkstream, getPhaseResults, on);
76
+ function buildPhase(spec, ordinal, insertedAt) {
77
+ return {
78
+ id: spec.id ?? generateId('phase'),
79
+ ordinal,
80
+ role: spec.role,
81
+ capacity: spec.capacity,
82
+ gate: spec.gate,
83
+ kind: spec.kind,
84
+ ...(insertedAt !== undefined ? { insertedAt } : {}),
85
+ };
86
+ }
87
+ function buildItem(spec, startPhaseId) {
88
+ return {
89
+ id: spec.id ?? generateId('item'),
90
+ title: spec.title,
91
+ task: spec.task,
92
+ currentPhaseId: startPhaseId,
93
+ state: startPhaseId ? 'pending' : 'passed',
94
+ allAgentIds: [],
95
+ visits: new Map(),
96
+ touchedPaths: [],
97
+ usage: emptyWorkItemUsage(),
98
+ transportRetryCount: 0,
99
+ createdAt: now(),
100
+ ...(startPhaseId ? {} : { completedAt: now() }),
101
+ };
102
+ }
103
+ function createWorkstream(input) {
104
+ const phases = input.phases.map((spec, index) => buildPhase(spec, index + 1));
105
+ const workstream = {
106
+ id: input.id ?? generateId('ws'),
107
+ title: input.title,
108
+ schemaVersion: CURRENT_WORKSTREAM_SCHEMA_VERSION,
109
+ phases,
110
+ items: [],
111
+ budget: input.budget,
112
+ createdAt: now(),
113
+ };
114
+ const first = firstPhase(workstream)?.id ?? null;
115
+ workstream.items = input.items.map((spec) => buildItem(spec, first));
116
+ workstreams.set(workstream.id, workstream);
117
+ completedResults.set(workstream.id, []);
118
+ return workstream;
119
+ }
120
+ function insertPhase(workstreamId, afterOrdinal, spec) {
121
+ const workstream = workstreams.get(workstreamId);
122
+ if (!workstream)
123
+ return null;
124
+ const following = sortedPhases(workstream).filter((p) => p.ordinal > afterOrdinal);
125
+ const nextOrdinal = following[0]?.ordinal ?? afterOrdinal + 1;
126
+ const ordinal = (afterOrdinal + nextOrdinal) / 2;
127
+ const phase = buildPhase(spec, ordinal, now());
128
+ workstream.phases.push(phase);
129
+ emit({ type: 'phase-inserted', workstreamId, phase });
130
+ return phase;
131
+ }
132
+ function findOrInsertFixPhase(workstream, reviewPhase) {
133
+ const existing = workstream.phases.find((p) => p.kind === 'fix' && reviewPhaseBefore(workstream, p)?.id === reviewPhase.id);
134
+ if (existing)
135
+ return existing;
136
+ return insertPhase(workstream.id, reviewPhase.ordinal, {
137
+ role: 'fixer',
138
+ capacity: reviewPhase.capacity,
139
+ gate: reviewPhase.gate,
140
+ kind: 'fix',
141
+ });
142
+ }
143
+ function visitsFor(item, phaseId) {
144
+ return item.visits.get(phaseId) ?? 0;
145
+ }
146
+ function recordVisit(item, phaseId) {
147
+ item.visits.set(phaseId, visitsFor(item, phaseId) + 1);
148
+ }
149
+ function routeItem(workstream, item, toPhase, fromPhaseId) {
150
+ if (toPhase) {
151
+ item.currentPhaseId = toPhase.id;
152
+ item.state = 'awaiting-capacity';
153
+ emit({ type: 'item-advanced', workstreamId: workstream.id, itemId: item.id, fromPhaseId, toPhaseId: toPhase.id });
154
+ }
155
+ else {
156
+ item.currentPhaseId = null;
157
+ item.state = 'passed';
158
+ item.completedAt = now();
159
+ emit({ type: 'item-passed', workstreamId: workstream.id, itemId: item.id });
160
+ }
161
+ }
162
+ function failItem(workstream, item, reason) {
163
+ item.state = 'failed';
164
+ item.completedAt = now();
165
+ item.failureReason = reason;
166
+ emit({ type: 'item-failed', workstreamId: workstream.id, itemId: item.id, reason });
167
+ }
168
+ /** Indirection to defeat TS's cross-await narrowing of item.state — see the call site below. */
169
+ function currentState(item) {
170
+ return item.state;
171
+ }
172
+ async function runItemPhase(workstream, item, phase) {
173
+ recordVisit(item, phase.id);
174
+ item.state = 'in-phase';
175
+ item.currentPhaseId = phase.id;
176
+ item.blockedReason = undefined;
177
+ const priorReports = getPhaseResults(workstream.id).filter((r) => r.itemId === item.id);
178
+ const outcome = await runPhase(workstream, item, phase, priorReports, {
179
+ agentManager: deps.agentManager,
180
+ configManager: deps.configManager,
181
+ runtimeBus: deps.runtimeBus,
182
+ projectRoot: deps.projectRoot,
183
+ sessionId,
184
+ createWorktree: deps.createWorktree,
185
+ cancellation,
186
+ priceUsage: deps.priceUsage,
187
+ skipClaimVerification: deps.skipClaimVerification,
188
+ launchDirtySnapshot,
189
+ });
190
+ item.usage = mergeUsageFromResult(item.usage, outcome.result.usage);
191
+ const results = completedResults.get(workstream.id) ?? [];
192
+ results.push(outcome.result);
193
+ completedResults.set(workstream.id, results);
194
+ emit({ type: 'workstream-persisted', workstreamId: workstream.id });
195
+ // kill() already transitioned the item to 'failed' synchronously before
196
+ // this promise settled — don't clobber that terminal state. Read through
197
+ // currentState() (not a direct `item.state` narrowing site) since TS's
198
+ // control-flow analysis otherwise "remembers" the 'in-phase' assignment
199
+ // above as if it still held after the `await`, even though kill() can
200
+ // mutate the same object concurrently while this call was in flight.
201
+ if (currentState(item) === 'failed')
202
+ return;
203
+ if (outcome.agentStatus === 'cancelled') {
204
+ failItem(workstream, item, 'cancelled by operator');
205
+ return;
206
+ }
207
+ if (outcome.agentStatus === 'failed') {
208
+ failItem(workstream, item, outcome.result.report.summary);
209
+ return;
210
+ }
211
+ if (outcome.result.gate.passed) {
212
+ // A fix phase's PASSING gate routes BACK to the review phase it was
213
+ // inserted after (dynamic insertion places 'fix' at an ordinal AFTER
214
+ // its review, per design (b) — "inserts a fix phase after review and
215
+ // re-routes that item back"), not forward past it. Every other kind
216
+ // advances to the next phase by ordinal as usual.
217
+ const forwardTarget = phase.kind === 'fix'
218
+ ? (reviewPhaseBefore(workstream, phase) ?? nextPhaseAfter(workstream, phase.ordinal) ?? null)
219
+ : (nextPhaseAfter(workstream, phase.ordinal) ?? null);
220
+ routeItem(workstream, item, forwardTarget, phase.id);
221
+ return;
222
+ }
223
+ // A review's FAILING gate with unsatisfied constraints gets one more
224
+ // cycle through a (found-or-inserted) fix phase, bounded by visits. A
225
+ // fix phase's own FAILING gate (the fixer didn't actually fix anything —
226
+ // phantom guard or quality gates caught it) is a genuine terminal
227
+ // failure, not something to retry-loop.
228
+ const unsatisfied = outcome.result.gate.unsatisfiedConstraintIds ?? [];
229
+ if (phase.kind === 'review' && unsatisfied.length > 0 && visitsFor(item, phase.id) < maxPhaseVisits) {
230
+ const fixPhase = findOrInsertFixPhase(workstream, phase);
231
+ routeItem(workstream, item, fixPhase, phase.id);
232
+ return;
233
+ }
234
+ failItem(workstream, item, outcome.result.gate.results.filter((r) => !r.passed).map((r) => `${r.gate}: ${r.output}`).join('; ') || 'gate failed');
235
+ }
236
+ function mergeUsageFromResult(current, added) {
237
+ const sawReasoning = current.reasoningTokens !== undefined || added.reasoningTokens !== undefined;
238
+ const bothPriced = current.costState === 'priced' && added.costState === 'priced';
239
+ const neitherPriced = current.costUsd === null && added.costUsd === null;
240
+ return {
241
+ inputTokens: current.inputTokens + added.inputTokens,
242
+ outputTokens: current.outputTokens + added.outputTokens,
243
+ cacheReadTokens: current.cacheReadTokens + added.cacheReadTokens,
244
+ cacheWriteTokens: current.cacheWriteTokens + added.cacheWriteTokens,
245
+ reasoningTokens: sawReasoning ? (current.reasoningTokens ?? 0) + (added.reasoningTokens ?? 0) : undefined,
246
+ llmCallCount: current.llmCallCount + added.llmCallCount,
247
+ turnCount: current.turnCount + added.turnCount,
248
+ toolCallCount: current.toolCallCount + added.toolCallCount,
249
+ costUsd: current.costUsd !== null && added.costUsd !== null ? current.costUsd + added.costUsd : (current.costUsd ?? added.costUsd),
250
+ costState: bothPriced ? 'priced' : neitherPriced ? 'unpriced' : 'estimated',
251
+ };
252
+ }
253
+ function tick(workstreamId) {
254
+ if (disposed)
255
+ return;
256
+ const workstream = workstreams.get(workstreamId);
257
+ if (!workstream)
258
+ return;
259
+ const claims = computeClaims(workstream);
260
+ for (const { item, phase } of claims) {
261
+ const budgetCheck = checkBudget(workstream);
262
+ if (!budgetCheck.allowed) {
263
+ // Re-checked on every tick (computeClaims keeps 'blocked-budget'
264
+ // items in the waiting set — see scheduler.ts) rather than a
265
+ // one-time transition: the reason can change between checks (e.g.
266
+ // token ceiling was the blocker, then a cost ceiling is reached
267
+ // too), so keep it current even though the event only fires once
268
+ // per NEW block to avoid spamming listeners on every idle tick.
269
+ const wasAlreadyBlocked = item.state === 'blocked-budget';
270
+ item.state = 'blocked-budget';
271
+ item.blockedReason = budgetCheck.reason;
272
+ if (!wasAlreadyBlocked) {
273
+ emit({ type: 'item-blocked-budget', workstreamId, itemId: item.id, phaseId: phase.id, reason: budgetCheck.reason ?? 'budget ceiling reached' });
274
+ }
275
+ continue;
276
+ }
277
+ void runItemPhase(workstream, item, phase)
278
+ .catch((error) => {
279
+ logger.error('orchestration engine: phase run threw', {
280
+ workstreamId, itemId: item.id, phaseId: phase.id, error: summarizeError(error),
281
+ });
282
+ failItem(workstream, item, summarizeError(error));
283
+ })
284
+ .finally(() => {
285
+ if (!disposed)
286
+ tick(workstreamId);
287
+ });
288
+ }
289
+ }
290
+ function start(workstreamId) {
291
+ tick(workstreamId);
292
+ }
293
+ function findItemAndWorkstream(itemId) {
294
+ for (const workstream of workstreams.values()) {
295
+ const item = workstream.items.find((i) => i.id === itemId);
296
+ if (item)
297
+ return { workstream, item };
298
+ }
299
+ return null;
300
+ }
301
+ function kill(itemId) {
302
+ const found = findItemAndWorkstream(itemId);
303
+ if (!found)
304
+ return false;
305
+ const { workstream, item } = found;
306
+ if (item.state === 'passed' || item.state === 'failed')
307
+ return false;
308
+ cancellation.abort(itemId);
309
+ if (item.agentId)
310
+ deps.agentManager.cancel(item.agentId, 'kill');
311
+ failItem(workstream, item, 'cancelled by operator');
312
+ emit({ type: 'item-cancelled', workstreamId: workstream.id, itemId, reason: 'cancelled by operator' });
313
+ return true;
314
+ }
315
+ function updateBudget(workstreamId, ceiling) {
316
+ const workstream = workstreams.get(workstreamId);
317
+ if (!workstream)
318
+ return false;
319
+ workstream.budget = ceiling;
320
+ tick(workstreamId);
321
+ return true;
322
+ }
323
+ function serializeWorkstream(workstreamId) {
324
+ const workstream = workstreams.get(workstreamId);
325
+ if (!workstream)
326
+ return null;
327
+ return serializeWorkstreamSnapshot(workstream, getPhaseResults(workstreamId));
328
+ }
329
+ function isTerminalWorkstream(workstream) {
330
+ return workstream.items.every((item) => item.state === 'passed' || item.state === 'failed');
331
+ }
332
+ /**
333
+ * An item persisted as 'in-phase' was mid-run when the snapshot was
334
+ * written (the debounced writer, persistence.ts, captures state
335
+ * synchronously — see runItemPhase — so this is always a crash artifact,
336
+ * never a live agent this process could still be waiting on). Left
337
+ * verbatim, it would count as an OCCUPIED capacity slot forever
338
+ * (computeClaims, scheduler.ts) while never being in the re-claimable
339
+ * waiting set, permanently starving every sibling in the same phase.
340
+ * Requeue it as 'pending' and drop the stale agentId so the next tick()
341
+ * reclaims it like any other waiting item — its prior PhaseResult (if any)
342
+ * was only ever pushed AFTER phase completion (see runItemPhase), so
343
+ * re-running the phase from here cannot produce a duplicate result.
344
+ */
345
+ function reconcileImportedItems(workstream) {
346
+ for (const item of workstream.items) {
347
+ if (item.state !== 'in-phase')
348
+ continue;
349
+ item.state = 'pending';
350
+ item.agentId = undefined;
351
+ emit({
352
+ type: 'item-requeued',
353
+ workstreamId: workstream.id,
354
+ itemId: item.id,
355
+ reason: 're-queued after restart — was in-phase when the snapshot was written',
356
+ });
357
+ }
358
+ }
359
+ function importWorkstream(snapshotJson, force = false) {
360
+ const snapshot = deserializeWorkstreamSnapshot(snapshotJson);
361
+ if (!snapshot)
362
+ return false;
363
+ const workstream = deserializeWorkstreamModel(snapshot.workstream);
364
+ const existing = workstreams.get(workstream.id);
365
+ if (existing && !isTerminalWorkstream(existing) && !force) {
366
+ logger.warn('orchestration engine: importWorkstream refused — existing workstream is non-terminal; use force=true to overwrite', {
367
+ workstreamId: workstream.id,
368
+ });
369
+ return false;
370
+ }
371
+ reconcileImportedItems(workstream);
372
+ workstreams.set(workstream.id, workstream);
373
+ completedResults.set(workstream.id, [...snapshot.completedResults]);
374
+ return true;
375
+ }
376
+ function resumeWorkstream(workstreamId) {
377
+ if (!workstreams.has(workstreamId)) {
378
+ const snapshot = loadWorkstreamSnapshot(deps.projectRoot, workstreamId);
379
+ if (!snapshot)
380
+ return false;
381
+ if (!importWorkstream(JSON.stringify(snapshot)))
382
+ return false;
383
+ }
384
+ tick(workstreamId);
385
+ return true;
386
+ }
387
+ function resumeAllFromDisk() {
388
+ let count = 0;
389
+ for (const workstreamId of listSnapshotWorkstreamIds(deps.projectRoot)) {
390
+ if (resumeWorkstream(workstreamId))
391
+ count += 1;
392
+ }
393
+ return count;
394
+ }
395
+ function dispose() {
396
+ if (disposed)
397
+ return;
398
+ disposed = true;
399
+ unsubscribeWriter();
400
+ listeners.clear();
401
+ }
402
+ return {
403
+ createWorkstream,
404
+ getWorkstream,
405
+ listWorkstreams,
406
+ insertPhase,
407
+ start,
408
+ kill,
409
+ updateBudget,
410
+ getPhaseResults,
411
+ serializeWorkstream,
412
+ importWorkstream,
413
+ resumeWorkstream,
414
+ resumeAllFromDisk,
415
+ on,
416
+ dispose,
417
+ };
418
+ }
@@ -0,0 +1,18 @@
1
+ /** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
2
+ export type { BudgetCeiling, CommitExclusion, GateOutcome, OrchestrationEvent, OrchestrationEventListener, Phase, PhaseGateSpec, PhaseKind, PhaseResult, PhaseRole, PhaseSpec, SerializedWorkItem, SerializedWorkstream, WorkItem, WorkItemSpec, WorkItemState, WorkItemUsage, Workstream, WorkstreamSnapshot, } from './types.js';
3
+ export { CURRENT_WORKSTREAM_SCHEMA_VERSION, emptyWorkItemUsage } from './types.js';
4
+ export type { CreateWorkstreamInput, OrchestrationEngine, OrchestrationEngineDeps } from './engine.js';
5
+ export { createOrchestrationEngine } from './engine.js';
6
+ export type { CancellationRegistry } from './cancellation.js';
7
+ export { createCancellationRegistry } from './cancellation.js';
8
+ export type { BudgetCheck } from './budget.js';
9
+ export { checkBudget } from './budget.js';
10
+ export { deserializeWorkstream, deserializeWorkstreamSnapshot, listSnapshotWorkstreamIds, loadWorkstreamSnapshot, serializeWorkstream, serializeWorkstreamSnapshot, writeWorkstreamSnapshot, } from './persistence.js';
11
+ export { fromChainSpec } from './controller-compat.js';
12
+ export type { PhaseRunnerAgentManagerLike, PhaseRunnerDeps, PhaseRunOutcome, WrfcWorktreeOps } from './phase-runner.js';
13
+ export { runPhase } from './phase-runner.js';
14
+ export type { DirtyLaunchSnapshot, ScopedCommitExclusion } from './dirty-guard.js';
15
+ export { excludeUntouchedLaunchResidue, hashWorkingTreeFile, snapshotDirtyTree } from './dirty-guard.js';
16
+ export { computeClaims, firstPhase, nextPhaseAfter, phaseById, reviewPhaseBefore, sortedPhases, } from './scheduler.js';
17
+ export type { PhaseClaim } from './scheduler.js';
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/platform/orchestration/index.ts"],"names":[],"mappings":"AAAA,qFAAqF;AAMrF,YAAY,EACV,aAAa,EACb,eAAe,EACf,WAAW,EACX,kBAAkB,EAClB,0BAA0B,EAC1B,KAAK,EACL,aAAa,EACb,SAAS,EACT,WAAW,EACX,SAAS,EACT,SAAS,EACT,kBAAkB,EAClB,oBAAoB,EACpB,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,aAAa,EACb,UAAU,EACV,kBAAkB,GACnB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,iCAAiC,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAEnF,YAAY,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AACvG,OAAO,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAExD,YAAY,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC9D,OAAO,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAC;AAE/D,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE1C,OAAO,EACL,qBAAqB,EACrB,6BAA6B,EAC7B,yBAAyB,EACzB,sBAAsB,EACtB,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,GACxB,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAEvD,YAAY,EAAE,2BAA2B,EAAE,eAAe,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACxH,OAAO,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AAE7C,YAAY,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AACnF,OAAO,EAAE,6BAA6B,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAEzG,OAAO,EACL,aAAa,EACb,UAAU,EACV,cAAc,EACd,SAAS,EACT,iBAAiB,EACjB,YAAY,GACb,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC"}
@@ -0,0 +1,10 @@
1
+ /** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
2
+ export { CURRENT_WORKSTREAM_SCHEMA_VERSION, emptyWorkItemUsage } from './types.js';
3
+ export { createOrchestrationEngine } from './engine.js';
4
+ export { createCancellationRegistry } from './cancellation.js';
5
+ export { checkBudget } from './budget.js';
6
+ export { deserializeWorkstream, deserializeWorkstreamSnapshot, listSnapshotWorkstreamIds, loadWorkstreamSnapshot, serializeWorkstream, serializeWorkstreamSnapshot, writeWorkstreamSnapshot, } from './persistence.js';
7
+ export { fromChainSpec } from './controller-compat.js';
8
+ export { runPhase } from './phase-runner.js';
9
+ export { excludeUntouchedLaunchResidue, hashWorkingTreeFile, snapshotDirtyTree } from './dirty-guard.js';
10
+ export { computeClaims, firstPhase, nextPhaseAfter, phaseById, reviewPhaseBefore, sortedPhases, } from './scheduler.js';
@@ -0,0 +1,24 @@
1
+ /** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
2
+ import { type OrchestrationEvent, type PhaseResult, type SerializedWorkstream, type Workstream, type WorkstreamSnapshot } from './types.js';
3
+ export declare function serializeWorkstream(workstream: Workstream): SerializedWorkstream;
4
+ export declare function deserializeWorkstream(serialized: SerializedWorkstream): Workstream;
5
+ /** Mirrors WrfcController.serializeChain: JSON.stringify a schema-versioned envelope. Returns null on serialization failure rather than throwing. */
6
+ export declare function serializeWorkstreamSnapshot(workstream: Workstream, completedResults: readonly PhaseResult[]): string | null;
7
+ /**
8
+ * Mirrors WrfcController.deserializeChain's future-schemaVersion-reject
9
+ * guard: a snapshot written by a newer runtime is rejected (fail closed)
10
+ * rather than partially trusted.
11
+ */
12
+ export declare function deserializeWorkstreamSnapshot(json: string): WorkstreamSnapshot | null;
13
+ /** Read + quarantine-on-corrupt (never throws, never crashes the caller on a bad file). */
14
+ export declare function loadWorkstreamSnapshot(projectRoot: string, workstreamId: string): WorkstreamSnapshot | null;
15
+ /** List the workstream ids with a snapshot on disk (recognized or not — callers decide via loadWorkstreamSnapshot). */
16
+ export declare function listSnapshotWorkstreamIds(projectRoot: string): string[];
17
+ export declare function writeWorkstreamSnapshot(projectRoot: string, workstream: Workstream, completedResults: readonly PhaseResult[]): void;
18
+ /**
19
+ * Debounced trailing writer (250ms, exactly like wrfc-persistence.ts
20
+ * DEBOUNCE_MS), subscribing to engine lifecycle events. Returns an
21
+ * unsubscribe function that also flushes any pending timers.
22
+ */
23
+ export declare function attachDebouncedWriter(projectRoot: string, getWorkstream: (workstreamId: string) => Workstream | null, getCompletedResults: (workstreamId: string) => readonly PhaseResult[], subscribe: (listener: (event: OrchestrationEvent) => void) => () => void): () => void;
24
+ //# sourceMappingURL=persistence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../../src/platform/orchestration/persistence.ts"],"names":[],"mappings":"AAAA,qFAAqF;AAerF,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,WAAW,EAEhB,KAAK,oBAAoB,EAEzB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACxB,MAAM,YAAY,CAAC;AAYpB,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,UAAU,GAAG,oBAAoB,CAEhF;AAED,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,oBAAoB,GAAG,UAAU,CAElF;AAED,qJAAqJ;AACrJ,wBAAgB,2BAA2B,CAAC,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,SAAS,WAAW,EAAE,GAAG,MAAM,GAAG,IAAI,CAa3H;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI,CA6BrF;AAUD,2FAA2F;AAC3F,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,kBAAkB,GAAG,IAAI,CAsB3G;AAED,uHAAuH;AACvH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAWvE;AAED,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,SAAS,WAAW,EAAE,GAAG,IAAI,CAUnI;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,EAC1D,mBAAmB,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,SAAS,WAAW,EAAE,EACrE,SAAS,EAAE,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,KAAK,MAAM,IAAI,GACvE,MAAM,IAAI,CA4BZ"}
@@ -0,0 +1,176 @@
1
+ /** SDK-owned platform module. This implementation is maintained in goodvibes-sdk. */
2
+ /**
3
+ * Persistence (Wave 4, wo701) — mirrors the WrfcController chain seams
4
+ * exactly: serializeChain:323 / deserializeChain:345 (including the
5
+ * future-schemaVersion-reject guard at :364) / importChain:402. Writes to
6
+ * `.goodvibes/orchestration/<workstreamId>.json` — SEPARATE from the TUI's
7
+ * `.goodvibes/tui/wrfc-chains.json` (src/runtime/wrfc-persistence.ts), no
8
+ * path collision. Debounce (250ms) and corrupt-snapshot quarantine
9
+ * (`<path>.unrecognized`) mirror that same TUI module's conventions.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { logger } from '../utils/logger.js';
14
+ import { summarizeError } from '../utils/error-display.js';
15
+ import { CURRENT_WORKSTREAM_SCHEMA_VERSION, } from './types.js';
16
+ const DEBOUNCE_MS = 250;
17
+ function serializeWorkItem(item) {
18
+ return { ...item, visits: Object.fromEntries(item.visits) };
19
+ }
20
+ function deserializeWorkItem(raw) {
21
+ return { ...raw, visits: new Map(Object.entries(raw.visits)) };
22
+ }
23
+ export function serializeWorkstream(workstream) {
24
+ return { ...workstream, items: workstream.items.map(serializeWorkItem) };
25
+ }
26
+ export function deserializeWorkstream(serialized) {
27
+ return { ...serialized, items: serialized.items.map(deserializeWorkItem) };
28
+ }
29
+ /** Mirrors WrfcController.serializeChain: JSON.stringify a schema-versioned envelope. Returns null on serialization failure rather than throwing. */
30
+ export function serializeWorkstreamSnapshot(workstream, completedResults) {
31
+ const snapshot = {
32
+ schemaVersion: CURRENT_WORKSTREAM_SCHEMA_VERSION,
33
+ writtenAt: Date.now(),
34
+ workstream: serializeWorkstream(workstream),
35
+ completedResults,
36
+ };
37
+ try {
38
+ return JSON.stringify(snapshot);
39
+ }
40
+ catch (error) {
41
+ logger.error('orchestration persistence: JSON serialization failed', { workstreamId: workstream.id, error: summarizeError(error) });
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Mirrors WrfcController.deserializeChain's future-schemaVersion-reject
47
+ * guard: a snapshot written by a newer runtime is rejected (fail closed)
48
+ * rather than partially trusted.
49
+ */
50
+ export function deserializeWorkstreamSnapshot(json) {
51
+ let raw;
52
+ try {
53
+ raw = JSON.parse(json);
54
+ }
55
+ catch (error) {
56
+ logger.error('orchestration persistence: JSON parse failed', { error: summarizeError(error) });
57
+ return null;
58
+ }
59
+ if (raw === null || typeof raw !== 'object') {
60
+ logger.warn('orchestration persistence: invalid snapshot JSON — not an object');
61
+ return null;
62
+ }
63
+ const candidate = raw;
64
+ if (typeof candidate.schemaVersion !== 'number') {
65
+ logger.warn('orchestration persistence: invalid snapshot JSON — missing schemaVersion');
66
+ return null;
67
+ }
68
+ if (candidate.schemaVersion > CURRENT_WORKSTREAM_SCHEMA_VERSION) {
69
+ logger.error('orchestration persistence: future schemaVersion rejected — upgrade runtime to read this snapshot', {
70
+ schemaVersion: candidate.schemaVersion,
71
+ supportedVersion: CURRENT_WORKSTREAM_SCHEMA_VERSION,
72
+ });
73
+ return null;
74
+ }
75
+ if (!candidate.workstream || typeof candidate.workstream !== 'object' || !Array.isArray(candidate.completedResults)) {
76
+ logger.warn('orchestration persistence: invalid snapshot JSON — missing workstream/completedResults');
77
+ return null;
78
+ }
79
+ return candidate;
80
+ }
81
+ function orchestrationDir(projectRoot) {
82
+ return join(projectRoot, '.goodvibes', 'orchestration');
83
+ }
84
+ function snapshotPath(projectRoot, workstreamId) {
85
+ return join(orchestrationDir(projectRoot), `${workstreamId}.json`);
86
+ }
87
+ /** Read + quarantine-on-corrupt (never throws, never crashes the caller on a bad file). */
88
+ export function loadWorkstreamSnapshot(projectRoot, workstreamId) {
89
+ const path = snapshotPath(projectRoot, workstreamId);
90
+ if (!existsSync(path))
91
+ return null;
92
+ let text;
93
+ try {
94
+ text = readFileSync(path, 'utf-8');
95
+ }
96
+ catch (error) {
97
+ logger.warn('orchestration persistence: snapshot read failed', { path, error: summarizeError(error) });
98
+ return null;
99
+ }
100
+ const snapshot = deserializeWorkstreamSnapshot(text);
101
+ if (snapshot === null) {
102
+ const quarantinePath = `${path}.unrecognized`;
103
+ try {
104
+ renameSync(path, quarantinePath);
105
+ logger.warn('orchestration persistence: quarantined unrecognized snapshot', { path, quarantinePath });
106
+ }
107
+ catch (error) {
108
+ logger.error('orchestration persistence: failed to quarantine unrecognized snapshot', { path, error: summarizeError(error) });
109
+ }
110
+ return null;
111
+ }
112
+ return snapshot;
113
+ }
114
+ /** List the workstream ids with a snapshot on disk (recognized or not — callers decide via loadWorkstreamSnapshot). */
115
+ export function listSnapshotWorkstreamIds(projectRoot) {
116
+ const dir = orchestrationDir(projectRoot);
117
+ if (!existsSync(dir))
118
+ return [];
119
+ try {
120
+ return readdirSync(dir)
121
+ .filter((entry) => entry.endsWith('.json'))
122
+ .map((entry) => entry.slice(0, -'.json'.length));
123
+ }
124
+ catch (error) {
125
+ logger.warn('orchestration persistence: failed to list snapshot directory', { dir, error: summarizeError(error) });
126
+ return [];
127
+ }
128
+ }
129
+ export function writeWorkstreamSnapshot(projectRoot, workstream, completedResults) {
130
+ const json = serializeWorkstreamSnapshot(workstream, completedResults);
131
+ if (json === null)
132
+ return;
133
+ const path = snapshotPath(projectRoot, workstream.id);
134
+ try {
135
+ mkdirSync(orchestrationDir(projectRoot), { recursive: true });
136
+ writeFileSync(path, json, 'utf-8');
137
+ }
138
+ catch (error) {
139
+ logger.error('orchestration persistence: snapshot write failed', { path, error: summarizeError(error) });
140
+ }
141
+ }
142
+ /**
143
+ * Debounced trailing writer (250ms, exactly like wrfc-persistence.ts
144
+ * DEBOUNCE_MS), subscribing to engine lifecycle events. Returns an
145
+ * unsubscribe function that also flushes any pending timers.
146
+ */
147
+ export function attachDebouncedWriter(projectRoot, getWorkstream, getCompletedResults, subscribe) {
148
+ const timers = new Map();
149
+ function scheduleWrite(workstreamId) {
150
+ const existing = timers.get(workstreamId);
151
+ if (existing)
152
+ clearTimeout(existing);
153
+ const timer = setTimeout(() => {
154
+ timers.delete(workstreamId);
155
+ const workstream = getWorkstream(workstreamId);
156
+ if (!workstream)
157
+ return;
158
+ writeWorkstreamSnapshot(projectRoot, workstream, getCompletedResults(workstreamId));
159
+ }, DEBOUNCE_MS);
160
+ timer.unref?.();
161
+ timers.set(workstreamId, timer);
162
+ }
163
+ const unsubscribe = subscribe((event) => {
164
+ // 'dirty-tree-at-launch' (Wave 6, wo-F item 4) is engine-wide, not
165
+ // workstream-scoped — it has no workstreamId to schedule a write for.
166
+ if (event.type === 'dirty-tree-at-launch')
167
+ return;
168
+ scheduleWrite(event.workstreamId);
169
+ });
170
+ return () => {
171
+ unsubscribe();
172
+ for (const timer of timers.values())
173
+ clearTimeout(timer);
174
+ timers.clear();
175
+ };
176
+ }