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