@naxodev/apnea 0.2.0 → 0.2.2

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 (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -1,6 +1,10 @@
1
1
  import * as path from "node:path"
2
2
  import { Cause, Clock, Effect, Exit, Result } from "effect"
3
+ import { inferKind } from "../domain/artifact-kind.ts"
4
+ import { parseFrontMatter } from "../domain/frontmatter.ts"
5
+ import { looksLikeShellOnly } from "../domain/herdr.ts"
3
6
  import {
7
+ abs,
4
8
  packageRoot,
5
9
  phaseDir,
6
10
  planPath,
@@ -17,27 +21,35 @@ import {
17
21
  toolAllowed,
18
22
  type DispatchKind,
19
23
  } from "../domain/state-machine.ts"
20
- import { timeoutMsForKind } from "../domain/timeouts.ts"
24
+ import { deadlineAfter, timeoutMsForKind } from "../domain/timeouts.ts"
21
25
  import {
26
+ ArtifactInvalid,
22
27
  GateRefused,
23
28
  HerdrError,
24
29
  IllegalKind,
25
30
  type AppError,
26
31
  } from "../errors.ts"
27
32
  import type { Role } from "../domain/types.ts"
28
- import { ROLE_MODE } from "../domain/types.ts"
33
+ import {
34
+ LEGACY_CODE_REWORK,
35
+ LEGACY_PLAN_REWORK,
36
+ ROLE_MODE,
37
+ } from "../domain/types.ts"
29
38
  import { ok, type ToolResult } from "../result.ts"
39
+ import { validateArtifactCompletion } from "../schema/frontmatter.ts"
30
40
  import { Config } from "../services/config.ts"
31
41
  import { FileSystem } from "../services/file-system.ts"
32
- import { Herdr } from "../services/herdr.ts"
42
+ import { Herdr, type RolePaneRef } from "../services/herdr.ts"
33
43
  import { RunStore } from "../services/run-store.ts"
34
44
  import { Vcs } from "../services/vcs.ts"
35
45
 
36
46
  export type DispatchParams = {
37
47
  kind: DispatchKind
38
48
  task_markdown?: string
39
- /** Increment round after CHANGES_REQUIRED (protocol: only then). */
49
+ /** @deprecated Assertion only. Persisted state owns rework through 0.2.x. */
40
50
  rework?: boolean
51
+ /** Reuse persisted pending ownership after a demonstrably dead delivery. */
52
+ redeliver?: boolean
41
53
  }
42
54
 
43
55
  function taskBody(opts: {
@@ -134,14 +146,14 @@ export const dispatchWorkflow = (
134
146
  const herdr = yield* Herdr
135
147
 
136
148
  const state = yield* store.require(root)
137
- const stateBeforeDispatch = structuredClone(state)
149
+ const redeliver = params.redeliver === true
138
150
 
139
151
  const allowed = toolAllowed(state.step, "dispatch_role")
140
152
  if (Result.isFailure(allowed)) {
141
153
  return yield* allowed.failure
142
154
  }
143
155
 
144
- if (state.pending_artifact != null) {
156
+ if (state.pending_artifact != null && !redeliver) {
145
157
  return yield* new GateRefused({
146
158
  gate: "dispatch_pending",
147
159
  message:
@@ -153,6 +165,110 @@ export const dispatchWorkflow = (
153
165
  },
154
166
  })
155
167
  }
168
+ if (state.pending_artifact == null && redeliver) {
169
+ return yield* new GateRefused({
170
+ gate: "redelivery",
171
+ message: "redeliver=true requires an existing pending dispatch",
172
+ })
173
+ }
174
+
175
+ const role = expectedRole(params.kind)
176
+ const pendingArtifact = state.pending_artifact
177
+ if (redeliver && pendingArtifact != null) {
178
+ const inferred = inferKind(pendingArtifact)
179
+ if (Result.isFailure(inferred) || inferred.success !== params.kind) {
180
+ return yield* new GateRefused({
181
+ gate: "redelivery",
182
+ message: `pending artifact does not belong to ${params.kind}`,
183
+ details: {
184
+ pending_artifact: pendingArtifact,
185
+ inferred_kind: Result.isSuccess(inferred)
186
+ ? inferred.success
187
+ : "unknown",
188
+ },
189
+ })
190
+ }
191
+ if (state.pending_role !== role) {
192
+ return yield* new GateRefused({
193
+ gate: "redelivery",
194
+ message: `pending role does not match ${params.kind}`,
195
+ details: { pending_role: state.pending_role, expected_role: role },
196
+ })
197
+ }
198
+ const pendingAbs = abs(pendingArtifact, root)
199
+ if (yield* fs.projectPathExists(root, pendingAbs)) {
200
+ const pendingText = yield* fs.readProjectFile(root, pendingAbs).pipe(
201
+ Effect.mapError(
202
+ (error) =>
203
+ new ArtifactInvalid({
204
+ artifact: pendingArtifact,
205
+ message: error.message,
206
+ }),
207
+ ),
208
+ )
209
+ const frontmatter = parseFrontMatter(pendingText)
210
+ const completion = validateArtifactCompletion(
211
+ inferred.success,
212
+ frontmatter,
213
+ pendingArtifact,
214
+ )
215
+ if (Result.isSuccess(completion) && completion.success !== null) {
216
+ return yield* new GateRefused({
217
+ gate: "redelivery",
218
+ message:
219
+ "pending artifact is already complete; call workflow_wait instead of redelivering",
220
+ details: {
221
+ artifact: pendingArtifact,
222
+ kind: inferred.success,
223
+ verdict: completion.success.frontmatter.verdict ?? null,
224
+ next: "workflow_wait",
225
+ guidance: "call workflow_wait to ingest the completed artifact",
226
+ },
227
+ })
228
+ }
229
+ }
230
+ if (state.pending_delivery === null) {
231
+ return yield* new GateRefused({
232
+ gate: "redelivery",
233
+ message:
234
+ "pending delivery mode is unknown; null-pane legacy ownership is ambiguous",
235
+ details: { pending_delivery: null, liveness: "ambiguous" },
236
+ })
237
+ }
238
+ if (
239
+ state.pending_delivery === "interactive" &&
240
+ state.pending_pane_id === null
241
+ ) {
242
+ return yield* new GateRefused({
243
+ gate: "redelivery",
244
+ message:
245
+ "interactive delivery has no persisted pane id; prior acceptance is ambiguous",
246
+ details: { pending_delivery: "interactive", liveness: "ambiguous" },
247
+ })
248
+ }
249
+ if (
250
+ state.pending_delivery === "manual" &&
251
+ state.pending_pane_id !== null
252
+ ) {
253
+ return yield* new GateRefused({
254
+ gate: "redelivery",
255
+ message: "manual delivery cannot own an interactive pane",
256
+ details: {
257
+ pending_delivery: "manual",
258
+ pane_id: state.pending_pane_id,
259
+ liveness: "ambiguous",
260
+ },
261
+ })
262
+ }
263
+ if (state.required_rework !== null || params.rework === true) {
264
+ return yield* new GateRefused({
265
+ gate: "redelivery",
266
+ message:
267
+ "redelivery reuses consumed ownership and cannot start or assert rework",
268
+ details: { required_rework: state.required_rework },
269
+ })
270
+ }
271
+ }
156
272
 
157
273
  const kinds = allowedKinds(state.step)
158
274
  if (!kinds.includes(params.kind)) {
@@ -163,40 +279,109 @@ export const dispatchWorkflow = (
163
279
  })
164
280
  }
165
281
 
166
- // Rework flag validation
167
- if (params.rework) {
168
- const okRework =
169
- (params.kind === "plan" && state.step === "planning") ||
170
- (params.kind === "code" && state.step === "coding") ||
171
- (params.kind === "phase_package" &&
172
- state.step === "phase_packaging" &&
173
- state.phase_package_rework) ||
174
- (params.kind === "plan_review" && state.step === "plan_review") ||
175
- (params.kind === "code_review" && state.step === "code_review")
176
- // After CHANGES_REQUIRED, step moves back to planning/coding; rework
177
- // dispatch is plan/code with rework=true.
178
- if (!okRework && !(params.kind === "plan" || params.kind === "code")) {
179
- return yield* new GateRefused({
180
- gate: "rework",
181
- message:
182
- "rework=true only valid for plan/code after CHANGES_REQUIRED, phase_package after package rework, or same-gate re-review",
183
- })
184
- }
282
+ const legacyPlanAssertion =
283
+ state[LEGACY_PLAN_REWORK] === true &&
284
+ state.step === "planning" &&
285
+ params.kind === "plan" &&
286
+ params.rework === true
287
+ const legacyCodeAssertion =
288
+ state[LEGACY_CODE_REWORK] === true &&
289
+ state.step === "coding" &&
290
+ params.kind === "code" &&
291
+ params.rework === true
292
+ if (legacyPlanAssertion) state.required_rework = "plan"
293
+ if (legacyCodeAssertion) state.required_rework = "code"
294
+
295
+ const requiredRework = state.required_rework
296
+ if (params.rework === true && requiredRework === null) {
297
+ return yield* new GateRefused({
298
+ gate: "rework",
299
+ message:
300
+ "rework=true is only a deprecated assertion; state has no required rework",
301
+ })
302
+ }
303
+ if (requiredRework !== null && params.kind !== requiredRework) {
304
+ return yield* new GateRefused({
305
+ gate: "rework",
306
+ message: `state requires ${requiredRework} rework before dispatching ${params.kind}`,
307
+ details: { required_rework: requiredRework },
308
+ })
185
309
  }
310
+ const isRework = !redeliver && requiredRework === params.kind
311
+ const stateBeforeDispatch = structuredClone(state)
186
312
 
187
- const role = expectedRole(params.kind)
188
313
  const cfg = yield* config.load(root)
189
314
  const roleTimeoutMs = timeoutMsForKind(params.kind, cfg.timeouts_ms)
190
315
  // Validate the orchestrator's current pane before creating task artifacts.
191
316
  // A stale inherited environment is equivalent to running outside Herdr;
192
317
  // other CLI failures remain typed errors and stop dispatch.
193
- const herdrAvailability = yield* herdr.availability
318
+ const availability = yield* Effect.result(herdr.availability)
319
+ if (Result.isFailure(availability)) {
320
+ if (redeliver && state.pending_pane_id != null) {
321
+ return yield* new GateRefused({
322
+ gate: "redelivery",
323
+ message: "cannot verify whether the prior pane is still live",
324
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
325
+ })
326
+ }
327
+ return yield* availability.failure
328
+ }
329
+ const herdrAvailability = availability.success
330
+
331
+ if (redeliver && state.pending_pane_id != null) {
332
+ if (herdrAvailability !== "available") {
333
+ return yield* new GateRefused({
334
+ gate: "redelivery",
335
+ message:
336
+ "cannot prove the prior pane is dead while Herdr is unavailable",
337
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
338
+ })
339
+ }
340
+ const pane = yield* Effect.exit(herdr.paneGet(state.pending_pane_id))
341
+ if (Exit.isFailure(pane)) {
342
+ return yield* new GateRefused({
343
+ gate: "redelivery",
344
+ message: "cannot verify whether the prior pane is still live",
345
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
346
+ })
347
+ }
348
+ if (!pane.value.ok && pane.value.missing !== true) {
349
+ return yield* new GateRefused({
350
+ gate: "redelivery",
351
+ message: "prior pane lookup failed without proof that it is missing",
352
+ details: { pane_id: state.pending_pane_id, liveness: "ambiguous" },
353
+ })
354
+ }
355
+ if (pane.value.ok && pane.value.agent_status !== "done") {
356
+ const foreground = yield* Effect.exit(
357
+ herdr.paneForegroundNames(state.pending_pane_id),
358
+ )
359
+ const shellOnly =
360
+ Exit.isSuccess(foreground) && looksLikeShellOnly(foreground.value)
361
+ if (!shellOnly) {
362
+ const status = pane.value.agent_status ?? "unknown"
363
+ return yield* new GateRefused({
364
+ gate: "redelivery",
365
+ message: `prior pane is ${status}; refusing duplicate delivery`,
366
+ details: {
367
+ pane_id: state.pending_pane_id,
368
+ liveness:
369
+ status === "working" ||
370
+ status === "blocked" ||
371
+ status === "idle"
372
+ ? "live"
373
+ : "ambiguous",
374
+ },
375
+ })
376
+ }
377
+ }
378
+ }
194
379
 
195
380
  // --- Round numbers (increment ONLY on rework after CHANGES_REQUIRED) ---
196
381
  let round = 1
197
382
  if (params.kind === "plan" || params.kind === "plan_review") {
198
383
  const key = roundKey(0, "plan_review")
199
- if (params.rework && params.kind === "plan") {
384
+ if (isRework && params.kind === "plan") {
200
385
  // starting a new plan revision after CHANGES_REQUIRED → next review round
201
386
  setRound(state, key, getRound(state, key) + 1)
202
387
  } else if (!state.rounds[key]) {
@@ -209,12 +394,9 @@ export const dispatchWorkflow = (
209
394
  params.kind === "phase_package"
210
395
  ) {
211
396
  const key = codeReviewRoundKey(state.phase_index)
212
- if (params.rework && params.kind === "code") {
397
+ if (isRework && params.kind === "code") {
213
398
  setRound(state, key, getRound(state, key) + 1)
214
- } else if (
215
- params.kind === "phase_package" &&
216
- state.phase_package_rework
217
- ) {
399
+ } else if (isRework && params.kind === "phase_package") {
218
400
  setRound(state, key, getRound(state, key) + 1)
219
401
  } else if (!state.rounds[key]) {
220
402
  setRound(state, key, 1)
@@ -228,6 +410,7 @@ export const dispatchWorkflow = (
228
410
  ? roundKey(0, "plan_review")
229
411
  : codeReviewRoundKey(state.phase_index)
230
412
  if (
413
+ !redeliver &&
231
414
  (params.kind === "plan" ||
232
415
  params.kind === "code" ||
233
416
  params.kind === "phase_package" ||
@@ -264,7 +447,7 @@ export const dispatchWorkflow = (
264
447
  artifactAbs = path.join(d, "phase-package.md")
265
448
  extra =
266
449
  extra ||
267
- (state.phase_package_rework
450
+ (isRework || (redeliver && state.current_code_review !== null)
268
451
  ? `Revise the phase ${state.phase_index} package after code review \`${state.current_code_review}\`. Preserve approved-plan scope and address package findings.`
269
452
  : `Emit phase package for phase ${state.phase_index} only from approved plan \`${rel(planPath(root), root)}\`.`)
270
453
  break
@@ -304,6 +487,20 @@ export const dispatchWorkflow = (
304
487
  break
305
488
  }
306
489
 
490
+ const artifactRel = rel(artifactAbs, root)
491
+ if (redeliver && artifactRel !== pendingArtifact) {
492
+ return yield* new GateRefused({
493
+ gate: "redelivery",
494
+ message: "pending artifact is not the current phase and round target",
495
+ details: {
496
+ pending_artifact: pendingArtifact,
497
+ expected_artifact: artifactRel,
498
+ phase_index: state.phase_index,
499
+ round,
500
+ },
501
+ })
502
+ }
503
+
307
504
  // Resolve the brief BEFORE clear-before-dispatch. This block can refuse,
308
505
  // and the rename below is a mutation: refusing after it left the prior
309
506
  // artifact stranded at a timestamped .bak path, so a failed dispatch
@@ -359,15 +556,14 @@ export const dispatchWorkflow = (
359
556
  }
360
557
 
361
558
  // clear-before-dispatch
362
- yield* fs.mkdir(path.dirname(artifactAbs), { recursive: true })
559
+ yield* fs.mkdirProject(root, path.dirname(artifactAbs))
363
560
  let backupAbs: string | null = null
364
- if (yield* fs.exists(artifactAbs)) {
561
+ if (yield* fs.projectPathExists(root, artifactAbs)) {
365
562
  const backupMillis = yield* Clock.currentTimeMillis
366
563
  backupAbs = `${artifactAbs}.bak.${backupMillis}`
367
- yield* fs.rename(artifactAbs, backupAbs)
564
+ yield* fs.renameProjectFile(root, artifactAbs, backupAbs)
368
565
  }
369
566
 
370
- const artifactRel = rel(artifactAbs, root)
371
567
  const body = taskBody({
372
568
  kind: params.kind,
373
569
  role,
@@ -382,14 +578,14 @@ export const dispatchWorkflow = (
382
578
  tasksDir(root),
383
579
  `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
384
580
  )
385
- while (yield* fs.exists(taskFile)) {
581
+ while (yield* fs.projectPathExists(root, taskFile)) {
386
582
  taskFileMillis += 1
387
583
  taskFile = path.join(
388
584
  tasksDir(root),
389
585
  `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
390
586
  )
391
587
  }
392
- yield* fs.writeFile(taskFile, body)
588
+ yield* fs.writeProjectFile(root, taskFile, body)
393
589
  const taskRef = {
394
590
  task: rel(taskFile, root),
395
591
  artifact: artifactRel,
@@ -410,19 +606,25 @@ export const dispatchWorkflow = (
410
606
  const rollbackLaunch = (restoreState = true) =>
411
607
  Effect.gen(function* () {
412
608
  const errors: string[] = []
413
- const attempt = (label: string, operation: Effect.Effect<void>) =>
609
+ const attempt = (
610
+ label: string,
611
+ operation: Effect.Effect<void, unknown>,
612
+ ) =>
414
613
  Effect.gen(function* () {
415
614
  const exit = yield* Effect.exit(operation)
416
615
  if (Exit.isFailure(exit)) {
417
616
  errors.push(`${label}: ${Cause.pretty(exit.cause)}`)
418
617
  }
419
618
  })
420
- yield* attempt("remove task", fs.remove(taskFile))
421
- yield* attempt("remove replacement artifact", fs.remove(artifactAbs))
619
+ yield* attempt("remove task", fs.removeProjectFile(root, taskFile))
620
+ yield* attempt(
621
+ "remove replacement artifact",
622
+ fs.removeProjectFile(root, artifactAbs),
623
+ )
422
624
  if (backupAbs != null) {
423
625
  yield* attempt(
424
626
  "restore prior artifact",
425
- fs.rename(backupAbs, artifactAbs),
627
+ fs.renameProjectFile(root, backupAbs, artifactAbs),
426
628
  )
427
629
  }
428
630
  if (restoreState) {
@@ -434,19 +636,26 @@ export const dispatchWorkflow = (
434
636
  return errors
435
637
  })
436
638
 
437
- const markPending = (launchedAt: number): void => {
639
+ const markPending = (
640
+ launchedAt: number,
641
+ delivery: "manual" | "interactive",
642
+ ): void => {
438
643
  state.pending_artifact = artifactRel
439
644
  state.pending_role = role
645
+ state.pending_delivery = delivery
440
646
  state.pending_started_at = launchedAt
441
- state.pending_deadline_ms = launchedAt + roleTimeoutMs
442
- if (params.kind === "phase_package") state.phase_package_rework = false
647
+ state.pending_deadline_ms = deadlineAfter(launchedAt, roleTimeoutMs)
648
+ if (isRework) state.required_rework = null
443
649
  resetRecoveryLadder(state)
444
650
  }
445
651
 
446
652
  // Persist ownership before crossing an external launch boundary. If this
447
653
  // process dies after Herdr accepts work, a retry must not start a duplicate.
448
654
  const preparedAt = yield* Clock.currentTimeMillis
449
- markPending(preparedAt)
655
+ markPending(
656
+ preparedAt,
657
+ herdrAvailability === "unavailable" ? "manual" : "interactive",
658
+ )
450
659
  state.pending_pane_id = null
451
660
  state.pending_pane_label = null
452
661
  const preparedState = yield* Effect.exit(store.save(state, root))
@@ -492,19 +701,53 @@ export const dispatchWorkflow = (
492
701
  const remembered = state.role_panes[role] ?? null
493
702
  const prefer =
494
703
  remembered?.profile_fingerprint === profileFingerprint ? remembered : null
704
+ const recordPaneOwnership = (pane: RolePaneRef) => {
705
+ state.pending_pane_id = pane.pane_id
706
+ state.pending_pane_label = pane.label
707
+ state.role_panes[role] = {
708
+ pane_id: pane.pane_id,
709
+ label: pane.label,
710
+ profile_fingerprint: profileFingerprint,
711
+ }
712
+ }
713
+ let acquisitionRollbackErrors: string[] | undefined
495
714
  const launched = yield* Effect.result(
496
- herdr.runInteractivePrompt(role, cmd, prompt, prefer),
715
+ herdr.runInteractivePrompt(
716
+ role,
717
+ cmd,
718
+ prompt,
719
+ prefer,
720
+ (pane) =>
721
+ Effect.gen(function* () {
722
+ recordPaneOwnership(pane)
723
+ const persisted = yield* Effect.exit(store.save(state, root))
724
+ if (Exit.isFailure(persisted)) {
725
+ // Restore proven non-delivery while the adapter still masks
726
+ // cancellation. A pending interruption can hide the typed failure.
727
+ acquisitionRollbackErrors = yield* rollbackLaunch()
728
+ return yield* new HerdrError({
729
+ message: `failed to persist pane ownership before delivery: ${Cause.pretty(persisted.cause)}`,
730
+ })
731
+ }
732
+ }),
733
+ () =>
734
+ Effect.gen(function* () {
735
+ acquisitionRollbackErrors = yield* rollbackLaunch()
736
+ }),
737
+ ),
497
738
  )
498
739
  if (Result.isFailure(launched)) {
499
740
  if (launched.failure.details?.delivery === "unknown") {
500
- const paneId = String(launched.failure.details.pane_id)
501
- const paneLabel = String(launched.failure.details.pane_label)
502
- state.pending_pane_id = paneId
503
- state.pending_pane_label = paneLabel
504
- state.role_panes[role] = {
505
- pane_id: paneId,
506
- label: paneLabel,
507
- profile_fingerprint: profileFingerprint,
741
+ const paneId = launched.failure.details.pane_id
742
+ const paneLabel = launched.failure.details.pane_label
743
+ // Only a pane-backed save actually preserves pending ownership; a
744
+ // split failure never reached the state machine, so claiming
745
+ // `pending_preserved` here would send operators to redeliver against
746
+ // ownership that was never written.
747
+ const preserved =
748
+ typeof paneId === "string" && typeof paneLabel === "string"
749
+ if (preserved) {
750
+ recordPaneOwnership({ pane_id: paneId, label: paneLabel })
508
751
  }
509
752
  yield* store.save(state, root)
510
753
  return yield* new HerdrError({
@@ -516,11 +759,12 @@ export const dispatchWorkflow = (
516
759
  ...(launched.failure.details ?? {}),
517
760
  task_attempted: taskRef.task,
518
761
  artifact: artifactRel,
519
- pending_preserved: true,
762
+ pending_preserved: preserved,
520
763
  },
521
764
  })
522
765
  }
523
- const rollbackErrors = yield* rollbackLaunch()
766
+ const rollbackErrors =
767
+ acquisitionRollbackErrors ?? (yield* rollbackLaunch())
524
768
  return yield* herdrAfterRollback(
525
769
  launched.failure,
526
770
  {
@@ -542,20 +786,14 @@ export const dispatchWorkflow = (
542
786
  prompt_attempts: r.prompt_attempts,
543
787
  last_status: r.last_status ?? null,
544
788
  }
545
- state.pending_pane_id = r.pane_id
546
- state.pending_pane_label = r.label
547
- state.role_panes[role] = {
548
- pane_id: r.pane_id,
549
- label: r.label,
550
- profile_fingerprint: profileFingerprint,
551
- }
789
+ recordPaneOwnership(r)
552
790
 
553
791
  // After the launch, not before it: `runInteractivePrompt` blocks in
554
792
  // `waitAgentReady` (up to 90s) plus prompt-submit retries. Anchoring at
555
793
  // the top of the workflow charged that startup against the role's own
556
794
  // deadline and burned wait's 12s liveness grace before the first poll.
557
795
  const launchedAt = yield* Clock.currentTimeMillis
558
- markPending(launchedAt)
796
+ markPending(launchedAt, "interactive")
559
797
  yield* store.save(state, root)
560
798
 
561
799
  return ok(