@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.
- package/CONTEXT.md +61 -0
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/SECURITY.md +35 -0
- package/briefs/coder.md +40 -0
- package/briefs/orchestrator.md +49 -0
- package/briefs/planner.md +54 -0
- package/briefs/reviewer.md +40 -0
- package/dist/cli.js +39397 -0
- package/docs/adr/0001-completion-signaling.md +3 -0
- package/docs/adr/0002-orchestrator-authority.md +3 -0
- package/docs/adr/0003-verify-at-gate.md +3 -0
- package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
- package/docs/adr/0005-harness-profiles.md +5 -0
- package/docs/adr/0006-config-trust-model.md +3 -0
- package/docs/adr/0007-jj-first-commits.md +3 -0
- package/docs/adr/0008-effect-v4-internals.md +3 -0
- package/docs/adr/0009-cli-driver-split.md +9 -0
- package/docs/adr/0010-package-split.md +23 -0
- package/docs/protocol/artifacts.md +68 -0
- package/docs/protocol/config.md +186 -0
- package/docs/protocol/manual-gate.md +38 -0
- package/docs/protocol/overview.md +96 -0
- package/extension/adapters/commit.ts +15 -0
- package/extension/adapters/dispatch.ts +15 -0
- package/extension/adapters/setup.ts +34 -0
- package/extension/adapters/start.ts +16 -0
- package/extension/adapters/status.ts +24 -0
- package/extension/adapters/wait.ts +20 -0
- package/extension/api.ts +16 -0
- package/extension/cli/format.ts +44 -0
- package/extension/cli/human-gate.ts +44 -0
- package/extension/cli/main.ts +218 -0
- package/extension/cli/parse.ts +48 -0
- package/extension/domain/artifact-kind.ts +26 -0
- package/extension/domain/frontmatter.ts +69 -0
- package/extension/domain/herdr.ts +109 -0
- package/extension/domain/paths.ts +139 -0
- package/extension/domain/recovery.ts +25 -0
- package/extension/domain/rounds.ts +16 -0
- package/extension/domain/setup.ts +158 -0
- package/extension/domain/slug.ts +9 -0
- package/extension/domain/state-machine.ts +132 -0
- package/extension/domain/timeouts.ts +24 -0
- package/extension/domain/types.ts +145 -0
- package/extension/domain/verify-commands.ts +128 -0
- package/extension/errors.ts +247 -0
- package/extension/host-adapter.ts +8 -0
- package/extension/registry.ts +323 -0
- package/extension/result.ts +55 -0
- package/extension/run-tool.ts +43 -0
- package/extension/schema/config.ts +315 -0
- package/extension/schema/frontmatter.ts +34 -0
- package/extension/schema/state.ts +119 -0
- package/extension/services/app-live.ts +24 -0
- package/extension/services/config.ts +103 -0
- package/extension/services/file-system.ts +178 -0
- package/extension/services/herdr.ts +860 -0
- package/extension/services/run-store.ts +99 -0
- package/extension/services/vcs.ts +246 -0
- package/extension/workflows/commit.ts +148 -0
- package/extension/workflows/dispatch.ts +693 -0
- package/extension/workflows/reset.ts +26 -0
- package/extension/workflows/setup.ts +301 -0
- package/extension/workflows/start.ts +149 -0
- package/extension/workflows/status.ts +45 -0
- package/extension/workflows/wait.ts +793 -0
- package/herdr-plugin/herdr-plugin.toml +15 -0
- package/herdr-plugin/scripts/run-task.sh +8 -0
- package/package.json +75 -0
- package/schemas/artifact-frontmatter.md +38 -0
- package/schemas/config.schema.json +50 -0
- 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
|
+
})
|