@defrex/autobuild 0.12.0 → 0.13.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 +119 -15
- package/docs/architecture.md +59 -3
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +7 -0
- package/docs/remote-store-protocol.md +10 -0
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +3 -3
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +156 -3
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +20 -2
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/self-update.ts +5 -1
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +164 -34
- package/packages/core/src/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/reducer-registry.ts +2 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +5 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +130 -20
- package/packages/core/src/plugin-sdk/index.ts +6 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- package/packages/core/src/processes/dispatcher.ts +110 -32
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-runner.ts +24 -4
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/skills/guide/SKILL.md +91 -11
- package/skills/guide/references/operator-api.md +7 -0
- package/skills/guide/references/remote-store-protocol.md +10 -0
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +2 -2
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { ensureExecutionProgress } from './execution-no-progress'
|
|
2
|
+
import { ticketIdentity } from '../ports/tickets/identity'
|
|
3
|
+
import { ticketLifecycle } from '../ports/tickets/lifecycle'
|
|
1
4
|
import { ensureDispatchBudget } from './dispatch-failure-budget'
|
|
2
5
|
import {
|
|
3
6
|
reduceTicketSourceHealth,
|
|
@@ -62,6 +65,8 @@ import {
|
|
|
62
65
|
} from '../kernel/auto-merge-default'
|
|
63
66
|
import { reduceDispatchSettings } from '../kernel/dispatch-settings'
|
|
64
67
|
import { decideNextFromFacts } from '../kernel/engine'
|
|
68
|
+
import { composeBuildConfig } from '../config/live'
|
|
69
|
+
import { BUILD_EFFECTIVE_CONFIG_ARTIFACT, parseEffectiveBuildConfig } from './build-execution-state'
|
|
65
70
|
import {
|
|
66
71
|
DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
|
|
67
72
|
decideHarvestControl,
|
|
@@ -83,7 +88,7 @@ import type {
|
|
|
83
88
|
import { TicketSourceRateLimitError } from '../ports/types'
|
|
84
89
|
import type { Exec } from '../ports/workspace/git-worktree'
|
|
85
90
|
import type { ArtifactMeta, BuildDigest, BuildRecord, BuildStore, Clock } from '../store/types'
|
|
86
|
-
import {
|
|
91
|
+
import { preparePinnedAssets } from '../store/ticket-assets'
|
|
87
92
|
import { resolveRepoOrigin } from '../cli/repo-state'
|
|
88
93
|
import { specConformance } from '../spec-standard'
|
|
89
94
|
export { specConformance, type SpecConformance } from '../spec-standard'
|
|
@@ -148,16 +153,11 @@ export function readyCriteria(config: Config): {
|
|
|
148
153
|
* readiness is: the file tracker's grooming area IS the `triage/` directory,
|
|
149
154
|
* while a Linear team only has a "Triage" workflow state when the team's
|
|
150
155
|
* triage feature is enabled — Backlog is the state every Linear team has.
|
|
151
|
-
*
|
|
152
|
-
*
|
|
156
|
+
* Hosted projects default to lowercase triage; plugin sources use the neutral
|
|
157
|
+
* `Triage` fallback unless configured.
|
|
153
158
|
*/
|
|
154
159
|
export function defaultTriageState(config: Config): string {
|
|
155
|
-
return (
|
|
156
|
-
config.tickets.triageState ??
|
|
157
|
-
(config.tickets.source === 'linear' || config.tickets.source === 'hosted'
|
|
158
|
-
? 'Backlog'
|
|
159
|
-
: 'Triage')
|
|
160
|
-
)
|
|
160
|
+
return ticketLifecycle(config.tickets).triage
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
@@ -547,6 +547,8 @@ export interface DispatcherDeps {
|
|
|
547
547
|
retiredWorkspaces?: readonly WorkspaceProvider[]
|
|
548
548
|
forge: Forge
|
|
549
549
|
config: Config
|
|
550
|
+
/** Configuration-only publication for incompatible pins; never launches a runner. */
|
|
551
|
+
repairBuildConfig?: (slug: string) => Promise<void>
|
|
550
552
|
/** Current process snapshot, captured once at the beginning of each tick.
|
|
551
553
|
* Omission preserves the cron/static-config API. */
|
|
552
554
|
getConfig?: () => Config
|
|
@@ -647,6 +649,38 @@ export function heldByRepositoryPause(state: BuildState, paused: boolean): boole
|
|
|
647
649
|
}
|
|
648
650
|
|
|
649
651
|
export class Dispatcher {
|
|
652
|
+
/** Read at each decision boundary: artifact revisions are independent of events. */
|
|
653
|
+
private async decideBuild(
|
|
654
|
+
slug: string,
|
|
655
|
+
state: Parameters<typeof decideNextFromFacts>[0],
|
|
656
|
+
log: Parameters<typeof decideNextFromFacts>[1],
|
|
657
|
+
report: TickReport,
|
|
658
|
+
): Promise<ReturnType<typeof decideNextFromFacts> | undefined> {
|
|
659
|
+
let revision: number | undefined
|
|
660
|
+
try {
|
|
661
|
+
let artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
662
|
+
if (artifact === null) return decideNextFromFacts(state, log, this.deps.config)
|
|
663
|
+
revision = artifact.meta.revision
|
|
664
|
+
let pinned: Config
|
|
665
|
+
try {
|
|
666
|
+
pinned = parseEffectiveBuildConfig(artifact)
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (this.deps.repairBuildConfig === undefined) throw error
|
|
669
|
+
await this.deps.repairBuildConfig(slug)
|
|
670
|
+
artifact = await this.store.getArtifact(slug, BUILD_EFFECTIVE_CONFIG_ARTIFACT)
|
|
671
|
+
if (artifact === null) throw new Error('configuration repair deposited no pipeline')
|
|
672
|
+
revision = artifact.meta.revision
|
|
673
|
+
pinned = parseEffectiveBuildConfig(artifact)
|
|
674
|
+
}
|
|
675
|
+
return decideNextFromFacts(state, log, composeBuildConfig(pinned, this.deps.config))
|
|
676
|
+
} catch (error) {
|
|
677
|
+
report.blockedDiagnostics.push(
|
|
678
|
+
`build ${slug} pipeline${revision === undefined ? '' : ` revision ${revision}`} unavailable: pipeline decisions withheld; next tick retries. Restore readable build-branch configuration or use a compatible dispatcher version. ${error instanceof Error ? error.message : String(error)}`,
|
|
679
|
+
)
|
|
680
|
+
return undefined
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
650
684
|
/** Memoized normalized origin of the served repository (one git call per
|
|
651
685
|
* dispatcher), recorded on created builds so ambient reads can accept a
|
|
652
686
|
* differently located checkout of the same repository by origin equality.
|
|
@@ -670,7 +704,7 @@ export class Dispatcher {
|
|
|
670
704
|
return (
|
|
671
705
|
this.deps.opts?.doneState ??
|
|
672
706
|
(this.deps.config.tickets.source === 'hosted'
|
|
673
|
-
? this.deps.config.tickets.
|
|
707
|
+
? ticketLifecycle(this.deps.config.tickets).done
|
|
674
708
|
: undefined) ??
|
|
675
709
|
'Done'
|
|
676
710
|
)
|
|
@@ -988,6 +1022,23 @@ export class Dispatcher {
|
|
|
988
1022
|
}
|
|
989
1023
|
}
|
|
990
1024
|
|
|
1025
|
+
private async activeTicketIdentities(refs: TicketRef[], listed: Ticket[]): Promise<Set<string>> {
|
|
1026
|
+
const key = (ref: TicketRef) => `${ref.source}:${ref.id}`
|
|
1027
|
+
const ids = new Set(refs.map(key))
|
|
1028
|
+
if (this.deps.tickets.name !== 'hosted' || listed.length === 0) return ids
|
|
1029
|
+
const listedIds = new Set(listed.map((ticket) => key(ticket.ref)))
|
|
1030
|
+
const identity = ticketIdentity(this.deps.tickets)
|
|
1031
|
+
try {
|
|
1032
|
+
for (const ref of refs) {
|
|
1033
|
+
if (ref.source === 'hosted' && !listedIds.has(key(ref))) ids.add(await identity(ref))
|
|
1034
|
+
}
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
await this.sourceFailure(error, 'ready-scan')
|
|
1037
|
+
throw error
|
|
1038
|
+
}
|
|
1039
|
+
return ids
|
|
1040
|
+
}
|
|
1041
|
+
|
|
991
1042
|
/** Observation-only ready listing for Store-backed frontends while intake is
|
|
992
1043
|
* disabled. It shares the active-ticket exclusion with dispatch but performs
|
|
993
1044
|
* no dependency fetch, claim, bounce, build, workspace, or launch side
|
|
@@ -999,20 +1050,21 @@ export class Dispatcher {
|
|
|
999
1050
|
}> {
|
|
1000
1051
|
this.deps.config = this.deps.getConfig?.() ?? this.deps.config
|
|
1001
1052
|
await this.store.refresh()
|
|
1002
|
-
const activeTicketIds = new Set<string>()
|
|
1003
|
-
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
1004
|
-
if (record.ticket !== undefined && digest.terminal === undefined) {
|
|
1005
|
-
activeTicketIds.add(record.ticket.id)
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
1053
|
const report = emptyTickReport()
|
|
1009
1054
|
this.restoreScan(report)
|
|
1010
1055
|
const checkpoint = await this.scanCheckpoint('observation')
|
|
1011
1056
|
if (checkpoint.due) {
|
|
1012
1057
|
await this.store.refreshJournal()
|
|
1013
1058
|
const listing = await this.listReady()
|
|
1059
|
+
const refs: TicketRef[] = []
|
|
1060
|
+
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
1061
|
+
if (record.ticket && digest.terminal === undefined) refs.push(record.ticket)
|
|
1062
|
+
}
|
|
1063
|
+
const activeTicketIds = await this.activeTicketIdentities(refs, listing.tickets)
|
|
1014
1064
|
await this.store.refreshJournal()
|
|
1015
|
-
report.queued = listing.tickets.filter(
|
|
1065
|
+
report.queued = listing.tickets.filter(
|
|
1066
|
+
(ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
|
|
1067
|
+
).length
|
|
1016
1068
|
report.invalidTickets = listing.diagnostics.length
|
|
1017
1069
|
report.ticketDiagnostics = [...listing.diagnostics]
|
|
1018
1070
|
report.dependencyBlocked = 0
|
|
@@ -1173,9 +1225,24 @@ export class Dispatcher {
|
|
|
1173
1225
|
}
|
|
1174
1226
|
}
|
|
1175
1227
|
|
|
1176
|
-
private async launch(
|
|
1228
|
+
private async launch(
|
|
1229
|
+
slug: string,
|
|
1230
|
+
launched: Set<string>,
|
|
1231
|
+
report: TickReport = emptyTickReport(),
|
|
1232
|
+
): Promise<LaunchRunnerResult> {
|
|
1177
1233
|
if (launched.has(slug)) return 'already-active'
|
|
1178
1234
|
launched.add(slug)
|
|
1235
|
+
if (
|
|
1236
|
+
!(await ensureExecutionProgress(
|
|
1237
|
+
this.store,
|
|
1238
|
+
this.deps.ids,
|
|
1239
|
+
slug,
|
|
1240
|
+
this.deps.config,
|
|
1241
|
+
(state, log) => this.decideBuild(slug, state, log, report),
|
|
1242
|
+
))
|
|
1243
|
+
) {
|
|
1244
|
+
return 'already-active'
|
|
1245
|
+
}
|
|
1179
1246
|
return this.deps.launchRunner(slug)
|
|
1180
1247
|
}
|
|
1181
1248
|
|
|
@@ -2089,7 +2156,7 @@ export class Dispatcher {
|
|
|
2089
2156
|
}
|
|
2090
2157
|
// The dispatcher never runs agents (§15.7): re-attach a build-runner,
|
|
2091
2158
|
// which executes the reconcile epilogue phase.
|
|
2092
|
-
await this.launch(record.slug, launched)
|
|
2159
|
+
await this.launch(record.slug, launched, report)
|
|
2093
2160
|
report.conflicted += 1
|
|
2094
2161
|
return
|
|
2095
2162
|
}
|
|
@@ -2123,12 +2190,12 @@ export class Dispatcher {
|
|
|
2123
2190
|
const latest = await store.buildFacts(record.slug)
|
|
2124
2191
|
const latestState = latest.state
|
|
2125
2192
|
const latestIntent = pendingAutoMerge(latestState)
|
|
2126
|
-
const decision =
|
|
2193
|
+
const decision = await this.decideBuild(record.slug, latest.state, latest.log, report)
|
|
2127
2194
|
if (
|
|
2128
2195
|
latestState.pr?.number === pr.number &&
|
|
2129
2196
|
latestIntent?.enabled === true &&
|
|
2130
2197
|
latestIntent.commandSeq === autoMerge.commandSeq &&
|
|
2131
|
-
decision
|
|
2198
|
+
decision?.kind === 'wait' &&
|
|
2132
2199
|
decision.reason === 'awaiting-pr'
|
|
2133
2200
|
) {
|
|
2134
2201
|
await forge.squashMerge(workspacePath, pr.number, result.headSha)
|
|
@@ -2584,7 +2651,14 @@ export class Dispatcher {
|
|
|
2584
2651
|
// can only sample the ticket's current assets (SPEC §6.3).
|
|
2585
2652
|
const assets =
|
|
2586
2653
|
seed?.assets ??
|
|
2587
|
-
(await
|
|
2654
|
+
(await preparePinnedAssets(this.deps.tickets, store, record.repo, record.ticket.id).catch(
|
|
2655
|
+
(error) => {
|
|
2656
|
+
// Preserve legacy recovery only without native assets. Native preparation
|
|
2657
|
+
// must never publish an empty pin after losing the source bytes.
|
|
2658
|
+
if (this.deps.tickets.assets || this.deps.tickets.name === 'hosted') throw error
|
|
2659
|
+
return []
|
|
2660
|
+
},
|
|
2661
|
+
))
|
|
2588
2662
|
await store.appendIfCurrent(record.slug, 0, {
|
|
2589
2663
|
actor: DISPATCHER,
|
|
2590
2664
|
type: 'build.created',
|
|
@@ -2796,7 +2870,7 @@ export class Dispatcher {
|
|
|
2796
2870
|
return 'parked'
|
|
2797
2871
|
}
|
|
2798
2872
|
if (launched === undefined) await this.deps.launchRunner(record.slug)
|
|
2799
|
-
else await this.launch(record.slug, launched)
|
|
2873
|
+
else await this.launch(record.slug, launched, report)
|
|
2800
2874
|
if (authored && report !== undefined) report.authored += 1
|
|
2801
2875
|
return 'completed'
|
|
2802
2876
|
} catch (error) {
|
|
@@ -2873,7 +2947,6 @@ export class Dispatcher {
|
|
|
2873
2947
|
opts: TickOpts = {},
|
|
2874
2948
|
): Promise<void> {
|
|
2875
2949
|
const store = this.store
|
|
2876
|
-
const { config } = this.deps
|
|
2877
2950
|
for (const record of await this.nonterminalBuilds()) {
|
|
2878
2951
|
if (launched.has(record.slug)) continue
|
|
2879
2952
|
// Budget gate: resume launches runners (transport-backed work).
|
|
@@ -2896,7 +2969,8 @@ export class Dispatcher {
|
|
|
2896
2969
|
!(await this.dispatchBudget(record.slug))
|
|
2897
2970
|
)
|
|
2898
2971
|
continue
|
|
2899
|
-
const decision =
|
|
2972
|
+
const decision = await this.decideBuild(record.slug, state, facts.log, report)
|
|
2973
|
+
if (decision === undefined) continue
|
|
2900
2974
|
if (decision.kind === 'wait') {
|
|
2901
2975
|
if (decision.reason === 'blocked' && state.openEscalations.length > 0) {
|
|
2902
2976
|
report.blockedDiagnostics.push(
|
|
@@ -2910,7 +2984,7 @@ export class Dispatcher {
|
|
|
2910
2984
|
// Disposable remote environments must pass through the stale-lease
|
|
2911
2985
|
// fencing sweep below; startup must never attach directly to an old VM.
|
|
2912
2986
|
if (this.deps.workspaces.recovery !== undefined) continue
|
|
2913
|
-
const result = await this.launch(record.slug, launched)
|
|
2987
|
+
const result = await this.launch(record.slug, launched, report)
|
|
2914
2988
|
if (result === 'scheduled') report.resumed += 1
|
|
2915
2989
|
}
|
|
2916
2990
|
}
|
|
@@ -2972,7 +3046,8 @@ export class Dispatcher {
|
|
|
2972
3046
|
!(await this.dispatchBudget(record.slug))
|
|
2973
3047
|
)
|
|
2974
3048
|
continue
|
|
2975
|
-
const decision =
|
|
3049
|
+
const decision = await this.decideBuild(record.slug, state, facts.log, report)
|
|
3050
|
+
if (decision === undefined) continue
|
|
2976
3051
|
if (state.status === 'done' || state.status === 'aborted') continue
|
|
2977
3052
|
// Raw history, read at most once and only by a branch that acts on it.
|
|
2978
3053
|
let loaded: AbEvent[] | undefined
|
|
@@ -3073,7 +3148,7 @@ export class Dispatcher {
|
|
|
3073
3148
|
// boundaries and the launch; this tick performs neither.
|
|
3074
3149
|
continue
|
|
3075
3150
|
}
|
|
3076
|
-
const result = await this.launch(record.slug, launched)
|
|
3151
|
+
const result = await this.launch(record.slug, launched, report)
|
|
3077
3152
|
if (result === 'scheduled') report.swept += 1
|
|
3078
3153
|
}
|
|
3079
3154
|
}
|
|
@@ -3101,11 +3176,11 @@ export class Dispatcher {
|
|
|
3101
3176
|
// pending work are live, only waiting on a human. Capacity is per repo
|
|
3102
3177
|
// (§16.1) — another repo's builds never consume this repo's slots.
|
|
3103
3178
|
let active = 0
|
|
3104
|
-
const
|
|
3179
|
+
const activeRefs: TicketRef[] = []
|
|
3105
3180
|
for (const { record, digest } of await this.repoBuildDigests()) {
|
|
3106
3181
|
if (digest.terminal === undefined) {
|
|
3107
3182
|
active += 1
|
|
3108
|
-
if (record.ticket)
|
|
3183
|
+
if (record.ticket) activeRefs.push(record.ticket)
|
|
3109
3184
|
}
|
|
3110
3185
|
}
|
|
3111
3186
|
let capacity = config.capacity - active
|
|
@@ -3117,6 +3192,7 @@ export class Dispatcher {
|
|
|
3117
3192
|
await this.store.refreshJournal()
|
|
3118
3193
|
const journalBeforeReady = this.store.recordedJournal()
|
|
3119
3194
|
const listing = await this.listReady()
|
|
3195
|
+
const activeTicketIds = await this.activeTicketIdentities(activeRefs, listing.tickets)
|
|
3120
3196
|
await this.store.refreshJournal()
|
|
3121
3197
|
const journalAfterReady = this.store.recordedJournal()
|
|
3122
3198
|
if (reduceTicketSourceHealth(journalAfterReady)?.cause === 'ready-scan-failed') {
|
|
@@ -3128,7 +3204,9 @@ export class Dispatcher {
|
|
|
3128
3204
|
creation,
|
|
3129
3205
|
]),
|
|
3130
3206
|
)
|
|
3131
|
-
const ready = listing.tickets.filter(
|
|
3207
|
+
const ready = listing.tickets.filter(
|
|
3208
|
+
(ticket) => !activeTicketIds.has(`${ticket.ref.source}:${ticket.ref.id}`),
|
|
3209
|
+
)
|
|
3132
3210
|
report.invalidTickets += listing.diagnostics.length
|
|
3133
3211
|
report.ticketDiagnostics.push(...listing.diagnostics)
|
|
3134
3212
|
report.queued = ready.length
|
|
@@ -3225,7 +3303,7 @@ export class Dispatcher {
|
|
|
3225
3303
|
|
|
3226
3304
|
// Freeze the ticket's assets at the claim (SPEC §6.3): sampled before the
|
|
3227
3305
|
// spec-authoring interval so edits made during it are never pinned.
|
|
3228
|
-
const assets = await
|
|
3306
|
+
const assets = await preparePinnedAssets(tickets, store, this.deps.repo, ticket.ref.id)
|
|
3229
3307
|
|
|
3230
3308
|
// Quality gate (§6.3): import a conforming ticket body as the spec;
|
|
3231
3309
|
// author one for thin-but-groomed tickets; otherwise bounce.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { AbEvent } from '../events/catalog'
|
|
2
|
+
import { DISPATCHER } from '../events/envelope'
|
|
3
|
+
import type { EventType } from '../events/payloads'
|
|
4
|
+
import type { IdSource } from '../ids'
|
|
5
|
+
import type { Config } from '../config/schema'
|
|
6
|
+
import { type Decision, decideNextFromFacts } from '../kernel/engine'
|
|
7
|
+
import { defineReducer } from '../kernel/incremental'
|
|
8
|
+
import type { RepoViewStore } from './repo-view'
|
|
9
|
+
|
|
10
|
+
/** Every new event must explicitly choose whether it advances build work. */
|
|
11
|
+
export const executionProgress = {
|
|
12
|
+
'build.created': 'progress',
|
|
13
|
+
'build.completed': 'progress',
|
|
14
|
+
'runner.attached': 'bookkeeping',
|
|
15
|
+
'runner.setup-failed': 'failure',
|
|
16
|
+
'workspace.provisioned': 'bookkeeping',
|
|
17
|
+
'workspace.provision-started': 'bookkeeping',
|
|
18
|
+
'workspace.released': 'bookkeeping',
|
|
19
|
+
'execution.started': 'bookkeeping',
|
|
20
|
+
'execution.ended': 'bookkeeping',
|
|
21
|
+
'infrastructure.failed': 'failure',
|
|
22
|
+
'infrastructure.cleanup-attempted': 'bookkeeping',
|
|
23
|
+
'pr-summary.requested': 'progress',
|
|
24
|
+
'pr-summary.attempted': 'progress',
|
|
25
|
+
'pr-summary.result': 'progress',
|
|
26
|
+
'pr-summary.settled': 'progress',
|
|
27
|
+
'finalize.step-started': 'progress',
|
|
28
|
+
'publication.requested': 'progress',
|
|
29
|
+
'publication.lost': 'progress',
|
|
30
|
+
'abort.remote-branch-deleted': 'progress',
|
|
31
|
+
'abort.local-branch-deleted': 'progress',
|
|
32
|
+
'abort.ticket-returned': 'progress',
|
|
33
|
+
'dispatch.failed': 'failure',
|
|
34
|
+
'dispatch.comment-posted': 'bookkeeping',
|
|
35
|
+
'build.pause-requested': 'progress',
|
|
36
|
+
'build.resume-requested': 'progress',
|
|
37
|
+
'build.abort-requested': 'progress',
|
|
38
|
+
'build.discard-requested': 'progress',
|
|
39
|
+
'build.auto-merge-requested': 'progress',
|
|
40
|
+
'build.auto-merge-cancelled': 'progress',
|
|
41
|
+
'build.auto-merge-default-observed': 'bookkeeping',
|
|
42
|
+
'build.paused': 'progress',
|
|
43
|
+
'build.resumed': 'progress',
|
|
44
|
+
'build.aborted': 'progress',
|
|
45
|
+
'spec.imported': 'progress',
|
|
46
|
+
'spec.authored': 'progress',
|
|
47
|
+
'spec.revised': 'progress',
|
|
48
|
+
'session.started': 'progress',
|
|
49
|
+
'session.ended': 'progress',
|
|
50
|
+
'plan.started': 'progress',
|
|
51
|
+
'plan.completed': 'progress',
|
|
52
|
+
'plan-review.started': 'progress',
|
|
53
|
+
'plan-review.verdict': 'progress',
|
|
54
|
+
'implement.started': 'progress',
|
|
55
|
+
'implement.completed': 'progress',
|
|
56
|
+
'code-review.started': 'progress',
|
|
57
|
+
'code-review.verdict': 'progress',
|
|
58
|
+
'verify.reconcile-policy-captured': 'progress',
|
|
59
|
+
'verify.started': 'progress',
|
|
60
|
+
'verify.completed': 'progress',
|
|
61
|
+
'finalize.started': 'progress',
|
|
62
|
+
'finalize.completed': 'progress',
|
|
63
|
+
'finalize.step-completed': 'progress',
|
|
64
|
+
'pr-attachment.designated': 'progress',
|
|
65
|
+
'pr-attachment.hosted': 'progress',
|
|
66
|
+
'pr-attachment.reclaimed': 'progress',
|
|
67
|
+
'pr-attachment.reclaim-failed': 'progress',
|
|
68
|
+
'pr.auto-merge-enabled': 'progress',
|
|
69
|
+
'pr.auto-merge-disabled': 'progress',
|
|
70
|
+
'pr.merged': 'progress',
|
|
71
|
+
'pr.closed': 'progress',
|
|
72
|
+
'pr.conflicted': 'progress',
|
|
73
|
+
'reconcile.progress-checked': 'progress',
|
|
74
|
+
'reconcile.started': 'progress',
|
|
75
|
+
'reconcile.completed': 'progress',
|
|
76
|
+
'observation.recorded': 'progress',
|
|
77
|
+
'escalation.raised': 'progress',
|
|
78
|
+
'escalation.answered': 'progress',
|
|
79
|
+
'phase.failed': 'failure',
|
|
80
|
+
} satisfies Record<EventType, 'bookkeeping' | 'failure' | 'progress'>
|
|
81
|
+
|
|
82
|
+
interface Accumulator {
|
|
83
|
+
active: { instance: string; seq: number; eligible: boolean } | null
|
|
84
|
+
count: number
|
|
85
|
+
open: string[]
|
|
86
|
+
latest: { instance: string; seq: number } | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const executionNoProgressReducer = defineReducer({
|
|
90
|
+
version: 1,
|
|
91
|
+
initial: (): Accumulator => ({ active: null, count: 0, open: [], latest: null }),
|
|
92
|
+
fold(acc, events: readonly AbEvent[]) {
|
|
93
|
+
for (const event of events) {
|
|
94
|
+
if (event.type === 'execution.started') {
|
|
95
|
+
// An abandoned start breaks the consecutive completed-execution streak.
|
|
96
|
+
if (acc.active !== null) acc.count = 0
|
|
97
|
+
acc.active = { instance: event.payload.instance, seq: event.seq, eligible: true }
|
|
98
|
+
} else if (event.type === 'execution.ended') {
|
|
99
|
+
if (acc.active?.instance !== event.payload.instance) continue
|
|
100
|
+
const clean = event.payload.outcome === 'completed' && event.payload.exitCode === 0
|
|
101
|
+
acc.count = clean && acc.active.eligible ? acc.count + 1 : 0
|
|
102
|
+
acc.latest = { instance: event.payload.instance, seq: event.seq }
|
|
103
|
+
acc.active = null
|
|
104
|
+
} else if (
|
|
105
|
+
event.type === 'escalation.raised' &&
|
|
106
|
+
event.payload.source === 'policy' &&
|
|
107
|
+
event.payload.policyCause === 'execution-no-progress'
|
|
108
|
+
) {
|
|
109
|
+
if (!acc.open.includes(event.payload.id)) acc.open.push(event.payload.id)
|
|
110
|
+
} else if (event.type === 'escalation.answered') {
|
|
111
|
+
if (!acc.open.includes(event.payload.id)) continue
|
|
112
|
+
acc.open = acc.open.filter((id) => id !== event.payload.id)
|
|
113
|
+
acc.count = 0
|
|
114
|
+
acc.active = null
|
|
115
|
+
} else if (executionProgress[event.type] !== 'bookkeeping') {
|
|
116
|
+
acc.count = 0
|
|
117
|
+
if (acc.active !== null) acc.active.eligible = false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
finish: (acc) => ({ ...acc, open: [...acc.open] }),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
/** Describe routing without copying commands or phase feedback into the question. */
|
|
125
|
+
function expectedAction(decision: Decision): string {
|
|
126
|
+
if (decision.kind === 'run-phase') return `${decision.kind} ${decision.phase}@${decision.round}`
|
|
127
|
+
if ('step' in decision)
|
|
128
|
+
return `${decision.kind} ${decision.step} (attempt ${'attempt' in decision ? decision.attempt : 1})`
|
|
129
|
+
if (decision.kind === 'raise-escalation') return `${decision.kind} ${decision.phase}`
|
|
130
|
+
return decision.kind
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Repair the third-completion/raise crash gap without provisioning execution four. */
|
|
134
|
+
export async function ensureExecutionProgress(
|
|
135
|
+
store: Pick<RepoViewStore, 'buildFacts' | 'appendIfCurrent' | 'refreshBuild'>,
|
|
136
|
+
ids: IdSource,
|
|
137
|
+
slug: string,
|
|
138
|
+
config: Config,
|
|
139
|
+
decide?: (
|
|
140
|
+
state: Parameters<typeof decideNextFromFacts>[0],
|
|
141
|
+
log: Parameters<typeof decideNextFromFacts>[1],
|
|
142
|
+
) => Promise<Decision | undefined>,
|
|
143
|
+
): Promise<boolean> {
|
|
144
|
+
for (;;) {
|
|
145
|
+
await store.refreshBuild(slug)
|
|
146
|
+
const facts = await store.buildFacts(slug)
|
|
147
|
+
const decision = decide
|
|
148
|
+
? await decide(facts.state, facts.log)
|
|
149
|
+
: decideNextFromFacts(facts.state, facts.log, config)
|
|
150
|
+
if (decision === undefined) return false
|
|
151
|
+
// Commands must still be acknowledged even while this guard is parked.
|
|
152
|
+
if (decision.kind === 'acknowledge') return true
|
|
153
|
+
const streak = facts.executionNoProgress
|
|
154
|
+
if (streak.open.length > 0) return false
|
|
155
|
+
// Callers own wait routing, including publication-recovery launches. A
|
|
156
|
+
// legitimate wait is not dispatcher/runner disagreement.
|
|
157
|
+
if (decision.kind === 'wait') return true
|
|
158
|
+
if (streak.count < 3) return true
|
|
159
|
+
const raised = await store.appendIfCurrent(slug, facts.state.lastSeq, {
|
|
160
|
+
actor: DISPATCHER,
|
|
161
|
+
type: 'escalation.raised',
|
|
162
|
+
payload: {
|
|
163
|
+
id: ids('esc'),
|
|
164
|
+
phase: 'setup',
|
|
165
|
+
source: 'policy',
|
|
166
|
+
policyCause: 'execution-no-progress',
|
|
167
|
+
question: `${streak.count} consecutive executions ended normally without build progress. Dispatcher expected ${expectedAction(decision)}. Inspect the dispatcher/runner disagreement, then answer with guidance or retry.`,
|
|
168
|
+
refs:
|
|
169
|
+
streak.latest === null
|
|
170
|
+
? []
|
|
171
|
+
: [`execution:${streak.latest.instance}`, `seq:${streak.latest.seq}`],
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
if (raised !== null) return false
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ticketGrantRequest } from '../ports/tickets/grants'
|
|
1
2
|
/**
|
|
2
3
|
* Crash-safe repository-scoped harvest workflow: scan -> synthesize <-> review
|
|
3
4
|
* -> file. It reuses `converge` for adversarial review semantics while every
|
|
@@ -56,6 +57,20 @@ export async function runHarvestChild(
|
|
|
56
57
|
workspacePath: string = process.cwd(),
|
|
57
58
|
): Promise<void> {
|
|
58
59
|
const fullStore: BuildStore = openStore(input.storeRef, env.AB_TOKEN)
|
|
60
|
+
const ticketAuthority =
|
|
61
|
+
env.AB_TICKET_URL && env.AB_TICKET_TOKEN
|
|
62
|
+
? { url: env.AB_TICKET_URL, token: env.AB_TICKET_TOKEN }
|
|
63
|
+
: undefined
|
|
64
|
+
const renewTickets = ticketAuthority
|
|
65
|
+
? setInterval(() => {
|
|
66
|
+
void ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
|
|
67
|
+
action: 'renew',
|
|
68
|
+
repo: input.repo,
|
|
69
|
+
}).catch(() => {
|
|
70
|
+
// Expiry denies subsequent ticket operations; never regain project authority.
|
|
71
|
+
})
|
|
72
|
+
}, 60_000)
|
|
73
|
+
: undefined
|
|
59
74
|
try {
|
|
60
75
|
const config = await loadConfig(join(workspacePath, 'autobuild.toml'))
|
|
61
76
|
// Full build-child composition parity, in the same order: plugins load
|
|
@@ -80,7 +95,7 @@ export async function runHarvestChild(
|
|
|
80
95
|
const { targetRepo, localStateRoot } = guestTicketSourceArgs(input, workspacePath)
|
|
81
96
|
const tickets = await createTicketSource(
|
|
82
97
|
config.tickets,
|
|
83
|
-
env,
|
|
98
|
+
{ ...env, AB_HARVEST: input.preparedRun?.run ?? input.instance },
|
|
84
99
|
targetRepo,
|
|
85
100
|
localStateRoot,
|
|
86
101
|
plugins,
|
|
@@ -103,6 +118,8 @@ export async function runHarvestChild(
|
|
|
103
118
|
...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
|
|
104
119
|
...(input.environment !== undefined ? { environment: input.environment } : {}),
|
|
105
120
|
sessionEnv: {
|
|
121
|
+
...(env.AB_TICKET_URL ? { AB_TICKET_URL: env.AB_TICKET_URL } : {}),
|
|
122
|
+
...(env.AB_TICKET_TOKEN ? { AB_TICKET_TOKEN: env.AB_TICKET_TOKEN } : {}),
|
|
106
123
|
AB_STORE: input.storeRef,
|
|
107
124
|
...(env.AB_TOKEN !== undefined && env.AB_TOKEN !== '' ? { AB_TOKEN: env.AB_TOKEN } : {}),
|
|
108
125
|
},
|
|
@@ -112,10 +129,19 @@ export async function runHarvestChild(
|
|
|
112
129
|
})
|
|
113
130
|
await runner.run()
|
|
114
131
|
} finally {
|
|
132
|
+
if (renewTickets) clearInterval(renewTickets)
|
|
115
133
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
if (ticketAuthority)
|
|
135
|
+
await ticketGrantRequest(ticketAuthority.url, ticketAuthority.token, {
|
|
136
|
+
action: 'revoke',
|
|
137
|
+
repo: input.repo,
|
|
138
|
+
})
|
|
139
|
+
} finally {
|
|
140
|
+
try {
|
|
141
|
+
await fullStore.close()
|
|
142
|
+
} catch {
|
|
143
|
+
// Process teardown cannot change the already-durable runner outcome.
|
|
144
|
+
}
|
|
119
145
|
}
|
|
120
146
|
}
|
|
121
147
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ticketIdentity } from '../ports/tickets/identity'
|
|
2
|
+
import { issueTicketGrant } from '../ports/tickets/grants'
|
|
1
3
|
import { delegateCredential } from '../store/remote/credentials'
|
|
2
4
|
/**
|
|
3
5
|
* Crash-safe repository-scoped harvest workflow:
|
|
@@ -1176,9 +1178,16 @@ export class HarvestRunner {
|
|
|
1176
1178
|
)
|
|
1177
1179
|
const packet = await loadScanPacket(store, repo, current.scan)
|
|
1178
1180
|
const observations = packet.observations
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
)
|
|
1181
|
+
const identity = ticketIdentity(tickets)
|
|
1182
|
+
const knownLedger = new Map<string, (typeof packet.ledger)[number]>()
|
|
1183
|
+
for (const entry of packet.ledger) {
|
|
1184
|
+
const current =
|
|
1185
|
+
entry.ticket.source === tickets.name ? await tickets.get(entry.ticket.id) : null
|
|
1186
|
+
knownLedger.set(await identity(entry.ticket), {
|
|
1187
|
+
...entry,
|
|
1188
|
+
ticket: current?.ref ?? entry.ticket,
|
|
1189
|
+
})
|
|
1190
|
+
}
|
|
1182
1191
|
const alreadyFiled = new Map(current.filed.map((entry) => [entry.proposalKey, entry]))
|
|
1183
1192
|
const reservations = new Map(current.reservations.map((entry) => [entry.proposalKey, entry.id]))
|
|
1184
1193
|
const dispositions: HarvestDisposition[] = []
|
|
@@ -1258,7 +1267,7 @@ export class HarvestRunner {
|
|
|
1258
1267
|
...(filed.blockers !== undefined ? { blockers: filed.blockers } : {}),
|
|
1259
1268
|
})
|
|
1260
1269
|
} else if (proposal.action === 'join') {
|
|
1261
|
-
const known = knownLedger.get(
|
|
1270
|
+
const known = knownLedger.get(await identity(proposal.ticket))
|
|
1262
1271
|
if (!known) {
|
|
1263
1272
|
throw new Error(
|
|
1264
1273
|
`approved join target ${proposal.ticket.source}:${proposal.ticket.id} is not in the ledger context`,
|
|
@@ -1638,9 +1647,20 @@ export class HarvestRunner {
|
|
|
1638
1647
|
session,
|
|
1639
1648
|
})
|
|
1640
1649
|
: undefined
|
|
1650
|
+
const ticketGrant =
|
|
1651
|
+
parent?.AB_TICKET_URL && parent.AB_TICKET_TOKEN
|
|
1652
|
+
? await issueTicketGrant(
|
|
1653
|
+
parent.AB_TICKET_URL,
|
|
1654
|
+
parent.AB_TICKET_TOKEN,
|
|
1655
|
+
this.deps.repo,
|
|
1656
|
+
this.deps.instance,
|
|
1657
|
+
'phase',
|
|
1658
|
+
)
|
|
1659
|
+
: undefined
|
|
1641
1660
|
return {
|
|
1642
1661
|
AB_STORE: 'local',
|
|
1643
1662
|
...this.deps.sessionEnv,
|
|
1663
|
+
...(ticketGrant ? { AB_TICKET_TOKEN: ticketGrant.token } : {}),
|
|
1644
1664
|
...(token ? { AB_TOKEN: token } : {}),
|
|
1645
1665
|
AB_REPO: this.deps.repo,
|
|
1646
1666
|
AB_HARVEST: run,
|