@lota-sdk/core 0.1.5

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 (153) hide show
  1. package/infrastructure/schema/00_workstream.surql +55 -0
  2. package/infrastructure/schema/01_memory.surql +47 -0
  3. package/infrastructure/schema/02_execution_plan.surql +62 -0
  4. package/infrastructure/schema/03_learned_skill.surql +32 -0
  5. package/infrastructure/schema/04_runtime_bootstrap.surql +8 -0
  6. package/package.json +128 -0
  7. package/src/ai/definitions.ts +308 -0
  8. package/src/bifrost/bifrost.ts +256 -0
  9. package/src/config/agent-defaults.ts +99 -0
  10. package/src/config/constants.ts +33 -0
  11. package/src/config/env-shapes.ts +122 -0
  12. package/src/config/logger.ts +29 -0
  13. package/src/config/model-constants.ts +31 -0
  14. package/src/config/search.ts +17 -0
  15. package/src/config/workstream-defaults.ts +68 -0
  16. package/src/db/base.service.ts +55 -0
  17. package/src/db/cursor-pagination.ts +73 -0
  18. package/src/db/memory-query-builder.ts +207 -0
  19. package/src/db/memory-store.helpers.ts +118 -0
  20. package/src/db/memory-store.rows.ts +29 -0
  21. package/src/db/memory-store.ts +974 -0
  22. package/src/db/memory-types.ts +193 -0
  23. package/src/db/memory.ts +505 -0
  24. package/src/db/record-id.ts +78 -0
  25. package/src/db/service.ts +932 -0
  26. package/src/db/startup.ts +152 -0
  27. package/src/db/tables.ts +20 -0
  28. package/src/document/org-document-chunking.ts +224 -0
  29. package/src/document/parsing.ts +40 -0
  30. package/src/embeddings/provider.ts +76 -0
  31. package/src/index.ts +302 -0
  32. package/src/queues/context-compaction.queue.ts +82 -0
  33. package/src/queues/document-processor.queue.ts +118 -0
  34. package/src/queues/memory-consolidation.queue.ts +65 -0
  35. package/src/queues/post-chat-memory.queue.ts +128 -0
  36. package/src/queues/recent-activity-title-refinement.queue.ts +69 -0
  37. package/src/queues/regular-chat-memory-digest.config.ts +12 -0
  38. package/src/queues/regular-chat-memory-digest.queue.ts +73 -0
  39. package/src/queues/skill-extraction.config.ts +9 -0
  40. package/src/queues/skill-extraction.queue.ts +62 -0
  41. package/src/redis/connection.ts +176 -0
  42. package/src/redis/index.ts +30 -0
  43. package/src/redis/org-memory-lock.ts +43 -0
  44. package/src/redis/redis-lease-lock.ts +158 -0
  45. package/src/runtime/agent-contract.ts +1 -0
  46. package/src/runtime/agent-prompt-context.ts +119 -0
  47. package/src/runtime/agent-runtime-policy.ts +192 -0
  48. package/src/runtime/agent-stream-helpers.ts +117 -0
  49. package/src/runtime/agent-types.ts +22 -0
  50. package/src/runtime/approval-continuation.ts +16 -0
  51. package/src/runtime/chat-attachments.ts +46 -0
  52. package/src/runtime/chat-message.ts +10 -0
  53. package/src/runtime/chat-request-routing.ts +21 -0
  54. package/src/runtime/chat-run-orchestration.ts +25 -0
  55. package/src/runtime/chat-run-registry.ts +20 -0
  56. package/src/runtime/chat-types.ts +18 -0
  57. package/src/runtime/context-compaction-constants.ts +11 -0
  58. package/src/runtime/context-compaction-runtime.ts +86 -0
  59. package/src/runtime/context-compaction.ts +909 -0
  60. package/src/runtime/execution-plan.ts +59 -0
  61. package/src/runtime/helper-model.ts +405 -0
  62. package/src/runtime/indexed-repositories-policy.ts +28 -0
  63. package/src/runtime/instruction-sections.ts +8 -0
  64. package/src/runtime/llm-content.ts +71 -0
  65. package/src/runtime/memory-block.ts +264 -0
  66. package/src/runtime/memory-digest-policy.ts +14 -0
  67. package/src/runtime/memory-format.ts +8 -0
  68. package/src/runtime/memory-pipeline.ts +570 -0
  69. package/src/runtime/memory-prompts-fact.ts +47 -0
  70. package/src/runtime/memory-prompts-parse.ts +3 -0
  71. package/src/runtime/memory-prompts-update.ts +37 -0
  72. package/src/runtime/memory-scope.ts +43 -0
  73. package/src/runtime/plugin-types.ts +10 -0
  74. package/src/runtime/retrieval-adapters.ts +25 -0
  75. package/src/runtime/retrieval-pipeline.ts +3 -0
  76. package/src/runtime/runtime-extensions.ts +154 -0
  77. package/src/runtime/skill-extraction-policy.ts +3 -0
  78. package/src/runtime/team-consultation-orchestrator.ts +245 -0
  79. package/src/runtime/team-consultation-prompts.ts +32 -0
  80. package/src/runtime/title-helpers.ts +12 -0
  81. package/src/runtime/turn-lifecycle.ts +28 -0
  82. package/src/runtime/workstream-chat-helpers.ts +187 -0
  83. package/src/runtime/workstream-routing-policy.ts +301 -0
  84. package/src/runtime/workstream-state.ts +261 -0
  85. package/src/services/attachment.service.ts +159 -0
  86. package/src/services/chat-attachments.service.ts +17 -0
  87. package/src/services/chat-run-registry.service.ts +3 -0
  88. package/src/services/context-compaction-runtime.ts +13 -0
  89. package/src/services/context-compaction.service.ts +115 -0
  90. package/src/services/document-chunk.service.ts +141 -0
  91. package/src/services/execution-plan.service.ts +890 -0
  92. package/src/services/learned-skill.service.ts +328 -0
  93. package/src/services/memory-assessment.service.ts +43 -0
  94. package/src/services/memory.service.ts +807 -0
  95. package/src/services/memory.utils.ts +84 -0
  96. package/src/services/mutating-approval.service.ts +110 -0
  97. package/src/services/recent-activity-title.service.ts +74 -0
  98. package/src/services/recent-activity.service.ts +397 -0
  99. package/src/services/workstream-change-tracker.service.ts +313 -0
  100. package/src/services/workstream-message.service.ts +283 -0
  101. package/src/services/workstream-title.service.ts +58 -0
  102. package/src/services/workstream-turn-preparation.ts +1340 -0
  103. package/src/services/workstream-turn.ts +37 -0
  104. package/src/services/workstream.service.ts +854 -0
  105. package/src/services/workstream.types.ts +118 -0
  106. package/src/storage/attachment-parser.ts +101 -0
  107. package/src/storage/attachment-storage.service.ts +391 -0
  108. package/src/storage/attachments.types.ts +11 -0
  109. package/src/storage/attachments.utils.ts +58 -0
  110. package/src/storage/generated-document-storage.service.ts +55 -0
  111. package/src/system-agents/agent-result.ts +27 -0
  112. package/src/system-agents/context-compacter.agent.ts +46 -0
  113. package/src/system-agents/delegated-agent-factory.ts +177 -0
  114. package/src/system-agents/helper-agent-options.ts +20 -0
  115. package/src/system-agents/memory-reranker.agent.ts +38 -0
  116. package/src/system-agents/memory.agent.ts +58 -0
  117. package/src/system-agents/recent-activity-title-refiner.agent.ts +53 -0
  118. package/src/system-agents/regular-chat-memory-digest.agent.ts +75 -0
  119. package/src/system-agents/researcher.agent.ts +34 -0
  120. package/src/system-agents/skill-extractor.agent.ts +88 -0
  121. package/src/system-agents/skill-manager.agent.ts +80 -0
  122. package/src/system-agents/title-generator.agent.ts +42 -0
  123. package/src/system-agents/workstream-tracker.agent.ts +58 -0
  124. package/src/tools/execution-plan.tool.ts +163 -0
  125. package/src/tools/fetch-webpage.tool.ts +132 -0
  126. package/src/tools/firecrawl-client.ts +12 -0
  127. package/src/tools/memory-block.tool.ts +55 -0
  128. package/src/tools/read-file-parts.tool.ts +80 -0
  129. package/src/tools/remember-memory.tool.ts +85 -0
  130. package/src/tools/research-topic.tool.ts +15 -0
  131. package/src/tools/search-tools.ts +55 -0
  132. package/src/tools/search-web.tool.ts +175 -0
  133. package/src/tools/team-think.tool.ts +125 -0
  134. package/src/tools/tool-contract.ts +21 -0
  135. package/src/tools/user-questions.tool.ts +18 -0
  136. package/src/utils/async.ts +50 -0
  137. package/src/utils/date-time.ts +34 -0
  138. package/src/utils/error.ts +10 -0
  139. package/src/utils/errors.ts +28 -0
  140. package/src/utils/hono-error-handler.ts +71 -0
  141. package/src/utils/string.ts +51 -0
  142. package/src/workers/bootstrap.ts +44 -0
  143. package/src/workers/memory-consolidation.worker.ts +318 -0
  144. package/src/workers/regular-chat-memory-digest.helpers.ts +100 -0
  145. package/src/workers/regular-chat-memory-digest.runner.ts +363 -0
  146. package/src/workers/regular-chat-memory-digest.worker.ts +22 -0
  147. package/src/workers/skill-extraction.runner.ts +331 -0
  148. package/src/workers/skill-extraction.worker.ts +22 -0
  149. package/src/workers/utils/repo-indexer-chunker.ts +331 -0
  150. package/src/workers/utils/repo-structure-extractor.ts +645 -0
  151. package/src/workers/utils/repomix-process-concurrency.ts +65 -0
  152. package/src/workers/utils/sandbox-error.ts +5 -0
  153. package/src/workers/worker-utils.ts +182 -0
@@ -0,0 +1,890 @@
1
+ import {
2
+ ExecutionPlanEventSchema,
3
+ ExecutionPlanSchema,
4
+ ExecutionPlanTaskResultStatusSchema,
5
+ ExecutionPlanTaskSchema,
6
+ } from '@lota-sdk/shared/schemas/execution-plan'
7
+ import type {
8
+ ExecutionPlanEventRecord,
9
+ ExecutionPlanRecord,
10
+ ExecutionPlanStatus,
11
+ ExecutionPlanTaskRecord,
12
+ ExecutionPlanTaskResultStatus,
13
+ ExecutionPlanTaskStatus,
14
+ SerializableExecutionPlan,
15
+ SerializableExecutionPlanCarriedTask,
16
+ SerializableExecutionPlanEvent,
17
+ SerializableExecutionPlanTask,
18
+ } from '@lota-sdk/shared/schemas/execution-plan'
19
+ import type {
20
+ CreateExecutionPlanArgs,
21
+ ExecutionPlanToolResultData,
22
+ ReplaceExecutionPlanArgs,
23
+ SetExecutionTaskStatusArgs,
24
+ RestartExecutionTaskArgs,
25
+ } from '@lota-sdk/shared/schemas/tools'
26
+ import { BoundQuery, RecordId } from 'surrealdb'
27
+
28
+ import type { RecordIdInput, RecordIdRef } from '../db/record-id'
29
+ import { ensureRecordId, recordIdToString } from '../db/record-id'
30
+ import { databaseService } from '../db/service'
31
+ import { TABLES } from '../db/tables'
32
+ import { toIsoDateTimeString, toOptionalIsoDateTimeString } from '../utils/date-time'
33
+
34
+ const ACTIVE_PLAN_STATUSES: ExecutionPlanStatus[] = ['draft', 'executing', 'blocked']
35
+ const ACTIVE_PLAN_STATUS_SET = new Set<ExecutionPlanStatus>(ACTIVE_PLAN_STATUSES)
36
+ const SUCCESSFUL_RESULT_STATUS_SET = new Set<ExecutionPlanTaskResultStatus>(['success', 'partial'])
37
+ const CARRIED_RESULT_STATUS_SET = new Set<ExecutionPlanTaskResultStatus>(['success', 'partial'])
38
+ const RESTART_REQUIRED_TASK_STATUS_SET = new Set<ExecutionPlanTaskStatus>(['completed', 'failed', 'skipped'])
39
+ const TASK_STATUS_TRANSITIONS: Record<ExecutionPlanTaskStatus, ReadonlySet<ExecutionPlanTaskStatus>> = {
40
+ pending: new Set<ExecutionPlanTaskStatus>(['in-progress', 'blocked', 'skipped']),
41
+ 'in-progress': new Set<ExecutionPlanTaskStatus>(['completed', 'blocked', 'failed', 'skipped']),
42
+ blocked: new Set<ExecutionPlanTaskStatus>(['in-progress', 'completed', 'failed', 'skipped']),
43
+ completed: new Set<ExecutionPlanTaskStatus>(),
44
+ failed: new Set<ExecutionPlanTaskStatus>(),
45
+ skipped: new Set<ExecutionPlanTaskStatus>(),
46
+ }
47
+
48
+ function normalizeTaskInput(
49
+ input: CreateExecutionPlanArgs['tasks'][number]['input'],
50
+ ): string | Record<string, unknown> | undefined {
51
+ if (input === undefined) return undefined
52
+ return typeof input === 'string' ? input : structuredClone(input)
53
+ }
54
+
55
+ function isExecutionPlanRecord(value: RecordIdInput | ExecutionPlanRecord): value is ExecutionPlanRecord {
56
+ return typeof value === 'object' && 'workstreamId' in value
57
+ }
58
+
59
+ function isExecutionPlanTaskRecord(value: RecordIdInput | ExecutionPlanTaskRecord): value is ExecutionPlanTaskRecord {
60
+ return typeof value === 'object' && 'position' in value
61
+ }
62
+
63
+ function toPlanId(planId: RecordIdInput | ExecutionPlanRecord): RecordIdRef {
64
+ return ensureRecordId(isExecutionPlanRecord(planId) ? planId.id : planId, TABLES.PLAN)
65
+ }
66
+
67
+ function toTaskId(taskId: RecordIdInput | ExecutionPlanTaskRecord): RecordIdRef {
68
+ return ensureRecordId(isExecutionPlanTaskRecord(taskId) ? taskId.id : taskId, TABLES.PLAN_TASK)
69
+ }
70
+
71
+ function toPlanIdString(planId: RecordIdInput | ExecutionPlanRecord): string {
72
+ return recordIdToString(toPlanId(planId), TABLES.PLAN)
73
+ }
74
+
75
+ function toTaskIdString(taskId: RecordIdInput | ExecutionPlanTaskRecord): string {
76
+ return recordIdToString(toTaskId(taskId), TABLES.PLAN_TASK)
77
+ }
78
+
79
+ function setDefinedOptionValue<TValue>(
80
+ target: Record<string, unknown>,
81
+ key: string,
82
+ value: TValue | null | undefined,
83
+ mapValue?: (value: TValue) => unknown,
84
+ ) {
85
+ if (value === undefined || value === null) return
86
+ target[key] = mapValue ? mapValue(value) : value
87
+ }
88
+
89
+ function pushOptionalAssignment<TValue>(
90
+ assignments: string[],
91
+ bindings: Record<string, unknown>,
92
+ key: string,
93
+ value: TValue | null | undefined,
94
+ mapValue?: (value: TValue) => unknown,
95
+ ) {
96
+ if (value === undefined) return
97
+ if (value === null) {
98
+ assignments.push(`${key} = NONE`)
99
+ return
100
+ }
101
+
102
+ assignments.push(`${key} = $${key}`)
103
+ bindings[key] = mapValue ? mapValue(value) : value
104
+ }
105
+
106
+ function serializeTask(task: ExecutionPlanTaskRecord): SerializableExecutionPlanTask {
107
+ return {
108
+ id: recordIdToString(task.id, TABLES.PLAN_TASK),
109
+ position: task.position,
110
+ title: task.title,
111
+ rationale: task.rationale,
112
+ kind: task.kind,
113
+ ownerType: task.ownerType,
114
+ ownerRef: task.ownerRef,
115
+ status: task.status,
116
+ resultStatus: task.resultStatus,
117
+ ...(task.input !== undefined ? { input: task.input } : {}),
118
+ outputSummary: task.outputSummary,
119
+ blockedReason: task.blockedReason,
120
+ externalRef: task.externalRef,
121
+ retryCount: task.retryCount,
122
+ idempotencyKey: task.idempotencyKey,
123
+ startedAt: toOptionalIsoDateTimeString(task.startedAt),
124
+ completedAt: toOptionalIsoDateTimeString(task.completedAt),
125
+ }
126
+ }
127
+
128
+ function serializeEvent(event: ExecutionPlanEventRecord): SerializableExecutionPlanEvent {
129
+ return {
130
+ id: recordIdToString(event.id, TABLES.PLAN_EVENT),
131
+ taskId: event.taskId ? recordIdToString(event.taskId, TABLES.PLAN_TASK) : undefined,
132
+ eventType: event.eventType,
133
+ fromStatus: event.fromStatus,
134
+ toStatus: event.toStatus,
135
+ message: event.message,
136
+ detail: event.detail,
137
+ emittedBy: event.emittedBy,
138
+ createdAt: toIsoDateTimeString(event.createdAt),
139
+ }
140
+ }
141
+
142
+ function buildProgress(tasks: ExecutionPlanTaskRecord[]) {
143
+ const counts = tasks.reduce(
144
+ (summary, task) => {
145
+ summary[task.status] += 1
146
+ return summary
147
+ },
148
+ { pending: 0, 'in-progress': 0, completed: 0, blocked: 0, failed: 0, skipped: 0 } satisfies Record<
149
+ ExecutionPlanTaskStatus,
150
+ number
151
+ >,
152
+ )
153
+ const total = tasks.length
154
+ const completedWork = counts.completed + counts.skipped
155
+
156
+ return {
157
+ total,
158
+ pending: counts.pending,
159
+ inProgress: counts['in-progress'],
160
+ completed: counts.completed,
161
+ blocked: counts.blocked,
162
+ failed: counts.failed,
163
+ skipped: counts.skipped,
164
+ completionRatio: total > 0 ? Number((completedWork / total).toFixed(4)) : undefined,
165
+ }
166
+ }
167
+
168
+ function findCurrentTask(
169
+ tasks: ExecutionPlanTaskRecord[],
170
+ currentTaskId: string | Record<string, unknown> | null | undefined,
171
+ ) {
172
+ if (currentTaskId) {
173
+ const normalized = recordIdToString(currentTaskId as RecordIdInput, TABLES.PLAN_TASK)
174
+ const matched = tasks.find((task) => recordIdToString(task.id, TABLES.PLAN_TASK) === normalized)
175
+ if (matched) return matched
176
+ }
177
+
178
+ return tasks.find((task) => task.status === 'in-progress') ?? tasks.find((task) => task.status === 'pending') ?? null
179
+ }
180
+
181
+ function assertTaskStatusTransition(
182
+ task: ExecutionPlanTaskRecord,
183
+ nextStatus: ExecutionPlanTaskStatus,
184
+ taskTitle: string,
185
+ ): void {
186
+ if (task.status === nextStatus) {
187
+ throw new Error(`Execution task "${taskTitle}" is already ${nextStatus}.`)
188
+ }
189
+
190
+ if (RESTART_REQUIRED_TASK_STATUS_SET.has(task.status)) {
191
+ throw new Error(`Use restartExecutionTask before changing execution task "${taskTitle}" from ${task.status}.`)
192
+ }
193
+
194
+ if (TASK_STATUS_TRANSITIONS[task.status].has(nextStatus)) {
195
+ return
196
+ }
197
+
198
+ throw new Error(`Cannot change execution task "${taskTitle}" from ${task.status} to ${nextStatus}.`)
199
+ }
200
+
201
+ class ExecutionPlanService {
202
+ private async getTaskById(taskId: RecordIdInput): Promise<ExecutionPlanTaskRecord> {
203
+ const recordId = ensureRecordId(taskId, TABLES.PLAN_TASK)
204
+ const row = await databaseService.findOne(TABLES.PLAN_TASK, { id: recordId }, ExecutionPlanTaskSchema)
205
+ if (!row) {
206
+ throw new Error(`Execution plan task not found: ${recordId.toString()}`)
207
+ }
208
+ return row
209
+ }
210
+
211
+ private async listPlanTasks(planId: RecordIdInput): Promise<ExecutionPlanTaskRecord[]> {
212
+ return await databaseService.findMany(
213
+ TABLES.PLAN_TASK,
214
+ { planId: ensureRecordId(planId, TABLES.PLAN) },
215
+ ExecutionPlanTaskSchema,
216
+ { orderBy: 'position', orderDir: 'ASC' },
217
+ )
218
+ }
219
+
220
+ private async listPlanEvents(planId: RecordIdInput, limit = 20): Promise<ExecutionPlanEventRecord[]> {
221
+ return (
222
+ await databaseService.findMany(
223
+ TABLES.PLAN_EVENT,
224
+ { planId: ensureRecordId(planId, TABLES.PLAN) },
225
+ ExecutionPlanEventSchema,
226
+ { orderBy: 'createdAt', orderDir: 'DESC', limit },
227
+ )
228
+ ).reverse()
229
+ }
230
+
231
+ private async emitEvent(params: {
232
+ planId: RecordIdInput
233
+ taskId?: RecordIdInput | null
234
+ eventType: ExecutionPlanEventRecord['eventType']
235
+ fromStatus?: string | null
236
+ toStatus?: string | null
237
+ message: string
238
+ detail?: Record<string, unknown> | null
239
+ emittedBy: string
240
+ }): Promise<void> {
241
+ const eventData: Record<string, unknown> = {
242
+ planId: ensureRecordId(params.planId, TABLES.PLAN),
243
+ eventType: params.eventType,
244
+ message: params.message,
245
+ emittedBy: params.emittedBy,
246
+ }
247
+ setDefinedOptionValue(eventData, 'taskId', params.taskId, (taskId) => ensureRecordId(taskId, TABLES.PLAN_TASK))
248
+ setDefinedOptionValue(eventData, 'fromStatus', params.fromStatus)
249
+ setDefinedOptionValue(eventData, 'toStatus', params.toStatus)
250
+ setDefinedOptionValue(eventData, 'detail', params.detail)
251
+
252
+ await databaseService.create(TABLES.PLAN_EVENT, eventData, ExecutionPlanEventSchema)
253
+ }
254
+
255
+ private async updatePlanStatus(params: {
256
+ plan: ExecutionPlanRecord
257
+ status: ExecutionPlanStatus
258
+ currentTaskId?: RecordIdInput | null
259
+ restartFromTaskId?: RecordIdInput | null
260
+ failureCount?: number
261
+ startedAt?: Date | null
262
+ completedAt?: Date | null
263
+ emittedBy: string
264
+ message?: string
265
+ detail?: Record<string, unknown>
266
+ }): Promise<ExecutionPlanRecord> {
267
+ const bindings: Record<string, unknown> = { status: params.status }
268
+ const assignments = ['status = $status']
269
+ pushOptionalAssignment(assignments, bindings, 'currentTaskId', params.currentTaskId, (taskId) =>
270
+ ensureRecordId(taskId, TABLES.PLAN_TASK),
271
+ )
272
+ pushOptionalAssignment(assignments, bindings, 'restartFromTaskId', params.restartFromTaskId, (taskId) =>
273
+ ensureRecordId(taskId, TABLES.PLAN_TASK),
274
+ )
275
+ if (params.failureCount !== undefined) {
276
+ assignments.push('failureCount = $failureCount')
277
+ bindings.failureCount = params.failureCount
278
+ }
279
+ pushOptionalAssignment(assignments, bindings, 'startedAt', params.startedAt)
280
+ pushOptionalAssignment(assignments, bindings, 'completedAt', params.completedAt)
281
+
282
+ const rows = await databaseService.query<unknown>(
283
+ new BoundQuery(`UPDATE ONLY ${toPlanIdString(params.plan)} SET ${assignments.join(', ')} RETURN AFTER`, bindings),
284
+ )
285
+ const next = rows.at(0) ? ExecutionPlanSchema.parse(rows[0]) : null
286
+
287
+ if (!next) {
288
+ throw new Error(`Failed to update execution plan ${recordIdToString(params.plan.id, TABLES.PLAN)}`)
289
+ }
290
+
291
+ if (params.plan.status !== next.status) {
292
+ await this.emitEvent({
293
+ planId: next.id,
294
+ eventType: 'plan-status-changed',
295
+ fromStatus: params.plan.status,
296
+ toStatus: next.status,
297
+ message: params.message ?? `Plan status changed from ${params.plan.status} to ${next.status}.`,
298
+ detail: params.detail,
299
+ emittedBy: params.emittedBy,
300
+ })
301
+ }
302
+
303
+ return next
304
+ }
305
+
306
+ private async updateTaskRecord(params: {
307
+ task: ExecutionPlanTaskRecord
308
+ status: ExecutionPlanTaskStatus
309
+ resultStatus: ExecutionPlanTaskResultStatus
310
+ outputSummary?: string | null
311
+ blockedReason?: string | null
312
+ externalRef?: string | null
313
+ startedAt?: Date | null
314
+ completedAt?: Date | null
315
+ }): Promise<ExecutionPlanTaskRecord> {
316
+ const bindings: Record<string, unknown> = { status: params.status, resultStatus: params.resultStatus }
317
+ const assignments = ['status = $status', 'resultStatus = $resultStatus']
318
+ pushOptionalAssignment(assignments, bindings, 'outputSummary', params.outputSummary)
319
+ pushOptionalAssignment(assignments, bindings, 'blockedReason', params.blockedReason)
320
+ pushOptionalAssignment(assignments, bindings, 'externalRef', params.externalRef)
321
+ pushOptionalAssignment(assignments, bindings, 'startedAt', params.startedAt)
322
+ pushOptionalAssignment(assignments, bindings, 'completedAt', params.completedAt)
323
+
324
+ const rows = await databaseService.query<unknown>(
325
+ new BoundQuery(`UPDATE ONLY ${toTaskIdString(params.task)} SET ${assignments.join(', ')} RETURN AFTER`, bindings),
326
+ )
327
+ const next = rows.at(0) ? ExecutionPlanTaskSchema.parse(rows[0]) : null
328
+ if (!next) {
329
+ throw new Error(`Failed to update execution task ${recordIdToString(params.task.id, TABLES.PLAN_TASK)}`)
330
+ }
331
+
332
+ return next
333
+ }
334
+
335
+ private async getActivePlanRecord(workstreamId: RecordIdInput): Promise<ExecutionPlanRecord | null> {
336
+ const plans = await databaseService.findMany(
337
+ TABLES.PLAN,
338
+ { workstreamId: ensureRecordId(workstreamId, TABLES.WORKSTREAM) },
339
+ ExecutionPlanSchema,
340
+ { orderBy: 'updatedAt', orderDir: 'DESC' },
341
+ )
342
+
343
+ return plans.find((plan) => ACTIVE_PLAN_STATUS_SET.has(plan.status)) ?? null
344
+ }
345
+
346
+ private async collectCarriedTasks(plan: ExecutionPlanRecord): Promise<SerializableExecutionPlanCarriedTask[]> {
347
+ const carried: SerializableExecutionPlanCarriedTask[] = []
348
+ let currentReplacedPlanId = plan.replacedPlanId ? ensureRecordId(plan.replacedPlanId, TABLES.PLAN) : null
349
+ let depth = 0
350
+
351
+ while (currentReplacedPlanId && depth < 5) {
352
+ const previousPlan = await databaseService.findOne(
353
+ TABLES.PLAN,
354
+ { id: currentReplacedPlanId },
355
+ ExecutionPlanSchema,
356
+ )
357
+ if (!previousPlan) break
358
+
359
+ const previousTasks = await this.listPlanTasks(previousPlan.id)
360
+ const currentPlanId = recordIdToString(previousPlan.id, TABLES.PLAN)
361
+ carried.unshift(
362
+ ...previousTasks
363
+ .filter(
364
+ (task) =>
365
+ CARRIED_RESULT_STATUS_SET.has(task.resultStatus) &&
366
+ typeof task.outputSummary === 'string' &&
367
+ task.outputSummary.trim().length > 0,
368
+ )
369
+ .map((task) => ({
370
+ planId: currentPlanId,
371
+ taskId: recordIdToString(task.id, TABLES.PLAN_TASK),
372
+ title: task.title,
373
+ resultStatus: task.resultStatus,
374
+ outputSummary: task.outputSummary ?? '',
375
+ })),
376
+ )
377
+
378
+ currentReplacedPlanId = previousPlan.replacedPlanId
379
+ ? ensureRecordId(previousPlan.replacedPlanId, TABLES.PLAN)
380
+ : null
381
+ depth += 1
382
+ }
383
+
384
+ return carried
385
+ }
386
+
387
+ private async toSerializablePlan(
388
+ plan: ExecutionPlanRecord,
389
+ options?: { includeEvents?: boolean },
390
+ ): Promise<SerializableExecutionPlan> {
391
+ const tasks = await this.listPlanTasks(plan.id)
392
+ const recentEvents = options?.includeEvents ? await this.listPlanEvents(plan.id, 20) : []
393
+ const carriedTasks = await this.collectCarriedTasks(plan)
394
+ const currentTask = findCurrentTask(tasks, plan.currentTaskId)
395
+
396
+ return {
397
+ planId: recordIdToString(plan.id, TABLES.PLAN),
398
+ workstreamId: recordIdToString(plan.workstreamId, TABLES.WORKSTREAM),
399
+ organizationId: recordIdToString(plan.organizationId, TABLES.ORGANIZATION),
400
+ title: plan.title,
401
+ objective: plan.objective,
402
+ status: plan.status,
403
+ leadAgentId: plan.leadAgentId,
404
+ currentTaskId: currentTask ? recordIdToString(currentTask.id, TABLES.PLAN_TASK) : undefined,
405
+ restartFromTaskId: plan.restartFromTaskId
406
+ ? recordIdToString(plan.restartFromTaskId, TABLES.PLAN_TASK)
407
+ : undefined,
408
+ replacedPlanId: plan.replacedPlanId ? recordIdToString(plan.replacedPlanId, TABLES.PLAN) : undefined,
409
+ failureCount: plan.failureCount,
410
+ startedAt: toOptionalIsoDateTimeString(plan.startedAt),
411
+ completedAt: toOptionalIsoDateTimeString(plan.completedAt),
412
+ progress: buildProgress(tasks),
413
+ tasks: tasks.map(serializeTask),
414
+ recentEvents: recentEvents.map(serializeEvent),
415
+ carriedTasks,
416
+ }
417
+ }
418
+
419
+ private buildToolResult(params: {
420
+ action: ExecutionPlanToolResultData['action']
421
+ plan: SerializableExecutionPlan | null
422
+ message?: string
423
+ changedTaskId?: string
424
+ }): ExecutionPlanToolResultData {
425
+ return {
426
+ action: params.action,
427
+ ...(params.message ? { message: params.message } : {}),
428
+ ...(params.changedTaskId !== undefined ? { changedTaskId: params.changedTaskId } : {}),
429
+ ...(params.plan ? { plan: params.plan } : {}),
430
+ hasPlan: params.plan !== null,
431
+ status: params.plan?.status,
432
+ }
433
+ }
434
+
435
+ async hasActivePlan(workstreamId: RecordIdInput): Promise<boolean> {
436
+ return (await this.getActivePlanRecord(workstreamId)) !== null
437
+ }
438
+
439
+ async getActivePlanForWorkstream(
440
+ workstreamId: RecordIdInput,
441
+ options?: { includeEvents?: boolean },
442
+ ): Promise<SerializableExecutionPlan | null> {
443
+ const plan = await this.getActivePlanRecord(workstreamId)
444
+ if (!plan) return null
445
+ return await this.toSerializablePlan(plan, options)
446
+ }
447
+
448
+ async createPlan(params: {
449
+ organizationId: RecordIdInput
450
+ workstreamId: RecordIdInput
451
+ leadAgentId: ExecutionPlanRecord['leadAgentId']
452
+ input: CreateExecutionPlanArgs
453
+ }): Promise<ExecutionPlanToolResultData> {
454
+ const activePlan = await this.getActivePlanRecord(params.workstreamId)
455
+ if (activePlan) {
456
+ throw new Error('An active execution plan already exists for this workstream. Replace it instead.')
457
+ }
458
+
459
+ const planId = new RecordId(TABLES.PLAN, Bun.randomUUIDv7())
460
+ const taskIds = params.input.tasks.map(() => new RecordId(TABLES.PLAN_TASK, Bun.randomUUIDv7()))
461
+ const organizationId = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
462
+ const workstreamId = ensureRecordId(params.workstreamId, TABLES.WORKSTREAM)
463
+ const planData: Record<string, unknown> = {
464
+ organizationId,
465
+ workstreamId,
466
+ title: params.input.title,
467
+ objective: params.input.objective,
468
+ status: 'draft',
469
+ leadAgentId: params.leadAgentId,
470
+ currentTaskId: taskIds[0],
471
+ failureCount: 0,
472
+ }
473
+
474
+ const plan = await databaseService.createWithId(TABLES.PLAN, planId, planData, ExecutionPlanSchema)
475
+
476
+ for (const [index, task] of params.input.tasks.entries()) {
477
+ const taskData: Record<string, unknown> = {
478
+ planId,
479
+ organizationId,
480
+ workstreamId,
481
+ position: index,
482
+ title: task.title,
483
+ rationale: task.rationale,
484
+ kind: task.kind,
485
+ ownerType: task.ownerType,
486
+ ownerRef: task.ownerRef,
487
+ status: 'pending',
488
+ resultStatus: 'not-started',
489
+ retryCount: 0,
490
+ idempotencyKey: `${planId.toString()}:${index}`,
491
+ }
492
+ setDefinedOptionValue(taskData, 'input', task.input, normalizeTaskInput)
493
+
494
+ await databaseService.createWithId(TABLES.PLAN_TASK, taskIds[index], taskData, ExecutionPlanTaskSchema)
495
+ }
496
+
497
+ await this.emitEvent({
498
+ planId: plan.id,
499
+ eventType: 'plan-created',
500
+ message: `Created execution plan "${plan.title}" with ${params.input.tasks.length} tasks.`,
501
+ detail: { title: plan.title, objective: plan.objective, taskCount: params.input.tasks.length },
502
+ emittedBy: params.leadAgentId,
503
+ })
504
+
505
+ const serializable = await this.toSerializablePlan(plan, { includeEvents: true })
506
+ return this.buildToolResult({
507
+ action: 'created',
508
+ plan: serializable,
509
+ message: `Created execution plan "${plan.title}".`,
510
+ })
511
+ }
512
+
513
+ async replacePlan(params: {
514
+ workstreamId: RecordIdInput
515
+ organizationId: RecordIdInput
516
+ leadAgentId: ExecutionPlanRecord['leadAgentId']
517
+ input: ReplaceExecutionPlanArgs
518
+ }): Promise<ExecutionPlanToolResultData> {
519
+ const activePlan = await this.getActivePlanRecord(params.workstreamId)
520
+ if (!activePlan) {
521
+ throw new Error('No active execution plan exists for this workstream.')
522
+ }
523
+ if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
524
+ throw new Error('Only the active execution plan can be replaced.')
525
+ }
526
+
527
+ const oldPlan = await this.updatePlanStatus({
528
+ plan: activePlan,
529
+ status: 'aborted',
530
+ currentTaskId: activePlan.currentTaskId,
531
+ restartFromTaskId: activePlan.restartFromTaskId,
532
+ emittedBy: params.leadAgentId,
533
+ message: `Plan "${activePlan.title}" was replaced.`,
534
+ detail: { reason: params.input.reason },
535
+ })
536
+
537
+ const planId = new RecordId(TABLES.PLAN, Bun.randomUUIDv7())
538
+ const taskIds = params.input.tasks.map(() => new RecordId(TABLES.PLAN_TASK, Bun.randomUUIDv7()))
539
+ const organizationId = ensureRecordId(params.organizationId, TABLES.ORGANIZATION)
540
+ const workstreamId = ensureRecordId(params.workstreamId, TABLES.WORKSTREAM)
541
+ const nextPlanData: Record<string, unknown> = {
542
+ organizationId,
543
+ workstreamId,
544
+ title: params.input.title,
545
+ objective: params.input.objective,
546
+ status: 'draft',
547
+ leadAgentId: params.leadAgentId,
548
+ currentTaskId: taskIds[0],
549
+ replacedPlanId: ensureRecordId(oldPlan.id, TABLES.PLAN),
550
+ failureCount: 0,
551
+ }
552
+
553
+ const nextPlan = await databaseService.createWithId(TABLES.PLAN, planId, nextPlanData, ExecutionPlanSchema)
554
+
555
+ for (const [index, task] of params.input.tasks.entries()) {
556
+ const taskData: Record<string, unknown> = {
557
+ planId,
558
+ organizationId,
559
+ workstreamId,
560
+ position: index,
561
+ title: task.title,
562
+ rationale: task.rationale,
563
+ kind: task.kind,
564
+ ownerType: task.ownerType,
565
+ ownerRef: task.ownerRef,
566
+ status: 'pending',
567
+ resultStatus: 'not-started',
568
+ retryCount: 0,
569
+ idempotencyKey: `${planId.toString()}:${index}`,
570
+ }
571
+ setDefinedOptionValue(taskData, 'input', task.input, normalizeTaskInput)
572
+
573
+ await databaseService.createWithId(TABLES.PLAN_TASK, taskIds[index], taskData, ExecutionPlanTaskSchema)
574
+ }
575
+
576
+ await this.emitEvent({
577
+ planId: oldPlan.id,
578
+ eventType: 'replan',
579
+ message: `Created replacement plan "${nextPlan.title}".`,
580
+ detail: {
581
+ reason: params.input.reason,
582
+ oldPlanId: recordIdToString(oldPlan.id, TABLES.PLAN),
583
+ newPlanId: recordIdToString(nextPlan.id, TABLES.PLAN),
584
+ },
585
+ emittedBy: params.leadAgentId,
586
+ })
587
+ await this.emitEvent({
588
+ planId: nextPlan.id,
589
+ eventType: 'plan-created',
590
+ message: `Created execution plan "${nextPlan.title}" with ${params.input.tasks.length} tasks.`,
591
+ detail: {
592
+ title: nextPlan.title,
593
+ objective: nextPlan.objective,
594
+ taskCount: params.input.tasks.length,
595
+ replacedPlanId: recordIdToString(oldPlan.id, TABLES.PLAN),
596
+ },
597
+ emittedBy: params.leadAgentId,
598
+ })
599
+ await this.emitEvent({
600
+ planId: nextPlan.id,
601
+ eventType: 'plan-replaced',
602
+ message: `Plan "${nextPlan.title}" replaces "${oldPlan.title}".`,
603
+ detail: { reason: params.input.reason, replacedPlanId: recordIdToString(oldPlan.id, TABLES.PLAN) },
604
+ emittedBy: params.leadAgentId,
605
+ })
606
+
607
+ const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
608
+ return this.buildToolResult({
609
+ action: 'replaced',
610
+ plan: serializable,
611
+ message: `Replaced execution plan with "${nextPlan.title}".`,
612
+ })
613
+ }
614
+
615
+ async getActivePlanToolResult(params: {
616
+ workstreamId: RecordIdInput
617
+ includeEvents?: boolean
618
+ }): Promise<ExecutionPlanToolResultData> {
619
+ const plan = await this.getActivePlanForWorkstream(params.workstreamId, { includeEvents: params.includeEvents })
620
+ return this.buildToolResult({
621
+ action: plan ? 'loaded' : 'none',
622
+ plan,
623
+ ...(plan ? { message: `Loaded execution plan "${plan.title}".` } : { message: 'No active execution plan.' }),
624
+ })
625
+ }
626
+
627
+ async setTaskStatus(params: {
628
+ workstreamId: RecordIdInput
629
+ emittedBy: string
630
+ input: SetExecutionTaskStatusArgs
631
+ }): Promise<ExecutionPlanToolResultData> {
632
+ const activePlan = await this.getActivePlanRecord(params.workstreamId)
633
+ if (!activePlan) {
634
+ throw new Error('No active execution plan exists for this workstream.')
635
+ }
636
+ if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
637
+ throw new Error('Task updates must target the active execution plan.')
638
+ }
639
+
640
+ const task = await this.getTaskById(params.input.taskId)
641
+ if (recordIdToString(task.planId, TABLES.PLAN) !== params.input.planId) {
642
+ throw new Error('Execution task does not belong to the provided plan.')
643
+ }
644
+ assertTaskStatusTransition(task, params.input.status, task.title)
645
+
646
+ const tasks = await this.listPlanTasks(activePlan.id)
647
+ const otherInProgressTask = tasks.find(
648
+ (candidate) =>
649
+ candidate.status === 'in-progress' &&
650
+ recordIdToString(candidate.id, TABLES.PLAN_TASK) !== recordIdToString(task.id, TABLES.PLAN_TASK),
651
+ )
652
+ if (params.input.status === 'in-progress' && otherInProgressTask) {
653
+ throw new Error('Only one execution-plan task may be in progress at a time.')
654
+ }
655
+
656
+ if (params.input.status === 'pending') {
657
+ throw new Error('Use restartExecutionTask to reset a task back to pending.')
658
+ }
659
+
660
+ if (params.input.status === 'completed') {
661
+ if (!params.input.resultStatus) {
662
+ throw new Error('Completed tasks require resultStatus.')
663
+ }
664
+ if (!SUCCESSFUL_RESULT_STATUS_SET.has(params.input.resultStatus)) {
665
+ throw new Error('Completed tasks require resultStatus to be success or partial.')
666
+ }
667
+ if (!params.input.outputSummary?.trim() && !params.input.externalRef?.trim()) {
668
+ throw new Error('Completed tasks require outputSummary or externalRef.')
669
+ }
670
+ }
671
+
672
+ if (params.input.status === 'blocked' && !params.input.blockedReason?.trim()) {
673
+ throw new Error('Blocked tasks require blockedReason.')
674
+ }
675
+
676
+ if (params.input.status === 'failed' && !params.input.outputSummary?.trim()) {
677
+ throw new Error('Failed tasks require outputSummary.')
678
+ }
679
+
680
+ const now = new Date()
681
+ let nextPlan = activePlan
682
+ let nextResultStatus = task.resultStatus
683
+ let nextFailureCount = activePlan.failureCount
684
+ let taskStartedAt: Date | null | undefined = task.startedAt
685
+ ? undefined
686
+ : params.input.status === 'in-progress'
687
+ ? now
688
+ : undefined
689
+ let taskCompletedAt: Date | null | undefined = undefined
690
+ let message = ''
691
+
692
+ if (params.input.status === 'in-progress') {
693
+ nextPlan = await this.updatePlanStatus({
694
+ plan: activePlan,
695
+ status: 'executing',
696
+ currentTaskId: task.id,
697
+ restartFromTaskId: null,
698
+ ...(activePlan.startedAt ? {} : { startedAt: now }),
699
+ emittedBy: params.emittedBy,
700
+ })
701
+ nextResultStatus = task.resultStatus
702
+ message = `Started task "${task.title}".`
703
+ } else if (params.input.status === 'completed' || params.input.status === 'skipped') {
704
+ nextResultStatus =
705
+ params.input.status === 'completed'
706
+ ? ExecutionPlanTaskResultStatusSchema.parse(params.input.resultStatus)
707
+ : (params.input.resultStatus ?? 'not-started')
708
+ taskCompletedAt = now
709
+
710
+ const refreshedTasks = tasks.map((candidate) =>
711
+ recordIdToString(candidate.id, TABLES.PLAN_TASK) === recordIdToString(task.id, TABLES.PLAN_TASK)
712
+ ? { ...candidate, status: params.input.status, resultStatus: nextResultStatus }
713
+ : candidate,
714
+ )
715
+ const nextPendingTask =
716
+ refreshedTasks.find((candidate) => candidate.position > task.position && candidate.status === 'pending') ??
717
+ refreshedTasks.find((candidate) => candidate.status === 'pending') ??
718
+ null
719
+
720
+ if (nextPendingTask) {
721
+ nextPlan = await this.updatePlanStatus({
722
+ plan: activePlan,
723
+ status: 'executing',
724
+ currentTaskId: nextPendingTask.id,
725
+ restartFromTaskId: null,
726
+ failureCount: 0,
727
+ emittedBy: params.emittedBy,
728
+ })
729
+ } else {
730
+ nextPlan = await this.updatePlanStatus({
731
+ plan: activePlan,
732
+ status: 'completed',
733
+ currentTaskId: null,
734
+ restartFromTaskId: null,
735
+ failureCount: 0,
736
+ completedAt: now,
737
+ ...(activePlan.startedAt ? {} : { startedAt: now }),
738
+ emittedBy: params.emittedBy,
739
+ })
740
+ }
741
+
742
+ nextFailureCount = 0
743
+ message =
744
+ params.input.status === 'completed' ? `Completed task "${task.title}".` : `Skipped task "${task.title}".`
745
+ } else {
746
+ nextFailureCount = activePlan.failureCount + 1
747
+ nextResultStatus =
748
+ params.input.status === 'failed'
749
+ ? 'failed'
750
+ : (params.input.resultStatus ?? (params.input.outputSummary?.trim() ? 'partial' : task.resultStatus))
751
+ nextPlan = await this.updatePlanStatus({
752
+ plan: activePlan,
753
+ status: 'blocked',
754
+ currentTaskId: task.id,
755
+ restartFromTaskId: task.id,
756
+ failureCount: nextFailureCount,
757
+ emittedBy: params.emittedBy,
758
+ })
759
+ if (params.input.status === 'failed') {
760
+ taskCompletedAt = now
761
+ }
762
+ message = params.input.status === 'failed' ? `Task "${task.title}" failed.` : `Task "${task.title}" is blocked.`
763
+ }
764
+
765
+ const updatedTask = await this.updateTaskRecord({
766
+ task,
767
+ status: params.input.status,
768
+ resultStatus: nextResultStatus,
769
+ ...(params.input.outputSummary !== undefined ? { outputSummary: params.input.outputSummary || null } : {}),
770
+ ...(params.input.blockedReason !== undefined ? { blockedReason: params.input.blockedReason || null } : {}),
771
+ ...(params.input.externalRef !== undefined ? { externalRef: params.input.externalRef || null } : {}),
772
+ ...(params.input.status === 'in-progress' && taskStartedAt ? { startedAt: taskStartedAt } : {}),
773
+ ...(taskCompletedAt !== undefined ? { completedAt: taskCompletedAt } : {}),
774
+ })
775
+
776
+ const eventType =
777
+ params.input.status === 'in-progress'
778
+ ? 'task-started'
779
+ : params.input.status === 'completed'
780
+ ? 'task-completed'
781
+ : params.input.status === 'blocked'
782
+ ? 'task-blocked'
783
+ : params.input.status === 'failed'
784
+ ? 'task-failed'
785
+ : 'task-skipped'
786
+
787
+ await this.emitEvent({
788
+ planId: nextPlan.id,
789
+ taskId: updatedTask.id,
790
+ eventType,
791
+ fromStatus: task.status,
792
+ toStatus: updatedTask.status,
793
+ message,
794
+ detail: {
795
+ resultStatus: updatedTask.resultStatus,
796
+ outputSummary: updatedTask.outputSummary,
797
+ blockedReason: updatedTask.blockedReason,
798
+ externalRef: updatedTask.externalRef,
799
+ failureCount: nextFailureCount,
800
+ },
801
+ emittedBy: params.emittedBy,
802
+ })
803
+
804
+ const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
805
+ return this.buildToolResult({
806
+ action: 'task-status-updated',
807
+ plan: serializable,
808
+ message:
809
+ nextFailureCount >= 2 && nextPlan.status === 'blocked'
810
+ ? `${message} Failure budget reached; the plan is paused until it is restarted or replaced.`
811
+ : message,
812
+ changedTaskId: recordIdToString(updatedTask.id, TABLES.PLAN_TASK),
813
+ })
814
+ }
815
+
816
+ async restartTask(params: {
817
+ workstreamId: RecordIdInput
818
+ emittedBy: string
819
+ input: RestartExecutionTaskArgs
820
+ }): Promise<ExecutionPlanToolResultData> {
821
+ const activePlan = await this.getActivePlanRecord(params.workstreamId)
822
+ if (!activePlan) {
823
+ throw new Error('No active execution plan exists for this workstream.')
824
+ }
825
+ if (recordIdToString(activePlan.id, TABLES.PLAN) !== params.input.planId) {
826
+ throw new Error('Only the active execution plan can restart tasks.')
827
+ }
828
+
829
+ const tasks = await this.listPlanTasks(activePlan.id)
830
+ const targetTask = tasks.find((task) => recordIdToString(task.id, TABLES.PLAN_TASK) === params.input.taskId)
831
+ if (!targetTask) {
832
+ throw new Error('Execution plan task not found for restart.')
833
+ }
834
+ if (targetTask.status !== 'blocked' && targetTask.status !== 'failed') {
835
+ throw new Error('Only blocked or failed execution tasks can be restarted.')
836
+ }
837
+
838
+ for (const task of tasks) {
839
+ const shouldReset =
840
+ recordIdToString(task.id, TABLES.PLAN_TASK) === params.input.taskId ||
841
+ (params.input.resetDownstream && task.position > targetTask.position)
842
+
843
+ if (!shouldReset) continue
844
+
845
+ const nextStatus: ExecutionPlanTaskStatus = 'pending'
846
+ await this.updateTaskRecord({
847
+ task,
848
+ status: nextStatus,
849
+ resultStatus: 'not-started',
850
+ outputSummary: null,
851
+ blockedReason: null,
852
+ externalRef: null,
853
+ startedAt: null,
854
+ completedAt: null,
855
+ })
856
+ }
857
+
858
+ const nextPlan = await this.updatePlanStatus({
859
+ plan: activePlan,
860
+ status: 'executing',
861
+ currentTaskId: targetTask.id,
862
+ restartFromTaskId: targetTask.id,
863
+ failureCount: 0,
864
+ completedAt: null,
865
+ ...(activePlan.startedAt ? {} : { startedAt: new Date() }),
866
+ emittedBy: params.emittedBy,
867
+ })
868
+
869
+ await this.emitEvent({
870
+ planId: nextPlan.id,
871
+ taskId: targetTask.id,
872
+ eventType: 'task-restarted',
873
+ fromStatus: targetTask.status,
874
+ toStatus: 'pending',
875
+ message: `Restarted task "${targetTask.title}".`,
876
+ detail: { resetDownstream: params.input.resetDownstream },
877
+ emittedBy: params.emittedBy,
878
+ })
879
+
880
+ const serializable = await this.toSerializablePlan(nextPlan, { includeEvents: true })
881
+ return this.buildToolResult({
882
+ action: 'task-restarted',
883
+ plan: serializable,
884
+ changedTaskId: recordIdToString(targetTask.id, TABLES.PLAN_TASK),
885
+ message: `Restarted task "${targetTask.title}".`,
886
+ })
887
+ }
888
+ }
889
+
890
+ export const executionPlanService = new ExecutionPlanService()