@defrex/autobuild 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +15 -1
- package/bin/ab-dispatch-kernel.ts +1 -1
- package/docs/configuration.md +32 -3
- package/docs/operator-api.md +17 -0
- package/docs/remote-ticket-protocol.md +5 -0
- package/package.json +3 -3
- package/packages/core/src/cli/binary.ts +3 -3
- package/packages/core/src/cli/dashboard/model.ts +4 -0
- package/packages/core/src/cli/dashboard/render.ts +37 -7
- package/packages/core/src/cli/dispatch-frontend.ts +16 -1
- package/packages/core/src/cli/dispatch-process.ts +25 -7
- package/packages/core/src/cli/dispatch.ts +64 -6
- package/packages/core/src/cli/dispatcher-liveness.ts +91 -0
- package/packages/core/src/cli/help.ts +8 -5
- package/packages/core/src/cli/repository-status.ts +45 -5
- package/packages/core/src/cli/watch.ts +159 -14
- package/packages/core/src/config/live.ts +1 -0
- package/packages/core/src/config/schema.ts +9 -2
- package/packages/core/src/events/globs.ts +8 -3
- package/packages/core/src/events/repository.ts +51 -2
- package/packages/core/src/integration/harness.ts +2 -1
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +18 -0
- package/packages/core/src/kernel/dispatch-status.ts +9 -1
- package/packages/core/src/kernel/dispatcher-liveness.ts +70 -0
- package/packages/core/src/kernel/reducer-registry.ts +4 -0
- package/packages/core/src/kernel/ticket-source-health.ts +30 -0
- package/packages/core/src/operator/query.ts +6 -3
- package/packages/core/src/ports/tickets/contract.ts +25 -0
- package/packages/core/src/ports/tickets/fake.ts +10 -8
- package/packages/core/src/ports/tickets/file.ts +1 -0
- package/packages/core/src/ports/tickets/linear.ts +241 -49
- package/packages/core/src/ports/tickets/remote-protocol.ts +2 -0
- package/packages/core/src/ports/types.ts +17 -0
- package/packages/core/src/processes/dispatcher.ts +277 -73
- package/packages/core/src/processes/repo-view.ts +2 -1
- package/packages/core/src/store/adapter.ts +1 -0
- package/packages/core/src/store/contract.ts +109 -0
- package/packages/core/src/store/digest.ts +14 -2
- package/packages/core/src/store/local/store.ts +18 -5
- package/packages/core/src/store/remote/protocol.ts +1 -0
- package/packages/core/src/store/repo-state-events.ts +49 -59
- package/packages/core/src/store/types.ts +5 -1
- package/skills/guide/SKILL.md +31 -0
- package/skills/operate/SKILL.md +39 -4
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
reduceTicketSourceHealth,
|
|
3
|
+
type TicketSourceDegradation,
|
|
4
|
+
} from '../kernel/ticket-source-health'
|
|
1
5
|
/**
|
|
2
6
|
* The dispatcher process (SPEC §3.3, §12): watches the TicketSource for
|
|
3
7
|
* Ready tickets, claims, provisions a workspace, and launches build-runners
|
|
@@ -36,6 +40,7 @@
|
|
|
36
40
|
* validation and fallback remain here. The supervising kernel reserves each
|
|
37
41
|
* execution lease before launch; the child renews that same-holder lease.
|
|
38
42
|
*/
|
|
43
|
+
import { createHash } from 'node:crypto'
|
|
39
44
|
import type { Config } from '../config/schema'
|
|
40
45
|
import type { LanguageModel } from 'ai'
|
|
41
46
|
import { runOrchestratorTickStep } from './orchestrator-tick'
|
|
@@ -74,6 +79,7 @@ import type {
|
|
|
74
79
|
TicketSource,
|
|
75
80
|
WorkspaceProvider,
|
|
76
81
|
} from '../ports/types'
|
|
82
|
+
import { TicketSourceRateLimitError } from '../ports/types'
|
|
77
83
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
78
84
|
import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
|
|
79
85
|
import { samplePinnedAssets } from '../store/ticket-assets'
|
|
@@ -201,7 +207,9 @@ function findCycle(
|
|
|
201
207
|
nodes: Map<string, DependencyState>,
|
|
202
208
|
explored: Set<string>,
|
|
203
209
|
): string[] | null {
|
|
204
|
-
|
|
210
|
+
const node = nodes.get(from)
|
|
211
|
+
if (!node?.exists || node.resolved) return null
|
|
212
|
+
for (const next of node.blockedBy) {
|
|
205
213
|
const seen = path.indexOf(next)
|
|
206
214
|
if (seen !== -1) return [...path.slice(seen), next]
|
|
207
215
|
if (explored.has(next)) continue
|
|
@@ -301,7 +309,7 @@ export function validateSlugCandidate(candidate: string | null | undefined): str
|
|
|
301
309
|
/** Counts per action, for observability and tests. An idempotent re-run of
|
|
302
310
|
* `tick()` over unchanged state reports all zeroes — except invalid-record,
|
|
303
311
|
* in-flight-creation, and dependency fields, which are standing queue reports rather than records of
|
|
304
|
-
* action. They repeat
|
|
312
|
+
* action. They repeat between scans and refresh on completed scans, because that is the only
|
|
305
313
|
* place the source problems are visible without provider inspection. */
|
|
306
314
|
export interface TickReport {
|
|
307
315
|
/** Janitor (§15.7): builds completed as merged. */
|
|
@@ -354,13 +362,15 @@ export interface TickReport {
|
|
|
354
362
|
/** Standing queue depth: ready tickets still waiting after this tick —
|
|
355
363
|
* excludes tickets already embodied by an active build, and ones that left
|
|
356
364
|
* the queue this tick (dispatched, bounced, or claimed elsewhere). Like the
|
|
357
|
-
* invalid/dependency fields it
|
|
365
|
+
* invalid/dependency fields it repeats between completed scans. */
|
|
358
366
|
queued: number
|
|
359
367
|
/** Ready tickets withheld while Autobuild's own external creation is still
|
|
360
368
|
* between durable reservation and settled blocker recording. */
|
|
361
369
|
creationWithheld: number
|
|
362
370
|
/** One line per creation hold, with Harvest run/proposal provenance. */
|
|
363
371
|
creationDiagnostics: string[]
|
|
372
|
+
/** Ready tickets whose dependency reads failed rather than yielding a verdict. */
|
|
373
|
+
dependencyCheckFailed: number
|
|
364
374
|
/** Dependency gate (§13): ready tickets held back by unresolved blockers. */
|
|
365
375
|
dependencyBlocked: number
|
|
366
376
|
/** One line per held ticket naming its unresolved blockers — the operator's
|
|
@@ -410,6 +420,7 @@ export function emptyTickReport(): TickReport {
|
|
|
410
420
|
queued: 0,
|
|
411
421
|
creationWithheld: 0,
|
|
412
422
|
creationDiagnostics: [],
|
|
423
|
+
dependencyCheckFailed: 0,
|
|
413
424
|
dependencyBlocked: 0,
|
|
414
425
|
dependencyDiagnostics: [],
|
|
415
426
|
harvestStarted: 0,
|
|
@@ -729,6 +740,7 @@ export class Dispatcher {
|
|
|
729
740
|
}
|
|
730
741
|
}
|
|
731
742
|
const report = emptyTickReport()
|
|
743
|
+
this.restoreScan(report)
|
|
732
744
|
/** Builds already launched this tick — a janitor/startup re-attach must
|
|
733
745
|
* not be doubled by the sweep, nor a fresh dispatch by anything. */
|
|
734
746
|
const launched = new Set<string>()
|
|
@@ -893,6 +905,81 @@ export class Dispatcher {
|
|
|
893
905
|
.map(({ record }) => record)
|
|
894
906
|
}
|
|
895
907
|
|
|
908
|
+
private async sourceCondition(
|
|
909
|
+
next?: TicketSourceDegradation,
|
|
910
|
+
successfulOperation?: 'ready-scan' | 'dependency-check',
|
|
911
|
+
): Promise<void> {
|
|
912
|
+
await this.store.refreshJournal()
|
|
913
|
+
const current = reduceTicketSourceHealth(this.store.recordedJournal())
|
|
914
|
+
if (next !== undefined && current?.cause === next.cause && current.source === next.source) {
|
|
915
|
+
if (
|
|
916
|
+
next.cause === 'rate-limited' &&
|
|
917
|
+
next.operation === 'dependency-check' &&
|
|
918
|
+
current.operation !== 'dependency-check'
|
|
919
|
+
) {
|
|
920
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
921
|
+
actor: DISPATCHER,
|
|
922
|
+
type: 'dispatcher.ticket-source-dependency-limited',
|
|
923
|
+
payload: { source: current.source },
|
|
924
|
+
})
|
|
925
|
+
}
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
if (
|
|
929
|
+
next === undefined &&
|
|
930
|
+
current?.operation === 'dependency-check' &&
|
|
931
|
+
successfulOperation !== 'dependency-check'
|
|
932
|
+
)
|
|
933
|
+
return
|
|
934
|
+
if (current !== undefined) {
|
|
935
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
936
|
+
actor: DISPATCHER,
|
|
937
|
+
type: 'dispatcher.ticket-source-recovered',
|
|
938
|
+
payload: { source: current.source, cause: current.cause, operation: current.operation },
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
if (next !== undefined) {
|
|
942
|
+
await this.store.ensureRepo(this.deps.repo)
|
|
943
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
944
|
+
actor: DISPATCHER,
|
|
945
|
+
type: 'dispatcher.ticket-source-degraded',
|
|
946
|
+
payload: next,
|
|
947
|
+
})
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
private async sourceFailure(
|
|
952
|
+
error: unknown,
|
|
953
|
+
operation: 'ready-scan' | 'dependency-check',
|
|
954
|
+
): Promise<void> {
|
|
955
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
956
|
+
await this.sourceCondition(
|
|
957
|
+
error instanceof TicketSourceRateLimitError
|
|
958
|
+
? {
|
|
959
|
+
source: error.source,
|
|
960
|
+
cause: 'rate-limited',
|
|
961
|
+
operation,
|
|
962
|
+
message,
|
|
963
|
+
resetAt: new Date(error.resetAt).toISOString(),
|
|
964
|
+
}
|
|
965
|
+
: {
|
|
966
|
+
source: this.deps.tickets.name,
|
|
967
|
+
cause: 'ready-scan-failed',
|
|
968
|
+
operation,
|
|
969
|
+
message: message || 'Ready scan failed',
|
|
970
|
+
},
|
|
971
|
+
)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private async listReady() {
|
|
975
|
+
try {
|
|
976
|
+
return await this.deps.tickets.listReady(readyCriteria(this.deps.config))
|
|
977
|
+
} catch (error) {
|
|
978
|
+
await this.sourceFailure(error, 'ready-scan')
|
|
979
|
+
throw error
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
896
983
|
/** Observation-only ready listing for Store-backed frontends while intake is
|
|
897
984
|
* disabled. It shares the active-ticket exclusion with dispatch but performs
|
|
898
985
|
* no dependency fetch, claim, bounce, build, workspace, or launch side
|
|
@@ -910,14 +997,109 @@ export class Dispatcher {
|
|
|
910
997
|
activeTicketIds.add(record.ticket.id)
|
|
911
998
|
}
|
|
912
999
|
}
|
|
913
|
-
const
|
|
1000
|
+
const report = emptyTickReport()
|
|
1001
|
+
this.restoreScan(report)
|
|
1002
|
+
const checkpoint = await this.scanCheckpoint('observation')
|
|
1003
|
+
if (checkpoint.due) {
|
|
1004
|
+
await this.store.refreshJournal()
|
|
1005
|
+
const listing = await this.listReady()
|
|
1006
|
+
await this.store.refreshJournal()
|
|
1007
|
+
report.queued = listing.tickets.filter((ticket) => !activeTicketIds.has(ticket.ref.id)).length
|
|
1008
|
+
report.invalidTickets = listing.diagnostics.length
|
|
1009
|
+
report.ticketDiagnostics = [...listing.diagnostics]
|
|
1010
|
+
report.dependencyBlocked = 0
|
|
1011
|
+
report.dependencyDiagnostics = []
|
|
1012
|
+
report.creationWithheld = 0
|
|
1013
|
+
report.creationDiagnostics = []
|
|
1014
|
+
await this.sourceCondition(undefined, 'ready-scan')
|
|
1015
|
+
await this.completeScan(checkpoint, report)
|
|
1016
|
+
}
|
|
914
1017
|
return {
|
|
915
|
-
queued:
|
|
916
|
-
invalidTickets:
|
|
917
|
-
ticketDiagnostics:
|
|
1018
|
+
queued: report.queued,
|
|
1019
|
+
invalidTickets: report.invalidTickets,
|
|
1020
|
+
ticketDiagnostics: report.ticketDiagnostics,
|
|
918
1021
|
}
|
|
919
1022
|
}
|
|
920
1023
|
|
|
1024
|
+
private latestScan() {
|
|
1025
|
+
return this.store
|
|
1026
|
+
.recordedJournal()
|
|
1027
|
+
.findLast(
|
|
1028
|
+
(event): event is Extract<RepositoryEvent, { type: 'dispatcher.ticket-scan-completed' }> =>
|
|
1029
|
+
event.type === 'dispatcher.ticket-scan-completed',
|
|
1030
|
+
)?.payload
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private restoreScan(report: TickReport): void {
|
|
1034
|
+
const previous = this.latestScan()
|
|
1035
|
+
if (previous === undefined) return
|
|
1036
|
+
for (const key of [
|
|
1037
|
+
'queued',
|
|
1038
|
+
'invalidTickets',
|
|
1039
|
+
'dependencyBlocked',
|
|
1040
|
+
'creationWithheld',
|
|
1041
|
+
] as const)
|
|
1042
|
+
report[key] = previous[key]
|
|
1043
|
+
for (const key of [
|
|
1044
|
+
'ticketDiagnostics',
|
|
1045
|
+
'dependencyDiagnostics',
|
|
1046
|
+
'creationDiagnostics',
|
|
1047
|
+
] as const)
|
|
1048
|
+
report[key] = [...previous[key]]
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
private async scanCheckpoint(mode: 'admission' | 'observation') {
|
|
1052
|
+
const digest = (await this.repoBuildDigests())
|
|
1053
|
+
.filter(({ digest }) => digest.readinessChangeSeq !== undefined)
|
|
1054
|
+
.map(({ record, digest }) => [record.slug, digest.readinessChangeSeq] as const)
|
|
1055
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1056
|
+
const hash = (value: unknown) =>
|
|
1057
|
+
createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
1058
|
+
const readinessFingerprint = hash(digest)
|
|
1059
|
+
const criteriaFingerprint = hash(readyCriteria(this.deps.config))
|
|
1060
|
+
const startedAt = this.deps.clock().toISOString()
|
|
1061
|
+
const previous = this.latestScan()
|
|
1062
|
+
const elapsed =
|
|
1063
|
+
previous === undefined ? Infinity : Date.parse(startedAt) - Date.parse(previous.startedAt)
|
|
1064
|
+
return {
|
|
1065
|
+
startedAt,
|
|
1066
|
+
mode,
|
|
1067
|
+
readinessFingerprint,
|
|
1068
|
+
criteriaFingerprint,
|
|
1069
|
+
due:
|
|
1070
|
+
previous === undefined ||
|
|
1071
|
+
elapsed < 0 ||
|
|
1072
|
+
elapsed >= this.deps.config.ticketScanIntervalSeconds * 1000 ||
|
|
1073
|
+
previous.readinessFingerprint !== readinessFingerprint ||
|
|
1074
|
+
previous.criteriaFingerprint !== criteriaFingerprint ||
|
|
1075
|
+
(mode === 'admission' && previous.mode === 'observation'),
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private async completeScan(
|
|
1080
|
+
checkpoint: Awaited<ReturnType<Dispatcher['scanCheckpoint']>>,
|
|
1081
|
+
report: TickReport,
|
|
1082
|
+
): Promise<void> {
|
|
1083
|
+
await this.store.ensureRepo(this.deps.repo)
|
|
1084
|
+
await this.store.appendRepo(this.deps.repo, {
|
|
1085
|
+
actor: DISPATCHER,
|
|
1086
|
+
type: 'dispatcher.ticket-scan-completed',
|
|
1087
|
+
payload: {
|
|
1088
|
+
startedAt: checkpoint.startedAt,
|
|
1089
|
+
mode: checkpoint.mode,
|
|
1090
|
+
readinessFingerprint: checkpoint.readinessFingerprint,
|
|
1091
|
+
criteriaFingerprint: checkpoint.criteriaFingerprint,
|
|
1092
|
+
queued: report.queued,
|
|
1093
|
+
invalidTickets: report.invalidTickets,
|
|
1094
|
+
ticketDiagnostics: report.ticketDiagnostics.slice(0, 1000),
|
|
1095
|
+
dependencyBlocked: report.dependencyBlocked,
|
|
1096
|
+
dependencyDiagnostics: report.dependencyDiagnostics.slice(0, 1000),
|
|
1097
|
+
creationWithheld: report.creationWithheld,
|
|
1098
|
+
creationDiagnostics: report.creationDiagnostics.slice(0, 1000),
|
|
1099
|
+
},
|
|
1100
|
+
})
|
|
1101
|
+
}
|
|
1102
|
+
|
|
921
1103
|
/** Apply the newest durable `dispatcher.auto-merge-default-set` fact to every
|
|
922
1104
|
* current non-terminal build of this repository: on — request consent; off —
|
|
923
1105
|
* cancel it. Attribution is the toggling human's own actor, exactly as if
|
|
@@ -2859,6 +3041,13 @@ export class Dispatcher {
|
|
|
2859
3041
|
): Promise<void> {
|
|
2860
3042
|
const store = this.store
|
|
2861
3043
|
const { tickets, config } = this.deps
|
|
3044
|
+
const checkpoint = await this.scanCheckpoint('admission')
|
|
3045
|
+
if (!checkpoint.due) return
|
|
3046
|
+
// Clear only standing measurement fields; action counters belong to this tick.
|
|
3047
|
+
report.queued = report.invalidTickets = report.dependencyBlocked = report.creationWithheld = 0
|
|
3048
|
+
report.ticketDiagnostics = []
|
|
3049
|
+
report.dependencyDiagnostics = []
|
|
3050
|
+
report.creationDiagnostics = []
|
|
2862
3051
|
// Blocked and paused builds still occupy a slot: their workspaces and
|
|
2863
3052
|
// pending work are live, only waiting on a human. Capacity is per repo
|
|
2864
3053
|
// (§16.1) — another repo's builds never consume this repo's slots.
|
|
@@ -2878,9 +3067,12 @@ export class Dispatcher {
|
|
|
2878
3067
|
// ready state are deduped against the active builds embodying them.
|
|
2879
3068
|
await this.store.refreshJournal()
|
|
2880
3069
|
const journalBeforeReady = this.store.recordedJournal()
|
|
2881
|
-
const listing = await
|
|
3070
|
+
const listing = await this.listReady()
|
|
2882
3071
|
await this.store.refreshJournal()
|
|
2883
3072
|
const journalAfterReady = this.store.recordedJournal()
|
|
3073
|
+
if (reduceTicketSourceHealth(journalAfterReady)?.cause === 'ready-scan-failed') {
|
|
3074
|
+
await this.sourceCondition(undefined, 'ready-scan')
|
|
3075
|
+
}
|
|
2884
3076
|
const creationHolds = new Map(
|
|
2885
3077
|
harvestCreationsDuringReadyScan(journalBeforeReady, journalAfterReady).map((creation) => [
|
|
2886
3078
|
creation.creationKey.toLowerCase(),
|
|
@@ -2912,55 +3104,67 @@ export class Dispatcher {
|
|
|
2912
3104
|
// still runs either way, so the standing queue-depth report stays honest —
|
|
2913
3105
|
// this only declines to claim a ticket into a build that would immediately
|
|
2914
3106
|
// sit held.
|
|
2915
|
-
if (capacity <= 0 || paused)
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
3107
|
+
if (capacity <= 0 || paused) {
|
|
3108
|
+
await this.sourceCondition(undefined, 'ready-scan')
|
|
3109
|
+
if (!this.outOfBudget(opts)) await this.completeScan(checkpoint, report)
|
|
3110
|
+
return
|
|
3111
|
+
}
|
|
3112
|
+
// One dependency-node cache per scan: blockers are commonly shared across
|
|
3113
|
+
// the ready set. Never reuse ticket or dependency objects between scans.
|
|
3114
|
+
const nodes = new Map<string, DependencyState>(
|
|
3115
|
+
listing.tickets.map((ticket) => [
|
|
3116
|
+
ticket.ref.id,
|
|
3117
|
+
{
|
|
3118
|
+
id: ticket.ref.id,
|
|
3119
|
+
exists: true,
|
|
3120
|
+
resolved: ticket.resolved,
|
|
3121
|
+
blockedBy: [...(ticket.blockedBy ?? [])],
|
|
3122
|
+
},
|
|
3123
|
+
]),
|
|
3124
|
+
)
|
|
2920
3125
|
|
|
3126
|
+
// Finish read-only evaluation before any intake claim: a later source
|
|
3127
|
+
// failure must leave the entire ready set unclaimed. Publish dependency
|
|
3128
|
+
// diagnostics only after the complete preflight succeeds.
|
|
3129
|
+
let scanSuccessful = true
|
|
3130
|
+
const verdicts = new Map<string, DependencyVerdict>()
|
|
3131
|
+
const failed = new Set<string>()
|
|
2921
3132
|
for (const ticket of ready) {
|
|
2922
|
-
if (
|
|
2923
|
-
// Budget gate before the dependency fetch / claim / createBuild: the
|
|
2924
|
-
// first ready read above stays (it is the standing queue-depth report);
|
|
2925
|
-
// once the budget is spent no additional transport call is started, so
|
|
2926
|
-
// the remaining tickets are left un-attempted for the next invocation.
|
|
2927
|
-
if (this.outOfBudget(opts)) break
|
|
2928
|
-
|
|
2929
|
-
// Autobuild's own in-flight create gate precedes even dependency reads:
|
|
2930
|
-
// the ready projection may have been captured before blocker recording.
|
|
2931
|
-
// It spends no capacity and leaves queue depth unchanged.
|
|
3133
|
+
if (this.outOfBudget(opts)) return
|
|
2932
3134
|
if (withheld.has(ticket.ref.id)) continue
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
let verdict: DependencyVerdict
|
|
2943
|
-
try {
|
|
2944
|
-
await this.loadDependencyGraph(ticket, nodes)
|
|
2945
|
-
verdict = analyzeDependencies(ticket.ref.id, ticket.blockedBy ?? [], nodes)
|
|
2946
|
-
} catch (error) {
|
|
2947
|
-
// A broken graph is this ticket's problem, not the tick's: skip it
|
|
2948
|
-
// and let unrelated eligible tickets dispatch normally.
|
|
2949
|
-
verdict = {
|
|
2950
|
-
unresolved: [ticket.ref.id],
|
|
2951
|
-
diagnostics: [
|
|
2952
|
-
`ticket ${ticket.ref.id}: dependency check failed — ${
|
|
2953
|
-
error instanceof Error ? error.message : String(error)
|
|
2954
|
-
}`,
|
|
2955
|
-
],
|
|
2956
|
-
}
|
|
2957
|
-
}
|
|
2958
|
-
if (verdict.unresolved.length > 0) {
|
|
2959
|
-
report.dependencyBlocked += 1
|
|
2960
|
-
report.dependencyDiagnostics.push(...verdict.diagnostics)
|
|
2961
|
-
continue
|
|
3135
|
+
if (ticket.blockersResolved === true || (ticket.blockedBy ?? []).length === 0) continue
|
|
3136
|
+
let verdict: DependencyVerdict
|
|
3137
|
+
try {
|
|
3138
|
+
if (!(await this.loadDependencyGraph(ticket, nodes, opts))) return
|
|
3139
|
+
verdict = analyzeDependencies(ticket.ref.id, ticket.blockedBy ?? [], nodes)
|
|
3140
|
+
} catch (error) {
|
|
3141
|
+
if (error instanceof TicketSourceRateLimitError) {
|
|
3142
|
+
await this.sourceFailure(error, 'dependency-check')
|
|
3143
|
+
throw error
|
|
2962
3144
|
}
|
|
3145
|
+
scanSuccessful = false
|
|
3146
|
+
failed.add(ticket.ref.id)
|
|
3147
|
+
report.dependencyCheckFailed += 1
|
|
3148
|
+
report.dependencyDiagnostics.push(
|
|
3149
|
+
`ticket ${ticket.ref.id}: dependency check failed — ${error instanceof Error ? error.message : String(error)}`,
|
|
3150
|
+
)
|
|
3151
|
+
continue
|
|
2963
3152
|
}
|
|
3153
|
+
verdicts.set(ticket.ref.id, verdict)
|
|
3154
|
+
}
|
|
3155
|
+
if (this.outOfBudget(opts)) return
|
|
3156
|
+
if (failed.size === 0) await this.sourceCondition(undefined, 'dependency-check')
|
|
3157
|
+
for (const verdict of verdicts.values()) {
|
|
3158
|
+
if (verdict.unresolved.length > 0) {
|
|
3159
|
+
report.dependencyBlocked += 1
|
|
3160
|
+
report.dependencyDiagnostics.push(...verdict.diagnostics)
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
for (const ticket of ready) {
|
|
3164
|
+
if (capacity <= 0 || this.outOfBudget(opts)) break
|
|
3165
|
+
if (withheld.has(ticket.ref.id)) continue
|
|
3166
|
+
if (failed.has(ticket.ref.id)) continue
|
|
3167
|
+
if ((verdicts.get(ticket.ref.id)?.unresolved.length ?? 0) > 0) continue
|
|
2964
3168
|
|
|
2965
3169
|
// Claim-before-launch (§12): losing the claim means another dispatcher
|
|
2966
3170
|
// (or an earlier tick) owns this ticket.
|
|
@@ -3036,36 +3240,36 @@ export class Dispatcher {
|
|
|
3036
3240
|
// means the remote provisioning continuation owns the rest.
|
|
3037
3241
|
if (outcome === 'completed' || outcome === 'deferred') report.dispatched += 1
|
|
3038
3242
|
}
|
|
3243
|
+
if (scanSuccessful && !this.outOfBudget(opts)) await this.completeScan(checkpoint, report)
|
|
3039
3244
|
}
|
|
3040
3245
|
|
|
3041
3246
|
/**
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3044
|
-
* level, skipping ids already cached — the common case (a blocker or two,
|
|
3045
|
-
* none of them blocked themselves) is a single call.
|
|
3046
|
-
*
|
|
3047
|
-
* INVARIANT: `nodes` contains only states actually returned by the source.
|
|
3048
|
-
* The cache is shared across the tick's tickets, so a node this ticket
|
|
3049
|
-
* invents is a node the *next* ticket will trust as a fact about its own
|
|
3050
|
-
* blocker. Nothing is seeded here for that reason (f_8bc9ee0c); the
|
|
3051
|
-
* analyzer takes the ticket's own blockers as an argument instead, and
|
|
3052
|
-
* `findCycle` closes a cycle via its path rather than the map.
|
|
3053
|
-
*
|
|
3054
|
-
* A provider-side cycle terminates the walk: an id already in `nodes` is
|
|
3055
|
-
* never re-queued, so every reachable node is fetched exactly once.
|
|
3247
|
+
* Walk unresolved branches in provider order. Listing facts and dependency
|
|
3248
|
+
* reads share a tick-local cache; traversal visits are local to this root.
|
|
3056
3249
|
*/
|
|
3057
3250
|
private async loadDependencyGraph(
|
|
3058
3251
|
ticket: Ticket,
|
|
3059
3252
|
nodes: Map<string, DependencyState>,
|
|
3060
|
-
|
|
3061
|
-
|
|
3253
|
+
opts: TickOpts = {},
|
|
3254
|
+
): Promise<boolean> {
|
|
3255
|
+
const visited = new Set<string>()
|
|
3256
|
+
let frontier = [...new Set(ticket.blockedBy ?? [])]
|
|
3062
3257
|
while (frontier.length > 0) {
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3258
|
+
if (this.outOfBudget(opts)) return false
|
|
3259
|
+
const uncached = frontier.filter((id) => !nodes.has(id))
|
|
3260
|
+
if (uncached.length > 0) {
|
|
3261
|
+
const states = await this.deps.tickets.dependencyStates(uncached)
|
|
3262
|
+
for (const state of states) nodes.set(state.id, state)
|
|
3263
|
+
}
|
|
3264
|
+
const next: string[] = []
|
|
3265
|
+
for (const id of frontier) {
|
|
3266
|
+
visited.add(id)
|
|
3267
|
+
const node = nodes.get(id)
|
|
3268
|
+
if (node?.exists && !node.resolved) next.push(...node.blockedBy)
|
|
3269
|
+
}
|
|
3270
|
+
frontier = [...new Set(next)].filter((id) => !visited.has(id))
|
|
3068
3271
|
}
|
|
3272
|
+
return true
|
|
3069
3273
|
}
|
|
3070
3274
|
|
|
3071
3275
|
/** Bounce (§6.3): back to Triage with a comment citing the standard and
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* What is held:
|
|
33
33
|
*
|
|
34
34
|
* - the bounded repository journal (`getRepoStateEvents`' subset), pruned on
|
|
35
|
-
* arrival by the same keep rule — durable
|
|
35
|
+
* arrival by the same keep rule — durable facts, latest-only measurements,
|
|
36
|
+
* and the tail (excluding latest-only types) from the latest
|
|
36
37
|
* `dispatcher.run-started` — which is exact for seq-ordered input;
|
|
37
38
|
* - for *work* builds (nonterminal, aborted, reclaim-pending, execution-open),
|
|
38
39
|
* the full event array. Settled terminal builds hold only the discovery
|
|
@@ -607,21 +607,25 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
607
607
|
})
|
|
608
608
|
expect(digests.get('dg-done')).toEqual({
|
|
609
609
|
slug: 'dg-done',
|
|
610
|
+
readinessChangeSeq: 2,
|
|
610
611
|
terminal: 'done',
|
|
611
612
|
observations: [{ seq: 3, ts: tsOf('dg-done', 3) }],
|
|
612
613
|
})
|
|
613
614
|
expect(digests.get('dg-cleaning')).toEqual({
|
|
614
615
|
slug: 'dg-cleaning',
|
|
616
|
+
readinessChangeSeq: 2,
|
|
615
617
|
terminal: 'aborted',
|
|
616
618
|
observations: [{ seq: 3, ts: tsOf('dg-cleaning', 3) }],
|
|
617
619
|
})
|
|
618
620
|
expect(digests.get('dg-abandoned')).toEqual({
|
|
619
621
|
slug: 'dg-abandoned',
|
|
622
|
+
readinessChangeSeq: 3,
|
|
620
623
|
terminal: 'done',
|
|
621
624
|
observations: [],
|
|
622
625
|
})
|
|
623
626
|
expect(digests.get('dg-overwrite')).toEqual({
|
|
624
627
|
slug: 'dg-overwrite',
|
|
628
|
+
readinessChangeSeq: 4,
|
|
625
629
|
terminal: 'done',
|
|
626
630
|
observations: [],
|
|
627
631
|
})
|
|
@@ -684,6 +688,7 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
684
688
|
digest = (await store.getRepoBuildDigests(repo)).get('dg-paths')
|
|
685
689
|
expect(digest).toEqual({
|
|
686
690
|
slug: 'dg-paths',
|
|
691
|
+
readinessChangeSeq: 6,
|
|
687
692
|
terminal: 'done',
|
|
688
693
|
merged: secondMergeTs,
|
|
689
694
|
observations: [
|
|
@@ -1176,6 +1181,48 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
1176
1181
|
]
|
|
1177
1182
|
}
|
|
1178
1183
|
|
|
1184
|
+
test('ticket-source health remains durable across newer runs', async () => {
|
|
1185
|
+
await withStore(factory, undefined, async (store) => {
|
|
1186
|
+
const repo = 'acme/source-health'
|
|
1187
|
+
await store.ensureRepo(repo)
|
|
1188
|
+
const condition = {
|
|
1189
|
+
source: 'fake',
|
|
1190
|
+
cause: 'rate-limited' as const,
|
|
1191
|
+
operation: 'ready-scan' as const,
|
|
1192
|
+
message: 'quota exhausted',
|
|
1193
|
+
resetAt: '2026-10-14T12:00:00.000Z',
|
|
1194
|
+
}
|
|
1195
|
+
await store.appendRepo(repo, {
|
|
1196
|
+
actor: DISPATCHER,
|
|
1197
|
+
type: 'dispatcher.ticket-source-degraded',
|
|
1198
|
+
payload: condition,
|
|
1199
|
+
})
|
|
1200
|
+
await store.appendRepo(repo, {
|
|
1201
|
+
actor: DISPATCHER,
|
|
1202
|
+
type: 'dispatcher.ticket-source-dependency-limited',
|
|
1203
|
+
payload: { source: 'fake' },
|
|
1204
|
+
})
|
|
1205
|
+
for (const write of noopInvocation('new-run')) await store.appendRepo(repo, write)
|
|
1206
|
+
const bounded = await store.getRepoStateEvents(repo)
|
|
1207
|
+
expect(bounded[0]?.type).toBe('dispatcher.ticket-source-degraded')
|
|
1208
|
+
expect(bounded[1]?.type).toBe('dispatcher.ticket-source-dependency-limited')
|
|
1209
|
+
await store.appendRepo(repo, {
|
|
1210
|
+
actor: DISPATCHER,
|
|
1211
|
+
type: 'dispatcher.ticket-source-recovered',
|
|
1212
|
+
payload: { source: 'fake', cause: 'rate-limited', operation: 'dependency-check' },
|
|
1213
|
+
})
|
|
1214
|
+
expect(
|
|
1215
|
+
(await store.getRepoStateEvents(repo)).filter((event) =>
|
|
1216
|
+
event.type.startsWith('dispatcher.ticket-source-'),
|
|
1217
|
+
),
|
|
1218
|
+
).toEqual(
|
|
1219
|
+
(await store.getRepoEvents(repo)).filter((event) =>
|
|
1220
|
+
event.type.startsWith('dispatcher.ticket-source-'),
|
|
1221
|
+
),
|
|
1222
|
+
)
|
|
1223
|
+
})
|
|
1224
|
+
})
|
|
1225
|
+
|
|
1179
1226
|
function noopTickCounters() {
|
|
1180
1227
|
return {
|
|
1181
1228
|
merged: 0,
|
|
@@ -1202,6 +1249,68 @@ export function describeBuildStoreContract(name: string, factory: BuildStoreFact
|
|
|
1202
1249
|
}
|
|
1203
1250
|
}
|
|
1204
1251
|
|
|
1252
|
+
test('latest-only scans stay bounded across scans, runs and repositories', async () => {
|
|
1253
|
+
await withStore(factory, undefined, async (store) => {
|
|
1254
|
+
for (const repo of ['acme/scans', 'acme/foreign-scans']) await store.ensureRepo(repo)
|
|
1255
|
+
const appendScan = (repo: string, queued: number) =>
|
|
1256
|
+
store.appendRepo(repo, {
|
|
1257
|
+
actor: DISPATCHER,
|
|
1258
|
+
type: 'dispatcher.ticket-scan-completed',
|
|
1259
|
+
payload: {
|
|
1260
|
+
startedAt: '2026-10-14T00:00:00.000Z',
|
|
1261
|
+
mode: 'admission',
|
|
1262
|
+
readinessFingerprint: 'r',
|
|
1263
|
+
criteriaFingerprint: 'c',
|
|
1264
|
+
queued,
|
|
1265
|
+
invalidTickets: 0,
|
|
1266
|
+
ticketDiagnostics: [],
|
|
1267
|
+
dependencyBlocked: 0,
|
|
1268
|
+
dependencyDiagnostics: [],
|
|
1269
|
+
creationWithheld: 0,
|
|
1270
|
+
creationDiagnostics: [],
|
|
1271
|
+
},
|
|
1272
|
+
})
|
|
1273
|
+
await store.appendRepo('acme/scans', {
|
|
1274
|
+
actor: humanActor('op'),
|
|
1275
|
+
type: 'dispatcher.intake-set',
|
|
1276
|
+
payload: { enabled: true },
|
|
1277
|
+
})
|
|
1278
|
+
const check = async (expected: number, rows: number) => {
|
|
1279
|
+
const all = await store.getRepoEvents('acme/scans')
|
|
1280
|
+
const bounded = await store.getRepoStateEvents('acme/scans')
|
|
1281
|
+
expect(bounded).toEqual(projectRepositoryStateEvents(all))
|
|
1282
|
+
expect(bounded).toHaveLength(rows)
|
|
1283
|
+
const scans = bounded.filter((e) => e.type === 'dispatcher.ticket-scan-completed')
|
|
1284
|
+
expect(scans).toHaveLength(1)
|
|
1285
|
+
expect(scans[0]!.payload).toMatchObject({ queued: expected })
|
|
1286
|
+
expect(reduceDispatchSettings(bounded)).toEqual(reduceDispatchSettings(all))
|
|
1287
|
+
}
|
|
1288
|
+
for (let i = 0; i < 200; i++) await appendScan('acme/scans', i)
|
|
1289
|
+
await check(199, 2)
|
|
1290
|
+
for (let i = 200; i < 400; i++) {
|
|
1291
|
+
await appendScan('acme/scans', i)
|
|
1292
|
+
await appendScan('acme/foreign-scans', 999)
|
|
1293
|
+
}
|
|
1294
|
+
await check(399, 2)
|
|
1295
|
+
for (let run = 0; run < 20; run++) {
|
|
1296
|
+
for (const write of noopInvocation(`scan-run-${run}`))
|
|
1297
|
+
await store.appendRepo('acme/scans', write)
|
|
1298
|
+
await appendScan('acme/scans', run)
|
|
1299
|
+
}
|
|
1300
|
+
await check(19, 6)
|
|
1301
|
+
for (const write of noopInvocation('scan-final'))
|
|
1302
|
+
await store.appendRepo('acme/scans', write)
|
|
1303
|
+
await check(19, 6) // latest scan is older than the run anchor
|
|
1304
|
+
for (let i = 0; i < 100; i++) await appendScan('acme/scans', i)
|
|
1305
|
+
await check(99, 6)
|
|
1306
|
+
expect(
|
|
1307
|
+
(await store.getRepoEvents('acme/scans')).filter(
|
|
1308
|
+
(e) => e.type === 'dispatcher.ticket-scan-completed',
|
|
1309
|
+
),
|
|
1310
|
+
).toHaveLength(520)
|
|
1311
|
+
})
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1205
1314
|
test('unknown repo rejects like getRepoEvents', async () => {
|
|
1206
1315
|
await withStore(factory, undefined, async (store) => {
|
|
1207
1316
|
await expect(store.getRepoStateEvents('acme/never-seen')).rejects.toThrow(/unknown repo/)
|