@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,306 @@
1
+ import { contentHash } from '@boxxy/content-hash';
2
+ import type {
3
+ ActorRef,
4
+ AttemptId,
5
+ AttemptRow,
6
+ Claim,
7
+ ItemDetailRows,
8
+ ItemView,
9
+ JsonValue,
10
+ KeptSession,
11
+ LimitCount,
12
+ LimitName,
13
+ LoadedRules,
14
+ Recipe,
15
+ RecipeMaterial,
16
+ SessionMode,
17
+ Snapshot,
18
+ SpawnRequest,
19
+ StartPick,
20
+ StoreTx,
21
+ WakePick,
22
+ } from '@boxxy/model';
23
+ import { brandSchemas, toJSONValue } from '@boxxy/model';
24
+ import { buildFrame, decide } from '@boxxy/planning';
25
+ import invariant from 'tiny-invariant';
26
+ import type { AttemptChange } from '../attempts/build-attempt-change.ts';
27
+ import { buildAttemptChange } from '../attempts/build-attempt-change.ts';
28
+ import type { ClaimedEvent, DecisionDraft } from '../attempts/types.ts';
29
+ import { buildSnapshotHash } from './build-snapshot-hash.ts';
30
+ import { pickKeptSession } from './pick-kept-session.ts';
31
+ import type { ClaimInput, SpawnSettings } from './types.ts';
32
+
33
+ // A race that `decide` did not see: the change took no lease or no seat. The runner ends the
34
+ // tick's claim loop on it.
35
+ export class ClaimRaceError extends Error {
36
+ constructor(result: string) {
37
+ super(`the claim lost a race: ${result}`);
38
+
39
+ this.name = 'ClaimRaceError';
40
+ }
41
+ }
42
+
43
+ // Picks with `decide` and writes the first pick, all inside one store lock. A start pick takes
44
+ // the item-phase lease and its seats; a wake pick takes the sleeping attempt's seats.
45
+ export function claimNext(input: ClaimInput): Promise<Claim | null> {
46
+ return input.store.exclusive(async (tx) => {
47
+ const runner = await tx.readRunner(input.runner);
48
+
49
+ if (runner?.paused === true) {
50
+ return null;
51
+ }
52
+
53
+ const snapshot = await tx.readSnapshot(input.now, input.rules);
54
+
55
+ const backoff = [...input.backoff.listHeld(input.now)];
56
+ const frame = buildFrame({ snapshot, rules: input.rules, now: input.now });
57
+ const decision = decide({ frame, pools: input.rules.pools, snapshot, backoff });
58
+ const [pick] = decision.picks;
59
+
60
+ if (pick === undefined) {
61
+ return null;
62
+ }
63
+
64
+ const draft = {
65
+ rulesVersion: input.rules.version,
66
+ snapshotHash: buildSnapshotHash(snapshot, backoff),
67
+ storeVersion: snapshot.storeVersion,
68
+ pepysOffsets: {},
69
+ faults: frame.faults,
70
+ limits: collectLimitCounts(snapshot, pick.limits),
71
+ };
72
+
73
+ return pick.kind === 'start'
74
+ ? writeStartClaim(tx, input, pick, draft)
75
+ : writeWakeClaim(tx, input, pick, draft);
76
+ });
77
+ }
78
+
79
+ function collectLimitCounts(snapshot: Snapshot, limits: readonly LimitName[]): LimitCount[] {
80
+ return snapshot.limits
81
+ .filter((limit) => limits.includes(limit.name))
82
+ .map((limit) => ({ limit: limit.name, held: limit.held, max: limit.max }));
83
+ }
84
+
85
+ type SharedDraft = Omit<DecisionDraft, 'item' | 'kind' | 'pool'>;
86
+
87
+ async function writeStartClaim(
88
+ tx: StoreTx,
89
+ input: ClaimInput,
90
+ pick: StartPick,
91
+ draft: SharedDraft,
92
+ ): Promise<Claim> {
93
+ const detail = await tx.readItemDetail(pick.item, input.now, input.rules);
94
+
95
+ invariant(detail !== null, `decide picked the item ${pick.item}, which the store holds`);
96
+
97
+ const event = buildClaimedEvent({
98
+ pick,
99
+ detail,
100
+ rules: input.rules,
101
+ settings: input.spawn,
102
+ attempt: brandSchemas.attemptId.parse(input.newId()),
103
+ leaseGen: (await tx.readLastLeaseGen(pick.item, pick.phase)) + 1,
104
+ draft,
105
+ runner: input.runner,
106
+ });
107
+
108
+ const built = buildAttemptChange({ ...buildBase(input), attempt: null, event: event.claimed });
109
+
110
+ const attempt = await writeClaimChange(tx, built, event.claimed.attempt);
111
+
112
+ return { kind: 'start', attempt, spawn: event.spawn, kept: event.kept };
113
+ }
114
+
115
+ interface ClaimedParts {
116
+ readonly pick: StartPick;
117
+ readonly detail: ItemDetailRows;
118
+ readonly rules: LoadedRules;
119
+ readonly settings: SpawnSettings;
120
+ readonly attempt: AttemptId;
121
+ readonly leaseGen: number;
122
+ readonly draft: SharedDraft;
123
+ readonly runner: ClaimInput['runner'];
124
+ }
125
+
126
+ // The new attempt's round, input, mode, recipe and spawn request. Pure.
127
+ function buildClaimedEvent(parts: ClaimedParts): {
128
+ readonly claimed: ClaimedEvent;
129
+ readonly spawn: SpawnRequest;
130
+ readonly kept: KeptSession | null;
131
+ } {
132
+ const item = parts.detail.item;
133
+ const phase = parts.rules.phases.get(parts.pick.phase);
134
+ const kind = parts.rules.kinds.get(item.kind);
135
+
136
+ invariant(phase !== undefined, `the rules load every phase of a pipeline: ${parts.pick.phase}`);
137
+ invariant(kind !== undefined, `decide picks only an item of a loaded kind: ${item.kind}`);
138
+
139
+ const mode: SessionMode = phase.mode?.(item) ?? 'agent';
140
+
141
+ const recipeName =
142
+ (mode === 'attended' ? kind.recipes[`${parts.pick.phase}/attended`] : undefined) ??
143
+ kind.recipes[parts.pick.phase];
144
+
145
+ const material = recipeName === undefined ? undefined : parts.rules.recipes.get(recipeName);
146
+
147
+ invariant(
148
+ material !== undefined,
149
+ `the loader refuses a step with no recipe: ${parts.pick.phase}`,
150
+ );
151
+
152
+ const phaseInput = phase.input({
153
+ item,
154
+ history: parts.detail.attempts,
155
+ pullRequests: parts.detail.pullRequests,
156
+ });
157
+
158
+ const spawn = buildSpawnRequest({
159
+ item,
160
+ attempt: parts.attempt,
161
+ recipe: material.recipe,
162
+ input: phaseInput,
163
+ settings: parts.settings,
164
+ });
165
+
166
+ const kept = pickKeptSession({
167
+ item,
168
+ attempts: parts.detail.attempts,
169
+ pipeline: kind.pipeline,
170
+ phase: parts.pick.phase,
171
+ });
172
+
173
+ return {
174
+ spawn,
175
+ kept,
176
+ claimed: {
177
+ kind: 'claimed',
178
+ attempt: parts.attempt,
179
+ pick: parts.pick,
180
+ number: 1 + parts.detail.attempts.filter((row) => row.phase === parts.pick.phase).length,
181
+ runner: parts.runner,
182
+ leaseGen: parts.leaseGen,
183
+ recipe: {
184
+ hash: buildRecipeHash(material, parts.rules),
185
+ rulesVersion: parts.rules.version,
186
+ },
187
+ skillsRef: parts.rules.skills?.ref ?? null,
188
+ spawnRequest: toJSONValue(spawn),
189
+ spawnRequestHash: contentHash(toJSONValue(spawn)),
190
+ input: phaseInput,
191
+ mode,
192
+
193
+ // the resumed session makes its first call before the worker answers the resume
194
+ caller: kept?.caller ?? null,
195
+ decision: { ...parts.draft, item: item.id, kind: 'start', pool: parts.pick.pool },
196
+ },
197
+ };
198
+ }
199
+
200
+ interface SpawnParts {
201
+ readonly item: ItemView;
202
+ readonly attempt: AttemptId;
203
+ readonly recipe: Recipe;
204
+ readonly input: JsonValue;
205
+ readonly settings: SpawnSettings;
206
+ }
207
+
208
+ function buildSpawnRequest(parts: SpawnParts): SpawnRequest {
209
+ const cwd = parts.settings.repoPaths.get(parts.item.repo);
210
+ const template = parts.settings.prompts.get(parts.recipe.prompt);
211
+
212
+ invariant(cwd !== undefined, `the configuration names no path for the repo ${parts.item.repo}`);
213
+ invariant(template !== undefined, `the rules checkout has no prompt ${parts.recipe.prompt}`);
214
+
215
+ return {
216
+ target: parts.settings.target,
217
+ cwd,
218
+ prompt: renderPrompt(template, {
219
+ title: parts.item.title,
220
+ outcome: parts.item.outcome,
221
+ repo: parts.item.repo,
222
+ attempt_id: parts.attempt,
223
+ acceptance: parts.item.acceptance
224
+ .map((criterion) => `- ${criterion.id}: ${criterion.text}`)
225
+ .join('\n'),
226
+ input: JSON.stringify(parts.input, null, 2),
227
+ }),
228
+ agent: parts.recipe.agent,
229
+ model: parts.recipe.model,
230
+ effort: parts.recipe.effort,
231
+ name: `boxxy/${parts.attempt}`,
232
+ idempotencyKey: parts.attempt,
233
+ };
234
+ }
235
+
236
+ // Each `{{name}}` with a value is replaced; any other text stays as it is.
237
+ function renderPrompt(template: string, values: Readonly<Record<string, string>>): string {
238
+ return template.replaceAll(
239
+ /\{\{(?<name>[a-z_]+)\}\}/gu,
240
+ (whole, name: string) => values[name] ?? whole,
241
+ );
242
+ }
243
+
244
+ // The recipe hash covers the recipe's fields, its prompt, the rules version and the skills pin.
245
+ function buildRecipeHash(material: RecipeMaterial, rules: LoadedRules) {
246
+ const hash = contentHash(
247
+ toJSONValue({
248
+ recipe: material.recipe,
249
+ promptHash: material.promptHash,
250
+ rulesVersion: material.rulesVersion,
251
+ skills: rules.skills,
252
+ }),
253
+ );
254
+
255
+ return brandSchemas.recipeHash.parse(hash);
256
+ }
257
+
258
+ function buildBase(input: ClaimInput) {
259
+ const actor: ActorRef = `boxxy:runner:${input.runner}`;
260
+
261
+ return { actor, now: input.now, newId: input.newId };
262
+ }
263
+
264
+ async function writeClaimChange(
265
+ tx: StoreTx,
266
+ built: AttemptChange,
267
+ id: AttemptId,
268
+ ): Promise<AttemptRow> {
269
+ invariant(built.kind === 'change', `the attempt machine refused the claim of ${id}`);
270
+
271
+ const result = await tx.applyChange(built.change);
272
+
273
+ if (result.kind !== 'applied') {
274
+ throw new ClaimRaceError(result.kind);
275
+ }
276
+
277
+ const row = await tx.readAttempt(id);
278
+
279
+ invariant(row !== null, 'the claim wrote the attempt');
280
+
281
+ return row;
282
+ }
283
+
284
+ async function writeWakeClaim(
285
+ tx: StoreTx,
286
+ input: ClaimInput,
287
+ pick: WakePick,
288
+ draft: SharedDraft,
289
+ ): Promise<Claim> {
290
+ const attempt = await tx.readAttempt(pick.attempt);
291
+
292
+ invariant(attempt !== null, `decide picked the wake of ${pick.attempt}, which the store holds`);
293
+
294
+ const built = buildAttemptChange({
295
+ ...buildBase(input),
296
+ attempt,
297
+ event: {
298
+ kind: 'woken',
299
+ pick,
300
+ decision: { ...draft, item: attempt.item, kind: 'wake', pool: attempt.pool },
301
+ overCap: false,
302
+ },
303
+ });
304
+
305
+ return { kind: 'wake', attempt: await writeClaimChange(tx, built, attempt.id) };
306
+ }
@@ -0,0 +1,41 @@
1
+ import type { AttemptRow, ItemView, KeptSession, PhaseName, Pipeline } from '@boxxy/model';
2
+
3
+ interface KeptSessionInput {
4
+ readonly item: ItemView;
5
+
6
+ // the item's attempts, oldest first
7
+ readonly attempts: readonly AttemptRow[];
8
+ readonly pipeline: Pipeline;
9
+
10
+ // the phase of the new claim
11
+ readonly phase: PhaseName;
12
+ }
13
+
14
+ // The item's kept session, when the claim came back to `phase` through an edge with
15
+ // `resume: true`; else null. Pure.
16
+ export function pickKeptSession(input: KeptSessionInput): KeptSession | null {
17
+ const kept = input.item.keptSession;
18
+
19
+ if (kept === null) {
20
+ return null;
21
+ }
22
+
23
+ const latest = input.attempts
24
+ .filter((attempt) => attempt.endedAt !== null && isCounted(attempt))
25
+ .toSorted((first, second) => (first.endedAt ?? 0) - (second.endedAt ?? 0))
26
+ .at(-1);
27
+
28
+ const step = input.pipeline.steps.find((candidate) => candidate.phase === latest?.phase);
29
+
30
+ const edge =
31
+ latest?.outcome === null || latest === undefined ? undefined : step?.on[latest.outcome];
32
+
33
+ return typeof edge === 'object' && 'to' in edge && edge.to === input.phase && edge.resume === true
34
+ ? kept
35
+ : null;
36
+ }
37
+
38
+ // the two failures that move no item
39
+ function isCounted(attempt: AttemptRow): boolean {
40
+ return attempt.failure !== 'no-capacity' && attempt.failure !== 'usage-limit';
41
+ }
@@ -0,0 +1,17 @@
1
+ import type { MoveResult } from '../attempts/types.ts';
2
+ import { writeAttemptMove } from '../attempts/write-attempt-move.ts';
3
+ import type { RequestWakeInput } from './types.ts';
4
+
5
+ // The one way to ask for a wake: `boxxy item wake`, a session that runs while its attempt
6
+ // sleeps, and a host that slept with no request all end here. The next claim seats the attempt
7
+ // and resumes it. A second request, or an attempt that does not sleep, is `not-allowed`.
8
+ export function requestWake(input: RequestWakeInput): Promise<MoveResult> {
9
+ return input.store.exclusive((tx) =>
10
+ writeAttemptMove(
11
+ tx,
12
+ input.attempt,
13
+ { kind: 'wake-requested', by: input.by },
14
+ { actor: input.actor, now: input.now, newId: input.newId },
15
+ ),
16
+ );
17
+ }
@@ -0,0 +1,56 @@
1
+ import type { ActorRef, SessionFault } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+ import { writeAttemptMove } from '../attempts/write-attempt-move.ts';
4
+ import { writeSessionBind } from '../attempts/write-session-bind.ts';
5
+ import { timeouts } from '../timeouts.ts';
6
+ import type { ResumeClaimInput } from './types.ts';
7
+
8
+ const secondMs = 1000;
9
+
10
+ // Resumes a woken attempt's session outside the store lock. A refusal for capacity sleeps the
11
+ // row the claim woke again and holds its pool back; any other answer but `started` leaves it
12
+ // `waking` for the reading to retry.
13
+ export async function resumeClaim(input: ResumeClaimInput): Promise<SessionFault | null> {
14
+ const attempt = input.claim.attempt;
15
+ const actor: ActorRef = `boxxy:runner:${attempt.runner}`;
16
+ const base = { actor, now: input.now, newId: input.newId };
17
+
18
+ const outcome = await input.worker.resume(attempt);
19
+
20
+ if (outcome.kind === 'refused' && outcome.cause === 'capacity') {
21
+ input.backoff.hold(
22
+ attempt.pool,
23
+ brandSchemas.instant.parse(input.now + timeouts.capacityBackoffSeconds * secondMs),
24
+ );
25
+
26
+ await input.store.exclusive((tx) =>
27
+ writeAttemptMove(tx, attempt.id, { kind: 'wake-refused' }, { ...base, seen: attempt }),
28
+ );
29
+
30
+ return null;
31
+ }
32
+
33
+ if (outcome.kind !== 'started') {
34
+ return null;
35
+ }
36
+
37
+ return input.store.exclusive(async (tx) => {
38
+ const moved = await writeAttemptMove(
39
+ tx,
40
+ attempt.id,
41
+ { kind: 'resumed', atcSession: outcome.atcSession, caller: outcome.caller },
42
+ base,
43
+ );
44
+
45
+ const row = await tx.readAttempt(attempt.id);
46
+
47
+ return moved.kind === 'applied' && row !== null && outcome.agentSession !== null
48
+ ? writeSessionBind(
49
+ tx,
50
+ row,
51
+ { atcSession: outcome.atcSession, agentSession: outcome.agentSession },
52
+ base,
53
+ )
54
+ : null;
55
+ });
56
+ }
@@ -0,0 +1,99 @@
1
+ import type { ActorRef, SessionFault, StartOutcome } from '@boxxy/model';
2
+ import { brandSchemas } from '@boxxy/model';
3
+ import { match } from 'ts-pattern';
4
+ import { writeAttemptMove } from '../attempts/write-attempt-move.ts';
5
+ import { writeSessionBind } from '../attempts/write-session-bind.ts';
6
+ import { settleAttempt } from '../closeouts/settle-attempt.ts';
7
+ import { buildKeptClear } from '../sessions/build-kept-clear.ts';
8
+ import { resumeKeptSession } from '../sessions/resume-kept-session.ts';
9
+ import { timeouts } from '../timeouts.ts';
10
+ import type { StartClaimInput } from './types.ts';
11
+
12
+ const secondMs = 1000;
13
+
14
+ // Starts the claim's session outside the store lock, and records the worker's answer. A refusal
15
+ // for capacity settles `no-capacity` and holds the pool back. Returns the fault of an agent
16
+ // session that another attempt holds, or null.
17
+ export async function startClaim(input: StartClaimInput): Promise<SessionFault | null> {
18
+ const attempt = input.claim.attempt;
19
+ const actor: ActorRef = `boxxy:runner:${attempt.runner}`;
20
+ const base = { actor, now: input.now, newId: input.newId };
21
+ const kept = input.claim.kept;
22
+
23
+ const woken =
24
+ kept === null
25
+ ? { outcome: await input.worker.start(input.claim.spawn), resumeFallback: false }
26
+ : await resumeKeptSession(input.worker, kept, input.claim.spawn);
27
+
28
+ const outcome = woken.outcome;
29
+ const resumeFallback = woken.resumeFallback;
30
+ const keptClear = kept === null ? [] : [buildKeptClear(attempt.item, kept)];
31
+
32
+ return match<StartOutcome, Promise<SessionFault | null>>(outcome)
33
+ .with({ kind: 'started' }, (started) =>
34
+ input.store.exclusive(async (tx) => {
35
+ const moved = await writeAttemptMove(
36
+ tx,
37
+ attempt.id,
38
+ {
39
+ kind: 'started',
40
+ via: 'start',
41
+ atcSession: started.atcSession,
42
+ caller: started.caller,
43
+ resumeFallback,
44
+ },
45
+ { ...base, also: keptClear },
46
+ );
47
+
48
+ const row = await tx.readAttempt(attempt.id);
49
+
50
+ return moved.kind === 'applied' && row !== null && started.agentSession !== null
51
+ ? writeSessionBind(
52
+ tx,
53
+ row,
54
+ {
55
+ atcSession: started.atcSession,
56
+ agentSession: started.agentSession,
57
+ keptFrom: resumeFallback || kept === null ? null : kept.attempt,
58
+ },
59
+ base,
60
+ )
61
+ : null;
62
+ }),
63
+ )
64
+ .with({ kind: 'unknown' }, async (unknown) => {
65
+ await input.store.exclusive((tx) =>
66
+ writeAttemptMove(
67
+ tx,
68
+ attempt.id,
69
+ { kind: 'spawn-unanswered', reason: unknown.reason },
70
+ base,
71
+ ),
72
+ );
73
+
74
+ return null;
75
+ })
76
+ .with({ kind: 'refused' }, async (refused) => {
77
+ if (refused.cause === 'capacity') {
78
+ input.backoff.hold(
79
+ attempt.pool,
80
+ brandSchemas.instant.parse(input.now + timeouts.capacityBackoffSeconds * secondMs),
81
+ );
82
+ }
83
+
84
+ await settleAttempt({
85
+ ...base,
86
+ store: input.store,
87
+ attempt: attempt.id,
88
+ forced: refused.cause === 'capacity' ? 'no-capacity' : 'spawn-refused',
89
+ sessionStopped: true,
90
+
91
+ // a session recorded since, by another start of the same attempt, keeps it alive
92
+ seen: { atcSession: attempt.atcSession },
93
+ rules: input.rules,
94
+ });
95
+
96
+ return null;
97
+ })
98
+ .exhaustive();
99
+ }
@@ -0,0 +1,67 @@
1
+ import type {
2
+ ActorRef,
3
+ AttemptId,
4
+ CapacityBackoff,
5
+ Claim,
6
+ ClaimStore,
7
+ Instant,
8
+ LoadedRules,
9
+ NewId,
10
+ RepoName,
11
+ RunnerId,
12
+ WakeSource,
13
+ WorkerKind,
14
+ } from '@boxxy/model';
15
+
16
+ // What the server reads from its configuration and the rules checkout for a spawn request. A
17
+ // module reads no files, so the claim gets these as input.
18
+ export interface SpawnSettings {
19
+ // the worker target every session starts on
20
+ readonly target: string;
21
+
22
+ // each repo's path on the target
23
+ readonly repoPaths: ReadonlyMap<RepoName, string>;
24
+
25
+ // the text of each prompt template, by its path in the rules checkout
26
+ readonly prompts: ReadonlyMap<string, string>;
27
+ }
28
+
29
+ export interface ClaimInput {
30
+ readonly store: ClaimStore;
31
+ readonly runner: RunnerId;
32
+ readonly rules: LoadedRules;
33
+ readonly now: Instant;
34
+ readonly newId: NewId;
35
+
36
+ // the runner's memory of the pools in backoff
37
+ readonly backoff: CapacityBackoff;
38
+ readonly spawn: SpawnSettings;
39
+ }
40
+
41
+ export interface StartClaimInput {
42
+ readonly store: ClaimStore;
43
+ readonly worker: WorkerKind;
44
+ readonly rules: LoadedRules;
45
+ readonly claim: Extract<Claim, { kind: 'start' }>;
46
+ readonly now: Instant;
47
+ readonly newId: NewId;
48
+ readonly backoff: CapacityBackoff;
49
+ }
50
+
51
+ export interface ResumeClaimInput {
52
+ readonly store: ClaimStore;
53
+ readonly worker: WorkerKind;
54
+ readonly claim: Extract<Claim, { kind: 'wake' }>;
55
+ readonly now: Instant;
56
+ readonly newId: NewId;
57
+ readonly backoff: CapacityBackoff;
58
+ }
59
+
60
+ export interface RequestWakeInput {
61
+ readonly store: ClaimStore;
62
+ readonly attempt: AttemptId;
63
+ readonly by: WakeSource;
64
+ readonly actor: ActorRef;
65
+ readonly now: Instant;
66
+ readonly newId: NewId;
67
+ }