@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,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
|
+
})
|