@hassangameryt/freeflow 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/README.md +147 -127
  6. package/capabilities/cognitive-routing/SKILL.md +162 -93
  7. package/gemini-extension.json +1 -1
  8. package/package.json +1 -1
  9. package/pi-extension/dist/cognitive-routing-v2/config.js +35 -19
  10. package/pi-extension/dist/cognitive-routing-v2/projection.js +53 -44
  11. package/pi-extension/dist/cognitive-routing-v2/render.js +31 -10
  12. package/pi-extension/dist/cognitive-routing-v2/runtime.js +701 -105
  13. package/pi-extension/dist/cognitive-routing-v2/schemas.js +90 -24
  14. package/pi-extension/dist/cognitive-routing-v2/state.js +243 -27
  15. package/pi-extension/dist/cognitive-routing-v2/tools.js +19 -50
  16. package/pi-extension/dist/cognitive-routing-v2/types.js +5 -1
  17. package/pi-extension/dist/context-virtualization/runtime.js +29 -7
  18. package/pi-extension/dist/freeflow-context/tool.js +7 -6
  19. package/pi-extension/dist/index.js +36 -15
  20. package/pi-extension/dist/provider-support/astra/adapter.js +110 -0
  21. package/pi-extension/dist/provider-support/astra/history.js +125 -0
  22. package/pi-extension/dist/provider-support/astra/index.js +11 -0
  23. package/pi-extension/dist/provider-support/astra/session-state.js +114 -0
  24. package/pi-extension/dist/provider-support/index.js +4 -0
  25. package/pi-extension/dist/runtime/request-history.js +216 -0
  26. package/pi-extension/dist/runtime/runtime-context.js +29 -10
  27. package/pi-extension/dist/session-sources/events.js +22 -0
  28. package/pi-extension/dist/session-sources/provenance.js +32 -0
  29. package/pi-extension/dist/session-sources/sources.js +51 -7
  30. package/pi-extension/dist/settings/settings-ui.js +247 -47
  31. package/pi-extension/src/cognitive-routing-v2/config.ts +49 -19
  32. package/pi-extension/src/cognitive-routing-v2/projection.ts +53 -43
  33. package/pi-extension/src/cognitive-routing-v2/render.ts +31 -10
  34. package/pi-extension/src/cognitive-routing-v2/runtime.ts +712 -105
  35. package/pi-extension/src/cognitive-routing-v2/schemas.ts +95 -24
  36. package/pi-extension/src/cognitive-routing-v2/state.ts +244 -27
  37. package/pi-extension/src/cognitive-routing-v2/tools.ts +19 -50
  38. package/pi-extension/src/cognitive-routing-v2/types.ts +35 -3
  39. package/pi-extension/src/context-virtualization/runtime.ts +30 -8
  40. package/pi-extension/src/freeflow-context/tool.ts +7 -6
  41. package/pi-extension/src/index.ts +36 -14
  42. package/pi-extension/src/provider-support/astra/adapter.ts +108 -0
  43. package/pi-extension/src/provider-support/astra/history.ts +145 -0
  44. package/pi-extension/src/provider-support/astra/index.ts +12 -0
  45. package/pi-extension/src/provider-support/astra/session-state.ts +113 -0
  46. package/pi-extension/src/provider-support/index.ts +5 -0
  47. package/pi-extension/src/runtime/request-history.ts +243 -0
  48. package/pi-extension/src/runtime/runtime-context.ts +32 -10
  49. package/pi-extension/src/session-sources/events.ts +22 -0
  50. package/pi-extension/src/session-sources/provenance.ts +39 -0
  51. package/pi-extension/src/session-sources/sources.ts +77 -6
  52. package/pi-extension/src/settings/settings-ui.ts +276 -46
  53. package/pi-extension/tests/cache-reuse/history.test.js +117 -0
  54. package/pi-extension/tests/cache-reuse/native.test.js +91 -0
  55. package/pi-extension/tests/cache-reuse/settings.test.js +211 -0
  56. package/pi-extension/tests/cognitive-routing-v2/communication.test.js +94 -1
  57. package/pi-extension/tests/cognitive-routing-v2/delegation.test.js +557 -0
  58. package/pi-extension/tests/cognitive-routing-v2/native.test.js +34 -1
  59. package/pi-extension/tests/cognitive-routing-v2/persistence.test.js +55 -0
  60. package/pi-extension/tests/cognitive-routing-v2/recovery.test.js +800 -2
  61. package/pi-extension/tests/cognitive-routing-v2/render.test.js +15 -1
  62. package/pi-extension/tests/cognitive-routing-v2/runtime.test.js +273 -3
  63. package/pi-extension/tests/cognitive-routing-v2/state.test.js +312 -1
  64. package/pi-extension/tests/context-virtualization/extension.test.js +20 -10
  65. package/pi-extension/tests/context-virtualization/runtime.test.js +17 -3
  66. package/pi-extension/tests/fixtures/routing-native.js +4 -1
  67. package/pi-extension/tests/integration/mode-free-core.test.js +4 -3
  68. package/pi-extension/tests/integration/pi-extension.test.js +508 -29
  69. package/pi-extension/tests/integration/prompt-architecture.test.js +56 -40
  70. package/pi-extension/tests/integration/unaffected-regressions.test.js +3 -3
  71. package/pi-extension/tests/provider-support/astra-boundaries.test.js +75 -0
  72. package/pi-extension/tests/provider-support/astra.test.js +306 -0
  73. package/pi-extension/tests/provider-support/cli.test.js +92 -0
  74. package/pi-extension/tests/provider-support/native.test.js +183 -0
  75. package/plugin.json +1 -1
  76. package/runtime/prompts/cognitive-routing.md +4 -4
  77. package/skills/design-for-depth/SKILL.md +1 -1
  78. package/skills/design-for-depth/references/implementation-decisions.md +3 -3
  79. package/skills/discuss/SKILL.md +2 -2
@@ -1,29 +1,61 @@
1
1
  import { isObject } from "./types.js";
2
- const string = (maxLength = 32768) => ({ type: "string", minLength: 1, maxLength });
3
- const object = (properties: Record<string, any>, required = Object.keys(properties)) => ({
2
+ const string = (maxLength = 32768, description?: string) => ({
3
+ type: "string",
4
+ minLength: 1,
5
+ maxLength,
6
+ ...(description ? { description } : {}),
7
+ });
8
+ const object = (
9
+ properties: Record<string, any>,
10
+ required = Object.keys(properties),
11
+ description?: string,
12
+ title?: string,
13
+ ) => ({
4
14
  type: "object",
5
15
  additionalProperties: false,
6
16
  properties,
7
17
  required,
18
+ ...(description ? { description } : {}),
19
+ ...(title ? { title } : {}),
8
20
  });
9
- const operation = (value: string) => ({ type: "string", enum: [value] });
10
- const submit = object(
11
- {
12
- operation: operation("submit"),
13
- report: string(),
14
- outcome: { type: "string", enum: ["completed", "partial", "blocked"] },
15
- limitations: { type: "array", maxItems: 32, items: string(2048) },
16
- },
17
- ["operation", "report", "outcome"],
18
- );
21
+ const operation = (value: string, description?: string) => ({
22
+ type: "string",
23
+ enum: [value],
24
+ ...(description ? { description } : {}),
25
+ });
26
+ const evidenceRefs = (description: string) => ({
27
+ type: "array",
28
+ maxItems: 64,
29
+ items: string(512),
30
+ description,
31
+ });
32
+ const worker = {
33
+ type: "string",
34
+ enum: ["helper", "executor"],
35
+ description: "Worker for this assignment. Required when delegation mode enables both workers.",
36
+ };
37
+ const report = (name: "submit" | "supplement") =>
38
+ object(
39
+ {
40
+ operation: operation(name),
41
+ report: string(),
42
+ outcome: { type: "string", enum: ["completed", "partial", "blocked"] },
43
+ limitations: { type: "array", maxItems: 32, items: string(2048) },
44
+ },
45
+ ["operation", "report", "outcome"],
46
+ );
19
47
  export const ROUTING_SCHEMAS: Record<string, any> = {
20
48
  freeflow_delegate: {
21
49
  oneOf: [
22
- object({ operation: operation("assign"), contract: string() }),
23
- object({ operation: operation("replace"), contract: string(), reason: string(2048) }),
50
+ object({ operation: operation("assign"), contract: string(), worker }, ["operation", "contract"]),
51
+ object({ operation: operation("replace"), contract: string(), reason: string(2048), worker }, [
52
+ "operation",
53
+ "contract",
54
+ "reason",
55
+ ]),
24
56
  ],
25
57
  },
26
- freeflow_return: { oneOf: [submit, object({ operation: operation("retry") })] },
58
+ freeflow_return: { oneOf: [report("submit"), report("supplement"), object({ operation: operation("retry") })] },
27
59
  freeflow_unit: {
28
60
  oneOf: [
29
61
  object(
@@ -37,6 +69,15 @@ export const ROUTING_SCHEMAS: Record<string, any> = {
37
69
  ["operation"],
38
70
  ),
39
71
  object({ operation: operation("assess") }),
72
+ object(
73
+ {
74
+ operation: operation("recover"),
75
+ request: string(),
76
+ paths: { type: "array", maxItems: 32, items: string(4096) },
77
+ },
78
+ ["operation", "request"],
79
+ ),
80
+ object({ operation: operation("cancel-recovery"), reason: string(2048) }),
40
81
  object({
41
82
  operation: operation("close"),
42
83
  outcome: { type: "string", enum: ["accepted", "cancelled", "deferred"] },
@@ -48,18 +89,41 @@ export const ROUTING_SCHEMAS: Record<string, any> = {
48
89
  oneOf: [
49
90
  object(
50
91
  {
51
- operation: operation("inspect"),
52
- scope: { type: "string", enum: ["selected", "assignment", "active", "history"] },
53
- cursor: string(128),
92
+ operation: operation("inspect", "Read current selection and offered evidence candidates."),
93
+ scope: {
94
+ type: "string",
95
+ enum: ["selected", "assignment", "active", "history"],
96
+ description: "Optional candidate scope; omit to inspect the current assignment.",
97
+ },
98
+ cursor: string(128, "Use the nextCursor returned by a previous inspection."),
54
99
  },
55
100
  ["operation"],
101
+ "Inspect selection and candidates without changing evidence state.",
102
+ "Inspect evidence selection",
103
+ ),
104
+ object(
105
+ {
106
+ operation: operation("add", "Select eligible worker task-evidence refs; do not include reason."),
107
+ refs: evidenceRefs(
108
+ "Exact eligible visible refs for worker task evidence. Add known eligible refs directly; inspect when identity, eligibility, representation, or selection state is unclear. Never add a ref marked not offered for new evidence selection.",
109
+ ),
110
+ },
111
+ ["operation", "refs"],
112
+ "Add evidence refs. This shape has no reason field.",
113
+ "Add evidence",
114
+ ),
115
+ object(
116
+ {
117
+ operation: operation("remove", "Withdraw selected or unresolved refs; reason is required."),
118
+ refs: evidenceRefs(
119
+ "Currently selected or unresolved refs to withdraw. A non-selectable ref may be removed with a reason to clear its unresolved request.",
120
+ ),
121
+ reason: string(2048, "Required only for remove; explain why the selection is being withdrawn."),
122
+ },
123
+ ["operation", "refs", "reason"],
124
+ "Remove evidence refs. This is the only operation that accepts reason.",
125
+ "Remove evidence",
56
126
  ),
57
- object({ operation: operation("add"), refs: { type: "array", maxItems: 64, items: string(512) } }),
58
- object({
59
- operation: operation("remove"),
60
- refs: { type: "array", maxItems: 64, items: string(512) },
61
- reason: string(2048),
62
- }),
63
127
  ],
64
128
  },
65
129
  };
@@ -73,7 +137,14 @@ for (const schema of Object.values(ROUTING_SCHEMAS)) {
73
137
  schema.properties.operation = {
74
138
  type: "string",
75
139
  enum: schema.oneOf.flatMap((branch: any) => branch.properties.operation.enum),
140
+ description: "Choose exactly one operation; each operation has a strict shape and rejects unknown fields.",
76
141
  };
142
+ if (schema.properties.refs)
143
+ schema.properties.refs = {
144
+ ...schema.properties.refs,
145
+ description:
146
+ "For add, use exact eligible visible refs directly when their identity and eligibility are clear; inspect when identity, eligibility, representation, or selection state is unclear. Never add refs marked not offered for new evidence selection. For remove, use currently selected or unresolved refs; a non-selectable ref may be named to clear its unresolved request and requires reason.",
147
+ };
77
148
  }
78
149
 
79
150
  export function matches(value: any, schema: any): boolean {
@@ -7,6 +7,7 @@ import {
7
7
  eventKey,
8
8
  eventValue,
9
9
  isObject,
10
+ isWorkerProfile,
10
11
  requireCondition as check,
11
12
  refFor,
12
13
  type RoutingEvent,
@@ -14,6 +15,7 @@ import {
14
15
  type NativeEntry,
15
16
  type Pair,
16
17
  type Handoff,
18
+ type Recovery,
17
19
  type Reservation,
18
20
  } from "./types.js";
19
21
 
@@ -28,6 +30,11 @@ const pair = (x: any): x is Pair =>
28
30
  identity(x.provider) &&
29
31
  identity(x.modelId) &&
30
32
  EFFORTS.includes(x.thinking);
33
+ const profileOverrides = (x: any): boolean =>
34
+ isObject(x) &&
35
+ Object.keys(x).length > 0 &&
36
+ Object.keys(x).every((key) => PROFILES.includes(key as any)) &&
37
+ Object.values(x).every((value) => value === null || pair(value));
31
38
  const problemList = (xs: any): boolean =>
32
39
  Array.isArray(xs) &&
33
40
  xs.every(
@@ -60,6 +67,41 @@ const reservation = (r: any): r is Reservation =>
60
67
  Number.isFinite(r.outputReserve) &&
61
68
  r.outputReserve >= 0 &&
62
69
  text(r.estimateMethod);
70
+ const recovery = (r: any): r is Recovery =>
71
+ shape(r, [
72
+ "id",
73
+ "assignmentId",
74
+ "assessmentHandoffId",
75
+ "baseReportRevision",
76
+ "request",
77
+ "requestedPaths",
78
+ "paths",
79
+ "state",
80
+ "requestHandoffId",
81
+ "supplementHandoffId",
82
+ "supplementRevision",
83
+ "cancellationReason",
84
+ ]) &&
85
+ identity(r.id) &&
86
+ identity(r.assignmentId) &&
87
+ identity(r.assessmentHandoffId) &&
88
+ Number.isSafeInteger(r.baseReportRevision) &&
89
+ r.baseReportRevision > 0 &&
90
+ text(r.request) &&
91
+ Array.isArray(r.requestedPaths) &&
92
+ r.requestedPaths.length <= 32 &&
93
+ r.requestedPaths.every((path: unknown) => text(path, 4096)) &&
94
+ new Set(r.requestedPaths).size === r.requestedPaths.length &&
95
+ Array.isArray(r.paths) &&
96
+ r.paths.length <= 32 &&
97
+ r.paths.every((path: unknown) => text(path, 4096)) &&
98
+ new Set(r.paths).size === r.paths.length &&
99
+ ["requested", "reading", "returning", "completed", "cancelled"].includes(r.state) &&
100
+ identity(r.requestHandoffId) &&
101
+ (r.supplementHandoffId === undefined || identity(r.supplementHandoffId)) &&
102
+ Number.isSafeInteger(r.supplementRevision) &&
103
+ r.supplementRevision >= 0 &&
104
+ (r.cancellationReason === undefined || text(r.cancellationReason, 2048));
63
105
  const handoff = (h: any): h is Handoff =>
64
106
  shape(h, [
65
107
  "id",
@@ -83,7 +125,7 @@ const handoff = (h: any): h is Handoff =>
83
125
  identity(h.executionId) &&
84
126
  identity(h.toolCallId) &&
85
127
  nullableId(h.basisUserEntryId) &&
86
- ["delegate", "return"].includes(h.kind) &&
128
+ ["delegate", "return", "recovery-request", "recovery-return"].includes(h.kind) &&
87
129
  PROFILES.includes(h.from) &&
88
130
  PROFILES.includes(h.to) &&
89
131
  h.from !== h.to &&
@@ -95,15 +137,21 @@ const handoff = (h: any): h is Handoff =>
95
137
  h.limitations.every((x: unknown) => text(x, 2048)) &&
96
138
  (h.reason === undefined || text(h.reason, 4096)) &&
97
139
  (h.outcome === undefined || ["completed", "partial", "blocked"].includes(h.outcome)) &&
98
- (h.kind !== "delegate" || (h.outcome === undefined && h.reportRevision === 0));
140
+ (["delegate", "recovery-request"].includes(h.kind)
141
+ ? h.outcome === undefined && h.reportRevision === 0
142
+ : ["completed", "partial", "blocked"].includes(h.outcome) && h.reportRevision > 0);
99
143
  const fields: Record<string, string[]> = {
100
144
  "execution-interrupted": ["executionId", "reason"],
101
145
  "assignment-resumed": ["assignmentId", "basisUserEntryId"],
102
146
  control: ["control", "profile", "reason"],
147
+ "profile-overrides": ["overrides", "reason"],
103
148
  "execution-opened": ["execution"],
104
149
  "execution-bound": ["executionId", "assistantEntryId", "resultEntryIds", "outcome"],
105
150
  "delegate-accepted": ["unit", "assignment", "handoff", "replacement"],
106
151
  "return-accepted": ["handoff"],
152
+ "recovery-request-accepted": ["recovery", "handoff"],
153
+ "recovery-supplement-accepted": ["recoveryId", "handoff"],
154
+ "recovery-cancelled": ["recoveryId", "reason"],
107
155
  "handoff-retry-requested": [
108
156
  "handoffId",
109
157
  "attemptId",
@@ -163,17 +211,21 @@ export function parseRoutingEvent(raw: unknown): RoutingEvent {
163
211
  "invalid_control",
164
212
  );
165
213
  break;
214
+ case "profile-overrides":
215
+ check(profileOverrides(d.overrides) && text(d.reason, 4096), "invalid_profile_overrides");
216
+ break;
166
217
  case "execution-opened": {
167
218
  const e = d.execution;
168
219
  check(
169
- shape(e, ["id", "profile", "assignmentId", "basisUserEntryId", "pair", "resultEntryIds"]) &&
220
+ shape(e, ["id", "profile", "assignmentId", "recoveryId", "basisUserEntryId", "pair", "resultEntryIds"]) &&
170
221
  identity(e.id) &&
171
222
  ["solo", ...PROFILES].includes(e.profile) &&
172
223
  nullableId(e.basisUserEntryId) &&
173
224
  pair(e.pair) &&
174
225
  uniqueStrings(e.resultEntryIds) &&
175
226
  e.resultEntryIds.length === 0 &&
176
- (e.assignmentId === undefined || identity(e.assignmentId)),
227
+ (e.assignmentId === undefined || identity(e.assignmentId)) &&
228
+ (e.recoveryId === undefined || identity(e.recoveryId)),
177
229
  "invalid_execution",
178
230
  );
179
231
  break;
@@ -221,13 +273,28 @@ export function parseRoutingEvent(raw: unknown): RoutingEvent {
221
273
  break;
222
274
  }
223
275
  case "return-accepted":
276
+ check(handoff(d.handoff) && d.handoff.kind === "return", "invalid_return");
277
+ break;
278
+ case "recovery-request-accepted":
224
279
  check(
225
- handoff(d.handoff) &&
226
- ["completed", "partial", "blocked"].includes(d.handoff.outcome) &&
227
- d.handoff.reportRevision > 0,
228
- "invalid_return",
280
+ recovery(d.recovery) &&
281
+ handoff(d.handoff) &&
282
+ d.handoff.kind === "recovery-request" &&
283
+ d.recovery.requestHandoffId === d.handoff.id &&
284
+ d.recovery.assignmentId === d.handoff.assignmentId &&
285
+ d.recovery.request === d.handoff.text,
286
+ "invalid_recovery_request",
229
287
  );
230
288
  break;
289
+ case "recovery-supplement-accepted":
290
+ check(
291
+ identity(d.recoveryId) && handoff(d.handoff) && d.handoff.kind === "recovery-return",
292
+ "invalid_recovery_supplement",
293
+ );
294
+ break;
295
+ case "recovery-cancelled":
296
+ check(identity(d.recoveryId) && text(d.reason, 2048), "invalid_recovery_cancellation");
297
+ break;
231
298
  case "handoff-retry-requested":
232
299
  check(
233
300
  identity(d.attemptId) &&
@@ -275,7 +342,10 @@ export function parseRoutingEvent(raw: unknown): RoutingEvent {
275
342
  );
276
343
  break;
277
344
  case "assessment-resumed":
278
- check(nullableId(d.basisUserEntryId) && reservation(d.reservation), "invalid_resumption");
345
+ check(
346
+ nullableId(d.basisUserEntryId) && (d.reservation === undefined || reservation(d.reservation)),
347
+ "invalid_resumption",
348
+ );
279
349
  break;
280
350
  case "unit-closed":
281
351
  check(
@@ -315,6 +385,7 @@ export function initialState(): State {
315
385
  units: new Map(),
316
386
  assignments: new Map(),
317
387
  handoffs: new Map(),
388
+ recoveries: new Map(),
318
389
  executions: new Map(),
319
390
  selections: new Map(),
320
391
  reservations: new Map(),
@@ -322,6 +393,7 @@ export function initialState(): State {
322
393
  exposure: new Map(),
323
394
  authors: new Map(),
324
395
  resumeBasis: new Map(),
396
+ profileOverrides: new Map(),
325
397
  events: new Map(),
326
398
  eventIds: new Map(),
327
399
  };
@@ -344,6 +416,7 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
344
416
  units: new Map([...state.units].map(([k, v]) => [k, { ...v }])),
345
417
  assignments: new Map([...state.assignments].map(([k, v]) => [k, { ...v }])),
346
418
  handoffs: new Map([...state.handoffs].map(([k, v]) => [k, { ...v }])),
419
+ recoveries: new Map([...state.recoveries].map(([k, v]) => [k, { ...v, paths: [...v.paths] }])),
347
420
  executions: new Map([...state.executions].map(([k, v]) => [k, { ...v }])),
348
421
  selections: new Map(state.selections),
349
422
  reservations: new Map(state.reservations),
@@ -351,17 +424,27 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
351
424
  exposure: new Map(state.exposure),
352
425
  authors: new Map(state.authors),
353
426
  resumeBasis: new Map(state.resumeBasis),
427
+ profileOverrides: new Map(state.profileOverrides),
354
428
  events: new Map(state.events),
355
429
  eventIds: new Map(state.eventIds),
356
430
  assessment: state.assessment ? { ...state.assessment } : undefined,
357
431
  },
358
432
  d = e.data;
359
433
  const assignment = () => (s.assignmentId ? s.assignments.get(s.assignmentId) : undefined);
434
+ const assignmentWorker = () => {
435
+ const a = assignment();
436
+ const h = a ? s.handoffs.get(a.delegateHandoffId) : undefined;
437
+ check(h?.kind === "delegate" && h.assignmentId === a?.id && isWorkerProfile(h.to), "assignment_worker_missing");
438
+ return h.to;
439
+ };
360
440
  const pending = () => (s.pendingId ? s.handoffs.get(s.pendingId) : undefined);
361
441
  const isPending = () => pending() && ["pending", "blocked"].includes(pending()!.state);
362
442
  const currentReturn = (id: string) => {
363
443
  const h = s.handoffs.get(id);
364
- check(h && h.kind === "return" && h.assignmentId === s.assignmentId, "not_current_return");
444
+ check(
445
+ h && ["return", "recovery-return"].includes(h.kind) && h.assignmentId === s.assignmentId,
446
+ "not_current_return",
447
+ );
365
448
  return h;
366
449
  };
367
450
  switch (d.type) {
@@ -384,6 +467,14 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
384
467
  s.control = d.control;
385
468
  s.profile = d.profile;
386
469
  break;
470
+ case "profile-overrides":
471
+ for (const profile of PROFILES) {
472
+ if (!Object.hasOwn(d.overrides, profile)) continue;
473
+ const override = d.overrides[profile];
474
+ if (override === null) s.profileOverrides.delete(profile);
475
+ else s.profileOverrides.set(profile, override);
476
+ }
477
+ break;
387
478
  case "execution-opened":
388
479
  check(!s.executions.has(d.execution.id), "duplicate_execution");
389
480
  s.executions.set(d.execution.id, d.execution);
@@ -417,7 +508,7 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
417
508
  check(
418
509
  h.kind === "delegate" &&
419
510
  h.from === "coordinator" &&
420
- h.to === "executor" &&
511
+ isWorkerProfile(h.to) &&
421
512
  h.state === "pending" &&
422
513
  h.text === d.assignment.contract &&
423
514
  h.assignmentId === d.assignment.id &&
@@ -435,9 +526,9 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
435
526
  check(a?.state === "outstanding" && a.id === d.replacement.assignmentId && u, "no_replaceable_assignment");
436
527
  check(
437
528
  ![...s.executions.values()].some(
438
- (x) => x.assignmentId === a.id && x.profile === "executor" && !x.assistantEntryId && !x.interrupted,
529
+ (x) => x.assignmentId === a.id && isWorkerProfile(x.profile) && !x.assistantEntryId && !x.interrupted,
439
530
  ),
440
- "unresolved_executor_execution",
531
+ "unresolved_worker_execution",
441
532
  );
442
533
  check(
443
534
  !isPending() || (pending()!.kind === "delegate" && d.replacement.supersededHandoffId === pending()!.id),
@@ -456,14 +547,14 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
456
547
  break;
457
548
  }
458
549
  case "return-accepted": {
459
- check(s.control === "automatic" && s.profile === "executor", "wrong_control");
550
+ check(s.control === "automatic" && s.profile === assignmentWorker(), "wrong_control");
460
551
  const a = assignment(),
461
552
  h = d.handoff;
462
553
  check(
463
554
  a &&
464
555
  h.assignmentId === a.id &&
465
556
  h.kind === "return" &&
466
- h.from === "executor" &&
557
+ h.from === assignmentWorker() &&
467
558
  h.to === "coordinator" &&
468
559
  h.state === "pending",
469
560
  "return_identity_mismatch",
@@ -493,11 +584,101 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
493
584
  };
494
585
  break;
495
586
  }
587
+ case "recovery-request-accepted": {
588
+ const a = assignment(),
589
+ base = s.handoffs.get(d.recovery.assessmentHandoffId),
590
+ h = d.handoff;
591
+ check(
592
+ s.control === "automatic" &&
593
+ s.profile === "coordinator" &&
594
+ a?.state === "returned" &&
595
+ d.recovery.assignmentId === a.id &&
596
+ d.recovery.state === "requested" &&
597
+ d.recovery.supplementRevision === 0 &&
598
+ d.recovery.supplementHandoffId === undefined &&
599
+ d.recovery.cancellationReason === undefined &&
600
+ s.assessment?.handoffId === base?.id &&
601
+ base?.kind === "return" &&
602
+ a.returnHandoffId === base.id &&
603
+ base.state === "configured" &&
604
+ base.reportRevision === d.recovery.baseReportRevision &&
605
+ !s.recoveryId &&
606
+ !isPending() &&
607
+ h.kind === "recovery-request" &&
608
+ h.assignmentId === a.id &&
609
+ h.from === "coordinator" &&
610
+ h.to === assignmentWorker() &&
611
+ h.state === "pending" &&
612
+ !s.handoffs.has(h.id) &&
613
+ !s.recoveries.has(d.recovery.id),
614
+ "recovery_not_available",
615
+ );
616
+ s.recoveries.set(d.recovery.id, d.recovery);
617
+ s.recoveryId = d.recovery.id;
618
+ s.handoffs.set(h.id, h);
619
+ s.pendingId = h.id;
620
+ s.assessment.view = "suspended";
621
+ s.assessment.suspensionReason = "recovery";
622
+ s.assessment.basisUserEntryId = h.basisUserEntryId;
623
+ s.assessment.problems = [];
624
+ break;
625
+ }
626
+ case "recovery-supplement-accepted": {
627
+ const a = assignment(),
628
+ r = s.recoveries.get(d.recoveryId),
629
+ h = d.handoff,
630
+ previous = r?.supplementHandoffId ? s.handoffs.get(r.supplementHandoffId) : undefined;
631
+ check(
632
+ s.control === "automatic" &&
633
+ s.profile === assignmentWorker() &&
634
+ a?.state === "returned" &&
635
+ r?.id === s.recoveryId &&
636
+ r.assignmentId === a.id &&
637
+ s.assessment?.handoffId === r.assessmentHandoffId &&
638
+ s.handoffs.get(r.assessmentHandoffId)?.reportRevision === r.baseReportRevision &&
639
+ ["reading", "returning"].includes(r.state) &&
640
+ h.kind === "recovery-return" &&
641
+ h.assignmentId === a.id &&
642
+ h.from === assignmentWorker() &&
643
+ h.to === "coordinator" &&
644
+ h.state === "pending" &&
645
+ (!previous ? !s.handoffs.has(h.id) : ["pending", "blocked"].includes(previous.state) && previous.id === h.id),
646
+ "recovery_not_returnable",
647
+ );
648
+ const expectedRevision = (previous?.reportRevision ?? 0) + 1;
649
+ check(h.reportRevision === expectedRevision, "invalid_report_revision");
650
+ r.state = "returning";
651
+ r.supplementHandoffId = h.id;
652
+ r.supplementRevision = h.reportRevision;
653
+ s.handoffs.set(h.id, h);
654
+ s.pendingId = h.id;
655
+ break;
656
+ }
657
+ case "recovery-cancelled": {
658
+ const r = s.recoveries.get(d.recoveryId);
659
+ check(
660
+ s.control === "automatic" &&
661
+ s.profile === "coordinator" &&
662
+ r?.id === s.recoveryId &&
663
+ ["requested", "reading", "returning"].includes(r.state),
664
+ "recovery_not_cancellable",
665
+ );
666
+ const p = pending();
667
+ if (p) {
668
+ check([r.requestHandoffId, r.supplementHandoffId].includes(p.id), "handoff_pending");
669
+ p.state = "superseded";
670
+ s.pendingId = undefined;
671
+ }
672
+ r.state = "cancelled";
673
+ r.cancellationReason = d.reason;
674
+ s.recoveryId = undefined;
675
+ break;
676
+ }
496
677
  case "handoff-retry-requested": {
497
678
  const h = currentReturn(d.handoffId);
498
679
  check(
499
680
  s.control === "automatic" &&
500
- s.profile === "executor" &&
681
+ s.profile === assignmentWorker() &&
501
682
  ["blocked", "pending"].includes(h.state) &&
502
683
  h.reportRevision === d.reportRevision &&
503
684
  (s.selections.get(h.assignmentId)?.revision ?? 0) === d.selectionRevision,
@@ -517,6 +698,11 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
517
698
  check(d.reservation.selectionRevision === (s.selections.get(h.assignmentId)?.revision ?? 0), "stale_selection");
518
699
  s.reservations.set(h.id, d.reservation);
519
700
  if (s.assessment?.handoffId === h.id) s.assessment.reservation = d.reservation;
701
+ if (h.kind === "recovery-return") {
702
+ const recovery = [...s.recoveries.values()].find((candidate) => candidate.supplementHandoffId === h.id);
703
+ if (recovery && s.assessment?.handoffId === recovery.assessmentHandoffId)
704
+ s.assessment.reservation = d.reservation;
705
+ }
520
706
  break;
521
707
  }
522
708
  case "handoff-state": {
@@ -528,6 +714,12 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
528
714
  check(d.observedPair, "configuration_unobserved");
529
715
  s.profile = h.to;
530
716
  s.pendingId = undefined;
717
+ const r = s.recoveryId ? s.recoveries.get(s.recoveryId) : undefined;
718
+ if (r && h.id === r.requestHandoffId) r.state = "reading";
719
+ if (r && h.id === r.supplementHandoffId) {
720
+ r.state = "completed";
721
+ s.recoveryId = undefined;
722
+ }
531
723
  }
532
724
  if (d.state === "superseded") s.pendingId = undefined;
533
725
  break;
@@ -537,8 +729,10 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
537
729
  check(
538
730
  a?.id === d.assignmentId &&
539
731
  s.control === "automatic" &&
540
- s.profile === "executor" &&
541
- (a.state === "outstanding" || (a.state === "returned" && isPending())),
732
+ s.profile === assignmentWorker() &&
733
+ (a.state === "outstanding" ||
734
+ (a.state === "returned" &&
735
+ (isPending() || (s.recoveryId !== undefined && s.recoveries.get(s.recoveryId)?.state === "reading")))),
542
736
  "selection_wrong_phase",
543
737
  );
544
738
  check(d.selection.revision === (s.selections.get(a.id)?.revision ?? 0) + 1, "selection_revision");
@@ -548,6 +742,7 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
548
742
  case "assessment-suspended": {
549
743
  check(s.assessment?.handoffId === d.handoffId, "assessment_missing");
550
744
  s.assessment.view = "suspended";
745
+ s.assessment.suspensionReason = d.reason;
551
746
  s.assessment.basisUserEntryId = d.basisUserEntryId;
552
747
  s.assessment.problems = d.problems;
553
748
  break;
@@ -556,19 +751,24 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
556
751
  check(
557
752
  s.control === "automatic" &&
558
753
  s.profile === "coordinator" &&
754
+ !s.recoveryId &&
559
755
  s.assessment?.handoffId === d.handoffId &&
560
756
  s.assessment.view === "suspended",
561
- "assessment_not_suspended",
562
- );
563
- check(
564
- d.reservation.selectionRevision === (s.selections.get(s.assessment.assignmentId)?.revision ?? 0),
565
- "stale_selection",
757
+ s.recoveryId ? "recovery_outstanding" : "assessment_not_suspended",
566
758
  );
759
+ if (d.reservation)
760
+ check(
761
+ d.reservation.selectionRevision === (s.selections.get(s.assessment.assignmentId)?.revision ?? 0),
762
+ "stale_selection",
763
+ );
567
764
  s.assessment.view = "active";
765
+ s.assessment.suspensionReason = undefined;
568
766
  s.assessment.basisUserEntryId = d.basisUserEntryId;
569
- s.assessment.reservation = d.reservation;
767
+ if (d.reservation) {
768
+ s.assessment.reservation = d.reservation;
769
+ s.reservations.set(d.handoffId, d.reservation);
770
+ }
570
771
  s.assessment.problems = [];
571
- s.reservations.set(d.handoffId, d.reservation);
572
772
  break;
573
773
  }
574
774
  case "unit-closed": {
@@ -578,7 +778,24 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
578
778
  s.control === "automatic" && s.profile === "coordinator" && u?.id === s.unitId && u.state === "open",
579
779
  "unit_not_open",
580
780
  );
581
- if (d.outcome === "accepted") check(a?.state !== "outstanding" && !isPending(), "unfinished_work");
781
+ if (d.outcome === "accepted")
782
+ check(a?.state !== "outstanding" && !isPending() && !s.recoveryId, "unfinished_work");
783
+ const recovery = s.recoveryId ? s.recoveries.get(s.recoveryId) : undefined;
784
+ if (recovery) {
785
+ check(
786
+ d.outcome !== "accepted" && ["requested", "reading", "returning"].includes(recovery.state),
787
+ "unfinished_work",
788
+ );
789
+ const transfer = pending();
790
+ if (transfer) {
791
+ check([recovery.requestHandoffId, recovery.supplementHandoffId].includes(transfer.id), "handoff_pending");
792
+ transfer.state = "superseded";
793
+ s.pendingId = undefined;
794
+ }
795
+ recovery.state = "cancelled";
796
+ recovery.cancellationReason = d.assessment;
797
+ s.recoveryId = undefined;
798
+ }
582
799
  if (a?.state === "outstanding") {
583
800
  check(d.supersededAssignmentId === a.id && d.outcome !== "accepted", "supersession_required");
584
801
  a.state = "superseded";
@@ -602,7 +819,7 @@ function applyEvent(state: State, event: RoutingEvent, owned = false): State {
602
819
  case "sources-exposed": {
603
820
  const x = s.executions.get(d.executionId);
604
821
  check(x && x.profile === d.view, "exposure_execution_missing");
605
- if (d.view === "executor") for (const source of d.sources) s.exposure.set(source.ref, source.bodyHash);
822
+ if (isWorkerProfile(d.view)) for (const source of d.sources) s.exposure.set(source.ref, source.bodyHash);
606
823
  break;
607
824
  }
608
825
  }