@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,95 @@
1
+ import type {
2
+ ActorRef,
3
+ AnyNewFact,
4
+ AttemptId,
5
+ AttemptRow,
6
+ Instant,
7
+ NewId,
8
+ RowWrite,
9
+ StoreChange,
10
+ } from '@boxxy/model';
11
+ import invariant from 'tiny-invariant';
12
+ import { buildAttemptChange } from './build-attempt-change.ts';
13
+ import type { MoveEvent } from './types.ts';
14
+
15
+ interface EndItemAttemptsInput {
16
+ // the item's live attempts, read in the same lock as the close
17
+ readonly attempts: readonly AttemptRow[];
18
+ readonly actor: ActorRef;
19
+ readonly now: Instant;
20
+ readonly newId: NewId;
21
+ }
22
+
23
+ export interface EndedAttempts {
24
+ // the lease of the item's live attempt; null when it has none
25
+ readonly under: NonNullable<StoreChange['under']> | null;
26
+ readonly writes: readonly RowWrite[];
27
+ readonly facts: readonly AnyNewFact[];
28
+
29
+ // what `boxxy.item.closed` lists
30
+ readonly ended: readonly { readonly attempt: AttemptId; readonly how: 'settled' | 'closing' }[];
31
+ }
32
+
33
+ // The writes that the close of an item makes to its live attempts. A sleeping attempt settles
34
+ // at once, with no wake; a closing one is left as it is, for the runner to settle; any other
35
+ // moves to `closing` and keeps its seats until the runner stops its session.
36
+ export function endItemAttempts(input: EndItemAttemptsInput): EndedAttempts {
37
+ const moves = input.attempts.flatMap((attempt) => {
38
+ const event = pickEndEvent(attempt);
39
+
40
+ if (event === null) {
41
+ return [];
42
+ }
43
+
44
+ const built = buildAttemptChange({
45
+ attempt,
46
+ event,
47
+ actor: input.actor,
48
+ now: input.now,
49
+ newId: input.newId,
50
+ });
51
+
52
+ invariant(built.kind === 'change', `a ${attempt.state} attempt can end with its item`);
53
+
54
+ return [
55
+ {
56
+ change: built.change,
57
+ ended: {
58
+ attempt: attempt.id,
59
+ how: event.kind === 'settled' ? 'settled' : 'closing',
60
+ } as const,
61
+ },
62
+ ];
63
+ });
64
+
65
+ // an item stands at one phase, so it has one live attempt at most
66
+ invariant(moves.length <= 1, 'an item has one live attempt at most');
67
+
68
+ return {
69
+ under: moves[0]?.change.under ?? null,
70
+ writes: moves.flatMap((move) => move.change.writes),
71
+ facts: moves.flatMap((move) => move.change.facts),
72
+ ended: moves.map((move) => move.ended),
73
+ };
74
+ }
75
+
76
+ function pickEndEvent(attempt: AttemptRow): MoveEvent | null {
77
+ if (attempt.state === 'sleeping') {
78
+ return {
79
+ kind: 'settled',
80
+ state: 'failed',
81
+ failure: 'item-closed',
82
+ outcome: 'lost',
83
+ returnedOutcome: null,
84
+ sessionStopped: true,
85
+ criteria: [],
86
+ matched: [],
87
+ frictionDropped: 0,
88
+ skippedEffects: [],
89
+ };
90
+ }
91
+
92
+ return attempt.state === 'closing' || attempt.state === 'succeeded' || attempt.state === 'failed'
93
+ ? null
94
+ : { kind: 'item-closed' };
95
+ }
@@ -0,0 +1,22 @@
1
+ import type { AttemptRow } from '@boxxy/model';
2
+
3
+ // The columns the machine's guards read.
4
+ const attemptGuardColumns = [
5
+ 'state',
6
+ 'holdGen',
7
+ 'suspendRequestedAt',
8
+ 'wakeRequestedAt',
9
+ 'closing',
10
+ 'atcSession',
11
+ 'strayedAt',
12
+ 'sessionStoppedAt',
13
+ 'sessionForgottenAt',
14
+ ] as const satisfies readonly (keyof AttemptRow)[];
15
+
16
+ export type AttemptGuard = Pick<AttemptRow, (typeof attemptGuardColumns)[number]>;
17
+
18
+ // True when the row agrees with each guard column the caller decided on. A caller that passes a
19
+ // whole row checks every column; a caller that passes some columns checks only those.
20
+ export function isSameGuard(seen: Partial<AttemptGuard>, row: AttemptRow): boolean {
21
+ return attemptGuardColumns.every((column) => !(column in seen) || seen[column] === row[column]);
22
+ }
@@ -0,0 +1,32 @@
1
+ import type { AttemptRow, CallerIdentity } from '@boxxy/model';
2
+ import type { CallRef } from '../closeouts/types.ts';
3
+
4
+ export type CallAttempt =
5
+ | { readonly kind: 'attempt'; readonly attempt: AttemptRow }
6
+ | { readonly kind: 'refused'; readonly reason: 'no-attempt' | 'not-current' };
7
+
8
+ // The one check of every call from inside a session. `latest` is the latest attempt of the
9
+ // calling session, read inside the caller's lock. The call reaches the attempt it names only
10
+ // when that one is `latest` and holds the proven caller. Pure.
11
+ export function pickCallAttempt(latest: AttemptRow | null, call: CallRef): CallAttempt {
12
+ if (latest === null) {
13
+ return { kind: 'refused', reason: 'no-attempt' };
14
+ }
15
+
16
+ if (latest.id !== call.attempt) {
17
+ return { kind: 'refused', reason: 'not-current' };
18
+ }
19
+
20
+ // a row with no caller, or another caller, is refused; an older row of the session never counts
21
+ if (!isSameCaller(latest.caller, call.caller)) {
22
+ return { kind: 'refused', reason: 'no-attempt' };
23
+ }
24
+
25
+ return { kind: 'attempt', attempt: latest };
26
+ }
27
+
28
+ function isSameCaller(recorded: CallerIdentity | null, proven: CallerIdentity): boolean {
29
+ return (
30
+ recorded !== null && recorded.issuer === proven.issuer && recorded.subject === proven.subject
31
+ );
32
+ }
@@ -0,0 +1,48 @@
1
+ import type {
2
+ ActorRef,
3
+ AttemptRow,
4
+ ClaimStore,
5
+ Instant,
6
+ NewId,
7
+ StopOutcome,
8
+ WorkerKind,
9
+ } from '@boxxy/model';
10
+ import type { MoveResult } from './types.ts';
11
+ import { writeAttemptMove } from './write-attempt-move.ts';
12
+
13
+ export interface StopStraySessionInput {
14
+ readonly store: ClaimStore;
15
+ readonly worker: WorkerKind;
16
+
17
+ // an attempt that the give-up rule settled while its session still ran
18
+ readonly attempt: AttemptRow;
19
+ readonly actor: ActorRef;
20
+ readonly now: Instant;
21
+ readonly newId: NewId;
22
+ }
23
+
24
+ // Sends the stop again, and records `stray-stopped` once the session is gone. The runner calls
25
+ // it on every tick for each stray, so a stop that fails is tried again on the next tick. null:
26
+ // the session still runs, or it never appeared.
27
+ export async function stopStraySession(input: StopStraySessionInput): Promise<MoveResult | null> {
28
+ const outcome = await input.worker.stop(input.attempt);
29
+
30
+ if (!isSessionGone(input.attempt, outcome)) {
31
+ return null;
32
+ }
33
+
34
+ return input.store.exclusive((tx) =>
35
+ writeAttemptMove(
36
+ tx,
37
+ input.attempt.id,
38
+ { kind: 'stray-stopped' },
39
+ { actor: input.actor, now: input.now, newId: input.newId },
40
+ ),
41
+ );
42
+ }
43
+
44
+ // An attempt with no session is looked for by its spawn name, so `absent` there means the session
45
+ // has not appeared yet, and the loop keeps sending the stop.
46
+ function isSessionGone(attempt: AttemptRow, outcome: StopOutcome): boolean {
47
+ return outcome.kind === 'stopped' || (outcome.kind === 'absent' && attempt.atcSession !== null);
48
+ }
@@ -0,0 +1,231 @@
1
+ import type {
2
+ ActorRef,
3
+ AgentSessionId,
4
+ AtcSessionId,
5
+ AttemptId,
6
+ AttemptRow,
7
+ AttemptSessionRow,
8
+ AttemptState,
9
+ CallerIdentity,
10
+ CloseOut,
11
+ ClosingCause,
12
+ CommitSha,
13
+ DecisionRow,
14
+ FactId,
15
+ FactType,
16
+ Instant,
17
+ Minutes,
18
+ NewId,
19
+ ResolvedRecipe,
20
+ RunnerId,
21
+ SessionMode,
22
+ StartPick,
23
+ WakePick,
24
+ WakeSource,
25
+ WorkFacts,
26
+ } from '@boxxy/model';
27
+
28
+ // What the guards of the attempt machine read. Each field comes from one column of the attempt
29
+ // row.
30
+ export interface AttemptContext {
31
+ // `suspendRequestedAt` is set
32
+ readonly mark: boolean;
33
+
34
+ // `wakeRequestedAt` is set
35
+ readonly wakeRequested: boolean;
36
+ readonly mode: SessionMode;
37
+ readonly holdGen: number;
38
+ readonly closing: ClosingCause | null;
39
+
40
+ // `atcSession` is set
41
+ readonly hasSession: boolean;
42
+
43
+ // `strayedAt` is set
44
+ readonly strayed: boolean;
45
+
46
+ // `sessionStoppedAt` is set
47
+ readonly sessionStopped: boolean;
48
+
49
+ // `sessionForgottenAt` is set
50
+ readonly sessionForgotten: boolean;
51
+ }
52
+
53
+ // The machine's events. XState names the kind `type`; `claimed` is the initial transition and
54
+ // has no event.
55
+ export type AttemptMachineEvent =
56
+ | { readonly type: 'started'; readonly via: 'start' | 'reconcile' }
57
+ | { readonly type: 'woken'; readonly overCap: boolean }
58
+ | {
59
+ readonly type: 'settled';
60
+ readonly state: 'succeeded' | 'failed';
61
+ readonly sessionStopped: boolean;
62
+ }
63
+ | {
64
+ readonly type:
65
+ | 'spawn-unanswered'
66
+ | 'session-seen'
67
+ | 'closed-out'
68
+ | 'timed-out'
69
+ | 'item-closed'
70
+ | 'turn-ended'
71
+ | 'stalled'
72
+ | 'skill-missing'
73
+ | 'usage-limited'
74
+ | 'suspend-asked'
75
+ | 'suspend-refused'
76
+ | 'slept'
77
+ | 'wake-requested'
78
+ | 'resumed'
79
+ | 'wake-refused'
80
+ | 'stray-stopped'
81
+ | 'session-forgotten';
82
+ };
83
+
84
+ // A write that a move makes besides the attempt row, or a fact it records.
85
+ export type MoveEffect =
86
+ | 'take-lease'
87
+ | 'release-lease'
88
+ | 'take-seats'
89
+ | 'release-seats'
90
+ | 'record-decision'
91
+ | 'bind-session'
92
+ | FactType;
93
+
94
+ // One row of the table of moves.
95
+ export interface AttemptMove {
96
+ readonly event: AttemptMachineEvent['type'] | 'claimed';
97
+
98
+ // empty for `claimed`, which starts from no attempt
99
+ readonly from: readonly AttemptState[];
100
+
101
+ // the move is allowed only when this holds; no `when` allows it from every `from` state
102
+ readonly when?: (context: AttemptContext, event: AttemptMachineEvent) => boolean;
103
+ readonly to: AttemptState | 'same';
104
+ readonly effects: readonly MoveEffect[];
105
+ readonly context?: (
106
+ context: AttemptContext,
107
+ event: AttemptMachineEvent,
108
+ ) => Partial<AttemptContext>;
109
+ }
110
+
111
+ // What the claim, a wake and the decision fact record; the builder adds the ID, the attempt and
112
+ // the time.
113
+ export type DecisionDraft = Omit<DecisionRow, 'id' | 'attempt' | 'at'>;
114
+
115
+ export interface ClaimedEvent {
116
+ readonly kind: 'claimed';
117
+
118
+ // made before the change, because the spawn request names it
119
+ readonly attempt: AttemptId;
120
+ readonly pick: StartPick;
121
+ readonly number: number;
122
+ readonly runner: RunnerId;
123
+
124
+ // one above the item phase's last lease generation
125
+ readonly leaseGen: number;
126
+ readonly recipe: Pick<ResolvedRecipe, 'hash' | 'rulesVersion'>;
127
+ readonly skillsRef: CommitSha | null;
128
+ readonly spawnRequest: AttemptRow['spawnRequest'];
129
+ readonly spawnRequestHash: AttemptRow['spawnRequestHash'];
130
+ readonly input: AttemptRow['input'];
131
+ readonly mode: SessionMode;
132
+
133
+ // a claim through a resume edge copies the kept session's caller
134
+ readonly caller: CallerIdentity | null;
135
+ readonly decision: DecisionDraft;
136
+ }
137
+
138
+ type SettledFact = WorkFacts['boxxy.attempt.settled'];
139
+
140
+ export type MoveEvent =
141
+ | {
142
+ readonly kind: 'started';
143
+ readonly via: 'start' | 'reconcile';
144
+ readonly atcSession: AtcSessionId;
145
+ readonly caller: CallerIdentity | null;
146
+ readonly resumeFallback: boolean;
147
+ }
148
+ | { readonly kind: 'spawn-unanswered'; readonly reason: string }
149
+ | { readonly kind: 'session-seen'; readonly row: AttemptSessionRow }
150
+ | {
151
+ readonly kind: 'closed-out';
152
+ readonly closeOut: CloseOut;
153
+ readonly hash: NonNullable<AttemptRow['closeOutHash']>;
154
+ }
155
+ | { readonly kind: 'timed-out'; readonly afterMinutes: Minutes }
156
+ | { readonly kind: 'item-closed' }
157
+ | { readonly kind: 'turn-ended' }
158
+ | { readonly kind: 'stalled' }
159
+ | { readonly kind: 'skill-missing' }
160
+ | { readonly kind: 'usage-limited' }
161
+ | { readonly kind: 'suspend-asked' }
162
+ | { readonly kind: 'suspend-refused' }
163
+ | { readonly kind: 'slept'; readonly idleSince: Instant; readonly asked: boolean }
164
+ | { readonly kind: 'wake-requested'; readonly by: WakeSource }
165
+ | {
166
+ readonly kind: 'woken';
167
+ readonly pick: WakePick;
168
+ readonly decision: DecisionDraft;
169
+ readonly overCap: boolean;
170
+ }
171
+ | {
172
+ readonly kind: 'resumed';
173
+ readonly atcSession: AtcSessionId;
174
+ readonly caller: CallerIdentity | null;
175
+ }
176
+ | { readonly kind: 'wake-refused' }
177
+ | (Pick<
178
+ SettledFact,
179
+ | 'state'
180
+ | 'failure'
181
+ | 'returnedOutcome'
182
+ | 'sessionStopped'
183
+ | 'criteria'
184
+ | 'matched'
185
+ | 'frictionDropped'
186
+ | 'skippedEffects'
187
+ > & {
188
+ readonly kind: 'settled';
189
+ readonly outcome: NonNullable<AttemptRow['outcome']>;
190
+ })
191
+ | { readonly kind: 'stray-stopped' }
192
+ | { readonly kind: 'session-forgotten' };
193
+
194
+ export type AttemptEvent = ClaimedEvent | MoveEvent;
195
+
196
+ // The rows one move reads and writes: the attempt as read, the attempt as the move leaves it,
197
+ // and the decision row of a claim or a wake.
198
+ export interface MoveParts {
199
+ readonly before: AttemptRow;
200
+ readonly after: AttemptRow;
201
+ readonly event: AttemptEvent;
202
+ readonly decision: DecisionRow | null;
203
+ }
204
+
205
+ // What every move of a caller outside the builder passes on.
206
+ export interface MoveBase {
207
+ readonly actor: ActorRef;
208
+ readonly now: Instant;
209
+ readonly newId: NewId;
210
+ }
211
+
212
+ export type MoveResult =
213
+ | { readonly kind: 'applied' }
214
+ | { readonly kind: 'not-allowed' }
215
+
216
+ // the store refused the change: a race the next tick reads again
217
+ | { readonly kind: 'refused'; readonly reason: string };
218
+
219
+ export interface SeenSession {
220
+ readonly atcSession: AtcSessionId;
221
+ readonly agentSession: AgentSessionId;
222
+
223
+ // the attempt whose kept session this one resumed; it holds the same agent session
224
+ readonly keptFrom?: AttemptId | null;
225
+ }
226
+
227
+ export interface FactEnvelopeParts {
228
+ readonly id: FactId;
229
+ readonly actor: ActorRef;
230
+ readonly now: Instant;
231
+ }
@@ -0,0 +1,41 @@
1
+ import type { AttemptId, RowWrite, StoreTx } from '@boxxy/model';
2
+ import { buildAttemptChange } from './build-attempt-change.ts';
3
+ import type { AttemptGuard } from './is-same-guard.ts';
4
+ import { isSameGuard } from './is-same-guard.ts';
5
+ import type { MoveBase, MoveEvent, MoveResult } from './types.ts';
6
+
7
+ // Applies one move of the attempt as the store holds it inside `tx`, built from the row it
8
+ // changes. `also` rides in the same change. A row that moved on from `seen`, the row a caller
9
+ // decided on before the lock, refuses the move.
10
+ export async function writeAttemptMove(
11
+ tx: StoreTx,
12
+ attempt: AttemptId,
13
+ event: MoveEvent,
14
+ base: MoveBase & {
15
+ readonly also?: readonly RowWrite[];
16
+ readonly seen?: Partial<AttemptGuard>;
17
+ },
18
+ ): Promise<MoveResult> {
19
+ const row = await tx.readAttempt(attempt);
20
+
21
+ if (row === null) {
22
+ return { kind: 'not-allowed' };
23
+ }
24
+
25
+ if (base.seen !== undefined && !isSameGuard(base.seen, row)) {
26
+ return { kind: 'refused', reason: 'guard-mismatch' };
27
+ }
28
+
29
+ const built = buildAttemptChange({ ...base, attempt: row, event });
30
+
31
+ if (built.kind === 'not-allowed') {
32
+ return { kind: 'not-allowed' };
33
+ }
34
+
35
+ const result = await tx.applyChange({
36
+ ...built.change,
37
+ writes: [...built.change.writes, ...(base.also ?? [])],
38
+ });
39
+
40
+ return result.kind === 'applied' ? result : { kind: 'refused', reason: result.kind };
41
+ }
@@ -0,0 +1,47 @@
1
+ import type { AttemptRow, SessionFault, StoreTx } from '@boxxy/model';
2
+ import type { MoveBase, SeenSession } from './types.ts';
3
+ import { writeAttemptMove } from './write-attempt-move.ts';
4
+
5
+ // Binds the agent session to the attempt, once. An agent session that another attempt holds
6
+ // writes nothing and gives the fault, which the server logs.
7
+ export async function writeSessionBind(
8
+ tx: StoreTx,
9
+ attempt: AttemptRow,
10
+ session: SeenSession,
11
+ base: MoveBase,
12
+ ): Promise<SessionFault | null> {
13
+ const holder = await tx.findAttemptByAgentSession(session.agentSession);
14
+
15
+ if (holder === attempt.id) {
16
+ return null;
17
+ }
18
+
19
+ if (holder !== null && holder !== session.keptFrom) {
20
+ return {
21
+ kind: 'fault',
22
+ fault: 'agent-session-owned',
23
+ agentSession: session.agentSession,
24
+ attempt: attempt.id,
25
+ holder,
26
+ };
27
+ }
28
+
29
+ await writeAttemptMove(
30
+ tx,
31
+ attempt.id,
32
+ {
33
+ kind: 'session-seen',
34
+ row: {
35
+ attempt: attempt.id,
36
+ agentSession: session.agentSession,
37
+ atcSession: session.atcSession,
38
+ product: 'claude_code',
39
+ source: 'worker',
40
+ seenAt: base.now,
41
+ },
42
+ },
43
+ base,
44
+ );
45
+
46
+ return null;
47
+ }
@@ -0,0 +1,9 @@
1
+ import { contentHash } from '@boxxy/content-hash';
2
+ import type { PoolName, Snapshot, SnapshotHash } from '@boxxy/model';
3
+ import { brandSchemas, toJSONValue } from '@boxxy/model';
4
+
5
+ // The hash covers the pools in backoff too: they decide the picks as much as the rows do, and
6
+ // the runner keeps them in memory only.
7
+ export function buildSnapshotHash(snapshot: Snapshot, backoff: readonly PoolName[]): SnapshotHash {
8
+ return brandSchemas.snapshotHash.parse(contentHash(toJSONValue({ snapshot, backoff })));
9
+ }