@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reduction of the repository-scoped harvest journal. As with builds,
|
|
3
|
+
* snapshots are never authoritative: dashboard state, resumption boundaries,
|
|
4
|
+
* claims, and the dedup ledger are all re-derived from append-only facts.
|
|
5
|
+
*/
|
|
6
|
+
import type { RepositoryEvent } from '../events/repository'
|
|
7
|
+
import type { Actor } from '../events/envelope'
|
|
8
|
+
import type { ArtifactRef, Finding, TicketRef } from '../ontology'
|
|
9
|
+
import {
|
|
10
|
+
occurrenceKey,
|
|
11
|
+
type HarvestBlockerProvenance,
|
|
12
|
+
type HarvestDisposition,
|
|
13
|
+
type HarvestPendingProposal,
|
|
14
|
+
type HarvestStep,
|
|
15
|
+
type HarvestTrigger,
|
|
16
|
+
type OccurrenceKey,
|
|
17
|
+
} from '../harvest/schema'
|
|
18
|
+
|
|
19
|
+
export interface HarvestStepOccurrence {
|
|
20
|
+
step: HarvestStep
|
|
21
|
+
round?: number
|
|
22
|
+
startedSeq: number
|
|
23
|
+
startedAt: string
|
|
24
|
+
completedSeq?: number
|
|
25
|
+
completedAt?: string
|
|
26
|
+
outcome?: 'completed' | 'approve' | 'revise' | 'escalate' | 'failed'
|
|
27
|
+
artifact?: ArtifactRef
|
|
28
|
+
detail?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HarvestReviewRound {
|
|
32
|
+
round: number
|
|
33
|
+
verdict: 'approve' | 'revise' | 'escalate'
|
|
34
|
+
findings: Finding[]
|
|
35
|
+
artifact: ArtifactRef
|
|
36
|
+
reason?: string
|
|
37
|
+
seq: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HarvestProposalReservation {
|
|
41
|
+
proposalKey: string
|
|
42
|
+
id: string
|
|
43
|
+
seq: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface HarvestRecoveryRequest {
|
|
47
|
+
attempt: number
|
|
48
|
+
limit: number
|
|
49
|
+
seq: number
|
|
50
|
+
requestedAt: string
|
|
51
|
+
acknowledgedSeq?: number
|
|
52
|
+
acknowledgedAt?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HarvestRecoveryExhaustion {
|
|
56
|
+
step: HarvestStep
|
|
57
|
+
round?: number
|
|
58
|
+
error: string
|
|
59
|
+
attempts: number
|
|
60
|
+
limit: number
|
|
61
|
+
releasedObservations: OccurrenceKey[]
|
|
62
|
+
committedDispositions: HarvestDisposition[]
|
|
63
|
+
pendingProposals: HarvestPendingProposal[]
|
|
64
|
+
seq: number
|
|
65
|
+
at: string
|
|
66
|
+
attentionAcknowledgedSeq?: number
|
|
67
|
+
attentionAcknowledgedAt?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface HarvestRunState {
|
|
71
|
+
run: string
|
|
72
|
+
status: 'running' | 'completed' | 'escalated' | 'failed'
|
|
73
|
+
startedSeq: number
|
|
74
|
+
startedAt: string
|
|
75
|
+
observations: OccurrenceKey[]
|
|
76
|
+
scan: ArtifactRef
|
|
77
|
+
/** Absent only on runs started by historical pre-drift journals. */
|
|
78
|
+
trigger?: HarvestTrigger
|
|
79
|
+
steps: HarvestStepOccurrence[]
|
|
80
|
+
proposals: Array<{ round: number; artifact: ArtifactRef; seq: number }>
|
|
81
|
+
reviews: HarvestReviewRound[]
|
|
82
|
+
reservations: HarvestProposalReservation[]
|
|
83
|
+
filed: Array<{
|
|
84
|
+
proposalKey: string
|
|
85
|
+
ticket: TicketRef
|
|
86
|
+
blockers?: HarvestBlockerProvenance
|
|
87
|
+
seq: number
|
|
88
|
+
}>
|
|
89
|
+
dispositions: HarvestDisposition[]
|
|
90
|
+
report?: ArtifactRef
|
|
91
|
+
escalation?: {
|
|
92
|
+
source: 'agent' | 'stall' | 'policy'
|
|
93
|
+
reason: string
|
|
94
|
+
round?: number
|
|
95
|
+
}
|
|
96
|
+
failure?: {
|
|
97
|
+
step: HarvestStep
|
|
98
|
+
round?: number
|
|
99
|
+
attempt: number
|
|
100
|
+
error: string
|
|
101
|
+
willRetry: boolean
|
|
102
|
+
}
|
|
103
|
+
/** Repo seq of the failure fact that set `failure`; deleted with it, so it
|
|
104
|
+
* marks the recency of the currently unresolved failure. */
|
|
105
|
+
failureSeq?: number
|
|
106
|
+
/** Durable outer recoveries, independent of within-step attempt facts. */
|
|
107
|
+
recoveryRequests: HarvestRecoveryRequest[]
|
|
108
|
+
/** Present after the automatic recovery budget is atomically exhausted. */
|
|
109
|
+
recoveryExhaustion?: HarvestRecoveryExhaustion
|
|
110
|
+
terminalSeq?: number
|
|
111
|
+
terminalAt?: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface HarvestLedgerEntry extends HarvestDisposition {
|
|
115
|
+
run: string
|
|
116
|
+
seq: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** An operator request not yet acknowledged by the harvest kernel. Requests
|
|
120
|
+
* are ordered repository facts; pause and resume countermand older opposing
|
|
121
|
+
* intent exactly as build commands do. */
|
|
122
|
+
export interface HarvestPendingCommand {
|
|
123
|
+
command: 'pause' | 'resume'
|
|
124
|
+
seq: number
|
|
125
|
+
actor: Actor
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface HarvestState {
|
|
129
|
+
lastSeq: number
|
|
130
|
+
runs: HarvestRunState[]
|
|
131
|
+
latest?: HarvestRunState
|
|
132
|
+
/** Repository-wide gate, independent of any one run. It changes only on the
|
|
133
|
+
* kernel acknowledgement, never on the human request. */
|
|
134
|
+
paused: boolean
|
|
135
|
+
/** Boundary at which the current pause was acknowledged. Dashboard timing
|
|
136
|
+
* freezes here; absent whenever the gate is open. */
|
|
137
|
+
pausedSeq?: number
|
|
138
|
+
pausedAt?: string
|
|
139
|
+
/** Unacknowledged human commands in repository sequence order. */
|
|
140
|
+
pendingCommands: HarvestPendingCommand[]
|
|
141
|
+
/** Occurrences still owned by started runs; exhaustion may selectively
|
|
142
|
+
* release pending members while retaining committed ones. */
|
|
143
|
+
claimed: OccurrenceKey[]
|
|
144
|
+
/** Completed and recovery-exhausted committed disposition facts. */
|
|
145
|
+
ledger: HarvestLedgerEntry[]
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS = 2
|
|
149
|
+
|
|
150
|
+
export type HarvestControlDecision =
|
|
151
|
+
| { kind: 'acknowledge'; command: 'pause' | 'resume' }
|
|
152
|
+
| { kind: 'request-recovery'; run: string; attempt: number; limit: number }
|
|
153
|
+
| { kind: 'exhaust-recovery'; run: string; attempts: number; limit: number }
|
|
154
|
+
| { kind: 'park' }
|
|
155
|
+
| { kind: 'proceed' }
|
|
156
|
+
|
|
157
|
+
function cloneRef(ref: ArtifactRef): ArtifactRef {
|
|
158
|
+
return { kind: ref.kind, rev: ref.rev }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isOrdinaryParkedRun(run: HarvestRunState): boolean {
|
|
162
|
+
return run.status === 'failed' && run.recoveryExhaustion === undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasUnresolvedExhaustion(run: HarvestRunState): boolean {
|
|
166
|
+
return (
|
|
167
|
+
run.status === 'failed' &&
|
|
168
|
+
run.recoveryExhaustion !== undefined &&
|
|
169
|
+
run.recoveryExhaustion.attentionAcknowledgedSeq === undefined
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function requireRun(
|
|
174
|
+
runs: Map<string, HarvestRunState>,
|
|
175
|
+
run: string,
|
|
176
|
+
event: RepositoryEvent,
|
|
177
|
+
): HarvestRunState {
|
|
178
|
+
const state = runs.get(run)
|
|
179
|
+
if (!state) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`${event.type} at repo seq ${event.seq} references unknown harvest run "${run}"`,
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
return state
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Total for valid journals; malformed cross-event references throw loudly so
|
|
188
|
+
* storage corruption cannot masquerade as an idle harvester. */
|
|
189
|
+
export function reduceHarvest(events: readonly RepositoryEvent[]): HarvestState {
|
|
190
|
+
const runs = new Map<string, HarvestRunState>()
|
|
191
|
+
const order: HarvestRunState[] = []
|
|
192
|
+
const claimed = new Map<string, { occurrence: OccurrenceKey; run: string }>()
|
|
193
|
+
const ledger: HarvestLedgerEntry[] = []
|
|
194
|
+
const pending: Record<HarvestPendingCommand['command'], HarvestPendingCommand[]> = {
|
|
195
|
+
pause: [],
|
|
196
|
+
resume: [],
|
|
197
|
+
}
|
|
198
|
+
let paused = false
|
|
199
|
+
let pausedSeq: number | undefined
|
|
200
|
+
let pausedAt: string | undefined
|
|
201
|
+
let lastSeq = 0
|
|
202
|
+
|
|
203
|
+
for (const event of events) {
|
|
204
|
+
lastSeq = Math.max(lastSeq, event.seq)
|
|
205
|
+
switch (event.type) {
|
|
206
|
+
case 'harvest.pause-requested':
|
|
207
|
+
pending.resume = []
|
|
208
|
+
pending.pause.push({
|
|
209
|
+
command: 'pause',
|
|
210
|
+
seq: event.seq,
|
|
211
|
+
actor: event.actor,
|
|
212
|
+
})
|
|
213
|
+
break
|
|
214
|
+
case 'harvest.resume-requested':
|
|
215
|
+
pending.pause = []
|
|
216
|
+
pending.resume.push({
|
|
217
|
+
command: 'resume',
|
|
218
|
+
seq: event.seq,
|
|
219
|
+
actor: event.actor,
|
|
220
|
+
})
|
|
221
|
+
break
|
|
222
|
+
case 'harvest.paused':
|
|
223
|
+
paused = true
|
|
224
|
+
pausedSeq = event.seq
|
|
225
|
+
pausedAt = event.ts
|
|
226
|
+
pending.pause = []
|
|
227
|
+
break
|
|
228
|
+
case 'harvest.resumed': {
|
|
229
|
+
const hadHumanResume = pending.resume.length > 0
|
|
230
|
+
paused = false
|
|
231
|
+
pausedSeq = undefined
|
|
232
|
+
pausedAt = undefined
|
|
233
|
+
pending.resume = []
|
|
234
|
+
for (const run of order) {
|
|
235
|
+
const exhaustion = run.recoveryExhaustion
|
|
236
|
+
if (exhaustion !== undefined) {
|
|
237
|
+
// Give-up is terminal. A human acknowledgement removes every
|
|
238
|
+
// outstanding repository attention barrier without resurrecting
|
|
239
|
+
// a run or changing its selectively retained claims.
|
|
240
|
+
if (hadHumanResume && exhaustion.attentionAcknowledgedSeq === undefined) {
|
|
241
|
+
exhaustion.attentionAcknowledgedSeq = event.seq
|
|
242
|
+
exhaustion.attentionAcknowledgedAt = event.ts
|
|
243
|
+
}
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (!isOrdinaryParkedRun(run)) continue
|
|
247
|
+
|
|
248
|
+
const automatic = [...run.recoveryRequests]
|
|
249
|
+
.reverse()
|
|
250
|
+
.find((request) => request.acknowledgedSeq === undefined)
|
|
251
|
+
if (automatic !== undefined) {
|
|
252
|
+
automatic.acknowledgedSeq = event.seq
|
|
253
|
+
automatic.acknowledgedAt = event.ts
|
|
254
|
+
}
|
|
255
|
+
// One human acknowledgement is repository-wide and reopens every
|
|
256
|
+
// ordinary parked run. Without human intent, the unscoped resumed
|
|
257
|
+
// fact correlates only to runs with durable automatic requests.
|
|
258
|
+
if (hadHumanResume || automatic !== undefined) {
|
|
259
|
+
run.status = 'running'
|
|
260
|
+
delete run.failure
|
|
261
|
+
delete run.failureSeq
|
|
262
|
+
delete run.terminalSeq
|
|
263
|
+
delete run.terminalAt
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
case 'harvest.recovery-requested': {
|
|
269
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
270
|
+
if (run.status !== 'failed' || run.recoveryExhaustion !== undefined) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`harvest.recovery-requested at repo seq ${event.seq} requires an ` +
|
|
273
|
+
`ordinary failed run; "${run.run}" is ${run.status}`,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
if (run.recoveryRequests.some((request) => request.acknowledgedSeq === undefined)) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
`harvest run "${run.run}" already has an unacknowledged automatic recovery request`,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
const expectedAttempt = run.recoveryRequests.length + 1
|
|
282
|
+
if (event.payload.attempt !== expectedAttempt) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`harvest run "${run.run}" recovery attempt must be ${expectedAttempt}; ` +
|
|
285
|
+
`repo seq ${event.seq} recorded ${event.payload.attempt}`,
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
const appliedLimit = run.recoveryRequests[0]?.limit
|
|
289
|
+
if (appliedLimit !== undefined && event.payload.limit !== appliedLimit) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`harvest run "${run.run}" recovery limit was already applied as ` +
|
|
292
|
+
`${appliedLimit}; repo seq ${event.seq} cannot change it to ${event.payload.limit}`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
if (event.payload.attempt > event.payload.limit) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`harvest run "${run.run}" recovery attempt ${event.payload.attempt} ` +
|
|
298
|
+
`exceeds its applied limit ${event.payload.limit}`,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
run.recoveryRequests.push({
|
|
302
|
+
attempt: event.payload.attempt,
|
|
303
|
+
limit: event.payload.limit,
|
|
304
|
+
seq: event.seq,
|
|
305
|
+
requestedAt: event.ts,
|
|
306
|
+
})
|
|
307
|
+
break
|
|
308
|
+
}
|
|
309
|
+
case 'harvest.recovery-exhausted': {
|
|
310
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
311
|
+
if (run.recoveryExhaustion !== undefined) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`duplicate harvest.recovery-exhausted for run "${run.run}" at repo seq ${event.seq}`,
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
if (run.status !== 'failed' || run.failure === undefined) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`harvest.recovery-exhausted at repo seq ${event.seq} requires a failed run`,
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
if (
|
|
322
|
+
run.failure.step !== event.payload.step ||
|
|
323
|
+
run.failure.round !== event.payload.round ||
|
|
324
|
+
run.failure.error !== event.payload.error
|
|
325
|
+
) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`harvest.recovery-exhausted at repo seq ${event.seq} does not match ` +
|
|
328
|
+
`the stopped boundary for run "${run.run}"`,
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
if (
|
|
332
|
+
event.payload.attempts !== run.recoveryRequests.length ||
|
|
333
|
+
event.payload.attempts !== event.payload.limit ||
|
|
334
|
+
run.recoveryRequests.some((request) => request.limit !== event.payload.limit)
|
|
335
|
+
) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`harvest run "${run.run}" exhaustion must record its exact recovery ` +
|
|
338
|
+
`count and limit; got ${event.payload.attempts}/${event.payload.limit} ` +
|
|
339
|
+
`after ${run.recoveryRequests.length} requests`,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
if (run.recoveryRequests.some((request) => request.acknowledgedSeq === undefined)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
`harvest run "${run.run}" cannot exhaust with an unacknowledged recovery request`,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const expected = new Set(run.observations.map(occurrenceKey))
|
|
349
|
+
const partition = new Set<string>()
|
|
350
|
+
const released = new Set<string>()
|
|
351
|
+
for (const disposition of event.payload.committedDispositions) {
|
|
352
|
+
const key = occurrenceKey(disposition.occurrence)
|
|
353
|
+
if (!expected.has(key) || partition.has(key)) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`harvest run "${run.run}" exhaustion has an invalid or duplicate committed occurrence ${key}`,
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
partition.add(key)
|
|
359
|
+
}
|
|
360
|
+
for (const occurrence of event.payload.releasedObservations) {
|
|
361
|
+
const key = occurrenceKey(occurrence)
|
|
362
|
+
if (!expected.has(key) || partition.has(key)) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`harvest run "${run.run}" exhaustion has an invalid or duplicate released occurrence ${key}`,
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
partition.add(key)
|
|
368
|
+
released.add(key)
|
|
369
|
+
}
|
|
370
|
+
if (partition.size !== expected.size) {
|
|
371
|
+
const missing = [...expected].filter((key) => !partition.has(key))
|
|
372
|
+
throw new Error(
|
|
373
|
+
`harvest run "${run.run}" exhaustion does not partition its snapshot; ` +
|
|
374
|
+
`missing ${missing.join(', ')}`,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
const pendingKeys = new Set<string>()
|
|
378
|
+
const pendingOccurrences = new Set<string>()
|
|
379
|
+
for (const proposal of event.payload.pendingProposals) {
|
|
380
|
+
if (pendingKeys.has(proposal.proposalKey)) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`harvest run "${run.run}" exhaustion repeats pending proposal key "${proposal.proposalKey}"`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
pendingKeys.add(proposal.proposalKey)
|
|
386
|
+
for (const occurrence of proposal.observations) {
|
|
387
|
+
const key = occurrenceKey(occurrence)
|
|
388
|
+
if (!released.has(key) || pendingOccurrences.has(key)) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`harvest run "${run.run}" pending proposal "${proposal.proposalKey}" ` +
|
|
391
|
+
`does not uniquely describe released occurrence ${key}`,
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
pendingOccurrences.add(key)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const key of released) {
|
|
399
|
+
const owner = claimed.get(key)
|
|
400
|
+
if (owner?.run !== run.run) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`harvest run "${run.run}" cannot release ${key}; its claim owner is ` +
|
|
403
|
+
`${owner?.run ?? 'missing'}`,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
claimed.delete(key)
|
|
407
|
+
}
|
|
408
|
+
run.dispositions = structuredClone(event.payload.committedDispositions)
|
|
409
|
+
for (const disposition of event.payload.committedDispositions) {
|
|
410
|
+
ledger.push({
|
|
411
|
+
...structuredClone(disposition),
|
|
412
|
+
run: run.run,
|
|
413
|
+
seq: event.seq,
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
run.recoveryExhaustion = {
|
|
417
|
+
step: event.payload.step,
|
|
418
|
+
...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
|
|
419
|
+
error: event.payload.error,
|
|
420
|
+
attempts: event.payload.attempts,
|
|
421
|
+
limit: event.payload.limit,
|
|
422
|
+
releasedObservations: structuredClone(event.payload.releasedObservations),
|
|
423
|
+
committedDispositions: structuredClone(event.payload.committedDispositions),
|
|
424
|
+
pendingProposals: structuredClone(event.payload.pendingProposals),
|
|
425
|
+
seq: event.seq,
|
|
426
|
+
at: event.ts,
|
|
427
|
+
}
|
|
428
|
+
run.terminalSeq = event.seq
|
|
429
|
+
run.terminalAt = event.ts
|
|
430
|
+
break
|
|
431
|
+
}
|
|
432
|
+
case 'harvest.started': {
|
|
433
|
+
if (runs.has(event.payload.run)) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
`duplicate harvest.started for run "${event.payload.run}" at repo seq ${event.seq}`,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
const run: HarvestRunState = {
|
|
439
|
+
run: event.payload.run,
|
|
440
|
+
status: 'running',
|
|
441
|
+
startedSeq: event.seq,
|
|
442
|
+
startedAt: event.ts,
|
|
443
|
+
observations: event.payload.observations.map((key) => ({ ...key })),
|
|
444
|
+
scan: cloneRef(event.payload.scan),
|
|
445
|
+
...(event.payload.trigger !== undefined ? { trigger: event.payload.trigger } : {}),
|
|
446
|
+
steps: [],
|
|
447
|
+
proposals: [],
|
|
448
|
+
reviews: [],
|
|
449
|
+
reservations: [],
|
|
450
|
+
filed: [],
|
|
451
|
+
dispositions: [],
|
|
452
|
+
recoveryRequests: [],
|
|
453
|
+
}
|
|
454
|
+
runs.set(run.run, run)
|
|
455
|
+
order.push(run)
|
|
456
|
+
for (const key of run.observations) {
|
|
457
|
+
const id = occurrenceKey(key)
|
|
458
|
+
const existing = claimed.get(id)
|
|
459
|
+
if (existing !== undefined) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`harvest run "${run.run}" at repo seq ${event.seq} cannot claim ` +
|
|
462
|
+
`${id}; it is already claimed by "${existing.run}"`,
|
|
463
|
+
)
|
|
464
|
+
}
|
|
465
|
+
claimed.set(id, { occurrence: { ...key }, run: run.run })
|
|
466
|
+
}
|
|
467
|
+
break
|
|
468
|
+
}
|
|
469
|
+
case 'harvest.step.started': {
|
|
470
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
471
|
+
run.steps.push({
|
|
472
|
+
step: event.payload.step,
|
|
473
|
+
...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
|
|
474
|
+
startedSeq: event.seq,
|
|
475
|
+
startedAt: event.ts,
|
|
476
|
+
})
|
|
477
|
+
break
|
|
478
|
+
}
|
|
479
|
+
case 'harvest.step.completed': {
|
|
480
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
481
|
+
const occurrence = [...run.steps]
|
|
482
|
+
.reverse()
|
|
483
|
+
.find(
|
|
484
|
+
(step) =>
|
|
485
|
+
step.step === event.payload.step &&
|
|
486
|
+
step.round === event.payload.round &&
|
|
487
|
+
step.completedSeq === undefined,
|
|
488
|
+
)
|
|
489
|
+
const target =
|
|
490
|
+
occurrence ??
|
|
491
|
+
({
|
|
492
|
+
step: event.payload.step,
|
|
493
|
+
...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
|
|
494
|
+
startedSeq: event.seq,
|
|
495
|
+
startedAt: event.ts,
|
|
496
|
+
} satisfies HarvestStepOccurrence)
|
|
497
|
+
if (occurrence === undefined) run.steps.push(target)
|
|
498
|
+
target.completedSeq = event.seq
|
|
499
|
+
target.completedAt = event.ts
|
|
500
|
+
target.outcome = event.payload.outcome
|
|
501
|
+
if (event.payload.artifact !== undefined) {
|
|
502
|
+
target.artifact = cloneRef(event.payload.artifact)
|
|
503
|
+
}
|
|
504
|
+
if (event.payload.detail !== undefined) target.detail = event.payload.detail
|
|
505
|
+
break
|
|
506
|
+
}
|
|
507
|
+
case 'harvest.proposals.submitted': {
|
|
508
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
509
|
+
run.proposals.push({
|
|
510
|
+
round: event.payload.round,
|
|
511
|
+
artifact: cloneRef(event.payload.artifact),
|
|
512
|
+
seq: event.seq,
|
|
513
|
+
})
|
|
514
|
+
break
|
|
515
|
+
}
|
|
516
|
+
case 'harvest.review.verdict': {
|
|
517
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
518
|
+
run.reviews.push({
|
|
519
|
+
round: event.payload.round,
|
|
520
|
+
verdict: event.payload.verdict,
|
|
521
|
+
findings: structuredClone(event.payload.findings),
|
|
522
|
+
artifact: cloneRef(event.payload.artifact),
|
|
523
|
+
...(event.payload.reason !== undefined ? { reason: event.payload.reason } : {}),
|
|
524
|
+
seq: event.seq,
|
|
525
|
+
})
|
|
526
|
+
break
|
|
527
|
+
}
|
|
528
|
+
case 'harvest.proposal.id-reserved': {
|
|
529
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
530
|
+
const normalizedId = event.payload.id.toLowerCase()
|
|
531
|
+
const existingKey = run.reservations.find(
|
|
532
|
+
(entry) => entry.proposalKey === event.payload.proposalKey,
|
|
533
|
+
)
|
|
534
|
+
if (existingKey !== undefined) {
|
|
535
|
+
if (existingKey.id.toLowerCase() !== normalizedId) {
|
|
536
|
+
throw new Error(
|
|
537
|
+
`harvest proposal key "${event.payload.proposalKey}" was already ` +
|
|
538
|
+
`reserved as "${existingKey.id}" at repo seq ${existingKey.seq}; ` +
|
|
539
|
+
`repo seq ${event.seq} cannot replace it with "${event.payload.id}"`,
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
// Exact replay is one logical reservation.
|
|
543
|
+
break
|
|
544
|
+
}
|
|
545
|
+
const existingId = run.reservations.find((entry) => entry.id.toLowerCase() === normalizedId)
|
|
546
|
+
if (existingId !== undefined) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
`harvest reserved id "${event.payload.id}" already belongs to proposal ` +
|
|
549
|
+
`key "${existingId.proposalKey}" at repo seq ${existingId.seq}; ` +
|
|
550
|
+
`repo seq ${event.seq} cannot reuse it for "${event.payload.proposalKey}"`,
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
run.reservations.push({
|
|
554
|
+
proposalKey: event.payload.proposalKey,
|
|
555
|
+
id: event.payload.id,
|
|
556
|
+
seq: event.seq,
|
|
557
|
+
})
|
|
558
|
+
break
|
|
559
|
+
}
|
|
560
|
+
case 'harvest.proposal.filed': {
|
|
561
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
562
|
+
// Idempotent projection: a retry may adopt the external ticket and see
|
|
563
|
+
// the already-journaled fact. Keep one entry per stable proposal key.
|
|
564
|
+
const existing = run.filed.find((entry) => entry.proposalKey === event.payload.proposalKey)
|
|
565
|
+
if (!existing) {
|
|
566
|
+
run.filed.push({
|
|
567
|
+
proposalKey: event.payload.proposalKey,
|
|
568
|
+
ticket: structuredClone(event.payload.ticket),
|
|
569
|
+
...(event.payload.blockers !== undefined
|
|
570
|
+
? { blockers: structuredClone(event.payload.blockers) }
|
|
571
|
+
: {}),
|
|
572
|
+
seq: event.seq,
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
break
|
|
576
|
+
}
|
|
577
|
+
case 'harvest.completed': {
|
|
578
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
579
|
+
if (run.status !== 'running') break
|
|
580
|
+
run.status = 'completed'
|
|
581
|
+
run.dispositions = structuredClone(event.payload.dispositions)
|
|
582
|
+
run.report = cloneRef(event.payload.report)
|
|
583
|
+
run.terminalSeq = event.seq
|
|
584
|
+
run.terminalAt = event.ts
|
|
585
|
+
for (const disposition of run.dispositions) {
|
|
586
|
+
ledger.push({
|
|
587
|
+
...structuredClone(disposition),
|
|
588
|
+
run: run.run,
|
|
589
|
+
seq: event.seq,
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
break
|
|
593
|
+
}
|
|
594
|
+
case 'harvest.escalated': {
|
|
595
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
596
|
+
if (run.status !== 'running') break
|
|
597
|
+
run.status = 'escalated'
|
|
598
|
+
run.escalation = {
|
|
599
|
+
source: event.payload.source,
|
|
600
|
+
reason: event.payload.reason,
|
|
601
|
+
...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
|
|
602
|
+
}
|
|
603
|
+
run.terminalSeq = event.seq
|
|
604
|
+
run.terminalAt = event.ts
|
|
605
|
+
break
|
|
606
|
+
}
|
|
607
|
+
case 'harvest.failed': {
|
|
608
|
+
const run = requireRun(runs, event.payload.run, event)
|
|
609
|
+
// Completion and deliberate escalation are irrevocable outcomes. A
|
|
610
|
+
// late infrastructure fact remains in the journal but cannot replace
|
|
611
|
+
// either with a recoverable error projection.
|
|
612
|
+
if (run.status !== 'running') break
|
|
613
|
+
run.failure = {
|
|
614
|
+
step: event.payload.step,
|
|
615
|
+
...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
|
|
616
|
+
attempt: event.payload.attempt,
|
|
617
|
+
error: event.payload.error,
|
|
618
|
+
willRetry: event.payload.willRetry,
|
|
619
|
+
}
|
|
620
|
+
run.failureSeq = event.seq
|
|
621
|
+
if (!event.payload.willRetry) {
|
|
622
|
+
// Failed is a parked infrastructure stop, not a completed outcome.
|
|
623
|
+
// The claim and every workflow artifact remain owned by this run
|
|
624
|
+
// until an explicit harvest.resumed fact reopens it.
|
|
625
|
+
run.status = 'failed'
|
|
626
|
+
}
|
|
627
|
+
break
|
|
628
|
+
}
|
|
629
|
+
case 'harvest.session.started':
|
|
630
|
+
case 'harvest.session.ended':
|
|
631
|
+
// Session facts remain individually queryable but add no transition.
|
|
632
|
+
break
|
|
633
|
+
case 'dispatcher.intake-set':
|
|
634
|
+
case 'dispatcher.pause-set':
|
|
635
|
+
case 'dispatcher.auto-merge-default-set':
|
|
636
|
+
// Dispatcher controls share the repository journal but are projected
|
|
637
|
+
// independently; they never alter harvest workflow state.
|
|
638
|
+
break
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const state: HarvestState = {
|
|
643
|
+
lastSeq,
|
|
644
|
+
runs: order,
|
|
645
|
+
paused,
|
|
646
|
+
...(pausedSeq !== undefined ? { pausedSeq } : {}),
|
|
647
|
+
...(pausedAt !== undefined ? { pausedAt } : {}),
|
|
648
|
+
pendingCommands: [...pending.pause, ...pending.resume].sort(
|
|
649
|
+
(left, right) => left.seq - right.seq,
|
|
650
|
+
),
|
|
651
|
+
claimed: [...claimed.values()].map(({ occurrence }) => occurrence),
|
|
652
|
+
ledger,
|
|
653
|
+
}
|
|
654
|
+
const latest = order.at(-1)
|
|
655
|
+
if (latest !== undefined) state.latest = latest
|
|
656
|
+
return state
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function claimedOccurrenceKeys(state: HarvestState): Set<string> {
|
|
660
|
+
return new Set(state.claimed.map(occurrenceKey))
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Durable correlation for a ticket whose external create may still be between
|
|
664
|
+
* source creation and blocker recording. Only running workflows contribute;
|
|
665
|
+
* every terminal/parked status releases unmatched reservations. */
|
|
666
|
+
export interface InFlightHarvestCreation {
|
|
667
|
+
run: string
|
|
668
|
+
proposalKey: string
|
|
669
|
+
creationKey: string
|
|
670
|
+
reservationSeq: number
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function inFlightHarvestCreations(state: HarvestState): InFlightHarvestCreation[] {
|
|
674
|
+
const creations: InFlightHarvestCreation[] = []
|
|
675
|
+
for (const run of state.runs) {
|
|
676
|
+
if (run.status !== 'running') continue
|
|
677
|
+
const filed = new Set(run.filed.map((entry) => entry.proposalKey))
|
|
678
|
+
for (const reservation of run.reservations) {
|
|
679
|
+
if (filed.has(reservation.proposalKey)) continue
|
|
680
|
+
creations.push({
|
|
681
|
+
run: run.run,
|
|
682
|
+
proposalKey: reservation.proposalKey,
|
|
683
|
+
creationKey: reservation.id,
|
|
684
|
+
reservationSeq: reservation.seq,
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return creations
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Bracket a ready-source listing with repository snapshots. Creations active
|
|
692
|
+
* before the listing remain withheld for this scan even if they settle during
|
|
693
|
+
* it. A reservation first written during the listing is also withheld once,
|
|
694
|
+
* including when its filing/terminal fact lands before the second snapshot:
|
|
695
|
+
* the ready projection may have captured the source between those writes. */
|
|
696
|
+
export function harvestCreationsDuringReadyScan(
|
|
697
|
+
beforeEvents: readonly RepositoryEvent[],
|
|
698
|
+
afterEvents: readonly RepositoryEvent[],
|
|
699
|
+
): InFlightHarvestCreation[] {
|
|
700
|
+
const before = reduceHarvest(beforeEvents)
|
|
701
|
+
// Validate the complete second snapshot before using its event suffix.
|
|
702
|
+
reduceHarvest(afterEvents)
|
|
703
|
+
const creations = new Map<string, InFlightHarvestCreation>()
|
|
704
|
+
const keyOf = (run: string, proposalKey: string): string => `${run}\0${proposalKey}`
|
|
705
|
+
const known = new Set<string>()
|
|
706
|
+
for (const run of before.runs) {
|
|
707
|
+
for (const reservation of run.reservations) {
|
|
708
|
+
known.add(keyOf(run.run, reservation.proposalKey))
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
for (const creation of inFlightHarvestCreations(before)) {
|
|
712
|
+
creations.set(keyOf(creation.run, creation.proposalKey), creation)
|
|
713
|
+
}
|
|
714
|
+
for (const event of afterEvents) {
|
|
715
|
+
if (event.seq <= before.lastSeq || event.type !== 'harvest.proposal.id-reserved') continue
|
|
716
|
+
const key = keyOf(event.payload.run, event.payload.proposalKey)
|
|
717
|
+
if (known.has(key)) continue
|
|
718
|
+
known.add(key)
|
|
719
|
+
creations.set(key, {
|
|
720
|
+
run: event.payload.run,
|
|
721
|
+
proposalKey: event.payload.proposalKey,
|
|
722
|
+
creationKey: event.payload.id,
|
|
723
|
+
reservationSeq: event.seq,
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
return [...creations.values()]
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Runs are always returned in durable start order. `latest` remains useful for
|
|
730
|
+
* history, but recovery and execution must never use it as an authority. */
|
|
731
|
+
export function parkedHarvestRuns(state: HarvestState): HarvestRunState[] {
|
|
732
|
+
return state.runs.filter(isOrdinaryParkedRun)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function unresolvedHarvestAttentionRuns(state: HarvestState): HarvestRunState[] {
|
|
736
|
+
return state.runs.filter(hasUnresolvedExhaustion)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export function openHarvestRuns(state: HarvestState): HarvestRunState[] {
|
|
740
|
+
return state.runs.filter((run) => run.status === 'running')
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** The concrete run a control boundary should report if it parks. Exhaustion
|
|
744
|
+
* barriers are repository-global, then ordinary parked work takes priority,
|
|
745
|
+
* then the oldest open workflow is the run execution should continue. */
|
|
746
|
+
export function actionableHarvestRun(state: HarvestState): HarvestRunState | undefined {
|
|
747
|
+
return (
|
|
748
|
+
unresolvedHarvestAttentionRuns(state)[0] ??
|
|
749
|
+
parkedHarvestRuns(state)[0] ??
|
|
750
|
+
openHarvestRuns(state)[0]
|
|
751
|
+
)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Pure repository-control routing shared by dispatcher and harvest runner.
|
|
755
|
+
* Human commands settle first. Recorded exhaustion is a global barrier; then
|
|
756
|
+
* every durable automatic request is acknowledged before the oldest ordinary
|
|
757
|
+
* parked run receives its next run-local attempt. Only a journal with no
|
|
758
|
+
* unresolved failure may proceed to open work or a new scan. */
|
|
759
|
+
export function decideHarvestControl(
|
|
760
|
+
state: HarvestState,
|
|
761
|
+
maxRecoveryAttempts = DEFAULT_MAX_HARVEST_RECOVERY_ATTEMPTS,
|
|
762
|
+
): HarvestControlDecision {
|
|
763
|
+
if (!Number.isInteger(maxRecoveryAttempts) || maxRecoveryAttempts <= 0) {
|
|
764
|
+
throw new Error('max harvest recovery attempts must be a positive integer')
|
|
765
|
+
}
|
|
766
|
+
const wantsResume = state.pendingCommands.some((command) => command.command === 'resume')
|
|
767
|
+
if (state.paused) {
|
|
768
|
+
if (wantsResume) return { kind: 'acknowledge', command: 'resume' }
|
|
769
|
+
return { kind: 'park' }
|
|
770
|
+
}
|
|
771
|
+
if (state.pendingCommands.some((command) => command.command === 'pause')) {
|
|
772
|
+
return { kind: 'acknowledge', command: 'pause' }
|
|
773
|
+
}
|
|
774
|
+
// A stale/racing human resume is still a command and must be consumed. It
|
|
775
|
+
// reopens all ordinary failures and acknowledges all exhaustion barriers.
|
|
776
|
+
if (wantsResume) return { kind: 'acknowledge', command: 'resume' }
|
|
777
|
+
|
|
778
|
+
if (unresolvedHarvestAttentionRuns(state).length > 0) {
|
|
779
|
+
return { kind: 'park' }
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const parked = parkedHarvestRuns(state)
|
|
783
|
+
if (
|
|
784
|
+
parked.some((run) =>
|
|
785
|
+
run.recoveryRequests.some((request) => request.acknowledgedSeq === undefined),
|
|
786
|
+
)
|
|
787
|
+
) {
|
|
788
|
+
return { kind: 'acknowledge', command: 'resume' }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const run = parked[0]
|
|
792
|
+
if (run === undefined) return { kind: 'proceed' }
|
|
793
|
+
|
|
794
|
+
const attempts = run.recoveryRequests.length
|
|
795
|
+
// Once the first request records the applied policy, replacements honor
|
|
796
|
+
// that durable limit even if a later process ships a different default.
|
|
797
|
+
const appliedLimit = run.recoveryRequests[0]?.limit ?? maxRecoveryAttempts
|
|
798
|
+
if (attempts < appliedLimit) {
|
|
799
|
+
return {
|
|
800
|
+
kind: 'request-recovery',
|
|
801
|
+
run: run.run,
|
|
802
|
+
attempt: attempts + 1,
|
|
803
|
+
limit: appliedLimit,
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
kind: 'exhaust-recovery',
|
|
808
|
+
run: run.run,
|
|
809
|
+
attempts,
|
|
810
|
+
limit: appliedLimit,
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function openHarvestRun(state: HarvestState): HarvestRunState | undefined {
|
|
815
|
+
return openHarvestRuns(state)[0]
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function proposalArtifactForRound(
|
|
819
|
+
run: HarvestRunState,
|
|
820
|
+
round: number,
|
|
821
|
+
): ArtifactRef | undefined {
|
|
822
|
+
return [...run.proposals].reverse().find((proposal) => proposal.round === round)?.artifact
|
|
823
|
+
}
|