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