@pennixrv/trellis 0.7.1-beta.14 → 0.7.1-beta.16

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.
@@ -1,8 +1,10 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
3
5
  import { isTaskPhaseId, parseTaskWorkflowState, } from "@pennixrv/trellis-core/task";
4
6
  import { assertCoordinationTransition, readTeamworkEvents, readCoordinationState, recordCoordinationEvent, } from "@pennixrv/trellis-core/teamwork";
5
- import { cleanupSettledTeamworkArtifacts, readTeammateReport, writeTeammateQueueFact, writeTeammateClaim, writeTeammateDisposition, validateTeamworkPlan, } from "./artifact.js";
7
+ import { TeammateReportReadError, cleanupSettledTeamworkArtifacts, readTeammateReport, writeTeammateQueueFact, writeTeammateClaim, writeTeammateDisposition, validateTeamworkPlan, } from "./artifact.js";
6
8
  import { assertTeammateAdapterContract, assertTeammateHostCapability, } from "./teammate-adapters.js";
7
9
  import { effectiveTeammateCapacity, resolveTeammateRuntimeConfig, } from "./teammate-config.js";
8
10
  export function assertTeamworkPlanCurrent(plan, taskRoot) {
@@ -35,6 +37,165 @@ export function assertTeamworkPlanCurrent(plan, taskRoot) {
35
37
  throw new Error("Teamwork plan does not match the current active task phase and revisions");
36
38
  }
37
39
  }
40
+ function terminalReceiptFromProjection(plan, projection) {
41
+ const mismatch = (reason) => {
42
+ throw new Error(`batch '${plan.batchId}' ${reason}`);
43
+ };
44
+ if (projection.issues.length > 0)
45
+ mismatch("has coordination projection issues");
46
+ const batch = projection.batches.find((entry) => entry.batchId === plan.batchId);
47
+ if (!batch)
48
+ return mismatch("does not match the frozen plan");
49
+ if (batch.planHash !== plan.planHash)
50
+ return mismatch("does not match the frozen plan");
51
+ if (batch.state !== "ALL_ACCEPTED" && batch.state !== "SETTLED_WITH_FAILURE")
52
+ mismatch("is not terminal");
53
+ const effectiveCapacity = batch.effectiveCapacity;
54
+ if (typeof effectiveCapacity !== "number" ||
55
+ !Number.isSafeInteger(effectiveCapacity) ||
56
+ effectiveCapacity < 1)
57
+ return mismatch("has no valid effective capacity");
58
+ if (projection.leases.some((lease) => lease.batchId === plan.batchId))
59
+ mismatch("still owns capacity leases");
60
+ const projectedAssignments = projection.assignments.filter((assignment) => assignment.batchId === plan.batchId);
61
+ if (projectedAssignments.length !== plan.assignments.length ||
62
+ new Set(projectedAssignments.map((assignment) => assignment.assignmentId))
63
+ .size !== plan.assignments.length)
64
+ mismatch("does not contain the exact frozen assignment set");
65
+ const assignmentById = new Map(projectedAssignments.map((assignment) => [
66
+ assignment.assignmentId,
67
+ assignment,
68
+ ]));
69
+ const planAssignmentIds = new Set(plan.assignments.map((assignment) => assignment.assignmentId));
70
+ const attempts = projection.attempts.filter((attempt) => attempt.batchId === plan.batchId);
71
+ const teammates = projection.teammates.filter((teammate) => teammate.batchId === plan.batchId);
72
+ if (attempts.length !== teammates.length)
73
+ mismatch("has unmatched provider attempts and teammates");
74
+ for (const attempt of attempts) {
75
+ if (!attempt.assignmentId ||
76
+ !planAssignmentIds.has(attempt.assignmentId) ||
77
+ !attempt.teammateId ||
78
+ attempt.terminal !== true ||
79
+ !["REPORT_SUBMITTED", "FAILED", "FINAL_FAILED"].includes(attempt.state))
80
+ mismatch("has an unresolved provider attempt");
81
+ const teammate = teammates.find((entry) => entry.attemptId === attempt.attemptId &&
82
+ entry.teammateId === attempt.teammateId);
83
+ if (teammate?.terminal !== true ||
84
+ (teammate?.state !== "REPORT_SUBMITTED" && teammate?.state !== "FAILED"))
85
+ mismatch("has an unresolved teammate terminal fact");
86
+ }
87
+ const assignments = plan.assignments.map((assignment) => {
88
+ const projected = assignmentById.get(assignment.assignmentId);
89
+ if (!projected ||
90
+ (projected.state !== "ACCEPTED" && projected.state !== "FINAL_FAILED"))
91
+ return mismatch(`assignment '${assignment.assignmentId}' is not settled`);
92
+ if (projected.state === "ACCEPTED") {
93
+ const acceptedAttempt = attempts.find((attempt) => attempt.attemptId === projected.attemptId &&
94
+ attempt.assignmentId === assignment.assignmentId);
95
+ if (acceptedAttempt?.state !== "REPORT_SUBMITTED")
96
+ return mismatch(`assignment '${assignment.assignmentId}' has no accepted attempt`);
97
+ }
98
+ return {
99
+ assignmentId: assignment.assignmentId,
100
+ status: projected.state,
101
+ };
102
+ });
103
+ const status = assignments.every((item) => item.status === "ACCEPTED")
104
+ ? "ALL_ACCEPTED"
105
+ : "SETTLED_WITH_FAILURE";
106
+ if (batch.state !== status)
107
+ mismatch("terminal state disagrees with its assignment dispositions");
108
+ const retryCounts = Object.fromEntries(plan.assignments.flatMap((assignment) => {
109
+ const count = assignmentById.get(assignment.assignmentId)?.retryCount;
110
+ if (count === undefined)
111
+ return mismatch(`assignment '${assignment.assignmentId}' is missing`);
112
+ const started = attempts.some((attempt) => attempt.assignmentId === assignment.assignmentId);
113
+ return started || count > 0 ? [[assignment.assignmentId, count]] : [];
114
+ }));
115
+ const evaluationGroups = [
116
+ ...new Set(plan.assignments
117
+ .map((assignment) => assignment.evaluationGroup?.id)
118
+ .filter((id) => id !== undefined)),
119
+ ].map((id) => ({
120
+ id,
121
+ status: plan.assignments
122
+ .filter((assignment) => assignment.evaluationGroup?.id === id)
123
+ .every((assignment) => assignmentById.get(assignment.assignmentId)?.state === "ACCEPTED")
124
+ ? "COMPLETE"
125
+ : "INCOMPLETE",
126
+ }));
127
+ return {
128
+ schemaVersion: 1,
129
+ batchId: plan.batchId,
130
+ status,
131
+ assignments,
132
+ capacity: effectiveCapacity,
133
+ retryCounts,
134
+ evaluationGroups,
135
+ };
136
+ }
137
+ function reconciliationReceipt(plan, projection, capacity) {
138
+ return {
139
+ schemaVersion: 1,
140
+ batchId: plan.batchId,
141
+ status: "RECONCILIATION_REQUIRED",
142
+ assignments: plan.assignments.map((item) => {
143
+ const current = projection.assignments.find((entry) => entry.batchId === plan.batchId &&
144
+ entry.assignmentId === item.assignmentId);
145
+ return {
146
+ assignmentId: item.assignmentId,
147
+ status: current?.state === "ACCEPTED" || current?.state === "FINAL_FAILED"
148
+ ? current.state
149
+ : "UNKNOWN",
150
+ };
151
+ }),
152
+ capacity: projection.batches.find((entry) => entry.batchId === plan.batchId)
153
+ ?.effectiveCapacity ?? capacity,
154
+ retryCounts: Object.fromEntries(projection.assignments
155
+ .filter((entry) => entry.batchId === plan.batchId)
156
+ .map((entry) => [entry.assignmentId, entry.retryCount])),
157
+ };
158
+ }
159
+ async function recordRecoveryReconciliation(input) {
160
+ const { plan, cwd, by, existingState, projection, capacity, reason } = input;
161
+ if (existingState !== "RECONCILIATION_REQUIRED" &&
162
+ existingState !== "ALL_ACCEPTED" &&
163
+ existingState !== "SETTLED_WITH_FAILURE") {
164
+ await appendTransition({
165
+ sessionId: plan.sessionId,
166
+ cwd,
167
+ by,
168
+ batchId: plan.batchId,
169
+ entity: "batch",
170
+ id: plan.batchId,
171
+ state: "RECONCILIATION_REQUIRED",
172
+ states: new Map([[plan.batchId, existingState]]),
173
+ planHash: plan.planHash,
174
+ payload: { recoveredAfterRestart: true },
175
+ });
176
+ }
177
+ const receipt = reconciliationReceipt(plan, projection, capacity);
178
+ await appendAction({
179
+ sessionId: plan.sessionId,
180
+ cwd,
181
+ by,
182
+ batchId: plan.batchId,
183
+ entity: "batch",
184
+ action: "reconciliation_required",
185
+ reason,
186
+ });
187
+ await appendAction({
188
+ sessionId: plan.sessionId,
189
+ cwd,
190
+ by,
191
+ batchId: plan.batchId,
192
+ entity: "batch",
193
+ action: "receipt_recorded",
194
+ id: `${plan.batchId}:RECONCILIATION_REQUIRED`,
195
+ payload: { ...receipt, reason },
196
+ });
197
+ return receipt;
198
+ }
38
199
  async function recoverExistingBatch(input) {
39
200
  const projection = await readCoordinationState({
40
201
  sessionId: input.plan.sessionId,
@@ -50,64 +211,54 @@ async function recoverExistingBatch(input) {
50
211
  sessionId: input.plan.sessionId,
51
212
  cwd: input.cwd,
52
213
  });
53
- const saved = events.find((event) => event.kind === "coordination" &&
54
- event.batchId === input.plan.batchId &&
55
- event.action === "receipt_recorded" &&
56
- event.payload &&
57
- (event.payload.status === "ALL_ACCEPTED" ||
58
- event.payload.status === "SETTLED_WITH_FAILURE"));
59
- if (saved?.kind === "coordination" && saved.payload) {
60
- return saved.payload;
61
- }
62
- const states = new Map([
63
- [input.plan.batchId, existing.state],
64
- ]);
65
- if (existing.state !== "RECONCILIATION_REQUIRED") {
66
- await appendTransition({
67
- sessionId: input.plan.sessionId,
68
- by: input.by,
69
- batchId: input.plan.batchId,
70
- entity: "batch",
71
- id: input.plan.batchId,
72
- state: "RECONCILIATION_REQUIRED",
73
- states,
74
- planHash: input.plan.planHash,
75
- payload: { recoveredAfterRestart: true },
76
- });
214
+ if (existing.state === "ALL_ACCEPTED" ||
215
+ existing.state === "SETTLED_WITH_FAILURE") {
216
+ let receipt;
217
+ try {
218
+ receipt = terminalReceiptFromProjection(input.plan, projection);
219
+ }
220
+ catch (error) {
221
+ return recordRecoveryReconciliation({
222
+ ...input,
223
+ existingState: existing.state,
224
+ projection,
225
+ reason: error instanceof Error ? error.message : String(error),
226
+ });
227
+ }
228
+ const receipts = events.filter((event) => event.kind === "coordination" &&
229
+ event.batchId === input.plan.batchId &&
230
+ event.action === "receipt_recorded");
231
+ if (receipts.length > 1 ||
232
+ (receipts.length === 1 &&
233
+ !isDeepStrictEqual(receipts[0]?.payload, receipt))) {
234
+ return recordRecoveryReconciliation({
235
+ ...input,
236
+ existingState: existing.state,
237
+ projection,
238
+ reason: "saved terminal receipt disagrees with durable coordination facts",
239
+ });
240
+ }
241
+ if (receipts.length === 0) {
242
+ await appendAction({
243
+ sessionId: input.plan.sessionId,
244
+ cwd: input.cwd,
245
+ by: input.by,
246
+ batchId: input.plan.batchId,
247
+ entity: "batch",
248
+ action: "receipt_recorded",
249
+ id: `${input.plan.batchId}:${receipt.status}`,
250
+ payload: { ...receipt },
251
+ });
252
+ }
253
+ await assertTeamworkReceiptDurable(input.plan, receipt, input.cwd);
254
+ return receipt;
77
255
  }
78
- const assignments = input.plan.assignments.map((item) => {
79
- const current = projection.assignments.find((entry) => entry.batchId === input.plan.batchId &&
80
- entry.assignmentId === item.assignmentId);
81
- return {
82
- assignmentId: item.assignmentId,
83
- status: current?.state === "ACCEPTED" || current?.state === "FINAL_FAILED"
84
- ? current.state
85
- : "UNKNOWN",
86
- };
87
- });
88
- const retryCounts = Object.fromEntries(projection.assignments
89
- .filter((entry) => entry.batchId === input.plan.batchId)
90
- .map((entry) => [entry.assignmentId, entry.retryCount]));
91
- const receipt = {
92
- schemaVersion: 1,
93
- batchId: input.plan.batchId,
94
- status: "RECONCILIATION_REQUIRED",
95
- assignments,
96
- capacity: existing.effectiveCapacity ?? input.capacity,
97
- retryCounts,
98
- };
99
- await recordCoordinationEvent({
100
- sessionId: input.plan.sessionId,
101
- cwd: input.cwd,
102
- by: input.by,
103
- batchId: input.plan.batchId,
104
- entity: "batch",
105
- action: "receipt_recorded",
106
- receiptId: `${input.plan.batchId}:RECONCILIATION_REQUIRED`,
107
- payload: { ...receipt, reason: "recovered batch requires reconciliation" },
108
- idempotencyKey: `${input.plan.batchId}:RECONCILIATION_REQUIRED:receipt`,
256
+ return recordRecoveryReconciliation({
257
+ ...input,
258
+ existingState: existing.state,
259
+ projection,
260
+ reason: "recovered nonterminal batch requires reconciliation",
109
261
  });
110
- return receipt;
111
262
  }
112
263
  function transitionState(states, key, next, entity) {
113
264
  assertCoordinationTransition(entity, states.get(key), next);
@@ -117,6 +268,7 @@ async function appendTransition(input) {
117
268
  transitionState(input.states, input.id, input.state, input.entity);
118
269
  await recordCoordinationEvent({
119
270
  sessionId: input.sessionId,
271
+ ...(input.cwd ? { cwd: input.cwd } : {}),
120
272
  by: input.by,
121
273
  batchId: input.batchId,
122
274
  entity: input.entity,
@@ -139,6 +291,7 @@ async function appendTransition(input) {
139
291
  async function appendAction(input) {
140
292
  await recordCoordinationEvent({
141
293
  sessionId: input.sessionId,
294
+ ...(input.cwd ? { cwd: input.cwd } : {}),
142
295
  by: input.by,
143
296
  batchId: input.batchId,
144
297
  entity: input.entity,
@@ -203,19 +356,8 @@ export async function runTeamworkCoordinator(input) {
203
356
  capacity,
204
357
  });
205
358
  let guardHeld = true;
206
- let recovered;
207
- try {
208
- recovered = await recoverExistingBatch({ plan, cwd, by, capacity });
209
- }
210
- catch (error) {
211
- await input.guard.release({
212
- batchId: plan.batchId,
213
- planHash: plan.planHash,
214
- capacity,
215
- });
216
- guardHeld = false;
217
- throw error;
218
- }
359
+ // Failed recovery cannot prove that an older provider process exited.
360
+ const recovered = await recoverExistingBatch({ plan, cwd, by, capacity });
219
361
  if (recovered) {
220
362
  if (recovered.status !== "RECONCILIATION_REQUIRED") {
221
363
  await input.guard.release({
@@ -324,8 +466,9 @@ export async function runTeamworkCoordinator(input) {
324
466
  const active = new Map();
325
467
  const waits = new Map();
326
468
  const final = new Map();
327
- const acceptedAttempts = new Map();
469
+ const retainedReports = new Map();
328
470
  const retryCounts = {};
471
+ let admissionClosed = false;
329
472
  async function reconcile(reason) {
330
473
  await appendAction({
331
474
  sessionId: plan.sessionId,
@@ -368,9 +511,39 @@ export async function runTeamworkCoordinator(input) {
368
511
  });
369
512
  return receipt;
370
513
  }
514
+ async function failPending(reason) {
515
+ admissionClosed = true;
516
+ while (pending.length > 0) {
517
+ const assignment = pending.shift();
518
+ if (!assignment)
519
+ continue;
520
+ await appendTransition({
521
+ sessionId: plan.sessionId,
522
+ by,
523
+ batchId: plan.batchId,
524
+ entity: "assignment",
525
+ id: assignment.assignmentId,
526
+ state: "FINAL_FAILED",
527
+ states: assignmentStates,
528
+ assignmentId: assignment.assignmentId,
529
+ retryCount: retryCounts[assignment.assignmentId] ?? 0,
530
+ payload: { reason, dispatched: false },
531
+ });
532
+ final.set(assignment.assignmentId, "FINAL_FAILED");
533
+ }
534
+ }
371
535
  async function dispatch(slot, assignment, retryCount) {
372
- const attemptId = `${assignment.assignmentId}.attempt.${retryCount}`;
373
- const teammateId = `${assignment.assignmentId}.teammate.${retryCount}`;
536
+ const boundedId = (kind) => {
537
+ const candidate = `${assignment.assignmentId}.${kind}.${retryCount}`;
538
+ return candidate.length <= 64
539
+ ? candidate
540
+ : crypto
541
+ .createHash("sha256")
542
+ .update(`${assignment.assignmentId}:${kind}:${retryCount}`)
543
+ .digest("hex");
544
+ };
545
+ const attemptId = boundedId("attempt");
546
+ const teammateId = boundedId("teammate");
374
547
  const frozen = frozenLaunches.get(assignment.assignmentId);
375
548
  if (!frozen)
376
549
  throw new Error(`missing frozen launch for '${assignment.assignmentId}'`);
@@ -506,6 +679,7 @@ export async function runTeamworkCoordinator(input) {
506
679
  teammateId,
507
680
  slot,
508
681
  retryCount,
682
+ transitionKey: `attempt:${attemptId}`,
509
683
  ...(context.providerSessionId
510
684
  ? { providerSessionId: context.providerSessionId }
511
685
  : {}),
@@ -558,7 +732,63 @@ export async function runTeamworkCoordinator(input) {
558
732
  });
559
733
  guardHeld = false;
560
734
  }
735
+ async function resumeFromWait(current) {
736
+ if (assignmentStates.get(current.assignment.assignmentId) !==
737
+ "WAITING_FOR_MAIN_COMMAND")
738
+ return;
739
+ const transitionKey = `continuation:${current.continuations}`;
740
+ await appendTransition({
741
+ sessionId: plan.sessionId,
742
+ by,
743
+ batchId: plan.batchId,
744
+ entity: "attempt",
745
+ id: current.context.attemptId,
746
+ state: "RUNNING",
747
+ states: attemptStates,
748
+ assignmentId: current.assignment.assignmentId,
749
+ attemptId: current.context.attemptId,
750
+ teammateId: current.context.teammateId,
751
+ retryCount: current.retryCount,
752
+ ...(current.providerSessionId
753
+ ? { providerSessionId: current.providerSessionId }
754
+ : {}),
755
+ transitionKey,
756
+ });
757
+ await appendTransition({
758
+ sessionId: plan.sessionId,
759
+ by,
760
+ batchId: plan.batchId,
761
+ entity: "teammate",
762
+ id: current.context.teammateId,
763
+ state: "RUNNING",
764
+ states: teammateStates,
765
+ attemptId: current.context.attemptId,
766
+ teammateId: current.context.teammateId,
767
+ ...(current.providerSessionId
768
+ ? { providerSessionId: current.providerSessionId }
769
+ : {}),
770
+ transitionKey,
771
+ });
772
+ await appendTransition({
773
+ sessionId: plan.sessionId,
774
+ by,
775
+ batchId: plan.batchId,
776
+ entity: "assignment",
777
+ id: current.assignment.assignmentId,
778
+ state: "EXECUTING",
779
+ states: assignmentStates,
780
+ assignmentId: current.assignment.assignmentId,
781
+ attemptId: current.context.attemptId,
782
+ teammateId: current.context.teammateId,
783
+ retryCount: current.retryCount,
784
+ ...(current.providerSessionId
785
+ ? { providerSessionId: current.providerSessionId }
786
+ : {}),
787
+ transitionKey,
788
+ });
789
+ }
561
790
  async function cancelAndDrain(reason, terminal) {
791
+ await failPending(reason);
562
792
  if (batchStates.get(plan.batchId) === "RUNNING") {
563
793
  await appendTransition({
564
794
  sessionId: plan.sessionId,
@@ -571,52 +801,68 @@ export async function runTeamworkCoordinator(input) {
571
801
  payload: { reason },
572
802
  });
573
803
  }
574
- if (!input.runtime.cancel && active.size > 0 && !terminal) {
575
- return reconcile(`host cannot confirm cancellation: ${reason}`);
576
- }
577
- for (const current of [...active.values()]) {
578
- let result;
579
- if (terminal?.current === current) {
580
- result = terminal.result;
804
+ const cancel = input.runtime.cancel;
805
+ const outcomes = await Promise.all([...active.values()].map(async (current) => {
806
+ if (terminal?.current === current)
807
+ return { current, result: terminal.result };
808
+ if (!cancel)
809
+ return {
810
+ current,
811
+ error: new Error(`host cannot cancel active attempt: ${reason}`),
812
+ };
813
+ try {
814
+ return { current, result: await cancel(current.context) };
581
815
  }
582
- else {
583
- try {
584
- if (!input.runtime.cancel)
585
- return reconcile(`host cannot cancel active attempt: ${reason}`);
586
- result = await input.runtime.cancel(current.context);
587
- }
588
- catch (error) {
589
- return reconcile(error instanceof Error ? error.message : String(error));
590
- }
816
+ catch (error) {
817
+ return { current, error };
818
+ }
819
+ }));
820
+ let cancellationFailure;
821
+ for (const outcome of outcomes) {
822
+ const { current } = outcome;
823
+ if ("error" in outcome) {
824
+ cancellationFailure ??=
825
+ outcome.error instanceof Error
826
+ ? outcome.error.message
827
+ : String(outcome.error);
828
+ continue;
591
829
  }
830
+ const { result } = outcome;
592
831
  if (result.kind === "reconciliation" ||
593
832
  result.kind === "timeout" ||
594
833
  result.terminal !== true) {
595
- return reconcile(`attempt cancellation is not terminal: ${reason}`);
834
+ cancellationFailure ??= `attempt cancellation is not terminal: ${reason}`;
835
+ continue;
596
836
  }
597
837
  if (result.providerSessionId &&
598
838
  current.providerSessionId &&
599
839
  result.providerSessionId !== current.providerSessionId) {
600
- return reconcile("cancellation returned a different host session");
840
+ cancellationFailure ??=
841
+ "cancellation returned a different host session";
842
+ continue;
601
843
  }
602
844
  if (result.providerSessionId) {
603
845
  current.providerSessionId = result.providerSessionId;
604
846
  current.context.providerSessionId = result.providerSessionId;
605
847
  }
606
848
  try {
607
- if (result.kind === "report")
849
+ await resumeFromWait(current);
850
+ if (result.kind === "report") {
608
851
  await complete(current, false);
852
+ }
609
853
  else
610
854
  await fail(current, result.reason ?? "cancelled by coordinator", false);
611
855
  }
612
856
  catch (error) {
613
- return reconcile(error instanceof Error ? error.message : String(error));
857
+ cancellationFailure ??=
858
+ error instanceof Error ? error.message : String(error);
859
+ continue;
614
860
  }
615
861
  active.delete(current.slot);
616
862
  waits.delete(current.slot);
617
863
  }
618
- if (pending.length > 0)
619
- return reconcile(`queued assignment remains after cancellation: ${reason}`);
864
+ if (cancellationFailure)
865
+ return reconcile(cancellationFailure);
620
866
  return settle();
621
867
  }
622
868
  async function fail(activeWork, reason, allowRetry = true) {
@@ -653,7 +899,7 @@ export async function runTeamworkCoordinator(input) {
653
899
  : {}),
654
900
  terminal: true,
655
901
  });
656
- if (allowRetry && retryCount < assignment.maxRetries) {
902
+ if (allowRetry && !admissionClosed && retryCount < assignment.maxRetries) {
657
903
  await release(activeWork);
658
904
  retryCounts[assignment.assignmentId] = retryCount + 1;
659
905
  pending.unshift(assignment);
@@ -739,7 +985,7 @@ export async function runTeamworkCoordinator(input) {
739
985
  });
740
986
  await release(activeWork);
741
987
  final.set(assignment.assignmentId, "ACCEPTED");
742
- acceptedAttempts.set(assignment.assignmentId, context.attemptId);
988
+ retainedReports.set(assignment.assignmentId, context.attemptId);
743
989
  }
744
990
  async function complete(activeWork, allowRetry = true) {
745
991
  const { assignment, context, retryCount } = activeWork;
@@ -778,9 +1024,20 @@ export async function runTeamworkCoordinator(input) {
778
1024
  });
779
1025
  }
780
1026
  catch (error) {
1027
+ if (error instanceof TeammateReportReadError && error.kind !== "missing")
1028
+ throw error;
781
1029
  await fail(activeWork, error instanceof Error ? error.message : String(error), allowRetry);
782
1030
  return;
783
1031
  }
1032
+ if (report.status === "blocked") {
1033
+ retainedReports.set(assignment.assignmentId, context.attemptId);
1034
+ await fail(activeWork, `teammate reported blocked: ${report.evidence[0]?.summary ?? "blocker evidence missing"}`, false);
1035
+ return;
1036
+ }
1037
+ if (report.status !== "complete") {
1038
+ await fail(activeWork, `teammate reported ${report.status}`, allowRetry);
1039
+ return;
1040
+ }
784
1041
  if (input.runtime.validateReport &&
785
1042
  !(await input.runtime.validateReport(context, report))) {
786
1043
  await fail(activeWork, "report rejected by acceptance contract", allowRetry);
@@ -866,7 +1123,7 @@ export async function runTeamworkCoordinator(input) {
866
1123
  const cleanupFailures = cleanupSettledTeamworkArtifacts({
867
1124
  taskRoot: input.taskRoot,
868
1125
  assignments: plan.assignments,
869
- acceptedAttempts,
1126
+ retainedReports,
870
1127
  });
871
1128
  if (cleanupFailures.length > 0) {
872
1129
  return reconcile(`Teamwork batch cleanup failed: ${cleanupFailures.join("; ")}`);
@@ -914,12 +1171,25 @@ export async function runTeamworkCoordinator(input) {
914
1171
  id: `${plan.batchId}:${status}`,
915
1172
  payload: { ...receipt },
916
1173
  });
917
- await assertTeamworkReceiptDurable(plan.sessionId, plan.batchId, plan.planHash, cwd);
1174
+ await assertTeamworkReceiptDurable(plan, receipt, cwd);
918
1175
  await releaseGuard();
919
1176
  return receipt;
920
1177
  }
921
1178
  async function fill() {
1179
+ if (admissionClosed)
1180
+ return;
922
1181
  while (active.size < capacity && pending.length > 0) {
1182
+ if (Date.parse(plan.deadline) <= Date.now()) {
1183
+ await failPending("batch deadline elapsed before dispatch");
1184
+ return;
1185
+ }
1186
+ try {
1187
+ assertTeamworkPlanCurrent(plan, input.taskRoot);
1188
+ }
1189
+ catch (error) {
1190
+ await failPending(`task phase changed before dispatch: ${error instanceof Error ? error.message : String(error)}`);
1191
+ return;
1192
+ }
923
1193
  const assignment = pending.shift();
924
1194
  if (!assignment)
925
1195
  return;
@@ -987,162 +1257,116 @@ export async function runTeamworkCoordinator(input) {
987
1257
  result: next.result,
988
1258
  });
989
1259
  }
1260
+ try {
1261
+ await resumeFromWait(current);
1262
+ }
1263
+ catch (error) {
1264
+ return reconcile(error instanceof Error ? error.message : String(error));
1265
+ }
990
1266
  let releaseSlot = true;
991
1267
  if (next.result.kind === "timeout") {
992
1268
  current.continuations += 1;
993
- if (current.continuations > 8) {
994
- return reconcile("continuation limit exceeded");
1269
+ if (Date.now() >= Date.parse(plan.deadline))
1270
+ return cancelAndDrain("batch deadline elapsed");
1271
+ const transitionKey = `continuation:${current.continuations}`;
1272
+ await appendTransition({
1273
+ sessionId: plan.sessionId,
1274
+ by,
1275
+ batchId: plan.batchId,
1276
+ entity: "attempt",
1277
+ id: current.context.attemptId,
1278
+ state: "TIMEOUT_YIELDED",
1279
+ states: attemptStates,
1280
+ assignmentId: current.assignment.assignmentId,
1281
+ attemptId: current.context.attemptId,
1282
+ teammateId: current.context.teammateId,
1283
+ retryCount: current.retryCount,
1284
+ ...(current.providerSessionId
1285
+ ? { providerSessionId: current.providerSessionId }
1286
+ : {}),
1287
+ transitionKey,
1288
+ });
1289
+ await appendTransition({
1290
+ sessionId: plan.sessionId,
1291
+ by,
1292
+ batchId: plan.batchId,
1293
+ entity: "teammate",
1294
+ id: current.context.teammateId,
1295
+ state: "TIMEOUT_YIELDED",
1296
+ states: teammateStates,
1297
+ attemptId: current.context.attemptId,
1298
+ teammateId: current.context.teammateId,
1299
+ ...(current.providerSessionId
1300
+ ? { providerSessionId: current.providerSessionId }
1301
+ : {}),
1302
+ transitionKey,
1303
+ });
1304
+ await appendTransition({
1305
+ sessionId: plan.sessionId,
1306
+ by,
1307
+ batchId: plan.batchId,
1308
+ entity: "assignment",
1309
+ id: current.assignment.assignmentId,
1310
+ state: "WAITING_FOR_MAIN_COMMAND",
1311
+ states: assignmentStates,
1312
+ assignmentId: current.assignment.assignmentId,
1313
+ attemptId: current.context.attemptId,
1314
+ teammateId: current.context.teammateId,
1315
+ retryCount: current.retryCount,
1316
+ ...(current.providerSessionId
1317
+ ? { providerSessionId: current.providerSessionId }
1318
+ : {}),
1319
+ transitionKey,
1320
+ });
1321
+ await appendTransition({
1322
+ sessionId: plan.sessionId,
1323
+ by,
1324
+ batchId: plan.batchId,
1325
+ entity: "attempt",
1326
+ id: current.context.attemptId,
1327
+ state: "WAITING_FOR_MAIN_COMMAND",
1328
+ states: attemptStates,
1329
+ assignmentId: current.assignment.assignmentId,
1330
+ attemptId: current.context.attemptId,
1331
+ teammateId: current.context.teammateId,
1332
+ retryCount: current.retryCount,
1333
+ ...(current.providerSessionId
1334
+ ? { providerSessionId: current.providerSessionId }
1335
+ : {}),
1336
+ transitionKey,
1337
+ });
1338
+ await appendTransition({
1339
+ sessionId: plan.sessionId,
1340
+ by,
1341
+ batchId: plan.batchId,
1342
+ entity: "teammate",
1343
+ id: current.context.teammateId,
1344
+ state: "WAITING_FOR_MAIN_COMMAND",
1345
+ states: teammateStates,
1346
+ attemptId: current.context.attemptId,
1347
+ teammateId: current.context.teammateId,
1348
+ ...(current.providerSessionId
1349
+ ? { providerSessionId: current.providerSessionId }
1350
+ : {}),
1351
+ transitionKey,
1352
+ });
1353
+ let continued;
1354
+ try {
1355
+ continued = input.runtime.continue(current.context);
995
1356
  }
996
- else {
997
- await appendTransition({
998
- sessionId: plan.sessionId,
999
- by,
1000
- batchId: plan.batchId,
1001
- entity: "attempt",
1002
- id: current.context.attemptId,
1003
- state: "TIMEOUT_YIELDED",
1004
- states: attemptStates,
1005
- assignmentId: current.assignment.assignmentId,
1006
- attemptId: current.context.attemptId,
1007
- teammateId: current.context.teammateId,
1008
- retryCount: current.retryCount,
1009
- ...(current.providerSessionId
1010
- ? { providerSessionId: current.providerSessionId }
1011
- : {}),
1012
- transitionKey: `continuation:${current.continuations}`,
1013
- });
1014
- await appendTransition({
1015
- sessionId: plan.sessionId,
1016
- by,
1017
- batchId: plan.batchId,
1018
- entity: "teammate",
1019
- id: current.context.teammateId,
1020
- state: "TIMEOUT_YIELDED",
1021
- states: teammateStates,
1022
- attemptId: current.context.attemptId,
1023
- teammateId: current.context.teammateId,
1024
- ...(current.providerSessionId
1025
- ? { providerSessionId: current.providerSessionId }
1026
- : {}),
1027
- transitionKey: `continuation:${current.continuations}`,
1028
- });
1029
- await appendTransition({
1030
- sessionId: plan.sessionId,
1031
- by,
1032
- batchId: plan.batchId,
1033
- entity: "assignment",
1034
- id: current.assignment.assignmentId,
1035
- state: "WAITING_FOR_MAIN_COMMAND",
1036
- states: assignmentStates,
1037
- assignmentId: current.assignment.assignmentId,
1038
- attemptId: current.context.attemptId,
1039
- teammateId: current.context.teammateId,
1040
- retryCount: current.retryCount,
1041
- ...(current.providerSessionId
1042
- ? { providerSessionId: current.providerSessionId }
1043
- : {}),
1044
- transitionKey: `continuation:${current.continuations}`,
1045
- });
1046
- await appendTransition({
1047
- sessionId: plan.sessionId,
1048
- by,
1049
- batchId: plan.batchId,
1050
- entity: "attempt",
1051
- id: current.context.attemptId,
1052
- state: "WAITING_FOR_MAIN_COMMAND",
1053
- states: attemptStates,
1054
- assignmentId: current.assignment.assignmentId,
1055
- attemptId: current.context.attemptId,
1056
- teammateId: current.context.teammateId,
1057
- retryCount: current.retryCount,
1058
- ...(current.providerSessionId
1059
- ? { providerSessionId: current.providerSessionId }
1060
- : {}),
1061
- transitionKey: `continuation:${current.continuations}`,
1062
- });
1063
- await appendTransition({
1064
- sessionId: plan.sessionId,
1065
- by,
1066
- batchId: plan.batchId,
1067
- entity: "teammate",
1068
- id: current.context.teammateId,
1069
- state: "WAITING_FOR_MAIN_COMMAND",
1070
- states: teammateStates,
1071
- attemptId: current.context.attemptId,
1072
- teammateId: current.context.teammateId,
1073
- ...(current.providerSessionId
1074
- ? { providerSessionId: current.providerSessionId }
1075
- : {}),
1076
- transitionKey: `continuation:${current.continuations}`,
1077
- });
1078
- let continued;
1079
- try {
1080
- continued = await input.runtime.continue(current.context);
1081
- }
1082
- catch (error) {
1083
- return reconcile(error instanceof Error ? error.message : String(error));
1084
- }
1085
- if (continued.providerSessionId &&
1086
- current.providerSessionId &&
1087
- continued.providerSessionId !== current.providerSessionId) {
1088
- return reconcile("continuation returned a different host session");
1089
- }
1090
- if (continued.providerSessionId) {
1091
- current.providerSessionId = continued.providerSessionId;
1092
- current.context.providerSessionId = continued.providerSessionId;
1093
- }
1094
- await appendTransition({
1095
- sessionId: plan.sessionId,
1096
- by,
1097
- batchId: plan.batchId,
1098
- entity: "attempt",
1099
- id: current.context.attemptId,
1100
- state: "RUNNING",
1101
- states: attemptStates,
1102
- assignmentId: current.assignment.assignmentId,
1103
- attemptId: current.context.attemptId,
1104
- teammateId: current.context.teammateId,
1105
- retryCount: current.retryCount,
1106
- ...(current.providerSessionId
1107
- ? { providerSessionId: current.providerSessionId }
1108
- : {}),
1109
- transitionKey: `continuation:${current.continuations}`,
1110
- });
1111
- await appendTransition({
1112
- sessionId: plan.sessionId,
1113
- by,
1114
- batchId: plan.batchId,
1115
- entity: "teammate",
1116
- id: current.context.teammateId,
1117
- state: "RUNNING",
1118
- states: teammateStates,
1119
- attemptId: current.context.attemptId,
1120
- teammateId: current.context.teammateId,
1121
- ...(current.providerSessionId
1122
- ? { providerSessionId: current.providerSessionId }
1123
- : {}),
1124
- transitionKey: `continuation:${current.continuations}`,
1125
- });
1126
- await appendTransition({
1127
- sessionId: plan.sessionId,
1128
- by,
1129
- batchId: plan.batchId,
1130
- entity: "assignment",
1131
- id: current.assignment.assignmentId,
1132
- state: "EXECUTING",
1133
- states: assignmentStates,
1134
- assignmentId: current.assignment.assignmentId,
1135
- attemptId: current.context.attemptId,
1136
- teammateId: current.context.teammateId,
1137
- retryCount: current.retryCount,
1138
- ...(current.providerSessionId
1139
- ? { providerSessionId: current.providerSessionId }
1140
- : {}),
1141
- transitionKey: `continuation:${current.continuations}`,
1142
- });
1143
- waits.set(next.slot, Promise.resolve({ slot: next.slot, result: continued }));
1144
- releaseSlot = false;
1357
+ catch (error) {
1358
+ return reconcile(error instanceof Error ? error.message : String(error));
1145
1359
  }
1360
+ waits.set(next.slot, Promise.resolve(continued)
1361
+ .then((result) => ({ slot: next.slot, result }))
1362
+ .catch((error) => ({
1363
+ slot: next.slot,
1364
+ result: {
1365
+ kind: "reconciliation",
1366
+ reason: error instanceof Error ? error.message : String(error),
1367
+ },
1368
+ })));
1369
+ releaseSlot = false;
1146
1370
  }
1147
1371
  else if (next.result.kind === "report") {
1148
1372
  try {
@@ -1173,22 +1397,25 @@ export async function runTeamworkCoordinator(input) {
1173
1397
  }
1174
1398
  return settle();
1175
1399
  }
1176
- export async function assertTeamworkReceiptDurable(sessionId, batchId, planHash, cwd) {
1400
+ export async function assertTeamworkReceiptDurable(plan, receipt, cwd) {
1177
1401
  const [state, events] = await Promise.all([
1178
- readCoordinationState({ sessionId, ...(cwd ? { cwd } : {}) }),
1179
- readTeamworkEvents({ sessionId, ...(cwd ? { cwd } : {}) }),
1402
+ readCoordinationState({
1403
+ sessionId: plan.sessionId,
1404
+ ...(cwd ? { cwd } : {}),
1405
+ }),
1406
+ readTeamworkEvents({
1407
+ sessionId: plan.sessionId,
1408
+ ...(cwd ? { cwd } : {}),
1409
+ }),
1180
1410
  ]);
1181
- const batch = state.batches.find((entry) => entry.batchId === batchId);
1182
- if (batch?.planHash !== planHash ||
1183
- (batch.state !== "ALL_ACCEPTED" && batch.state !== "SETTLED_WITH_FAILURE")) {
1184
- throw new Error(`batch '${batchId}' has no durable terminal receipt`);
1185
- }
1186
- const receiptRecorded = events.some((event) => event.kind === "coordination" &&
1187
- event.batchId === batchId &&
1188
- event.action === "receipt_recorded" &&
1189
- event.payload?.status === batch.state);
1190
- if (!receiptRecorded) {
1191
- throw new Error(`batch '${batchId}' has no matching durable receipt event`);
1192
- }
1411
+ const expected = terminalReceiptFromProjection(plan, state);
1412
+ if (!isDeepStrictEqual(receipt, expected))
1413
+ throw new Error(`batch '${plan.batchId}' receipt disagrees with durable facts`);
1414
+ const receipts = events.filter((event) => event.kind === "coordination" &&
1415
+ event.batchId === plan.batchId &&
1416
+ event.action === "receipt_recorded");
1417
+ if (receipts.length !== 1 ||
1418
+ !isDeepStrictEqual(receipts[0]?.payload, expected))
1419
+ throw new Error(`batch '${plan.batchId}' has no matching durable receipt event`);
1193
1420
  }
1194
1421
  //# sourceMappingURL=coordinator.js.map