@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,315 @@
1
+ import type {
2
+ AttemptRow,
3
+ ClosingCause,
4
+ ForcedFailure,
5
+ Instant,
6
+ LiveAttemptState,
7
+ SessionReading,
8
+ } from '@boxxy/model';
9
+ import invariant from 'tiny-invariant';
10
+ import { timeouts } from '../timeouts.ts';
11
+ import { findWaitingSince } from './find-waiting-since.ts';
12
+ import type { ReadingAction, ReadingInput, ReadingTable, RunningBound } from './types.ts';
13
+
14
+ const minuteMs = 60_000;
15
+ const secondMs = 1000;
16
+
17
+ // The reconcile table: one action for each reading of a live attempt's session. Pure.
18
+ export function pickReadingAction(input: ReadingInput): ReadingAction {
19
+ const state = input.attempt.state;
20
+
21
+ invariant(isLiveState(state), `the runner reads only live attempts, not ${state}`);
22
+
23
+ return pickCell(input, state);
24
+ }
25
+
26
+ function isLiveState(state: AttemptRow['state']): state is LiveAttemptState {
27
+ return state !== 'succeeded' && state !== 'failed';
28
+ }
29
+
30
+ // Read it as: the reading, then the attempt's state. v1-lifecycle.md has the same table.
31
+ const readingTable: ReadingTable = {
32
+ running: {
33
+ starting: moveToRunning,
34
+ reconciling: moveToRunning,
35
+ running: pickRunningBound,
36
+ sleeping: pickSessionRan,
37
+ waking: () => ({ kind: 'move-to-running', via: 'resume' }),
38
+ closing: pickClosingStop,
39
+ },
40
+ suspended: {
41
+ starting: sleepUnasked,
42
+ reconciling: sleepUnasked,
43
+ running: sleepUnasked,
44
+ sleeping: nothing,
45
+ waking: resumeOrLost,
46
+ closing: settleClosing,
47
+ },
48
+ ended: {
49
+ starting: settleWith(null),
50
+ reconciling: settleWith(null),
51
+ running: sleepOrSettle,
52
+ sleeping: nothing,
53
+ waking: resumeOrLost,
54
+ closing: settleClosing,
55
+ },
56
+ absent: {
57
+ starting: respawnOrLost,
58
+ reconciling: respawnOrLost,
59
+ running: settleWith('session-lost'),
60
+ sleeping: settleWith('session-lost'),
61
+ waking: settleWith('session-lost'),
62
+ closing: awaitSpawnOrSettle,
63
+ },
64
+ };
65
+
66
+ // Each reading kind narrows the input before it reaches its row of the table.
67
+ function pickCell(input: ReadingInput, state: LiveAttemptState): ReadingAction {
68
+ const reading = input.reading;
69
+
70
+ if (reading.kind === 'running') {
71
+ return readingTable.running[state]({ ...input, reading });
72
+ }
73
+
74
+ if (reading.kind === 'suspended') {
75
+ return readingTable.suspended[state]({ ...input, reading });
76
+ }
77
+
78
+ if (reading.kind === 'ended') {
79
+ return readingTable.ended[state]({ ...input, reading });
80
+ }
81
+
82
+ return readingTable.absent[state]({ ...input, reading });
83
+ }
84
+
85
+ function moveToRunning(): ReadingAction {
86
+ return { kind: 'move-to-running', via: 'reconcile' };
87
+ }
88
+
89
+ function pickRunningBound(
90
+ input: ReadingInput<Extract<SessionReading, { kind: 'running' }>>,
91
+ ): ReadingAction {
92
+ for (const bound of runningBounds) {
93
+ const action = bound.pick(input);
94
+
95
+ if (action !== null) {
96
+ return action;
97
+ }
98
+ }
99
+
100
+ return { kind: 'bind-only' };
101
+ }
102
+
103
+ // The bounds of a running attempt whose session runs, in the order they are checked. A session
104
+ // that waits for the owner sleeps before the stall rule can stop it: both fall due 30 minutes
105
+ // after the last activity, and a stall first would leave the idle sleep no case in agent mode.
106
+ const runningBounds: readonly RunningBound[] = [
107
+ {
108
+ name: 'a suspend that did not happen',
109
+ pick: (input) => (input.attempt.suspendRequestedAt === null ? null : { kind: 'clear-mark' }),
110
+ },
111
+ {
112
+ name: 'the usage limit',
113
+ pick: (input) =>
114
+ input.reading.usageLimitResetsAt === null
115
+ ? null
116
+ : {
117
+ kind: 'close',
118
+ cause: 'usage-limit',
119
+ resetsAt: input.reading.usageLimitResetsAt,
120
+ },
121
+ },
122
+ {
123
+ name: 'the step timeout, agent mode',
124
+ pick: (input) => {
125
+ const step = input.step;
126
+ const since = getStretchStart(input.attempt);
127
+
128
+ return input.attempt.mode === 'agent' &&
129
+ step !== null &&
130
+ input.now - since >= step.timeoutMinutes * minuteMs
131
+ ? {
132
+ kind: 'close',
133
+ cause: 'timed-out',
134
+ afterMinutes: step.timeoutMinutes,
135
+ }
136
+ : null;
137
+ },
138
+ },
139
+ {
140
+ name: 'a turn that ended with no close-out, agent mode',
141
+ pick: (input) =>
142
+ input.attempt.mode === 'agent' && input.reading.attention === 'turn-ended'
143
+ ? { kind: 'close', cause: 'turn-ended' }
144
+ : null,
145
+ },
146
+ {
147
+ name: 'a session that waits for the owner',
148
+ pick: (input) => {
149
+ const waitingSince = findWaitingSince({
150
+ attempt: input.attempt,
151
+ reading: input.reading,
152
+ quietAfterMinutes: input.sleep.afterQuietMinutes,
153
+ now: input.now,
154
+ });
155
+
156
+ // atc cannot revive a session stopped before it captured the agent session ID
157
+ if (waitingSince === null || input.reading.agentSession === null) {
158
+ return null;
159
+ }
160
+
161
+ return input.now - waitingSince >= input.sleep.afterIdleMinutes * minuteMs
162
+ ? { kind: 'sleep-if-idle', waitingSince }
163
+ : { kind: 'bind-only' };
164
+ },
165
+ },
166
+ {
167
+ name: 'a stall, agent mode',
168
+ pick: (input) =>
169
+ input.attempt.mode === 'agent' &&
170
+ input.now - input.reading.lastActivityAt >= timeouts.stallMinutes * minuteMs
171
+ ? { kind: 'close', cause: 'stalled' }
172
+ : null,
173
+ },
174
+ {
175
+ name: 'an owner who has not attached, attended mode',
176
+ pick: (input) => {
177
+ const since = getLater(getStretchStart(input.attempt), input.reading.lastOwnerAt);
178
+
179
+ return input.attempt.mode === 'attended' &&
180
+ input.reading.agentSession !== null &&
181
+ input.now - since >= input.sleep.afterUnattendedMinutes * minuteMs
182
+ ? { kind: 'sleep-unattended', since }
183
+ : null;
184
+ },
185
+ },
186
+ ];
187
+
188
+ // One stretch of running starts at the start or at the latest wake.
189
+ function getStretchStart(attempt: AttemptRow): Instant {
190
+ return getLater(attempt.startedAt ?? attempt.claimedAt, attempt.wokenAt);
191
+ }
192
+
193
+ function getLater(first: Instant, second: Instant | null): Instant {
194
+ return second !== null && second > first ? second : first;
195
+ }
196
+
197
+ // The owner revived a sleeping session in atc: ask for the wake, and wake over the cap when no
198
+ // seat came free in time.
199
+ function pickSessionRan(input: ReadingInput): ReadingAction {
200
+ const requestedAt = input.attempt.wakeRequestedAt;
201
+
202
+ if (requestedAt === null) {
203
+ return { kind: 'request-wake', by: 'session-ran' };
204
+ }
205
+
206
+ return input.now - requestedAt >= timeouts.seatlessSeconds * secondMs
207
+ ? { kind: 'wake-over-cap' }
208
+ : { kind: 'nothing' };
209
+ }
210
+
211
+ function pickClosingStop(
212
+ input: ReadingInput<Extract<SessionReading, { kind: 'running' }>>,
213
+ ): ReadingAction {
214
+ const closingAt = input.attempt.closingAt;
215
+
216
+ invariant(closingAt !== null, 'a closing attempt records when it closed');
217
+
218
+ const closingMs = input.now - closingAt;
219
+
220
+ if (closingMs >= timeouts.closingGiveUpMinutes * minuteMs) {
221
+ return {
222
+ kind: 'settle',
223
+ forced: findClosingFailure(input.attempt),
224
+ sessionStopped: false,
225
+ };
226
+ }
227
+
228
+ const isInsideGrace =
229
+ input.attempt.closing !== 'item-closed' &&
230
+ input.reading.attention === 'working' &&
231
+ closingMs < timeouts.stopGraceSeconds * secondMs;
232
+
233
+ return isInsideGrace ? { kind: 'nothing' } : { kind: 'stop' };
234
+ }
235
+
236
+ // A closed-out attempt settles by its close-out; one that closed for its turn end has none
237
+ const closingFailures: Readonly<Record<ClosingCause, ForcedFailure | null>> = {
238
+ 'closed-out': null,
239
+ 'turn-ended': null,
240
+ 'timed-out': 'timed-out',
241
+ 'item-closed': 'item-closed',
242
+ stalled: 'stalled',
243
+ 'skill-missing': 'skill-missing',
244
+ 'usage-limit': 'usage-limit',
245
+ };
246
+
247
+ function findClosingFailure(attempt: AttemptRow): ForcedFailure | null {
248
+ invariant(attempt.closing !== null, 'a closing attempt has a cause');
249
+
250
+ return closingFailures[attempt.closing];
251
+ }
252
+
253
+ // A session that stopped with no request of boxxy's: the host slept, so it wakes by itself.
254
+ function sleepUnasked(
255
+ input: ReadingInput<Extract<SessionReading, { kind: 'suspended' }>>,
256
+ ): ReadingAction {
257
+ return {
258
+ kind: 'sleep',
259
+ requestWake: !input.reading.asked && input.attempt.suspendRequestedAt === null,
260
+ };
261
+ }
262
+
263
+ function nothing(): ReadingAction {
264
+ return { kind: 'nothing' };
265
+ }
266
+
267
+ function resumeOrLost(input: ReadingInput): ReadingAction {
268
+ const wokenAt = input.attempt.wokenAt;
269
+
270
+ invariant(wokenAt !== null, 'a waking attempt records when it woke');
271
+
272
+ return input.now - wokenAt >= timeouts.resumeLostMinutes * minuteMs
273
+ ? { kind: 'settle', forced: 'resume-lost', sessionStopped: true }
274
+ : { kind: 'resume' };
275
+ }
276
+
277
+ function settleClosing(input: ReadingInput): ReadingAction {
278
+ return {
279
+ kind: 'settle',
280
+ forced: findClosingFailure(input.attempt),
281
+ sessionStopped: true,
282
+ };
283
+ }
284
+
285
+ function settleWith(forced: ForcedFailure | null): () => ReadingAction {
286
+ return () => ({ kind: 'settle', forced, sessionStopped: true });
287
+ }
288
+
289
+ function sleepOrSettle(input: ReadingInput): ReadingAction {
290
+ return input.attempt.suspendRequestedAt === null
291
+ ? { kind: 'settle', forced: null, sessionStopped: true }
292
+ : { kind: 'sleep', requestWake: false };
293
+ }
294
+
295
+ function respawnOrLost(input: ReadingInput): ReadingAction {
296
+ return input.now - input.attempt.claimedAt >= timeouts.spawnLostMinutes * minuteMs
297
+ ? { kind: 'settle', forced: 'spawn-lost', sessionStopped: true }
298
+ : { kind: 'respawn' };
299
+ }
300
+
301
+ // A closing attempt whose session was never seen: its spawn may still land. After the spawn
302
+ // time it settles as a stray, so the runner keeps looking for the session by its spawn name.
303
+ function awaitSpawnOrSettle(input: ReadingInput): ReadingAction {
304
+ if (input.attempt.atcSession !== null) {
305
+ return settleClosing(input);
306
+ }
307
+
308
+ return input.now - input.attempt.claimedAt >= timeouts.spawnLostMinutes * minuteMs
309
+ ? {
310
+ kind: 'settle',
311
+ forced: findClosingFailure(input.attempt),
312
+ sessionStopped: false,
313
+ }
314
+ : { kind: 'await-spawn' };
315
+ }
@@ -0,0 +1,98 @@
1
+ import type {
2
+ AttemptRow,
3
+ CapacityBackoff,
4
+ ClaimStore,
5
+ ForcedFailure,
6
+ Instant,
7
+ LiveAttemptState,
8
+ LoadedRules,
9
+ NewId,
10
+ PipelineStep,
11
+ SessionReading,
12
+ SleepRules,
13
+ WakeSource,
14
+ WorkerKind,
15
+ } from '@boxxy/model';
16
+
17
+ // What the runner does with one reading of a live attempt's session.
18
+ export type ReadingAction =
19
+ | { readonly kind: 'nothing' }
20
+ | { readonly kind: 'bind-only' }
21
+ | { readonly kind: 'move-to-running'; readonly via: 'reconcile' | 'resume' }
22
+
23
+ // sends the stored spawn request again
24
+ | { readonly kind: 'respawn' }
25
+ | { readonly kind: 'clear-mark' }
26
+ | { readonly kind: 'sleep-if-idle'; readonly waitingSince: Instant }
27
+
28
+ // the mark, then a suspend with no idle check, then `slept`
29
+ | { readonly kind: 'sleep-unattended'; readonly since: Instant }
30
+
31
+ // `closing` with no session seen: no respawn and no settle yet
32
+ | { readonly kind: 'await-spawn' }
33
+
34
+ // the session stopped with no sleep of boxxy's in progress, or after the mark
35
+ | { readonly kind: 'sleep'; readonly requestWake: boolean }
36
+ | { readonly kind: 'request-wake'; readonly by: WakeSource }
37
+ | { readonly kind: 'wake-over-cap' }
38
+
39
+ // asks the worker to resume the woken session again
40
+ | { readonly kind: 'resume' }
41
+ | {
42
+ readonly kind: 'close';
43
+ readonly cause: 'timed-out';
44
+ readonly afterMinutes: number;
45
+ }
46
+ | { readonly kind: 'close'; readonly cause: 'turn-ended' | 'stalled' }
47
+ | {
48
+ readonly kind: 'close';
49
+ readonly cause: 'usage-limit';
50
+ readonly resetsAt: Instant;
51
+ }
52
+ | { readonly kind: 'stop' }
53
+ | {
54
+ readonly kind: 'settle';
55
+ readonly forced: ForcedFailure | null;
56
+ readonly sessionStopped: boolean;
57
+ };
58
+
59
+ export interface ReadingInput<Reading extends SessionReading = SessionReading> {
60
+ readonly attempt: AttemptRow;
61
+ readonly reading: Reading;
62
+
63
+ // the attempt's step in the item's pipeline; null when the rules lost the phase
64
+ readonly step: PipelineStep | null;
65
+ readonly sleep: SleepRules;
66
+ readonly now: Instant;
67
+ }
68
+
69
+ type ReadingRule<Kind extends SessionReading['kind']> = (
70
+ input: ReadingInput<Extract<SessionReading, { kind: Kind }>>,
71
+ ) => ReadingAction;
72
+
73
+ // One rule for each reading and each live state, so the type check proves every cell is there.
74
+ export type ReadingTable = {
75
+ readonly [Kind in SessionReading['kind']]: Readonly<Record<LiveAttemptState, ReadingRule<Kind>>>;
76
+ };
77
+
78
+ export interface ActOnReadingInput {
79
+ readonly store: ClaimStore;
80
+ readonly worker: WorkerKind;
81
+ readonly rules: LoadedRules;
82
+ readonly backoff: CapacityBackoff;
83
+
84
+ // the attempt as the runner listed it before the reading
85
+ readonly attempt: AttemptRow;
86
+ readonly reading: SessionReading;
87
+ readonly action: ReadingAction;
88
+ readonly now: Instant;
89
+ readonly newId: NewId;
90
+ }
91
+
92
+ // One bound of a running attempt whose session runs. The first bound that gives an action wins.
93
+ export interface RunningBound {
94
+ readonly name: string;
95
+ readonly pick: (
96
+ input: ReadingInput<Extract<SessionReading, { kind: 'running' }>>,
97
+ ) => ReadingAction | null;
98
+ }
@@ -0,0 +1,55 @@
1
+ import type { AttemptRow, Instant, ItemView, LoadedRules, RowWrite } from '@boxxy/model';
2
+ import { spawnRequestSchema } from '@boxxy/model';
3
+
4
+ interface KeepInput {
5
+ // the attempt that settled `succeeded` with its session stopped
6
+ readonly attempt: AttemptRow;
7
+ readonly item: ItemView;
8
+ readonly rules: LoadedRules;
9
+ readonly now: Instant;
10
+ }
11
+
12
+ // The write that records the attempt's session as the item's kept session, when an edge of the
13
+ // item's pipeline comes back to the attempt's phase with `resume: true`; else none. Pure.
14
+ export function buildKeepWrites(input: KeepInput): RowWrite[] {
15
+ const attempt = input.attempt;
16
+ const item = input.item;
17
+
18
+ if (attempt.atcSession === null) {
19
+ return [];
20
+ }
21
+
22
+ const steps = input.rules.kinds.get(item.kind)?.pipeline.steps ?? [];
23
+
24
+ const isResumeEdge = steps.some((step) =>
25
+ Object.values(step.on).some(
26
+ (edge) =>
27
+ typeof edge === 'object' &&
28
+ 'to' in edge &&
29
+ edge.to === attempt.phase &&
30
+ edge.resume === true,
31
+ ),
32
+ );
33
+
34
+ if (!isResumeEdge) {
35
+ return [];
36
+ }
37
+
38
+ return [
39
+ {
40
+ op: 'update',
41
+ table: 'items',
42
+ key: { id: item.id },
43
+ expect: { keptSession: item.keptSession },
44
+ set: {
45
+ keptSession: {
46
+ atcSession: attempt.atcSession,
47
+ target: spawnRequestSchema.parse(attempt.spawnRequest).target,
48
+ attempt: attempt.id,
49
+ keptAt: input.now,
50
+ caller: attempt.caller,
51
+ },
52
+ },
53
+ },
54
+ ];
55
+ }
@@ -0,0 +1,12 @@
1
+ import type { ItemId, KeptSession, RowWrite } from '@boxxy/model';
2
+
3
+ // The write that clears the item's kept session, guarded by the value it was read as.
4
+ export function buildKeptClear(item: ItemId, kept: KeptSession): RowWrite {
5
+ return {
6
+ op: 'update',
7
+ table: 'items',
8
+ key: { id: item },
9
+ expect: { keptSession: kept },
10
+ set: { keptSession: null },
11
+ };
12
+ }
@@ -0,0 +1,106 @@
1
+ import type { AtcSessionId, AttemptRow, ItemId, KeptSession } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+ import { writeAttemptMove } from '../attempts/write-attempt-move.ts';
4
+ import { timeouts } from '../timeouts.ts';
5
+ import { buildKeptClear } from './build-kept-clear.ts';
6
+ import { isKeptDue } from './is-kept-due.ts';
7
+ import type { ForgetReport, ForgetSessionsInput } from './types.ts';
8
+
9
+ const hourMs = 3_600_000;
10
+
11
+ // The cleanup of one runner tick: the sessions of settled attempts that are not kept, and the
12
+ // kept sessions that are due. A forget the worker does not confirm changes nothing and is tried
13
+ // again on the next tick.
14
+ export async function forgetSessions(input: ForgetSessionsInput): Promise<ForgetReport> {
15
+ const cutoff = brandSchemas.instant.parse(
16
+ Math.max(0, input.now - timeouts.failedSessionHours * hourMs),
17
+ );
18
+
19
+ const rows = await input.store.read(async (tx) => ({
20
+ kept: await tx.listKeptSessions(),
21
+ due: await tx.listSessionsToForget(cutoff),
22
+ }));
23
+
24
+ const kept = rows.kept;
25
+ const due = rows.due;
26
+ const forgotten: AtcSessionId[] = [];
27
+ const failed: AtcSessionId[] = [];
28
+
29
+ for (const entry of kept) {
30
+ const isDue = await input.store.read(async (tx) =>
31
+ isKeptDue({
32
+ kept: entry.kept,
33
+ item: await tx.readItem(entry.item, input.now, input.rules),
34
+ latest: await tx.findAttemptBySession(entry.kept.atcSession),
35
+ now: input.now,
36
+ }),
37
+ );
38
+
39
+ if (isDue) {
40
+ const done = await forgetKept(input, entry.item, entry.kept);
41
+
42
+ (done ? forgotten : failed).push(entry.kept.atcSession);
43
+ }
44
+ }
45
+
46
+ for (const attempt of due) {
47
+ const done = await forgetAttempt(input, attempt);
48
+
49
+ if (attempt.atcSession !== null) {
50
+ (done ? forgotten : failed).push(attempt.atcSession);
51
+ }
52
+ }
53
+
54
+ return { forgotten, failed };
55
+ }
56
+
57
+ async function forgetKept(
58
+ input: ForgetSessionsInput,
59
+ item: ItemId,
60
+ kept: KeptSession,
61
+ ): Promise<boolean> {
62
+ const outcome = await input.worker.forget(kept.atcSession);
63
+
64
+ if (outcome.kind !== 'forgotten' && outcome.kind !== 'absent') {
65
+ return false;
66
+ }
67
+
68
+ await input.store.exclusive(async (tx) => {
69
+ const moved = await writeAttemptMove(
70
+ tx,
71
+ kept.attempt,
72
+ { kind: 'session-forgotten' },
73
+ { ...buildBase(input), also: [buildKeptClear(item, kept)] },
74
+ );
75
+
76
+ if (moved.kind !== 'applied') {
77
+ await tx.applyChange({
78
+ at: input.now,
79
+ writes: [buildKeptClear(item, kept)],
80
+ facts: [],
81
+ });
82
+ }
83
+ });
84
+
85
+ return true;
86
+ }
87
+
88
+ function buildBase(input: ForgetSessionsInput) {
89
+ return { actor: input.actor, now: input.now, newId: input.newId };
90
+ }
91
+
92
+ async function forgetAttempt(input: ForgetSessionsInput, attempt: AttemptRow): Promise<boolean> {
93
+ if (attempt.atcSession !== null) {
94
+ const outcome = await input.worker.forget(attempt.atcSession);
95
+
96
+ if (outcome.kind !== 'forgotten' && outcome.kind !== 'absent') {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ await input.store.exclusive((tx) =>
102
+ writeAttemptMove(tx, attempt.id, { kind: 'session-forgotten' }, buildBase(input)),
103
+ );
104
+
105
+ return true;
106
+ }
@@ -0,0 +1,27 @@
1
+ import { timeouts } from '../timeouts.ts';
2
+ import type { KeptForgetInput } from './types.ts';
3
+
4
+ const hourMs = 3_600_000;
5
+
6
+ // Whether the kept session is due to be forgotten: its item is done or closed, or it was kept
7
+ // `keptSessionHours` ago. A session that a live later attempt runs on is never due. Pure.
8
+ export function isKeptDue(input: KeptForgetInput): boolean {
9
+ if (
10
+ input.latest !== null &&
11
+ input.latest.id !== input.kept.attempt &&
12
+ input.latest.endedAt === null
13
+ ) {
14
+ return false;
15
+ }
16
+
17
+ const item = input.item;
18
+ const expired = input.now - input.kept.keptAt >= timeouts.keptSessionHours * hourMs;
19
+
20
+ return (
21
+ item === null ||
22
+ item.closedAt !== null ||
23
+ item.derived.state === 'done' ||
24
+ item.derived.state === 'archived' ||
25
+ expired
26
+ );
27
+ }
@@ -0,0 +1,25 @@
1
+ import type { KeptSession, SpawnRequest, StartOutcome, WorkerKind } from '@boxxy/model';
2
+
3
+ export interface ResumedSession {
4
+ readonly outcome: StartOutcome;
5
+
6
+ // true when the wake failed and the outcome is that of a fresh spawn
7
+ readonly resumeFallback: boolean;
8
+ }
9
+
10
+ // Wakes the kept session with the new attempt's prompt as a message. A session that is gone or
11
+ // refuses the wake is replaced by a fresh spawn. A refusal for capacity or an unanswered wake is
12
+ // not: a spawn would meet the same capacity, and the session may be running.
13
+ export async function resumeKeptSession(
14
+ worker: WorkerKind,
15
+ kept: KeptSession,
16
+ spawn: SpawnRequest,
17
+ ): Promise<ResumedSession> {
18
+ const outcome = await worker.resumeKept(kept, spawn);
19
+
20
+ if (outcome.kind === 'refused' && outcome.cause === 'other') {
21
+ return { outcome: await worker.start(spawn), resumeFallback: true };
22
+ }
23
+
24
+ return { outcome, resumeFallback: false };
25
+ }
@@ -0,0 +1,39 @@
1
+ import type {
2
+ ActorRef,
3
+ AtcSessionId,
4
+ AttemptRow,
5
+ ClaimStore,
6
+ Instant,
7
+ ItemView,
8
+ KeptSession,
9
+ LoadedRules,
10
+ NewId,
11
+ WorkerKind,
12
+ } from '@boxxy/model';
13
+
14
+ export interface ForgetSessionsInput {
15
+ readonly store: ClaimStore;
16
+ readonly worker: WorkerKind;
17
+ readonly rules: LoadedRules;
18
+ readonly actor: ActorRef;
19
+ readonly now: Instant;
20
+ readonly newId: NewId;
21
+ }
22
+
23
+ // The sessions this tick forgot, and those whose forget the worker did not confirm. A failed
24
+ // forget is tried again on the next tick.
25
+ export interface ForgetReport {
26
+ readonly forgotten: readonly AtcSessionId[];
27
+ readonly failed: readonly AtcSessionId[];
28
+ }
29
+
30
+ export interface KeptForgetInput {
31
+ readonly kept: KeptSession;
32
+
33
+ // null when the store has no such item
34
+ readonly item: ItemView | null;
35
+
36
+ // the latest attempt that the kept session carries
37
+ readonly latest: AttemptRow | null;
38
+ readonly now: Instant;
39
+ }