@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.
- package/README.md +4 -1
- package/dist/attempts/attempt-machine.d.ts +804 -0
- package/dist/attempts/attempt-moves.d.ts +2 -0
- package/dist/attempts/bind-attempt-session.d.ts +14 -0
- package/dist/attempts/build-attempt-change.d.ts +73 -0
- package/dist/attempts/build-attempt-fact.d.ts +3 -0
- package/dist/attempts/end-item-attempts.d.ts +18 -0
- package/dist/attempts/is-same-guard.d.ts +5 -0
- package/dist/attempts/pick-call-attempt.d.ts +10 -0
- package/dist/attempts/stop-stray-session.d.ts +11 -0
- package/dist/attempts/types.d.ts +142 -0
- package/dist/attempts/write-attempt-move.d.ts +7 -0
- package/dist/attempts/write-session-bind.d.ts +3 -0
- package/dist/claims/build-snapshot-hash.d.ts +2 -0
- package/dist/claims/claim-next.d.ts +6 -0
- package/dist/claims/pick-kept-session.d.ts +9 -0
- package/dist/claims/request-wake.d.ts +3 -0
- package/dist/claims/resume-claim.d.ts +3 -0
- package/dist/claims/start-claim.d.ts +3 -0
- package/dist/claims/types.d.ts +44 -0
- package/dist/closeouts/build-settle-plans.d.ts +17 -0
- package/dist/closeouts/close-out-attempt.d.ts +3 -0
- package/dist/closeouts/effect-handlers.d.ts +2 -0
- package/dist/closeouts/pick-settlement.d.ts +14 -0
- package/dist/closeouts/settle-attempt.d.ts +3 -0
- package/dist/closeouts/types.d.ts +50 -0
- package/dist/index.d.ts +38 -0
- package/dist/notices/constants.d.ts +4 -0
- package/dist/notices/create-null-notifier.d.ts +2 -0
- package/dist/notices/send-notices.d.ts +2 -0
- package/dist/notices/types.d.ts +11 -0
- package/dist/outbox/build-backoff-ms.d.ts +2 -0
- package/dist/outbox/build-initial-sender-state.d.ts +2 -0
- package/dist/outbox/build-outbox-status.d.ts +15 -0
- package/dist/outbox/constants.d.ts +2 -0
- package/dist/outbox/measure-wire-bytes.d.ts +2 -0
- package/dist/outbox/pick-sender-move.d.ts +10 -0
- package/dist/outbox/run-sender-once.d.ts +2 -0
- package/dist/outbox/take-batch.d.ts +3 -0
- package/dist/outbox/types.d.ts +61 -0
- package/dist/pull-requests/build-link-change.d.ts +11 -0
- package/dist/pull-requests/build-state-fact.d.ts +8 -0
- package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
- package/dist/pull-requests/collect-seen-state.d.ts +7 -0
- package/dist/pull-requests/constants.d.ts +3 -0
- package/dist/pull-requests/link-pull-request.d.ts +2 -0
- package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
- package/dist/pull-requests/types.d.ts +37 -0
- package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
- package/dist/readings/act-on-reading.d.ts +3 -0
- package/dist/readings/find-waiting-since.d.ts +9 -0
- package/dist/readings/pick-reading-action.d.ts +2 -0
- package/dist/readings/types.d.ts +79 -0
- package/dist/sessions/build-keep-writes.d.ts +9 -0
- package/dist/sessions/build-kept-clear.d.ts +2 -0
- package/dist/sessions/forget-sessions.d.ts +2 -0
- package/dist/sessions/is-kept-due.d.ts +2 -0
- package/dist/sessions/resume-kept-session.d.ts +6 -0
- package/dist/sessions/types.d.ts +19 -0
- package/dist/test-utils/apply-memory-change.d.ts +3 -0
- package/dist/test-utils/build-memory-reader.d.ts +3 -0
- package/dist/test-utils/build-mock-bind-change.d.ts +7 -0
- package/dist/test-utils/build-mock-claim-change.d.ts +2 -0
- package/dist/test-utils/build-mock-file-item-change.d.ts +2 -0
- package/dist/test-utils/build-mock-lever-change.d.ts +2 -0
- package/dist/test-utils/build-mock-new-id.d.ts +2 -0
- package/dist/test-utils/build-mock-note-change.d.ts +2 -0
- package/dist/test-utils/build-mock-pause-change.d.ts +2 -0
- package/dist/test-utils/build-mock-pull-request-change.d.ts +8 -0
- package/dist/test-utils/build-mock-relation-change.d.ts +7 -0
- package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
- package/dist/test-utils/build-mock-settle-change.d.ts +7 -0
- package/dist/test-utils/build-mock-start-change.d.ts +2 -0
- package/dist/test-utils/claim-store-attempt-reader-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-claim-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-contract.d.ts +2 -0
- package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-exclusive-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-guard-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-item-reader-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-rollback-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
- package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
- package/dist/test-utils/compare-text.d.ts +1 -0
- package/dist/test-utils/create-fake-backoff.d.ts +2 -0
- package/dist/test-utils/create-fake-clock.d.ts +5 -0
- package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
- package/dist/test-utils/create-fake-forge.d.ts +17 -0
- package/dist/test-utils/create-fake-worker.d.ts +42 -0
- package/dist/test-utils/create-memory-claim-store.d.ts +2 -0
- package/dist/test-utils/create-test-dispatch.d.ts +46 -0
- package/dist/test-utils/forge-contract.d.ts +15 -0
- package/dist/test-utils/is-same-held-seat.d.ts +2 -0
- package/dist/test-utils/types.d.ts +19 -0
- package/dist/test-utils/worker-contract.d.ts +14 -0
- package/dist/timeouts.d.ts +2 -0
- package/dist/types.d.ts +13 -0
- package/package.json +15 -2
- package/src/attempts/attempt-machine.ts +147 -0
- package/src/attempts/attempt-moves.ts +218 -0
- package/src/attempts/bind-attempt-session.ts +61 -0
- package/src/attempts/build-attempt-change.ts +540 -0
- package/src/attempts/build-attempt-fact.ts +135 -0
- package/src/attempts/end-item-attempts.ts +95 -0
- package/src/attempts/is-same-guard.ts +22 -0
- package/src/attempts/pick-call-attempt.ts +32 -0
- package/src/attempts/stop-stray-session.ts +48 -0
- package/src/attempts/types.ts +231 -0
- package/src/attempts/write-attempt-move.ts +41 -0
- package/src/attempts/write-session-bind.ts +47 -0
- package/src/claims/build-snapshot-hash.ts +9 -0
- package/src/claims/claim-next.ts +306 -0
- package/src/claims/pick-kept-session.ts +41 -0
- package/src/claims/request-wake.ts +17 -0
- package/src/claims/resume-claim.ts +56 -0
- package/src/claims/start-claim.ts +99 -0
- package/src/claims/types.ts +67 -0
- package/src/closeouts/build-settle-plans.ts +173 -0
- package/src/closeouts/close-out-attempt.ts +156 -0
- package/src/closeouts/effect-handlers.ts +232 -0
- package/src/closeouts/pick-settlement.ts +80 -0
- package/src/closeouts/settle-attempt.ts +161 -0
- package/src/closeouts/types.ts +87 -0
- package/src/index.ts +61 -0
- package/src/notices/constants.ts +7 -0
- package/src/notices/create-null-notifier.ts +6 -0
- package/src/notices/send-notices.ts +41 -0
- package/src/notices/types.ts +14 -0
- package/src/outbox/build-backoff-ms.ts +14 -0
- package/src/outbox/build-initial-sender-state.ts +14 -0
- package/src/outbox/build-outbox-status.ts +43 -0
- package/src/outbox/constants.ts +15 -0
- package/src/outbox/measure-wire-bytes.ts +31 -0
- package/src/outbox/pick-sender-move.ts +37 -0
- package/src/outbox/run-sender-once.ts +174 -0
- package/src/outbox/take-batch.ts +35 -0
- package/src/outbox/types.ts +76 -0
- package/src/pull-requests/build-link-change.ts +95 -0
- package/src/pull-requests/build-state-fact.ts +39 -0
- package/src/pull-requests/collect-attempt-trailers.ts +20 -0
- package/src/pull-requests/collect-seen-state.ts +19 -0
- package/src/pull-requests/constants.ts +5 -0
- package/src/pull-requests/link-pull-request.ts +119 -0
- package/src/pull-requests/parse-pull-request-url.ts +30 -0
- package/src/pull-requests/types.ts +63 -0
- package/src/pull-requests/watch-pull-requests.ts +308 -0
- package/src/readings/act-on-reading.ts +318 -0
- package/src/readings/find-waiting-since.ts +46 -0
- package/src/readings/pick-reading-action.ts +315 -0
- package/src/readings/types.ts +98 -0
- package/src/sessions/build-keep-writes.ts +55 -0
- package/src/sessions/build-kept-clear.ts +12 -0
- package/src/sessions/forget-sessions.ts +106 -0
- package/src/sessions/is-kept-due.ts +27 -0
- package/src/sessions/resume-kept-session.ts +25 -0
- package/src/sessions/types.ts +39 -0
- package/src/test-utils/apply-memory-change.ts +252 -0
- package/src/test-utils/build-memory-reader.ts +364 -0
- package/src/test-utils/build-mock-bind-change.ts +43 -0
- package/src/test-utils/build-mock-claim-change.ts +52 -0
- package/src/test-utils/build-mock-file-item-change.ts +20 -0
- package/src/test-utils/build-mock-lever-change.ts +18 -0
- package/src/test-utils/build-mock-new-id.ts +12 -0
- package/src/test-utils/build-mock-note-change.ts +18 -0
- package/src/test-utils/build-mock-pause-change.ts +19 -0
- package/src/test-utils/build-mock-pull-request-change.ts +63 -0
- package/src/test-utils/build-mock-relation-change.ts +40 -0
- package/src/test-utils/build-mock-running-reading.ts +19 -0
- package/src/test-utils/build-mock-settle-change.ts +70 -0
- package/src/test-utils/build-mock-start-change.ts +38 -0
- package/src/test-utils/claim-store-attempt-reader-cases.ts +357 -0
- package/src/test-utils/claim-store-claim-cases.ts +314 -0
- package/src/test-utils/claim-store-close-cases.ts +95 -0
- package/src/test-utils/claim-store-contract.ts +28 -0
- package/src/test-utils/claim-store-copy-cases.ts +100 -0
- package/src/test-utils/claim-store-exclusive-cases.ts +243 -0
- package/src/test-utils/claim-store-guard-cases.ts +383 -0
- package/src/test-utils/claim-store-item-reader-cases.ts +471 -0
- package/src/test-utils/claim-store-outbox-cases.ts +281 -0
- package/src/test-utils/claim-store-race-cases.ts +150 -0
- package/src/test-utils/claim-store-rollback-cases.ts +175 -0
- package/src/test-utils/claim-store-seat-cases.ts +270 -0
- package/src/test-utils/claim-store-session-cases.ts +289 -0
- package/src/test-utils/collect-memory-limit-fills.ts +15 -0
- package/src/test-utils/compare-text.ts +8 -0
- package/src/test-utils/create-fake-backoff.ts +16 -0
- package/src/test-utils/create-fake-clock.ts +20 -0
- package/src/test-utils/create-fake-fact-sink.ts +74 -0
- package/src/test-utils/create-fake-forge.ts +117 -0
- package/src/test-utils/create-fake-worker.ts +303 -0
- package/src/test-utils/create-memory-claim-store.ts +163 -0
- package/src/test-utils/create-test-dispatch.ts +597 -0
- package/src/test-utils/forge-contract.ts +171 -0
- package/src/test-utils/is-same-held-seat.ts +11 -0
- package/src/test-utils/types.ts +23 -0
- package/src/test-utils/worker-contract.ts +192 -0
- package/src/timeouts.ts +17 -0
- package/src/types.ts +32 -0
|
@@ -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
|
+
}
|