@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,1047 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kernel pipeline engine (SPEC §8): "read the event log, decide the next
|
|
3
|
+
* phase per the transition table, invoke the AgentRunner with `/{skill}
|
|
4
|
+
* {build}`, wait for the terminal event, repeat." `decideNext` IS that
|
|
5
|
+
* transition table — the determinism half of the constitution (§2.1): agents
|
|
6
|
+
* never decide phase transitions; this pure, total function does. Same events
|
|
7
|
+
* + config → same decision, and any prefix of a valid log decides — which is
|
|
8
|
+
* why resumability is not a feature (§2.2): re-deciding over the log IS the
|
|
9
|
+
* resume path (§15.6-C), and a fresh sandbox resumes a dead sandbox's build
|
|
10
|
+
* by pulling events and asking the same question (§7.4).
|
|
11
|
+
*
|
|
12
|
+
* The engine performs no I/O and appends nothing. It returns a Decision; the
|
|
13
|
+
* build-runner executes it (starts sessions, runs check commands, appends the
|
|
14
|
+
* events execution produces) and asks again. Escalation raising is itself a
|
|
15
|
+
* decision (`raise-escalation`), so the stall/policy thresholds live here —
|
|
16
|
+
* §15.4's split made code: judgment marks `persists`, determinism applies the
|
|
17
|
+
* threshold.
|
|
18
|
+
*
|
|
19
|
+
* Projection policy: `reduceBuild` is used for everything it already projects
|
|
20
|
+
* (status, pending commands, escalations, spec rev, prState, reconcile
|
|
21
|
+
* attempts). The engine drops to raw events only where the reducer lacks a
|
|
22
|
+
* projection; each such spot is a documented field of `LogIndex` below.
|
|
23
|
+
*/
|
|
24
|
+
import type { Config } from '../config/schema'
|
|
25
|
+
import type { AbEvent } from '../events/catalog'
|
|
26
|
+
import { normalizeVerifyCompletion, type EventPayload } from '../events/payloads'
|
|
27
|
+
import {
|
|
28
|
+
isVerifyPhase,
|
|
29
|
+
verifyPhase,
|
|
30
|
+
type ArtifactRef,
|
|
31
|
+
type CorePhase,
|
|
32
|
+
type EscalationSource,
|
|
33
|
+
type EscalationTarget,
|
|
34
|
+
type Feedback,
|
|
35
|
+
type Finding,
|
|
36
|
+
type Phase,
|
|
37
|
+
type PolicyEscalationCause,
|
|
38
|
+
type ReviewVerdictKind,
|
|
39
|
+
type VerifyOutcome,
|
|
40
|
+
} from '../ontology'
|
|
41
|
+
import { reduceBuild, type AnsweredEscalation } from './reducer'
|
|
42
|
+
import { stalledChains, type FindingChain } from './stall'
|
|
43
|
+
|
|
44
|
+
// ── The decision contract ────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export type WaitReason = 'blocked' | 'paused' | 'awaiting-spec' | 'awaiting-pr' | 'done' | 'aborted'
|
|
47
|
+
|
|
48
|
+
export type VerifyAction =
|
|
49
|
+
| { kind: 'run-check'; step: string; command: string; attempt: number }
|
|
50
|
+
| {
|
|
51
|
+
kind: 'run-agent-verify'
|
|
52
|
+
step: string
|
|
53
|
+
skill: string
|
|
54
|
+
attempt: number
|
|
55
|
+
feedback?: Feedback
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type FinalizeAction = { kind: 'check'; command: string } | { kind: 'agent'; skill: string }
|
|
59
|
+
|
|
60
|
+
export type Decision =
|
|
61
|
+
| { kind: 'wait'; reason: WaitReason }
|
|
62
|
+
| { kind: 'acknowledge'; command: 'pause' | 'resume' | 'abort' }
|
|
63
|
+
| {
|
|
64
|
+
kind: 'run-phase'
|
|
65
|
+
phase: CorePhase
|
|
66
|
+
round: number
|
|
67
|
+
feedback?: Feedback
|
|
68
|
+
/** Present iff phase === 'reconcile'; execution resolves its base (§15.7). */
|
|
69
|
+
reconcile?: { attempt: number }
|
|
70
|
+
}
|
|
71
|
+
| VerifyAction
|
|
72
|
+
| {
|
|
73
|
+
/** Kernel-side path gating to resolve against the live Git diff. */
|
|
74
|
+
kind: 'evaluate-verify'
|
|
75
|
+
step: string
|
|
76
|
+
attempt: number
|
|
77
|
+
paths: string[]
|
|
78
|
+
action: VerifyAction
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
/** Kernel-authored exclusion that launches no verifier work. */
|
|
82
|
+
kind: 'skip-verify'
|
|
83
|
+
step: string
|
|
84
|
+
attempt: number
|
|
85
|
+
reason: string
|
|
86
|
+
}
|
|
87
|
+
| { kind: 'run-finalize-step'; step: string; action: FinalizeAction }
|
|
88
|
+
| {
|
|
89
|
+
/** Authoritative repeat-conflict observation before policy routing. */
|
|
90
|
+
kind: 'check-reconcile-progress'
|
|
91
|
+
conflictSeq: number
|
|
92
|
+
completedAttempt: number
|
|
93
|
+
nextAttempt: number
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
kind: 'raise-escalation'
|
|
97
|
+
source: 'agent' | 'stall'
|
|
98
|
+
phase: Phase
|
|
99
|
+
round?: number
|
|
100
|
+
question: string
|
|
101
|
+
refs?: string[]
|
|
102
|
+
}
|
|
103
|
+
| {
|
|
104
|
+
kind: 'raise-escalation'
|
|
105
|
+
source: 'policy'
|
|
106
|
+
policyCause: PolicyEscalationCause
|
|
107
|
+
phase: Phase
|
|
108
|
+
round?: number
|
|
109
|
+
question: string
|
|
110
|
+
refs?: string[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Raw-log index ────────────────────────────────────────────────────────────
|
|
114
|
+
//
|
|
115
|
+
// Every field here is a projection the reducer does not carry (or carries
|
|
116
|
+
// without the seq the engine routes on). Loop and verify progress counts only
|
|
117
|
+
// events with seq > restartSeq (§6.3 immutability: every reviewer approves
|
|
118
|
+
// conformance to ONE spec revision — a drifting spec silently converts
|
|
119
|
+
// approvals into approvals-of-something-else, so approvals of an old spec do
|
|
120
|
+
// not carry across `spec.revised`).
|
|
121
|
+
|
|
122
|
+
interface VerdictRecord {
|
|
123
|
+
seq: number
|
|
124
|
+
round: number
|
|
125
|
+
verdict: ReviewVerdictKind
|
|
126
|
+
findings: Finding[]
|
|
127
|
+
reason?: string
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface PlanCompletionRecord {
|
|
131
|
+
seq: number
|
|
132
|
+
artifact: ArtifactRef
|
|
133
|
+
verifySteps?: string[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface RoundRecord {
|
|
137
|
+
startedSeq?: number
|
|
138
|
+
completedSeq?: number
|
|
139
|
+
/** Plan only. Kept separately so approval snapshots this exact completion. */
|
|
140
|
+
planCompletion?: PlanCompletionRecord
|
|
141
|
+
verdict?: VerdictRecord
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface LoopIndex {
|
|
145
|
+
/**
|
|
146
|
+
* Max round in any of this loop's events over the FULL log. Round numbers
|
|
147
|
+
* continue monotonically across spec restarts (next round = max round ever
|
|
148
|
+
* seen + 1) so the log stays unambiguous: "plan round 1" must name exactly
|
|
149
|
+
* one producer run, not one per spec revision.
|
|
150
|
+
*/
|
|
151
|
+
maxRoundEver: number
|
|
152
|
+
/** Max round with post-restart events; 0 when the loop is untouched. */
|
|
153
|
+
maxRound: number
|
|
154
|
+
/** Post-restart per-round event records (latest occurrence wins). */
|
|
155
|
+
rounds: Map<number, RoundRecord>
|
|
156
|
+
/** Latest post-restart revise verdict — the findings-feedback source. */
|
|
157
|
+
latestRevise?: VerdictRecord
|
|
158
|
+
/** seq of the latest post-restart approve verdict (verify cycle boundary). */
|
|
159
|
+
latestApproveSeq: number
|
|
160
|
+
/** Plan only: the completion present when the latest approve verdict landed.
|
|
161
|
+
* A later orphan or superseded completion cannot replace this snapshot. */
|
|
162
|
+
approvedPlan?: PlanCompletionRecord
|
|
163
|
+
/** Findings per round (index round-1), post-restart — stalledChains input.
|
|
164
|
+
* The reducer's reviewFindings spans restarts; this one must not. */
|
|
165
|
+
findingsByRound: Finding[][]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface VerifyRecord {
|
|
169
|
+
seq: number
|
|
170
|
+
step: string
|
|
171
|
+
attempt: number
|
|
172
|
+
outcome: VerifyOutcome
|
|
173
|
+
report?: ArtifactRef
|
|
174
|
+
reason?: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface GuidanceDelivery {
|
|
178
|
+
escalation: string
|
|
179
|
+
/** seq of the matching `session.started` that made the carrier actionable. */
|
|
180
|
+
seq: number
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ReconcileStartRecord {
|
|
184
|
+
seq: number
|
|
185
|
+
attempt: number
|
|
186
|
+
baseSha: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface ReconcileCompletionRecord extends ReconcileStartRecord {
|
|
190
|
+
completedSeq: number
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface ReconcileProgressRecord {
|
|
194
|
+
seq: number
|
|
195
|
+
conflictSeq: number
|
|
196
|
+
attempt: number
|
|
197
|
+
baseSha: string
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface LogIndex {
|
|
201
|
+
/** seq of the latest `spec.revised`, else 0 — the restart boundary (§6.3). */
|
|
202
|
+
restartSeq: number
|
|
203
|
+
plan: LoopIndex
|
|
204
|
+
code: LoopIndex
|
|
205
|
+
/** Post-restart `verify.completed` facts with seq, for the same sequence-based
|
|
206
|
+
* cycle boundary query exposed by the reducer (§15.6-A). */
|
|
207
|
+
verifyCompleted: VerifyRecord[]
|
|
208
|
+
/** Post-restart `verify.started` facts — a crashed step re-runs at the SAME
|
|
209
|
+
* attempt (§15.6-C), so the current cycle's attempt must be readable from
|
|
210
|
+
* its start events even before any completion lands. */
|
|
211
|
+
verifyStarted: { seq: number; attempt: number }[]
|
|
212
|
+
/**
|
|
213
|
+
* Max verify attempt in any `verify.started`/`verify.completed` over the
|
|
214
|
+
* FULL log. Attempt numbers continue monotonically across spec restarts and
|
|
215
|
+
* reconcile cycles — the same rationale as LoopIndex.maxRoundEver: the log
|
|
216
|
+
* stays unambiguous ("verify attempt 2" names exactly one cycle) and D5
|
|
217
|
+
* failure keys (verify:<step>, round = attempt) never collide across cycles.
|
|
218
|
+
* This high-water allocates attempt numbers; current-cycle membership is
|
|
219
|
+
* independently sequence-based.
|
|
220
|
+
*/
|
|
221
|
+
maxVerifyAttemptEver: number
|
|
222
|
+
/** seq of the latest post-restart `reconcile.completed` — cycle boundary
|
|
223
|
+
* input: reconciliation changed code, verify re-runs in full (§15.7). */
|
|
224
|
+
lastReconcileCompletedSeq: number
|
|
225
|
+
/** seq of the latest post-restart `implement.completed` — a verify failure
|
|
226
|
+
* with an implement round after it was already routed (§15.6-A). */
|
|
227
|
+
lastImplementCompletedSeq: number
|
|
228
|
+
/** Post-restart `finalize.completed` seen (the reducer only projects pr). */
|
|
229
|
+
finalizeCompleted: boolean
|
|
230
|
+
/** Post-restart `finalize.step-completed` steps, ok true OR false —
|
|
231
|
+
* post-steps are failure-tolerant (§5), so any completion counts. */
|
|
232
|
+
finalizeStepsDone: Set<string>
|
|
233
|
+
/** Latest `pr.conflicted` (full log — the epilogue is restart-orthogonal);
|
|
234
|
+
* only its seq is needed for policy/dedupe. Its baseSha is detection-time
|
|
235
|
+
* evidence, not the reconcile merge target (§15.7). */
|
|
236
|
+
lastConflict?: { seq: number }
|
|
237
|
+
/** A `reconcile.started` after lastConflict without its completion — a
|
|
238
|
+
* crashed reconcile re-runs the SAME attempt from its start (§15.6-C). */
|
|
239
|
+
conflictReconcileStarted?: { attempt: number }
|
|
240
|
+
/** Most recent completed reconcile, paired with the latest same-occurrence
|
|
241
|
+
* start so crash retries use the base that actually completed. */
|
|
242
|
+
lastReconcileCompleted?: ReconcileCompletionRecord
|
|
243
|
+
/** Explicit authoritative observation for the current repeat conflict. */
|
|
244
|
+
lastConflictProgressCheck?: ReconcileProgressRecord
|
|
245
|
+
/** Distinct completed attempts observed still conflicted at the same base.
|
|
246
|
+
* A later attempt start supplies the observation for pre-event logs. */
|
|
247
|
+
reconcileNoProgressCount: number
|
|
248
|
+
/** Guidance carriers that reached a matching durable session launch.
|
|
249
|
+
* Plan, implement, and agent-verify starts may cite feedback (§15.3), but a
|
|
250
|
+
* citation alone remains recoverable across the pre-launch crash boundary.
|
|
251
|
+
* Only a later `session.started` for the same phase and round/attempt marks
|
|
252
|
+
* that answer delivered (§15.6-B). */
|
|
253
|
+
guidanceDeliveries: GuidanceDelivery[]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function decideNext(events: AbEvent[], config: Config): Decision {
|
|
257
|
+
const state = reduceBuild(events)
|
|
258
|
+
const log = indexLog(events)
|
|
259
|
+
|
|
260
|
+
// ── 1. Terminal (§15.5: terminal wins, latest wins) ────────────────────────
|
|
261
|
+
if (state.status === 'aborted') return { kind: 'wait', reason: 'aborted' }
|
|
262
|
+
if (state.status === 'done') return { kind: 'wait', reason: 'done' }
|
|
263
|
+
|
|
264
|
+
// ── 2. Operator commands (D2, §15.2.7) ─────────────────────────────────────
|
|
265
|
+
// Requests queue in the log; the kernel acknowledges with fact events, and a
|
|
266
|
+
// dead runner receives its commands on resume. Abort wins over everything
|
|
267
|
+
// below; paused parks every phase decision until resumed (§15.5 precedence:
|
|
268
|
+
// paused wins over blocked, and the escalation is not lost).
|
|
269
|
+
if (state.pendingCommands.some((c) => c.command === 'abort')) {
|
|
270
|
+
return { kind: 'acknowledge', command: 'abort' }
|
|
271
|
+
}
|
|
272
|
+
if (state.status === 'paused') {
|
|
273
|
+
if (state.pendingCommands.some((c) => c.command === 'resume')) {
|
|
274
|
+
return { kind: 'acknowledge', command: 'resume' }
|
|
275
|
+
}
|
|
276
|
+
return { kind: 'wait', reason: 'paused' }
|
|
277
|
+
}
|
|
278
|
+
if (state.pendingCommands.some((c) => c.command === 'pause')) {
|
|
279
|
+
return { kind: 'acknowledge', command: 'pause' }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── 3. Escalations (§11, §15.6-B) ──────────────────────────────────────────
|
|
283
|
+
// blocked ≡ any open (unanswered) escalation (§15.5).
|
|
284
|
+
if (state.openEscalations.length > 0) return { kind: 'wait', reason: 'blocked' }
|
|
285
|
+
// revise-spec: park until the human lands spec rev N+1 (§6.3); the
|
|
286
|
+
// spec.revised event's seq becomes the restart boundary used everywhere
|
|
287
|
+
// below. guidance and dismiss-finding resolutions do not decide anything
|
|
288
|
+
// here — they route inside the loop rules (feedback priority and stall
|
|
289
|
+
// suppression respectively).
|
|
290
|
+
if (
|
|
291
|
+
state.answeredEscalations.some(
|
|
292
|
+
(e) => e.resolution === 'revise-spec' && e.answeredSeq > log.restartSeq,
|
|
293
|
+
)
|
|
294
|
+
) {
|
|
295
|
+
return { kind: 'wait', reason: 'awaiting-spec' }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── 4. Spec (§6.3: dispatch owns the import; `spec` is not a phase §5) ─────
|
|
299
|
+
if (state.specRev === undefined) return { kind: 'wait', reason: 'awaiting-spec' }
|
|
300
|
+
|
|
301
|
+
// ── Shared routing inputs ──────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
// dismiss-finding contributes the raised escalation's refs to the dismissed
|
|
304
|
+
// set (§15.6-B). The reducer already joins escalation.raised (refs) to
|
|
305
|
+
// escalation.answered (resolution) by id, so no raw-event join is needed.
|
|
306
|
+
const dismissedIds = new Set<string>()
|
|
307
|
+
for (const e of state.answeredEscalations) {
|
|
308
|
+
if (e.resolution !== 'dismiss-finding') continue
|
|
309
|
+
for (const ref of e.refs ?? []) dismissedIds.add(ref)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// "Has this exact escalation class already landed after seq X?" — open and
|
|
313
|
+
// answered escalations both carry their raise seq, source, and target, so
|
|
314
|
+
// the union is the complete durable deduplication history. Both dimensions
|
|
315
|
+
// must match: a later raise for another source or target cannot repair this
|
|
316
|
+
// condition's crash gap or acknowledge its exhausted budget.
|
|
317
|
+
const allRaised = [...state.openEscalations, ...state.answeredEscalations]
|
|
318
|
+
const raisedAfter = (seq: number, source: EscalationSource, phase: EscalationTarget): boolean =>
|
|
319
|
+
allRaised.some((e) => e.seq > seq && e.source === source && e.phase === phase)
|
|
320
|
+
const reconcileNoProgressRaisedAfter = (seq: number): boolean =>
|
|
321
|
+
allRaised.some(
|
|
322
|
+
(e) =>
|
|
323
|
+
e.seq > seq &&
|
|
324
|
+
e.source === 'policy' &&
|
|
325
|
+
e.phase === 'reconcile' &&
|
|
326
|
+
(e.policyCause === 'reconcile-no-progress' ||
|
|
327
|
+
// Compatibility for durable logs written before policyCause existed.
|
|
328
|
+
// An explicit different cause must never inherit this old meaning.
|
|
329
|
+
(e.policyCause === undefined && e.round === undefined)),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
// Latest-only guidance for an explicit destination (§15.6-B). Plan/code
|
|
333
|
+
// escalations feed their producer; an agent verifier's own escalation feeds
|
|
334
|
+
// that exact verifier occurrence; policy verify guidance keeps its existing
|
|
335
|
+
// implement destination. A cited *.started payload carries the answer; its
|
|
336
|
+
// matching durable session launch consumes it. Select the destination winner
|
|
337
|
+
// before checking delivery: a newer answer durably supersedes older
|
|
338
|
+
// same-destination answers, so consuming the winner cannot reveal one of them
|
|
339
|
+
// later. An answer appended after that delivery becomes the new winner and
|
|
340
|
+
// remains eligible.
|
|
341
|
+
const guidanceFeedback = (destination: GuidanceDestination): Feedback | undefined => {
|
|
342
|
+
let latest: AnsweredEscalation | undefined
|
|
343
|
+
for (const e of state.answeredEscalations) {
|
|
344
|
+
if (e.resolution !== 'guidance' || guidanceDestination(e) !== destination) continue
|
|
345
|
+
if (latest === undefined || e.answeredSeq > latest.answeredSeq) latest = e
|
|
346
|
+
}
|
|
347
|
+
if (
|
|
348
|
+
latest === undefined ||
|
|
349
|
+
log.guidanceDeliveries.some(
|
|
350
|
+
(delivery) => delivery.escalation === latest.id && delivery.seq > latest.answeredSeq,
|
|
351
|
+
)
|
|
352
|
+
) {
|
|
353
|
+
return undefined
|
|
354
|
+
}
|
|
355
|
+
return { guidance: { escalation: latest.id, answer: latest.answer } }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Verify cycle (§15.6-A): results only count after the latest of (last
|
|
359
|
+
// code-review approve, last reconcile.completed) — implement or reconcile
|
|
360
|
+
// changed the code, so earlier results describe code that no longer exists
|
|
361
|
+
// and the cycle re-runs from the FIRST step, cheap checks first.
|
|
362
|
+
const cycleBoundary = Math.max(log.code.latestApproveSeq, log.lastReconcileCompletedSeq)
|
|
363
|
+
const cycleResults = log.verifyCompleted.filter((v) => v.seq > cycleBoundary)
|
|
364
|
+
const cycleFails = cycleResults.filter((v) => v.outcome === 'fail')
|
|
365
|
+
const lastCycleFail = cycleFails.at(-1)
|
|
366
|
+
// "Without a subsequent implement round": an implement.completed after the
|
|
367
|
+
// failure means the code loop already picked it up. implement.*started* is
|
|
368
|
+
// deliberately not enough — a crashed fail-routed round must recompute the
|
|
369
|
+
// same verify feedback on re-run (§15.6-C).
|
|
370
|
+
const pendingFail =
|
|
371
|
+
lastCycleFail !== undefined && lastCycleFail.seq > log.lastImplementCompletedSeq
|
|
372
|
+
? lastCycleFail
|
|
373
|
+
: undefined
|
|
374
|
+
|
|
375
|
+
const findingsFeedback = (loop: LoopIndex): Feedback | undefined =>
|
|
376
|
+
loop.latestRevise === undefined
|
|
377
|
+
? undefined
|
|
378
|
+
: { findings: loop.latestRevise.findings.map((f) => f.id) }
|
|
379
|
+
// A failure without a report has nothing to materialize (§8.3 routes the
|
|
380
|
+
// report into .ab/verify/); the run-phase decision still fires without it.
|
|
381
|
+
const verifyFeedback = (): Feedback | undefined =>
|
|
382
|
+
pendingFail?.report === undefined
|
|
383
|
+
? undefined
|
|
384
|
+
: { verify: { step: pendingFail.step, report: pendingFail.report } }
|
|
385
|
+
|
|
386
|
+
// ── 5. Plan loop (§5, §10): rounds pair plan ⇄ plan-review ─────────────────
|
|
387
|
+
const planDecision = decideLoop({
|
|
388
|
+
loop: log.plan,
|
|
389
|
+
producer: 'plan',
|
|
390
|
+
reviewer: 'plan-review',
|
|
391
|
+
policy: config.policy,
|
|
392
|
+
reviewRoundCeiling: state.reviewRoundCeilings.plan,
|
|
393
|
+
dismissedIds,
|
|
394
|
+
raisedAfter,
|
|
395
|
+
// Feedback: unconsumed guidance, else the latest revise verdict's ids.
|
|
396
|
+
producerFeedback: () => guidanceFeedback('plan') ?? findingsFeedback(log.plan),
|
|
397
|
+
})
|
|
398
|
+
if (planDecision !== 'approved') return planDecision
|
|
399
|
+
|
|
400
|
+
// ── 6. Code loop: identical structure, plus verify routing ─────────────────
|
|
401
|
+
const codeDecision = decideLoop({
|
|
402
|
+
loop: log.code,
|
|
403
|
+
producer: 'implement',
|
|
404
|
+
reviewer: 'code-review',
|
|
405
|
+
policy: config.policy,
|
|
406
|
+
reviewRoundCeiling: state.reviewRoundCeilings.code,
|
|
407
|
+
dismissedIds,
|
|
408
|
+
raisedAfter,
|
|
409
|
+
// Feedback priority: guidance > verify failure > findings (§15.6-A/B).
|
|
410
|
+
producerFeedback: () =>
|
|
411
|
+
guidanceFeedback('code') ?? verifyFeedback() ?? findingsFeedback(log.code),
|
|
412
|
+
})
|
|
413
|
+
if (codeDecision !== 'approved') return codeDecision
|
|
414
|
+
|
|
415
|
+
// ── 7. Verify (§5, §15.6-A) — gated on code-review approved ────────────────
|
|
416
|
+
// Exhaustion counts post-restart failures only; pre-restart results
|
|
417
|
+
// (including failures) do not carry across a spec revision (§6.3). Attempt
|
|
418
|
+
// NUMBERS, by contrast, continue monotonically over the full log — see the
|
|
419
|
+
// attempt computation below and LogIndex.maxVerifyAttemptEver.
|
|
420
|
+
const fails = log.verifyCompleted.filter((v) => v.outcome === 'fail')
|
|
421
|
+
const lastFail = fails.at(-1)
|
|
422
|
+
if (
|
|
423
|
+
lastFail !== undefined &&
|
|
424
|
+
fails.length >= config.policy.maxVerifyAttempts &&
|
|
425
|
+
!raisedAfter(lastFail.seq, 'policy', verifyPhase(lastFail.step))
|
|
426
|
+
) {
|
|
427
|
+
// Exhaustion escalates once per failure: an exact policy raise for this
|
|
428
|
+
// verify target after the last failure (answered or not) suppresses replay;
|
|
429
|
+
// a NEW failure re-arms it.
|
|
430
|
+
return {
|
|
431
|
+
kind: 'raise-escalation',
|
|
432
|
+
source: 'policy',
|
|
433
|
+
policyCause: 'verify-failure-limit',
|
|
434
|
+
phase: verifyPhase(lastFail.step),
|
|
435
|
+
question: `maxVerifyAttempts (${config.policy.maxVerifyAttempts}) exhausted: verify:${lastFail.step} is still failing`,
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (pendingFail !== undefined) {
|
|
439
|
+
// A pending fail in the current cycle routes back into the code loop
|
|
440
|
+
// (§15.6-A) — it never re-runs the step directly. Guidance outranks the
|
|
441
|
+
// verify report (§15.6-B: a human answer is authoritative feedback).
|
|
442
|
+
const feedback = guidanceFeedback('code') ?? verifyFeedback()
|
|
443
|
+
const round = log.code.maxRound + 1
|
|
444
|
+
return feedback === undefined
|
|
445
|
+
? { kind: 'run-phase', phase: 'implement', round }
|
|
446
|
+
: { kind: 'run-phase', phase: 'implement', round, feedback }
|
|
447
|
+
}
|
|
448
|
+
// Attempt numbering (§15.6-A): every step in one cycle shares one attempt.
|
|
449
|
+
// A cycle already underway (started or completed events after the boundary)
|
|
450
|
+
// keeps its number — a crashed step re-runs at the SAME attempt (§15.6-C);
|
|
451
|
+
// a fresh cycle takes max attempt ever + 1, monotonic across spec restarts
|
|
452
|
+
// and reconcile cycles exactly like loop rounds (see maxVerifyAttemptEver).
|
|
453
|
+
const cycleStarted = log.verifyStarted.filter((v) => v.seq > cycleBoundary)
|
|
454
|
+
const cycleAttempts = [...cycleResults, ...cycleStarted].map((v) => v.attempt)
|
|
455
|
+
const attempt =
|
|
456
|
+
cycleAttempts.length > 0 ? Math.max(...cycleAttempts) : log.maxVerifyAttemptEver + 1
|
|
457
|
+
const approvedPlan = log.plan.approvedPlan
|
|
458
|
+
const approvedSelection =
|
|
459
|
+
approvedPlan?.verifySteps === undefined
|
|
460
|
+
? undefined
|
|
461
|
+
: {
|
|
462
|
+
steps: new Set(approvedPlan.verifySteps),
|
|
463
|
+
planRev: approvedPlan.artifact.rev,
|
|
464
|
+
}
|
|
465
|
+
for (const step of config.verify.steps) {
|
|
466
|
+
// First unsatisfied step in the current cycle runs next — only an explicit
|
|
467
|
+
// pass or skip satisfies that step. A failure anywhere was handled above
|
|
468
|
+
// and therefore can never be hidden by another step's skip.
|
|
469
|
+
if (
|
|
470
|
+
cycleResults.some((v) => v.step === step && (v.outcome === 'pass' || v.outcome === 'skipped'))
|
|
471
|
+
) {
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
474
|
+
const stepConfig = config.verify.stepConfigs[step]
|
|
475
|
+
if (stepConfig === undefined) continue // unreachable: configSchema cross-validates (§16.1)
|
|
476
|
+
|
|
477
|
+
// Missing selection data is the historical default-all behavior. For a
|
|
478
|
+
// current explicit selection, exclusion precedes path evaluation and any
|
|
479
|
+
// verifier construction. Mandatory gates remain selected defensively even
|
|
480
|
+
// if a directly-authored event bypassed the CLI's deposit validation.
|
|
481
|
+
if (
|
|
482
|
+
approvedSelection !== undefined &&
|
|
483
|
+
!approvedSelection.steps.has(step) &&
|
|
484
|
+
stepConfig.always !== true
|
|
485
|
+
) {
|
|
486
|
+
return {
|
|
487
|
+
kind: 'skip-verify',
|
|
488
|
+
step,
|
|
489
|
+
attempt,
|
|
490
|
+
reason:
|
|
491
|
+
`excluded by approved plan selection (plan@${approvedSelection.planRev}): ` +
|
|
492
|
+
`verify step ${JSON.stringify(step)} was not selected`,
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const verifierFeedback = guidanceFeedback(verifyPhase(step))
|
|
497
|
+
const action: VerifyAction =
|
|
498
|
+
stepConfig.kind === 'check'
|
|
499
|
+
? {
|
|
500
|
+
// Resolve the [commands] ref (§16.1) — config validation guarantees
|
|
501
|
+
// it exists; the raw-ref fallback only keeps decideNext total.
|
|
502
|
+
kind: 'run-check',
|
|
503
|
+
step,
|
|
504
|
+
command: config.commands[stepConfig.command] ?? stepConfig.command,
|
|
505
|
+
attempt,
|
|
506
|
+
}
|
|
507
|
+
: {
|
|
508
|
+
kind: 'run-agent-verify',
|
|
509
|
+
step,
|
|
510
|
+
skill: stepConfig.skill,
|
|
511
|
+
attempt,
|
|
512
|
+
...(verifierFeedback !== undefined ? { feedback: verifierFeedback } : {}),
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Omitted paths preserve the historical unconditional behavior. Explicit
|
|
516
|
+
// `always = true` wins even when selectors are present, making the
|
|
517
|
+
// mandatory-gate guard structural rather than dependent on truthiness.
|
|
518
|
+
if (stepConfig.paths === undefined || stepConfig.always === true) return action
|
|
519
|
+
return {
|
|
520
|
+
kind: 'evaluate-verify',
|
|
521
|
+
step,
|
|
522
|
+
attempt,
|
|
523
|
+
paths: stepConfig.paths,
|
|
524
|
+
action,
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── 8. Finalize (§5): all verify steps satisfied in the current cycle ─────
|
|
529
|
+
if (!log.finalizeCompleted) return { kind: 'run-phase', phase: 'finalize', round: 1 }
|
|
530
|
+
for (const step of config.finalize.steps) {
|
|
531
|
+
// Post-steps are independent and failure-tolerant (§5): a completion with
|
|
532
|
+
// ok false still counts — it filed its observation and never re-runs.
|
|
533
|
+
if (log.finalizeStepsDone.has(step)) continue
|
|
534
|
+
const stepConfig = config.finalize.stepConfigs[step]
|
|
535
|
+
if (stepConfig === undefined) continue // unreachable: configSchema cross-validates
|
|
536
|
+
const action: FinalizeAction =
|
|
537
|
+
stepConfig.kind === 'check'
|
|
538
|
+
? {
|
|
539
|
+
kind: 'check',
|
|
540
|
+
// Resolve the [commands] ref; fallback keeps decideNext total for
|
|
541
|
+
// directly-constructed Config values that bypass validation.
|
|
542
|
+
command: config.commands[stepConfig.command] ?? stepConfig.command,
|
|
543
|
+
}
|
|
544
|
+
: { kind: 'agent', skill: stepConfig.skill }
|
|
545
|
+
return { kind: 'run-finalize-step', step, action }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ── 9. Post-PR epilogue (§15.7): finalize → (conflicted → reconcile →
|
|
549
|
+
// verify:*)* → merged | closed. The dispatcher's janitor emits pr.* and
|
|
550
|
+
// completes the build; the engine only ever runs reconcile here.
|
|
551
|
+
if (state.prState === 'conflicted' && log.lastConflict !== undefined) {
|
|
552
|
+
if (log.conflictReconcileStarted !== undefined) {
|
|
553
|
+
// Crashed reconcile: re-run the SAME attempt from its start (§15.6-C) —
|
|
554
|
+
// the reducer's reconcileAttempts is the kernel's own counter precisely
|
|
555
|
+
// so a re-run does not double-count.
|
|
556
|
+
const crashAttempt = log.conflictReconcileStarted.attempt
|
|
557
|
+
return {
|
|
558
|
+
kind: 'run-phase',
|
|
559
|
+
phase: 'reconcile',
|
|
560
|
+
round: crashAttempt,
|
|
561
|
+
reconcile: { attempt: crashAttempt },
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const nextAttempt = state.reconcileAttempts + 1
|
|
565
|
+
const completed = log.lastReconcileCompleted
|
|
566
|
+
if (completed !== undefined && completed.completedSeq < log.lastConflict.seq) {
|
|
567
|
+
const progress = log.lastConflictProgressCheck
|
|
568
|
+
if (progress === undefined || progress.attempt !== completed.attempt) {
|
|
569
|
+
// A repeat conflict cannot spend policy budget using the janitor's
|
|
570
|
+
// detection-time base. Ask execution for a fresh authoritative fact;
|
|
571
|
+
// replay after that append consumes the same durable decision.
|
|
572
|
+
return {
|
|
573
|
+
kind: 'check-reconcile-progress',
|
|
574
|
+
conflictSeq: log.lastConflict.seq,
|
|
575
|
+
completedAttempt: completed.attempt,
|
|
576
|
+
nextAttempt,
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const baseUnchanged = progress.baseSha === completed.baseSha
|
|
581
|
+
if (
|
|
582
|
+
baseUnchanged &&
|
|
583
|
+
log.reconcileNoProgressCount >= config.policy.maxReconcileAttempts &&
|
|
584
|
+
!reconcileNoProgressRaisedAfter(log.lastConflict.seq)
|
|
585
|
+
) {
|
|
586
|
+
// Only reconciles that leave the PR conflicted against the same
|
|
587
|
+
// authoritative base consume this budget. policyCause is this
|
|
588
|
+
// conflict-scoped condition's durable identity; round remains only
|
|
589
|
+
// occurrence scope. A moving-base race always gets another monotonic
|
|
590
|
+
// attempt, even after prior no-progress outcomes.
|
|
591
|
+
return {
|
|
592
|
+
kind: 'raise-escalation',
|
|
593
|
+
source: 'policy',
|
|
594
|
+
policyCause: 'reconcile-no-progress',
|
|
595
|
+
phase: 'reconcile',
|
|
596
|
+
question:
|
|
597
|
+
`maxReconcileAttempts (${config.policy.maxReconcileAttempts}) exhausted: ` +
|
|
598
|
+
'reconciliation made no progress against an unchanged base',
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
kind: 'run-phase',
|
|
604
|
+
phase: 'reconcile',
|
|
605
|
+
round: nextAttempt,
|
|
606
|
+
reconcile: { attempt: nextAttempt },
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// open → the janitor is watching the PR; merged/closed → the janitor
|
|
610
|
+
// releases the workspace and emits build.completed (§15.7).
|
|
611
|
+
return { kind: 'wait', reason: 'awaiting-pr' }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Review-loop transition (rules 5 and 6 share one structure — §10) ─────────
|
|
615
|
+
|
|
616
|
+
interface LoopArgs {
|
|
617
|
+
loop: LoopIndex
|
|
618
|
+
producer: 'plan' | 'implement'
|
|
619
|
+
reviewer: 'plan-review' | 'code-review'
|
|
620
|
+
policy: Config['policy']
|
|
621
|
+
reviewRoundCeiling?: number
|
|
622
|
+
dismissedIds: ReadonlySet<string>
|
|
623
|
+
raisedAfter: (seq: number, source: EscalationSource, phase: EscalationTarget) => boolean
|
|
624
|
+
producerFeedback: () => Feedback | undefined
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function decideLoop(args: LoopArgs): Decision | 'approved' {
|
|
628
|
+
const {
|
|
629
|
+
loop,
|
|
630
|
+
producer,
|
|
631
|
+
reviewer,
|
|
632
|
+
policy,
|
|
633
|
+
reviewRoundCeiling,
|
|
634
|
+
dismissedIds,
|
|
635
|
+
raisedAfter,
|
|
636
|
+
producerFeedback,
|
|
637
|
+
} = args
|
|
638
|
+
const runProducer = (round: number): Decision => {
|
|
639
|
+
const feedback = producerFeedback()
|
|
640
|
+
return feedback === undefined
|
|
641
|
+
? { kind: 'run-phase', phase: producer, round }
|
|
642
|
+
: { kind: 'run-phase', phase: producer, round, feedback }
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// No rounds since the restart boundary: the loop is due. Round numbers
|
|
646
|
+
// continue monotonically across restarts (§6.3): next = max ever seen + 1.
|
|
647
|
+
if (loop.maxRound === 0) return runProducer(loop.maxRoundEver + 1)
|
|
648
|
+
|
|
649
|
+
const round = loop.rounds.get(loop.maxRound)
|
|
650
|
+
const verdict = round?.verdict
|
|
651
|
+
|
|
652
|
+
if (verdict === undefined) {
|
|
653
|
+
if (round?.completedSeq !== undefined) {
|
|
654
|
+
// Producer completed, no verdict yet: the reviewer is due (or crashed
|
|
655
|
+
// mid-round and re-runs from its start — §15.6-C).
|
|
656
|
+
return { kind: 'run-phase', phase: reviewer, round: loop.maxRound }
|
|
657
|
+
}
|
|
658
|
+
// Started-or-due without a completion is a crashed producer: re-run the
|
|
659
|
+
// same round from its start (§15.6-C); the runner owns retry counting,
|
|
660
|
+
// not the engine (§8.4). Feedback is recomputed, never replayed.
|
|
661
|
+
return runProducer(loop.maxRound)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (verdict.verdict === 'approve') return 'approved'
|
|
665
|
+
|
|
666
|
+
if (verdict.verdict === 'escalate') {
|
|
667
|
+
// CLI crash gap: the verdict landed but its escalation.raised did not
|
|
668
|
+
// (§8.5 makes the pair near-atomic; the repair keeps decideNext total).
|
|
669
|
+
if (!raisedAfter(verdict.seq, 'agent', reviewer)) {
|
|
670
|
+
return {
|
|
671
|
+
kind: 'raise-escalation',
|
|
672
|
+
source: 'agent',
|
|
673
|
+
phase: reviewer,
|
|
674
|
+
round: verdict.round,
|
|
675
|
+
question: verdict.reason ?? 'reviewer escalated',
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// The escalation exists and was answered (an open one already returned
|
|
679
|
+
// wait/blocked in rule 3): the loop proceeds to the next producer round;
|
|
680
|
+
// a guidance answer rides producerFeedback (§15.6-B).
|
|
681
|
+
return runProducer(loop.maxRound + 1)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// revise — stall check FIRST (§15.4), then the policy round cap (§10), then
|
|
685
|
+
// the next producer round with findings feedback.
|
|
686
|
+
const stalled = liveChains(
|
|
687
|
+
stalledChains(loop.findingsByRound, policy.stallRounds, dismissedIds),
|
|
688
|
+
loop.findingsByRound,
|
|
689
|
+
verdict.round,
|
|
690
|
+
)
|
|
691
|
+
if (stalled.length > 0 && !raisedAfter(verdict.seq, 'stall', reviewer)) {
|
|
692
|
+
// Deepest chain reported; first in root order on ties (converge does the
|
|
693
|
+
// same). Dedupe: raise once per verdict — a stall raise for this reviewer
|
|
694
|
+
// already recorded after this verdict (answered or not) suppresses replay.
|
|
695
|
+
const chain = stalled.reduce((deepest, candidate) =>
|
|
696
|
+
candidate.rounds > deepest.rounds ? candidate : deepest,
|
|
697
|
+
)
|
|
698
|
+
return {
|
|
699
|
+
kind: 'raise-escalation',
|
|
700
|
+
source: 'stall',
|
|
701
|
+
phase: reviewer,
|
|
702
|
+
round: verdict.round,
|
|
703
|
+
question: `finding chain persisted ${chain.rounds} rounds: ${chain.ids.join(' -> ')}`,
|
|
704
|
+
refs: chain.ids,
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const effectiveCeiling = reviewRoundCeiling ?? policy.maxReviewRounds
|
|
708
|
+
const reviewedRounds = [...loop.rounds.values()].filter(
|
|
709
|
+
(record) => record.verdict !== undefined,
|
|
710
|
+
).length
|
|
711
|
+
if (reviewedRounds >= effectiveCeiling && !raisedAfter(verdict.seq, 'policy', reviewer)) {
|
|
712
|
+
return {
|
|
713
|
+
kind: 'raise-escalation',
|
|
714
|
+
source: 'policy',
|
|
715
|
+
policyCause: 'review-round-limit',
|
|
716
|
+
phase: reviewer,
|
|
717
|
+
round: verdict.round,
|
|
718
|
+
question: `maxReviewRounds (${effectiveCeiling}) exhausted without approval`,
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return runProducer(loop.maxRound + 1)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* §15.4 reads "a chain survives N rounds" as a LIVE streak. `stalledChains`
|
|
726
|
+
* reports the longest historical streak, but a chain the current round's
|
|
727
|
+
* reviewer did not continue was judged resolved by that round's fresh skeptic
|
|
728
|
+
* (see src/kernel/stall.ts's streak-break rationale) — re-raising it would
|
|
729
|
+
* park the build on a disagreement nobody is still having, e.g. on the first
|
|
730
|
+
* revise after a guidance answer settled the chain. Only chains with a member
|
|
731
|
+
* in the round whose verdict is being routed can raise.
|
|
732
|
+
*/
|
|
733
|
+
function liveChains(
|
|
734
|
+
chains: FindingChain[],
|
|
735
|
+
findingsByRound: Finding[][],
|
|
736
|
+
round: number,
|
|
737
|
+
): FindingChain[] {
|
|
738
|
+
const current = new Set((findingsByRound[round - 1] ?? []).map((f) => f.id))
|
|
739
|
+
return chains.filter((chain) => chain.ids.some((id) => current.has(id)))
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
type GuidanceDestination = 'plan' | 'code' | `verify:${string}` | 'other'
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Where an escalation answer is delivered (§15.6-B). Producer/reviewer and
|
|
746
|
+
* policy/stall routes retain their loop producer. The deliberate exception is
|
|
747
|
+
* an agent verifier's own `ab escalate`: its answer returns to the exact
|
|
748
|
+
* verifier that asked, while a policy escalation after verify failures still
|
|
749
|
+
* feeds implement. Finalize/reconcile guidance travels through `ab context`.
|
|
750
|
+
*/
|
|
751
|
+
function guidanceDestination(
|
|
752
|
+
escalation: Pick<AnsweredEscalation, 'phase' | 'source'>,
|
|
753
|
+
): GuidanceDestination {
|
|
754
|
+
const { phase, source } = escalation
|
|
755
|
+
if (phase === 'plan' || phase === 'plan-review') return 'plan'
|
|
756
|
+
if (phase === 'implement' || phase === 'code-review') return 'code'
|
|
757
|
+
if (isVerifyPhase(phase)) return source === 'agent' ? phase : 'code'
|
|
758
|
+
return 'other'
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ── Raw-log indexing ─────────────────────────────────────────────────────────
|
|
762
|
+
|
|
763
|
+
type ReviewVerdictPayload = EventPayload<'plan-review.verdict'>
|
|
764
|
+
|
|
765
|
+
function emptyLoop(): LoopIndex {
|
|
766
|
+
return {
|
|
767
|
+
maxRoundEver: 0,
|
|
768
|
+
maxRound: 0,
|
|
769
|
+
rounds: new Map(),
|
|
770
|
+
latestApproveSeq: 0,
|
|
771
|
+
findingsByRound: [],
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function indexLog(events: AbEvent[]): LogIndex {
|
|
776
|
+
// Pass 1: the restart boundary — seq of the latest spec.revised (§6.3).
|
|
777
|
+
let restartSeq = 0
|
|
778
|
+
for (const event of events) {
|
|
779
|
+
if (event.type === 'spec.revised') restartSeq = event.seq
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const plan = emptyLoop()
|
|
783
|
+
const code = emptyLoop()
|
|
784
|
+
const verifyCompleted: VerifyRecord[] = []
|
|
785
|
+
const verifyStarted: { seq: number; attempt: number }[] = []
|
|
786
|
+
let maxVerifyAttemptEver = 0
|
|
787
|
+
const finalizeStepsDone = new Set<string>()
|
|
788
|
+
const guidanceDeliveries: GuidanceDelivery[] = []
|
|
789
|
+
const pendingGuidanceStarts = new Map<string, { escalation: string; seq: number }>()
|
|
790
|
+
let lastReconcileCompletedSeq = 0
|
|
791
|
+
let lastImplementCompletedSeq = 0
|
|
792
|
+
let finalizeCompleted = false
|
|
793
|
+
let lastConflict: { seq: number } | undefined
|
|
794
|
+
let conflictReconcileStarted: { attempt: number } | undefined
|
|
795
|
+
const conflictSeqs = new Set<number>()
|
|
796
|
+
const reconcileStarts: ReconcileStartRecord[] = []
|
|
797
|
+
const reconcileCompletions: ReconcileCompletionRecord[] = []
|
|
798
|
+
const reconcileProgressChecks: ReconcileProgressRecord[] = []
|
|
799
|
+
let activeReconcileStart: ReconcileStartRecord | undefined
|
|
800
|
+
|
|
801
|
+
/** Track a loop round: maxRoundEver over the full log; the per-round record
|
|
802
|
+
* only for post-restart events (returns undefined pre-restart). */
|
|
803
|
+
const roundRecord = (loop: LoopIndex, r: number, post: boolean): RoundRecord | undefined => {
|
|
804
|
+
if (r > loop.maxRoundEver) loop.maxRoundEver = r
|
|
805
|
+
if (!post) return undefined
|
|
806
|
+
if (r > loop.maxRound) loop.maxRound = r
|
|
807
|
+
let record = loop.rounds.get(r)
|
|
808
|
+
if (record === undefined) {
|
|
809
|
+
record = {}
|
|
810
|
+
loop.rounds.set(r, record)
|
|
811
|
+
}
|
|
812
|
+
return record
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const noteGuidanceStart = (key: string, feedback: Feedback | undefined, seq: number): void => {
|
|
816
|
+
// A newer start is the authoritative carrier for this exact occurrence.
|
|
817
|
+
// In particular, a guidance-free retry must not let its later session
|
|
818
|
+
// consume an older citation that never launched.
|
|
819
|
+
if (feedback !== undefined && 'guidance' in feedback) {
|
|
820
|
+
pendingGuidanceStarts.set(key, { escalation: feedback.guidance.escalation, seq })
|
|
821
|
+
} else {
|
|
822
|
+
pendingGuidanceStarts.delete(key)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const noteVerdict = (
|
|
827
|
+
loop: LoopIndex,
|
|
828
|
+
payload: ReviewVerdictPayload,
|
|
829
|
+
seq: number,
|
|
830
|
+
post: boolean,
|
|
831
|
+
): void => {
|
|
832
|
+
const record = roundRecord(loop, payload.round, post)
|
|
833
|
+
if (record === undefined) return
|
|
834
|
+
const verdict: VerdictRecord = {
|
|
835
|
+
seq,
|
|
836
|
+
round: payload.round,
|
|
837
|
+
verdict: payload.verdict,
|
|
838
|
+
findings: payload.findings,
|
|
839
|
+
reason: payload.reason,
|
|
840
|
+
}
|
|
841
|
+
record.verdict = verdict
|
|
842
|
+
if (payload.verdict === 'revise') loop.latestRevise = verdict
|
|
843
|
+
if (payload.verdict === 'approve') {
|
|
844
|
+
loop.latestApproveSeq = seq
|
|
845
|
+
// Snapshot at verdict time. A plan.completed appended later — even for
|
|
846
|
+
// this round — was never reviewed and therefore has no authority.
|
|
847
|
+
if (loop === plan) loop.approvedPlan = record.planCompletion
|
|
848
|
+
}
|
|
849
|
+
// Findings per round, reducer-style padding (rounds without verdicts stay
|
|
850
|
+
// empty — including every pre-restart round, which is the point).
|
|
851
|
+
while (loop.findingsByRound.length < payload.round) loop.findingsByRound.push([])
|
|
852
|
+
loop.findingsByRound[payload.round - 1] = payload.findings
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
for (const event of events) {
|
|
856
|
+
const post = event.seq > restartSeq
|
|
857
|
+
switch (event.type) {
|
|
858
|
+
case 'plan.started': {
|
|
859
|
+
noteGuidanceStart(`plan@${event.payload.round}`, event.payload.feedback, event.seq)
|
|
860
|
+
const record = roundRecord(plan, event.payload.round, post)
|
|
861
|
+
if (record !== undefined) record.startedSeq = event.seq
|
|
862
|
+
break
|
|
863
|
+
}
|
|
864
|
+
case 'plan.completed': {
|
|
865
|
+
const record = roundRecord(plan, event.payload.round, post)
|
|
866
|
+
if (record !== undefined) {
|
|
867
|
+
record.completedSeq = event.seq
|
|
868
|
+
record.planCompletion = {
|
|
869
|
+
seq: event.seq,
|
|
870
|
+
artifact: event.payload.artifact,
|
|
871
|
+
...(event.payload.verifySteps !== undefined
|
|
872
|
+
? { verifySteps: [...event.payload.verifySteps] }
|
|
873
|
+
: {}),
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
break
|
|
877
|
+
}
|
|
878
|
+
case 'plan-review.started':
|
|
879
|
+
roundRecord(plan, event.payload.round, post)
|
|
880
|
+
break
|
|
881
|
+
case 'plan-review.verdict':
|
|
882
|
+
noteVerdict(plan, event.payload, event.seq, post)
|
|
883
|
+
break
|
|
884
|
+
|
|
885
|
+
case 'implement.started': {
|
|
886
|
+
noteGuidanceStart(`implement@${event.payload.round}`, event.payload.feedback, event.seq)
|
|
887
|
+
const record = roundRecord(code, event.payload.round, post)
|
|
888
|
+
if (record !== undefined) record.startedSeq = event.seq
|
|
889
|
+
break
|
|
890
|
+
}
|
|
891
|
+
case 'implement.completed': {
|
|
892
|
+
const record = roundRecord(code, event.payload.round, post)
|
|
893
|
+
if (record !== undefined) record.completedSeq = event.seq
|
|
894
|
+
if (post) lastImplementCompletedSeq = event.seq
|
|
895
|
+
break
|
|
896
|
+
}
|
|
897
|
+
case 'code-review.started':
|
|
898
|
+
roundRecord(code, event.payload.round, post)
|
|
899
|
+
break
|
|
900
|
+
case 'code-review.verdict':
|
|
901
|
+
noteVerdict(code, event.payload, event.seq, post)
|
|
902
|
+
break
|
|
903
|
+
|
|
904
|
+
case 'verify.started': {
|
|
905
|
+
noteGuidanceStart(
|
|
906
|
+
`${verifyPhase(event.payload.step)}@${event.payload.attempt}`,
|
|
907
|
+
event.payload.feedback,
|
|
908
|
+
event.seq,
|
|
909
|
+
)
|
|
910
|
+
maxVerifyAttemptEver = Math.max(maxVerifyAttemptEver, event.payload.attempt)
|
|
911
|
+
if (post) verifyStarted.push({ seq: event.seq, attempt: event.payload.attempt })
|
|
912
|
+
break
|
|
913
|
+
}
|
|
914
|
+
case 'session.started': {
|
|
915
|
+
if (event.payload.round === undefined) break
|
|
916
|
+
const key = `${event.payload.phase}@${event.payload.round}`
|
|
917
|
+
const carrier = pendingGuidanceStarts.get(key)
|
|
918
|
+
if (carrier !== undefined && carrier.seq < event.seq) {
|
|
919
|
+
guidanceDeliveries.push({ escalation: carrier.escalation, seq: event.seq })
|
|
920
|
+
pendingGuidanceStarts.delete(key)
|
|
921
|
+
}
|
|
922
|
+
break
|
|
923
|
+
}
|
|
924
|
+
case 'verify.completed': {
|
|
925
|
+
const result = normalizeVerifyCompletion(event.payload)
|
|
926
|
+
maxVerifyAttemptEver = Math.max(maxVerifyAttemptEver, result.attempt)
|
|
927
|
+
if (post) {
|
|
928
|
+
verifyCompleted.push({
|
|
929
|
+
seq: event.seq,
|
|
930
|
+
step: result.step,
|
|
931
|
+
attempt: result.attempt,
|
|
932
|
+
outcome: result.outcome,
|
|
933
|
+
...(result.report !== undefined ? { report: result.report } : {}),
|
|
934
|
+
...(result.reason !== undefined ? { reason: result.reason } : {}),
|
|
935
|
+
})
|
|
936
|
+
}
|
|
937
|
+
break
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
case 'finalize.completed':
|
|
941
|
+
if (post) finalizeCompleted = true
|
|
942
|
+
break
|
|
943
|
+
case 'finalize.step-completed':
|
|
944
|
+
if (post) finalizeStepsDone.add(event.payload.step)
|
|
945
|
+
break
|
|
946
|
+
|
|
947
|
+
case 'pr.conflicted':
|
|
948
|
+
lastConflict = { seq: event.seq }
|
|
949
|
+
conflictSeqs.add(event.seq)
|
|
950
|
+
conflictReconcileStarted = undefined
|
|
951
|
+
break
|
|
952
|
+
case 'reconcile.progress-checked':
|
|
953
|
+
reconcileProgressChecks.push({ seq: event.seq, ...event.payload })
|
|
954
|
+
break
|
|
955
|
+
case 'reconcile.started': {
|
|
956
|
+
const start = { seq: event.seq, ...event.payload }
|
|
957
|
+
reconcileStarts.push(start)
|
|
958
|
+
activeReconcileStart = start
|
|
959
|
+
if (lastConflict !== undefined && event.seq > lastConflict.seq) {
|
|
960
|
+
conflictReconcileStarted = { attempt: event.payload.attempt }
|
|
961
|
+
}
|
|
962
|
+
break
|
|
963
|
+
}
|
|
964
|
+
case 'reconcile.completed':
|
|
965
|
+
if (post) lastReconcileCompletedSeq = event.seq
|
|
966
|
+
if (activeReconcileStart !== undefined) {
|
|
967
|
+
reconcileCompletions.push({
|
|
968
|
+
...activeReconcileStart,
|
|
969
|
+
completedSeq: event.seq,
|
|
970
|
+
})
|
|
971
|
+
activeReconcileStart = undefined
|
|
972
|
+
}
|
|
973
|
+
conflictReconcileStarted = undefined
|
|
974
|
+
break
|
|
975
|
+
|
|
976
|
+
default:
|
|
977
|
+
break
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// One completed occurrence per monotonic attempt. A later duplicate
|
|
982
|
+
// completion is safer to pair with its own latest start than with an older
|
|
983
|
+
// aggregate high-water, and malformed unmatched completions classify nothing.
|
|
984
|
+
const completedByAttempt = new Map<number, ReconcileCompletionRecord>()
|
|
985
|
+
for (const completion of reconcileCompletions) {
|
|
986
|
+
completedByAttempt.set(completion.attempt, completion)
|
|
987
|
+
}
|
|
988
|
+
const completed = [...completedByAttempt.values()].sort(
|
|
989
|
+
(left, right) => left.completedSeq - right.completedSeq,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
let reconcileNoProgressCount = 0
|
|
993
|
+
for (const completion of completed) {
|
|
994
|
+
const nextStart = reconcileStarts.find(
|
|
995
|
+
(start) => start.seq > completion.completedSeq && start.attempt > completion.attempt,
|
|
996
|
+
)
|
|
997
|
+
const observationLimit = nextStart?.seq ?? Number.POSITIVE_INFINITY
|
|
998
|
+
// Explicit checks win over the historical fallback. Use the latest one
|
|
999
|
+
// that could have routed this next occurrence; duplicate conflict facts do
|
|
1000
|
+
// not count one completed reconcile more than once.
|
|
1001
|
+
const explicit = reconcileProgressChecks
|
|
1002
|
+
.filter(
|
|
1003
|
+
(check) =>
|
|
1004
|
+
check.attempt === completion.attempt &&
|
|
1005
|
+
check.seq > completion.completedSeq &&
|
|
1006
|
+
check.seq < observationLimit &&
|
|
1007
|
+
check.conflictSeq > completion.completedSeq &&
|
|
1008
|
+
check.seq > check.conflictSeq &&
|
|
1009
|
+
conflictSeqs.has(check.conflictSeq),
|
|
1010
|
+
)
|
|
1011
|
+
.at(-1)
|
|
1012
|
+
const observedBase = explicit?.baseSha ?? nextStart?.baseSha
|
|
1013
|
+
if (observedBase === completion.baseSha) reconcileNoProgressCount += 1
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const lastReconcileCompleted = completed.at(-1)
|
|
1017
|
+
const lastConflictProgressCheck =
|
|
1018
|
+
lastConflict === undefined || lastReconcileCompleted === undefined
|
|
1019
|
+
? undefined
|
|
1020
|
+
: reconcileProgressChecks
|
|
1021
|
+
.filter(
|
|
1022
|
+
(check) =>
|
|
1023
|
+
check.conflictSeq === lastConflict.seq &&
|
|
1024
|
+
check.attempt === lastReconcileCompleted.attempt &&
|
|
1025
|
+
check.seq > lastConflict.seq,
|
|
1026
|
+
)
|
|
1027
|
+
.at(-1)
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
restartSeq,
|
|
1031
|
+
plan,
|
|
1032
|
+
code,
|
|
1033
|
+
verifyCompleted,
|
|
1034
|
+
verifyStarted,
|
|
1035
|
+
maxVerifyAttemptEver,
|
|
1036
|
+
lastReconcileCompletedSeq,
|
|
1037
|
+
lastImplementCompletedSeq,
|
|
1038
|
+
finalizeCompleted,
|
|
1039
|
+
finalizeStepsDone,
|
|
1040
|
+
lastConflict,
|
|
1041
|
+
conflictReconcileStarted,
|
|
1042
|
+
lastReconcileCompleted,
|
|
1043
|
+
lastConflictProgressCheck,
|
|
1044
|
+
reconcileNoProgressCount,
|
|
1045
|
+
guidanceDeliveries,
|
|
1046
|
+
}
|
|
1047
|
+
}
|