@pennixrv/trellis 0.7.1-beta.15 → 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
- };
256
+ return recordRecoveryReconciliation({
257
+ ...input,
258
+ existingState: existing.state,
259
+ projection,
260
+ reason: "recovered nonterminal batch requires reconciliation",
87
261
  });
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`,
109
- });
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,
@@ -313,8 +466,9 @@ export async function runTeamworkCoordinator(input) {
313
466
  const active = new Map();
314
467
  const waits = new Map();
315
468
  const final = new Map();
316
- const acceptedAttempts = new Map();
469
+ const retainedReports = new Map();
317
470
  const retryCounts = {};
471
+ let admissionClosed = false;
318
472
  async function reconcile(reason) {
319
473
  await appendAction({
320
474
  sessionId: plan.sessionId,
@@ -357,9 +511,39 @@ export async function runTeamworkCoordinator(input) {
357
511
  });
358
512
  return receipt;
359
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
+ }
360
535
  async function dispatch(slot, assignment, retryCount) {
361
- const attemptId = `${assignment.assignmentId}.attempt.${retryCount}`;
362
- 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");
363
547
  const frozen = frozenLaunches.get(assignment.assignmentId);
364
548
  if (!frozen)
365
549
  throw new Error(`missing frozen launch for '${assignment.assignmentId}'`);
@@ -495,6 +679,7 @@ export async function runTeamworkCoordinator(input) {
495
679
  teammateId,
496
680
  slot,
497
681
  retryCount,
682
+ transitionKey: `attempt:${attemptId}`,
498
683
  ...(context.providerSessionId
499
684
  ? { providerSessionId: context.providerSessionId }
500
685
  : {}),
@@ -547,7 +732,63 @@ export async function runTeamworkCoordinator(input) {
547
732
  });
548
733
  guardHeld = false;
549
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
+ }
550
790
  async function cancelAndDrain(reason, terminal) {
791
+ await failPending(reason);
551
792
  if (batchStates.get(plan.batchId) === "RUNNING") {
552
793
  await appendTransition({
553
794
  sessionId: plan.sessionId,
@@ -560,52 +801,68 @@ export async function runTeamworkCoordinator(input) {
560
801
  payload: { reason },
561
802
  });
562
803
  }
563
- if (!input.runtime.cancel && active.size > 0 && !terminal) {
564
- return reconcile(`host cannot confirm cancellation: ${reason}`);
565
- }
566
- for (const current of [...active.values()]) {
567
- let result;
568
- if (terminal?.current === current) {
569
- 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) };
570
815
  }
571
- else {
572
- try {
573
- if (!input.runtime.cancel)
574
- return reconcile(`host cannot cancel active attempt: ${reason}`);
575
- result = await input.runtime.cancel(current.context);
576
- }
577
- catch (error) {
578
- return reconcile(error instanceof Error ? error.message : String(error));
579
- }
816
+ catch (error) {
817
+ return { current, error };
580
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;
829
+ }
830
+ const { result } = outcome;
581
831
  if (result.kind === "reconciliation" ||
582
832
  result.kind === "timeout" ||
583
833
  result.terminal !== true) {
584
- return reconcile(`attempt cancellation is not terminal: ${reason}`);
834
+ cancellationFailure ??= `attempt cancellation is not terminal: ${reason}`;
835
+ continue;
585
836
  }
586
837
  if (result.providerSessionId &&
587
838
  current.providerSessionId &&
588
839
  result.providerSessionId !== current.providerSessionId) {
589
- return reconcile("cancellation returned a different host session");
840
+ cancellationFailure ??=
841
+ "cancellation returned a different host session";
842
+ continue;
590
843
  }
591
844
  if (result.providerSessionId) {
592
845
  current.providerSessionId = result.providerSessionId;
593
846
  current.context.providerSessionId = result.providerSessionId;
594
847
  }
595
848
  try {
596
- if (result.kind === "report")
849
+ await resumeFromWait(current);
850
+ if (result.kind === "report") {
597
851
  await complete(current, false);
852
+ }
598
853
  else
599
854
  await fail(current, result.reason ?? "cancelled by coordinator", false);
600
855
  }
601
856
  catch (error) {
602
- return reconcile(error instanceof Error ? error.message : String(error));
857
+ cancellationFailure ??=
858
+ error instanceof Error ? error.message : String(error);
859
+ continue;
603
860
  }
604
861
  active.delete(current.slot);
605
862
  waits.delete(current.slot);
606
863
  }
607
- if (pending.length > 0)
608
- return reconcile(`queued assignment remains after cancellation: ${reason}`);
864
+ if (cancellationFailure)
865
+ return reconcile(cancellationFailure);
609
866
  return settle();
610
867
  }
611
868
  async function fail(activeWork, reason, allowRetry = true) {
@@ -642,7 +899,7 @@ export async function runTeamworkCoordinator(input) {
642
899
  : {}),
643
900
  terminal: true,
644
901
  });
645
- if (allowRetry && retryCount < assignment.maxRetries) {
902
+ if (allowRetry && !admissionClosed && retryCount < assignment.maxRetries) {
646
903
  await release(activeWork);
647
904
  retryCounts[assignment.assignmentId] = retryCount + 1;
648
905
  pending.unshift(assignment);
@@ -728,7 +985,7 @@ export async function runTeamworkCoordinator(input) {
728
985
  });
729
986
  await release(activeWork);
730
987
  final.set(assignment.assignmentId, "ACCEPTED");
731
- acceptedAttempts.set(assignment.assignmentId, context.attemptId);
988
+ retainedReports.set(assignment.assignmentId, context.attemptId);
732
989
  }
733
990
  async function complete(activeWork, allowRetry = true) {
734
991
  const { assignment, context, retryCount } = activeWork;
@@ -767,9 +1024,20 @@ export async function runTeamworkCoordinator(input) {
767
1024
  });
768
1025
  }
769
1026
  catch (error) {
1027
+ if (error instanceof TeammateReportReadError && error.kind !== "missing")
1028
+ throw error;
770
1029
  await fail(activeWork, error instanceof Error ? error.message : String(error), allowRetry);
771
1030
  return;
772
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
+ }
773
1041
  if (input.runtime.validateReport &&
774
1042
  !(await input.runtime.validateReport(context, report))) {
775
1043
  await fail(activeWork, "report rejected by acceptance contract", allowRetry);
@@ -855,7 +1123,7 @@ export async function runTeamworkCoordinator(input) {
855
1123
  const cleanupFailures = cleanupSettledTeamworkArtifacts({
856
1124
  taskRoot: input.taskRoot,
857
1125
  assignments: plan.assignments,
858
- acceptedAttempts,
1126
+ retainedReports,
859
1127
  });
860
1128
  if (cleanupFailures.length > 0) {
861
1129
  return reconcile(`Teamwork batch cleanup failed: ${cleanupFailures.join("; ")}`);
@@ -903,12 +1171,25 @@ export async function runTeamworkCoordinator(input) {
903
1171
  id: `${plan.batchId}:${status}`,
904
1172
  payload: { ...receipt },
905
1173
  });
906
- await assertTeamworkReceiptDurable(plan.sessionId, plan.batchId, plan.planHash, cwd);
1174
+ await assertTeamworkReceiptDurable(plan, receipt, cwd);
907
1175
  await releaseGuard();
908
1176
  return receipt;
909
1177
  }
910
1178
  async function fill() {
1179
+ if (admissionClosed)
1180
+ return;
911
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
+ }
912
1193
  const assignment = pending.shift();
913
1194
  if (!assignment)
914
1195
  return;
@@ -976,162 +1257,116 @@ export async function runTeamworkCoordinator(input) {
976
1257
  result: next.result,
977
1258
  });
978
1259
  }
1260
+ try {
1261
+ await resumeFromWait(current);
1262
+ }
1263
+ catch (error) {
1264
+ return reconcile(error instanceof Error ? error.message : String(error));
1265
+ }
979
1266
  let releaseSlot = true;
980
1267
  if (next.result.kind === "timeout") {
981
1268
  current.continuations += 1;
982
- if (current.continuations > 8) {
983
- 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);
984
1356
  }
985
- else {
986
- await appendTransition({
987
- sessionId: plan.sessionId,
988
- by,
989
- batchId: plan.batchId,
990
- entity: "attempt",
991
- id: current.context.attemptId,
992
- state: "TIMEOUT_YIELDED",
993
- states: attemptStates,
994
- assignmentId: current.assignment.assignmentId,
995
- attemptId: current.context.attemptId,
996
- teammateId: current.context.teammateId,
997
- retryCount: current.retryCount,
998
- ...(current.providerSessionId
999
- ? { providerSessionId: current.providerSessionId }
1000
- : {}),
1001
- transitionKey: `continuation:${current.continuations}`,
1002
- });
1003
- await appendTransition({
1004
- sessionId: plan.sessionId,
1005
- by,
1006
- batchId: plan.batchId,
1007
- entity: "teammate",
1008
- id: current.context.teammateId,
1009
- state: "TIMEOUT_YIELDED",
1010
- states: teammateStates,
1011
- attemptId: current.context.attemptId,
1012
- teammateId: current.context.teammateId,
1013
- ...(current.providerSessionId
1014
- ? { providerSessionId: current.providerSessionId }
1015
- : {}),
1016
- transitionKey: `continuation:${current.continuations}`,
1017
- });
1018
- await appendTransition({
1019
- sessionId: plan.sessionId,
1020
- by,
1021
- batchId: plan.batchId,
1022
- entity: "assignment",
1023
- id: current.assignment.assignmentId,
1024
- state: "WAITING_FOR_MAIN_COMMAND",
1025
- states: assignmentStates,
1026
- assignmentId: current.assignment.assignmentId,
1027
- attemptId: current.context.attemptId,
1028
- teammateId: current.context.teammateId,
1029
- retryCount: current.retryCount,
1030
- ...(current.providerSessionId
1031
- ? { providerSessionId: current.providerSessionId }
1032
- : {}),
1033
- transitionKey: `continuation:${current.continuations}`,
1034
- });
1035
- await appendTransition({
1036
- sessionId: plan.sessionId,
1037
- by,
1038
- batchId: plan.batchId,
1039
- entity: "attempt",
1040
- id: current.context.attemptId,
1041
- state: "WAITING_FOR_MAIN_COMMAND",
1042
- states: attemptStates,
1043
- assignmentId: current.assignment.assignmentId,
1044
- attemptId: current.context.attemptId,
1045
- teammateId: current.context.teammateId,
1046
- retryCount: current.retryCount,
1047
- ...(current.providerSessionId
1048
- ? { providerSessionId: current.providerSessionId }
1049
- : {}),
1050
- transitionKey: `continuation:${current.continuations}`,
1051
- });
1052
- await appendTransition({
1053
- sessionId: plan.sessionId,
1054
- by,
1055
- batchId: plan.batchId,
1056
- entity: "teammate",
1057
- id: current.context.teammateId,
1058
- state: "WAITING_FOR_MAIN_COMMAND",
1059
- states: teammateStates,
1060
- attemptId: current.context.attemptId,
1061
- teammateId: current.context.teammateId,
1062
- ...(current.providerSessionId
1063
- ? { providerSessionId: current.providerSessionId }
1064
- : {}),
1065
- transitionKey: `continuation:${current.continuations}`,
1066
- });
1067
- let continued;
1068
- try {
1069
- continued = await input.runtime.continue(current.context);
1070
- }
1071
- catch (error) {
1072
- return reconcile(error instanceof Error ? error.message : String(error));
1073
- }
1074
- if (continued.providerSessionId &&
1075
- current.providerSessionId &&
1076
- continued.providerSessionId !== current.providerSessionId) {
1077
- return reconcile("continuation returned a different host session");
1078
- }
1079
- if (continued.providerSessionId) {
1080
- current.providerSessionId = continued.providerSessionId;
1081
- current.context.providerSessionId = continued.providerSessionId;
1082
- }
1083
- await appendTransition({
1084
- sessionId: plan.sessionId,
1085
- by,
1086
- batchId: plan.batchId,
1087
- entity: "attempt",
1088
- id: current.context.attemptId,
1089
- state: "RUNNING",
1090
- states: attemptStates,
1091
- assignmentId: current.assignment.assignmentId,
1092
- attemptId: current.context.attemptId,
1093
- teammateId: current.context.teammateId,
1094
- retryCount: current.retryCount,
1095
- ...(current.providerSessionId
1096
- ? { providerSessionId: current.providerSessionId }
1097
- : {}),
1098
- transitionKey: `continuation:${current.continuations}`,
1099
- });
1100
- await appendTransition({
1101
- sessionId: plan.sessionId,
1102
- by,
1103
- batchId: plan.batchId,
1104
- entity: "teammate",
1105
- id: current.context.teammateId,
1106
- state: "RUNNING",
1107
- states: teammateStates,
1108
- attemptId: current.context.attemptId,
1109
- teammateId: current.context.teammateId,
1110
- ...(current.providerSessionId
1111
- ? { providerSessionId: current.providerSessionId }
1112
- : {}),
1113
- transitionKey: `continuation:${current.continuations}`,
1114
- });
1115
- await appendTransition({
1116
- sessionId: plan.sessionId,
1117
- by,
1118
- batchId: plan.batchId,
1119
- entity: "assignment",
1120
- id: current.assignment.assignmentId,
1121
- state: "EXECUTING",
1122
- states: assignmentStates,
1123
- assignmentId: current.assignment.assignmentId,
1124
- attemptId: current.context.attemptId,
1125
- teammateId: current.context.teammateId,
1126
- retryCount: current.retryCount,
1127
- ...(current.providerSessionId
1128
- ? { providerSessionId: current.providerSessionId }
1129
- : {}),
1130
- transitionKey: `continuation:${current.continuations}`,
1131
- });
1132
- waits.set(next.slot, Promise.resolve({ slot: next.slot, result: continued }));
1133
- releaseSlot = false;
1357
+ catch (error) {
1358
+ return reconcile(error instanceof Error ? error.message : String(error));
1134
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;
1135
1370
  }
1136
1371
  else if (next.result.kind === "report") {
1137
1372
  try {
@@ -1162,22 +1397,25 @@ export async function runTeamworkCoordinator(input) {
1162
1397
  }
1163
1398
  return settle();
1164
1399
  }
1165
- export async function assertTeamworkReceiptDurable(sessionId, batchId, planHash, cwd) {
1400
+ export async function assertTeamworkReceiptDurable(plan, receipt, cwd) {
1166
1401
  const [state, events] = await Promise.all([
1167
- readCoordinationState({ sessionId, ...(cwd ? { cwd } : {}) }),
1168
- readTeamworkEvents({ sessionId, ...(cwd ? { cwd } : {}) }),
1402
+ readCoordinationState({
1403
+ sessionId: plan.sessionId,
1404
+ ...(cwd ? { cwd } : {}),
1405
+ }),
1406
+ readTeamworkEvents({
1407
+ sessionId: plan.sessionId,
1408
+ ...(cwd ? { cwd } : {}),
1409
+ }),
1169
1410
  ]);
1170
- const batch = state.batches.find((entry) => entry.batchId === batchId);
1171
- if (batch?.planHash !== planHash ||
1172
- (batch.state !== "ALL_ACCEPTED" && batch.state !== "SETTLED_WITH_FAILURE")) {
1173
- throw new Error(`batch '${batchId}' has no durable terminal receipt`);
1174
- }
1175
- const receiptRecorded = events.some((event) => event.kind === "coordination" &&
1176
- event.batchId === batchId &&
1177
- event.action === "receipt_recorded" &&
1178
- event.payload?.status === batch.state);
1179
- if (!receiptRecorded) {
1180
- throw new Error(`batch '${batchId}' has no matching durable receipt event`);
1181
- }
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`);
1182
1420
  }
1183
1421
  //# sourceMappingURL=coordinator.js.map