@defrex/autobuild 0.11.0 → 0.12.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.
Files changed (96) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +222 -22
  3. package/docs/architecture.md +66 -4
  4. package/docs/configuration.md +105 -16
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +214 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +90 -7
  9. package/docs/remote-store-protocol.md +189 -144
  10. package/docs/remote-ticket-protocol.md +107 -19
  11. package/docs/setup.md +173 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/package.json +7 -6
  14. package/packages/core/src/cli/attention.ts +209 -0
  15. package/packages/core/src/cli/configured-store.ts +17 -0
  16. package/packages/core/src/cli/connect.ts +261 -0
  17. package/packages/core/src/cli/dashboard/model.ts +4 -5
  18. package/packages/core/src/cli/dispatch.ts +183 -41
  19. package/packages/core/src/cli/help.ts +39 -10
  20. package/packages/core/src/cli/init-validation.ts +27 -2
  21. package/packages/core/src/cli/main.ts +41 -15
  22. package/packages/core/src/cli/mcp.ts +7 -1
  23. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  24. package/packages/core/src/cli/pr-summary.ts +2 -0
  25. package/packages/core/src/cli/project-credentials.ts +132 -0
  26. package/packages/core/src/cli/repo-state.ts +15 -2
  27. package/packages/core/src/cli/self-update.ts +10 -0
  28. package/packages/core/src/cli/store-opening.ts +22 -14
  29. package/packages/core/src/cli/terminals.ts +0 -10
  30. package/packages/core/src/cli/ticket.ts +8 -1
  31. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  32. package/packages/core/src/cli/wait.ts +84 -17
  33. package/packages/core/src/cli/watch.ts +197 -60
  34. package/packages/core/src/config/live.ts +3 -0
  35. package/packages/core/src/config/schema.ts +56 -9
  36. package/packages/core/src/connection.ts +86 -0
  37. package/packages/core/src/events/catalog.ts +10 -2
  38. package/packages/core/src/events/payloads.ts +44 -0
  39. package/packages/core/src/events/repository.ts +13 -0
  40. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +130 -83
  41. package/packages/core/src/kernel/engine.ts +24 -0
  42. package/packages/core/src/kernel/generators/build-log.ts +10 -0
  43. package/packages/core/src/kernel/log-index.ts +17 -0
  44. package/packages/core/src/kernel/pr-summary.ts +89 -0
  45. package/packages/core/src/kernel/reducer-registry.ts +8 -0
  46. package/packages/core/src/kernel/reducer.ts +8 -0
  47. package/packages/core/src/ontology.ts +1 -0
  48. package/packages/core/src/operator/index.ts +7 -0
  49. package/packages/core/src/operator/query.ts +38 -16
  50. package/packages/core/src/operator/ticket-source-backend.ts +1 -0
  51. package/packages/core/src/operator/tickets.ts +6 -8
  52. package/packages/core/src/plugin-sdk/index.ts +1 -0
  53. package/packages/core/src/plugins/manifest.ts +5 -3
  54. package/packages/core/src/plugins/runtimes.ts +3 -1
  55. package/packages/core/src/ports/forge/fake.ts +13 -1
  56. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  57. package/packages/core/src/ports/forge/github.ts +29 -5
  58. package/packages/core/src/ports/forge/local-git.ts +12 -4
  59. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  60. package/packages/core/src/ports/runner/session-env.ts +1 -0
  61. package/packages/core/src/ports/tickets/create.ts +11 -3
  62. package/packages/core/src/ports/tickets/hosted-index.ts +0 -3
  63. package/packages/core/src/ports/tickets/remote-protocol.ts +1 -0
  64. package/packages/core/src/ports/tickets/remote.ts +1 -0
  65. package/packages/core/src/ports/workspace/build-execution.ts +5 -1
  66. package/packages/core/src/ports/workspace/create.ts +11 -2
  67. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  68. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  69. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  70. package/packages/core/src/ports/workspace/vercel-sandbox.ts +47 -19
  71. package/packages/core/src/processes/build-execution-state.ts +1 -1
  72. package/packages/core/src/processes/build-runner.ts +87 -26
  73. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  74. package/packages/core/src/processes/dispatcher.ts +59 -10
  75. package/packages/core/src/processes/harvest-child.ts +1 -0
  76. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  77. package/packages/core/src/processes/harvest-runner.ts +142 -100
  78. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  79. package/packages/core/src/processes/publication-loss.ts +18 -1
  80. package/packages/core/src/store/remote/client.ts +1 -1
  81. package/packages/core/src/store/remote/compatibility.ts +90 -0
  82. package/packages/core/src/store/remote/credentials.ts +28 -0
  83. package/packages/core/src/store/remote/index.ts +14 -1
  84. package/packages/core/src/store/remote/protocol.ts +25 -0
  85. package/packages/core/src/store/remote/token.ts +74 -13
  86. package/packages/core/src/testing/index.ts +7 -0
  87. package/skills/guide/SKILL.md +144 -26
  88. package/skills/guide/references/operator-api.md +135 -13
  89. package/skills/guide/references/plugin-authoring.md +10 -2
  90. package/skills/guide/references/remote-store-protocol.md +189 -144
  91. package/skills/guide/references/remote-ticket-protocol.md +111 -18
  92. package/skills/guide/references/setup.md +173 -16
  93. package/skills/guide/references/ticket-dependencies.md +1 -1
  94. package/skills/operate/SKILL.md +126 -36
  95. package/templates/hosted-service/package.json +13 -0
  96. package/templates/hosted-service/vercel.json +7 -0
@@ -20,6 +20,7 @@
20
20
  * normalized against that checkout so the dispatcher and agents cannot
21
21
  * interpret a relative path from different working directories.
22
22
  */
23
+ import { configuredStore } from './configured-store'
23
24
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
24
25
  import type { Exec } from '../ports/workspace/git-worktree'
25
26
  import type { BuildRecord } from '../store/types'
@@ -92,6 +93,8 @@ export async function resolveMainRepo(targetRepo: string, exec: Exec): Promise<s
92
93
  }
93
94
 
94
95
  export interface RepoStatePaths {
96
+ /** Validated repository setting used for this selection, when present. */
97
+ configStore?: string
95
98
  /** Repository identity in BuildStore records/journals: the checkout's
96
99
  * normalized origin URL, falling back to the resolved checkout path only
97
100
  * when the checkout has no origin remote. Opaque to the store schema. */
@@ -116,7 +119,7 @@ function nonBlank(value: string | undefined): string | undefined {
116
119
 
117
120
  /**
118
121
  * Select state with one precedence rule for every sessionless command:
119
- * non-blank explicit `--store` > non-blank `AB_STORE` > repository-local default.
122
+ * non-blank explicit `--store` > non-blank `AB_STORE` > `[store] url` > local default.
120
123
  *
121
124
  * A local selection relocates the whole local tree, including worktrees. A
122
125
  * remote store has no filesystem root, so its worktrees remain local beneath
@@ -130,14 +133,20 @@ export function resolveRepoStatePaths(opts: {
130
133
  checkout?: string
131
134
  storeRef?: string
132
135
  envStore?: string
136
+ configStore?: string
133
137
  }): RepoStatePaths {
134
138
  const checkout = resolve(opts.checkout ?? opts.repo)
135
139
  const defaultLocalRoot = join(checkout, LOCAL_STATE_DIR)
136
- const selected = nonBlank(opts.storeRef) ?? nonBlank(opts.envStore) ?? defaultLocalRoot
140
+ const selected =
141
+ nonBlank(opts.storeRef) ??
142
+ nonBlank(opts.envStore) ??
143
+ nonBlank(opts.configStore) ??
144
+ defaultLocalRoot
137
145
  const remote = isRemoteStoreRef(selected)
138
146
  const storeRef = remote ? selected : resolve(checkout, selected)
139
147
  const localStateRoot = remote ? defaultLocalRoot : storeRef
140
148
  return {
149
+ ...(opts.configStore !== undefined ? { configStore: opts.configStore } : {}),
141
150
  repo: opts.repo,
142
151
  checkout,
143
152
  defaultLocalRoot,
@@ -223,10 +232,14 @@ export async function resolveRepoState(opts: {
223
232
  exec: Exec
224
233
  storeRef?: string
225
234
  envStore?: string
235
+ configStore?: string
226
236
  }): Promise<RepoStatePaths> {
227
237
  const checkout = await resolveMainRepo(opts.targetRepo, opts.exec)
228
238
  const origin = await resolveRepoOrigin(checkout, opts.exec)
229
239
  return resolveRepoStatePaths({
240
+ configStore:
241
+ opts.configStore ??
242
+ (nonBlank(opts.storeRef) || nonBlank(opts.envStore) ? undefined : configuredStore(checkout)),
230
243
  repo: origin ?? checkout,
231
244
  checkout,
232
245
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
@@ -1,3 +1,4 @@
1
+ import { checkUpgradeCompatibility, type CompatibilityFetch } from './upgrade-compatibility'
1
2
  import { readFile, writeFile } from 'node:fs/promises'
2
3
  import semver from 'semver'
3
4
  import type { ExecResult } from '../ports/workspace/git-worktree'
@@ -71,6 +72,7 @@ export function registryVersion(response: { status: number; body: string }): str
71
72
 
72
73
  export interface SelfUpdateOptions {
73
74
  targetRepo: string
75
+ compatibilityFetch?: CompatibilityFetch
74
76
  version?: string
75
77
  distRoot?: string
76
78
  env?: Record<string, string | undefined>
@@ -393,6 +395,14 @@ export async function selfUpdate(options: SelfUpdateOptions): Promise<SelfUpdate
393
395
  return { kind: 'continue' }
394
396
  }
395
397
 
398
+ await checkUpgradeCompatibility({
399
+ targetRepo: options.targetRepo,
400
+ version: resolved,
401
+ env: options.env,
402
+ stderr: options.stderr,
403
+ fetch: options.compatibilityFetch,
404
+ })
405
+
396
406
  const dependency = updateDependency(install, resolved)
397
407
  let updateCommand: string[]
398
408
  if (install.scope === 'global') {
@@ -6,6 +6,8 @@
6
6
  * joins those seams to the production remote adapter and owns finite-command
7
7
  * store lifecycle.
8
8
  */
9
+ import { projectToken } from './project-credentials'
10
+ import { isRemoteStoreRef } from './repo-state'
9
11
  import type { Exec } from '../ports/workspace/git-worktree'
10
12
  import { RemoteBuildStore } from '../store/remote/client'
11
13
  import { scopeLocalStoreToPhaseSession } from '../store/phase-session'
@@ -16,7 +18,7 @@ import {
16
18
  type CliEnv,
17
19
  type HarvestCliEnv,
18
20
  } from './env'
19
- import { isRemoteStoreRef, resolveRepoState, type RepoStatePaths } from './repo-state'
21
+ import { resolveRepoState, type RepoStatePaths } from './repo-state'
20
22
  import { resolveStore } from './store-ref'
21
23
 
22
24
  /** Shared injection seam used by every command that opens a BuildStore. */
@@ -39,15 +41,14 @@ export const openProductionStore: StoreOpener = (ref, token) => {
39
41
  }
40
42
 
41
43
  /** Apply validated ambient authority to a selected Store handle. Remote
42
- * handles retain token-backed server authorization; filesystem handles receive
43
- * the equivalent in-process scope. Keeping this primitive shared prevents
44
+ * handles retain server-side project authorization; every handle receives
45
+ * the same in-process build/session scope. Keeping this primitive shared prevents
44
46
  * phase commands and ambient-aware query commands from drifting. */
45
47
  function scopeSelectedStoreToPhaseSession(
46
48
  store: BuildStore,
47
- ref: string,
49
+ _ref: string,
48
50
  env: AmbientReadSession,
49
51
  ): BuildStore {
50
- if (isRemoteStoreRef(ref)) return store
51
52
  return scopeLocalStoreToPhaseSession(
52
53
  store,
53
54
  'build' in env
@@ -65,15 +66,15 @@ export interface OpenedStoreContext extends RepoStatePaths {
65
66
  store: BuildStore
66
67
  token?: string
67
68
  /** The validated ambient phase/Harvest identity this invocation carries, if
68
- * any — the same value that scoped `store` on the local path. Exposed so a
69
- * command core can extend own-resource authority to remote handles too,
70
- * where no session wrapper exists. */
69
+ * any — the same value that scoped `store`. Exposed for commands whose
70
+ * repository selection also depends on ambient own-build authority. */
71
71
  ambient?: AmbientReadSession
72
72
  }
73
73
 
74
74
  export interface OpenStoreForRepoStateOpts {
75
75
  env: Record<string, string | undefined>
76
76
  openStore?: StoreOpener
77
+ useProjectCredential?: boolean
77
78
  }
78
79
 
79
80
  /** Open from an already canonicalized state selection. Dispatch uses this form
@@ -82,7 +83,13 @@ export function openStoreForRepoState(
82
83
  state: RepoStatePaths,
83
84
  opts: OpenStoreForRepoStateOpts,
84
85
  ): OpenedStoreContext {
85
- const token = optionalToken(opts.env.AB_TOKEN)
86
+ const token =
87
+ opts.useProjectCredential !== false && isRemoteStoreRef(state.storeRef)
88
+ ? projectToken(state.storeRef, state.repo, opts.env)
89
+ : optionalToken(opts.env.AB_TOKEN)
90
+ if (!token && isRemoteStoreRef(state.storeRef) && state.configStore === state.storeRef) {
91
+ throw new Error('No project credential for this service; run ab connect')
92
+ }
86
93
  const store = (opts.openStore ?? openProductionStore)(state.storeRef, token)
87
94
  return {
88
95
  ...state,
@@ -115,10 +122,8 @@ export async function openSessionlessStore(
115
122
  /** Finite command ownership boundary. Opening failures occur before the
116
123
  * try/finally, so only a successfully returned handle is ever closed.
117
124
  *
118
- * Remote store handles do not session-scope (bearer authorization is the
119
- * server's concern), so a command core that needs own-build authority must
120
- * consume `ambient` from the context directly rather than rely on the handle
121
- * enforcing a scope. */
125
+ * Ambient query openers apply in-process build/session scope to both local
126
+ * and remote handles; the server additionally enforces project authority. */
122
127
  export async function withSessionlessStore<T>(
123
128
  opts: SessionlessStoreOpts,
124
129
  use: (context: OpenedStoreContext) => Promise<T> | T,
@@ -145,7 +150,10 @@ export async function openAmbientReadStore(
145
150
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
146
151
  ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
147
152
  })
148
- const context = openStoreForRepoState(state, opts)
153
+ const context = openStoreForRepoState(state, {
154
+ ...opts,
155
+ useProjectCredential: ambient === undefined,
156
+ })
149
157
  if (ambient === undefined) return context
150
158
  return {
151
159
  ...context,
@@ -50,7 +50,6 @@ import type { ArtifactMeta, BuildStore } from '../store/types'
50
50
  import { textContent } from '../store/types'
51
51
  import { expandIssues } from '../zod-issues'
52
52
  import { preparePrAttachments } from './pr-attachments'
53
- import { renderPrSummary } from './pr-summary'
54
53
  import type { CliEnv } from './env'
55
54
 
56
55
  /** Structural subset of CliDeps (src/cli/main.ts) — what terminals need. */
@@ -441,15 +440,6 @@ export async function completeFinalizePr(
441
440
  }
442
441
  }
443
442
 
444
- try {
445
- await deps.forge.commentOnPr(
446
- deps.workspacePath,
447
- pr.number,
448
- renderPrSummary(env, await store.getEvents(env.build)),
449
- )
450
- } catch {
451
- // The durable audit trail remains authoritative.
452
- }
453
443
  return event
454
444
  }
455
445
 
@@ -199,13 +199,20 @@ export async function openTicketSource(
199
199
  const plugins = await loadPlugins(config.plugins, targetRepo)
200
200
  const repoState = resolveRepoStatePaths({
201
201
  repo: targetRepo,
202
+ configStore: config.store?.url,
202
203
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
203
204
  ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
204
205
  })
205
206
  const factory = opts.sourceFactory ?? createTicketSource
206
207
  return {
207
208
  config,
208
- source: await factory(config.tickets, opts.env, targetRepo, repoState.localStateRoot, plugins),
209
+ source: await factory(
210
+ config.tickets,
211
+ config.tickets.source === 'hosted' ? { ...opts.env, AB_STORE: repoState.storeRef } : opts.env,
212
+ targetRepo,
213
+ repoState.localStateRoot,
214
+ plugins,
215
+ ),
209
216
  }
210
217
  }
211
218
 
@@ -0,0 +1,80 @@
1
+ import { spawnExec } from '../ports/workspace/git-worktree'
2
+ import { readDistributionIdentity } from './installation'
3
+ import { isRemoteStoreRef, resolveRepoState } from './repo-state'
4
+ import {
5
+ classifyClient,
6
+ compatibilityAdvice,
7
+ stableVersion,
8
+ supportedClientRangeSchema,
9
+ } from '../store/remote/compatibility'
10
+
11
+ export type CompatibilityFetch = (url: string, init: RequestInit) => Promise<Response>
12
+
13
+ /** Read-only, advisory, and deliberately credential-free. Never expose a store URL. */
14
+ export async function checkUpgradeCompatibility(options: {
15
+ targetRepo: string
16
+ version?: string
17
+ distRoot?: string
18
+ env?: Record<string, string | undefined>
19
+ stderr: (line: string) => void
20
+ fetch?: CompatibilityFetch
21
+ timeoutMs?: number
22
+ }): Promise<void> {
23
+ const paths = await resolveRepoState({
24
+ targetRepo: options.targetRepo,
25
+ exec: spawnExec,
26
+ envStore: (options.env ?? process.env).AB_STORE,
27
+ })
28
+ if (!isRemoteStoreRef(paths.storeRef)) return
29
+ const unknown = () =>
30
+ options.stderr(
31
+ 'warning: hosted service compatibility could not be verified; the upgraded project may fail to connect',
32
+ )
33
+ try {
34
+ const targetVersion =
35
+ options.version ?? (await readDistributionIdentity(options.distRoot)).version
36
+ const url = new URL(paths.storeRef)
37
+ url.username = ''
38
+ url.password = ''
39
+ url.search = ''
40
+ url.hash = ''
41
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/health`
42
+ const controller = new AbortController()
43
+ let timeout: ReturnType<typeof setTimeout> | undefined
44
+ const expired = new Promise<never>((_, reject) => {
45
+ timeout = setTimeout(() => {
46
+ controller.abort()
47
+ reject(new Error('timeout'))
48
+ }, options.timeoutMs ?? 3000)
49
+ })
50
+ const response = await Promise.race([
51
+ (async () => {
52
+ const response = await (options.fetch ?? fetch)(url.toString(), {
53
+ signal: controller.signal,
54
+ redirect: 'error',
55
+ })
56
+ if (response.status !== 200) throw new Error('health failed')
57
+ return (await response.json()) as Record<string, unknown>
58
+ })(),
59
+ expired,
60
+ ]).finally(() => clearTimeout(timeout))
61
+ if (!response || !stableVersion(response.autobuildVersion)) return unknown()
62
+ const serviceVersion = response.autobuildVersion as string
63
+ if (!Object.hasOwn(response, 'supportedClientRange')) {
64
+ if (targetVersion !== serviceVersion)
65
+ options.stderr(
66
+ `warning: service Autobuild ${serviceVersion} requires an exact client version; target ${targetVersion} differs. Upgrade the service to the compatibility-window release first to publish a supported client range; the upgraded project may fail to connect`,
67
+ )
68
+ return
69
+ }
70
+ const range = supportedClientRangeSchema.safeParse(response.supportedClientRange)
71
+ if (!range.success || classifyClient(targetVersion, range.data) === 'invalid') return unknown()
72
+ const advice = compatibilityAdvice(targetVersion, serviceVersion, range.data)
73
+ if (advice)
74
+ options.stderr(
75
+ `warning: target Autobuild ${targetVersion}: ${advice}; the upgraded project may fail to connect`,
76
+ )
77
+ } catch {
78
+ unknown()
79
+ }
80
+ }
@@ -31,6 +31,7 @@
31
31
  * Read-only: it appends no event, claims or renews no lease, and creates no
32
32
  * build or repository record. Output carries no ANSI, ever, in either form.
33
33
  */
34
+ import { buildNeedsAttention, mergeDecision, mergeIdentity } from './attention'
34
35
  import type { AbEvent } from '../events/catalog'
35
36
  import type { EventType } from '../events/payloads'
36
37
  import type { BuildState, PrLifecycle } from '../kernel/reducer'
@@ -54,6 +55,8 @@ import {
54
55
  encodeCursor,
55
56
  parseDurationMs,
56
57
  projectRecord,
58
+ projectAttentionRecord,
59
+ renderAttentionLine,
57
60
  renderWatchLine,
58
61
  watchSelection,
59
62
  } from './watch'
@@ -153,8 +156,8 @@ export function parseConditions(
153
156
  return { conditions, globs }
154
157
  }
155
158
 
156
- /** Does one condition body hold over a reduction? Attention is event-only. */
157
- function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
159
+ /** Does one condition body hold over a reduction? Attention includes current escalation and merge state. */
160
+ function stateSatisfies(cond: ConditionBody, state: BuildState, events: AbEvent[] = []): boolean {
158
161
  switch (cond.kind) {
159
162
  case 'status':
160
163
  return state.status === cond.status
@@ -165,7 +168,7 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
165
168
  case 'phase':
166
169
  return (state.phase ?? null) === cond.phase
167
170
  case 'attention':
168
- return false
171
+ return buildNeedsAttention(events, state)
169
172
  }
170
173
  }
171
174
 
@@ -174,9 +177,13 @@ function stateSatisfies(cond: ConditionBody, state: BuildState): boolean {
174
177
  * the start-of-command and start-of-registration checks — never during a
175
178
  * backlog replay, where only event conditions may fire.
176
179
  */
177
- export function matchState(conditions: readonly WaitCondition[], state: BuildState): string | null {
180
+ export function matchState(
181
+ conditions: readonly WaitCondition[],
182
+ state: BuildState,
183
+ events: AbEvent[] = [],
184
+ ): string | null {
178
185
  for (const { text, cond } of conditions) {
179
- if (stateSatisfies(cond, state)) return text
186
+ if (stateSatisfies(cond, state, events)) return text
180
187
  }
181
188
  return null
182
189
  }
@@ -188,9 +195,15 @@ export function matchEvent(
188
195
  event: Pick<AbEvent, 'type'>,
189
196
  conditions: readonly WaitCondition[],
190
197
  globs: readonly EventGlob[],
198
+ events: AbEvent[] = [],
191
199
  ): string | null {
192
200
  for (const { text, cond } of conditions) {
193
- if (cond.kind === 'attention' && isAttentionEvent(event.type)) return text
201
+ if (
202
+ cond.kind === 'attention' &&
203
+ (isAttentionEvent(event.type) ||
204
+ (events.length > 0 && mergeDecision(events, new Date(0)) !== null))
205
+ )
206
+ return text
194
207
  }
195
208
  for (const { regex, text } of globs) {
196
209
  if (regex.test(event.type)) return text
@@ -209,10 +222,16 @@ function matchFirst(
209
222
  state: BuildState,
210
223
  conditions: readonly WaitCondition[],
211
224
  globs: readonly EventGlob[],
225
+ events: AbEvent[] = [],
212
226
  ): string | null {
213
227
  for (const { text, cond } of conditions) {
214
- if (stateSatisfies(cond, state)) return text
215
- if (cond.kind === 'attention' && isAttentionEvent(event.type)) return text
228
+ if (stateSatisfies(cond, state, events)) return text
229
+ if (
230
+ cond.kind === 'attention' &&
231
+ (isAttentionEvent(event.type) ||
232
+ (events.length > 0 && mergeDecision(events, new Date(0)) !== null))
233
+ )
234
+ return text
216
235
  }
217
236
  for (const { regex, text } of globs) {
218
237
  if (regex.test(event.type)) return text
@@ -305,11 +324,12 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
305
324
  const interval = intervalMs ?? watchSelection(storeRef).intervalMs
306
325
 
307
326
  // Decode --since before any read: a foreign cursor is rejected outright.
308
- const sinceStreams =
327
+ const resumedCursor =
309
328
  opts.since !== undefined
310
329
  ? decodeCursor(opts.since, { store: storeRef, repo }, { verb: 'wait', usage: WAIT_USAGE })
311
- .streams
312
- : {}
330
+ : undefined
331
+ const sinceStreams = resumedCursor?.streams ?? {}
332
+ const deliveredAttention = new Set(resumedCursor?.deliveredAttention ?? [])
313
333
 
314
334
  const identity = normalizeGitRemoteUrl(repo)
315
335
  const mine = (record: BuildRecord): boolean =>
@@ -322,17 +342,40 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
322
342
  const positions: Record<string, number> = { ...sinceStreams }
323
343
 
324
344
  const encodeCurrent = (): string =>
325
- encodeCursor({ v: 1, store: storeRef, repo, streams: { ...positions } })
345
+ encodeCursor({
346
+ v: 1,
347
+ store: storeRef,
348
+ repo,
349
+ streams: { ...positions },
350
+ ...(resumedCursor?.lostRuns ? { lostRuns: resumedCursor.lostRuns } : {}),
351
+ deliveredAttention: [...deliveredAttention],
352
+ })
326
353
 
327
354
  /** The one success/terminal record. Exit 0 is contractually tied to
328
355
  * exactly one condition-satisfying record; the exit-2 record carries
329
356
  * `condition` null so the caller can see how the build ended. */
330
357
  const finish = (condition: string | null, event: AbEvent, state: BuildState): number => {
331
- const record = { ...projectRecord(event, state, encodeCurrent()), condition }
358
+ const observation =
359
+ condition === 'attention' && event.type !== 'escalation.raised'
360
+ ? mergeDecision(
361
+ streams.get(event.build)?.events.filter((e) => e.seq <= event.seq) ?? [],
362
+ now(),
363
+ state,
364
+ )
365
+ : null
366
+ if (observation) deliveredAttention.add(mergeIdentity(event.build, observation.pr.number))
367
+ const record = {
368
+ ...(observation
369
+ ? projectAttentionRecord(observation, state, encodeCurrent(), { build: event.build })
370
+ : projectRecord(event, state, encodeCurrent())),
371
+ condition,
372
+ }
332
373
  if (json) {
333
374
  opts.stdout(JSON.stringify(record))
334
375
  } else {
335
- const line = renderWatchLine(event, state)
376
+ const line = observation
377
+ ? renderAttentionLine(observation, event.build)
378
+ : renderWatchLine(event, state)
336
379
  opts.stdout(condition === null ? line : `${line} (${condition})`)
337
380
  }
338
381
  return condition === null ? 2 : 0
@@ -351,6 +394,15 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
351
394
  if (satisfied === null) satisfied = { event, state, condition }
352
395
  }
353
396
 
397
+ const eventConditions = (event: AbEvent, prefix: AbEvent[]): readonly WaitCondition[] => {
398
+ if (event.type === 'escalation.raised') return conditions
399
+ const observation = mergeDecision(prefix, now())
400
+ return observation &&
401
+ !deliveredAttention.has(mergeIdentity(event.build, observation.pr.number))
402
+ ? conditions
403
+ : conditions.filter(({ cond }) => cond.kind !== 'attention')
404
+ }
405
+
354
406
  const registerStream = (slug: string, events: AbEvent[], named: boolean): WaitStream => {
355
407
  const state = reduceBuild(events)
356
408
  const stream: WaitStream = {
@@ -370,7 +422,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
370
422
  * latest event. Not cursor-relative — `--since` does not suppress it. */
371
423
  const checkRegistrationState = (stream: WaitStream): void => {
372
424
  const state = reduceBuild(stream.events)
373
- const condition = matchState(conditions, state)
425
+ const condition = matchState(conditions, state, stream.events)
374
426
  if (condition !== null && stream.events.length > 0) {
375
427
  match(stream.events.at(-1)!, state, condition)
376
428
  }
@@ -401,12 +453,20 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
401
453
  // the reduction of the prefix ENDING at that event, never the
402
454
  // reduction of the full loaded prefix.
403
455
  const replay: AbEvent[] = events.filter((event) => event.seq <= resumeSeq)
456
+ if (resumedCursor?.deliveredAttention === undefined) {
457
+ const historical: AbEvent[] = []
458
+ for (const event of replay) {
459
+ historical.push(event)
460
+ const observation = mergeDecision(historical, now())
461
+ if (observation) deliveredAttention.add(mergeIdentity(slug, observation.pr.number))
462
+ }
463
+ }
404
464
  for (const event of events) {
405
465
  if (event.seq <= resumeSeq) continue
406
466
  replay.push(event)
407
467
  stream.lastSeq = event.seq
408
468
  positions[slug] = event.seq
409
- const condition = matchEvent(event, conditions, globs)
469
+ const condition = matchEvent(event, eventConditions(event, replay), globs, replay)
410
470
  if (condition !== null) {
411
471
  match(event, reduceBuild(replay), condition)
412
472
  break
@@ -470,7 +530,13 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
470
530
  positions[stream.slug] = event.seq
471
531
  const state = reduceBuild(stream.events)
472
532
  stream.status = state.status
473
- const condition = matchFirst(event, state, conditions, globs)
533
+ const condition = matchFirst(
534
+ event,
535
+ state,
536
+ eventConditions(event, stream.events),
537
+ globs,
538
+ stream.events,
539
+ )
474
540
  if (condition !== null) {
475
541
  match(event, state, condition)
476
542
  return
@@ -564,6 +630,7 @@ export async function abWait(opts: AbWaitOpts): Promise<number> {
564
630
  // Cursor streams that are no longer named resume anyway: finishing
565
631
  // their backlog is what makes a resumed wait lossless.
566
632
  for (const key of Object.keys(sinceStreams)) {
633
+ if (key === '#repo') continue
567
634
  await trackBuild(key, sinceStreams[key])
568
635
  if (hasMatch()) return finishSatisfied()
569
636
  }