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