@boxxy/dispatch 0.1.1 → 0.1.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 (190) hide show
  1. package/dist/attempts/attempt-machine.d.ts +804 -0
  2. package/dist/attempts/attempt-moves.d.ts +2 -0
  3. package/dist/attempts/bind-attempt-session.d.ts +14 -0
  4. package/dist/attempts/build-attempt-change.d.ts +73 -0
  5. package/dist/attempts/build-attempt-fact.d.ts +3 -0
  6. package/dist/attempts/end-item-attempts.d.ts +18 -0
  7. package/dist/attempts/is-same-guard.d.ts +5 -0
  8. package/dist/attempts/pick-call-attempt.d.ts +10 -0
  9. package/dist/attempts/stop-stray-session.d.ts +11 -0
  10. package/dist/attempts/types.d.ts +142 -0
  11. package/dist/attempts/write-attempt-move.d.ts +7 -0
  12. package/dist/attempts/write-session-bind.d.ts +3 -0
  13. package/dist/claims/build-snapshot-hash.d.ts +2 -0
  14. package/dist/claims/claim-next.d.ts +6 -0
  15. package/dist/claims/pick-kept-session.d.ts +9 -0
  16. package/dist/claims/request-wake.d.ts +3 -0
  17. package/dist/claims/resume-claim.d.ts +3 -0
  18. package/dist/claims/start-claim.d.ts +3 -0
  19. package/dist/claims/types.d.ts +44 -0
  20. package/dist/closeouts/build-settle-plans.d.ts +17 -0
  21. package/dist/closeouts/close-out-attempt.d.ts +3 -0
  22. package/dist/closeouts/effect-handlers.d.ts +2 -0
  23. package/dist/closeouts/pick-settlement.d.ts +14 -0
  24. package/dist/closeouts/settle-attempt.d.ts +3 -0
  25. package/dist/closeouts/types.d.ts +50 -0
  26. package/dist/index.d.ts +38 -0
  27. package/dist/notices/constants.d.ts +4 -0
  28. package/dist/notices/create-null-notifier.d.ts +2 -0
  29. package/dist/notices/send-notices.d.ts +2 -0
  30. package/dist/notices/types.d.ts +11 -0
  31. package/dist/outbox/build-backoff-ms.d.ts +2 -0
  32. package/dist/outbox/build-initial-sender-state.d.ts +2 -0
  33. package/dist/outbox/build-outbox-status.d.ts +15 -0
  34. package/dist/outbox/constants.d.ts +2 -0
  35. package/dist/outbox/measure-wire-bytes.d.ts +2 -0
  36. package/dist/outbox/pick-sender-move.d.ts +10 -0
  37. package/dist/outbox/run-sender-once.d.ts +2 -0
  38. package/dist/outbox/take-batch.d.ts +3 -0
  39. package/dist/outbox/types.d.ts +61 -0
  40. package/dist/pull-requests/build-link-change.d.ts +11 -0
  41. package/dist/pull-requests/build-state-fact.d.ts +8 -0
  42. package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
  43. package/dist/pull-requests/collect-seen-state.d.ts +7 -0
  44. package/dist/pull-requests/constants.d.ts +3 -0
  45. package/dist/pull-requests/link-pull-request.d.ts +2 -0
  46. package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
  47. package/dist/pull-requests/types.d.ts +37 -0
  48. package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
  49. package/dist/readings/act-on-reading.d.ts +3 -0
  50. package/dist/readings/find-waiting-since.d.ts +9 -0
  51. package/dist/readings/pick-reading-action.d.ts +2 -0
  52. package/dist/readings/types.d.ts +79 -0
  53. package/dist/sessions/build-keep-writes.d.ts +9 -0
  54. package/dist/sessions/build-kept-clear.d.ts +2 -0
  55. package/dist/sessions/forget-sessions.d.ts +2 -0
  56. package/dist/sessions/is-kept-due.d.ts +2 -0
  57. package/dist/sessions/resume-kept-session.d.ts +6 -0
  58. package/dist/sessions/types.d.ts +19 -0
  59. package/dist/test-utils/apply-memory-change.d.ts +3 -0
  60. package/dist/test-utils/build-memory-reader.d.ts +3 -0
  61. package/dist/test-utils/build-mock-claim-change.d.ts +2 -2
  62. package/dist/test-utils/build-mock-new-id.d.ts +2 -0
  63. package/dist/test-utils/build-mock-note-change.d.ts +2 -0
  64. package/dist/test-utils/build-mock-pull-request-change.d.ts +1 -0
  65. package/dist/test-utils/{build-mock-link-change.d.ts → build-mock-relation-change.d.ts} +2 -2
  66. package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
  67. package/dist/test-utils/build-mock-start-change.d.ts +2 -2
  68. package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
  69. package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
  70. package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
  71. package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
  72. package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
  73. package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
  74. package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
  75. package/dist/test-utils/compare-text.d.ts +1 -0
  76. package/dist/test-utils/create-fake-backoff.d.ts +2 -0
  77. package/dist/test-utils/create-fake-clock.d.ts +5 -0
  78. package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
  79. package/dist/test-utils/create-fake-forge.d.ts +17 -0
  80. package/dist/test-utils/create-fake-worker.d.ts +42 -0
  81. package/dist/test-utils/create-memory-claim-store.d.ts +2 -2
  82. package/dist/test-utils/create-test-dispatch.d.ts +46 -0
  83. package/dist/test-utils/forge-contract.d.ts +15 -0
  84. package/dist/test-utils/is-same-held-seat.d.ts +2 -0
  85. package/dist/test-utils/types.d.ts +12 -2
  86. package/dist/test-utils/worker-contract.d.ts +14 -0
  87. package/dist/timeouts.d.ts +2 -0
  88. package/dist/types.d.ts +13 -0
  89. package/package.json +10 -3
  90. package/src/attempts/attempt-machine.ts +147 -0
  91. package/src/attempts/attempt-moves.ts +218 -0
  92. package/src/attempts/bind-attempt-session.ts +61 -0
  93. package/src/attempts/build-attempt-change.ts +540 -0
  94. package/src/attempts/build-attempt-fact.ts +135 -0
  95. package/src/attempts/end-item-attempts.ts +95 -0
  96. package/src/attempts/is-same-guard.ts +22 -0
  97. package/src/attempts/pick-call-attempt.ts +32 -0
  98. package/src/attempts/stop-stray-session.ts +48 -0
  99. package/src/attempts/types.ts +231 -0
  100. package/src/attempts/write-attempt-move.ts +41 -0
  101. package/src/attempts/write-session-bind.ts +47 -0
  102. package/src/claims/build-snapshot-hash.ts +9 -0
  103. package/src/claims/claim-next.ts +306 -0
  104. package/src/claims/pick-kept-session.ts +41 -0
  105. package/src/claims/request-wake.ts +17 -0
  106. package/src/claims/resume-claim.ts +56 -0
  107. package/src/claims/start-claim.ts +99 -0
  108. package/src/claims/types.ts +67 -0
  109. package/src/closeouts/build-settle-plans.ts +173 -0
  110. package/src/closeouts/close-out-attempt.ts +156 -0
  111. package/src/closeouts/effect-handlers.ts +232 -0
  112. package/src/closeouts/pick-settlement.ts +80 -0
  113. package/src/closeouts/settle-attempt.ts +161 -0
  114. package/src/closeouts/types.ts +87 -0
  115. package/src/index.ts +61 -0
  116. package/src/notices/constants.ts +7 -0
  117. package/src/notices/create-null-notifier.ts +6 -0
  118. package/src/notices/send-notices.ts +41 -0
  119. package/src/notices/types.ts +14 -0
  120. package/src/outbox/build-backoff-ms.ts +14 -0
  121. package/src/outbox/build-initial-sender-state.ts +14 -0
  122. package/src/outbox/build-outbox-status.ts +43 -0
  123. package/src/outbox/constants.ts +15 -0
  124. package/src/outbox/measure-wire-bytes.ts +31 -0
  125. package/src/outbox/pick-sender-move.ts +37 -0
  126. package/src/outbox/run-sender-once.ts +174 -0
  127. package/src/outbox/take-batch.ts +35 -0
  128. package/src/outbox/types.ts +76 -0
  129. package/src/pull-requests/build-link-change.ts +95 -0
  130. package/src/pull-requests/build-state-fact.ts +39 -0
  131. package/src/pull-requests/collect-attempt-trailers.ts +20 -0
  132. package/src/pull-requests/collect-seen-state.ts +19 -0
  133. package/src/pull-requests/constants.ts +5 -0
  134. package/src/pull-requests/link-pull-request.ts +119 -0
  135. package/src/pull-requests/parse-pull-request-url.ts +30 -0
  136. package/src/pull-requests/types.ts +63 -0
  137. package/src/pull-requests/watch-pull-requests.ts +308 -0
  138. package/src/readings/act-on-reading.ts +318 -0
  139. package/src/readings/find-waiting-since.ts +46 -0
  140. package/src/readings/pick-reading-action.ts +315 -0
  141. package/src/readings/types.ts +98 -0
  142. package/src/sessions/build-keep-writes.ts +55 -0
  143. package/src/sessions/build-kept-clear.ts +12 -0
  144. package/src/sessions/forget-sessions.ts +106 -0
  145. package/src/sessions/is-kept-due.ts +27 -0
  146. package/src/sessions/resume-kept-session.ts +25 -0
  147. package/src/sessions/types.ts +39 -0
  148. package/src/test-utils/apply-memory-change.ts +252 -0
  149. package/src/test-utils/build-memory-reader.ts +364 -0
  150. package/src/test-utils/build-mock-bind-change.ts +18 -11
  151. package/src/test-utils/build-mock-claim-change.ts +18 -8
  152. package/src/test-utils/build-mock-file-item-change.ts +3 -1
  153. package/src/test-utils/build-mock-new-id.ts +12 -0
  154. package/src/test-utils/build-mock-note-change.ts +18 -0
  155. package/src/test-utils/build-mock-pause-change.ts +2 -10
  156. package/src/test-utils/build-mock-pull-request-change.ts +19 -9
  157. package/src/test-utils/{build-mock-link-change.ts → build-mock-relation-change.ts} +6 -6
  158. package/src/test-utils/build-mock-running-reading.ts +19 -0
  159. package/src/test-utils/build-mock-settle-change.ts +18 -6
  160. package/src/test-utils/build-mock-start-change.ts +13 -6
  161. package/src/test-utils/claim-store-attempt-reader-cases.ts +163 -133
  162. package/src/test-utils/claim-store-claim-cases.ts +168 -85
  163. package/src/test-utils/claim-store-close-cases.ts +95 -0
  164. package/src/test-utils/claim-store-contract.ts +12 -2
  165. package/src/test-utils/claim-store-copy-cases.ts +100 -0
  166. package/src/test-utils/claim-store-exclusive-cases.ts +65 -19
  167. package/src/test-utils/claim-store-guard-cases.ts +92 -82
  168. package/src/test-utils/claim-store-item-reader-cases.ts +291 -77
  169. package/src/test-utils/claim-store-outbox-cases.ts +281 -0
  170. package/src/test-utils/claim-store-race-cases.ts +150 -0
  171. package/src/test-utils/claim-store-rollback-cases.ts +50 -50
  172. package/src/test-utils/claim-store-seat-cases.ts +270 -0
  173. package/src/test-utils/claim-store-session-cases.ts +289 -0
  174. package/src/test-utils/collect-memory-limit-fills.ts +15 -0
  175. package/src/test-utils/compare-text.ts +8 -0
  176. package/src/test-utils/create-fake-backoff.ts +16 -0
  177. package/src/test-utils/create-fake-clock.ts +20 -0
  178. package/src/test-utils/create-fake-fact-sink.ts +74 -0
  179. package/src/test-utils/create-fake-forge.ts +117 -0
  180. package/src/test-utils/create-fake-worker.ts +303 -0
  181. package/src/test-utils/create-memory-claim-store.ts +73 -484
  182. package/src/test-utils/create-test-dispatch.ts +597 -0
  183. package/src/test-utils/forge-contract.ts +171 -0
  184. package/src/test-utils/is-same-held-seat.ts +11 -0
  185. package/src/test-utils/types.ts +14 -2
  186. package/src/test-utils/worker-contract.ts +192 -0
  187. package/src/timeouts.ts +17 -0
  188. package/src/types.ts +32 -0
  189. package/dist/test-utils/claim-store-pool-cases.d.ts +0 -2
  190. package/src/test-utils/claim-store-pool-cases.ts +0 -178
@@ -0,0 +1,147 @@
1
+ import type { AttemptState, SessionMode } from '@boxxy/model';
2
+ import invariant from 'tiny-invariant';
3
+ import { createMachine, types } from 'xstate';
4
+ import { attemptMoves } from './attempt-moves.ts';
5
+ import type { AttemptContext, AttemptMachineEvent, AttemptMove, MoveEffect } from './types.ts';
6
+
7
+ // The attempt states as an XState machine, built from the table of moves. Callers run it only
8
+ // through the pure `transition` and `initialTransition`, and read each move's writes from the
9
+ // `effect` events it emits.
10
+ export const attemptMachine = createMachine({
11
+ schemas: {
12
+ context: types<AttemptContext>(),
13
+ input: types<{ mode: SessionMode }>(),
14
+ events: {
15
+ started: types<{ via: 'start' | 'reconcile' }>(),
16
+ 'spawn-unanswered': types<void>(),
17
+ 'session-seen': types<void>(),
18
+ 'closed-out': types<void>(),
19
+ 'timed-out': types<void>(),
20
+ 'item-closed': types<void>(),
21
+ 'turn-ended': types<void>(),
22
+ stalled: types<void>(),
23
+ 'skill-missing': types<void>(),
24
+ 'usage-limited': types<void>(),
25
+ 'suspend-asked': types<void>(),
26
+ 'suspend-refused': types<void>(),
27
+ slept: types<void>(),
28
+ 'wake-requested': types<void>(),
29
+ woken: types<{ overCap: boolean }>(),
30
+ resumed: types<void>(),
31
+ 'wake-refused': types<void>(),
32
+ settled: types<{ state: 'succeeded' | 'failed'; sessionStopped: boolean }>(),
33
+ 'stray-stopped': types<void>(),
34
+ 'session-forgotten': types<void>(),
35
+ },
36
+ emitted: { effect: types<{ effect: MoveEffect }>() },
37
+ },
38
+ context: (args) => ({
39
+ mark: false,
40
+ wakeRequested: false,
41
+ mode: args.input.mode,
42
+ holdGen: 1,
43
+ closing: null,
44
+ hasSession: false,
45
+ strayed: false,
46
+ sessionStopped: false,
47
+ sessionForgotten: false,
48
+ }),
49
+ entry: (_args, enq) => {
50
+ for (const effect of getClaimedMove().effects) {
51
+ enq.emit({ type: 'effect', effect });
52
+ }
53
+ },
54
+ initial: 'starting',
55
+ states: {
56
+ starting: { on: buildStateHandlers('starting') },
57
+ reconciling: { on: buildStateHandlers('reconciling') },
58
+ running: { on: buildStateHandlers('running') },
59
+ sleeping: { on: buildStateHandlers('sleeping') },
60
+ waking: { on: buildStateHandlers('waking') },
61
+ closing: { on: buildStateHandlers('closing') },
62
+
63
+ // plain states, not final: a final state drops `session-seen` and `stray-stopped`
64
+ succeeded: { on: buildStateHandlers('succeeded') },
65
+ failed: { on: buildStateHandlers('failed') },
66
+ },
67
+ });
68
+
69
+ function getClaimedMove(): AttemptMove {
70
+ const claimed = attemptMoves.find((move) => move.event === 'claimed');
71
+
72
+ invariant(claimed !== undefined, 'the table of moves has a claimed row');
73
+
74
+ return claimed;
75
+ }
76
+
77
+ interface MoveArgs {
78
+ readonly context: AttemptContext;
79
+ readonly event: AttemptMachineEvent;
80
+ }
81
+
82
+ interface EffectEvent {
83
+ type: 'effect';
84
+ effect: MoveEffect;
85
+ }
86
+
87
+ interface MoveEnqueue {
88
+ readonly emit: (event: EffectEvent) => void;
89
+ }
90
+
91
+ interface MoveTarget {
92
+ target?: AttemptState;
93
+ context?: Partial<AttemptContext>;
94
+ }
95
+
96
+ type MoveHandler = (args: MoveArgs, enq: MoveEnqueue) => MoveTarget | undefined;
97
+
98
+ // Every state lists every event, and a handler with no matching row leaves its event unhandled.
99
+ // The type check of `createMachine` refuses an `on` object whose keys are optional.
100
+ function buildStateHandlers(state: AttemptState): Record<AttemptMachineEvent['type'], MoveHandler> {
101
+ const build = (event: AttemptMachineEvent['type']): MoveHandler =>
102
+ buildMoveHandler(
103
+ attemptMoves.filter((move) => move.event === event && move.from.includes(state)),
104
+ );
105
+
106
+ return {
107
+ started: build('started'),
108
+ 'spawn-unanswered': build('spawn-unanswered'),
109
+ 'session-seen': build('session-seen'),
110
+ 'closed-out': build('closed-out'),
111
+ 'timed-out': build('timed-out'),
112
+ 'item-closed': build('item-closed'),
113
+ 'turn-ended': build('turn-ended'),
114
+ stalled: build('stalled'),
115
+ 'skill-missing': build('skill-missing'),
116
+ 'usage-limited': build('usage-limited'),
117
+ 'suspend-asked': build('suspend-asked'),
118
+ 'suspend-refused': build('suspend-refused'),
119
+ slept: build('slept'),
120
+ 'wake-requested': build('wake-requested'),
121
+ woken: build('woken'),
122
+ resumed: build('resumed'),
123
+ 'wake-refused': build('wake-refused'),
124
+ settled: build('settled'),
125
+ 'stray-stopped': build('stray-stopped'),
126
+ 'session-forgotten': build('session-forgotten'),
127
+ };
128
+ }
129
+
130
+ function buildMoveHandler(moves: readonly AttemptMove[]): MoveHandler {
131
+ return (args, enq) => {
132
+ const move = moves.find((candidate) => candidate.when?.(args.context, args.event) ?? true);
133
+
134
+ // XState counts any queued effect as a taken move, so the guard runs before the effects
135
+ return move === undefined ? undefined : takeMove(move, args, enq);
136
+ };
137
+ }
138
+
139
+ function takeMove(move: AttemptMove, args: MoveArgs, enq: MoveEnqueue): MoveTarget {
140
+ for (const effect of move.effects) {
141
+ enq.emit({ type: 'effect', effect });
142
+ }
143
+
144
+ const patch = move.context?.(args.context, args.event) ?? {};
145
+
146
+ return move.to === 'same' ? { context: patch } : { target: move.to, context: patch };
147
+ }
@@ -0,0 +1,218 @@
1
+ import type { AttemptState } from '@boxxy/model';
2
+ import type { AttemptMove } from './types.ts';
3
+
4
+ const liveStates: readonly AttemptState[] = [
5
+ 'starting',
6
+ 'reconciling',
7
+ 'running',
8
+ 'sleeping',
9
+ 'waking',
10
+ 'closing',
11
+ ];
12
+
13
+ // The table of section A of the core flow sketch. The attempt machine is built from it, and the
14
+ // first row whose `from` and `when` match an event is the move.
15
+ export const attemptMoves: readonly AttemptMove[] = [
16
+ {
17
+ event: 'claimed',
18
+ from: [],
19
+ to: 'starting',
20
+ effects: [
21
+ 'take-lease',
22
+ 'take-seats',
23
+ 'record-decision',
24
+ 'boxxy.attempt.claimed',
25
+ 'boxxy.decision.recorded',
26
+ ],
27
+ },
28
+ {
29
+ event: 'started',
30
+ from: ['starting', 'reconciling'],
31
+ when: (_context, event) => event.type === 'started' && event.via === 'start',
32
+ to: 'running',
33
+ effects: ['boxxy.attempt.started'],
34
+ context: () => ({ hasSession: true }),
35
+ },
36
+ {
37
+ event: 'started',
38
+ from: ['starting', 'reconciling'],
39
+ when: (_context, event) => event.type === 'started' && event.via === 'reconcile',
40
+ to: 'running',
41
+ effects: ['boxxy.attempt.reconciled'],
42
+ context: () => ({ hasSession: true }),
43
+ },
44
+ {
45
+ event: 'started',
46
+ from: ['closing'],
47
+ when: (context) => context.closing === 'item-closed' && !context.hasSession,
48
+ to: 'same',
49
+ effects: ['boxxy.attempt.started'],
50
+ context: () => ({ hasSession: true }),
51
+ },
52
+ {
53
+ event: 'spawn-unanswered',
54
+ from: ['starting'],
55
+ to: 'reconciling',
56
+ effects: ['boxxy.attempt.spawn_unanswered'],
57
+ },
58
+ {
59
+ event: 'session-seen',
60
+ from: [...liveStates, 'succeeded', 'failed'],
61
+ to: 'same',
62
+ effects: ['bind-session', 'boxxy.attempt.session_bound'],
63
+ },
64
+ {
65
+ event: 'closed-out',
66
+ from: ['starting', 'reconciling', 'running', 'sleeping', 'waking'],
67
+ to: 'closing',
68
+ effects: ['boxxy.attempt.closed_out'],
69
+ context: () => ({ closing: 'closed-out', mark: false, wakeRequested: false }),
70
+ },
71
+ {
72
+ event: 'timed-out',
73
+ from: ['running'],
74
+ when: (context) => context.mode === 'agent',
75
+ to: 'closing',
76
+ effects: ['boxxy.attempt.timed_out'],
77
+ context: () => ({ closing: 'timed-out' }),
78
+ },
79
+ {
80
+ // the close of the item writes `boxxy.item.closed` for each attempt it ends
81
+ event: 'item-closed',
82
+ from: ['starting', 'reconciling', 'running', 'waking'],
83
+ to: 'closing',
84
+ effects: [],
85
+ context: () => ({ closing: 'item-closed', mark: false, wakeRequested: false }),
86
+ },
87
+ {
88
+ event: 'turn-ended',
89
+ from: ['running'],
90
+ when: (context) => context.mode === 'agent',
91
+ to: 'closing',
92
+ effects: ['boxxy.attempt.turn_ended'],
93
+ context: () => ({ closing: 'turn-ended' }),
94
+ },
95
+ {
96
+ // `boxxy.attempt.settled` carries the cause
97
+ event: 'stalled',
98
+ from: ['running'],
99
+ when: (context) => context.mode === 'agent',
100
+ to: 'closing',
101
+ effects: [],
102
+ context: () => ({ closing: 'stalled' }),
103
+ },
104
+ {
105
+ // `boxxy.attempt.settled` carries the cause
106
+ event: 'skill-missing',
107
+ from: ['starting', 'reconciling', 'running'],
108
+ to: 'closing',
109
+ effects: [],
110
+ context: () => ({ closing: 'skill-missing' }),
111
+ },
112
+ {
113
+ // `boxxy.attempt.settled` carries the cause
114
+ event: 'usage-limited',
115
+ from: ['running'],
116
+ to: 'closing',
117
+ effects: [],
118
+ context: () => ({ closing: 'usage-limit' }),
119
+ },
120
+ {
121
+ event: 'suspend-asked',
122
+ from: ['running'],
123
+ when: (context) => !context.mark,
124
+ to: 'same',
125
+ effects: [],
126
+ context: () => ({ mark: true }),
127
+ },
128
+ {
129
+ event: 'suspend-refused',
130
+ from: ['running'],
131
+ when: (context) => context.mark,
132
+ to: 'same',
133
+ effects: [],
134
+ context: () => ({ mark: false, wakeRequested: false }),
135
+ },
136
+ {
137
+ event: 'slept',
138
+ from: ['starting', 'reconciling', 'running'],
139
+ to: 'sleeping',
140
+ effects: ['release-seats', 'boxxy.attempt.slept'],
141
+ context: () => ({ mark: false }),
142
+ },
143
+ {
144
+ event: 'wake-requested',
145
+ from: ['sleeping'],
146
+ when: (context) => !context.wakeRequested,
147
+ to: 'same',
148
+ effects: ['boxxy.attempt.wake_requested'],
149
+ context: () => ({ wakeRequested: true }),
150
+ },
151
+ {
152
+ event: 'wake-requested',
153
+ from: ['running'],
154
+ when: (context) => context.mark,
155
+ to: 'same',
156
+ effects: ['boxxy.attempt.wake_requested'],
157
+ context: () => ({ wakeRequested: true }),
158
+ },
159
+ {
160
+ event: 'woken',
161
+ from: ['sleeping'],
162
+ to: 'waking',
163
+ effects: ['take-seats', 'record-decision', 'boxxy.attempt.woken', 'boxxy.decision.recorded'],
164
+ context: (context) => ({ holdGen: context.holdGen + 1 }),
165
+ },
166
+ {
167
+ // the wake is done, so a later sleep starts with no request
168
+ event: 'resumed',
169
+ from: ['waking'],
170
+ to: 'running',
171
+ effects: ['boxxy.attempt.resumed'],
172
+ context: () => ({ wakeRequested: false }),
173
+ },
174
+ {
175
+ // the request stays, so a later claim tries the wake again
176
+ event: 'wake-refused',
177
+ from: ['waking'],
178
+ to: 'sleeping',
179
+ effects: ['release-seats', 'boxxy.attempt.slept'],
180
+ },
181
+ {
182
+ event: 'settled',
183
+ from: liveStates,
184
+ when: (_context, event) => event.type === 'settled' && event.state === 'succeeded',
185
+ to: 'succeeded',
186
+ effects: ['release-seats', 'release-lease', 'boxxy.attempt.settled'],
187
+ context: (_context, event) => ({
188
+ strayed: event.type === 'settled' && !event.sessionStopped,
189
+ }),
190
+ },
191
+ {
192
+ event: 'settled',
193
+ from: liveStates,
194
+ when: (_context, event) => event.type === 'settled' && event.state === 'failed',
195
+ to: 'failed',
196
+ effects: ['release-seats', 'release-lease', 'boxxy.attempt.settled'],
197
+ context: (_context, event) => ({
198
+ strayed: event.type === 'settled' && !event.sessionStopped,
199
+ }),
200
+ },
201
+ {
202
+ event: 'stray-stopped',
203
+ from: ['succeeded', 'failed'],
204
+ when: (context) => context.strayed && !context.sessionStopped,
205
+ to: 'same',
206
+ effects: [],
207
+ context: () => ({ sessionStopped: true }),
208
+ },
209
+ {
210
+ // a stray session is forgotten only after it has stopped
211
+ event: 'session-forgotten',
212
+ from: ['succeeded', 'failed'],
213
+ when: (context) => !context.sessionForgotten && (!context.strayed || context.sessionStopped),
214
+ to: 'same',
215
+ effects: [],
216
+ context: () => ({ sessionForgotten: true }),
217
+ },
218
+ ];
@@ -0,0 +1,61 @@
1
+ import type {
2
+ AgentSessionId,
3
+ ClaimStore,
4
+ Instant,
5
+ NewId,
6
+ SessionFault,
7
+ StoreTx,
8
+ } from '@boxxy/model';
9
+ import type { CallRef } from '../closeouts/types.ts';
10
+ import { pickCallAttempt } from './pick-call-attempt.ts';
11
+ import { writeAttemptMove } from './write-attempt-move.ts';
12
+ import { writeSessionBind } from './write-session-bind.ts';
13
+
14
+ export interface BindAttemptSessionInput {
15
+ readonly store: ClaimStore;
16
+ readonly call: CallRef;
17
+ readonly agentSession: AgentSessionId;
18
+
19
+ // each skill the recipe lists that the session-start hook did not find after its fetch
20
+ readonly missingSkills: readonly string[];
21
+ readonly now: Instant;
22
+ readonly newId: NewId;
23
+ }
24
+
25
+ export type BindAnswer =
26
+ | { readonly kind: 'bound' | 'no-attempt' | 'not-current' | 'skill-missing' }
27
+ | SessionFault;
28
+
29
+ // The session-start hook's call. It reaches only the attempt that `pickCallAttempt` gives. A
30
+ // missing skill moves the attempt to `closing`, and the runner settles it as `skill-missing`.
31
+ export function bindAttemptSession(input: BindAttemptSessionInput): Promise<BindAnswer> {
32
+ return input.store.exclusive((tx) => writeBind(tx, input));
33
+ }
34
+
35
+ async function writeBind(tx: StoreTx, input: BindAttemptSessionInput): Promise<BindAnswer> {
36
+ const latest = await tx.findAttemptBySession(input.call.session);
37
+
38
+ const picked = pickCallAttempt(latest, input.call);
39
+
40
+ if (picked.kind === 'refused') {
41
+ return { kind: picked.reason };
42
+ }
43
+
44
+ const attempt = picked.attempt;
45
+ const base = { actor: `boxxy:hook:${attempt.id}`, now: input.now, newId: input.newId } as const;
46
+
47
+ if (input.missingSkills.length > 0) {
48
+ await writeAttemptMove(tx, attempt.id, { kind: 'skill-missing' }, base);
49
+
50
+ return { kind: 'skill-missing' };
51
+ }
52
+
53
+ const fault = await writeSessionBind(
54
+ tx,
55
+ attempt,
56
+ { atcSession: input.call.session, agentSession: input.agentSession },
57
+ base,
58
+ );
59
+
60
+ return fault ?? { kind: 'bound' };
61
+ }