@naxodev/apnea 0.1.0 → 0.2.1

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 (51) hide show
  1. package/README.md +25 -10
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8571 -15324
  5. package/docs/adr/0005-harness-profiles.md +1 -1
  6. package/docs/adr/0010-package-split.md +1 -1
  7. package/docs/protocol/artifacts.md +18 -2
  8. package/docs/protocol/config.md +22 -25
  9. package/docs/protocol/manual-gate.md +8 -8
  10. package/docs/protocol/overview.md +18 -5
  11. package/extension/adapters/commit.ts +5 -1
  12. package/extension/adapters/dispatch.ts +9 -1
  13. package/extension/adapters/setup.ts +15 -1
  14. package/extension/adapters/start.ts +5 -1
  15. package/extension/adapters/status.ts +17 -2
  16. package/extension/adapters/wait.ts +6 -1
  17. package/extension/api.ts +7 -1
  18. package/extension/cli/main.ts +67 -7
  19. package/extension/cli/parse.ts +172 -5
  20. package/extension/domain/herdr.ts +0 -86
  21. package/extension/domain/paths.ts +3 -13
  22. package/extension/domain/setup.ts +0 -20
  23. package/extension/domain/timeouts.ts +4 -0
  24. package/extension/domain/types.ts +64 -11
  25. package/extension/domain/verify-commands.ts +200 -108
  26. package/extension/errors.ts +51 -16
  27. package/extension/operation-hooks.ts +6 -0
  28. package/extension/registry.ts +29 -15
  29. package/extension/run-tool.ts +19 -2
  30. package/extension/schema/config.ts +86 -30
  31. package/extension/schema/frontmatter.ts +57 -0
  32. package/extension/schema/state.ts +226 -16
  33. package/extension/services/app-live.ts +2 -1
  34. package/extension/services/config.ts +6 -4
  35. package/extension/services/file-system.ts +346 -75
  36. package/extension/services/herdr.ts +393 -402
  37. package/extension/services/operation-lock.ts +418 -0
  38. package/extension/services/process.ts +477 -0
  39. package/extension/services/run-store.ts +38 -16
  40. package/extension/services/vcs.ts +1388 -86
  41. package/extension/workflows/commit.ts +222 -18
  42. package/extension/workflows/dispatch.ts +320 -220
  43. package/extension/workflows/setup.ts +61 -141
  44. package/extension/workflows/start.ts +6 -5
  45. package/extension/workflows/status.ts +2 -2
  46. package/extension/workflows/wait.ts +63 -134
  47. package/package.json +2 -3
  48. package/schemas/config.schema.json +11 -7
  49. package/schemas/state.schema.json +170 -12
  50. package/herdr-plugin/herdr-plugin.toml +0 -15
  51. package/herdr-plugin/scripts/run-task.sh +0 -8
@@ -1,6 +1,8 @@
1
1
  import * as path from "node:path"
2
2
  import { Cause, Clock, Effect, Exit, Result } from "effect"
3
- import { effectivePaneStyle, supportsFloating } from "../domain/herdr.ts"
3
+ import { inferKind } from "../domain/artifact-kind.ts"
4
+ import { parseFrontMatter } from "../domain/frontmatter.ts"
5
+ import { looksLikeShellOnly } from "../domain/herdr.ts"
4
6
  import {
5
7
  abs,
6
8
  packageRoot,
@@ -19,16 +21,22 @@ import {
19
21
  toolAllowed,
20
22
  type DispatchKind,
21
23
  } from "../domain/state-machine.ts"
22
- import { timeoutMsForKind } from "../domain/timeouts.ts"
24
+ import { deadlineAfter, timeoutMsForKind } from "../domain/timeouts.ts"
23
25
  import {
26
+ ArtifactInvalid,
24
27
  GateRefused,
25
28
  HerdrError,
26
29
  IllegalKind,
27
30
  type AppError,
28
31
  } from "../errors.ts"
29
32
  import type { Role } from "../domain/types.ts"
30
- 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"
31
38
  import { ok, type ToolResult } from "../result.ts"
39
+ import { validateArtifactCompletion } from "../schema/frontmatter.ts"
32
40
  import { Config } from "../services/config.ts"
33
41
  import { FileSystem } from "../services/file-system.ts"
34
42
  import { Herdr } from "../services/herdr.ts"
@@ -38,8 +46,10 @@ import { Vcs } from "../services/vcs.ts"
38
46
  export type DispatchParams = {
39
47
  kind: DispatchKind
40
48
  task_markdown?: string
41
- /** Increment round after CHANGES_REQUIRED (protocol: only then). */
49
+ /** @deprecated Assertion only. Persisted state owns rework through 0.2.x. */
42
50
  rework?: boolean
51
+ /** Reuse persisted pending ownership after a demonstrably dead delivery. */
52
+ redeliver?: boolean
43
53
  }
44
54
 
45
55
  function taskBody(opts: {
@@ -110,8 +120,8 @@ function herdrAfterRollback(
110
120
  }
111
121
 
112
122
  /**
113
- * Write task file, open interactive harness TUI in a Herdr pane (or a
114
- * floating oneshot popup), wait until idle, submit a short pointer prompt.
123
+ * Write task file, open an interactive harness TUI in a reusable Herdr pane,
124
+ * wait until idle, then submit a short pointer prompt.
115
125
  * Refusals are tagged failures only — never ok:false ToolResults.
116
126
  */
117
127
  export const dispatchWorkflow = (
@@ -136,14 +146,14 @@ export const dispatchWorkflow = (
136
146
  const herdr = yield* Herdr
137
147
 
138
148
  const state = yield* store.require(root)
139
- const stateBeforeDispatch = structuredClone(state)
149
+ const redeliver = params.redeliver === true
140
150
 
141
151
  const allowed = toolAllowed(state.step, "dispatch_role")
142
152
  if (Result.isFailure(allowed)) {
143
153
  return yield* allowed.failure
144
154
  }
145
155
 
146
- if (state.pending_artifact != null) {
156
+ if (state.pending_artifact != null && !redeliver) {
147
157
  return yield* new GateRefused({
148
158
  gate: "dispatch_pending",
149
159
  message:
@@ -155,6 +165,110 @@ export const dispatchWorkflow = (
155
165
  },
156
166
  })
157
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
+ }
158
272
 
159
273
  const kinds = allowedKinds(state.step)
160
274
  if (!kinds.includes(params.kind)) {
@@ -165,40 +279,109 @@ export const dispatchWorkflow = (
165
279
  })
166
280
  }
167
281
 
168
- // Rework flag validation
169
- if (params.rework) {
170
- const okRework =
171
- (params.kind === "plan" && state.step === "planning") ||
172
- (params.kind === "code" && state.step === "coding") ||
173
- (params.kind === "phase_package" &&
174
- state.step === "phase_packaging" &&
175
- state.phase_package_rework) ||
176
- (params.kind === "plan_review" && state.step === "plan_review") ||
177
- (params.kind === "code_review" && state.step === "code_review")
178
- // After CHANGES_REQUIRED, step moves back to planning/coding; rework
179
- // dispatch is plan/code with rework=true.
180
- if (!okRework && !(params.kind === "plan" || params.kind === "code")) {
181
- return yield* new GateRefused({
182
- gate: "rework",
183
- message:
184
- "rework=true only valid for plan/code after CHANGES_REQUIRED, phase_package after package rework, or same-gate re-review",
185
- })
186
- }
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
+ })
187
309
  }
310
+ const isRework = !redeliver && requiredRework === params.kind
311
+ const stateBeforeDispatch = structuredClone(state)
188
312
 
189
- const role = expectedRole(params.kind)
190
313
  const cfg = yield* config.load(root)
191
314
  const roleTimeoutMs = timeoutMsForKind(params.kind, cfg.timeouts_ms)
192
315
  // Validate the orchestrator's current pane before creating task artifacts.
193
316
  // A stale inherited environment is equivalent to running outside Herdr;
194
317
  // other CLI failures remain typed errors and stop dispatch.
195
- 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
+ }
196
379
 
197
380
  // --- Round numbers (increment ONLY on rework after CHANGES_REQUIRED) ---
198
381
  let round = 1
199
382
  if (params.kind === "plan" || params.kind === "plan_review") {
200
383
  const key = roundKey(0, "plan_review")
201
- if (params.rework && params.kind === "plan") {
384
+ if (isRework && params.kind === "plan") {
202
385
  // starting a new plan revision after CHANGES_REQUIRED → next review round
203
386
  setRound(state, key, getRound(state, key) + 1)
204
387
  } else if (!state.rounds[key]) {
@@ -211,12 +394,9 @@ export const dispatchWorkflow = (
211
394
  params.kind === "phase_package"
212
395
  ) {
213
396
  const key = codeReviewRoundKey(state.phase_index)
214
- if (params.rework && params.kind === "code") {
397
+ if (isRework && params.kind === "code") {
215
398
  setRound(state, key, getRound(state, key) + 1)
216
- } else if (
217
- params.kind === "phase_package" &&
218
- state.phase_package_rework
219
- ) {
399
+ } else if (isRework && params.kind === "phase_package") {
220
400
  setRound(state, key, getRound(state, key) + 1)
221
401
  } else if (!state.rounds[key]) {
222
402
  setRound(state, key, 1)
@@ -230,6 +410,7 @@ export const dispatchWorkflow = (
230
410
  ? roundKey(0, "plan_review")
231
411
  : codeReviewRoundKey(state.phase_index)
232
412
  if (
413
+ !redeliver &&
233
414
  (params.kind === "plan" ||
234
415
  params.kind === "code" ||
235
416
  params.kind === "phase_package" ||
@@ -266,7 +447,7 @@ export const dispatchWorkflow = (
266
447
  artifactAbs = path.join(d, "phase-package.md")
267
448
  extra =
268
449
  extra ||
269
- (state.phase_package_rework
450
+ (isRework || (redeliver && state.current_code_review !== null)
270
451
  ? `Revise the phase ${state.phase_index} package after code review \`${state.current_code_review}\`. Preserve approved-plan scope and address package findings.`
271
452
  : `Emit phase package for phase ${state.phase_index} only from approved plan \`${rel(planPath(root), root)}\`.`)
272
453
  break
@@ -306,6 +487,20 @@ export const dispatchWorkflow = (
306
487
  break
307
488
  }
308
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
+
309
504
  // Resolve the brief BEFORE clear-before-dispatch. This block can refuse,
310
505
  // and the rename below is a mutation: refusing after it left the prior
311
506
  // artifact stranded at a timestamped .bak path, so a failed dispatch
@@ -345,57 +540,13 @@ export const dispatchWorkflow = (
345
540
  })
346
541
  }
347
542
 
348
- const paneStyle = effectivePaneStyle(cfg.pane_style, role)
349
- let roleCmd: string[] | null = null
350
- let profileFingerprint: string | null = null
351
-
352
- // Everything that can be checked without a task path runs before any
353
- // artifact mutation. Only the pane/script launch race remains afterward.
354
- if (herdrAvailability !== "unavailable") {
355
- if (paneStyle.style === "floating") {
356
- const version = yield* herdr.version
357
- if (!supportsFloating(version)) {
358
- return yield* new HerdrError({
359
- message:
360
- "floating panes need herdr >= 0.7.4 — run `herdr update`, or set pane_style=regular",
361
- })
362
- }
363
- if (!(yield* herdr.hasApneaPlugin)) {
364
- return yield* new HerdrError({
365
- message: `apnea herdr plugin not linked. Run /apnea setup, or: herdr plugin link ${livePackageRoot}/herdr-plugin`,
366
- })
367
- }
368
- if (state.pending_floating_exit) {
369
- const prevExitAbs = abs(state.pending_floating_exit, root)
370
- if (!(yield* fs.exists(prevExitAbs))) {
371
- return yield* new GateRefused({
372
- gate: "floating_in_flight",
373
- message:
374
- "floating oneshot already in flight (popup still open). Call workflow_wait, or dismiss the popup and re-dispatch after it exits",
375
- details: {
376
- pending_artifact: state.pending_artifact,
377
- pending_floating_exit: state.pending_floating_exit,
378
- },
379
- })
380
- }
381
- }
382
- const cmdResult = yield* Effect.result(
383
- config.resolveRoleCmd(cfg, role, "oneshot"),
384
- )
385
- if (Result.isFailure(cmdResult)) {
386
- return yield* new HerdrError({
387
- message: `floating dispatch requires cmd_oneshot on the role profile: ${cmdResult.failure.message}`,
388
- })
389
- }
390
- roleCmd = cmdResult.success
391
- } else {
392
- roleCmd = yield* config.resolveRoleCmd(cfg, role, "interactive")
393
- profileFingerprint = JSON.stringify([
394
- cfg.roles[role]?.profile ?? null,
395
- roleCmd,
396
- ])
397
- }
398
- }
543
+ // Resolve the interactive command before mutating artifacts. Apnea never
544
+ // falls back to a profile's oneshot command, even outside Herdr.
545
+ const roleCmd = yield* config.resolveRoleCmd(cfg, role, "interactive")
546
+ const profileFingerprint = JSON.stringify([
547
+ cfg.roles[role]?.profile ?? null,
548
+ roleCmd,
549
+ ])
399
550
 
400
551
  if (role === "reviewer") {
401
552
  state.reviewer_tree_fingerprint = yield* vcsSvc.treeFingerprint(
@@ -405,15 +556,14 @@ export const dispatchWorkflow = (
405
556
  }
406
557
 
407
558
  // clear-before-dispatch
408
- yield* fs.mkdir(path.dirname(artifactAbs), { recursive: true })
559
+ yield* fs.mkdirProject(root, path.dirname(artifactAbs))
409
560
  let backupAbs: string | null = null
410
- if (yield* fs.exists(artifactAbs)) {
561
+ if (yield* fs.projectPathExists(root, artifactAbs)) {
411
562
  const backupMillis = yield* Clock.currentTimeMillis
412
563
  backupAbs = `${artifactAbs}.bak.${backupMillis}`
413
- yield* fs.rename(artifactAbs, backupAbs)
564
+ yield* fs.renameProjectFile(root, artifactAbs, backupAbs)
414
565
  }
415
566
 
416
- const artifactRel = rel(artifactAbs, root)
417
567
  const body = taskBody({
418
568
  kind: params.kind,
419
569
  role,
@@ -428,14 +578,14 @@ export const dispatchWorkflow = (
428
578
  tasksDir(root),
429
579
  `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
430
580
  )
431
- while (yield* fs.exists(taskFile)) {
581
+ while (yield* fs.projectPathExists(root, taskFile)) {
432
582
  taskFileMillis += 1
433
583
  taskFile = path.join(
434
584
  tasksDir(root),
435
585
  `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
436
586
  )
437
587
  }
438
- yield* fs.writeFile(taskFile, body)
588
+ yield* fs.writeProjectFile(root, taskFile, body)
439
589
  const taskRef = {
440
590
  task: rel(taskFile, root),
441
591
  artifact: artifactRel,
@@ -451,34 +601,30 @@ export const dispatchWorkflow = (
451
601
 
452
602
  let launch: Record<string, unknown> = {
453
603
  mode: ROLE_MODE[role],
454
- pane_style: cfg.pane_style,
455
- pane_style_effective: paneStyle.effective,
456
604
  }
457
605
 
458
606
  const rollbackLaunch = (restoreState = true) =>
459
607
  Effect.gen(function* () {
460
608
  const errors: string[] = []
461
- const attempt = (label: string, operation: Effect.Effect<void>) =>
609
+ const attempt = (
610
+ label: string,
611
+ operation: Effect.Effect<void, unknown>,
612
+ ) =>
462
613
  Effect.gen(function* () {
463
614
  const exit = yield* Effect.exit(operation)
464
615
  if (Exit.isFailure(exit)) {
465
616
  errors.push(`${label}: ${Cause.pretty(exit.cause)}`)
466
617
  }
467
618
  })
468
- yield* attempt("remove task", fs.remove(taskFile))
469
- yield* attempt(
470
- "remove floating script",
471
- fs.remove(taskFile.replace(/\.md$/, ".sh")),
472
- )
619
+ yield* attempt("remove task", fs.removeProjectFile(root, taskFile))
473
620
  yield* attempt(
474
- "remove floating exit marker",
475
- fs.remove(taskFile.replace(/\.md$/, ".exit")),
621
+ "remove replacement artifact",
622
+ fs.removeProjectFile(root, artifactAbs),
476
623
  )
477
- yield* attempt("remove replacement artifact", fs.remove(artifactAbs))
478
624
  if (backupAbs != null) {
479
625
  yield* attempt(
480
626
  "restore prior artifact",
481
- fs.rename(backupAbs, artifactAbs),
627
+ fs.renameProjectFile(root, backupAbs, artifactAbs),
482
628
  )
483
629
  }
484
630
  if (restoreState) {
@@ -490,22 +636,28 @@ export const dispatchWorkflow = (
490
636
  return errors
491
637
  })
492
638
 
493
- const markPending = (launchedAt: number): void => {
639
+ const markPending = (
640
+ launchedAt: number,
641
+ delivery: "manual" | "interactive",
642
+ ): void => {
494
643
  state.pending_artifact = artifactRel
495
644
  state.pending_role = role
645
+ state.pending_delivery = delivery
496
646
  state.pending_started_at = launchedAt
497
- state.pending_deadline_ms = launchedAt + roleTimeoutMs
498
- 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
499
649
  resetRecoveryLadder(state)
500
650
  }
501
651
 
502
652
  // Persist ownership before crossing an external launch boundary. If this
503
653
  // process dies after Herdr accepts work, a retry must not start a duplicate.
504
654
  const preparedAt = yield* Clock.currentTimeMillis
505
- markPending(preparedAt)
655
+ markPending(
656
+ preparedAt,
657
+ herdrAvailability === "unavailable" ? "manual" : "interactive",
658
+ )
506
659
  state.pending_pane_id = null
507
660
  state.pending_pane_label = null
508
- state.pending_floating_exit = null
509
661
  const preparedState = yield* Effect.exit(store.save(state, root))
510
662
  if (Exit.isFailure(preparedState)) {
511
663
  const persistenceError = new HerdrError({
@@ -544,127 +696,75 @@ export const dispatchWorkflow = (
544
696
  )
545
697
  }
546
698
 
547
- if (paneStyle.style === "floating") {
548
- const cmd = roleCmd!
549
- const scriptAbs = taskFile.replace(/\.md$/, ".sh")
550
- const exitAbs = taskFile.replace(/\.md$/, ".exit")
551
- // Drop stale exit marker so wait cannot see a previous run's code.
552
- yield* fs.remove(exitAbs)
553
- const scriptWritten = yield* Effect.result(
554
- herdr.writeFloatingTaskScript(scriptAbs, root, cmd, prompt, exitAbs),
555
- )
556
- if (Result.isFailure(scriptWritten)) {
557
- const rollbackErrors = yield* rollbackLaunch()
558
- return yield* herdrAfterRollback(
559
- scriptWritten.failure,
560
- {
561
- task_attempted: taskRef.task,
562
- artifact: artifactRel,
563
- },
564
- rollbackErrors,
565
- )
566
- }
567
- // Popups have no pane id — liveness is the exit file; leave role_panes alone.
568
- state.pending_pane_id = null
569
- state.pending_pane_label = null
570
- state.pending_floating_exit = rel(exitAbs, root)
571
- yield* store.save(state, root)
572
- const opened = yield* Effect.result(
573
- herdr.openFloatingPane(scriptAbs, root),
574
- )
575
- if (Result.isFailure(opened)) {
576
- return yield* new HerdrError({
577
- message: opened.failure.message,
578
- ...(opened.failure.command !== undefined
579
- ? { command: opened.failure.command }
580
- : {}),
581
- details: {
582
- ...(opened.failure.details ?? {}),
583
- delivery: "unknown",
584
- task_attempted: taskRef.task,
585
- artifact: artifactRel,
586
- pending_preserved: true,
587
- },
588
- })
589
- }
590
- launch = {
591
- mode: "oneshot",
592
- pane_style: cfg.pane_style,
593
- pane_style_effective: "floating",
594
- script: rel(scriptAbs, root),
595
- exit: rel(exitAbs, root),
596
- cmd,
597
- prompt,
598
- }
599
- } else {
600
- // Interactive TUI: open harness, wait idle, submit pointer via pane run.
601
- const cmd = roleCmd!
602
- const remembered = state.role_panes[role] ?? null
603
- const prefer =
604
- remembered?.profile_fingerprint === profileFingerprint
605
- ? remembered
606
- : null
607
- const launched = yield* Effect.result(
608
- herdr.runInteractivePrompt(role, cmd, prompt, prefer),
609
- )
610
- if (Result.isFailure(launched)) {
611
- if (launched.failure.details?.delivery === "unknown") {
612
- const paneId = String(launched.failure.details.pane_id)
613
- const paneLabel = String(launched.failure.details.pane_label)
699
+ // Interactive TUI: open harness, wait idle, submit pointer via pane run.
700
+ const cmd = roleCmd
701
+ const remembered = state.role_panes[role] ?? null
702
+ const prefer =
703
+ remembered?.profile_fingerprint === profileFingerprint ? remembered : null
704
+ const launched = yield* Effect.result(
705
+ herdr.runInteractivePrompt(role, cmd, prompt, prefer),
706
+ )
707
+ if (Result.isFailure(launched)) {
708
+ if (launched.failure.details?.delivery === "unknown") {
709
+ const paneId = launched.failure.details.pane_id
710
+ const paneLabel = launched.failure.details.pane_label
711
+ // Only a pane-backed save actually preserves pending ownership; a
712
+ // split failure never reached the state machine, so claiming
713
+ // `pending_preserved` here would send operators to redeliver against
714
+ // ownership that was never written.
715
+ const preserved =
716
+ typeof paneId === "string" && typeof paneLabel === "string"
717
+ if (preserved) {
614
718
  state.pending_pane_id = paneId
615
719
  state.pending_pane_label = paneLabel
616
- state.pending_floating_exit = null
617
720
  state.role_panes[role] = {
618
721
  pane_id: paneId,
619
722
  label: paneLabel,
620
723
  profile_fingerprint: profileFingerprint,
621
724
  }
622
- yield* store.save(state, root)
623
- return yield* new HerdrError({
624
- message: launched.failure.message,
625
- ...(launched.failure.command !== undefined
626
- ? { command: launched.failure.command }
627
- : {}),
628
- details: {
629
- ...(launched.failure.details ?? {}),
630
- task_attempted: taskRef.task,
631
- artifact: artifactRel,
632
- pending_preserved: true,
633
- },
634
- })
635
725
  }
636
- const rollbackErrors = yield* rollbackLaunch()
637
- return yield* herdrAfterRollback(
638
- launched.failure,
639
- {
726
+ yield* store.save(state, root)
727
+ return yield* new HerdrError({
728
+ message: launched.failure.message,
729
+ ...(launched.failure.command !== undefined
730
+ ? { command: launched.failure.command }
731
+ : {}),
732
+ details: {
733
+ ...(launched.failure.details ?? {}),
640
734
  task_attempted: taskRef.task,
641
735
  artifact: artifactRel,
736
+ pending_preserved: preserved,
642
737
  },
643
- rollbackErrors,
644
- )
645
- }
646
- const r = launched.success
647
- launch = {
648
- mode: "interactive",
649
- pane_id: r.pane_id,
650
- label: r.label,
651
- reused: r.reused,
652
- cmd,
653
- prompt,
654
- pane_style: cfg.pane_style,
655
- pane_style_effective: paneStyle.effective,
656
- prompt_accepted: r.prompt_accepted,
657
- prompt_attempts: r.prompt_attempts,
658
- last_status: r.last_status ?? null,
659
- }
660
- state.pending_pane_id = r.pane_id
661
- state.pending_pane_label = r.label
662
- state.pending_floating_exit = null
663
- state.role_panes[role] = {
664
- pane_id: r.pane_id,
665
- label: r.label,
666
- profile_fingerprint: profileFingerprint,
738
+ })
667
739
  }
740
+ const rollbackErrors = yield* rollbackLaunch()
741
+ return yield* herdrAfterRollback(
742
+ launched.failure,
743
+ {
744
+ task_attempted: taskRef.task,
745
+ artifact: artifactRel,
746
+ },
747
+ rollbackErrors,
748
+ )
749
+ }
750
+ const r = launched.success
751
+ launch = {
752
+ mode: "interactive",
753
+ pane_id: r.pane_id,
754
+ label: r.label,
755
+ reused: r.reused,
756
+ cmd,
757
+ prompt,
758
+ prompt_accepted: r.prompt_accepted,
759
+ prompt_attempts: r.prompt_attempts,
760
+ last_status: r.last_status ?? null,
761
+ }
762
+ state.pending_pane_id = r.pane_id
763
+ state.pending_pane_label = r.label
764
+ state.role_panes[role] = {
765
+ pane_id: r.pane_id,
766
+ label: r.label,
767
+ profile_fingerprint: profileFingerprint,
668
768
  }
669
769
 
670
770
  // After the launch, not before it: `runInteractivePrompt` blocks in
@@ -672,7 +772,7 @@ export const dispatchWorkflow = (
672
772
  // the top of the workflow charged that startup against the role's own
673
773
  // deadline and burned wait's 12s liveness grace before the first poll.
674
774
  const launchedAt = yield* Clock.currentTimeMillis
675
- markPending(launchedAt)
775
+ markPending(launchedAt, "interactive")
676
776
  yield* store.save(state, root)
677
777
 
678
778
  return ok(