task-manager-logic 0.1.0 → 0.1.2

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.
@@ -0,0 +1,747 @@
1
+ import type {
2
+ CancelTaskCommand,
3
+ ClaimTaskCommand,
4
+ CreateTaskSpaceCommand,
5
+ ExpireTaskClaimCommand,
6
+ FailTaskCommand,
7
+ HeartbeatTaskClaimCommand,
8
+ PatchTaskSpaceCommand,
9
+ ReplanTaskCommand,
10
+ SettleTaskCommand,
11
+ StartTaskCommand,
12
+ TaskArtifactBody,
13
+ TaskClaimReceipt,
14
+ TaskClaimToken,
15
+ TaskCommand,
16
+ TaskCommandReceipt,
17
+ TaskHistoryEvent,
18
+ TaskId,
19
+ TaskNodeDefinition,
20
+ TaskProfileEnvelope,
21
+ TaskRecord,
22
+ TaskReplanReceipt,
23
+ TaskSettlementReceipt,
24
+ TaskSpaceDefinition,
25
+ TaskSpaceOwnerPort,
26
+ TaskSpaceSnapshot,
27
+ TaskStatus,
28
+ TaskTimestamp,
29
+ TaskTransitionReceipt,
30
+ WaitTaskCommand,
31
+ } from 'task-manager-contract';
32
+ import { TaskManagerError } from './errors.js';
33
+ import { assertTaskTransition, isTerminal } from './statusMachine.js';
34
+ import {
35
+ assertClaimIdentity,
36
+ assertClaimLive,
37
+ deepImmutableClone,
38
+ normalizeTaskProcessorConfig,
39
+ predecessorIds,
40
+ taskRecordById,
41
+ validateTaskSpaceDefinition,
42
+ validateTimestamp,
43
+ type TaskProcessorConfig,
44
+ } from './validation.js';
45
+
46
+ export interface TaskManagerRuntime {
47
+ readonly owner: TaskSpaceOwnerPort;
48
+ }
49
+
50
+ export interface TaskProcessorResult<Receipt extends TaskCommandReceipt = TaskCommandReceipt> {
51
+ readonly snapshot: TaskSpaceSnapshot;
52
+ readonly receipt: Receipt;
53
+ readonly replayed: boolean;
54
+ }
55
+
56
+ export async function createTaskSpace<Profile extends TaskProfileEnvelope>(
57
+ runtime: TaskManagerRuntime,
58
+ input: CreateTaskSpaceCommand<Profile>,
59
+ config: TaskProcessorConfig,
60
+ ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
61
+ const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
62
+ if (replay) return replay;
63
+ if (input.definition.taskSpaceId !== input.taskSpaceId) {
64
+ throw new TaskManagerError('cross-space-identity', 'definition and command TaskSpace identities differ');
65
+ }
66
+ if (await runtime.owner.readSnapshot(input.taskSpaceId)) {
67
+ throw new TaskManagerError('task-space-already-exists', `TaskSpace ${input.taskSpaceId} already exists`);
68
+ }
69
+ const normalized = normalizeTaskProcessorConfig(config);
70
+ validateTaskSpaceDefinition(input.definition, normalized);
71
+ validateTimestamp(input.createdAt, 'createdAt');
72
+
73
+ const tasks: TaskRecord[] = input.definition.tasks.map((definition) => ({
74
+ kind: 'task-record',
75
+ taskSpaceId: input.taskSpaceId,
76
+ taskId: definition.taskId,
77
+ definition,
78
+ profile: definition.profile,
79
+ status: predecessorIds(input.definition, definition.taskId).length === 0 ? 'Ready' : 'Pending',
80
+ attempt: 0,
81
+ leaseEpoch: 0,
82
+ activeClaim: null,
83
+ outputArtifacts: [],
84
+ createdAt: input.createdAt,
85
+ updatedAt: input.createdAt,
86
+ }));
87
+ const event = {
88
+ ...eventBase(input, 0, 0, 0, input.createdAt),
89
+ kind: 'task-space.created',
90
+ } as const;
91
+ const receipt: TaskTransitionReceipt = {
92
+ ...receiptBase(input, 0, 0, input.createdAt, [event.eventId]),
93
+ kind: 'task-transition-receipt',
94
+ };
95
+ const snapshot: TaskSpaceSnapshot = {
96
+ kind: 'task-space-snapshot',
97
+ taskSpaceId: input.taskSpaceId,
98
+ revision: 0,
99
+ definition: input.definition,
100
+ tasks,
101
+ historyEventIds: [event.eventId],
102
+ issuedReceiptIds: [receipt.receiptId],
103
+ createdAt: input.createdAt,
104
+ updatedAt: input.createdAt,
105
+ };
106
+ return admit(runtime, input, null, snapshot, [event], receipt, []);
107
+ }
108
+
109
+ export async function patchTaskSpace<Profile extends TaskProfileEnvelope>(
110
+ runtime: TaskManagerRuntime,
111
+ input: PatchTaskSpaceCommand<Profile>,
112
+ config: TaskProcessorConfig,
113
+ ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
114
+ const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
115
+ if (replay) return replay;
116
+ const snapshot = await loadExpected(runtime, input);
117
+ validateTimestamp(input.patchedAt, 'patchedAt');
118
+ const patch = input.patch;
119
+ const current = requireTask(snapshot, patch.taskId);
120
+ let nextDefinition: TaskNodeDefinition;
121
+ if (patch.kind === 'rename-task') {
122
+ nextDefinition = {
123
+ ...current.definition,
124
+ name: patch.name,
125
+ ...(patch.description === undefined ? {} : { description: patch.description }),
126
+ };
127
+ } else if (patch.kind === 'replace-task-profile') {
128
+ nextDefinition = { ...current.definition, profile: patch.profile };
129
+ } else {
130
+ const hasArtifact = current.definition.inputArtifacts.some(({ digest }) => digest === patch.artifact.digest);
131
+ nextDefinition = {
132
+ ...current.definition,
133
+ inputArtifacts: hasArtifact
134
+ ? current.definition.inputArtifacts
135
+ : [...current.definition.inputArtifacts, patch.artifact],
136
+ };
137
+ }
138
+ const definition: TaskSpaceDefinition = {
139
+ ...snapshot.definition,
140
+ tasks: snapshot.definition.tasks.map((task) => task.taskId === current.taskId ? nextDefinition : task),
141
+ };
142
+ validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
143
+ const nextTasks = snapshot.tasks.map((task) => task.taskId === current.taskId
144
+ ? { ...task, definition: nextDefinition, profile: nextDefinition.profile, updatedAt: input.patchedAt }
145
+ : task);
146
+ const event = {
147
+ ...nextEventBase(snapshot, input, 0, input.patchedAt),
148
+ kind: 'task-space.patched',
149
+ taskId: current.taskId,
150
+ } as const;
151
+ const receipt: TaskTransitionReceipt = {
152
+ ...nextReceiptBase(snapshot, input, input.patchedAt, [event.eventId]),
153
+ kind: 'task-transition-receipt',
154
+ taskId: current.taskId,
155
+ };
156
+ return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt);
157
+ }
158
+
159
+ export async function claimTask(
160
+ runtime: TaskManagerRuntime,
161
+ input: ClaimTaskCommand,
162
+ config: TaskProcessorConfig,
163
+ ): Promise<TaskProcessorResult<TaskClaimReceipt>> {
164
+ const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
165
+ if (replay) return replay;
166
+ const snapshot = await loadExpected(runtime, input);
167
+ const task = requireTask(snapshot, input.taskId);
168
+ if (task.definition.kind !== 'task' || task.status !== 'Ready') {
169
+ throw new TaskManagerError('task-not-ready', `task ${task.taskId} is not a claimable Ready task`);
170
+ }
171
+ const normalized = normalizeTaskProcessorConfig(config);
172
+ if (!Number.isSafeInteger(input.leaseDurationMs)
173
+ || input.leaseDurationMs <= 0
174
+ || input.leaseDurationMs > normalized.maxLeaseDurationMs) {
175
+ throw new TaskManagerError('lease-out-of-range', `leaseDurationMs must be within 1..${normalized.maxLeaseDurationMs}`);
176
+ }
177
+ const claimedAtMs = validateTimestamp(input.claimedAt, 'claimedAt');
178
+ const attempt = task.attempt === 0 ? 1 : task.attempt;
179
+ const leaseEpoch = task.leaseEpoch === 0 ? 1 : task.leaseEpoch;
180
+ const claim: TaskClaimToken = {
181
+ kind: 'task-claim-token',
182
+ claimId: stableId('claim', input.commandId),
183
+ taskSpaceId: input.taskSpaceId,
184
+ taskId: input.taskId,
185
+ assigneeRef: nonEmpty(input.assigneeRef, 'assigneeRef'),
186
+ attempt,
187
+ leaseEpoch,
188
+ claimedAt: input.claimedAt,
189
+ heartbeatAt: input.claimedAt,
190
+ expiresAt: new Date(claimedAtMs + input.leaseDurationMs).toISOString(),
191
+ };
192
+ assertTaskTransition(task.taskId, task.status, 'Claimed');
193
+ const nextTask: TaskRecord = {
194
+ ...task,
195
+ status: 'Claimed',
196
+ attempt,
197
+ leaseEpoch,
198
+ activeClaim: claim,
199
+ updatedAt: input.claimedAt,
200
+ };
201
+ const event = {
202
+ ...stateEventBase(snapshot, input, 0, input.claimedAt, task, 'Claimed'),
203
+ kind: 'task.claimed',
204
+ claim,
205
+ } as const;
206
+ const receipt: TaskClaimReceipt = {
207
+ ...nextReceiptBase(snapshot, input, input.claimedAt, [event.eventId]),
208
+ kind: 'task-claim-receipt',
209
+ taskId: task.taskId,
210
+ claim,
211
+ };
212
+ return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
213
+ }
214
+
215
+ export async function heartbeatTaskClaim(
216
+ runtime: TaskManagerRuntime,
217
+ input: HeartbeatTaskClaimCommand,
218
+ config: TaskProcessorConfig,
219
+ ): Promise<TaskProcessorResult<TaskClaimReceipt>> {
220
+ const replay = await replayed<TaskClaimReceipt>(runtime, input, 'task-claim-receipt');
221
+ if (replay) return replay;
222
+ const snapshot = await loadExpected(runtime, input);
223
+ const task = assertClaimIdentity(snapshot, input.claim);
224
+ const heartbeatAtMs = validateTimestamp(input.heartbeatAt, 'heartbeatAt');
225
+ const priorHeartbeatAtMs = validateTimestamp(input.claim.heartbeatAt, 'claim.heartbeatAt');
226
+ const priorExpiresAtMs = validateTimestamp(input.claim.expiresAt, 'claim.expiresAt');
227
+ if (heartbeatAtMs <= priorHeartbeatAtMs) {
228
+ throw new TaskManagerError('claim-token-mismatch', 'heartbeat must advance the live token timestamp');
229
+ }
230
+ if (heartbeatAtMs >= priorExpiresAtMs) {
231
+ throw new TaskManagerError('claim-expired', `claim ${input.claim.claimId} is already expired`);
232
+ }
233
+ const normalized = normalizeTaskProcessorConfig(config);
234
+ if (!Number.isSafeInteger(input.extendByMs)
235
+ || input.extendByMs <= 0
236
+ || input.extendByMs > normalized.maxLeaseDurationMs) {
237
+ throw new TaskManagerError('lease-out-of-range', `extendByMs must be within 1..${normalized.maxLeaseDurationMs}`);
238
+ }
239
+ const claim: TaskClaimToken = {
240
+ ...input.claim,
241
+ heartbeatAt: input.heartbeatAt,
242
+ expiresAt: new Date(heartbeatAtMs + input.extendByMs).toISOString(),
243
+ };
244
+ const nextTask: TaskRecord = { ...task, activeClaim: claim, updatedAt: input.heartbeatAt };
245
+ const event = {
246
+ ...nextEventBase(snapshot, input, 0, input.heartbeatAt),
247
+ kind: 'task.claim-heartbeat-recorded',
248
+ taskId: task.taskId,
249
+ previousClaim: input.claim,
250
+ claim,
251
+ } as const;
252
+ const receipt: TaskClaimReceipt = {
253
+ ...nextReceiptBase(snapshot, input, input.heartbeatAt, [event.eventId]),
254
+ kind: 'task-claim-receipt',
255
+ taskId: task.taskId,
256
+ claim,
257
+ };
258
+ return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
259
+ }
260
+
261
+ export async function expireTaskClaim(
262
+ runtime: TaskManagerRuntime,
263
+ input: ExpireTaskClaimCommand,
264
+ _config: TaskProcessorConfig,
265
+ ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
266
+ const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
267
+ if (replay) return replay;
268
+ const snapshot = await loadExpected(runtime, input);
269
+ const task = assertClaimIdentity(snapshot, input.claim);
270
+ const observedAtMs = validateTimestamp(input.observedAt, 'observedAt');
271
+ const expiresAtMs = validateTimestamp(input.claim.expiresAt, 'claim.expiresAt');
272
+ if (observedAtMs < expiresAtMs) {
273
+ throw new TaskManagerError('claim-not-expired', `claim ${input.claim.claimId} remains live until ${input.claim.expiresAt}`);
274
+ }
275
+ assertTaskTransition(task.taskId, task.status, 'Ready');
276
+ const nextAttempt = task.attempt + 1;
277
+ const nextLeaseEpoch = task.leaseEpoch + 1;
278
+ const nextTask: TaskRecord = {
279
+ ...task,
280
+ status: 'Ready',
281
+ attempt: nextAttempt,
282
+ leaseEpoch: nextLeaseEpoch,
283
+ activeClaim: null,
284
+ updatedAt: input.observedAt,
285
+ };
286
+ const event = {
287
+ ...stateEventBase(snapshot, input, 0, input.observedAt, task, 'Ready'),
288
+ kind: 'task.claim-expired',
289
+ expiredClaim: input.claim,
290
+ nextAttempt,
291
+ nextLeaseEpoch,
292
+ } as const;
293
+ const receipt: TaskTransitionReceipt = {
294
+ ...nextReceiptBase(snapshot, input, input.observedAt, [event.eventId]),
295
+ kind: 'task-transition-receipt',
296
+ taskId: task.taskId,
297
+ };
298
+ return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
299
+ }
300
+
301
+ export async function startTask(
302
+ runtime: TaskManagerRuntime,
303
+ input: StartTaskCommand,
304
+ _config: TaskProcessorConfig,
305
+ ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
306
+ return claimedStateTransition(runtime, input, 'Running', 'task.started', 'task-transition-receipt');
307
+ }
308
+
309
+ export async function waitTask(
310
+ runtime: TaskManagerRuntime,
311
+ input: WaitTaskCommand,
312
+ _config: TaskProcessorConfig,
313
+ ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
314
+ const replay = await replayed<TaskTransitionReceipt>(runtime, input, 'task-transition-receipt');
315
+ if (replay) return replay;
316
+ const snapshot = await loadExpected(runtime, input);
317
+ const task = assertClaimIdentity(snapshot, input.claim);
318
+ assertClaimLive(input.claim, input.occurredAt);
319
+ assertTaskTransition(task.taskId, task.status, 'Waiting');
320
+ const nextTask: TaskRecord = {
321
+ ...task,
322
+ status: 'Waiting',
323
+ profile: input.profile ?? task.profile,
324
+ updatedAt: input.occurredAt,
325
+ };
326
+ const event = {
327
+ ...stateEventBase(snapshot, input, 0, input.occurredAt, task, 'Waiting'),
328
+ kind: 'task.waiting',
329
+ claimId: input.claim.claimId,
330
+ reason: nonEmpty(input.reason, 'reason'),
331
+ ...(input.details === undefined ? {} : { details: input.details }),
332
+ } as const;
333
+ const receipt: TaskTransitionReceipt = {
334
+ ...nextReceiptBase(snapshot, input, input.occurredAt, [event.eventId]),
335
+ kind: 'task-transition-receipt',
336
+ taskId: task.taskId,
337
+ };
338
+ return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
339
+ }
340
+
341
+ export async function settleTask(
342
+ runtime: TaskManagerRuntime,
343
+ input: SettleTaskCommand,
344
+ _config: TaskProcessorConfig,
345
+ ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
346
+ const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
347
+ if (replay) return replay;
348
+ const snapshot = await loadExpected(runtime, input);
349
+ const task = assertClaimIdentity(snapshot, input.claim);
350
+ assertClaimLive(input.claim, input.occurredAt);
351
+ assertTaskTransition(task.taskId, task.status, 'Succeeded');
352
+ const settled: TaskRecord = {
353
+ ...task,
354
+ status: 'Succeeded',
355
+ profile: input.profile ?? task.profile,
356
+ activeClaim: null,
357
+ outputArtifacts: input.outputArtifacts,
358
+ updatedAt: input.occurredAt,
359
+ };
360
+ const tasks = replaceTask(snapshot, settled);
361
+ const events: TaskHistoryEvent[] = [{
362
+ ...stateEventBase(snapshot, input, 0, input.occurredAt, task, 'Succeeded'),
363
+ kind: 'task.settled',
364
+ claimId: input.claim.claimId,
365
+ outputArtifacts: input.outputArtifacts,
366
+ ...(input.result === undefined ? {} : { result: input.result }),
367
+ }];
368
+ for (const candidate of tasks) {
369
+ if (candidate.status !== 'Pending' || !dependenciesSucceeded(snapshot.definition, tasks, candidate.taskId)) continue;
370
+ const ready = { ...candidate, status: 'Ready' as const, updatedAt: input.occurredAt };
371
+ tasks.splice(tasks.findIndex(({ taskId }) => taskId === ready.taskId), 1, ready);
372
+ events.push({
373
+ ...stateEventBase(snapshot, input, events.length, input.occurredAt, candidate, 'Ready'),
374
+ kind: 'task.readied',
375
+ });
376
+ }
377
+ const receipt: TaskSettlementReceipt = {
378
+ ...nextReceiptBase(snapshot, input, input.occurredAt, events.map(({ eventId }) => eventId)),
379
+ kind: 'task-settlement-receipt',
380
+ taskId: task.taskId,
381
+ claimId: input.claim.claimId,
382
+ status: 'Succeeded',
383
+ };
384
+ return admitExisting(runtime, input, snapshot, snapshot.definition, tasks, events, receipt);
385
+ }
386
+
387
+ export async function failTask(
388
+ runtime: TaskManagerRuntime,
389
+ input: FailTaskCommand,
390
+ _config: TaskProcessorConfig,
391
+ ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
392
+ const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
393
+ if (replay) return replay;
394
+ const snapshot = await loadExpected(runtime, input);
395
+ const task = assertClaimIdentity(snapshot, input.claim);
396
+ assertClaimLive(input.claim, input.occurredAt);
397
+ assertTaskTransition(task.taskId, task.status, 'Failed');
398
+ const nextTask: TaskRecord = {
399
+ ...task,
400
+ status: 'Failed',
401
+ profile: input.profile ?? task.profile,
402
+ activeClaim: null,
403
+ updatedAt: input.occurredAt,
404
+ };
405
+ const event = {
406
+ ...stateEventBase(snapshot, input, 0, input.occurredAt, task, 'Failed'),
407
+ kind: 'task.failed',
408
+ claimId: input.claim.claimId,
409
+ failure: input.failure,
410
+ } as const;
411
+ const receipt: TaskSettlementReceipt = {
412
+ ...nextReceiptBase(snapshot, input, input.occurredAt, [event.eventId]),
413
+ kind: 'task-settlement-receipt',
414
+ taskId: task.taskId,
415
+ claimId: input.claim.claimId,
416
+ status: 'Failed',
417
+ };
418
+ return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
419
+ }
420
+
421
+ export async function cancelTask(
422
+ runtime: TaskManagerRuntime,
423
+ input: CancelTaskCommand,
424
+ _config: TaskProcessorConfig,
425
+ ): Promise<TaskProcessorResult<TaskSettlementReceipt>> {
426
+ const replay = await replayed<TaskSettlementReceipt>(runtime, input, 'task-settlement-receipt');
427
+ if (replay) return replay;
428
+ const snapshot = await loadExpected(runtime, input);
429
+ const task = requireTask(snapshot, input.taskId);
430
+ if (isTerminal(task.status)) {
431
+ throw new TaskManagerError('terminal-task-immutable', `task ${task.taskId} is terminal (${task.status})`);
432
+ }
433
+ validateTimestamp(input.cancelledAt, 'cancelledAt');
434
+ if (input.claim) assertClaimIdentity(snapshot, input.claim, task.taskId);
435
+ assertTaskTransition(task.taskId, task.status, 'Cancelled');
436
+ const nextTask: TaskRecord = {
437
+ ...task,
438
+ status: 'Cancelled',
439
+ profile: input.profile ?? task.profile,
440
+ activeClaim: null,
441
+ updatedAt: input.cancelledAt,
442
+ };
443
+ const event = {
444
+ ...stateEventBase(snapshot, input, 0, input.cancelledAt, task, 'Cancelled'),
445
+ kind: 'task.cancelled',
446
+ ...(input.claim === undefined ? {} : { claimId: input.claim.claimId }),
447
+ reason: nonEmpty(input.reason, 'reason'),
448
+ } as const;
449
+ const receipt: TaskSettlementReceipt = {
450
+ ...nextReceiptBase(snapshot, input, input.cancelledAt, [event.eventId]),
451
+ kind: 'task-settlement-receipt',
452
+ taskId: task.taskId,
453
+ ...(input.claim === undefined ? {} : { claimId: input.claim.claimId }),
454
+ status: 'Cancelled',
455
+ };
456
+ return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
457
+ }
458
+
459
+ export async function replanTask(
460
+ runtime: TaskManagerRuntime,
461
+ input: ReplanTaskCommand,
462
+ config: TaskProcessorConfig,
463
+ ): Promise<TaskProcessorResult<TaskReplanReceipt>> {
464
+ const replay = await replayed<TaskReplanReceipt>(runtime, input, 'task-replan-receipt');
465
+ if (replay) return replay;
466
+ const snapshot = await loadExpected(runtime, input);
467
+ validateTimestamp(input.replannedAt, 'replannedAt');
468
+ const definition: TaskSpaceDefinition = {
469
+ ...snapshot.definition,
470
+ tasks: input.plan.tasks,
471
+ relations: input.plan.relations,
472
+ };
473
+ validateTaskSpaceDefinition(definition, normalizeTaskProcessorConfig(config));
474
+ const nextIds = new Set(definition.tasks.map(({ taskId }) => taskId));
475
+ for (const prior of snapshot.tasks) {
476
+ if (!nextIds.has(prior.taskId) && (isTerminal(prior.status) || prior.activeClaim)) {
477
+ throw new TaskManagerError('terminal-task-immutable', `replan cannot remove durable task ${prior.taskId}`);
478
+ }
479
+ }
480
+ const provisional: TaskRecord[] = definition.tasks.map((nextDefinition) => {
481
+ const prior = taskRecordById(snapshot, nextDefinition.taskId);
482
+ if (prior) return { ...prior, definition: nextDefinition, updatedAt: input.replannedAt };
483
+ return {
484
+ kind: 'task-record',
485
+ taskSpaceId: snapshot.taskSpaceId,
486
+ taskId: nextDefinition.taskId,
487
+ definition: nextDefinition,
488
+ profile: nextDefinition.profile,
489
+ status: 'Pending',
490
+ attempt: 0,
491
+ leaseEpoch: 0,
492
+ activeClaim: null,
493
+ outputArtifacts: [],
494
+ createdAt: input.replannedAt,
495
+ updatedAt: input.replannedAt,
496
+ };
497
+ });
498
+ const nextTasks: TaskRecord[] = provisional.map((task) => {
499
+ if (isTerminal(task.status) || task.activeClaim || task.status === 'Running' || task.status === 'Waiting') return task;
500
+ const status: TaskStatus = dependenciesSucceeded(definition, provisional, task.taskId) ? 'Ready' : 'Pending';
501
+ return { ...task, status };
502
+ });
503
+ const affectedTaskIds = [...new Set([...snapshot.tasks.map(({ taskId }) => taskId), ...nextTasks.map(({ taskId }) => taskId)])].sort();
504
+ const event = {
505
+ ...nextEventBase(snapshot, input, 0, input.replannedAt),
506
+ kind: 'task.replanned',
507
+ planId: input.plan.planId,
508
+ affectedTaskIds,
509
+ } as const;
510
+ const receipt: TaskReplanReceipt = {
511
+ ...nextReceiptBase(snapshot, input, input.replannedAt, [event.eventId]),
512
+ kind: 'task-replan-receipt',
513
+ planId: input.plan.planId,
514
+ affectedTaskIds,
515
+ };
516
+ return admitExisting(runtime, input, snapshot, definition, nextTasks, [event], receipt);
517
+ }
518
+
519
+ async function claimedStateTransition(
520
+ runtime: TaskManagerRuntime,
521
+ input: StartTaskCommand,
522
+ toStatus: 'Running',
523
+ eventKind: 'task.started',
524
+ receiptKind: 'task-transition-receipt',
525
+ ): Promise<TaskProcessorResult<TaskTransitionReceipt>> {
526
+ const replay = await replayed<TaskTransitionReceipt>(runtime, input, receiptKind);
527
+ if (replay) return replay;
528
+ const snapshot = await loadExpected(runtime, input);
529
+ const task = assertClaimIdentity(snapshot, input.claim);
530
+ assertClaimLive(input.claim, input.occurredAt);
531
+ assertTaskTransition(task.taskId, task.status, toStatus);
532
+ const nextTask: TaskRecord = {
533
+ ...task,
534
+ status: toStatus,
535
+ profile: input.profile ?? task.profile,
536
+ updatedAt: input.occurredAt,
537
+ };
538
+ const event = {
539
+ ...stateEventBase(snapshot, input, 0, input.occurredAt, task, toStatus),
540
+ kind: eventKind,
541
+ claimId: input.claim.claimId,
542
+ } as const;
543
+ const receipt: TaskTransitionReceipt = {
544
+ ...nextReceiptBase(snapshot, input, input.occurredAt, [event.eventId]),
545
+ kind: receiptKind,
546
+ taskId: task.taskId,
547
+ };
548
+ return admitExisting(runtime, input, snapshot, snapshot.definition, replaceTask(snapshot, nextTask), [event], receipt);
549
+ }
550
+
551
+ async function replayed<Receipt extends TaskCommandReceipt>(
552
+ runtime: TaskManagerRuntime,
553
+ input: TaskCommand,
554
+ receiptKind: Receipt['kind'],
555
+ ): Promise<TaskProcessorResult<Receipt> | undefined> {
556
+ const receipt = await runtime.owner.readReceiptByCommand(input.taskSpaceId, input.commandId);
557
+ if (!receipt) return undefined;
558
+ if (receipt.kind !== receiptKind) {
559
+ throw new TaskManagerError('command-receipt-mismatch', `command ${input.commandId} was already used for ${receipt.kind}`);
560
+ }
561
+ const snapshot = await runtime.owner.readSnapshot(input.taskSpaceId);
562
+ if (!snapshot) {
563
+ throw new TaskManagerError('task-space-not-found', `receipt exists without TaskSpace ${input.taskSpaceId}`);
564
+ }
565
+ return deepImmutableClone({ snapshot, receipt: receipt as Receipt, replayed: true });
566
+ }
567
+
568
+ async function loadExpected(
569
+ runtime: TaskManagerRuntime,
570
+ input: Exclude<TaskCommand, CreateTaskSpaceCommand>,
571
+ ): Promise<TaskSpaceSnapshot> {
572
+ const snapshot = await runtime.owner.readSnapshot(input.taskSpaceId);
573
+ if (!snapshot) throw new TaskManagerError('task-space-not-found', `TaskSpace ${input.taskSpaceId} not found`);
574
+ if (snapshot.taskSpaceId !== input.taskSpaceId || snapshot.definition.taskSpaceId !== input.taskSpaceId) {
575
+ throw new TaskManagerError('cross-space-identity', 'snapshot contains a cross-space identity');
576
+ }
577
+ if (snapshot.revision !== input.expectedRevision) {
578
+ throw new TaskManagerError(
579
+ 'revision-conflict',
580
+ `TaskSpace ${input.taskSpaceId} expected revision ${input.expectedRevision}, actual ${snapshot.revision}`,
581
+ );
582
+ }
583
+ return snapshot;
584
+ }
585
+
586
+ async function admitExisting<Receipt extends TaskCommandReceipt>(
587
+ runtime: TaskManagerRuntime,
588
+ input: Exclude<TaskCommand, CreateTaskSpaceCommand>,
589
+ prior: TaskSpaceSnapshot,
590
+ definition: TaskSpaceDefinition,
591
+ tasks: readonly TaskRecord[],
592
+ events: readonly TaskHistoryEvent[],
593
+ receipt: Receipt,
594
+ ): Promise<TaskProcessorResult<Receipt>> {
595
+ const snapshot: TaskSpaceSnapshot = {
596
+ ...prior,
597
+ revision: prior.revision + 1,
598
+ definition,
599
+ tasks,
600
+ historyEventIds: [...prior.historyEventIds, ...events.map(({ eventId }) => eventId)],
601
+ issuedReceiptIds: [...prior.issuedReceiptIds, receipt.receiptId],
602
+ updatedAt: events.at(-1)?.occurredAt ?? prior.updatedAt,
603
+ };
604
+ return admit(runtime, input, prior.revision, snapshot, events, receipt, []);
605
+ }
606
+
607
+ async function admit<Receipt extends TaskCommandReceipt>(
608
+ runtime: TaskManagerRuntime,
609
+ input: TaskCommand,
610
+ expectedRevision: number | null,
611
+ snapshot: TaskSpaceSnapshot,
612
+ events: readonly TaskHistoryEvent[],
613
+ receipt: Receipt,
614
+ artifacts: readonly TaskArtifactBody[],
615
+ ): Promise<TaskProcessorResult<Receipt>> {
616
+ const result = await runtime.owner.compareAndSwap({
617
+ kind: 'task-space-commit',
618
+ taskSpaceId: input.taskSpaceId,
619
+ expectedRevision,
620
+ commandId: input.commandId,
621
+ snapshot,
622
+ events,
623
+ receipts: [receipt],
624
+ artifacts,
625
+ });
626
+ if (result.kind === 'revision-conflict') {
627
+ throw new TaskManagerError(
628
+ 'revision-conflict',
629
+ `TaskSpace ${input.taskSpaceId} expected revision ${String(result.expectedRevision)}, actual ${String(result.actualRevision)}`,
630
+ );
631
+ }
632
+ if (result.receipt.kind !== receipt.kind) {
633
+ throw new TaskManagerError('command-receipt-mismatch', `owner returned ${result.receipt.kind}, expected ${receipt.kind}`);
634
+ }
635
+ return deepImmutableClone({
636
+ snapshot: result.snapshot,
637
+ receipt: result.receipt as Receipt,
638
+ replayed: result.kind === 'command-replayed',
639
+ });
640
+ }
641
+
642
+ function requireTask(snapshot: TaskSpaceSnapshot, taskId: TaskId): TaskRecord {
643
+ const task = taskRecordById(snapshot, taskId);
644
+ if (!task) throw new TaskManagerError('task-not-found', `task ${taskId} not found`);
645
+ if (task.taskSpaceId !== snapshot.taskSpaceId) {
646
+ throw new TaskManagerError('cross-space-identity', `task ${taskId} belongs to ${task.taskSpaceId}`);
647
+ }
648
+ return task;
649
+ }
650
+
651
+ function replaceTask(snapshot: TaskSpaceSnapshot, replacement: TaskRecord): TaskRecord[] {
652
+ return snapshot.tasks.map((task) => task.taskId === replacement.taskId ? replacement : task);
653
+ }
654
+
655
+ function dependenciesSucceeded(
656
+ definition: TaskSpaceDefinition,
657
+ tasks: readonly TaskRecord[],
658
+ taskId: TaskId,
659
+ ): boolean {
660
+ const predecessors = predecessorIds(definition, taskId);
661
+ return predecessors.length === 0 || predecessors.every((predecessorId) =>
662
+ tasks.find((task) => task.taskId === predecessorId)?.status === 'Succeeded');
663
+ }
664
+
665
+ function eventBase(
666
+ input: TaskCommand,
667
+ index: number,
668
+ fromRevision: number,
669
+ toRevision: number,
670
+ occurredAt: TaskTimestamp,
671
+ ) {
672
+ return {
673
+ eventId: stableId('event', input.commandId, index),
674
+ commandId: input.commandId,
675
+ taskSpaceId: input.taskSpaceId,
676
+ sequence: index,
677
+ fromRevision,
678
+ toRevision,
679
+ occurredAt,
680
+ } as const;
681
+ }
682
+
683
+ function nextEventBase(
684
+ snapshot: TaskSpaceSnapshot,
685
+ input: TaskCommand,
686
+ index: number,
687
+ occurredAt: TaskTimestamp,
688
+ ) {
689
+ return {
690
+ ...eventBase(input, index, snapshot.revision, snapshot.revision + 1, occurredAt),
691
+ sequence: snapshot.historyEventIds.length + index,
692
+ } as const;
693
+ }
694
+
695
+ function stateEventBase(
696
+ snapshot: TaskSpaceSnapshot,
697
+ input: TaskCommand,
698
+ index: number,
699
+ occurredAt: TaskTimestamp,
700
+ task: TaskRecord,
701
+ toStatus: TaskStatus,
702
+ ) {
703
+ return {
704
+ ...nextEventBase(snapshot, input, index, occurredAt),
705
+ taskId: task.taskId,
706
+ fromStatus: task.status,
707
+ toStatus,
708
+ } as const;
709
+ }
710
+
711
+ function receiptBase(
712
+ input: TaskCommand,
713
+ fromRevision: number,
714
+ toRevision: number,
715
+ committedAt: TaskTimestamp,
716
+ eventIds: readonly string[],
717
+ ) {
718
+ return {
719
+ receiptId: stableId('receipt', input.commandId),
720
+ commandId: input.commandId,
721
+ taskSpaceId: input.taskSpaceId,
722
+ fromRevision,
723
+ toRevision,
724
+ eventIds,
725
+ committedAt,
726
+ } as const;
727
+ }
728
+
729
+ function nextReceiptBase(
730
+ snapshot: TaskSpaceSnapshot,
731
+ input: TaskCommand,
732
+ committedAt: TaskTimestamp,
733
+ eventIds: readonly string[],
734
+ ) {
735
+ return receiptBase(input, snapshot.revision, snapshot.revision + 1, committedAt, eventIds);
736
+ }
737
+
738
+ function stableId(namespace: string, commandId: string, index?: number): string {
739
+ return `${namespace}:${encodeURIComponent(commandId)}${index === undefined ? '' : `:${index}`}`;
740
+ }
741
+
742
+ function nonEmpty(value: string, field: string): string {
743
+ if (value.length === 0 || value.trim() !== value) {
744
+ throw new TaskManagerError('invalid-task-definition', `${field} must be non-empty and trimmed`);
745
+ }
746
+ return value;
747
+ }