@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,2614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The build-runner process (SPEC §3.3): one per build, owns one pipeline
|
|
3
|
+
* execution end to end. The kernel's job reduced to a loop (§8): read the
|
|
4
|
+
* event log, ask `decideNext` for the next move, execute it — start sessions,
|
|
5
|
+
* run check commands, append the events execution produces — and ask again.
|
|
6
|
+
* All judgment lives in the skills behind the AgentRunner; everything here is
|
|
7
|
+
* deterministic plumbing (constitution §2.1). That split makes the runner
|
|
8
|
+
* crash-safe by construction: killed mid-phase, a re-attached runner re-reads
|
|
9
|
+
* the log and `decideNext` re-decides the same started-but-unterminated phase
|
|
10
|
+
* from its start (§15.6-C) — resumability is not a feature (§2.2).
|
|
11
|
+
*
|
|
12
|
+
* Ownership: `attach` claims or renews the build's execution lease (§7.4) so a
|
|
13
|
+
* second sandbox can never execute the same build. The local supervising kernel
|
|
14
|
+
* reserves that same holder before launch. The runner may then acknowledge a pending pause or
|
|
15
|
+
* abort without preparing the workspace; workspace-backed work starts a
|
|
16
|
+
* heartbeat interval before setup (liveness is mutable columns, never events
|
|
17
|
+
* — §15.2.6). `run` exits when the build
|
|
18
|
+
* parks (§11: a parked build's runner exits; cron re-attaches when
|
|
19
|
+
* actionable). The runner releases nothing on exit: a local execution supervisor
|
|
20
|
+
* releases ownership after full tree teardown, while abrupt loss falls back to
|
|
21
|
+
* expiry and the dispatcher's sweep re-attaches (§15.6-C).
|
|
22
|
+
*
|
|
23
|
+
* Documented pragmatisms (where SPEC forces are in tension, the choice and
|
|
24
|
+
* its reason live here):
|
|
25
|
+
*
|
|
26
|
+
* - Producer session memory (§10) vs per-run transcripts (§7.1). The producer
|
|
27
|
+
* must *continue* its session across review rounds, but `AgentRunner.end`
|
|
28
|
+
* destroys continuability in every adapter. A SUCCESSFUL producer run
|
|
29
|
+
* therefore keeps its runner session live and closes its per-round
|
|
30
|
+
* session bracket with the turn's own transcript (final text + usage,
|
|
31
|
+
* straight off the AgentRunner interface — still "through the interface",
|
|
32
|
+
* §9, never scraped). The handle is truly `end()`ed when the phase later
|
|
33
|
+
* fails (a rambling session is never continued — fresh start next attempt,
|
|
34
|
+
* D5) or when `run` exits; the cumulative turn log is discarded then,
|
|
35
|
+
* because every round already deposited its own transcript.
|
|
36
|
+
* - Finalize checks are deterministic kernel work with no synthetic session;
|
|
37
|
+
* finalize agents retain their session actor. Both paths are deliberately
|
|
38
|
+
* failure-tolerant and drain through the same completion/observation writer.
|
|
39
|
+
* - The D5 retry guard also covers agent-verify steps (keyed `verify:<step>`,
|
|
40
|
+
* round = attempt): a no-terminal or provider-failed verify session would
|
|
41
|
+
* otherwise re-run forever, since its `verify.completed` never lands.
|
|
42
|
+
* - A throwing Exec in `run-check` propagates: deterministic checks carry no
|
|
43
|
+
* session to fail (§8.2), and a crashed runner's dangling `verify.started`
|
|
44
|
+
* simply re-runs the check on re-attach (§15.6-C) — the same crash model
|
|
45
|
+
* as every phase.
|
|
46
|
+
*/
|
|
47
|
+
import type { Config } from '../config/schema'
|
|
48
|
+
import type { AbEvent, EventWrite } from '../events/catalog'
|
|
49
|
+
import type { EventPayload } from '../events/payloads'
|
|
50
|
+
import { KERNEL, agentActor, type Actor } from '../events/envelope'
|
|
51
|
+
import type { IdSource } from '../ids'
|
|
52
|
+
import { decideNext, type Decision } from '../kernel/engine'
|
|
53
|
+
import { PHASE_SPECS } from '../kernel/phases'
|
|
54
|
+
import { reduceBuild, type BuildState } from '../kernel/reducer'
|
|
55
|
+
import { evaluateVerifyApplicability } from '../kernel/verify-applicability'
|
|
56
|
+
import {
|
|
57
|
+
verifyPhase,
|
|
58
|
+
verifyReportKind,
|
|
59
|
+
type CorePhase,
|
|
60
|
+
type Feedback,
|
|
61
|
+
type Phase,
|
|
62
|
+
} from '../ontology'
|
|
63
|
+
import {
|
|
64
|
+
createRuntimeResolver,
|
|
65
|
+
type ResolvedRole,
|
|
66
|
+
type ResolvedRuntime,
|
|
67
|
+
type RuntimeResolver,
|
|
68
|
+
} from '../ports/runner/routing'
|
|
69
|
+
import { isAlternateEligible, mayRetryPhase } from '../ports/runner/provider-error'
|
|
70
|
+
import { sessionPart } from '../ports/runner/stream-parts'
|
|
71
|
+
import type { SessionStreamInfo } from '../ports/runner/runtime'
|
|
72
|
+
import type { RuntimeRegistry } from '../ports/runner/runtime'
|
|
73
|
+
import { createSessionStreamSink } from '../store/streams/session-writer'
|
|
74
|
+
import type { StreamOutcome } from '../store/streams/types'
|
|
75
|
+
import type {
|
|
76
|
+
AgentRunner,
|
|
77
|
+
AgentSessionHandle,
|
|
78
|
+
AgentTurnFailure,
|
|
79
|
+
AgentTurnResult,
|
|
80
|
+
Forge,
|
|
81
|
+
SessionStreamSink,
|
|
82
|
+
} from '../ports/types'
|
|
83
|
+
import { BUILD_EXECUTION_LEASE_TTL_MS } from '../ports/workspace/build-execution'
|
|
84
|
+
import { phaseScratchRejection, scratchPathsTouchedInRange } from '../ports/workspace/phase-scratch'
|
|
85
|
+
import type { Exec } from '../ports/workspace/git-worktree'
|
|
86
|
+
import type { BuildScopedStore, Clock } from '../store/types'
|
|
87
|
+
import { resetsPhaseFailureBudget, type PhaseFailureResetEscalation } from './phase-failure-budget'
|
|
88
|
+
import { publicationPending } from './publication-state'
|
|
89
|
+
|
|
90
|
+
export { publicationPending } from './publication-state'
|
|
91
|
+
|
|
92
|
+
// ── Seams ────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/** Another sandbox owns the build (§7.4): its lease is live, so this runner
|
|
95
|
+
* must not execute. The dispatcher retries once the lease expires. */
|
|
96
|
+
export class LeaseHeldError extends Error {
|
|
97
|
+
constructor(
|
|
98
|
+
readonly slug: string,
|
|
99
|
+
readonly instance: string,
|
|
100
|
+
) {
|
|
101
|
+
super(
|
|
102
|
+
`build "${slug}" is leased to another runner — instance "${instance}" ` +
|
|
103
|
+
'cannot attach while the holder is live (§7.4)',
|
|
104
|
+
)
|
|
105
|
+
this.name = 'LeaseHeldError'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Recognizable launch failure whose durable detail is already in the build
|
|
110
|
+
* log. Interactive dispatch therefore renders it on the build row rather than
|
|
111
|
+
* duplicating it in process-global warning chrome. */
|
|
112
|
+
export class SetupFailureError extends Error {
|
|
113
|
+
constructor(
|
|
114
|
+
readonly slug: string,
|
|
115
|
+
readonly failure: EventPayload<'runner.setup-failed'>,
|
|
116
|
+
readonly exhausted: boolean,
|
|
117
|
+
) {
|
|
118
|
+
const status =
|
|
119
|
+
failure.exitStatus === null ? 'exit status unavailable' : `exited ${failure.exitStatus}`
|
|
120
|
+
super(
|
|
121
|
+
`[commands].setup ${JSON.stringify(failure.command)} ${status} ` +
|
|
122
|
+
`(attempt ${failure.attempt}): ${failure.output || '(no output)'}`,
|
|
123
|
+
)
|
|
124
|
+
this.name = 'SetupFailureError'
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface BuildRunnerOpts {
|
|
129
|
+
/** D5 retry policy: phase.failed count per phase+round before a policy
|
|
130
|
+
* escalation replaces the next run (never loop forever). Default 2. */
|
|
131
|
+
maxPhaseAttempts?: number
|
|
132
|
+
/** Lease keep-alive interval (§15.2.6). Default 15s. */
|
|
133
|
+
heartbeatMs?: number
|
|
134
|
+
/** Lease TTL passed to claimLease (§7.4). Default 60s. */
|
|
135
|
+
leaseTtlMs?: number
|
|
136
|
+
/** Test seam for phase-session deadlines. Production uses an unref'ed timer. */
|
|
137
|
+
scheduleSessionBudget?: (expire: () => void, delayMs: number) => () => void
|
|
138
|
+
/** Session streams (SPEC §9): open one stream per session bracket for
|
|
139
|
+
* runtimes that declare the streaming capability. Default true. A failing
|
|
140
|
+
* stream path never fails a turn; disabling reproduces the event log
|
|
141
|
+
* without streams. */
|
|
142
|
+
streamSessions?: boolean
|
|
143
|
+
/** Where stream diagnostics land. Default: a stderr write. */
|
|
144
|
+
onDiagnostic?: (message: string) => void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface BuildRunnerDeps {
|
|
148
|
+
store: BuildScopedStore
|
|
149
|
+
config: Config
|
|
150
|
+
/** Current dispatcher snapshot. It is sampled once for setup and once for
|
|
151
|
+
* each engine/action boundary; omission preserves static runner callers. */
|
|
152
|
+
getConfig?: () => Config | Promise<Config>
|
|
153
|
+
/** Runtime registry: name → adapter + compatibility data (§9). The resolver
|
|
154
|
+
* applies `config.roles`, including its reserved `default` entry, to it. */
|
|
155
|
+
runtimes: RuntimeRegistry
|
|
156
|
+
workspacePath: string
|
|
157
|
+
branch: string
|
|
158
|
+
slug: string
|
|
159
|
+
/** Shell seam for deterministic checks and finalize Git guards (§8.2, D7). */
|
|
160
|
+
exec: Exec
|
|
161
|
+
/** Remote publication stays behind the kernel-owned Forge port (D7). */
|
|
162
|
+
forge: Forge
|
|
163
|
+
ids: IdSource
|
|
164
|
+
clock: Clock
|
|
165
|
+
/** Lease holder identity (§7.4) and `runner.attached` payload (§15.3). */
|
|
166
|
+
instance: string
|
|
167
|
+
host: string
|
|
168
|
+
/** Extra AB_* the launcher wants in every session env (e.g. AB_TOKEN,
|
|
169
|
+
* AB_STORE for a remote store) — D8. May override AB_STORE; never the
|
|
170
|
+
* per-session identity keys (AB_BUILD/AB_PHASE/AB_SESSION). */
|
|
171
|
+
sessionEnv?: Record<string, string>
|
|
172
|
+
opts?: BuildRunnerOpts
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Internal shapes ──────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
type RunPhaseDecision = Extract<Decision, { kind: 'run-phase' }>
|
|
178
|
+
type RunCheckDecision = Extract<Decision, { kind: 'run-check' }>
|
|
179
|
+
type RunAgentVerifyDecision = Extract<Decision, { kind: 'run-agent-verify' }>
|
|
180
|
+
type EvaluateVerifyDecision = Extract<Decision, { kind: 'evaluate-verify' }>
|
|
181
|
+
type SkipVerifyDecision = Extract<Decision, { kind: 'skip-verify' }>
|
|
182
|
+
type RunFinalizeStepDecision = Extract<Decision, { kind: 'run-finalize-step' }>
|
|
183
|
+
type CheckReconcileProgressDecision = Extract<Decision, { kind: 'check-reconcile-progress' }>
|
|
184
|
+
type RaiseEscalationDecision = Extract<Decision, { kind: 'raise-escalation' }>
|
|
185
|
+
|
|
186
|
+
/** Lease acquisition can finish either workspace-backed attachment or one
|
|
187
|
+
* engine-selected stop acknowledgement that deliberately needs no workspace. */
|
|
188
|
+
export type AttachmentResult =
|
|
189
|
+
| { kind: 'workspace-ready' }
|
|
190
|
+
| { kind: 'control-acknowledged'; command: 'pause' | 'abort' }
|
|
191
|
+
|
|
192
|
+
/** §10 memory: a producer's live runner session, kept across review rounds. */
|
|
193
|
+
interface ProducerSession {
|
|
194
|
+
handle: AgentSessionHandle
|
|
195
|
+
runner: AgentRunner
|
|
196
|
+
route: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
type ProviderAttempt = NonNullable<EventPayload<'phase.failed'>['providerAttempts']>[number]
|
|
200
|
+
|
|
201
|
+
type TurnSettlement<T> =
|
|
202
|
+
| { kind: 'value'; value: T }
|
|
203
|
+
| { kind: 'error'; error: unknown }
|
|
204
|
+
| { kind: 'expired' }
|
|
205
|
+
|
|
206
|
+
interface BudgetedTurn<T> {
|
|
207
|
+
settlement: TurnSettlement<T>
|
|
208
|
+
/** The first kernel cancellation cause. A provider result cannot overwrite it. */
|
|
209
|
+
abortCause?: 'operator' | 'budget'
|
|
210
|
+
/** Eventually settles even when the kernel has stopped waiting. */
|
|
211
|
+
pending: Promise<Exclude<TurnSettlement<T>, { kind: 'expired' }>>
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One bracketed agent run (§15.3 sessions). */
|
|
215
|
+
interface SessionSpec {
|
|
216
|
+
/** Event/AB_PHASE identity: core phase or `verify:<step>`. */
|
|
217
|
+
phase: Phase
|
|
218
|
+
/** Loop round; verify attempt (AB_PHASE uses attempt as round); 1 for
|
|
219
|
+
* finalize. */
|
|
220
|
+
round: number
|
|
221
|
+
role: string
|
|
222
|
+
/** Deprecated compatibility role keys, consulted in order only when `role`
|
|
223
|
+
* is undeclared (§9 routing). Today: an agent verify step's configured skill
|
|
224
|
+
* name, the key verify used to route by before step names unified the rule. */
|
|
225
|
+
roleAliases?: readonly string[]
|
|
226
|
+
skill: string
|
|
227
|
+
abPhase: string
|
|
228
|
+
/** Set for producer core phases — §10 session memory applies. */
|
|
229
|
+
producerPhase?: CorePhase
|
|
230
|
+
/** Rendered into the continue message; the CLI context (`ab context`) is
|
|
231
|
+
* the real carrier — the message just names the .ab/ inputs. */
|
|
232
|
+
feedback?: Feedback
|
|
233
|
+
/** phase.failed count for this phase+round at decision time (D5). */
|
|
234
|
+
priorFailures: number
|
|
235
|
+
/** Phase-specific terminal recognizer; the session-scoped `escalation.
|
|
236
|
+
* raised` terminal (§8.4: escalate is always available) is added here. */
|
|
237
|
+
isTerminal: (event: AbEvent) => boolean
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Longest tail of check output preserved in a verify report (§8.2). */
|
|
241
|
+
const REPORT_TAIL_CHARS = 10_000
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One session bracket's live stream (SPEC §9): the sink the build-runner
|
|
245
|
+
* opened through the resolved target's streaming capability, the store id it
|
|
246
|
+
* returned, and the identity already emitted as the stream's first part.
|
|
247
|
+
* Undefined ⇒ the runtime declared no capability or the opener declined —
|
|
248
|
+
* the bracket then behaves exactly as before streams existed.
|
|
249
|
+
*/
|
|
250
|
+
interface SessionBracketStream {
|
|
251
|
+
sink: SessionStreamSink
|
|
252
|
+
streamId: string
|
|
253
|
+
info: SessionStreamInfo
|
|
254
|
+
}
|
|
255
|
+
/** Git object ids are 40 hex characters for SHA-1 repositories and 64 for
|
|
256
|
+
* SHA-256 repositories. `rev-parse <ref>^{commit}` supplies the type check. */
|
|
257
|
+
const GIT_OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
258
|
+
|
|
259
|
+
function errorMessage(error: unknown): string {
|
|
260
|
+
return error instanceof Error ? error.message : String(error)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sessionBudgetError(seconds: number): string {
|
|
264
|
+
return `phase session budget expired after ${seconds} seconds`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function defaultSessionBudgetScheduler(expire: () => void, delayMs: number): () => void {
|
|
268
|
+
// Node/Bun timers clamp larger delays. Chunk against an absolute deadline so
|
|
269
|
+
// a valid large positive config value can never become an immediate expiry.
|
|
270
|
+
const maxDelayMs = 2_147_483_647
|
|
271
|
+
const deadline = Date.now() + delayMs
|
|
272
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
273
|
+
const arm = (): void => {
|
|
274
|
+
const remaining = deadline - Date.now()
|
|
275
|
+
if (remaining <= 0) {
|
|
276
|
+
expire()
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
timer = setTimeout(arm, Math.min(remaining, maxDelayMs))
|
|
280
|
+
timer.unref?.()
|
|
281
|
+
}
|
|
282
|
+
arm()
|
|
283
|
+
return () => {
|
|
284
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function providerAttemptSummary(attempts: readonly ProviderAttempt[] | undefined): string {
|
|
289
|
+
if (attempts === undefined) return ''
|
|
290
|
+
return (
|
|
291
|
+
'; configured provider attempts: ' +
|
|
292
|
+
attempts
|
|
293
|
+
.map(
|
|
294
|
+
(attempt) =>
|
|
295
|
+
`#${attempt.index} ${attempt.runner}` +
|
|
296
|
+
`${attempt.model !== undefined ? `/${attempt.model}` : ''}: ${attempt.error}`,
|
|
297
|
+
)
|
|
298
|
+
.join(' | ')
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Latest event-checkpointed branch head that kernel plumbing published. */
|
|
303
|
+
export function selectPublishedBranchHead(events: readonly AbEvent[]): string {
|
|
304
|
+
let head: string | undefined
|
|
305
|
+
for (const event of events) {
|
|
306
|
+
if (event.type === 'implement.completed') {
|
|
307
|
+
head = event.payload.commits.head
|
|
308
|
+
} else if (event.type === 'reconcile.completed') {
|
|
309
|
+
head = event.payload.mergeCommit
|
|
310
|
+
} else if (
|
|
311
|
+
event.type === 'finalize.step-completed' &&
|
|
312
|
+
event.payload.ok &&
|
|
313
|
+
event.payload.headSha !== undefined
|
|
314
|
+
) {
|
|
315
|
+
head = event.payload.headSha
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (head === undefined) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
'finalize publication requires a prior implement.completed, reconcile.completed, ' +
|
|
321
|
+
'or successful finalize publication head, but this build has none',
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
return head
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Select the durable tree against which a conditional verifier evaluates the
|
|
329
|
+
* branch's current HEAD. The initial branch-cut SHA is authoritative until a
|
|
330
|
+
* successful reconcile; each completed reconcile promotes the latest start's
|
|
331
|
+
* refreshed base SHA. A dangling start is intentionally ignored.
|
|
332
|
+
*/
|
|
333
|
+
export function selectVerifyDiffBase(events: readonly AbEvent[]): string {
|
|
334
|
+
const provisioned = events.find((event) => event.type === 'workspace.provisioned')
|
|
335
|
+
if (provisioned === undefined || provisioned.type !== 'workspace.provisioned') {
|
|
336
|
+
throw new Error(
|
|
337
|
+
'conditional verify requires a workspace.provisioned base SHA, but this build has none',
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let baseSha = provisioned.payload.base.sha
|
|
342
|
+
let pendingReconcileBase: string | undefined
|
|
343
|
+
for (const event of events) {
|
|
344
|
+
if (event.type === 'reconcile.started') {
|
|
345
|
+
pendingReconcileBase = event.payload.baseSha
|
|
346
|
+
} else if (event.type === 'reconcile.completed') {
|
|
347
|
+
if (pendingReconcileBase !== undefined) baseSha = pendingReconcileBase
|
|
348
|
+
pendingReconcileBase = undefined
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return baseSha
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Parse `git diff --name-only -z`; malformed output fails closed. */
|
|
355
|
+
export function parseNulChangedPaths(output: string): string[] {
|
|
356
|
+
if (output.length === 0) return []
|
|
357
|
+
if (!output.endsWith('\0')) {
|
|
358
|
+
throw new Error('git diff --name-only -z returned non-NUL-terminated output')
|
|
359
|
+
}
|
|
360
|
+
const paths = output.slice(0, -1).split('\0')
|
|
361
|
+
if (paths.some((path) => path.length === 0)) {
|
|
362
|
+
throw new Error('git diff --name-only -z returned an empty path entry')
|
|
363
|
+
}
|
|
364
|
+
return paths
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Terminal events of loop phases carry `round`; finalize.completed and
|
|
368
|
+
* reconcile.completed do not (§15.3). Payloads are schema-validated at
|
|
369
|
+
* append, so the loose read is safe. */
|
|
370
|
+
function roundMatches(event: AbEvent, round: number): boolean {
|
|
371
|
+
const r = (event.payload as { round?: unknown }).round
|
|
372
|
+
return typeof r !== 'number' || r === round
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The producer's round-N+1 prompt (§10): assembled deterministically from
|
|
377
|
+
* the feedback, naming the .ab/ inputs `ab context` materializes (§8.3) —
|
|
378
|
+
* the message is a pointer, the CLI context is the real carrier.
|
|
379
|
+
*/
|
|
380
|
+
function continueMessage(spec: SessionSpec): string {
|
|
381
|
+
const refresh = 'run `ab context` to refresh .ab/, then finish with your terminal command'
|
|
382
|
+
const { feedback } = spec
|
|
383
|
+
if (feedback === undefined) {
|
|
384
|
+
return `Continue ${spec.phase} (round ${spec.round}): ${refresh}.`
|
|
385
|
+
}
|
|
386
|
+
if ('findings' in feedback) {
|
|
387
|
+
return (
|
|
388
|
+
`Revise ${spec.phase} (round ${spec.round}): address findings ` +
|
|
389
|
+
`${feedback.findings.join(', ')} — materialized in .ab/findings.json; ${refresh}.`
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
if ('verify' in feedback) {
|
|
393
|
+
return (
|
|
394
|
+
`verify:${feedback.verify.step} failed — its report ` +
|
|
395
|
+
`(${feedback.verify.report.kind}@${feedback.verify.report.rev}) is in ` +
|
|
396
|
+
`.ab/verify/; fix in ${spec.phase} round ${spec.round}, ${refresh}.`
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
return (
|
|
400
|
+
`Human guidance on escalation ${feedback.guidance.escalation} (authoritative, ` +
|
|
401
|
+
`§15.6-B): ${feedback.guidance.answer} — inputs in .ab/; ${refresh}.`
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── The runner ───────────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
function isPublicationBoundary(event: AbEvent, spec: SessionSpec, session: string): boolean {
|
|
408
|
+
if (
|
|
409
|
+
event.type !== 'publication.requested' ||
|
|
410
|
+
event.actor.kind !== 'agent' ||
|
|
411
|
+
event.actor.session !== session
|
|
412
|
+
)
|
|
413
|
+
return false
|
|
414
|
+
if (spec.phase === 'implement')
|
|
415
|
+
return event.payload.operation === 'implement' && event.payload.round === spec.round
|
|
416
|
+
if (spec.phase === 'reconcile') return event.payload.operation === 'reconcile'
|
|
417
|
+
if (spec.phase === 'finalize') return event.payload.operation === 'finalize'
|
|
418
|
+
return false
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export class BuildRunner {
|
|
422
|
+
private readonly maxPhaseAttempts: number
|
|
423
|
+
private readonly heartbeatMs: number
|
|
424
|
+
private readonly leaseTtlMs: number
|
|
425
|
+
private readonly streamSessions: boolean
|
|
426
|
+
private readonly onDiagnostic: (message: string) => void
|
|
427
|
+
private attached = false
|
|
428
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined
|
|
429
|
+
/** Set when a heartbeat reports the lease lapsed (§7.4): the store never
|
|
430
|
+
* renews an expired lease, so the holder must re-claim before doing any
|
|
431
|
+
* more work — or stop, because the sweep may already have launched a
|
|
432
|
+
* replacement runner (§15.6-C). */
|
|
433
|
+
private leaseLost = false
|
|
434
|
+
/** §10 producer session memory — in-memory by design: a new sandbox resumes
|
|
435
|
+
* with fresh sessions rehydrated from the store (§7.4, §9). */
|
|
436
|
+
private readonly producerSessions = new Map<CorePhase, ProducerSession>()
|
|
437
|
+
/** Captured only at setup/step boundaries. Async work below a boundary never
|
|
438
|
+
* consults the mutable owner again. */
|
|
439
|
+
private boundaryConfig: Config
|
|
440
|
+
private boundaryResolver: RuntimeResolver
|
|
441
|
+
|
|
442
|
+
constructor(private readonly deps: BuildRunnerDeps) {
|
|
443
|
+
this.maxPhaseAttempts = deps.opts?.maxPhaseAttempts ?? 2
|
|
444
|
+
this.heartbeatMs = deps.opts?.heartbeatMs ?? 15_000
|
|
445
|
+
this.leaseTtlMs = deps.opts?.leaseTtlMs ?? BUILD_EXECUTION_LEASE_TTL_MS
|
|
446
|
+
this.streamSessions = deps.opts?.streamSessions ?? true
|
|
447
|
+
this.onDiagnostic = deps.opts?.onDiagnostic ?? ((message) => console.error(message))
|
|
448
|
+
this.boundaryConfig = deps.config
|
|
449
|
+
this.boundaryResolver = createRuntimeResolver(
|
|
450
|
+
deps.runtimes,
|
|
451
|
+
deps.config.roles,
|
|
452
|
+
deps.config.policy.sessionBudgetSeconds,
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private async captureConfig(): Promise<Config> {
|
|
457
|
+
const config = (await this.deps.getConfig?.()) ?? this.deps.config
|
|
458
|
+
this.boundaryConfig = config
|
|
459
|
+
this.boundaryResolver = createRuntimeResolver(
|
|
460
|
+
this.deps.runtimes,
|
|
461
|
+
config.roles,
|
|
462
|
+
config.policy.sessionBudgetSeconds,
|
|
463
|
+
)
|
|
464
|
+
return config
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Consecutive durable setup failures since the latest successful attachment
|
|
468
|
+
* or explicit human answer. The answer re-arms the budget but deliberately
|
|
469
|
+
* does not erase the current diagnostic while recovery is still running. */
|
|
470
|
+
private setupStreak(events: readonly AbEvent[]): number {
|
|
471
|
+
const setupEscalations = new Set<string>()
|
|
472
|
+
let boundary = 0
|
|
473
|
+
for (const event of events) {
|
|
474
|
+
if (event.type === 'runner.attached') boundary = event.seq
|
|
475
|
+
else if (
|
|
476
|
+
event.type === 'escalation.raised' &&
|
|
477
|
+
event.payload.phase === 'setup' &&
|
|
478
|
+
event.payload.source === 'policy'
|
|
479
|
+
) {
|
|
480
|
+
setupEscalations.add(event.payload.id)
|
|
481
|
+
} else if (event.type === 'escalation.answered' && setupEscalations.has(event.payload.id)) {
|
|
482
|
+
boundary = event.seq
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return events.filter((event) => event.type === 'runner.setup-failed' && event.seq > boundary)
|
|
486
|
+
.length
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private setupQuestion(failure: EventPayload<'runner.setup-failed'>): string {
|
|
490
|
+
const status =
|
|
491
|
+
failure.exitStatus === null ? 'exit status unavailable' : `exit status ${failure.exitStatus}`
|
|
492
|
+
return (
|
|
493
|
+
`maxSetupAttempts (${this.boundaryConfig.policy.maxSetupAttempts}) exhausted: ` +
|
|
494
|
+
`[commands].setup ${JSON.stringify(failure.command)} failed with ${status}. ` +
|
|
495
|
+
`Output: ${failure.output || '(no output)'}`
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private async raiseSetupEscalation(failure: EventPayload<'runner.setup-failed'>): Promise<void> {
|
|
500
|
+
await this.deps.store.append(this.deps.slug, {
|
|
501
|
+
actor: KERNEL,
|
|
502
|
+
type: 'escalation.raised',
|
|
503
|
+
payload: {
|
|
504
|
+
id: this.deps.ids('esc'),
|
|
505
|
+
phase: 'setup',
|
|
506
|
+
source: 'policy',
|
|
507
|
+
policyCause: 'setup-failure-limit',
|
|
508
|
+
question: this.setupQuestion(failure),
|
|
509
|
+
},
|
|
510
|
+
} satisfies EventWrite<'escalation.raised'>)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Close every session open in the captured pre-attachment prefix, then
|
|
514
|
+
* announce takeover against that same boundary. If a write is interrupted,
|
|
515
|
+
* no attachment lands; the next runner reduces the newer prefix and closes
|
|
516
|
+
* only sessions that remain open. */
|
|
517
|
+
private async reclaimSessionsAndAttach(events: readonly AbEvent[]): Promise<void> {
|
|
518
|
+
const { store, slug, instance, host } = this.deps
|
|
519
|
+
const resumedFromSeq = events.at(-1)?.seq ?? 0
|
|
520
|
+
for (const session of reduceBuild([...events]).sessions.open) {
|
|
521
|
+
await store.append(slug, {
|
|
522
|
+
actor: KERNEL,
|
|
523
|
+
type: 'session.ended',
|
|
524
|
+
payload: {
|
|
525
|
+
session: session.session,
|
|
526
|
+
outcome: 'reclaimed',
|
|
527
|
+
reclaimedBy: { instance, resumedFromSeq },
|
|
528
|
+
},
|
|
529
|
+
} satisfies EventWrite<'session.ended'>)
|
|
530
|
+
}
|
|
531
|
+
await store.append(slug, {
|
|
532
|
+
actor: KERNEL,
|
|
533
|
+
type: 'runner.attached',
|
|
534
|
+
payload: {
|
|
535
|
+
instance,
|
|
536
|
+
host,
|
|
537
|
+
...(resumedFromSeq > 0 ? { resumedFromSeq } : {}),
|
|
538
|
+
},
|
|
539
|
+
} satisfies EventWrite<'runner.attached'>)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Claim the lease, honor engine-selected stop controls, then run pre-phase
|
|
544
|
+
* setup for workspace-backed work. The first workspace attempt retains the
|
|
545
|
+
* historical successful-path protocol by announcing attachment before setup.
|
|
546
|
+
* Once a setup failure is current, retries delay `runner.attached` until
|
|
547
|
+
* setup succeeds; that fact then proves recovery and clears the projection.
|
|
548
|
+
*/
|
|
549
|
+
async attach(): Promise<AttachmentResult> {
|
|
550
|
+
const config = await this.captureConfig()
|
|
551
|
+
const { store, slug, instance } = this.deps
|
|
552
|
+
const claimed = await store.claimLease(slug, instance, this.leaseTtlMs)
|
|
553
|
+
if (!claimed) throw new LeaseHeldError(slug, instance)
|
|
554
|
+
|
|
555
|
+
let events = await store.getEvents(slug)
|
|
556
|
+
const decision = decideNext(events, config)
|
|
557
|
+
if (
|
|
558
|
+
decision.kind === 'acknowledge' &&
|
|
559
|
+
(decision.command === 'pause' || decision.command === 'abort')
|
|
560
|
+
) {
|
|
561
|
+
await this.acknowledge(decision.command)
|
|
562
|
+
return { kind: 'control-acknowledged', command: decision.command }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const state = reduceBuild(events)
|
|
566
|
+
const currentFailure = state.setupFailure
|
|
567
|
+
const openSetupEscalation = state.openEscalations.some(
|
|
568
|
+
(escalation) => escalation.phase === 'setup' && escalation.source === 'policy',
|
|
569
|
+
)
|
|
570
|
+
const streak = this.setupStreak(events)
|
|
571
|
+
|
|
572
|
+
// Crash-safe exhaustion: the final failure and escalation are separate
|
|
573
|
+
// appends. If only the failure landed, raise the blocker before another
|
|
574
|
+
// command execution can buy an extra attempt.
|
|
575
|
+
if (currentFailure !== undefined && streak >= config.policy.maxSetupAttempts) {
|
|
576
|
+
if (!openSetupEscalation) await this.raiseSetupEscalation(currentFailure)
|
|
577
|
+
throw new SetupFailureError(slug, currentFailure, true)
|
|
578
|
+
}
|
|
579
|
+
if (currentFailure !== undefined && openSetupEscalation) {
|
|
580
|
+
throw new SetupFailureError(slug, currentFailure, true)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const recovering = currentFailure !== undefined
|
|
584
|
+
if (!recovering) await this.reclaimSessionsAndAttach(events)
|
|
585
|
+
|
|
586
|
+
// First beat immediately (liveness visible without waiting an interval),
|
|
587
|
+
// then keep-alive. Setup runs under this heartbeat so a slow install cannot
|
|
588
|
+
// outlive the claimed lease.
|
|
589
|
+
await store.heartbeat(slug, instance)
|
|
590
|
+
this.heartbeatTimer = setInterval(() => {
|
|
591
|
+
store.heartbeat(slug, instance).then(
|
|
592
|
+
(alive) => {
|
|
593
|
+
if (!alive) this.leaseLost = true
|
|
594
|
+
},
|
|
595
|
+
() => {
|
|
596
|
+
// Store unreachable — the next beat retries; the lease TTL is the
|
|
597
|
+
// real liveness contract (§15.2.6).
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
}, this.heartbeatMs)
|
|
601
|
+
this.heartbeatTimer.unref?.()
|
|
602
|
+
|
|
603
|
+
const setup = config.commands.setup
|
|
604
|
+
if (setup !== undefined) {
|
|
605
|
+
let failure: EventPayload<'runner.setup-failed'> | undefined
|
|
606
|
+
try {
|
|
607
|
+
const result = await this.deps.exec(['sh', '-c', setup], {
|
|
608
|
+
cwd: this.deps.workspacePath,
|
|
609
|
+
})
|
|
610
|
+
if (result.exitCode !== 0) {
|
|
611
|
+
failure = {
|
|
612
|
+
command: setup,
|
|
613
|
+
attempt: streak + 1,
|
|
614
|
+
exitStatus: result.exitCode,
|
|
615
|
+
output: [result.stderr, result.stdout].filter((text) => text !== '').join('\n'),
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
failure = {
|
|
620
|
+
command: setup,
|
|
621
|
+
attempt: streak + 1,
|
|
622
|
+
exitStatus: null,
|
|
623
|
+
output: errorMessage(error),
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (failure !== undefined) {
|
|
628
|
+
try {
|
|
629
|
+
await store.append(slug, {
|
|
630
|
+
actor: KERNEL,
|
|
631
|
+
type: 'runner.setup-failed',
|
|
632
|
+
payload: failure,
|
|
633
|
+
} satisfies EventWrite<'runner.setup-failed'>)
|
|
634
|
+
const exhausted = failure.attempt >= config.policy.maxSetupAttempts
|
|
635
|
+
if (exhausted) await this.raiseSetupEscalation(failure)
|
|
636
|
+
throw new SetupFailureError(slug, failure, exhausted)
|
|
637
|
+
} finally {
|
|
638
|
+
this.stopHeartbeat()
|
|
639
|
+
this.attached = false
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (recovering) {
|
|
645
|
+
try {
|
|
646
|
+
events = await store.getEvents(slug)
|
|
647
|
+
await this.reclaimSessionsAndAttach(events)
|
|
648
|
+
} catch (error) {
|
|
649
|
+
this.stopHeartbeat()
|
|
650
|
+
this.attached = false
|
|
651
|
+
throw error
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
this.attached = true
|
|
655
|
+
return { kind: 'workspace-ready' }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* One read→decide→execute cycle (§8): the unit-test surface. Returns the
|
|
660
|
+
* decision it executed; `wait` decisions execute nothing.
|
|
661
|
+
*/
|
|
662
|
+
async step(): Promise<Decision> {
|
|
663
|
+
const config = await this.captureConfig()
|
|
664
|
+
const { store, slug } = this.deps
|
|
665
|
+
const events = await store.getEvents(slug)
|
|
666
|
+
const decision = decideNext(events, config)
|
|
667
|
+
switch (decision.kind) {
|
|
668
|
+
case 'wait':
|
|
669
|
+
return decision
|
|
670
|
+
case 'acknowledge':
|
|
671
|
+
await this.acknowledge(decision.command)
|
|
672
|
+
return decision
|
|
673
|
+
case 'raise-escalation':
|
|
674
|
+
await this.raiseEscalation(decision)
|
|
675
|
+
return decision
|
|
676
|
+
case 'run-phase':
|
|
677
|
+
await this.runPhase(decision, events)
|
|
678
|
+
return decision
|
|
679
|
+
case 'run-check':
|
|
680
|
+
await this.runCheck(decision)
|
|
681
|
+
return decision
|
|
682
|
+
case 'run-agent-verify':
|
|
683
|
+
await this.runAgentVerify(decision, events)
|
|
684
|
+
return decision
|
|
685
|
+
case 'evaluate-verify':
|
|
686
|
+
await this.evaluateVerify(decision, events)
|
|
687
|
+
return decision
|
|
688
|
+
case 'skip-verify':
|
|
689
|
+
await this.skipVerify(decision)
|
|
690
|
+
return decision
|
|
691
|
+
case 'run-finalize-step':
|
|
692
|
+
await this.runFinalizeStep(decision, events)
|
|
693
|
+
return decision
|
|
694
|
+
case 'check-reconcile-progress':
|
|
695
|
+
await this.checkReconcileProgress(decision, events)
|
|
696
|
+
return decision
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The pipeline loop (§8): attach if needed, step until the build parks
|
|
702
|
+
* (`wait/*` — §11), return the final reduced state. Releases nothing on
|
|
703
|
+
* exit (leases expire; the dispatcher re-attaches) but stops the heartbeat,
|
|
704
|
+
* closes any live producer sessions (their per-round transcripts are
|
|
705
|
+
* already deposited — see module doc).
|
|
706
|
+
*/
|
|
707
|
+
async run(): Promise<BuildState> {
|
|
708
|
+
if (!this.attached) {
|
|
709
|
+
const attachment = await this.attach()
|
|
710
|
+
if (attachment.kind === 'control-acknowledged') {
|
|
711
|
+
return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
try {
|
|
715
|
+
if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) {
|
|
716
|
+
return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
|
|
717
|
+
}
|
|
718
|
+
for (;;) {
|
|
719
|
+
await this.ensureLease()
|
|
720
|
+
const decision = await this.step()
|
|
721
|
+
if (decision.kind === 'wait') break
|
|
722
|
+
if (publicationPending(await this.deps.store.getEvents(this.deps.slug))) break
|
|
723
|
+
}
|
|
724
|
+
return reduceBuild(await this.deps.store.getEvents(this.deps.slug))
|
|
725
|
+
} finally {
|
|
726
|
+
this.stopHeartbeat()
|
|
727
|
+
await this.closeProducerSessions()
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* §7.4 exclusivity, holder side: a lapsed lease means the sweep may already
|
|
733
|
+
* have launched a replacement, so this runner must re-claim before doing
|
|
734
|
+
* any more work. claimLease succeeds when the lease is expired or unheld
|
|
735
|
+
* (nobody took over — carry on) and fails when a live replacement holds it
|
|
736
|
+
* (stop: LeaseHeldError, mirroring attach). Checked at every loop boundary;
|
|
737
|
+
* work already in flight when the lapse happened cannot be interrupted, but
|
|
738
|
+
* nothing further executes without the lease.
|
|
739
|
+
*/
|
|
740
|
+
private async ensureLease(): Promise<void> {
|
|
741
|
+
if (!this.leaseLost) return
|
|
742
|
+
const { store, slug, instance } = this.deps
|
|
743
|
+
const reclaimed = await store.claimLease(slug, instance, this.leaseTtlMs)
|
|
744
|
+
if (!reclaimed) throw new LeaseHeldError(slug, instance)
|
|
745
|
+
this.leaseLost = false
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ── Decision execution ─────────────────────────────────────────────────────
|
|
749
|
+
|
|
750
|
+
/** D2: humans append `*-requested`; the kernel acknowledges with facts. */
|
|
751
|
+
private async acknowledge(command: 'pause' | 'resume' | 'abort'): Promise<void> {
|
|
752
|
+
const { store, slug } = this.deps
|
|
753
|
+
if (command === 'pause') {
|
|
754
|
+
await store.append(slug, { actor: KERNEL, type: 'build.paused', payload: {} })
|
|
755
|
+
} else if (command === 'resume') {
|
|
756
|
+
await store.append(slug, { actor: KERNEL, type: 'build.resumed', payload: {} })
|
|
757
|
+
} else {
|
|
758
|
+
await store.append(slug, { actor: KERNEL, type: 'build.aborted', payload: {} })
|
|
759
|
+
// The execution supervisor retains ownership until the complete process
|
|
760
|
+
// tree is gone; janitor cleanup is gated by that live lease.
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Stall/policy thresholds live in the engine (§15.4); the runner stamps
|
|
765
|
+
* the id (§15.4: ids are kernel-assigned) and appends the fact. */
|
|
766
|
+
private async raiseEscalation(decision: RaiseEscalationDecision): Promise<void> {
|
|
767
|
+
const { store, slug, ids } = this.deps
|
|
768
|
+
await store.append(slug, {
|
|
769
|
+
actor: KERNEL,
|
|
770
|
+
type: 'escalation.raised',
|
|
771
|
+
payload: {
|
|
772
|
+
id: ids('esc'),
|
|
773
|
+
phase: decision.phase,
|
|
774
|
+
...(decision.round !== undefined ? { round: decision.round } : {}),
|
|
775
|
+
source: decision.source,
|
|
776
|
+
...(decision.source === 'policy' ? { policyCause: decision.policyCause } : {}),
|
|
777
|
+
question: decision.question,
|
|
778
|
+
...(decision.refs !== undefined ? { refs: decision.refs } : {}),
|
|
779
|
+
},
|
|
780
|
+
} satisfies EventWrite<'escalation.raised'>)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Resolve and persist the authoritative base for one repeat conflict before
|
|
785
|
+
* the engine spends reconcile policy budget. This deliberately does not
|
|
786
|
+
* reuse the snapshot as the merge target: actual phase startup refreshes
|
|
787
|
+
* again, preserving the execution-boundary freshness guarantee.
|
|
788
|
+
*/
|
|
789
|
+
private async checkReconcileProgress(
|
|
790
|
+
decision: CheckReconcileProgressDecision,
|
|
791
|
+
events: AbEvent[],
|
|
792
|
+
): Promise<void> {
|
|
793
|
+
const { nextAttempt } = decision
|
|
794
|
+
const failures = this.phaseFailures(events, 'reconcile', nextAttempt)
|
|
795
|
+
if (failures.count >= this.maxPhaseAttempts) {
|
|
796
|
+
await this.raisePolicyExhausted('reconcile', nextAttempt, failures)
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
if (failures.lastWillRetry === false) {
|
|
800
|
+
await this.raisePolicyNonRetryable('reconcile', nextAttempt, failures)
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let baseSha: string
|
|
805
|
+
try {
|
|
806
|
+
baseSha = await this.refreshReconcileBase(events)
|
|
807
|
+
} catch (error) {
|
|
808
|
+
await this.failPhase(
|
|
809
|
+
'reconcile',
|
|
810
|
+
nextAttempt,
|
|
811
|
+
failures.count,
|
|
812
|
+
`failed to check reconcile progress: ${errorMessage(error)}`,
|
|
813
|
+
)
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
await this.deps.store.append(this.deps.slug, {
|
|
818
|
+
actor: KERNEL,
|
|
819
|
+
type: 'reconcile.progress-checked',
|
|
820
|
+
payload: {
|
|
821
|
+
conflictSeq: decision.conflictSeq,
|
|
822
|
+
attempt: decision.completedAttempt,
|
|
823
|
+
baseSha,
|
|
824
|
+
},
|
|
825
|
+
} satisfies EventWrite<'reconcile.progress-checked'>)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** A core-phase run: retry guard (D5), started event (§15.3), session. */
|
|
829
|
+
private async runPhase(decision: RunPhaseDecision, events: AbEvent[]): Promise<void> {
|
|
830
|
+
const { store, slug } = this.deps
|
|
831
|
+
const { phase, round } = decision
|
|
832
|
+
|
|
833
|
+
// D5 RETRY GUARD first: at the cap, escalate instead of running — a
|
|
834
|
+
// failing phase must never loop forever.
|
|
835
|
+
const failures = this.phaseFailures(events, phase, round)
|
|
836
|
+
if (failures.count >= this.maxPhaseAttempts) {
|
|
837
|
+
await this.raisePolicyExhausted(phase, round, failures)
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
if (failures.lastWillRetry === false) {
|
|
841
|
+
await this.raisePolicyNonRetryable(phase, round, failures)
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// A conflict's recorded baseSha is an observation-time snapshot. Resolve
|
|
846
|
+
// the build's frozen base branch again at the actual execution boundary,
|
|
847
|
+
// after the retry guard but before reconcile.started/the agent session.
|
|
848
|
+
// A crash re-enters this path at the same attempt and refreshes again.
|
|
849
|
+
let reconcileBaseSha: string | undefined
|
|
850
|
+
if (phase === 'reconcile') {
|
|
851
|
+
try {
|
|
852
|
+
reconcileBaseSha = await this.refreshReconcileBase(events)
|
|
853
|
+
} catch (error) {
|
|
854
|
+
await this.failPhase(
|
|
855
|
+
phase,
|
|
856
|
+
round,
|
|
857
|
+
failures.count,
|
|
858
|
+
`failed to refresh reconcile base: ${errorMessage(error)}`,
|
|
859
|
+
)
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
await store.append(slug, this.startedWrite(decision, reconcileBaseSha))
|
|
865
|
+
|
|
866
|
+
const spec = PHASE_SPECS[phase]
|
|
867
|
+
await this.executeSession({
|
|
868
|
+
phase,
|
|
869
|
+
round,
|
|
870
|
+
role: phase, // role name = core phase name (§9 routing)
|
|
871
|
+
skill: spec.skill,
|
|
872
|
+
abPhase: `${phase}@${round}`, // reconcile: decision.round IS the attempt
|
|
873
|
+
...(spec.kind === 'producer' ? { producerPhase: phase } : {}),
|
|
874
|
+
...(decision.feedback !== undefined ? { feedback: decision.feedback } : {}),
|
|
875
|
+
priorFailures: failures.count,
|
|
876
|
+
isTerminal: (event) => event.type === spec.terminalEvent && roundMatches(event, round),
|
|
877
|
+
})
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Resolve the current remote tip of this build's immutable base branch.
|
|
882
|
+
*
|
|
883
|
+
* The fetch target is build-scoped and internal: concurrent worktrees share
|
|
884
|
+
* one Git common directory, so FETCH_HEAD or a shared remote-tracking ref
|
|
885
|
+
* would let another build overwrite the object this attempt resolves. The
|
|
886
|
+
* explicit ref also makes the fetched remote-only commit available locally.
|
|
887
|
+
*/
|
|
888
|
+
private async refreshReconcileBase(events: AbEvent[]): Promise<string> {
|
|
889
|
+
const created = events.find((event) => event.type === 'build.created')
|
|
890
|
+
if (created === undefined || created.type !== 'build.created') {
|
|
891
|
+
throw new Error(
|
|
892
|
+
'this build has no build.created event — cannot resolve its frozen baseBranch (§15.3)',
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const sourceRef = `refs/heads/${created.payload.baseBranch}`
|
|
897
|
+
const targetRef = `refs/autobuild/reconcile/${this.deps.slug}/base`
|
|
898
|
+
if (this.deps.forge.snapshotBase !== undefined) {
|
|
899
|
+
const sha = await this.deps.forge.snapshotBase({
|
|
900
|
+
workspacePath: this.deps.workspacePath,
|
|
901
|
+
base: created.payload.baseBranch,
|
|
902
|
+
destinationRef: targetRef,
|
|
903
|
+
})
|
|
904
|
+
if (!GIT_OBJECT_ID.test(sha)) {
|
|
905
|
+
throw new Error(
|
|
906
|
+
`${this.deps.forge.name} base snapshot did not return a commit SHA: ${JSON.stringify(sha)}`,
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
return sha
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const fetchArgs = [
|
|
913
|
+
'git',
|
|
914
|
+
'fetch',
|
|
915
|
+
'--no-tags',
|
|
916
|
+
'--no-write-fetch-head',
|
|
917
|
+
'origin',
|
|
918
|
+
`+${sourceRef}:${targetRef}`,
|
|
919
|
+
]
|
|
920
|
+
const fetched = await this.deps.exec(fetchArgs, { cwd: this.deps.workspacePath })
|
|
921
|
+
if (fetched.exitCode !== 0) {
|
|
922
|
+
throw new Error(
|
|
923
|
+
`${fetchArgs.join(' ')} exited ${fetched.exitCode}: ` +
|
|
924
|
+
`${fetched.stderr.trim() || fetched.stdout.trim() || '(no output)'}`,
|
|
925
|
+
)
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const resolveArgs = ['git', 'rev-parse', '--verify', `${targetRef}^{commit}`]
|
|
929
|
+
const resolved = await this.deps.exec(resolveArgs, { cwd: this.deps.workspacePath })
|
|
930
|
+
const sha = resolved.stdout.trim()
|
|
931
|
+
if (resolved.exitCode !== 0 || !GIT_OBJECT_ID.test(sha)) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`${resolveArgs.join(' ')} did not resolve a commit SHA: ` +
|
|
934
|
+
`${resolved.stderr.trim() || sha || '(no output)'}`,
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
return sha
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Resolve a conditional verifier against the live branch diff. Exclusion is
|
|
942
|
+
* a canonical kernel-authored skip and launches no command or session.
|
|
943
|
+
* Inclusion delegates to the existing verifier path unchanged.
|
|
944
|
+
*/
|
|
945
|
+
private async evaluateVerify(decision: EvaluateVerifyDecision, events: AbEvent[]): Promise<void> {
|
|
946
|
+
const { exec, workspacePath } = this.deps
|
|
947
|
+
const baseSha = selectVerifyDiffBase(events)
|
|
948
|
+
const args = ['git', 'diff', '--no-renames', '--name-only', '-z', baseSha, 'HEAD', '--']
|
|
949
|
+
const result = await exec(args, { cwd: workspacePath })
|
|
950
|
+
if (result.exitCode !== 0) {
|
|
951
|
+
throw new Error(
|
|
952
|
+
`${args.join(' ')} exited ${result.exitCode}: ` +
|
|
953
|
+
`${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
|
|
954
|
+
)
|
|
955
|
+
}
|
|
956
|
+
const changedPaths = parseNulChangedPaths(result.stdout)
|
|
957
|
+
const applicability = evaluateVerifyApplicability(
|
|
958
|
+
{ kind: 'paths', step: decision.step, paths: decision.paths },
|
|
959
|
+
changedPaths,
|
|
960
|
+
)
|
|
961
|
+
if (!applicability.applies) {
|
|
962
|
+
await this.skipVerify({
|
|
963
|
+
kind: 'skip-verify',
|
|
964
|
+
step: decision.step,
|
|
965
|
+
attempt: decision.attempt,
|
|
966
|
+
reason: applicability.reason,
|
|
967
|
+
})
|
|
968
|
+
return
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (decision.action.kind === 'run-check') {
|
|
972
|
+
await this.runCheck(decision.action)
|
|
973
|
+
} else {
|
|
974
|
+
await this.runAgentVerify(decision.action, events)
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** Shared writer for every deterministic exclusion mechanism. A skip is a
|
|
979
|
+
* queryable two-fact phase occurrence and launches no command or session. */
|
|
980
|
+
private async skipVerify(decision: SkipVerifyDecision): Promise<void> {
|
|
981
|
+
const { store, slug } = this.deps
|
|
982
|
+
await store.append(slug, {
|
|
983
|
+
actor: KERNEL,
|
|
984
|
+
type: 'verify.started',
|
|
985
|
+
payload: { step: decision.step, attempt: decision.attempt },
|
|
986
|
+
} satisfies EventWrite<'verify.started'>)
|
|
987
|
+
await store.append(slug, {
|
|
988
|
+
actor: KERNEL,
|
|
989
|
+
type: 'verify.completed',
|
|
990
|
+
payload: {
|
|
991
|
+
step: decision.step,
|
|
992
|
+
attempt: decision.attempt,
|
|
993
|
+
outcome: 'skipped',
|
|
994
|
+
reason: decision.reason,
|
|
995
|
+
},
|
|
996
|
+
} satisfies EventWrite<'verify.completed'>)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Deterministic check (§8.2): NO session, NO CLI — the kernel runs the
|
|
1000
|
+
* command directly; a failure deposits the output tail as the report. */
|
|
1001
|
+
private async runCheck(decision: RunCheckDecision): Promise<void> {
|
|
1002
|
+
const { store, slug, exec, workspacePath } = this.deps
|
|
1003
|
+
const { step, command, attempt } = decision
|
|
1004
|
+
await store.append(slug, {
|
|
1005
|
+
actor: KERNEL,
|
|
1006
|
+
type: 'verify.started',
|
|
1007
|
+
payload: { step, attempt },
|
|
1008
|
+
} satisfies EventWrite<'verify.started'>)
|
|
1009
|
+
|
|
1010
|
+
const result = await exec(['sh', '-c', command], { cwd: workspacePath })
|
|
1011
|
+
if (result.exitCode === 0) {
|
|
1012
|
+
await store.append(slug, {
|
|
1013
|
+
actor: KERNEL,
|
|
1014
|
+
type: 'verify.completed',
|
|
1015
|
+
payload: { step, attempt, outcome: 'pass' },
|
|
1016
|
+
} satisfies EventWrite<'verify.completed'>)
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const output = [result.stdout, result.stderr].filter((s) => s !== '').join('\n')
|
|
1021
|
+
const content = output.length > REPORT_TAIL_CHARS ? output.slice(-REPORT_TAIL_CHARS) : output
|
|
1022
|
+
// Atomic deposit (D6): report artifact + verify.completed in one bundle;
|
|
1023
|
+
// the report routes back to implement via the engine (§15.6-A).
|
|
1024
|
+
await store.appendWithArtifacts(
|
|
1025
|
+
slug,
|
|
1026
|
+
[
|
|
1027
|
+
{
|
|
1028
|
+
kind: verifyReportKind(step),
|
|
1029
|
+
content: content || '(no output)',
|
|
1030
|
+
metadata: { step, attempt, command, exitCode: result.exitCode },
|
|
1031
|
+
},
|
|
1032
|
+
],
|
|
1033
|
+
(deposited) => {
|
|
1034
|
+
const meta = deposited[0]
|
|
1035
|
+
if (!meta) throw new Error('verify report deposit returned no metadata')
|
|
1036
|
+
return {
|
|
1037
|
+
actor: KERNEL,
|
|
1038
|
+
type: 'verify.completed',
|
|
1039
|
+
payload: {
|
|
1040
|
+
step,
|
|
1041
|
+
attempt,
|
|
1042
|
+
outcome: 'fail',
|
|
1043
|
+
report: { kind: meta.kind, rev: meta.revision },
|
|
1044
|
+
},
|
|
1045
|
+
} satisfies EventWrite<'verify.completed'>
|
|
1046
|
+
},
|
|
1047
|
+
)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Agent-verify step (§5): a session with a pass/fail/skip verdict. */
|
|
1051
|
+
private async runAgentVerify(decision: RunAgentVerifyDecision, events: AbEvent[]): Promise<void> {
|
|
1052
|
+
const { store, slug } = this.deps
|
|
1053
|
+
const { step, skill, attempt, feedback } = decision
|
|
1054
|
+
const phase = verifyPhase(step)
|
|
1055
|
+
|
|
1056
|
+
// D5 guard, extended to agent-verify (see module doc): keyed by
|
|
1057
|
+
// verify:<step> with the verify attempt as the round.
|
|
1058
|
+
const failures = this.phaseFailures(events, phase, attempt)
|
|
1059
|
+
if (failures.count >= this.maxPhaseAttempts) {
|
|
1060
|
+
await this.raisePolicyExhausted(phase, attempt, failures)
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
if (failures.lastWillRetry === false) {
|
|
1064
|
+
await this.raisePolicyNonRetryable(phase, attempt, failures)
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
await store.append(slug, {
|
|
1069
|
+
actor: KERNEL,
|
|
1070
|
+
type: 'verify.started',
|
|
1071
|
+
payload: { step, attempt, ...(feedback !== undefined ? { feedback } : {}) },
|
|
1072
|
+
} satisfies EventWrite<'verify.started'>)
|
|
1073
|
+
|
|
1074
|
+
await this.executeSession({
|
|
1075
|
+
phase,
|
|
1076
|
+
round: attempt,
|
|
1077
|
+
role: step, // role name = the verify step's logical name (§9 routing)
|
|
1078
|
+
// The deprecated skill-name key, still honoured for existing configs.
|
|
1079
|
+
roleAliases: [skill],
|
|
1080
|
+
skill,
|
|
1081
|
+
abPhase: `verify:${step}@${attempt}`,
|
|
1082
|
+
priorFailures: failures.count,
|
|
1083
|
+
isTerminal: (event) =>
|
|
1084
|
+
event.type === 'verify.completed' &&
|
|
1085
|
+
event.payload.step === step &&
|
|
1086
|
+
event.payload.attempt === attempt,
|
|
1087
|
+
})
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Failure-tolerant post-step (§5). Checks run directly and agents run their
|
|
1092
|
+
* exact configured skill, but either kind may commit selected repository
|
|
1093
|
+
* content locally. Only this kernel boundary may publish it (D7): a clean
|
|
1094
|
+
* descendant HEAD is regular-pushed and checkpointed before completion.
|
|
1095
|
+
* Step, Git, and Forge failures become the ordinary failed outcome plus an
|
|
1096
|
+
* observation and never route a green build backward.
|
|
1097
|
+
*/
|
|
1098
|
+
private async runFinalizeStep(
|
|
1099
|
+
decision: RunFinalizeStepDecision,
|
|
1100
|
+
events: AbEvent[],
|
|
1101
|
+
): Promise<void> {
|
|
1102
|
+
const { step, action } = decision
|
|
1103
|
+
let actor: Actor = KERNEL
|
|
1104
|
+
let durableHead: string | undefined
|
|
1105
|
+
let failureNote: string | undefined
|
|
1106
|
+
|
|
1107
|
+
try {
|
|
1108
|
+
await this.requireFinalizeWorktreeClean('before the post-step starts')
|
|
1109
|
+
durableHead = selectPublishedBranchHead(events)
|
|
1110
|
+
} catch (error) {
|
|
1111
|
+
failureNote = `finalize publication preflight failed: ${errorMessage(error)}`
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (failureNote === undefined) {
|
|
1115
|
+
if (action.kind === 'check') {
|
|
1116
|
+
failureNote = await this.runFinalizeCheck(action.command)
|
|
1117
|
+
} else {
|
|
1118
|
+
const outcome = await this.runFinalizeAgent(step, action.skill)
|
|
1119
|
+
if (outcome.cancelled) return
|
|
1120
|
+
actor = outcome.actor
|
|
1121
|
+
failureNote = outcome.failureNote
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let pushedHead: string | undefined
|
|
1126
|
+
if (failureNote === undefined && durableHead !== undefined) {
|
|
1127
|
+
try {
|
|
1128
|
+
await this.requireFinalizeWorktreeClean('after the post-step finishes')
|
|
1129
|
+
const head = await this.resolveFinalizeHead()
|
|
1130
|
+
if (head !== durableHead) {
|
|
1131
|
+
await this.requirePublishedHeadAncestor(durableHead, head)
|
|
1132
|
+
const scratchPaths = await scratchPathsTouchedInRange(
|
|
1133
|
+
this.deps.exec,
|
|
1134
|
+
this.deps.workspacePath,
|
|
1135
|
+
durableHead,
|
|
1136
|
+
head,
|
|
1137
|
+
)
|
|
1138
|
+
if (scratchPaths.length > 0) throw phaseScratchRejection(scratchPaths)
|
|
1139
|
+
const remote = events.some(
|
|
1140
|
+
(event) =>
|
|
1141
|
+
event.type === 'workspace.provisioned' && event.payload.provider === 'vercel-sandbox',
|
|
1142
|
+
)
|
|
1143
|
+
if (remote) {
|
|
1144
|
+
await this.deps.store.append(this.deps.slug, {
|
|
1145
|
+
actor,
|
|
1146
|
+
type: 'publication.requested',
|
|
1147
|
+
payload: {
|
|
1148
|
+
operation: 'finalize-step',
|
|
1149
|
+
branch: this.deps.branch,
|
|
1150
|
+
sha: head,
|
|
1151
|
+
step,
|
|
1152
|
+
},
|
|
1153
|
+
})
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
await this.deps.forge.pushBranch(this.deps.workspacePath, this.deps.branch)
|
|
1157
|
+
pushedHead = head
|
|
1158
|
+
}
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
failureNote = `finalize publication failed: ${errorMessage(error)}`
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
await this.recordFinalizeOutcome(step, actor, failureNote, pushedHead)
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** A finalize check is direct kernel plumbing: no agent session is created. */
|
|
1168
|
+
private async runFinalizeCheck(command: string): Promise<string | undefined> {
|
|
1169
|
+
try {
|
|
1170
|
+
const result = await this.deps.exec(['sh', '-c', command], {
|
|
1171
|
+
cwd: this.deps.workspacePath,
|
|
1172
|
+
})
|
|
1173
|
+
return result.exitCode === 0 ? undefined : `command exited ${result.exitCode}`
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
return `command execution failed: ${errorMessage(error)}`
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/** A finalize agent uses the configured skill verbatim and the logical step
|
|
1180
|
+
* name for role routing. Provider/launch failures remain post-step failures,
|
|
1181
|
+
* not build failures. */
|
|
1182
|
+
private async runFinalizeAgent(
|
|
1183
|
+
step: string,
|
|
1184
|
+
skill: string,
|
|
1185
|
+
): Promise<{ actor: Actor; failureNote: string | undefined; cancelled: boolean }> {
|
|
1186
|
+
const resolved = this.boundaryResolver.resolve(step)
|
|
1187
|
+
if (resolved.alternates.length === 0) return this.runFinalizeAgentSingle(step, skill)
|
|
1188
|
+
|
|
1189
|
+
const { store, slug, ids, workspacePath } = this.deps
|
|
1190
|
+
const targets: readonly ResolvedRuntime[] = [resolved, ...resolved.alternates]
|
|
1191
|
+
let substitution: ProviderAttempt | undefined
|
|
1192
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
1193
|
+
const target = targets[index]!
|
|
1194
|
+
const session = ids('s')
|
|
1195
|
+
const finalizeBracket = {
|
|
1196
|
+
phase: 'finalize' as const,
|
|
1197
|
+
round: 1,
|
|
1198
|
+
role: step,
|
|
1199
|
+
runnerName: target.runtime,
|
|
1200
|
+
}
|
|
1201
|
+
const streamBracket = await this.openSessionStream(target, {
|
|
1202
|
+
session,
|
|
1203
|
+
role: step,
|
|
1204
|
+
runner: target.runtime,
|
|
1205
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1206
|
+
phase: 'finalize',
|
|
1207
|
+
round: 1,
|
|
1208
|
+
})
|
|
1209
|
+
const stream = streamBracket?.sink
|
|
1210
|
+
const started = await store.append(slug, {
|
|
1211
|
+
actor: KERNEL,
|
|
1212
|
+
type: 'session.started',
|
|
1213
|
+
payload: {
|
|
1214
|
+
session,
|
|
1215
|
+
role: step,
|
|
1216
|
+
runner: target.runtime,
|
|
1217
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1218
|
+
args: [...target.args],
|
|
1219
|
+
phase: 'finalize',
|
|
1220
|
+
round: 1,
|
|
1221
|
+
...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
|
|
1222
|
+
...(substitution !== undefined
|
|
1223
|
+
? { substitution: { failed: substitution, selectedIndex: index } }
|
|
1224
|
+
: {}),
|
|
1225
|
+
},
|
|
1226
|
+
} satisfies EventWrite<'session.started'>)
|
|
1227
|
+
let handle: AgentSessionHandle | undefined
|
|
1228
|
+
let result: AgentTurnResult | undefined
|
|
1229
|
+
let turnError: unknown
|
|
1230
|
+
const turn = await this.runBudgetedTurn(
|
|
1231
|
+
started.seq,
|
|
1232
|
+
resolved.sessionBudgetSeconds,
|
|
1233
|
+
(signal) =>
|
|
1234
|
+
target.runner.start({
|
|
1235
|
+
skill,
|
|
1236
|
+
invocation: slug,
|
|
1237
|
+
buildSlug: slug,
|
|
1238
|
+
workspacePath,
|
|
1239
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1240
|
+
...(target.args !== undefined ? { args: target.args } : {}),
|
|
1241
|
+
env: this.sessionEnvFor('finalize@1', session),
|
|
1242
|
+
signal,
|
|
1243
|
+
...(stream !== undefined ? { stream } : {}),
|
|
1244
|
+
}),
|
|
1245
|
+
)
|
|
1246
|
+
if (turn.settlement.kind === 'value') {
|
|
1247
|
+
handle = turn.settlement.value.session
|
|
1248
|
+
result = turn.settlement.value.result
|
|
1249
|
+
} else if (turn.settlement.kind === 'error') {
|
|
1250
|
+
turnError = turn.settlement.error
|
|
1251
|
+
} else {
|
|
1252
|
+
this.releaseLateStart(turn.pending, target.runner)
|
|
1253
|
+
}
|
|
1254
|
+
if (turn.abortCause === 'budget') {
|
|
1255
|
+
if (handle !== undefined) {
|
|
1256
|
+
this.releaseExpiredSession(
|
|
1257
|
+
target.runner,
|
|
1258
|
+
handle,
|
|
1259
|
+
session,
|
|
1260
|
+
finalizeBracket,
|
|
1261
|
+
target.model,
|
|
1262
|
+
streamBracket,
|
|
1263
|
+
)
|
|
1264
|
+
} else {
|
|
1265
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1266
|
+
}
|
|
1267
|
+
return {
|
|
1268
|
+
actor: agentActor(step, session),
|
|
1269
|
+
failureNote: sessionBudgetError(resolved.sessionBudgetSeconds),
|
|
1270
|
+
cancelled: false,
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (handle !== undefined) {
|
|
1274
|
+
try {
|
|
1275
|
+
const transcript = await target.runner.end(handle)
|
|
1276
|
+
const outcome: StreamOutcome =
|
|
1277
|
+
turnError === undefined && result?.kind !== 'failed' ? 'completed' : 'aborted'
|
|
1278
|
+
await this.depositTranscriptAndEnd(
|
|
1279
|
+
session,
|
|
1280
|
+
finalizeBracket,
|
|
1281
|
+
transcript.content,
|
|
1282
|
+
transcript.metadata.usage,
|
|
1283
|
+
transcript.metadata.model ?? target.model,
|
|
1284
|
+
streamBracket !== undefined ? { stream: streamBracket, outcome } : undefined,
|
|
1285
|
+
)
|
|
1286
|
+
} catch {
|
|
1287
|
+
// The failure-tolerant post-step still records its outcome below.
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (turn.abortCause === 'operator') {
|
|
1291
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1292
|
+
return { actor: agentActor(step, session), failureNote: undefined, cancelled: true }
|
|
1293
|
+
}
|
|
1294
|
+
if (turnError === undefined && result?.kind !== 'failed') {
|
|
1295
|
+
this.closeBracketStream(streamBracket, 'completed')
|
|
1296
|
+
return { actor: agentActor(step, session), failureNote: undefined, cancelled: false }
|
|
1297
|
+
}
|
|
1298
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1299
|
+
const failure: AgentTurnFailure =
|
|
1300
|
+
turnError !== undefined
|
|
1301
|
+
? { message: errorMessage(turnError), permanent: false, cause: 'availability' }
|
|
1302
|
+
: result?.kind === 'failed'
|
|
1303
|
+
? result.failure
|
|
1304
|
+
: {
|
|
1305
|
+
message: 'agent session returned no result',
|
|
1306
|
+
permanent: false,
|
|
1307
|
+
cause: 'availability',
|
|
1308
|
+
}
|
|
1309
|
+
const attempt: ProviderAttempt = {
|
|
1310
|
+
index,
|
|
1311
|
+
session,
|
|
1312
|
+
runner: target.runtime,
|
|
1313
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1314
|
+
error: failure.message,
|
|
1315
|
+
...(failure.cause !== undefined ? { cause: failure.cause } : {}),
|
|
1316
|
+
}
|
|
1317
|
+
if (index + 1 < targets.length && isAlternateEligible(failure)) {
|
|
1318
|
+
substitution = attempt
|
|
1319
|
+
continue
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
actor: agentActor(step, session),
|
|
1323
|
+
failureNote:
|
|
1324
|
+
`${turnError !== undefined ? 'agent session failed' : 'agent turn failed'}: ` +
|
|
1325
|
+
failure.message,
|
|
1326
|
+
cancelled: false,
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
throw new Error('finalize alternate chain had no execution target')
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
private async runFinalizeAgentSingle(
|
|
1333
|
+
step: string,
|
|
1334
|
+
skill: string,
|
|
1335
|
+
): Promise<{ actor: Actor; failureNote: string | undefined; cancelled: boolean }> {
|
|
1336
|
+
const { store, slug, ids, workspacePath } = this.deps
|
|
1337
|
+
const session = ids('s')
|
|
1338
|
+
const resolved = this.boundaryResolver.resolve(step)
|
|
1339
|
+
const { runner, runtime: runnerName, model, args, sessionBudgetSeconds } = resolved
|
|
1340
|
+
const finalizeBracket = {
|
|
1341
|
+
phase: 'finalize' as const,
|
|
1342
|
+
round: 1,
|
|
1343
|
+
role: step,
|
|
1344
|
+
runnerName,
|
|
1345
|
+
}
|
|
1346
|
+
const streamBracket = await this.openSessionStream(resolved, {
|
|
1347
|
+
session,
|
|
1348
|
+
role: step,
|
|
1349
|
+
runner: runnerName,
|
|
1350
|
+
...(model !== undefined ? { model } : {}),
|
|
1351
|
+
phase: 'finalize',
|
|
1352
|
+
round: 1,
|
|
1353
|
+
})
|
|
1354
|
+
const stream = streamBracket?.sink
|
|
1355
|
+
|
|
1356
|
+
const started = await store.append(slug, {
|
|
1357
|
+
actor: KERNEL,
|
|
1358
|
+
type: 'session.started',
|
|
1359
|
+
payload: {
|
|
1360
|
+
session,
|
|
1361
|
+
role: step,
|
|
1362
|
+
runner: runnerName,
|
|
1363
|
+
...(model !== undefined ? { model } : {}),
|
|
1364
|
+
args: [...args],
|
|
1365
|
+
phase: 'finalize',
|
|
1366
|
+
round: 1,
|
|
1367
|
+
...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
|
|
1368
|
+
},
|
|
1369
|
+
} satisfies EventWrite<'session.started'>)
|
|
1370
|
+
|
|
1371
|
+
let handle: AgentSessionHandle | undefined
|
|
1372
|
+
let failureNote: string | undefined
|
|
1373
|
+
const turn = await this.runBudgetedTurn(started.seq, sessionBudgetSeconds, (signal) =>
|
|
1374
|
+
runner.start({
|
|
1375
|
+
skill,
|
|
1376
|
+
invocation: slug,
|
|
1377
|
+
buildSlug: slug,
|
|
1378
|
+
workspacePath,
|
|
1379
|
+
...(model !== undefined ? { model } : {}),
|
|
1380
|
+
...(args !== undefined ? { args } : {}),
|
|
1381
|
+
env: this.sessionEnvFor('finalize@1', session),
|
|
1382
|
+
signal,
|
|
1383
|
+
...(stream !== undefined ? { stream } : {}),
|
|
1384
|
+
}),
|
|
1385
|
+
)
|
|
1386
|
+
if (turn.settlement.kind === 'value') {
|
|
1387
|
+
handle = turn.settlement.value.session
|
|
1388
|
+
if (turn.settlement.value.result.kind === 'failed') {
|
|
1389
|
+
failureNote = `agent turn failed: ${turn.settlement.value.result.failure.message}`
|
|
1390
|
+
}
|
|
1391
|
+
} else if (turn.settlement.kind === 'error') {
|
|
1392
|
+
failureNote = `agent session failed: ${errorMessage(turn.settlement.error)}`
|
|
1393
|
+
} else {
|
|
1394
|
+
this.releaseLateStart(turn.pending, runner)
|
|
1395
|
+
}
|
|
1396
|
+
if (turn.abortCause === 'budget') {
|
|
1397
|
+
if (handle !== undefined) {
|
|
1398
|
+
this.releaseExpiredSession(runner, handle, session, finalizeBracket, model, streamBracket)
|
|
1399
|
+
} else {
|
|
1400
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1401
|
+
}
|
|
1402
|
+
return {
|
|
1403
|
+
actor: agentActor(step, session),
|
|
1404
|
+
failureNote: sessionBudgetError(sessionBudgetSeconds),
|
|
1405
|
+
cancelled: false,
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (handle !== undefined) {
|
|
1410
|
+
try {
|
|
1411
|
+
const transcript = await runner.end(handle)
|
|
1412
|
+
await this.depositTranscriptAndEnd(
|
|
1413
|
+
session,
|
|
1414
|
+
finalizeBracket,
|
|
1415
|
+
transcript.content,
|
|
1416
|
+
transcript.metadata.usage,
|
|
1417
|
+
transcript.metadata.model ?? model,
|
|
1418
|
+
streamBracket !== undefined
|
|
1419
|
+
? {
|
|
1420
|
+
stream: streamBracket,
|
|
1421
|
+
outcome: failureNote === undefined ? 'completed' : 'aborted',
|
|
1422
|
+
}
|
|
1423
|
+
: undefined,
|
|
1424
|
+
)
|
|
1425
|
+
} catch {
|
|
1426
|
+
// Best-effort: the step outcome below is still recorded.
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
this.closeBracketStream(
|
|
1430
|
+
streamBracket,
|
|
1431
|
+
failureNote === undefined && turn.abortCause !== 'operator' ? 'completed' : 'aborted',
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
return {
|
|
1435
|
+
actor: agentActor(step, session),
|
|
1436
|
+
failureNote,
|
|
1437
|
+
cancelled: turn.abortCause === 'operator',
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** Both finalize kinds append the same facts and always let the engine
|
|
1442
|
+
* advance. Durable-write failures still propagate; only step/publication
|
|
1443
|
+
* work is failure-tolerant. */
|
|
1444
|
+
private async recordFinalizeOutcome(
|
|
1445
|
+
step: string,
|
|
1446
|
+
actor: Actor,
|
|
1447
|
+
failureNote: string | undefined,
|
|
1448
|
+
pushedHead: string | undefined,
|
|
1449
|
+
): Promise<void> {
|
|
1450
|
+
const { store, slug, ids } = this.deps
|
|
1451
|
+
if (failureNote === undefined) {
|
|
1452
|
+
await store.append(slug, {
|
|
1453
|
+
actor,
|
|
1454
|
+
type: 'finalize.step-completed',
|
|
1455
|
+
payload: {
|
|
1456
|
+
step,
|
|
1457
|
+
ok: true,
|
|
1458
|
+
...(pushedHead !== undefined ? { headSha: pushedHead } : {}),
|
|
1459
|
+
},
|
|
1460
|
+
} satisfies EventWrite<'finalize.step-completed'>)
|
|
1461
|
+
return
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
await store.append(slug, {
|
|
1465
|
+
actor,
|
|
1466
|
+
type: 'finalize.step-completed',
|
|
1467
|
+
payload: { step, ok: false, note: failureNote },
|
|
1468
|
+
} satisfies EventWrite<'finalize.step-completed'>)
|
|
1469
|
+
// §5: a failed post-step files an observation; it never kills a green
|
|
1470
|
+
// build. Store-write failures intentionally propagate: no durable fact is
|
|
1471
|
+
// claimed when its append did not land.
|
|
1472
|
+
await store.append(slug, {
|
|
1473
|
+
actor,
|
|
1474
|
+
type: 'observation.recorded',
|
|
1475
|
+
payload: {
|
|
1476
|
+
id: ids('o'),
|
|
1477
|
+
kind: 'followup',
|
|
1478
|
+
summary: `finalize step "${step}" failed — needs manual follow-up: ${failureNote}`,
|
|
1479
|
+
},
|
|
1480
|
+
} satisfies EventWrite<'observation.recorded'>)
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
private async requireFinalizeWorktreeClean(when: string): Promise<void> {
|
|
1484
|
+
const args = ['git', 'status', '--porcelain']
|
|
1485
|
+
const result = await this.deps.exec(args, { cwd: this.deps.workspacePath })
|
|
1486
|
+
if (result.exitCode !== 0) {
|
|
1487
|
+
throw new Error(
|
|
1488
|
+
`${args.join(' ')} exited ${result.exitCode}: ` +
|
|
1489
|
+
`${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
|
|
1490
|
+
)
|
|
1491
|
+
}
|
|
1492
|
+
if (result.stdout.length > 0) {
|
|
1493
|
+
throw new Error(
|
|
1494
|
+
`workspace must be clean ${when}; commit intended files and remove ` +
|
|
1495
|
+
`temporary files before finishing:\n${result.stdout.trimEnd()}`,
|
|
1496
|
+
)
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
private async resolveFinalizeHead(): Promise<string> {
|
|
1501
|
+
const args = ['git', 'rev-parse', 'HEAD']
|
|
1502
|
+
const result = await this.deps.exec(args, { cwd: this.deps.workspacePath })
|
|
1503
|
+
const head = result.stdout.trim()
|
|
1504
|
+
if (result.exitCode !== 0 || head.length === 0 || /\s/.test(head)) {
|
|
1505
|
+
throw new Error(
|
|
1506
|
+
`${args.join(' ')} did not resolve one commit: ` +
|
|
1507
|
+
`${result.stderr.trim() || head || '(no output)'}`,
|
|
1508
|
+
)
|
|
1509
|
+
}
|
|
1510
|
+
return head
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
private async requirePublishedHeadAncestor(
|
|
1514
|
+
publishedHead: string,
|
|
1515
|
+
currentHead: string,
|
|
1516
|
+
): Promise<void> {
|
|
1517
|
+
const args = ['git', 'merge-base', '--is-ancestor', publishedHead, currentHead]
|
|
1518
|
+
const result = await this.deps.exec(args, { cwd: this.deps.workspacePath })
|
|
1519
|
+
if (result.exitCode === 0) return
|
|
1520
|
+
if (result.exitCode === 1) {
|
|
1521
|
+
throw new Error(
|
|
1522
|
+
`finalize step rewrote branch history: published head ${publishedHead} ` +
|
|
1523
|
+
`is not an ancestor of current HEAD ${currentHead}`,
|
|
1524
|
+
)
|
|
1525
|
+
}
|
|
1526
|
+
throw new Error(
|
|
1527
|
+
`${args.join(' ')} exited ${result.exitCode}: ` +
|
|
1528
|
+
`${result.stderr.trim() || result.stdout.trim() || '(no output)'}`,
|
|
1529
|
+
)
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// ── The session bracket (§15.3) ────────────────────────────────────────────
|
|
1533
|
+
|
|
1534
|
+
/** Run one adapter turn under the logical role's wall-clock budget. The
|
|
1535
|
+
* expiry promise is resolved one microtask after the deadline so a conforming
|
|
1536
|
+
* adapter can return its endable handle first. An earlier operator abort owns
|
|
1537
|
+
* classification, but does not disarm this backstop for an adapter that
|
|
1538
|
+
* ignores cancellation. */
|
|
1539
|
+
private async runBudgetedTurn<T>(
|
|
1540
|
+
preSeq: number,
|
|
1541
|
+
budgetSeconds: number,
|
|
1542
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
1543
|
+
): Promise<BudgetedTurn<T>> {
|
|
1544
|
+
const { store, slug } = this.deps
|
|
1545
|
+
const controller = new AbortController()
|
|
1546
|
+
let abortCause: 'operator' | 'budget' | undefined
|
|
1547
|
+
let resolveExpiry!: (settlement: { kind: 'expired' }) => void
|
|
1548
|
+
const expiry = new Promise<{ kind: 'expired' }>((resolve) => {
|
|
1549
|
+
resolveExpiry = resolve
|
|
1550
|
+
})
|
|
1551
|
+
const pending = Promise.resolve()
|
|
1552
|
+
.then(() => run(controller.signal))
|
|
1553
|
+
.then(
|
|
1554
|
+
(value): { kind: 'value'; value: T } => ({ kind: 'value', value }),
|
|
1555
|
+
(error): { kind: 'error'; error: unknown } => ({ kind: 'error', error }),
|
|
1556
|
+
)
|
|
1557
|
+
const unsubscribe = store.subscribe(slug, { fromSeq: preSeq, pollMs: 25 }, (event) => {
|
|
1558
|
+
if (event.type === 'build.abort-requested' && !controller.signal.aborted) {
|
|
1559
|
+
abortCause = 'operator'
|
|
1560
|
+
controller.abort(new Error(`build ${slug} aborted by operator`))
|
|
1561
|
+
}
|
|
1562
|
+
})
|
|
1563
|
+
const schedule = this.deps.opts?.scheduleSessionBudget ?? defaultSessionBudgetScheduler
|
|
1564
|
+
const cancelTimer = schedule(() => {
|
|
1565
|
+
if (!controller.signal.aborted) {
|
|
1566
|
+
abortCause = 'budget'
|
|
1567
|
+
controller.abort(new Error(sessionBudgetError(budgetSeconds)))
|
|
1568
|
+
}
|
|
1569
|
+
queueMicrotask(() => resolveExpiry({ kind: 'expired' }))
|
|
1570
|
+
}, budgetSeconds * 1000)
|
|
1571
|
+
|
|
1572
|
+
try {
|
|
1573
|
+
const settlement = await Promise.race([pending, expiry])
|
|
1574
|
+
return {
|
|
1575
|
+
settlement,
|
|
1576
|
+
...(abortCause !== undefined ? { abortCause } : {}),
|
|
1577
|
+
pending,
|
|
1578
|
+
}
|
|
1579
|
+
} finally {
|
|
1580
|
+
cancelTimer()
|
|
1581
|
+
unsubscribe()
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** Start resource cleanup without allowing a stuck `end()` or transcript
|
|
1586
|
+
* deposit to extend an already-expired phase. Conforming adapters still end
|
|
1587
|
+
* immediately; every cleanup/deposit failure remains best-effort. The
|
|
1588
|
+
* bracket's stream closes `aborted` (budget cancellation) before the
|
|
1589
|
+
* deposit, preserving the close-before-`session.ended` ordering. */
|
|
1590
|
+
private releaseExpiredSession(
|
|
1591
|
+
runner: AgentRunner,
|
|
1592
|
+
handle: AgentSessionHandle,
|
|
1593
|
+
session: string,
|
|
1594
|
+
bracket: { phase: Phase; round: number; role: string; runnerName: string },
|
|
1595
|
+
model?: string,
|
|
1596
|
+
stream?: SessionBracketStream,
|
|
1597
|
+
): void {
|
|
1598
|
+
void (async () => {
|
|
1599
|
+
try {
|
|
1600
|
+
const transcript = await runner.end(handle)
|
|
1601
|
+
await this.depositTranscriptAndEnd(
|
|
1602
|
+
session,
|
|
1603
|
+
bracket,
|
|
1604
|
+
transcript.content,
|
|
1605
|
+
transcript.metadata.usage,
|
|
1606
|
+
transcript.metadata.model ?? model,
|
|
1607
|
+
stream !== undefined ? { stream, outcome: 'aborted' } : undefined,
|
|
1608
|
+
)
|
|
1609
|
+
} catch {
|
|
1610
|
+
// The expiry failure must remain bounded even when cleanup is not.
|
|
1611
|
+
} finally {
|
|
1612
|
+
// Belt and braces: idempotent close covers a failed deposit path.
|
|
1613
|
+
if (stream !== undefined) void stream.sink.close('aborted')
|
|
1614
|
+
}
|
|
1615
|
+
})()
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/** A start call can ignore cancellation and return a handle after the kernel
|
|
1619
|
+
* has advanced. End that late handle in the background so it never becomes
|
|
1620
|
+
* reusable continuation state. The stale durable bracket is intentionally
|
|
1621
|
+
* left for the existing recovery model; complete descendant reaping remains
|
|
1622
|
+
* the execution supervisor's exit boundary. */
|
|
1623
|
+
private releaseLateStart(
|
|
1624
|
+
pending: Promise<Exclude<TurnSettlement<{ session: AgentSessionHandle }>, { kind: 'expired' }>>,
|
|
1625
|
+
runner: AgentRunner,
|
|
1626
|
+
): void {
|
|
1627
|
+
void pending.then(async (settlement) => {
|
|
1628
|
+
if (settlement.kind !== 'value') return
|
|
1629
|
+
try {
|
|
1630
|
+
await runner.end(settlement.value.session)
|
|
1631
|
+
} catch {
|
|
1632
|
+
// Best-effort resource release after a non-conforming late return.
|
|
1633
|
+
}
|
|
1634
|
+
})
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/**
|
|
1638
|
+
* One bracketed agent run: `session.started` → turn → terminal check →
|
|
1639
|
+
* crash-gap repair → `session.ended` (with transcript) or `phase.failed`.
|
|
1640
|
+
* Producer phases with a live handle continue it (§10); reviewers always
|
|
1641
|
+
* start fresh (fresh skeptic).
|
|
1642
|
+
*/
|
|
1643
|
+
private async executeSession(spec: SessionSpec): Promise<void> {
|
|
1644
|
+
const resolved = this.boundaryResolver.resolve(spec.role, ...(spec.roleAliases ?? []))
|
|
1645
|
+
// Preserve the historical single-target path byte-for-byte when no chain
|
|
1646
|
+
// is configured. Besides compatibility, this keeps its crash-gap and
|
|
1647
|
+
// producer-continuation behavior independently well exercised.
|
|
1648
|
+
if (resolved.alternates.length === 0) return this.executeSingleSession(spec)
|
|
1649
|
+
return this.executeSessionChain(spec, resolved, resolved.alternates)
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** Execute all configured targets inside one phase attempt. Every target has
|
|
1653
|
+
* its own durable session bracket; only exhaustion writes phase.failed. */
|
|
1654
|
+
private async executeSessionChain(
|
|
1655
|
+
spec: SessionSpec,
|
|
1656
|
+
primary: ResolvedRole,
|
|
1657
|
+
alternates: readonly ResolvedRuntime[],
|
|
1658
|
+
): Promise<void> {
|
|
1659
|
+
const { store, slug, ids, workspacePath } = this.deps
|
|
1660
|
+
const targets = [primary, ...alternates]
|
|
1661
|
+
const attempts: ProviderAttempt[] = []
|
|
1662
|
+
let substitution: ProviderAttempt | undefined
|
|
1663
|
+
const primaryRoute = JSON.stringify({
|
|
1664
|
+
runtime: primary.runtime,
|
|
1665
|
+
model: primary.model,
|
|
1666
|
+
args: primary.args,
|
|
1667
|
+
})
|
|
1668
|
+
let primaryLive =
|
|
1669
|
+
spec.producerPhase !== undefined ? this.producerSessions.get(spec.producerPhase) : undefined
|
|
1670
|
+
if (
|
|
1671
|
+
primaryLive !== undefined &&
|
|
1672
|
+
(primaryLive.runner !== primary.runner || primaryLive.route !== primaryRoute)
|
|
1673
|
+
) {
|
|
1674
|
+
try {
|
|
1675
|
+
await primaryLive.runner.end(primaryLive.handle)
|
|
1676
|
+
} catch {
|
|
1677
|
+
// Best-effort cleanup after the previous round reached its boundary.
|
|
1678
|
+
}
|
|
1679
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1680
|
+
primaryLive = undefined
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
1684
|
+
const target = targets[index]!
|
|
1685
|
+
const session = ids('s')
|
|
1686
|
+
// SPEC §9: each chain target gets its own durable bracket and its own
|
|
1687
|
+
// stream, opened before the bracket's first turn.
|
|
1688
|
+
const streamBracket = await this.openSessionStream(target, {
|
|
1689
|
+
session,
|
|
1690
|
+
role: spec.role,
|
|
1691
|
+
runner: target.runtime,
|
|
1692
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1693
|
+
phase: spec.phase,
|
|
1694
|
+
round: spec.round,
|
|
1695
|
+
})
|
|
1696
|
+
const stream = streamBracket?.sink
|
|
1697
|
+
const startedEnvelope = await store.append(slug, {
|
|
1698
|
+
actor: KERNEL,
|
|
1699
|
+
type: 'session.started',
|
|
1700
|
+
payload: {
|
|
1701
|
+
session,
|
|
1702
|
+
role: spec.role,
|
|
1703
|
+
runner: target.runtime,
|
|
1704
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1705
|
+
args: [...target.args],
|
|
1706
|
+
phase: spec.phase,
|
|
1707
|
+
round: spec.round,
|
|
1708
|
+
...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
|
|
1709
|
+
...(substitution !== undefined
|
|
1710
|
+
? { substitution: { failed: substitution, selectedIndex: index } }
|
|
1711
|
+
: {}),
|
|
1712
|
+
},
|
|
1713
|
+
} satisfies EventWrite<'session.started'>)
|
|
1714
|
+
const preSeq = startedEnvelope.seq
|
|
1715
|
+
const live = index === 0 ? primaryLive : undefined
|
|
1716
|
+
|
|
1717
|
+
let handle: AgentSessionHandle | undefined = live?.handle
|
|
1718
|
+
let result: AgentTurnResult | undefined
|
|
1719
|
+
let turnError: unknown
|
|
1720
|
+
const turn = await this.runBudgetedTurn(preSeq, primary.sessionBudgetSeconds, (signal) =>
|
|
1721
|
+
live !== undefined
|
|
1722
|
+
? live.runner
|
|
1723
|
+
.continue(live.handle, continueMessage(spec), {
|
|
1724
|
+
env: this.sessionEnvFor(spec.abPhase, session),
|
|
1725
|
+
signal,
|
|
1726
|
+
...(stream !== undefined ? { stream } : {}),
|
|
1727
|
+
})
|
|
1728
|
+
.then((continued) => ({ session: live.handle, result: continued }))
|
|
1729
|
+
: target.runner.start({
|
|
1730
|
+
skill: spec.skill,
|
|
1731
|
+
invocation: slug,
|
|
1732
|
+
buildSlug: slug,
|
|
1733
|
+
workspacePath,
|
|
1734
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1735
|
+
...(target.args !== undefined ? { args: target.args } : {}),
|
|
1736
|
+
env: this.sessionEnvFor(spec.abPhase, session),
|
|
1737
|
+
signal,
|
|
1738
|
+
...(stream !== undefined ? { stream } : {}),
|
|
1739
|
+
}),
|
|
1740
|
+
)
|
|
1741
|
+
if (turn.settlement.kind === 'value') {
|
|
1742
|
+
handle = turn.settlement.value.session
|
|
1743
|
+
result = turn.settlement.value.result
|
|
1744
|
+
} else if (turn.settlement.kind === 'error') {
|
|
1745
|
+
turnError = turn.settlement.error
|
|
1746
|
+
} else if (live === undefined) {
|
|
1747
|
+
this.releaseLateStart(turn.pending, target.runner)
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
const since = await store.getEvents(slug, preSeq)
|
|
1751
|
+
const terminal = since.some(
|
|
1752
|
+
(event) =>
|
|
1753
|
+
spec.isTerminal(event) ||
|
|
1754
|
+
isPublicationBoundary(event, spec, session) ||
|
|
1755
|
+
(event.type === 'escalation.raised' &&
|
|
1756
|
+
event.actor.kind === 'agent' &&
|
|
1757
|
+
event.actor.session === session),
|
|
1758
|
+
)
|
|
1759
|
+
if (spec.phase === 'plan-review' || spec.phase === 'code-review') {
|
|
1760
|
+
await this.repairEscalateGap(since, spec.phase, spec.round)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const bracket = {
|
|
1764
|
+
phase: spec.phase,
|
|
1765
|
+
round: spec.round,
|
|
1766
|
+
role: spec.role,
|
|
1767
|
+
runnerName: target.runtime,
|
|
1768
|
+
}
|
|
1769
|
+
const owner = live?.runner ?? target.runner
|
|
1770
|
+
|
|
1771
|
+
if (turn.abortCause === 'operator') {
|
|
1772
|
+
if (handle !== undefined) {
|
|
1773
|
+
try {
|
|
1774
|
+
const transcript = await owner.end(handle)
|
|
1775
|
+
await this.depositTranscriptAndEnd(
|
|
1776
|
+
session,
|
|
1777
|
+
bracket,
|
|
1778
|
+
transcript.content,
|
|
1779
|
+
transcript.metadata.usage,
|
|
1780
|
+
transcript.metadata.model ?? target.model,
|
|
1781
|
+
streamBracket !== undefined
|
|
1782
|
+
? { stream: streamBracket, outcome: 'aborted' }
|
|
1783
|
+
: undefined,
|
|
1784
|
+
)
|
|
1785
|
+
} catch {
|
|
1786
|
+
// Cancellation remains a control boundary, never phase failure.
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1790
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1791
|
+
return
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// A typed terminal deposited at the deadline remains authoritative even
|
|
1795
|
+
// when the adapter has not yet returned its handle/result.
|
|
1796
|
+
if (terminal && turn.abortCause === 'budget') {
|
|
1797
|
+
if (handle !== undefined) {
|
|
1798
|
+
this.releaseExpiredSession(owner, handle, session, bracket, target.model, streamBracket)
|
|
1799
|
+
} else {
|
|
1800
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1801
|
+
}
|
|
1802
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1803
|
+
return
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
if (terminal && handle !== undefined && result !== undefined) {
|
|
1807
|
+
if (spec.producerPhase !== undefined && index === 0) {
|
|
1808
|
+
this.producerSessions.set(spec.producerPhase, {
|
|
1809
|
+
handle,
|
|
1810
|
+
runner: owner,
|
|
1811
|
+
route: primaryRoute,
|
|
1812
|
+
})
|
|
1813
|
+
const content = JSON.stringify(
|
|
1814
|
+
{
|
|
1815
|
+
session,
|
|
1816
|
+
phase: spec.phase,
|
|
1817
|
+
round: spec.round,
|
|
1818
|
+
note: 'producer session kept live for §10 continuation; per-round turn transcript',
|
|
1819
|
+
turn: { text: result.text, usage: result.usage },
|
|
1820
|
+
},
|
|
1821
|
+
null,
|
|
1822
|
+
2,
|
|
1823
|
+
)
|
|
1824
|
+
await this.depositTranscriptAndEnd(
|
|
1825
|
+
session,
|
|
1826
|
+
bracket,
|
|
1827
|
+
content,
|
|
1828
|
+
result.usage,
|
|
1829
|
+
target.model,
|
|
1830
|
+
streamBracket !== undefined
|
|
1831
|
+
? { stream: streamBracket, outcome: 'completed' }
|
|
1832
|
+
: undefined,
|
|
1833
|
+
)
|
|
1834
|
+
} else {
|
|
1835
|
+
// An alternate producer must not become sticky across the review
|
|
1836
|
+
// boundary. End it like any fresh session; the next round begins on
|
|
1837
|
+
// the primary and rehydrates context from durable artifacts.
|
|
1838
|
+
const transcript = await owner.end(handle)
|
|
1839
|
+
await this.depositTranscriptAndEnd(
|
|
1840
|
+
session,
|
|
1841
|
+
bracket,
|
|
1842
|
+
transcript.content,
|
|
1843
|
+
transcript.metadata.usage,
|
|
1844
|
+
transcript.metadata.model ?? target.model,
|
|
1845
|
+
streamBracket !== undefined
|
|
1846
|
+
? { stream: streamBracket, outcome: 'completed' }
|
|
1847
|
+
: undefined,
|
|
1848
|
+
)
|
|
1849
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1850
|
+
}
|
|
1851
|
+
return
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (terminal) {
|
|
1855
|
+
if (handle !== undefined) {
|
|
1856
|
+
try {
|
|
1857
|
+
const transcript = await owner.end(handle)
|
|
1858
|
+
await this.depositTranscriptAndEnd(
|
|
1859
|
+
session,
|
|
1860
|
+
bracket,
|
|
1861
|
+
transcript.content,
|
|
1862
|
+
transcript.metadata.usage,
|
|
1863
|
+
transcript.metadata.model ?? target.model,
|
|
1864
|
+
streamBracket !== undefined
|
|
1865
|
+
? { stream: streamBracket, outcome: 'completed' }
|
|
1866
|
+
: undefined,
|
|
1867
|
+
)
|
|
1868
|
+
} catch {
|
|
1869
|
+
// The durable terminal remains authoritative over adapter failure
|
|
1870
|
+
// and best-effort transcript cleanup.
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
this.closeBracketStream(streamBracket, 'completed')
|
|
1874
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1875
|
+
return
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
if (turn.abortCause === 'budget') {
|
|
1879
|
+
if (handle !== undefined) {
|
|
1880
|
+
this.releaseExpiredSession(owner, handle, session, bracket, target.model, streamBracket)
|
|
1881
|
+
} else {
|
|
1882
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1883
|
+
}
|
|
1884
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1885
|
+
await this.failPhase(
|
|
1886
|
+
spec.phase,
|
|
1887
|
+
spec.round,
|
|
1888
|
+
spec.priorFailures,
|
|
1889
|
+
sessionBudgetError(primary.sessionBudgetSeconds),
|
|
1890
|
+
true,
|
|
1891
|
+
)
|
|
1892
|
+
return
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
if (handle !== undefined) {
|
|
1896
|
+
try {
|
|
1897
|
+
const transcript = await owner.end(handle)
|
|
1898
|
+
await this.depositTranscriptAndEnd(
|
|
1899
|
+
session,
|
|
1900
|
+
bracket,
|
|
1901
|
+
transcript.content,
|
|
1902
|
+
transcript.metadata.usage,
|
|
1903
|
+
transcript.metadata.model ?? target.model,
|
|
1904
|
+
streamBracket !== undefined ? { stream: streamBracket, outcome: 'aborted' } : undefined,
|
|
1905
|
+
)
|
|
1906
|
+
} catch {
|
|
1907
|
+
// A dead provider may leave no recoverable transcript.
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
1911
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1912
|
+
|
|
1913
|
+
const failure: AgentTurnFailure =
|
|
1914
|
+
turnError !== undefined
|
|
1915
|
+
? { message: errorMessage(turnError), permanent: false, cause: 'availability' }
|
|
1916
|
+
: result?.kind === 'failed'
|
|
1917
|
+
? result.failure
|
|
1918
|
+
: { message: 'no-terminal', permanent: false }
|
|
1919
|
+
const attempt: ProviderAttempt = {
|
|
1920
|
+
index,
|
|
1921
|
+
session,
|
|
1922
|
+
runner: target.runtime,
|
|
1923
|
+
...(target.model !== undefined ? { model: target.model } : {}),
|
|
1924
|
+
error: failure.message,
|
|
1925
|
+
...(failure.cause !== undefined ? { cause: failure.cause } : {}),
|
|
1926
|
+
}
|
|
1927
|
+
attempts.push(attempt)
|
|
1928
|
+
|
|
1929
|
+
const eligibleForAlternate =
|
|
1930
|
+
turnError !== undefined ||
|
|
1931
|
+
(result?.kind === 'failed' && isAlternateEligible(result.failure))
|
|
1932
|
+
if (index + 1 < targets.length && eligibleForAlternate) {
|
|
1933
|
+
substitution = attempt
|
|
1934
|
+
continue
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
await this.failPhase(
|
|
1938
|
+
spec.phase,
|
|
1939
|
+
spec.round,
|
|
1940
|
+
spec.priorFailures,
|
|
1941
|
+
failure.message,
|
|
1942
|
+
mayRetryPhase(failure),
|
|
1943
|
+
attempts,
|
|
1944
|
+
)
|
|
1945
|
+
return
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
/** Historical single-target bracket, retained unchanged for repositories
|
|
1950
|
+
* that declare no alternates. */
|
|
1951
|
+
private async executeSingleSession(spec: SessionSpec): Promise<void> {
|
|
1952
|
+
const { store, slug, ids, workspacePath } = this.deps
|
|
1953
|
+
const session = ids('s')
|
|
1954
|
+
// `session.started.role` records the LOGICAL name the pipeline dispatched
|
|
1955
|
+
// in every case, including when a deprecated alias answered the lookup —
|
|
1956
|
+
// the runtime/model on the same payload say what was resolved.
|
|
1957
|
+
const resolved = this.boundaryResolver.resolve(spec.role, ...(spec.roleAliases ?? []))
|
|
1958
|
+
const { runner, runtime: runnerName, model, args, sessionBudgetSeconds } = resolved
|
|
1959
|
+
const route = JSON.stringify({ runtime: runnerName, model, args })
|
|
1960
|
+
// SPEC §9: the stream opens before the bracket's first turn — its id is
|
|
1961
|
+
// already known when `session.started` appends.
|
|
1962
|
+
const streamBracket = await this.openSessionStream(resolved, {
|
|
1963
|
+
session,
|
|
1964
|
+
role: spec.role,
|
|
1965
|
+
runner: runnerName,
|
|
1966
|
+
...(model !== undefined ? { model } : {}),
|
|
1967
|
+
phase: spec.phase,
|
|
1968
|
+
round: spec.round,
|
|
1969
|
+
})
|
|
1970
|
+
let live =
|
|
1971
|
+
spec.producerPhase !== undefined ? this.producerSessions.get(spec.producerPhase) : undefined
|
|
1972
|
+
if (live !== undefined && (live.runner !== runner || live.route !== route)) {
|
|
1973
|
+
// A completed old round may keep provider continuation state, but a new
|
|
1974
|
+
// route must begin a new provider session. The old round itself was not
|
|
1975
|
+
// interrupted; it reached this boundary before being closed.
|
|
1976
|
+
try {
|
|
1977
|
+
await live.runner.end(live.handle)
|
|
1978
|
+
} catch {
|
|
1979
|
+
// Best-effort provider cleanup; route adoption must still proceed.
|
|
1980
|
+
}
|
|
1981
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
1982
|
+
live = undefined
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
const startedEnvelope = await store.append(slug, {
|
|
1986
|
+
actor: KERNEL,
|
|
1987
|
+
type: 'session.started',
|
|
1988
|
+
payload: {
|
|
1989
|
+
session,
|
|
1990
|
+
role: spec.role,
|
|
1991
|
+
runner: runnerName,
|
|
1992
|
+
...(model !== undefined ? { model } : {}),
|
|
1993
|
+
args: [...args],
|
|
1994
|
+
phase: spec.phase,
|
|
1995
|
+
round: spec.round,
|
|
1996
|
+
...(streamBracket !== undefined ? { stream: streamBracket.streamId } : {}),
|
|
1997
|
+
},
|
|
1998
|
+
} satisfies EventWrite<'session.started'>)
|
|
1999
|
+
const preSeq = startedEnvelope.seq
|
|
2000
|
+
const stream = streamBracket?.sink
|
|
2001
|
+
|
|
2002
|
+
let handle: AgentSessionHandle | undefined = live?.handle
|
|
2003
|
+
let result: AgentTurnResult | undefined
|
|
2004
|
+
let turnError: unknown
|
|
2005
|
+
const turn = await this.runBudgetedTurn(preSeq, sessionBudgetSeconds, (signal) => {
|
|
2006
|
+
if (live !== undefined) {
|
|
2007
|
+
// §10: the producer continues its session; the feedback message
|
|
2008
|
+
// points at the .ab/ inputs — `ab context` is the real carrier. The
|
|
2009
|
+
// turn re-issues ambient auth (D8) for the NEW bracket — AB_PHASE at
|
|
2010
|
+
// this round, AB_SESSION = this bracket's id — so the CLI's terminal
|
|
2011
|
+
// lands on the continued round instead of being rejected as round
|
|
2012
|
+
// 1's second terminal (§8.4 D5).
|
|
2013
|
+
return live.runner
|
|
2014
|
+
.continue(live.handle, continueMessage(spec), {
|
|
2015
|
+
env: this.sessionEnvFor(spec.abPhase, session),
|
|
2016
|
+
signal,
|
|
2017
|
+
...(stream !== undefined ? { stream } : {}),
|
|
2018
|
+
})
|
|
2019
|
+
.then((continued) => ({ session: live.handle, result: continued }))
|
|
2020
|
+
}
|
|
2021
|
+
return runner.start({
|
|
2022
|
+
skill: spec.skill,
|
|
2023
|
+
invocation: slug,
|
|
2024
|
+
buildSlug: slug,
|
|
2025
|
+
workspacePath,
|
|
2026
|
+
...(model !== undefined ? { model } : {}),
|
|
2027
|
+
...(args !== undefined ? { args } : {}),
|
|
2028
|
+
env: this.sessionEnvFor(spec.abPhase, session),
|
|
2029
|
+
signal,
|
|
2030
|
+
...(stream !== undefined ? { stream } : {}),
|
|
2031
|
+
})
|
|
2032
|
+
})
|
|
2033
|
+
if (turn.settlement.kind === 'value') {
|
|
2034
|
+
handle = turn.settlement.value.session
|
|
2035
|
+
result = turn.settlement.value.result
|
|
2036
|
+
} else if (turn.settlement.kind === 'error') {
|
|
2037
|
+
turnError = turn.settlement.error
|
|
2038
|
+
} else if (live === undefined) {
|
|
2039
|
+
this.releaseLateStart(turn.pending, runner)
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// The turn is over: what did the CLI leave in the log? Terminal = the
|
|
2043
|
+
// phase's terminal event for this round, or an `ab escalate` from this
|
|
2044
|
+
// session (§8.4: escalate is always an allowed terminal).
|
|
2045
|
+
const since = await store.getEvents(slug, preSeq)
|
|
2046
|
+
const terminal = since.some(
|
|
2047
|
+
(event) =>
|
|
2048
|
+
spec.isTerminal(event) ||
|
|
2049
|
+
isPublicationBoundary(event, spec, session) ||
|
|
2050
|
+
(event.type === 'escalation.raised' &&
|
|
2051
|
+
event.actor.kind === 'agent' &&
|
|
2052
|
+
event.actor.session === session),
|
|
2053
|
+
)
|
|
2054
|
+
|
|
2055
|
+
// CRASH-GAP REPAIR (§8.5 makes verdict+escalation near-atomic; this keeps
|
|
2056
|
+
// the log routable when the CLI died between the two).
|
|
2057
|
+
if (spec.phase === 'plan-review' || spec.phase === 'code-review') {
|
|
2058
|
+
await this.repairEscalateGap(since, spec.phase, spec.round)
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const bracket = {
|
|
2062
|
+
phase: spec.phase,
|
|
2063
|
+
round: spec.round,
|
|
2064
|
+
role: spec.role,
|
|
2065
|
+
runnerName,
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// Operator cancellation is not an infrastructure failure. Close whatever
|
|
2069
|
+
// handle the adapter created, retain its transcript, and return to the
|
|
2070
|
+
// engine loop; the pending durable command is acknowledged there.
|
|
2071
|
+
if (turn.abortCause === 'operator') {
|
|
2072
|
+
if (handle !== undefined) {
|
|
2073
|
+
const owner = live?.runner ?? runner
|
|
2074
|
+
try {
|
|
2075
|
+
const transcript = await owner.end(handle)
|
|
2076
|
+
await this.depositTranscriptAndEnd(
|
|
2077
|
+
session,
|
|
2078
|
+
bracket,
|
|
2079
|
+
transcript.content,
|
|
2080
|
+
transcript.metadata.usage,
|
|
2081
|
+
transcript.metadata.model ?? model,
|
|
2082
|
+
streamBracket !== undefined ? { stream: streamBracket, outcome: 'aborted' } : undefined,
|
|
2083
|
+
)
|
|
2084
|
+
} catch {
|
|
2085
|
+
// A provider that died before yielding an endable handle leaves the
|
|
2086
|
+
// open session for stale-runner recovery, but never a phase failure.
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
2090
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
2091
|
+
return
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// The typed terminal is the phase authority even when it lands on the
|
|
2095
|
+
// same boundary tick as the kernel deadline.
|
|
2096
|
+
if (terminal && turn.abortCause === 'budget') {
|
|
2097
|
+
if (handle !== undefined) {
|
|
2098
|
+
const owner = live?.runner ?? runner
|
|
2099
|
+
this.releaseExpiredSession(owner, handle, session, bracket, model, streamBracket)
|
|
2100
|
+
} else {
|
|
2101
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
2102
|
+
}
|
|
2103
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
2104
|
+
return
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (terminal && handle !== undefined && result !== undefined) {
|
|
2108
|
+
if (spec.producerPhase !== undefined) {
|
|
2109
|
+
// §10 memory: keep the handle live for the next round; the bracket
|
|
2110
|
+
// closes with THIS turn's transcript (see module doc — ending the
|
|
2111
|
+
// runner session would destroy the continuation every adapter needs).
|
|
2112
|
+
this.producerSessions.set(spec.producerPhase, {
|
|
2113
|
+
handle,
|
|
2114
|
+
runner: live?.runner ?? runner,
|
|
2115
|
+
route,
|
|
2116
|
+
})
|
|
2117
|
+
const content = JSON.stringify(
|
|
2118
|
+
{
|
|
2119
|
+
session,
|
|
2120
|
+
phase: spec.phase,
|
|
2121
|
+
round: spec.round,
|
|
2122
|
+
note: 'producer session kept live for §10 continuation; ' + 'per-round turn transcript',
|
|
2123
|
+
turn: { text: result.text, usage: result.usage },
|
|
2124
|
+
},
|
|
2125
|
+
null,
|
|
2126
|
+
2,
|
|
2127
|
+
)
|
|
2128
|
+
await this.depositTranscriptAndEnd(
|
|
2129
|
+
session,
|
|
2130
|
+
bracket,
|
|
2131
|
+
content,
|
|
2132
|
+
result.usage,
|
|
2133
|
+
model,
|
|
2134
|
+
streamBracket !== undefined ? { stream: streamBracket, outcome: 'completed' } : undefined,
|
|
2135
|
+
)
|
|
2136
|
+
} else {
|
|
2137
|
+
// Reviewer / agent-verify: single-run session, real transcript.
|
|
2138
|
+
const transcript = await runner.end(handle)
|
|
2139
|
+
await this.depositTranscriptAndEnd(
|
|
2140
|
+
session,
|
|
2141
|
+
bracket,
|
|
2142
|
+
transcript.content,
|
|
2143
|
+
transcript.metadata.usage,
|
|
2144
|
+
transcript.metadata.model ?? model,
|
|
2145
|
+
streamBracket !== undefined ? { stream: streamBracket, outcome: 'completed' } : undefined,
|
|
2146
|
+
)
|
|
2147
|
+
}
|
|
2148
|
+
return
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (terminal) {
|
|
2152
|
+
if (handle !== undefined) {
|
|
2153
|
+
const owner = live?.runner ?? runner
|
|
2154
|
+
try {
|
|
2155
|
+
const transcript = await owner.end(handle)
|
|
2156
|
+
await this.depositTranscriptAndEnd(
|
|
2157
|
+
session,
|
|
2158
|
+
bracket,
|
|
2159
|
+
transcript.content,
|
|
2160
|
+
transcript.metadata.usage,
|
|
2161
|
+
transcript.metadata.model ?? model,
|
|
2162
|
+
streamBracket !== undefined
|
|
2163
|
+
? { stream: streamBracket, outcome: 'completed' }
|
|
2164
|
+
: undefined,
|
|
2165
|
+
)
|
|
2166
|
+
} catch {
|
|
2167
|
+
// The durable terminal remains authoritative over adapter failure
|
|
2168
|
+
// and best-effort transcript cleanup.
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
this.closeBracketStream(streamBracket, 'completed')
|
|
2172
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
2173
|
+
return
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
if (turn.abortCause === 'budget') {
|
|
2177
|
+
if (handle !== undefined) {
|
|
2178
|
+
const owner = live?.runner ?? runner
|
|
2179
|
+
this.releaseExpiredSession(owner, handle, session, bracket, model, streamBracket)
|
|
2180
|
+
} else {
|
|
2181
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
2182
|
+
}
|
|
2183
|
+
if (spec.producerPhase !== undefined) this.producerSessions.delete(spec.producerPhase)
|
|
2184
|
+
await this.failPhase(
|
|
2185
|
+
spec.phase,
|
|
2186
|
+
spec.round,
|
|
2187
|
+
spec.priorFailures,
|
|
2188
|
+
sessionBudgetError(sessionBudgetSeconds),
|
|
2189
|
+
true,
|
|
2190
|
+
)
|
|
2191
|
+
return
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// No terminal, a structured failed turn, or a throw: an infra failure,
|
|
2195
|
+
// not a verdict (§8.4). End whatever session exists — the transcript still
|
|
2196
|
+
// joins the corpus —
|
|
2197
|
+
// then record the failure and drop the producer handle: a rambling
|
|
2198
|
+
// session is not continued; the next attempt starts fresh (D5).
|
|
2199
|
+
if (handle !== undefined) {
|
|
2200
|
+
const owner = live?.runner ?? runner
|
|
2201
|
+
try {
|
|
2202
|
+
const transcript = await owner.end(handle)
|
|
2203
|
+
await this.depositTranscriptAndEnd(
|
|
2204
|
+
session,
|
|
2205
|
+
bracket,
|
|
2206
|
+
transcript.content,
|
|
2207
|
+
transcript.metadata.usage,
|
|
2208
|
+
transcript.metadata.model ?? model,
|
|
2209
|
+
streamBracket !== undefined ? { stream: streamBracket, outcome: 'aborted' } : undefined,
|
|
2210
|
+
)
|
|
2211
|
+
} catch {
|
|
2212
|
+
// Transcript deposition can still fail. A later recovering runner
|
|
2213
|
+
// explicitly reclaims this open session before announcing attachment.
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
this.closeBracketStream(streamBracket, 'aborted')
|
|
2217
|
+
if (spec.producerPhase !== undefined) {
|
|
2218
|
+
this.producerSessions.delete(spec.producerPhase)
|
|
2219
|
+
}
|
|
2220
|
+
const failure =
|
|
2221
|
+
turnError !== undefined
|
|
2222
|
+
? { message: errorMessage(turnError), mayRetry: true }
|
|
2223
|
+
: result?.kind === 'failed'
|
|
2224
|
+
? {
|
|
2225
|
+
message: result.failure.message,
|
|
2226
|
+
mayRetry: !result.failure.permanent,
|
|
2227
|
+
}
|
|
2228
|
+
: { message: 'no-terminal', mayRetry: true }
|
|
2229
|
+
await this.failPhase(
|
|
2230
|
+
spec.phase,
|
|
2231
|
+
spec.round,
|
|
2232
|
+
spec.priorFailures,
|
|
2233
|
+
failure.message,
|
|
2234
|
+
failure.mayRetry,
|
|
2235
|
+
)
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/** Transcript artifact + `session.ended` in one atomic bundle (D6). The
|
|
2239
|
+
* metadata IS the analysis corpus (§7.1): phase, round, role, runner,
|
|
2240
|
+
* model, session, usage. When the bracket carried a stream, it closes
|
|
2241
|
+
* here — before the `session.ended` append lands — so a reader never sees
|
|
2242
|
+
* a closed session with an open stream. */
|
|
2243
|
+
private async depositTranscriptAndEnd(
|
|
2244
|
+
session: string,
|
|
2245
|
+
bracket: { phase: Phase; round: number; role: string; runnerName: string },
|
|
2246
|
+
content: string,
|
|
2247
|
+
usage: { inputTokens: number; outputTokens: number; turns: number },
|
|
2248
|
+
model?: string,
|
|
2249
|
+
close?: { stream: SessionBracketStream; outcome: StreamOutcome },
|
|
2250
|
+
): Promise<void> {
|
|
2251
|
+
const { store, slug } = this.deps
|
|
2252
|
+
if (close !== undefined) {
|
|
2253
|
+
// Close must land no later than `session.ended`; this makes the
|
|
2254
|
+
// ordering structural rather than call-site discipline.
|
|
2255
|
+
await close.stream.sink.close(close.outcome)
|
|
2256
|
+
}
|
|
2257
|
+
await store.appendWithArtifacts(
|
|
2258
|
+
slug,
|
|
2259
|
+
[
|
|
2260
|
+
{
|
|
2261
|
+
kind: 'transcript',
|
|
2262
|
+
content,
|
|
2263
|
+
metadata: {
|
|
2264
|
+
phase: bracket.phase,
|
|
2265
|
+
round: bracket.round,
|
|
2266
|
+
role: bracket.role,
|
|
2267
|
+
runner: bracket.runnerName,
|
|
2268
|
+
...(model !== undefined ? { model } : {}),
|
|
2269
|
+
session,
|
|
2270
|
+
usage,
|
|
2271
|
+
},
|
|
2272
|
+
},
|
|
2273
|
+
],
|
|
2274
|
+
(deposited) => {
|
|
2275
|
+
const meta = deposited[0]
|
|
2276
|
+
if (!meta) throw new Error('transcript deposit returned no metadata')
|
|
2277
|
+
return {
|
|
2278
|
+
actor: KERNEL,
|
|
2279
|
+
type: 'session.ended',
|
|
2280
|
+
payload: {
|
|
2281
|
+
session,
|
|
2282
|
+
transcript: { kind: meta.kind, rev: meta.revision },
|
|
2283
|
+
usage,
|
|
2284
|
+
},
|
|
2285
|
+
} satisfies EventWrite<'session.ended'>
|
|
2286
|
+
},
|
|
2287
|
+
)
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/** A review verdict 'escalate' whose `escalation.raised` never landed →
|
|
2291
|
+
* append it (kernel actor, source 'agent', question from the reason). */
|
|
2292
|
+
private async repairEscalateGap(
|
|
2293
|
+
since: AbEvent[],
|
|
2294
|
+
phase: 'plan-review' | 'code-review',
|
|
2295
|
+
round: number,
|
|
2296
|
+
): Promise<void> {
|
|
2297
|
+
let verdict: { seq: number; reason?: string } | undefined
|
|
2298
|
+
for (const event of since) {
|
|
2299
|
+
if (event.type !== 'plan-review.verdict' && event.type !== 'code-review.verdict') {
|
|
2300
|
+
continue
|
|
2301
|
+
}
|
|
2302
|
+
const expected = phase === 'plan-review' ? 'plan-review.verdict' : 'code-review.verdict'
|
|
2303
|
+
if (event.type !== expected) continue
|
|
2304
|
+
if (event.payload.round !== round || event.payload.verdict !== 'escalate') continue
|
|
2305
|
+
verdict = {
|
|
2306
|
+
seq: event.seq,
|
|
2307
|
+
...(event.payload.reason !== undefined ? { reason: event.payload.reason } : {}),
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
if (verdict === undefined) return
|
|
2311
|
+
const raisedSeq = verdict.seq
|
|
2312
|
+
if (since.some((e) => e.type === 'escalation.raised' && e.seq > raisedSeq)) return
|
|
2313
|
+
await this.deps.store.append(this.deps.slug, {
|
|
2314
|
+
actor: KERNEL,
|
|
2315
|
+
type: 'escalation.raised',
|
|
2316
|
+
payload: {
|
|
2317
|
+
id: this.deps.ids('esc'),
|
|
2318
|
+
phase,
|
|
2319
|
+
round,
|
|
2320
|
+
source: 'agent',
|
|
2321
|
+
question: verdict.reason ?? `${phase} escalated without a recorded reason`,
|
|
2322
|
+
},
|
|
2323
|
+
} satisfies EventWrite<'escalation.raised'>)
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// ── Session streams (SPEC §9) ────────────────────────────────────────────
|
|
2327
|
+
|
|
2328
|
+
/**
|
|
2329
|
+
* Open one stream for this session bracket through the resolved target's
|
|
2330
|
+
* streaming capability: create the store-backed sink, call the capability,
|
|
2331
|
+
* and — once it returns an id — emit the bracket's `data-ab-session` part.
|
|
2332
|
+
* Returns undefined (and reports once) when streaming is disabled, the
|
|
2333
|
+
* runtime declared no capability, or the opener declined/failed; the
|
|
2334
|
+
* bracket then proceeds exactly as without streams.
|
|
2335
|
+
*/
|
|
2336
|
+
private async openSessionStream(
|
|
2337
|
+
target: ResolvedRuntime,
|
|
2338
|
+
info: SessionStreamInfo,
|
|
2339
|
+
): Promise<SessionBracketStream | undefined> {
|
|
2340
|
+
if (!this.streamSessions) return undefined
|
|
2341
|
+
const open = target.openSessionStream
|
|
2342
|
+
if (open === undefined) return undefined
|
|
2343
|
+
const sink = createSessionStreamSink({
|
|
2344
|
+
store: this.deps.store,
|
|
2345
|
+
scope: { kind: 'build', build: this.deps.slug },
|
|
2346
|
+
onDiagnostic: this.onDiagnostic,
|
|
2347
|
+
})
|
|
2348
|
+
try {
|
|
2349
|
+
const streamId = await open(sink, info)
|
|
2350
|
+
if (streamId === undefined) return undefined
|
|
2351
|
+
sink.append([sessionPart(info)])
|
|
2352
|
+
return { sink, streamId, info }
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
this.onDiagnostic(
|
|
2355
|
+
`session stream for "${info.session}" failed to open: ${errorMessage(error)}`,
|
|
2356
|
+
)
|
|
2357
|
+
return undefined
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
/** Best-effort belt-and-braces close; idempotency makes repeated calls
|
|
2362
|
+
* harmless and lets the deposit path own the close-then-append ordering. */
|
|
2363
|
+
private closeBracketStream(
|
|
2364
|
+
stream: SessionBracketStream | undefined,
|
|
2365
|
+
outcome: StreamOutcome,
|
|
2366
|
+
): void {
|
|
2367
|
+
if (stream === undefined) return
|
|
2368
|
+
void stream.sink.close(outcome)
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/** D8 ambient auth: sessionEnv may override AB_STORE (remote stores) and
|
|
2372
|
+
* add AB_TOKEN, but never the per-session identity keys. */
|
|
2373
|
+
private sessionEnvFor(abPhase: string, session: string): Record<string, string> {
|
|
2374
|
+
return {
|
|
2375
|
+
AB_STORE: 'local',
|
|
2376
|
+
...this.deps.sessionEnv,
|
|
2377
|
+
AB_BUILD: this.deps.slug,
|
|
2378
|
+
AB_PHASE: abPhase,
|
|
2379
|
+
AB_SESSION: session,
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/**
|
|
2384
|
+
* phase.failed tally for one phase+round — D5's retry-guard input.
|
|
2385
|
+
*
|
|
2386
|
+
* An answered escalation re-arms this budget only when its semantic class
|
|
2387
|
+
* acknowledges phase-runner failures (§15.6-B). Runner-failure causes reset
|
|
2388
|
+
* their matching round, the phase-level verify cause resets all rounds, and
|
|
2389
|
+
* unrelated policy causes reset nothing. Cause-less historical and
|
|
2390
|
+
* non-policy raises retain the legacy round-shaped behavior. Failures after
|
|
2391
|
+
* an applicable answer count again, so a still-failing phase re-escalates on
|
|
2392
|
+
* new evidence instead of deadlocking before its recovery session starts.
|
|
2393
|
+
*/
|
|
2394
|
+
private phaseFailures(
|
|
2395
|
+
events: AbEvent[],
|
|
2396
|
+
phase: Phase,
|
|
2397
|
+
round: number,
|
|
2398
|
+
): {
|
|
2399
|
+
count: number
|
|
2400
|
+
lastError?: string
|
|
2401
|
+
lastWillRetry?: boolean
|
|
2402
|
+
lastProviderAttempts?: readonly ProviderAttempt[]
|
|
2403
|
+
} {
|
|
2404
|
+
/** id → the raise's reset semantics — answers are matched by id (§15.3). */
|
|
2405
|
+
const raised = new Map<string, PhaseFailureResetEscalation>()
|
|
2406
|
+
let count = 0
|
|
2407
|
+
let lastError: string | undefined
|
|
2408
|
+
let lastWillRetry: boolean | undefined
|
|
2409
|
+
let lastProviderAttempts: readonly ProviderAttempt[] | undefined
|
|
2410
|
+
for (const event of events) {
|
|
2411
|
+
switch (event.type) {
|
|
2412
|
+
case 'escalation.raised':
|
|
2413
|
+
raised.set(event.payload.id, {
|
|
2414
|
+
phase: event.payload.phase,
|
|
2415
|
+
source: event.payload.source,
|
|
2416
|
+
...(event.payload.policyCause !== undefined
|
|
2417
|
+
? { policyCause: event.payload.policyCause }
|
|
2418
|
+
: {}),
|
|
2419
|
+
...(event.payload.round !== undefined ? { round: event.payload.round } : {}),
|
|
2420
|
+
})
|
|
2421
|
+
break
|
|
2422
|
+
case 'escalation.answered': {
|
|
2423
|
+
const raise = raised.get(event.payload.id)
|
|
2424
|
+
if (raise !== undefined && resetsPhaseFailureBudget(raise, phase, round)) {
|
|
2425
|
+
count = 0
|
|
2426
|
+
lastError = undefined
|
|
2427
|
+
lastWillRetry = undefined
|
|
2428
|
+
lastProviderAttempts = undefined
|
|
2429
|
+
}
|
|
2430
|
+
break
|
|
2431
|
+
}
|
|
2432
|
+
case 'phase.failed':
|
|
2433
|
+
if (event.payload.phase === phase && event.payload.round === round) {
|
|
2434
|
+
count += 1
|
|
2435
|
+
lastError = event.payload.error
|
|
2436
|
+
lastWillRetry = event.payload.willRetry
|
|
2437
|
+
lastProviderAttempts = event.payload.providerAttempts
|
|
2438
|
+
}
|
|
2439
|
+
break
|
|
2440
|
+
default:
|
|
2441
|
+
break
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
return {
|
|
2445
|
+
count,
|
|
2446
|
+
...(lastError !== undefined ? { lastError } : {}),
|
|
2447
|
+
...(lastWillRetry !== undefined ? { lastWillRetry } : {}),
|
|
2448
|
+
...(lastProviderAttempts !== undefined ? { lastProviderAttempts } : {}),
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
private async failPhase(
|
|
2453
|
+
phase: Phase,
|
|
2454
|
+
round: number,
|
|
2455
|
+
priorFailures: number,
|
|
2456
|
+
error: string,
|
|
2457
|
+
mayRetry = true,
|
|
2458
|
+
providerAttempts?: readonly ProviderAttempt[],
|
|
2459
|
+
): Promise<void> {
|
|
2460
|
+
const attempt = priorFailures + 1
|
|
2461
|
+
await this.deps.store.append(this.deps.slug, {
|
|
2462
|
+
actor: KERNEL,
|
|
2463
|
+
type: 'phase.failed',
|
|
2464
|
+
payload: {
|
|
2465
|
+
phase,
|
|
2466
|
+
round,
|
|
2467
|
+
attempt,
|
|
2468
|
+
error,
|
|
2469
|
+
willRetry: mayRetry && attempt < this.maxPhaseAttempts,
|
|
2470
|
+
...(providerAttempts !== undefined ? { providerAttempts: [...providerAttempts] } : {}),
|
|
2471
|
+
},
|
|
2472
|
+
} satisfies EventWrite<'phase.failed'>)
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
/** A provider/runner-declared permanent failure parks immediately. The fact
|
|
2476
|
+
* comes from the latest durable `phase.failed {willRetry:false}`, so a crash
|
|
2477
|
+
* between that write and this escalation resumes safely without attempt 2. */
|
|
2478
|
+
private async raisePolicyNonRetryable(
|
|
2479
|
+
phase: Phase,
|
|
2480
|
+
round: number,
|
|
2481
|
+
failures: {
|
|
2482
|
+
count: number
|
|
2483
|
+
lastError?: string
|
|
2484
|
+
lastProviderAttempts?: readonly ProviderAttempt[]
|
|
2485
|
+
},
|
|
2486
|
+
): Promise<void> {
|
|
2487
|
+
await this.deps.store.append(this.deps.slug, {
|
|
2488
|
+
actor: KERNEL,
|
|
2489
|
+
type: 'escalation.raised',
|
|
2490
|
+
payload: {
|
|
2491
|
+
id: this.deps.ids('esc'),
|
|
2492
|
+
phase,
|
|
2493
|
+
round,
|
|
2494
|
+
source: 'policy',
|
|
2495
|
+
policyCause: 'non-retryable-phase-failure',
|
|
2496
|
+
question:
|
|
2497
|
+
`${phase} round ${round} stopped after a non-retryable ` +
|
|
2498
|
+
`provider/runner failure on attempt ${failures.count}; last error: ` +
|
|
2499
|
+
`${failures.lastError ?? 'unknown'}` +
|
|
2500
|
+
providerAttemptSummary(failures.lastProviderAttempts),
|
|
2501
|
+
},
|
|
2502
|
+
} satisfies EventWrite<'escalation.raised'>)
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
/** D5: the retry cap escalates (source 'policy') instead of running,
|
|
2506
|
+
* naming the phase, round, and last error. */
|
|
2507
|
+
private async raisePolicyExhausted(
|
|
2508
|
+
phase: Phase,
|
|
2509
|
+
round: number,
|
|
2510
|
+
failures: {
|
|
2511
|
+
count: number
|
|
2512
|
+
lastError?: string
|
|
2513
|
+
lastProviderAttempts?: readonly ProviderAttempt[]
|
|
2514
|
+
},
|
|
2515
|
+
): Promise<void> {
|
|
2516
|
+
await this.deps.store.append(this.deps.slug, {
|
|
2517
|
+
actor: KERNEL,
|
|
2518
|
+
type: 'escalation.raised',
|
|
2519
|
+
payload: {
|
|
2520
|
+
id: this.deps.ids('esc'),
|
|
2521
|
+
phase,
|
|
2522
|
+
round,
|
|
2523
|
+
source: 'policy',
|
|
2524
|
+
policyCause: 'phase-attempt-limit',
|
|
2525
|
+
question:
|
|
2526
|
+
`${phase} round ${round} failed ${failures.count} times ` +
|
|
2527
|
+
`(maxPhaseAttempts ${this.maxPhaseAttempts}); last error: ` +
|
|
2528
|
+
`${failures.lastError ?? 'unknown'}` +
|
|
2529
|
+
providerAttemptSummary(failures.lastProviderAttempts),
|
|
2530
|
+
},
|
|
2531
|
+
} satisfies EventWrite<'escalation.raised'>)
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
/** The phase's started event, payload per the catalog (§15.3). */
|
|
2535
|
+
private startedWrite(decision: RunPhaseDecision, reconcileBaseSha?: string): EventWrite {
|
|
2536
|
+
const { phase, round } = decision
|
|
2537
|
+
switch (phase) {
|
|
2538
|
+
case 'plan':
|
|
2539
|
+
// Feedback rides the started payload exactly like implement (§15.3
|
|
2540
|
+
// symmetric by design): a parked plan loop re-attaches with a FRESH
|
|
2541
|
+
// session, so `ab context` — not the continue message — is what
|
|
2542
|
+
// delivers an answered guidance escalation to the producer (§15.6-B).
|
|
2543
|
+
return {
|
|
2544
|
+
actor: KERNEL,
|
|
2545
|
+
type: 'plan.started',
|
|
2546
|
+
payload: {
|
|
2547
|
+
round,
|
|
2548
|
+
...(decision.feedback !== undefined ? { feedback: decision.feedback } : {}),
|
|
2549
|
+
},
|
|
2550
|
+
} satisfies EventWrite<'plan.started'>
|
|
2551
|
+
case 'plan-review':
|
|
2552
|
+
return {
|
|
2553
|
+
actor: KERNEL,
|
|
2554
|
+
type: 'plan-review.started',
|
|
2555
|
+
payload: { round },
|
|
2556
|
+
} satisfies EventWrite<'plan-review.started'>
|
|
2557
|
+
case 'implement':
|
|
2558
|
+
return {
|
|
2559
|
+
actor: KERNEL,
|
|
2560
|
+
type: 'implement.started',
|
|
2561
|
+
payload: {
|
|
2562
|
+
round,
|
|
2563
|
+
...(decision.feedback !== undefined ? { feedback: decision.feedback } : {}),
|
|
2564
|
+
},
|
|
2565
|
+
} satisfies EventWrite<'implement.started'>
|
|
2566
|
+
case 'code-review':
|
|
2567
|
+
return {
|
|
2568
|
+
actor: KERNEL,
|
|
2569
|
+
type: 'code-review.started',
|
|
2570
|
+
payload: { round },
|
|
2571
|
+
} satisfies EventWrite<'code-review.started'>
|
|
2572
|
+
case 'finalize':
|
|
2573
|
+
return {
|
|
2574
|
+
actor: KERNEL,
|
|
2575
|
+
type: 'finalize.started',
|
|
2576
|
+
payload: {},
|
|
2577
|
+
} satisfies EventWrite<'finalize.started'>
|
|
2578
|
+
case 'reconcile': {
|
|
2579
|
+
const reconcile = decision.reconcile
|
|
2580
|
+
if (reconcile === undefined || reconcileBaseSha === undefined) {
|
|
2581
|
+
// The engine supplies the attempt; runPhase resolves the current
|
|
2582
|
+
// base before calling this helper. Keep the runner total anyway.
|
|
2583
|
+
throw new Error('run-phase reconcile start requires an attempt and refreshed base SHA')
|
|
2584
|
+
}
|
|
2585
|
+
return {
|
|
2586
|
+
actor: KERNEL,
|
|
2587
|
+
type: 'reconcile.started',
|
|
2588
|
+
payload: { attempt: reconcile.attempt, baseSha: reconcileBaseSha },
|
|
2589
|
+
} satisfies EventWrite<'reconcile.started'>
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
private stopHeartbeat(): void {
|
|
2595
|
+
if (this.heartbeatTimer !== undefined) {
|
|
2596
|
+
clearInterval(this.heartbeatTimer)
|
|
2597
|
+
this.heartbeatTimer = undefined
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
/** End live producer sessions best-effort on exit: every round already
|
|
2602
|
+
* deposited its transcript, so the cumulative turn log is discarded; no
|
|
2603
|
+
* events are appended (the brackets are already closed). */
|
|
2604
|
+
private async closeProducerSessions(): Promise<void> {
|
|
2605
|
+
for (const entry of this.producerSessions.values()) {
|
|
2606
|
+
try {
|
|
2607
|
+
await entry.runner.end(entry.handle)
|
|
2608
|
+
} catch {
|
|
2609
|
+
// Already dead — nothing to release.
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
this.producerSessions.clear()
|
|
2613
|
+
}
|
|
2614
|
+
}
|