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