@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,793 @@
1
+ import { Clock, Effect, Exit, Option, Result } from "effect"
2
+ import { resetRecoveryLadder } from "../domain/recovery.ts"
3
+ import { inferKind } from "../domain/artifact-kind.ts"
4
+ import { timeoutMsForKind } from "../domain/timeouts.ts"
5
+ import {
6
+ asVerdict,
7
+ isCompleteArtifact,
8
+ parseFrontMatter,
9
+ } from "../domain/frontmatter.ts"
10
+ import {
11
+ looksLikeShellOnly,
12
+ parseFloatingExit,
13
+ shellJoin,
14
+ } from "../domain/herdr.ts"
15
+ import { abs, rel } from "../domain/paths.ts"
16
+ import {
17
+ nextAfter,
18
+ stepAfterArtifact,
19
+ toolAllowed,
20
+ } from "../domain/state-machine.ts"
21
+ import {
22
+ ArtifactInvalid,
23
+ GateRefused,
24
+ HerdrError,
25
+ WaitAborted,
26
+ WaitTimeout,
27
+ type AppError,
28
+ } from "../errors.ts"
29
+ import type { FrontMatter } from "../domain/types.ts"
30
+ import { ok, type ToolResult } from "../result.ts"
31
+ import { decodeFrontMatterResult } from "../schema/frontmatter.ts"
32
+ import { Config } from "../services/config.ts"
33
+ import { FileSystem } from "../services/file-system.ts"
34
+ import { Herdr, paneReadRecentArgs } from "../services/herdr.ts"
35
+ import { RunStore } from "../services/run-store.ts"
36
+ import { Vcs } from "../services/vcs.ts"
37
+
38
+ export type WaitParams = {
39
+ poll_ms?: number
40
+ /**
41
+ * Wall-clock this single call may block for. Defaults to
42
+ * `DEFAULT_BUDGET_MS` so the call fits inside an unconfigured host shell.
43
+ * The role's real deadline lives in `state.pending_deadline_ms` and is
44
+ * unaffected — spending the budget returns "still waiting", not a timeout.
45
+ */
46
+ budget_ms?: number
47
+ }
48
+
49
+ /**
50
+ * Fits inside an unconfigured host shell. Agent shell tools commonly default
51
+ * to a 120s timeout. (600s is the maximum Claude Code accepts, not its
52
+ * default.) A longer budget is killed before it can return the exit-3 "call
53
+ * me again" result. The caller then sees a killed command, which is the one
54
+ * outcome the chunked design exists to prevent.
55
+ */
56
+ export const DEFAULT_BUDGET_MS = 90_000
57
+
58
+ /**
59
+ * Shortest usable poll. Each poll spawns `herdr pane get` and
60
+ * `herdr pane foreground-names`, so a 1ms interval is ~144k subprocesses over
61
+ * one floor-length call. A sign check alone let that through.
62
+ */
63
+ export const MIN_POLL_MS = 250
64
+
65
+ /** Grace after dispatch before a pane is judged missing or shell-only. */
66
+ export const GRACE_MS = 12_000
67
+
68
+ /** Consecutive shell-only polls before the harness is declared dead. */
69
+ export const DEAD_POLLS_NEEDED = 4
70
+
71
+ /**
72
+ * Continuous idle time before the role is nudged once.
73
+ *
74
+ * 60s, not 90s, so the whole rung fits inside one default-budget call:
75
+ * `GRACE_MS + IDLE_NUDGE_AFTER_MS` is 72s, under `DEFAULT_BUDGET_MS`.
76
+ *
77
+ * This buys the rung its margin at the cost of signal quality, and the trade is
78
+ * real in both directions. A role can read `idle` for 60-89s while it is doing
79
+ * legitimate work — a coder blocked on a slow test run, a planner between
80
+ * streaming turns — and the nudge lands in its pane mid-turn. The nudge is
81
+ * one-shot, so a false one is also spent. At 90s the floor would be 102s, which
82
+ * forces `DEFAULT_BUDGET_MS` up to ~105s and leaves ~15s of margin under a 120s
83
+ * host shell. 60s trades some false-nudge risk for that margin; raising it back
84
+ * means raising the default budget with it.
85
+ */
86
+ export const IDLE_NUDGE_AFTER_MS = 60_000
87
+
88
+ /**
89
+ * The host shell timeout this design assumes. Not enforced by us — it belongs
90
+ * to whatever agent harness runs `apnea wait`, and 120s is the common default.
91
+ * Every budget rule here exists to return an exit-3 resume instruction before
92
+ * this fires, because a killed command carries no instruction at all.
93
+ */
94
+ export const HOST_SHELL_TIMEOUT_MS = 120_000
95
+
96
+ /**
97
+ * Smallest budget that lets every duration-based rung complete inside one
98
+ * call.
99
+ *
100
+ * Two rungs measure a duration by polling: the idle nudge needs
101
+ * `IDLE_NUDGE_AFTER_MS` of unbroken idleness, and the dead-harness check needs
102
+ * `DEAD_POLLS_NEEDED` consecutive shell-only polls. Both start counting after
103
+ * `GRACE_MS`.
104
+ *
105
+ * An earlier version of this file tried to carry those counters across calls
106
+ * in `state.json` instead. That cannot be made correct: nothing observes the
107
+ * role between calls, so the counter has to guess what happened in the gap.
108
+ * Guessing "still idle" nudges a role that was working; guessing "not idle"
109
+ * discards the evidence and the rung never fires. Both were reproduced. A
110
+ * duration is only meaningful over an interval something actually watched, so
111
+ * the call has to be long enough to contain it.
112
+ *
113
+ * Facts, unlike durations, survive a gap safely and stay in `state.json`: the
114
+ * deadline, the one-time extension, the final grace, and whether a nudge was
115
+ * sent.
116
+ */
117
+ export const minBudgetFor = (pollMs: number): number =>
118
+ GRACE_MS + Math.max(IDLE_NUDGE_AFTER_MS, DEAD_POLLS_NEEDED * pollMs)
119
+
120
+ /**
121
+ * The budget used when the caller did not pick one.
122
+ *
123
+ * Raised to the floor, because leaving it at the bare default made
124
+ * `apnea wait --poll=20000` — legal on main — exit 1 with a floor refusal.
125
+ *
126
+ * Exported so the tests drive their clock off the same rule the call uses.
127
+ * They had their own copy of this expression; a change here then advanced the
128
+ * test clock short of where the call actually returns, and the suite hung on a
129
+ * join that never resolved instead of failing with a readable message.
130
+ */
131
+ export const defaultBudgetFor = (pollMs: number): number =>
132
+ Math.max(DEFAULT_BUDGET_MS, minBudgetFor(pollMs))
133
+
134
+ /**
135
+ * Largest poll we will pick a budget for, advertised in the refusal message.
136
+ *
137
+ * The `- 1` is load-bearing: the budget has to land STRICTLY under the shell
138
+ * timeout, because a call that runs exactly `HOST_SHELL_TIMEOUT_MS` is killed
139
+ * at the same instant it would have returned exit 3. `Math.floor` alone gave
140
+ * 27000, whose budget is exactly 120000 — the boundary the ceiling exists to
141
+ * stay off. Nothing gates on this constant; `fitsHostShell` does the deciding,
142
+ * so a wrong value here misadvises but cannot let a doomed call through.
143
+ */
144
+ export const MAX_AUTO_POLL_MS =
145
+ Math.ceil((HOST_SHELL_TIMEOUT_MS - GRACE_MS) / DEAD_POLLS_NEEDED) - 1
146
+
147
+ /**
148
+ * Would a budget we pick ourselves survive a default host shell?
149
+ *
150
+ * Phrased as the invariant rather than as a poll bound. The first version
151
+ * compared `poll` against a separately derived ceiling, and the two drifted by
152
+ * one poll interval — the arithmetic that decides is now the arithmetic that
153
+ * runs.
154
+ */
155
+ export const fitsHostShell = (pollMs: number): boolean =>
156
+ defaultBudgetFor(pollMs) < HOST_SHELL_TIMEOUT_MS
157
+
158
+ export type WaitHooks = {
159
+ signal?: AbortSignal
160
+ onUpdate?: (partial: {
161
+ content: Array<{ type: "text"; text: string }>
162
+ }) => void
163
+ }
164
+
165
+ /**
166
+ * Async wait — polls via `Clock` + `Effect.sleep` so Pi stays responsive and
167
+ * an `AbortSignal` (Esc) interrupts through `Effect.raceFirst`, not a flag
168
+ * check.
169
+ *
170
+ * Recovery (does not immediately escalate):
171
+ * - idle without artifact for ≥60s → one nudge prompt into the role pane
172
+ * - still working/blocked at timeout → extend budget once by max(50%, 2m)
173
+ * - idle at final timeout and never nudged → final nudge + 3m grace
174
+ */
175
+ export const waitWorkflow = (
176
+ params: WaitParams,
177
+ root: string,
178
+ hooks: WaitHooks = {},
179
+ ): Effect.Effect<
180
+ ToolResult,
181
+ AppError,
182
+ FileSystem | RunStore | Config | Vcs | Herdr
183
+ > =>
184
+ Effect.gen(function* () {
185
+ const store = yield* RunStore
186
+ const fs = yield* FileSystem
187
+ const config = yield* Config
188
+ const vcsSvc = yield* Vcs
189
+ const herdr = yield* Herdr
190
+
191
+ const state = yield* store.require(root)
192
+
193
+ const allowed = toolAllowed(state.step, "workflow_wait")
194
+ if (Result.isFailure(allowed)) {
195
+ return yield* allowed.failure
196
+ }
197
+
198
+ if (!state.pending_artifact) {
199
+ return yield* new GateRefused({
200
+ gate: "wait",
201
+ message: "no pending_artifact; dispatch_role first",
202
+ })
203
+ }
204
+ const pendingArtifact = state.pending_artifact
205
+
206
+ const cfg = yield* config.load(root)
207
+
208
+ const poll = params.poll_ms ?? 2000
209
+ if (!Number.isFinite(poll) || poll < MIN_POLL_MS) {
210
+ return yield* new GateRefused({
211
+ gate: "poll_interval",
212
+ message: `poll_ms must be a finite number >= ${MIN_POLL_MS}; got ${poll}. Every poll spawns two herdr subprocesses, so a tiny interval is a busy-spin, not a faster wait.`,
213
+ })
214
+ }
215
+ // Refuse to PICK a budget that a default host shell would kill; still
216
+ // honour one the caller picked. Raising the budget silently was the
217
+ // whole point of `defaultBudgetFor`, but above `MAX_AUTO_POLL_MS` that
218
+ // raise lands past `HOST_SHELL_TIMEOUT_MS`, and the caller gets a killed
219
+ // command instead of the exit-3 resume instruction — the exact outcome
220
+ // the 90s default exists to prevent. Passing `--budget` explicitly is a
221
+ // deliberate opt-in to a longer call, so it stays legal.
222
+ if (params.budget_ms == null && !fitsHostShell(poll)) {
223
+ return yield* new GateRefused({
224
+ gate: "poll_interval",
225
+ message:
226
+ `poll_ms=${poll} needs a budget of at least ${minBudgetFor(poll)}ms, ` +
227
+ `over the ${HOST_SHELL_TIMEOUT_MS}ms an agent shell commonly allows. ` +
228
+ `This call would be killed before it could tell you to call again. ` +
229
+ `Lower --poll to ${MAX_AUTO_POLL_MS} or less, or pass --budget explicitly ` +
230
+ `if your shell allows a longer call.`,
231
+ })
232
+ }
233
+ const budget = params.budget_ms ?? defaultBudgetFor(poll)
234
+ // `Number.isFinite` first: every comparison against NaN is false, so an
235
+ // unvalidated NaN slips past the floor checks below, makes `budgetEnd`
236
+ // NaN, and the call blocks until the role's deadline instead of
237
+ // returning exit 3. The CLI is protected by `parseNumFlag`; a Pi tool
238
+ // call reaches `run` through a raw cast with no runtime coercion.
239
+ if (!Number.isFinite(budget)) {
240
+ return yield* new GateRefused({
241
+ gate: "budget_floor",
242
+ message: `budget_ms must be a finite number; got ${budget}`,
243
+ })
244
+ }
245
+ const floor = minBudgetFor(poll)
246
+ if (budget < floor) {
247
+ return yield* new GateRefused({
248
+ gate: "budget_floor",
249
+ message:
250
+ `budget_ms must be >= ${floor} at poll_ms=${poll}; got ${budget}. ` +
251
+ `A shorter call cannot contain the ${GRACE_MS}ms grace plus either the ` +
252
+ `${IDLE_NUDGE_AFTER_MS}ms idle nudge or ${DEAD_POLLS_NEEDED} polls, and those ` +
253
+ `durations cannot be measured across calls — nothing watches the role in between. ` +
254
+ `Raise --budget, or lower --poll.`,
255
+ })
256
+ }
257
+ const startedMs = yield* Clock.currentTimeMillis
258
+ const budgetEnd = startedMs + budget
259
+ const artifactAbs = abs(pendingArtifact, root)
260
+ const kindResult = inferKind(pendingArtifact)
261
+ if (Result.isFailure(kindResult)) {
262
+ return yield* kindResult.failure
263
+ }
264
+ const kind = kindResult.success
265
+ // The role's CONFIGURED timeout, not `pending_deadline_ms - dispatchedAt`.
266
+ // The recovery ladder moves the deadline, so the subtraction returned an
267
+ // inflated span on any later call: the one-time extension was sized from
268
+ // it (540s instead of 450s for a 900s role) and `WaitTimeout` reported it
269
+ // to the human as if it were the configured value.
270
+ const timeout = timeoutMsForKind(kind, cfg.timeouts_ms)
271
+
272
+ // Legacy state (dispatched before the clock existed) starts its budget now.
273
+ // The stamp has to come from `timeout` — the same per-kind lookup the
274
+ // extension and the `WaitTimeout` report use. It read `timeouts_ms.default`
275
+ // while `timeout` read the per-kind key, so a legacy coder configured for
276
+ // 45m was killed at the 15m default and then told it had been given 45.
277
+ const dispatchedAt = state.pending_started_at ?? startedMs
278
+ if (state.pending_deadline_ms == null) {
279
+ state.pending_started_at = dispatchedAt
280
+ state.pending_deadline_ms = dispatchedAt + timeout
281
+ yield* store.save(state, root)
282
+ }
283
+ const requireVerdict = kind === "plan_review" || kind === "code_review"
284
+
285
+ const readArtifact = (): Effect.Effect<FrontMatter | null> =>
286
+ Effect.gen(function* () {
287
+ const present = yield* fs.exists(artifactAbs)
288
+ if (!present) return null
289
+ const text = yield* fs.readFile(artifactAbs)
290
+ return parseFrontMatter(text)
291
+ })
292
+
293
+ const readFloatingExitCode = (
294
+ exitAbs: string,
295
+ ): Effect.Effect<number | null> =>
296
+ Effect.gen(function* () {
297
+ const present = yield* fs.exists(exitAbs)
298
+ if (!present) return null
299
+ const text = yield* fs.readFile(exitAbs)
300
+ return parseFloatingExit(text)
301
+ })
302
+
303
+ const advanceOnComplete = (
304
+ fm: FrontMatter,
305
+ msg = "artifact ready",
306
+ ): Effect.Effect<ToolResult, AppError> =>
307
+ Effect.gen(function* () {
308
+ if (
309
+ fm.rework !== undefined &&
310
+ (kind !== "code_review" || fm.verdict !== "CHANGES_REQUIRED")
311
+ ) {
312
+ return yield* new ArtifactInvalid({
313
+ artifact: pendingArtifact,
314
+ message:
315
+ "rework is valid only on a code_review artifact with verdict CHANGES_REQUIRED",
316
+ })
317
+ }
318
+
319
+ if (
320
+ state.pending_role === "reviewer" &&
321
+ state.reviewer_tree_fingerprint != null
322
+ ) {
323
+ const now = yield* vcsSvc.treeFingerprint(root, state.vcs)
324
+ if (now !== state.reviewer_tree_fingerprint) {
325
+ state.last_error = "reviewer dirtied file tree"
326
+ yield* store.save(state, root)
327
+ return yield* new GateRefused({
328
+ gate: "reviewer_clean_tree",
329
+ message:
330
+ "reviewer dirty-tree detection: file content changed during review — escalate to human",
331
+ details: {
332
+ before: state.reviewer_tree_fingerprint,
333
+ after: now,
334
+ artifact: pendingArtifact,
335
+ },
336
+ })
337
+ }
338
+ }
339
+
340
+ // Post-completion Schema guard only — isCompleteArtifact above is the
341
+ // completeness test. A bad/absent verdict must keep waiting, not fail
342
+ // here (that already happened before advanceOnComplete was called).
343
+ //
344
+ // Only review kinds carry a meaningful verdict. A stray `verdict:` on
345
+ // a plan/code/package artifact is noise the old parser ignored;
346
+ // validating it here would wedge the run permanently (state is not
347
+ // saved on failure, so every retry fails identically).
348
+ const decoded = decodeFrontMatterResult(
349
+ {
350
+ status: fm.status,
351
+ ...(requireVerdict ? { verdict: fm.verdict } : {}),
352
+ nits: fm.nits,
353
+ ...(kind === "code_review" && fm.rework
354
+ ? { rework: fm.rework }
355
+ : {}),
356
+ },
357
+ pendingArtifact,
358
+ )
359
+ if (Result.isFailure(decoded)) {
360
+ return yield* decoded.failure
361
+ }
362
+
363
+ const rework =
364
+ kind === "code_review" ? decoded.success.rework : undefined
365
+ const next = stepAfterArtifact(kind, fm.verdict, rework)
366
+ if (typeof next === "object") {
367
+ return yield* new ArtifactInvalid({
368
+ artifact: pendingArtifact,
369
+ message: next.error,
370
+ })
371
+ }
372
+
373
+ if (kind === "phase_package") {
374
+ state.current_phase_package = pendingArtifact
375
+ }
376
+ if (kind === "code_review") {
377
+ state.current_code_review = pendingArtifact
378
+ state.phase_package_rework =
379
+ fm.verdict === "CHANGES_REQUIRED" && rework === "phase_package"
380
+ }
381
+ const verdict = asVerdict(fm.verdict)
382
+ state.step = next
383
+ state.pending_artifact = null
384
+ state.pending_role = null
385
+ state.pending_pane_id = null
386
+ state.pending_pane_label = null
387
+ state.pending_floating_exit = null
388
+ state.pending_started_at = null
389
+ state.pending_deadline_ms = null
390
+ resetRecoveryLadder(state)
391
+ state.reviewer_tree_fingerprint = null
392
+ state.last_error = null
393
+ yield* store.save(state, root)
394
+
395
+ return ok(
396
+ `${msg}; step → ${next}`,
397
+ {
398
+ artifact: rel(artifactAbs, root),
399
+ kind,
400
+ verdict,
401
+ nits: fm.nits ?? null,
402
+ rework: rework ?? null,
403
+ step: next,
404
+ },
405
+ nextAfter(next),
406
+ )
407
+ })
408
+
409
+ const floatingFlushMs = 2_000
410
+
411
+ let lastStatus = "waiting"
412
+ /**
413
+ * Both counters below are per-call on purpose. They measure a duration
414
+ * by polling, and `minBudgetFor` guarantees this call is long enough to
415
+ * contain either one. Persisting them was tried and reverted: a counter
416
+ * that spans calls has to assume something about the gap it did not
417
+ * observe, and both assumptions produce real bugs.
418
+ */
419
+ let shellOnlyPolls = 0
420
+ let idleSince: number | null = null
421
+ let floatingExitSeenAt: number | null = null
422
+ let nudged = state.pending_nudged_at != null
423
+ let extendedOnce = state.pending_extended
424
+ let finalNudgeGrace = state.pending_final_grace
425
+
426
+ const nudgePrompt =
427
+ `You appear idle without writing the required artifact.\n` +
428
+ `Write it now exactly at: ${pendingArtifact}\n` +
429
+ `Front-matter must include status: done` +
430
+ (requireVerdict ? ` and verdict: APPROVED | CHANGES_REQUIRED` : "") +
431
+ `. Follow the brief and task file. Do not invent paths.`
432
+
433
+ /**
434
+ * `force` is for the deadline rung only.
435
+ *
436
+ * The `nudged` guard stops the IDLE rung re-prompting every poll, which
437
+ * is right — but the final grace exists to give a prompt time to land,
438
+ * and suppressing the prompt turns it into 180s of silence before the
439
+ * same escalation. A role that ignored its idle nudge got the delay
440
+ * without the second chance. `pending_final_grace` already makes the
441
+ * deadline rung one-shot per run, so forcing it cannot spam a pane.
442
+ */
443
+ const tryNudge = (why: string, force = false): Effect.Effect<void> =>
444
+ Effect.gen(function* () {
445
+ if (
446
+ (nudged && !force) ||
447
+ !(yield* herdr.enabled) ||
448
+ !state.pending_pane_id
449
+ ) {
450
+ return
451
+ }
452
+ const outcome = yield* Effect.option(
453
+ herdr.paneRun(state.pending_pane_id, nudgePrompt),
454
+ )
455
+ // Back off either way so a failing pane is not re-prompted every
456
+ // poll; only a delivered nudge burns the rung. Restarting the
457
+ // idle clock is the back-off: another full IDLE_NUDGE_AFTER_MS
458
+ // of unbroken idleness must pass before a retry.
459
+ idleSince = yield* Clock.currentTimeMillis
460
+ if (Option.isNone(outcome)) return
461
+ // Persisted only after the send succeeded. Recording it first made a
462
+ // failed `paneRun` disable both nudge rungs for the rest of the run
463
+ // while `WaitTimeout` still reported `nudged: true`. The opposite
464
+ // failure — a crash between send and save — costs one extra prompt.
465
+ nudged = true
466
+ state.pending_nudged_at = idleSince
467
+ yield* store.save(state, root)
468
+ hooks.onUpdate?.({
469
+ content: [
470
+ {
471
+ type: "text",
472
+ text: `${why} — nudged ${state.pending_role} to write ${pendingArtifact}`,
473
+ },
474
+ ],
475
+ })
476
+ })
477
+
478
+ hooks.onUpdate?.({
479
+ content: [
480
+ {
481
+ type: "text",
482
+ text: `waiting for ${pendingArtifact} (timeout ${Math.round(timeout / 1000)}s)…`,
483
+ },
484
+ ],
485
+ })
486
+
487
+ const loop: Effect.Effect<ToolResult, AppError> = Effect.gen(function* () {
488
+ while (true) {
489
+ const now = yield* Clock.currentTimeMillis
490
+ const deadline = state.pending_deadline_ms ?? startedMs + timeout
491
+
492
+ const fm = yield* readArtifact()
493
+ if (isCompleteArtifact(fm, { requireVerdict })) {
494
+ return yield* advanceOnComplete(
495
+ fm!,
496
+ nudged ? "artifact ready after nudge" : "artifact ready",
497
+ )
498
+ }
499
+
500
+ // Floating oneshot: no pane id — exit file is liveness. Fail closed
501
+ // when the popup dies without a complete artifact instead of
502
+ // hanging until timeout.
503
+ if (state.pending_floating_exit) {
504
+ const exitAbs = abs(state.pending_floating_exit, root)
505
+ const code = yield* readFloatingExitCode(exitAbs)
506
+ if (code != null) {
507
+ lastStatus = `floating_exit_${code}`
508
+ floatingExitSeenAt ??= now
509
+ // Short flush window: oneshot may finish writing as the process exits.
510
+ if (now - floatingExitSeenAt >= floatingFlushMs) {
511
+ const again = yield* readArtifact()
512
+ if (isCompleteArtifact(again, { requireVerdict })) {
513
+ return yield* advanceOnComplete(
514
+ again!,
515
+ "artifact ready (floating oneshot exited)",
516
+ )
517
+ }
518
+ state.pending_floating_exit = null
519
+ state.last_error = `floating oneshot exited ${code} without ${pendingArtifact}`
520
+ yield* store.save(state, root)
521
+ return yield* new HerdrError({
522
+ message: `floating ${state.pending_role} exited (code ${code}) without writing ${pendingArtifact}`,
523
+ details: {
524
+ exit_code: code,
525
+ last_agent_status: lastStatus,
526
+ hint:
527
+ code === 129
528
+ ? "popup received Hangup (dismiss/focus steal) — re-dispatch same round; keep focus on the popup"
529
+ : "inspect oneshot output; re-dispatch same round or set pane_style=regular",
530
+ },
531
+ })
532
+ }
533
+ } else {
534
+ lastStatus = "floating_running"
535
+ }
536
+ }
537
+
538
+ if ((yield* herdr.enabled) && state.pending_pane_id) {
539
+ const info = yield* herdr.paneGet(state.pending_pane_id)
540
+ if (!info.ok) {
541
+ lastStatus = "pane_missing"
542
+ if (now - dispatchedAt > GRACE_MS) {
543
+ state.last_error = `role pane missing while waiting for ${pendingArtifact}`
544
+ yield* store.save(state, root)
545
+ return yield* new HerdrError({
546
+ message: `role pane gone and artifact incomplete: ${pendingArtifact}`,
547
+ details: {
548
+ last_agent_status: lastStatus,
549
+ hint: "re-dispatch same round after investigate",
550
+ },
551
+ })
552
+ }
553
+ } else {
554
+ lastStatus = info.agent_status ?? "unknown"
555
+
556
+ if (lastStatus === "done") {
557
+ // Pane status and filesystem visibility are separate observations.
558
+ // The artifact may have completed between the read above and this
559
+ // terminal status, so fail the contract only after one fresh read.
560
+ const artifactAfterDone = yield* readArtifact()
561
+ if (isCompleteArtifact(artifactAfterDone, { requireVerdict })) {
562
+ return yield* advanceOnComplete(
563
+ artifactAfterDone!,
564
+ "artifact ready as role exited",
565
+ )
566
+ }
567
+
568
+ const paneId = state.pending_pane_id
569
+ const role = state.pending_role ?? "unknown role"
570
+ const transcriptCommand = shellJoin([
571
+ "herdr",
572
+ ...paneReadRecentArgs(paneId),
573
+ ])
574
+ const paneOutputExit = yield* Effect.exit(
575
+ herdr.paneReadRecent(paneId),
576
+ )
577
+ const paneOutput = Exit.isSuccess(paneOutputExit)
578
+ ? paneOutputExit.value
579
+ : null
580
+ state.last_error = `${role} reported done without complete artifact ${pendingArtifact}`
581
+ yield* store.save(state, root)
582
+ return yield* new HerdrError({
583
+ message: `role/artifact contract violated: ${role} reported done without completing ${pendingArtifact}`,
584
+ details: {
585
+ pane_id: paneId,
586
+ role,
587
+ artifact: pendingArtifact,
588
+ last_agent_status: lastStatus,
589
+ transcript_command: transcriptCommand,
590
+ pane_output: paneOutput,
591
+ hint: `inspect the terminal pane with: ${transcriptCommand}`,
592
+ },
593
+ })
594
+ }
595
+
596
+ const isIdle = lastStatus === "idle"
597
+ if (isIdle) {
598
+ // Per-call timestamp. `minBudgetFor` guarantees this call
599
+ // is long enough to reach the threshold from here.
600
+ if (idleSince == null) idleSince = now
601
+ else if (
602
+ now - idleSince >= IDLE_NUDGE_AFTER_MS &&
603
+ now - dispatchedAt > GRACE_MS
604
+ ) {
605
+ yield* tryNudge("idle stall")
606
+ }
607
+ } else {
608
+ idleSince = null
609
+ }
610
+
611
+ if (now - dispatchedAt > GRACE_MS) {
612
+ const names = yield* herdr.paneForegroundNames(
613
+ state.pending_pane_id,
614
+ )
615
+ if (looksLikeShellOnly(names)) {
616
+ const again = yield* readArtifact()
617
+ if (!isCompleteArtifact(again, { requireVerdict })) {
618
+ shellOnlyPolls += 1
619
+ if (shellOnlyPolls >= DEAD_POLLS_NEEDED) {
620
+ state.last_error = `role pane shell-only without artifact ${pendingArtifact}`
621
+ yield* store.save(state, root)
622
+ return yield* new HerdrError({
623
+ message: `${state.pending_role} harness exited without writing ${pendingArtifact}`,
624
+ details: {
625
+ last_agent_status: lastStatus,
626
+ foreground: names,
627
+ hint: "check pane transcript; re-dispatch same round",
628
+ },
629
+ })
630
+ }
631
+ }
632
+ } else {
633
+ shellOnlyPolls = 0
634
+ }
635
+ }
636
+ }
637
+ }
638
+
639
+ if (now >= budgetEnd && now < deadline) {
640
+ const elapsed = Math.round((now - dispatchedAt) / 1000)
641
+ const remaining = Math.round((deadline - now) / 1000)
642
+ return ok(
643
+ `still waiting for ${pendingArtifact} (${elapsed}s elapsed, ${remaining}s before timeout)`,
644
+ {
645
+ pending: true,
646
+ artifact: pendingArtifact,
647
+ elapsed_s: elapsed,
648
+ remaining_s: remaining,
649
+ last_agent_status: lastStatus,
650
+ },
651
+ ["workflow_wait"],
652
+ )
653
+ }
654
+
655
+ if (now >= deadline) {
656
+ // Still working: extend once.
657
+ if (
658
+ !extendedOnce &&
659
+ (lastStatus === "working" || lastStatus === "blocked")
660
+ ) {
661
+ extendedOnce = true
662
+ const extra = Math.max(Math.floor(timeout * 0.5), 120_000)
663
+ state.pending_extended = true
664
+ state.pending_deadline_ms = now + extra
665
+ yield* store.save(state, root)
666
+ hooks.onUpdate?.({
667
+ content: [
668
+ {
669
+ type: "text",
670
+ text: `agent still ${lastStatus} at timeout — extending ${Math.round(extra / 1000)}s once…`,
671
+ },
672
+ ],
673
+ })
674
+ continue
675
+ }
676
+
677
+ // Idle at the deadline: final nudge + short grace.
678
+ //
679
+ // `pending_final_grace` alone makes this one-shot per run — an
680
+ // earlier `!nudged` guard coupled it to the idle rung, so a role
681
+ // nudged early silently lost its grace. The nudge is FORCED: the
682
+ // grace is time for a prompt to land, so an already-nudged role
683
+ // that got the window but not the prompt only delayed its own
684
+ // escalation by three minutes.
685
+ if (
686
+ !finalNudgeGrace &&
687
+ lastStatus === "idle" &&
688
+ state.pending_pane_id
689
+ ) {
690
+ // Grant the whole thing in ONE save, then nudge. A flag
691
+ // persisted apart from the moved deadline is a divisible
692
+ // grant: an abort in between leaves the flag on disk with
693
+ // the old deadline, and the next call skips the rung and
694
+ // times out 180s early. The nudge is best-effort; the
695
+ // grace is not.
696
+ finalNudgeGrace = true
697
+ state.pending_final_grace = true
698
+ state.pending_deadline_ms = now + 180_000
699
+ yield* store.save(state, root)
700
+ yield* tryNudge("timeout idle", true)
701
+ continue
702
+ }
703
+
704
+ break
705
+ }
706
+
707
+ const elapsed = Math.round((now - dispatchedAt) / 1000)
708
+ hooks.onUpdate?.({
709
+ content: [
710
+ {
711
+ type: "text",
712
+ text: `waiting ${elapsed}s for ${pendingArtifact} (agent=${lastStatus})…`,
713
+ },
714
+ ],
715
+ })
716
+
717
+ // Clamp the sleep to the budget. Sleeping a full `poll` past
718
+ // `budgetEnd` is what let a legal `--budget=90000 --poll=60000`
719
+ // block ~120s: the budget is only re-checked at the top of the
720
+ // loop, so the call outlived the host shell timeout the default
721
+ // budget exists to fit inside, and the caller saw a killed
722
+ // command instead of the exit-3 resume instruction.
723
+ // Re-read: `now` predates this iteration's herdr subprocess calls,
724
+ // and clamping from a stale reading overshoots `budgetEnd` by
725
+ // however long they took.
726
+ const afterPolls = yield* Clock.currentTimeMillis
727
+ yield* Effect.sleep(Math.min(poll, Math.max(0, budgetEnd - afterPolls)))
728
+ }
729
+
730
+ state.last_error = `timeout waiting for ${pendingArtifact}`
731
+ yield* store.save(state, root)
732
+ // Report what the role ACTUALLY got, not what config allows. The
733
+ // two differ by every rung that moved the deadline, and only this
734
+ // number answers the question a human asks here — was the timeout
735
+ // too tight? Reporting the configured 900s for a role that ran
736
+ // 1530s on an extension plus a grace invites raising a limit that
737
+ // was never the problem. `timeout` still sizes the extension,
738
+ // because that has to come from config to stay stable across calls.
739
+ const grantedMs =
740
+ (state.pending_deadline_ms ?? dispatchedAt + timeout) - dispatchedAt
741
+ return yield* new WaitTimeout({
742
+ artifact: pendingArtifact,
743
+ timeoutMs: grantedMs,
744
+ details: {
745
+ last_agent_status: lastStatus,
746
+ nudged,
747
+ extended_once: extendedOnce,
748
+ configured_timeout_ms: timeout,
749
+ hint: "inspect pane transcript; re-dispatch same round or nudge via herdr pane run",
750
+ },
751
+ })
752
+ })
753
+
754
+ const abortEffect = (
755
+ signal: AbortSignal,
756
+ artifact: string,
757
+ ): Effect.Effect<never, WaitAborted> =>
758
+ Effect.callback<never, WaitAborted>((resume) => {
759
+ const fail = () => {
760
+ resume(
761
+ Effect.fail(
762
+ new WaitAborted({
763
+ artifact,
764
+ details: {
765
+ last_agent_status: lastStatus,
766
+ hint: "re-dispatch same round or wait again after investigate",
767
+ },
768
+ }),
769
+ ),
770
+ )
771
+ }
772
+ if (signal.aborted) {
773
+ fail()
774
+ return
775
+ }
776
+ signal.addEventListener("abort", fail, { once: true })
777
+ return Effect.sync(() => signal.removeEventListener("abort", fail))
778
+ })
779
+
780
+ const raced: Effect.Effect<ToolResult, AppError> = hooks.signal
781
+ ? Effect.raceFirst(loop, abortEffect(hooks.signal, pendingArtifact))
782
+ : loop
783
+
784
+ const r = yield* Effect.result(raced)
785
+ if (Result.isFailure(r)) {
786
+ if (r.failure._tag === "WaitAborted") {
787
+ state.last_error = "workflow_wait aborted"
788
+ yield* store.save(state, root)
789
+ }
790
+ return yield* r.failure
791
+ }
792
+ return r.success
793
+ })