@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.
- 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-claim-change.d.ts +2 -2
- 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-pull-request-change.d.ts +1 -0
- package/dist/test-utils/{build-mock-link-change.d.ts → build-mock-relation-change.d.ts} +2 -2
- package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
- package/dist/test-utils/build-mock-start-change.d.ts +2 -2
- package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-copy-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-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 -2
- 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 +12 -2
- 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 +10 -3
- 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 +18 -11
- package/src/test-utils/build-mock-claim-change.ts +18 -8
- package/src/test-utils/build-mock-file-item-change.ts +3 -1
- 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 +2 -10
- package/src/test-utils/build-mock-pull-request-change.ts +19 -9
- package/src/test-utils/{build-mock-link-change.ts → build-mock-relation-change.ts} +6 -6
- package/src/test-utils/build-mock-running-reading.ts +19 -0
- package/src/test-utils/build-mock-settle-change.ts +18 -6
- package/src/test-utils/build-mock-start-change.ts +13 -6
- package/src/test-utils/claim-store-attempt-reader-cases.ts +163 -133
- package/src/test-utils/claim-store-claim-cases.ts +168 -85
- package/src/test-utils/claim-store-close-cases.ts +95 -0
- package/src/test-utils/claim-store-contract.ts +12 -2
- package/src/test-utils/claim-store-copy-cases.ts +100 -0
- package/src/test-utils/claim-store-exclusive-cases.ts +65 -19
- package/src/test-utils/claim-store-guard-cases.ts +92 -82
- package/src/test-utils/claim-store-item-reader-cases.ts +291 -77
- 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 +50 -50
- 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 +73 -484
- 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 +14 -2
- package/src/test-utils/worker-contract.ts +192 -0
- package/src/timeouts.ts +17 -0
- package/src/types.ts +32 -0
- package/dist/test-utils/claim-store-pool-cases.d.ts +0 -2
- package/src/test-utils/claim-store-pool-cases.ts +0 -178
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActorRef,
|
|
3
|
+
AnyNewFact,
|
|
4
|
+
AttemptId,
|
|
5
|
+
AttemptRow,
|
|
6
|
+
PullRequestReading,
|
|
7
|
+
PullRequestRef,
|
|
8
|
+
PullRequestRow,
|
|
9
|
+
RepoName,
|
|
10
|
+
RowWrite,
|
|
11
|
+
StoreTx,
|
|
12
|
+
} from '@boxxy/model';
|
|
13
|
+
import { brandSchemas, buildPhase } from '@boxxy/model';
|
|
14
|
+
import { buildLinkChange } from './build-link-change.ts';
|
|
15
|
+
import { buildStateFact } from './build-state-fact.ts';
|
|
16
|
+
import { collectAttemptTrailers } from './collect-attempt-trailers.ts';
|
|
17
|
+
import { collectSeenState } from './collect-seen-state.ts';
|
|
18
|
+
import { watchLimits } from './constants.ts';
|
|
19
|
+
import type { WatchPullRequestsInput, WatchReport } from './types.ts';
|
|
20
|
+
|
|
21
|
+
interface Tally {
|
|
22
|
+
linked: number;
|
|
23
|
+
commitsLinked: number;
|
|
24
|
+
merged: number;
|
|
25
|
+
closed: number;
|
|
26
|
+
unanswered: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// One pass of the watch. It links the pull request of each succeeded build that the hook missed,
|
|
30
|
+
// then reads each open pull request to link its commits and record its merge or close. A forge
|
|
31
|
+
// call that throws counts in `unanswered` and writes nothing; the next pass tries again.
|
|
32
|
+
export async function watchPullRequests(input: WatchPullRequestsInput): Promise<WatchReport> {
|
|
33
|
+
const tally: Tally = { linked: 0, commitsLinked: 0, merged: 0, closed: 0, unanswered: 0 };
|
|
34
|
+
|
|
35
|
+
await linkMissed(input, tally);
|
|
36
|
+
|
|
37
|
+
const watched = await input.store.read((reader) => reader.listWatchedPullRequests());
|
|
38
|
+
|
|
39
|
+
for (const row of watched) {
|
|
40
|
+
await watchOne(input, row, tally);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return tally;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function linkMissed(input: WatchPullRequestsInput, tally: Tally): Promise<void> {
|
|
47
|
+
const byRepo = await input.store.read(async (reader) => {
|
|
48
|
+
const succeeded = await reader.listSucceededAttemptsWithoutPullRequest();
|
|
49
|
+
const watched = await reader.listWatchedPullRequests();
|
|
50
|
+
|
|
51
|
+
const busyItems = new Set<string>();
|
|
52
|
+
|
|
53
|
+
for (const row of watched) {
|
|
54
|
+
const owner = await reader.readAttempt(row.attempt);
|
|
55
|
+
|
|
56
|
+
if (owner !== null) {
|
|
57
|
+
busyItems.add(owner.item);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const candidates = succeeded.filter(
|
|
62
|
+
(attempt) =>
|
|
63
|
+
attempt.phase === buildPhase &&
|
|
64
|
+
!busyItems.has(attempt.item) &&
|
|
65
|
+
input.now - (attempt.endedAt ?? attempt.claimedAt) <= watchLimits.linkWindowMs,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const grouped = new Map<RepoName, AttemptRow[]>();
|
|
69
|
+
|
|
70
|
+
for (const attempt of candidates) {
|
|
71
|
+
const item = await reader.readItem(attempt.item, input.now, input.rules);
|
|
72
|
+
|
|
73
|
+
if (item !== null) {
|
|
74
|
+
grouped.set(item.repo, [...(grouped.get(item.repo) ?? []), attempt]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return grouped;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const [repo, attempts] of byRepo) {
|
|
82
|
+
await linkRepo(input, tally, repo, attempts);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function linkRepo(
|
|
87
|
+
input: WatchPullRequestsInput,
|
|
88
|
+
tally: Tally,
|
|
89
|
+
repo: RepoName,
|
|
90
|
+
attempts: readonly AttemptRow[],
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const open = await askForge(tally, () => input.forge.listOpenPullRequests(repo));
|
|
93
|
+
|
|
94
|
+
if (open === null) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const readings = new Map<number, PullRequestReading | null>();
|
|
99
|
+
const taken = new Set<number>();
|
|
100
|
+
|
|
101
|
+
for (const attempt of attempts) {
|
|
102
|
+
for (const ref of open.filter((candidate) => !taken.has(candidate.number))) {
|
|
103
|
+
if (!readings.has(ref.number)) {
|
|
104
|
+
const fresh = await readOpen(input, tally, ref);
|
|
105
|
+
|
|
106
|
+
readings.set(ref.number, fresh);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const reading = readings.get(ref.number) ?? null;
|
|
110
|
+
|
|
111
|
+
if (reading === null || !isMadeBy(reading, attempt.id)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
taken.add(ref.number);
|
|
116
|
+
|
|
117
|
+
const isLinked = await writeLink(input, attempt, ref, reading);
|
|
118
|
+
|
|
119
|
+
tally.linked += isLinked ? 1 : 0;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function askForge<Answer>(tally: Tally, call: () => Promise<Answer>): Promise<Answer | null> {
|
|
126
|
+
try {
|
|
127
|
+
return await call();
|
|
128
|
+
} catch {
|
|
129
|
+
tally.unanswered += 1;
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readOpen(
|
|
136
|
+
input: WatchPullRequestsInput,
|
|
137
|
+
tally: Tally,
|
|
138
|
+
ref: PullRequestRef,
|
|
139
|
+
): Promise<PullRequestReading | null> {
|
|
140
|
+
const reading = await askForge(tally, () => input.forge.readPullRequest(ref));
|
|
141
|
+
|
|
142
|
+
return reading === null || reading === 'not-found' ? null : reading;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isMadeBy(reading: PullRequestReading, attempt: AttemptId): boolean {
|
|
146
|
+
return reading.commits.some((commit) => collectAttemptTrailers(commit.message).includes(attempt));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeLink(
|
|
150
|
+
input: WatchPullRequestsInput,
|
|
151
|
+
attempt: AttemptRow,
|
|
152
|
+
ref: PullRequestRef,
|
|
153
|
+
reading: PullRequestReading,
|
|
154
|
+
): Promise<boolean> {
|
|
155
|
+
return input.store.exclusive(async (tx) => {
|
|
156
|
+
const existing = await tx.readPullRequestRow(ref);
|
|
157
|
+
|
|
158
|
+
if (existing !== null) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const result = await tx.applyChange(
|
|
163
|
+
buildLinkChange({
|
|
164
|
+
attempt,
|
|
165
|
+
ref,
|
|
166
|
+
reading,
|
|
167
|
+
source: 'watch',
|
|
168
|
+
actor: `boxxy:runner:${input.runner}`,
|
|
169
|
+
now: input.now,
|
|
170
|
+
newId: input.newId,
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return result.kind === 'applied';
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function watchOne(
|
|
179
|
+
input: WatchPullRequestsInput,
|
|
180
|
+
row: PullRequestRow,
|
|
181
|
+
tally: Tally,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const ref = { repo: row.repo, number: row.number };
|
|
184
|
+
|
|
185
|
+
const reading = await askForge(tally, () => input.forge.readPullRequest(ref));
|
|
186
|
+
|
|
187
|
+
if (reading === null) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await input.store.exclusive(async (tx) => {
|
|
192
|
+
if (reading !== 'not-found') {
|
|
193
|
+
await recordReading({ input, tx, row, reading, tally });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await tx.markPullRequestChecked(ref, input.now);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface RecordInput {
|
|
201
|
+
readonly input: WatchPullRequestsInput;
|
|
202
|
+
readonly tx: StoreTx;
|
|
203
|
+
readonly row: PullRequestRow;
|
|
204
|
+
readonly reading: PullRequestReading;
|
|
205
|
+
readonly tally: Tally;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function recordReading(record: RecordInput): Promise<void> {
|
|
209
|
+
const input = record.input;
|
|
210
|
+
const tx = record.tx;
|
|
211
|
+
const row = record.row;
|
|
212
|
+
|
|
213
|
+
const owner = await tx.readAttempt(row.attempt);
|
|
214
|
+
|
|
215
|
+
if (owner === null) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const actor: ActorRef = `boxxy:runner:${input.runner}`;
|
|
220
|
+
const entity = `boxxy:item:${owner.item}` as const;
|
|
221
|
+
const writes: RowWrite[] = [];
|
|
222
|
+
const facts: AnyNewFact[] = [];
|
|
223
|
+
|
|
224
|
+
const handled = new Set<string>();
|
|
225
|
+
|
|
226
|
+
for (const commit of record.reading.commits) {
|
|
227
|
+
for (const id of collectAttemptTrailers(commit.message)) {
|
|
228
|
+
const key = `${id} ${commit.sha}`;
|
|
229
|
+
|
|
230
|
+
if (handled.has(key)) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
handled.add(key);
|
|
235
|
+
|
|
236
|
+
const trailerAttempt = await tx.readAttempt(id);
|
|
237
|
+
const evidence = await tx.listEvidence(id);
|
|
238
|
+
|
|
239
|
+
const isNew =
|
|
240
|
+
trailerAttempt?.item === owner.item &&
|
|
241
|
+
!evidence.some((entry) => entry.kind === 'commit' && entry.ref === commit.sha);
|
|
242
|
+
|
|
243
|
+
if (!isNew) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const factId = brandSchemas.factId.parse(input.newId());
|
|
248
|
+
|
|
249
|
+
writes.push({
|
|
250
|
+
op: 'insert',
|
|
251
|
+
table: 'evidence',
|
|
252
|
+
row: {
|
|
253
|
+
factId,
|
|
254
|
+
attempt: id,
|
|
255
|
+
kind: 'commit',
|
|
256
|
+
ref: commit.sha,
|
|
257
|
+
observed: null,
|
|
258
|
+
source: 'trailer',
|
|
259
|
+
at: input.now,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
facts.push({
|
|
264
|
+
id: factId,
|
|
265
|
+
type: 'boxxy.commit.linked',
|
|
266
|
+
entity,
|
|
267
|
+
actor,
|
|
268
|
+
payload: { attempt: id, repo: row.repo, sha: commit.sha, pullRequestNumber: row.number },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const commitCount = facts.length;
|
|
274
|
+
const seenState = collectSeenState(record.reading);
|
|
275
|
+
|
|
276
|
+
if (seenState.state !== 'open') {
|
|
277
|
+
const stateFact = buildStateFact({
|
|
278
|
+
row: { ...row, ...seenState },
|
|
279
|
+
item: owner.item,
|
|
280
|
+
actor,
|
|
281
|
+
id: brandSchemas.factId.parse(input.newId()),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
writes.push({
|
|
285
|
+
op: 'update',
|
|
286
|
+
table: 'pull_requests',
|
|
287
|
+
key: { repo: row.repo, number: row.number },
|
|
288
|
+
expect: { state: 'open' },
|
|
289
|
+
set: { ...seenState, checkedAt: input.now },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (stateFact !== null) {
|
|
293
|
+
facts.push(stateFact);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (writes.length === 0) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const result = await tx.applyChange({ at: input.now, writes, facts });
|
|
302
|
+
|
|
303
|
+
if (result.kind === 'applied') {
|
|
304
|
+
record.tally.commitsLinked += commitCount;
|
|
305
|
+
record.tally.merged += seenState.state === 'merged' ? 1 : 0;
|
|
306
|
+
record.tally.closed += seenState.state === 'closed' ? 1 : 0;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActorRef,
|
|
3
|
+
AttemptRow,
|
|
4
|
+
Instant,
|
|
5
|
+
SessionFault,
|
|
6
|
+
SessionReading,
|
|
7
|
+
StoreTx,
|
|
8
|
+
} from '@boxxy/model';
|
|
9
|
+
import { brandSchemas, isFilterMatch, spawnRequestSchema } from '@boxxy/model';
|
|
10
|
+
import invariant from 'tiny-invariant';
|
|
11
|
+
import { match } from 'ts-pattern';
|
|
12
|
+
import { isSameGuard } from '../attempts/is-same-guard.ts';
|
|
13
|
+
import type { MoveBase, MoveEvent } from '../attempts/types.ts';
|
|
14
|
+
import { writeAttemptMove } from '../attempts/write-attempt-move.ts';
|
|
15
|
+
import { writeSessionBind } from '../attempts/write-session-bind.ts';
|
|
16
|
+
import { buildSnapshotHash } from '../claims/build-snapshot-hash.ts';
|
|
17
|
+
import { resumeClaim } from '../claims/resume-claim.ts';
|
|
18
|
+
import { startClaim } from '../claims/start-claim.ts';
|
|
19
|
+
import { settleAttempt } from '../closeouts/settle-attempt.ts';
|
|
20
|
+
import { timeouts } from '../timeouts.ts';
|
|
21
|
+
import type { ActOnReadingInput, ReadingAction } from './types.ts';
|
|
22
|
+
|
|
23
|
+
// Carries out the reconcile table's action for one reading, after it binds a new agent session
|
|
24
|
+
// that the reading shows. An action whose row moved on since the runner read it does nothing.
|
|
25
|
+
// Returns the fault of an agent session that another attempt holds, or null.
|
|
26
|
+
export async function actOnReading(input: ActOnReadingInput): Promise<SessionFault | null> {
|
|
27
|
+
const base: MoveBase = {
|
|
28
|
+
actor: `boxxy:runner:${input.attempt.runner}` satisfies ActorRef,
|
|
29
|
+
now: input.now,
|
|
30
|
+
newId: input.newId,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const fault = await writeReadingBind(input, base);
|
|
34
|
+
|
|
35
|
+
await runAction(input, input.action, base);
|
|
36
|
+
|
|
37
|
+
return fault;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeReadingBind(input: ActOnReadingInput, base: MoveBase): Promise<SessionFault | null> {
|
|
41
|
+
const reading = input.reading;
|
|
42
|
+
|
|
43
|
+
if (reading.kind === 'absent' || reading.agentSession === null) {
|
|
44
|
+
return Promise.resolve(null);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const agentSession = reading.agentSession;
|
|
48
|
+
|
|
49
|
+
return input.store.exclusive(async (tx) => {
|
|
50
|
+
const row = await tx.readAttempt(input.attempt.id);
|
|
51
|
+
|
|
52
|
+
invariant(row !== null, `the runner read a stored attempt: ${input.attempt.id}`);
|
|
53
|
+
|
|
54
|
+
return writeSessionBind(tx, row, { atcSession: reading.atcSession, agentSession }, base);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hourMs = 3_600_000;
|
|
59
|
+
|
|
60
|
+
function runAction(input: ActOnReadingInput, action: ReadingAction, base: MoveBase) {
|
|
61
|
+
const id = input.attempt.id;
|
|
62
|
+
const seen = { ...base, seen: input.attempt };
|
|
63
|
+
|
|
64
|
+
const writeMove = (event: MoveEvent) =>
|
|
65
|
+
input.store.exclusive((tx) => writeAttemptMove(tx, id, event, seen));
|
|
66
|
+
|
|
67
|
+
return match(action)
|
|
68
|
+
.with({ kind: 'nothing' }, { kind: 'bind-only' }, { kind: 'await-spawn' }, async () => {})
|
|
69
|
+
.with({ kind: 'move-to-running', via: 'reconcile' }, async () => {
|
|
70
|
+
const running = getRunningReading(input.reading);
|
|
71
|
+
|
|
72
|
+
await writeMove({
|
|
73
|
+
kind: 'started',
|
|
74
|
+
via: 'reconcile',
|
|
75
|
+
atcSession: running.atcSession,
|
|
76
|
+
caller: running.caller,
|
|
77
|
+
resumeFallback: false,
|
|
78
|
+
});
|
|
79
|
+
})
|
|
80
|
+
.with({ kind: 'move-to-running', via: 'resume' }, async () => {
|
|
81
|
+
const running = getRunningReading(input.reading);
|
|
82
|
+
|
|
83
|
+
await writeMove({ kind: 'resumed', atcSession: running.atcSession, caller: running.caller });
|
|
84
|
+
})
|
|
85
|
+
.with({ kind: 'respawn' }, async () => {
|
|
86
|
+
if (!(await probeSeenRow(input))) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await startClaim({
|
|
91
|
+
...input,
|
|
92
|
+
claim: {
|
|
93
|
+
kind: 'start',
|
|
94
|
+
attempt: input.attempt,
|
|
95
|
+
spawn: spawnRequestSchema.parse(input.attempt.spawnRequest),
|
|
96
|
+
kept: null,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
})
|
|
100
|
+
.with({ kind: 'clear-mark' }, async () => {
|
|
101
|
+
await writeMove({ kind: 'suspend-refused' });
|
|
102
|
+
})
|
|
103
|
+
.with({ kind: 'sleep-if-idle' }, (sleep) =>
|
|
104
|
+
runSleepSteps(input, base, {
|
|
105
|
+
ifIdleSince: sleep.waitingSince,
|
|
106
|
+
idleSince: sleep.waitingSince,
|
|
107
|
+
}),
|
|
108
|
+
)
|
|
109
|
+
.with({ kind: 'sleep-unattended' }, (sleep) =>
|
|
110
|
+
runSleepSteps(input, base, { ifIdleSince: null, idleSince: sleep.since }),
|
|
111
|
+
)
|
|
112
|
+
.with({ kind: 'sleep' }, (sleep) =>
|
|
113
|
+
input.store.exclusive(async (tx) => {
|
|
114
|
+
const moved = await writeAttemptMove(
|
|
115
|
+
tx,
|
|
116
|
+
id,
|
|
117
|
+
{
|
|
118
|
+
kind: 'slept',
|
|
119
|
+
idleSince: input.attempt.suspendRequestedAt ?? input.now,
|
|
120
|
+
asked: !sleep.requestWake,
|
|
121
|
+
},
|
|
122
|
+
seen,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// the host slept with no request, so the attempt wakes by itself
|
|
126
|
+
if (moved.kind === 'applied' && sleep.requestWake) {
|
|
127
|
+
await writeAttemptMove(
|
|
128
|
+
tx,
|
|
129
|
+
id,
|
|
130
|
+
{ kind: 'wake-requested', by: 'host-slept-unasked' },
|
|
131
|
+
base,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}),
|
|
135
|
+
)
|
|
136
|
+
.with({ kind: 'request-wake' }, async (wake) => {
|
|
137
|
+
await writeMove({ kind: 'wake-requested', by: wake.by });
|
|
138
|
+
})
|
|
139
|
+
.with({ kind: 'wake-over-cap' }, () =>
|
|
140
|
+
input.store.exclusive((tx) => writeWakeOverCap(tx, input, base)),
|
|
141
|
+
)
|
|
142
|
+
.with({ kind: 'resume' }, async () => {
|
|
143
|
+
if (!(await probeSeenRow(input))) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await resumeClaim({ ...input, claim: { kind: 'wake', attempt: input.attempt } });
|
|
148
|
+
})
|
|
149
|
+
.with({ kind: 'close', cause: 'timed-out' }, async (close) => {
|
|
150
|
+
await writeMove({ kind: 'timed-out', afterMinutes: close.afterMinutes });
|
|
151
|
+
})
|
|
152
|
+
.with({ kind: 'close', cause: 'turn-ended' }, async () => {
|
|
153
|
+
await writeMove({ kind: 'turn-ended' });
|
|
154
|
+
})
|
|
155
|
+
.with({ kind: 'close', cause: 'stalled' }, async () => {
|
|
156
|
+
await writeMove({ kind: 'stalled' });
|
|
157
|
+
})
|
|
158
|
+
.with({ kind: 'close', cause: 'usage-limit' }, async (close) => {
|
|
159
|
+
const latest = input.now + timeouts.usageLimitBackoffHours * hourMs;
|
|
160
|
+
|
|
161
|
+
// the worker adapter sends the time it saw the limit when atc gives no reset time
|
|
162
|
+
const until = close.resetsAt > input.now ? Math.min(close.resetsAt, latest) : latest;
|
|
163
|
+
|
|
164
|
+
const moved = await writeMove({ kind: 'usage-limited' });
|
|
165
|
+
|
|
166
|
+
if (moved.kind === 'applied') {
|
|
167
|
+
input.backoff.hold(input.attempt.pool, brandSchemas.instant.parse(until));
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
.with({ kind: 'stop' }, () => runStop(input, base))
|
|
171
|
+
.with({ kind: 'settle' }, async (settle) => {
|
|
172
|
+
await settleAttempt({
|
|
173
|
+
...base,
|
|
174
|
+
store: input.store,
|
|
175
|
+
attempt: id,
|
|
176
|
+
forced: settle.forced,
|
|
177
|
+
sessionStopped: settle.sessionStopped,
|
|
178
|
+
seen: input.attempt,
|
|
179
|
+
rules: input.rules,
|
|
180
|
+
});
|
|
181
|
+
})
|
|
182
|
+
.exhaustive();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getRunningReading(reading: SessionReading) {
|
|
186
|
+
invariant(
|
|
187
|
+
reading.kind === 'running',
|
|
188
|
+
'the table moves an attempt to running on a running reading',
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return reading;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The worker call of a respawn or a resume runs outside the lock, so it checks the row first: a
|
|
195
|
+
// row that moved since the reading makes the call wait for the next tick.
|
|
196
|
+
async function probeSeenRow(input: ActOnReadingInput): Promise<boolean> {
|
|
197
|
+
const row = await readRow(input);
|
|
198
|
+
|
|
199
|
+
return isSameGuard(input.attempt, row);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readRow(input: ActOnReadingInput): Promise<AttemptRow> {
|
|
203
|
+
return input.store.read(async (tx) => {
|
|
204
|
+
const row = await tx.readAttempt(input.attempt.id);
|
|
205
|
+
|
|
206
|
+
invariant(row !== null, `the runner read a stored attempt: ${input.attempt.id}`);
|
|
207
|
+
|
|
208
|
+
return row;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface SleepTimes {
|
|
213
|
+
// null: suspend with no idle check
|
|
214
|
+
readonly ifIdleSince: Instant | null;
|
|
215
|
+
readonly idleSince: Instant;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The mark, the suspend outside the lock, then `slept`, which releases the seats. A crash, or an
|
|
219
|
+
// unknown answer, leaves the mark, and the next reading clears it or sleeps the attempt.
|
|
220
|
+
async function runSleepSteps(
|
|
221
|
+
input: ActOnReadingInput,
|
|
222
|
+
base: MoveBase,
|
|
223
|
+
times: SleepTimes,
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
const id = input.attempt.id;
|
|
226
|
+
|
|
227
|
+
const marked = await input.store.exclusive((tx) =>
|
|
228
|
+
writeAttemptMove(tx, id, { kind: 'suspend-asked' }, { ...base, seen: input.attempt }),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (marked.kind !== 'applied') {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const row = await readRow(input);
|
|
236
|
+
const outcome = await input.worker.suspend(row, times.ifIdleSince);
|
|
237
|
+
|
|
238
|
+
if (outcome.kind === 'unknown') {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const event: MoveEvent =
|
|
243
|
+
outcome.kind === 'suspended'
|
|
244
|
+
? { kind: 'slept', idleSince: times.idleSince, asked: true }
|
|
245
|
+
: { kind: 'suspend-refused' };
|
|
246
|
+
|
|
247
|
+
await input.store.exclusive((tx) => writeAttemptMove(tx, id, event, { ...base, seen: row }));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// The owner revived the session and no seat came free in time: the attempt takes its seats past
|
|
251
|
+
// the cap, with no resume, because the session runs already. The decision row records each
|
|
252
|
+
// limit's count and max at that moment.
|
|
253
|
+
async function writeWakeOverCap(tx: StoreTx, input: ActOnReadingInput, base: MoveBase) {
|
|
254
|
+
const row = await tx.readAttempt(input.attempt.id);
|
|
255
|
+
|
|
256
|
+
invariant(row !== null, `the runner read a stored attempt: ${input.attempt.id}`);
|
|
257
|
+
|
|
258
|
+
const limits = await tx.listLimits();
|
|
259
|
+
|
|
260
|
+
const counting = limits.filter((limit) => isFilterMatch(limit.counts, row.fields));
|
|
261
|
+
|
|
262
|
+
const snapshot = await tx.readSnapshot(input.now, input.rules);
|
|
263
|
+
|
|
264
|
+
return writeAttemptMove(
|
|
265
|
+
tx,
|
|
266
|
+
row.id,
|
|
267
|
+
{
|
|
268
|
+
kind: 'woken',
|
|
269
|
+
pick: { kind: 'wake', attempt: row.id, limits: counting.map((limit) => limit.name) },
|
|
270
|
+
decision: {
|
|
271
|
+
item: row.item,
|
|
272
|
+
kind: 'wake',
|
|
273
|
+
pool: row.pool,
|
|
274
|
+
rulesVersion: input.rules.version,
|
|
275
|
+
snapshotHash: buildSnapshotHash(snapshot, input.backoff.listHeld(input.now)),
|
|
276
|
+
storeVersion: snapshot.storeVersion,
|
|
277
|
+
pepysOffsets: {},
|
|
278
|
+
faults: [],
|
|
279
|
+
limits: counting.map((limit) => ({ limit: limit.name, held: limit.held, max: limit.max })),
|
|
280
|
+
},
|
|
281
|
+
overCap: true,
|
|
282
|
+
},
|
|
283
|
+
{ ...base, seen: input.attempt },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A closing attempt whose spawn landed after its item closed records the session first, so the
|
|
288
|
+
// stop reaches it. The stop's answer moves nothing: the next reading settles a stopped session.
|
|
289
|
+
async function runStop(input: ActOnReadingInput, base: MoveBase): Promise<void> {
|
|
290
|
+
const reading = input.reading;
|
|
291
|
+
|
|
292
|
+
if (input.attempt.atcSession === null && reading.kind === 'running') {
|
|
293
|
+
const moved = await input.store.exclusive((tx) =>
|
|
294
|
+
writeAttemptMove(
|
|
295
|
+
tx,
|
|
296
|
+
input.attempt.id,
|
|
297
|
+
{
|
|
298
|
+
kind: 'started',
|
|
299
|
+
via: 'start',
|
|
300
|
+
atcSession: reading.atcSession,
|
|
301
|
+
caller: reading.caller,
|
|
302
|
+
resumeFallback: false,
|
|
303
|
+
},
|
|
304
|
+
{ ...base, seen: input.attempt },
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (moved.kind !== 'applied') {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
} else if (!(await probeSeenRow(input))) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const row = await readRow(input);
|
|
316
|
+
|
|
317
|
+
await input.worker.stop(row);
|
|
318
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { AttemptRow, Instant, SessionReading } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
const minuteMs = 60_000;
|
|
4
|
+
|
|
5
|
+
interface WaitingInput {
|
|
6
|
+
readonly attempt: AttemptRow;
|
|
7
|
+
readonly reading: SessionReading;
|
|
8
|
+
|
|
9
|
+
// the rules' `sleep.afterQuietMinutes`
|
|
10
|
+
readonly quietAfterMinutes: number;
|
|
11
|
+
readonly now: Instant;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Since when the session has waited for the owner, from the reading alone, or null. Pure.
|
|
15
|
+
export function findWaitingSince(input: WaitingInput): Instant | null {
|
|
16
|
+
const reading = input.reading;
|
|
17
|
+
|
|
18
|
+
if (reading.kind !== 'running') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (reading.attention === 'needs-input') {
|
|
23
|
+
return reading.lastActivityAt;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (reading.attention === 'turn-ended' && input.attempt.mode === 'attended') {
|
|
27
|
+
return reading.lastActivityAt;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// atc lists a revived session as running, so a woken session that did nothing since waits
|
|
31
|
+
const wokenAt = input.attempt.wokenAt;
|
|
32
|
+
|
|
33
|
+
if (wokenAt !== null && reading.lastActivityAt <= wokenAt) {
|
|
34
|
+
return wokenAt;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// the quiet rule: an attach moves atc's state to running, so a prompt the owner read and left
|
|
38
|
+
// reads as working with no later activity
|
|
39
|
+
const quietMs = input.now - reading.lastActivityAt;
|
|
40
|
+
|
|
41
|
+
if (input.attempt.mode === 'attended' && quietMs >= input.quietAfterMinutes * minuteMs) {
|
|
42
|
+
return reading.lastActivityAt;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|