@naxodev/apnea 0.1.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 (74) hide show
  1. package/CONTEXT.md +61 -0
  2. package/CONTRIBUTING.md +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +163 -0
  5. package/SECURITY.md +35 -0
  6. package/briefs/coder.md +40 -0
  7. package/briefs/orchestrator.md +49 -0
  8. package/briefs/planner.md +54 -0
  9. package/briefs/reviewer.md +40 -0
  10. package/dist/cli.js +39397 -0
  11. package/docs/adr/0001-completion-signaling.md +3 -0
  12. package/docs/adr/0002-orchestrator-authority.md +3 -0
  13. package/docs/adr/0003-verify-at-gate.md +3 -0
  14. package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
  15. package/docs/adr/0005-harness-profiles.md +5 -0
  16. package/docs/adr/0006-config-trust-model.md +3 -0
  17. package/docs/adr/0007-jj-first-commits.md +3 -0
  18. package/docs/adr/0008-effect-v4-internals.md +3 -0
  19. package/docs/adr/0009-cli-driver-split.md +9 -0
  20. package/docs/adr/0010-package-split.md +23 -0
  21. package/docs/protocol/artifacts.md +68 -0
  22. package/docs/protocol/config.md +186 -0
  23. package/docs/protocol/manual-gate.md +38 -0
  24. package/docs/protocol/overview.md +96 -0
  25. package/extension/adapters/commit.ts +15 -0
  26. package/extension/adapters/dispatch.ts +15 -0
  27. package/extension/adapters/setup.ts +34 -0
  28. package/extension/adapters/start.ts +16 -0
  29. package/extension/adapters/status.ts +24 -0
  30. package/extension/adapters/wait.ts +20 -0
  31. package/extension/api.ts +16 -0
  32. package/extension/cli/format.ts +44 -0
  33. package/extension/cli/human-gate.ts +44 -0
  34. package/extension/cli/main.ts +218 -0
  35. package/extension/cli/parse.ts +48 -0
  36. package/extension/domain/artifact-kind.ts +26 -0
  37. package/extension/domain/frontmatter.ts +69 -0
  38. package/extension/domain/herdr.ts +109 -0
  39. package/extension/domain/paths.ts +139 -0
  40. package/extension/domain/recovery.ts +25 -0
  41. package/extension/domain/rounds.ts +16 -0
  42. package/extension/domain/setup.ts +158 -0
  43. package/extension/domain/slug.ts +9 -0
  44. package/extension/domain/state-machine.ts +132 -0
  45. package/extension/domain/timeouts.ts +24 -0
  46. package/extension/domain/types.ts +145 -0
  47. package/extension/domain/verify-commands.ts +128 -0
  48. package/extension/errors.ts +247 -0
  49. package/extension/host-adapter.ts +8 -0
  50. package/extension/registry.ts +323 -0
  51. package/extension/result.ts +55 -0
  52. package/extension/run-tool.ts +43 -0
  53. package/extension/schema/config.ts +315 -0
  54. package/extension/schema/frontmatter.ts +34 -0
  55. package/extension/schema/state.ts +119 -0
  56. package/extension/services/app-live.ts +24 -0
  57. package/extension/services/config.ts +103 -0
  58. package/extension/services/file-system.ts +178 -0
  59. package/extension/services/herdr.ts +860 -0
  60. package/extension/services/run-store.ts +99 -0
  61. package/extension/services/vcs.ts +246 -0
  62. package/extension/workflows/commit.ts +148 -0
  63. package/extension/workflows/dispatch.ts +693 -0
  64. package/extension/workflows/reset.ts +26 -0
  65. package/extension/workflows/setup.ts +301 -0
  66. package/extension/workflows/start.ts +149 -0
  67. package/extension/workflows/status.ts +45 -0
  68. package/extension/workflows/wait.ts +793 -0
  69. package/herdr-plugin/herdr-plugin.toml +15 -0
  70. package/herdr-plugin/scripts/run-task.sh +8 -0
  71. package/package.json +75 -0
  72. package/schemas/artifact-frontmatter.md +38 -0
  73. package/schemas/config.schema.json +50 -0
  74. package/schemas/state.schema.json +63 -0
@@ -0,0 +1,693 @@
1
+ import * as path from "node:path"
2
+ import { Cause, Clock, Effect, Exit, Result } from "effect"
3
+ import { effectivePaneStyle, supportsFloating } from "../domain/herdr.ts"
4
+ import {
5
+ abs,
6
+ packageRoot,
7
+ phaseDir,
8
+ planPath,
9
+ planReviewPath,
10
+ prDescriptionPath,
11
+ rel,
12
+ tasksDir,
13
+ } from "../domain/paths.ts"
14
+ import { resetRecoveryLadder } from "../domain/recovery.ts"
15
+ import { getRound, roundKey, setRound } from "../domain/rounds.ts"
16
+ import {
17
+ allowedKinds,
18
+ expectedRole,
19
+ toolAllowed,
20
+ type DispatchKind,
21
+ } from "../domain/state-machine.ts"
22
+ import { timeoutMsForKind } from "../domain/timeouts.ts"
23
+ import {
24
+ GateRefused,
25
+ HerdrError,
26
+ IllegalKind,
27
+ type AppError,
28
+ } from "../errors.ts"
29
+ import type { Role } from "../domain/types.ts"
30
+ import { ROLE_MODE } from "../domain/types.ts"
31
+ import { ok, type ToolResult } from "../result.ts"
32
+ import { Config } from "../services/config.ts"
33
+ import { FileSystem } from "../services/file-system.ts"
34
+ import { Herdr } from "../services/herdr.ts"
35
+ import { RunStore } from "../services/run-store.ts"
36
+ import { Vcs } from "../services/vcs.ts"
37
+
38
+ export type DispatchParams = {
39
+ kind: DispatchKind
40
+ task_markdown?: string
41
+ /** Increment round after CHANGES_REQUIRED (protocol: only then). */
42
+ rework?: boolean
43
+ }
44
+
45
+ function taskBody(opts: {
46
+ kind: DispatchKind
47
+ role: Role
48
+ goal: string
49
+ artifactRel: string
50
+ briefAbs: string
51
+ extra: string
52
+ }): string {
53
+ return `# Dispatch: ${opts.role} (${opts.kind})
54
+
55
+ ## Role
56
+
57
+ ${opts.role}
58
+
59
+ ## Brief
60
+
61
+ Read and follow:
62
+
63
+ \`${opts.briefAbs}\`
64
+
65
+ ## Goal
66
+
67
+ ${opts.goal}
68
+
69
+ ## Artifact
70
+
71
+ Write **exactly**:
72
+
73
+ \`${opts.artifactRel}\`
74
+
75
+ Front-matter must include \`status: done\`. Review artifacts also need \`verdict: APPROVED | CHANGES_REQUIRED\` and optional \`nits\`. A code-review \`CHANGES_REQUIRED\` may use \`rework: code | phase_package\`; absent means code.
76
+
77
+ ## Details
78
+
79
+ ${opts.extra}
80
+
81
+ ## Rules
82
+
83
+ - Do not invent artifact paths.
84
+ - Do not edit \`.apnea/state.json\`.
85
+ - Do not commit / push.
86
+ `
87
+ }
88
+
89
+ function codeReviewRoundKey(phaseIndex: number): string {
90
+ return roundKey(phaseIndex, "code_review")
91
+ }
92
+
93
+ function herdrAfterRollback(
94
+ e: HerdrError,
95
+ context: { readonly task_attempted: string; readonly artifact: string },
96
+ rollbackErrors: readonly string[],
97
+ ): HerdrError {
98
+ return new HerdrError({
99
+ message: e.message,
100
+ ...(e.command !== undefined ? { command: e.command } : {}),
101
+ details: {
102
+ ...(e.details ?? {}),
103
+ ...context,
104
+ rolled_back: rollbackErrors.length === 0,
105
+ ...(rollbackErrors.length > 0
106
+ ? { rollback_errors: [...rollbackErrors] }
107
+ : {}),
108
+ },
109
+ })
110
+ }
111
+
112
+ /**
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.
115
+ * Refusals are tagged failures only — never ok:false ToolResults.
116
+ */
117
+ export const dispatchWorkflow = (
118
+ params: DispatchParams,
119
+ root: string,
120
+ // `packageRoot` override for tests. The real resolver reads the actual
121
+ // filesystem with node:fs — below the FileSystem service every other file
122
+ // access here goes through — so without this seam the stale-root tests had
123
+ // to seed the fake filesystem at a path derived from wherever the suite
124
+ // happened to run.
125
+ opts: { packageRoot?: () => string } = {},
126
+ ): Effect.Effect<
127
+ ToolResult,
128
+ AppError,
129
+ FileSystem | RunStore | Config | Vcs | Herdr
130
+ > =>
131
+ Effect.gen(function* () {
132
+ const store = yield* RunStore
133
+ const fs = yield* FileSystem
134
+ const config = yield* Config
135
+ const vcsSvc = yield* Vcs
136
+ const herdr = yield* Herdr
137
+
138
+ const state = yield* store.require(root)
139
+ const stateBeforeDispatch = structuredClone(state)
140
+
141
+ const allowed = toolAllowed(state.step, "dispatch_role")
142
+ if (Result.isFailure(allowed)) {
143
+ return yield* allowed.failure
144
+ }
145
+
146
+ if (state.pending_artifact != null) {
147
+ return yield* new GateRefused({
148
+ gate: "dispatch_pending",
149
+ message:
150
+ "a role dispatch is already pending; call workflow_wait before dispatching again",
151
+ details: {
152
+ pending_artifact: state.pending_artifact,
153
+ pending_role: state.pending_role,
154
+ pending_pane_id: state.pending_pane_id,
155
+ },
156
+ })
157
+ }
158
+
159
+ const kinds = allowedKinds(state.step)
160
+ if (!kinds.includes(params.kind)) {
161
+ return yield* new IllegalKind({
162
+ step: state.step,
163
+ kind: params.kind,
164
+ allowed: kinds,
165
+ })
166
+ }
167
+
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
+ }
187
+ }
188
+
189
+ const role = expectedRole(params.kind)
190
+ const cfg = yield* config.load(root)
191
+ const roleTimeoutMs = timeoutMsForKind(params.kind, cfg.timeouts_ms)
192
+ // Validate the orchestrator's current pane before creating task artifacts.
193
+ // A stale inherited environment is equivalent to running outside Herdr;
194
+ // other CLI failures remain typed errors and stop dispatch.
195
+ const herdrAvailability = yield* herdr.availability
196
+
197
+ // --- Round numbers (increment ONLY on rework after CHANGES_REQUIRED) ---
198
+ let round = 1
199
+ if (params.kind === "plan" || params.kind === "plan_review") {
200
+ const key = roundKey(0, "plan_review")
201
+ if (params.rework && params.kind === "plan") {
202
+ // starting a new plan revision after CHANGES_REQUIRED → next review round
203
+ setRound(state, key, getRound(state, key) + 1)
204
+ } else if (!state.rounds[key]) {
205
+ setRound(state, key, 1)
206
+ }
207
+ round = getRound(state, key)
208
+ } else if (
209
+ params.kind === "code" ||
210
+ params.kind === "code_review" ||
211
+ params.kind === "phase_package"
212
+ ) {
213
+ const key = codeReviewRoundKey(state.phase_index)
214
+ if (params.rework && params.kind === "code") {
215
+ setRound(state, key, getRound(state, key) + 1)
216
+ } else if (
217
+ params.kind === "phase_package" &&
218
+ state.phase_package_rework
219
+ ) {
220
+ setRound(state, key, getRound(state, key) + 1)
221
+ } else if (!state.rounds[key]) {
222
+ setRound(state, key, 1)
223
+ }
224
+ round = getRound(state, key)
225
+ }
226
+
227
+ // Cap: number of review rounds (rework count)
228
+ const capKey =
229
+ params.kind === "plan" || params.kind === "plan_review"
230
+ ? roundKey(0, "plan_review")
231
+ : codeReviewRoundKey(state.phase_index)
232
+ if (
233
+ (params.kind === "plan" ||
234
+ params.kind === "code" ||
235
+ params.kind === "phase_package" ||
236
+ params.kind === "plan_review" ||
237
+ params.kind === "code_review") &&
238
+ getRound(state, capKey) > cfg.review_round_cap
239
+ ) {
240
+ return yield* new GateRefused({
241
+ gate: "round_cap",
242
+ message: `review round cap ${cfg.review_round_cap} exceeded for ${capKey}. Human: apnea reset-rounds ${capKey} (or /apnea reset-rounds ${capKey}).`,
243
+ details: { gate_key: capKey, cap: cfg.review_round_cap },
244
+ })
245
+ }
246
+
247
+ // Resolve artifact path
248
+ let artifactAbs: string
249
+ let extra = params.task_markdown?.trim() || ""
250
+
251
+ switch (params.kind) {
252
+ case "plan":
253
+ artifactAbs = planPath(root)
254
+ if (!extra) {
255
+ extra = `Produce full plan for goal. Vertical phases with acceptance + verify commands.\nIf rework, address plan-review under .apnea/artifacts/plan-review/.`
256
+ }
257
+ break
258
+ case "plan_review":
259
+ artifactAbs = planReviewPath(round, root)
260
+ extra =
261
+ extra ||
262
+ `Review plan at \`${rel(planPath(root), root)}\`.\nWrite verdict front-matter.`
263
+ break
264
+ case "phase_package": {
265
+ const d = phaseDir(state.phase_index, round, root)
266
+ artifactAbs = path.join(d, "phase-package.md")
267
+ extra =
268
+ extra ||
269
+ (state.phase_package_rework
270
+ ? `Revise the phase ${state.phase_index} package after code review \`${state.current_code_review}\`. Preserve approved-plan scope and address package findings.`
271
+ : `Emit phase package for phase ${state.phase_index} only from approved plan \`${rel(planPath(root), root)}\`.`)
272
+ break
273
+ }
274
+ case "code": {
275
+ const d = phaseDir(state.phase_index, round, root)
276
+ artifactAbs = path.join(d, "coder-result.md")
277
+ const pkg =
278
+ state.current_phase_package ??
279
+ rel(
280
+ path.join(phaseDir(state.phase_index, 1, root), "phase-package.md"),
281
+ root,
282
+ )
283
+ extra =
284
+ extra ||
285
+ `Implement phase package \`${pkg}\` only.\nOn rework, read latest code-review and fix.`
286
+ break
287
+ }
288
+ case "code_review": {
289
+ const d = phaseDir(state.phase_index, round, root)
290
+ artifactAbs = path.join(d, "code-review.md")
291
+ const pkg =
292
+ state.current_phase_package ??
293
+ rel(
294
+ path.join(phaseDir(state.phase_index, 1, root), "phase-package.md"),
295
+ root,
296
+ )
297
+ const coder = rel(path.join(d, "coder-result.md"), root)
298
+ extra =
299
+ extra ||
300
+ `1) Compare phase package \`${pkg}\` to plan.\n2) Review code vs package.\n3) Check coder result \`${coder}\`.`
301
+ break
302
+ }
303
+ case "pr_description":
304
+ artifactAbs = prDescriptionPath(root)
305
+ extra = extra || "Write PR description summarizing all phases."
306
+ break
307
+ }
308
+
309
+ // Resolve the brief BEFORE clear-before-dispatch. This block can refuse,
310
+ // and the rename below is a mutation: refusing after it left the prior
311
+ // artifact stranded at a timestamped .bak path, so a failed dispatch
312
+ // quietly destroyed the artifact it was supposed to replace.
313
+ //
314
+ // The run's pinned root (stamped into state.json at `start`) comes
315
+ // FIRST. A run must keep the brief version it started with — the live
316
+ // install can be a newer checkout whose briefs describe a different
317
+ // artifact layout, and silently swapping mid-run points the role's
318
+ // instructions at one protocol while the run's gates expect another.
319
+ // The live root is strictly a repair path: it is consulted only when
320
+ // the pinned root has no brief at all, which is what a run stamped by
321
+ // the broken bundled resolver looks like.
322
+ const livePackageRoot = opts.packageRoot?.() ?? packageRoot()
323
+ const briefCandidates = [
324
+ ...new Set([
325
+ path.join(state.package_root, "briefs", `${role}.md`),
326
+ path.join(livePackageRoot, "briefs", `${role}.md`),
327
+ ]),
328
+ ]
329
+ let briefAbs: string | null = null
330
+ for (const candidate of briefCandidates) {
331
+ if (yield* fs.exists(candidate)) {
332
+ briefAbs = candidate
333
+ break
334
+ }
335
+ }
336
+ if (briefAbs == null) {
337
+ // Refuse loudly here rather than launching a pane whose role will
338
+ // stall on a missing file with no diagnostic from apnea.
339
+ return yield* new GateRefused({
340
+ gate: "brief",
341
+ message:
342
+ `no brief for role "${role}". Looked in ${briefCandidates.join(" and ")}. ` +
343
+ `The package root could not be resolved — reinstall @naxodev/apnea, or start a fresh run if this one predates a move.`,
344
+ details: { role, tried: briefCandidates },
345
+ })
346
+ }
347
+
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
+ }
399
+
400
+ if (role === "reviewer") {
401
+ state.reviewer_tree_fingerprint = yield* vcsSvc.treeFingerprint(
402
+ root,
403
+ state.vcs,
404
+ )
405
+ }
406
+
407
+ // clear-before-dispatch
408
+ yield* fs.mkdir(path.dirname(artifactAbs), { recursive: true })
409
+ let backupAbs: string | null = null
410
+ if (yield* fs.exists(artifactAbs)) {
411
+ const backupMillis = yield* Clock.currentTimeMillis
412
+ backupAbs = `${artifactAbs}.bak.${backupMillis}`
413
+ yield* fs.rename(artifactAbs, backupAbs)
414
+ }
415
+
416
+ const artifactRel = rel(artifactAbs, root)
417
+ const body = taskBody({
418
+ kind: params.kind,
419
+ role,
420
+ goal: state.goal,
421
+ artifactRel,
422
+ briefAbs,
423
+ extra,
424
+ })
425
+
426
+ let taskFileMillis = yield* Clock.currentTimeMillis
427
+ let taskFile = path.join(
428
+ tasksDir(root),
429
+ `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
430
+ )
431
+ while (yield* fs.exists(taskFile)) {
432
+ taskFileMillis += 1
433
+ taskFile = path.join(
434
+ tasksDir(root),
435
+ `${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
436
+ )
437
+ }
438
+ yield* fs.writeFile(taskFile, body)
439
+ const taskRef = {
440
+ task: rel(taskFile, root),
441
+ artifact: artifactRel,
442
+ }
443
+
444
+ const prompt = [
445
+ `You are the ${role}.`,
446
+ `Read brief: ${briefAbs}`,
447
+ `Read task: ${rel(taskFile, root)}`,
448
+ `Write artifact exactly at: ${artifactRel}`,
449
+ "Follow the brief. Do not invent paths. Do not commit. Do not edit .apnea/state.json.",
450
+ ].join("\n")
451
+
452
+ let launch: Record<string, unknown> = {
453
+ mode: ROLE_MODE[role],
454
+ pane_style: cfg.pane_style,
455
+ pane_style_effective: paneStyle.effective,
456
+ }
457
+
458
+ const rollbackLaunch = (restoreState = true) =>
459
+ Effect.gen(function* () {
460
+ const errors: string[] = []
461
+ const attempt = (label: string, operation: Effect.Effect<void>) =>
462
+ Effect.gen(function* () {
463
+ const exit = yield* Effect.exit(operation)
464
+ if (Exit.isFailure(exit)) {
465
+ errors.push(`${label}: ${Cause.pretty(exit.cause)}`)
466
+ }
467
+ })
468
+ yield* attempt("remove task", fs.remove(taskFile))
469
+ yield* attempt(
470
+ "remove floating script",
471
+ fs.remove(taskFile.replace(/\.md$/, ".sh")),
472
+ )
473
+ yield* attempt(
474
+ "remove floating exit marker",
475
+ fs.remove(taskFile.replace(/\.md$/, ".exit")),
476
+ )
477
+ yield* attempt("remove replacement artifact", fs.remove(artifactAbs))
478
+ if (backupAbs != null) {
479
+ yield* attempt(
480
+ "restore prior artifact",
481
+ fs.rename(backupAbs, artifactAbs),
482
+ )
483
+ }
484
+ if (restoreState) {
485
+ yield* attempt(
486
+ "restore workflow state",
487
+ store.save(stateBeforeDispatch, root),
488
+ )
489
+ }
490
+ return errors
491
+ })
492
+
493
+ const markPending = (launchedAt: number): void => {
494
+ state.pending_artifact = artifactRel
495
+ state.pending_role = role
496
+ state.pending_started_at = launchedAt
497
+ state.pending_deadline_ms = launchedAt + roleTimeoutMs
498
+ if (params.kind === "phase_package") state.phase_package_rework = false
499
+ resetRecoveryLadder(state)
500
+ }
501
+
502
+ // Persist ownership before crossing an external launch boundary. If this
503
+ // process dies after Herdr accepts work, a retry must not start a duplicate.
504
+ const preparedAt = yield* Clock.currentTimeMillis
505
+ markPending(preparedAt)
506
+ state.pending_pane_id = null
507
+ state.pending_pane_label = null
508
+ state.pending_floating_exit = null
509
+ const preparedState = yield* Effect.exit(store.save(state, root))
510
+ if (Exit.isFailure(preparedState)) {
511
+ const persistenceError = new HerdrError({
512
+ message: `failed to persist pending dispatch before launch: ${Cause.pretty(preparedState.cause)}`,
513
+ })
514
+ const rollbackErrors = yield* rollbackLaunch(false)
515
+ return yield* herdrAfterRollback(
516
+ persistenceError,
517
+ {
518
+ task_attempted: taskRef.task,
519
+ artifact: artifactRel,
520
+ },
521
+ rollbackErrors,
522
+ )
523
+ }
524
+
525
+ if (herdrAvailability === "unavailable") {
526
+ // Stamped here, not at the top of the workflow: `pending_started_at` is
527
+ // the anchor for both the role's deadline and wait's liveness grace, so
528
+ // it must mean "the role has the prompt", not "dispatch began".
529
+ return ok(
530
+ `task written (no Herdr). Launch ${role} yourself; then workflow_wait.`,
531
+ {
532
+ task: taskRef.task,
533
+ artifact: artifactRel,
534
+ round,
535
+ step: state.step,
536
+ launch,
537
+ next: "workflow_wait",
538
+ },
539
+ // Not `nextAfter(state.step)`: a dispatch is now outstanding, and
540
+ // `nextAfter` is step-derived so it cannot know that. Advertising
541
+ // `dispatch_role` here would invite a second dispatch that orphans
542
+ // this one's in-flight work and resets its deadline.
543
+ ["workflow_wait"],
544
+ )
545
+ }
546
+
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)
614
+ state.pending_pane_id = paneId
615
+ state.pending_pane_label = paneLabel
616
+ state.pending_floating_exit = null
617
+ state.role_panes[role] = {
618
+ pane_id: paneId,
619
+ label: paneLabel,
620
+ profile_fingerprint: profileFingerprint,
621
+ }
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
+ }
636
+ const rollbackErrors = yield* rollbackLaunch()
637
+ return yield* herdrAfterRollback(
638
+ launched.failure,
639
+ {
640
+ task_attempted: taskRef.task,
641
+ artifact: artifactRel,
642
+ },
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,
667
+ }
668
+ }
669
+
670
+ // After the launch, not before it: `runInteractivePrompt` blocks in
671
+ // `waitAgentReady` (up to 90s) plus prompt-submit retries. Anchoring at
672
+ // the top of the workflow charged that startup against the role's own
673
+ // deadline and burned wait's 12s liveness grace before the first poll.
674
+ const launchedAt = yield* Clock.currentTimeMillis
675
+ markPending(launchedAt)
676
+ yield* store.save(state, root)
677
+
678
+ return ok(
679
+ `dispatched ${params.kind} → ${role} artifact=${artifactRel}`,
680
+ {
681
+ task: taskRef.task,
682
+ artifact: artifactRel,
683
+ round,
684
+ step: state.step,
685
+ timeout_ms: roleTimeoutMs,
686
+ launch,
687
+ next: "workflow_wait",
688
+ },
689
+ // See the no-Herdr return above: one dispatch is outstanding, so
690
+ // `workflow_wait` is the only call that moves this run forward.
691
+ ["workflow_wait"],
692
+ )
693
+ })