@naxodev/apnea 0.2.0 → 0.2.2
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 +18 -1
- package/SECURITY.md +36 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8375 -15093
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +15 -3
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +17 -4
- 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/paths.ts +2 -11
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +65 -3
- 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 +58 -16
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +210 -13
- 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 +466 -260
- package/extension/services/operation-lock.ts +452 -0
- package/extension/services/process.ts +477 -0
- package/extension/services/run-store.ts +38 -16
- package/extension/services/vcs.ts +1258 -328
- package/extension/workflows/commit.ts +214 -13
- package/extension/workflows/dispatch.ts +305 -67
- package/extension/workflows/setup.ts +59 -32
- package/extension/workflows/start.ts +6 -4
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +62 -77
- package/package.json +2 -2
- package/schemas/config.schema.json +5 -1
- package/schemas/state.schema.json +165 -11
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as path from "node:path"
|
|
2
2
|
import { Cause, Clock, Effect, Exit, Result } from "effect"
|
|
3
|
+
import { inferKind } from "../domain/artifact-kind.ts"
|
|
4
|
+
import { parseFrontMatter } from "../domain/frontmatter.ts"
|
|
5
|
+
import { looksLikeShellOnly } from "../domain/herdr.ts"
|
|
3
6
|
import {
|
|
7
|
+
abs,
|
|
4
8
|
packageRoot,
|
|
5
9
|
phaseDir,
|
|
6
10
|
planPath,
|
|
@@ -17,27 +21,35 @@ import {
|
|
|
17
21
|
toolAllowed,
|
|
18
22
|
type DispatchKind,
|
|
19
23
|
} from "../domain/state-machine.ts"
|
|
20
|
-
import { timeoutMsForKind } from "../domain/timeouts.ts"
|
|
24
|
+
import { deadlineAfter, timeoutMsForKind } from "../domain/timeouts.ts"
|
|
21
25
|
import {
|
|
26
|
+
ArtifactInvalid,
|
|
22
27
|
GateRefused,
|
|
23
28
|
HerdrError,
|
|
24
29
|
IllegalKind,
|
|
25
30
|
type AppError,
|
|
26
31
|
} from "../errors.ts"
|
|
27
32
|
import type { Role } from "../domain/types.ts"
|
|
28
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
LEGACY_CODE_REWORK,
|
|
35
|
+
LEGACY_PLAN_REWORK,
|
|
36
|
+
ROLE_MODE,
|
|
37
|
+
} from "../domain/types.ts"
|
|
29
38
|
import { ok, type ToolResult } from "../result.ts"
|
|
39
|
+
import { validateArtifactCompletion } from "../schema/frontmatter.ts"
|
|
30
40
|
import { Config } from "../services/config.ts"
|
|
31
41
|
import { FileSystem } from "../services/file-system.ts"
|
|
32
|
-
import { Herdr } from "../services/herdr.ts"
|
|
42
|
+
import { Herdr, type RolePaneRef } from "../services/herdr.ts"
|
|
33
43
|
import { RunStore } from "../services/run-store.ts"
|
|
34
44
|
import { Vcs } from "../services/vcs.ts"
|
|
35
45
|
|
|
36
46
|
export type DispatchParams = {
|
|
37
47
|
kind: DispatchKind
|
|
38
48
|
task_markdown?: string
|
|
39
|
-
/**
|
|
49
|
+
/** @deprecated Assertion only. Persisted state owns rework through 0.2.x. */
|
|
40
50
|
rework?: boolean
|
|
51
|
+
/** Reuse persisted pending ownership after a demonstrably dead delivery. */
|
|
52
|
+
redeliver?: boolean
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
function taskBody(opts: {
|
|
@@ -134,14 +146,14 @@ export const dispatchWorkflow = (
|
|
|
134
146
|
const herdr = yield* Herdr
|
|
135
147
|
|
|
136
148
|
const state = yield* store.require(root)
|
|
137
|
-
const
|
|
149
|
+
const redeliver = params.redeliver === true
|
|
138
150
|
|
|
139
151
|
const allowed = toolAllowed(state.step, "dispatch_role")
|
|
140
152
|
if (Result.isFailure(allowed)) {
|
|
141
153
|
return yield* allowed.failure
|
|
142
154
|
}
|
|
143
155
|
|
|
144
|
-
if (state.pending_artifact != null) {
|
|
156
|
+
if (state.pending_artifact != null && !redeliver) {
|
|
145
157
|
return yield* new GateRefused({
|
|
146
158
|
gate: "dispatch_pending",
|
|
147
159
|
message:
|
|
@@ -153,6 +165,110 @@ export const dispatchWorkflow = (
|
|
|
153
165
|
},
|
|
154
166
|
})
|
|
155
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
|
+
}
|
|
156
272
|
|
|
157
273
|
const kinds = allowedKinds(state.step)
|
|
158
274
|
if (!kinds.includes(params.kind)) {
|
|
@@ -163,40 +279,109 @@ export const dispatchWorkflow = (
|
|
|
163
279
|
})
|
|
164
280
|
}
|
|
165
281
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
})
|
|
185
309
|
}
|
|
310
|
+
const isRework = !redeliver && requiredRework === params.kind
|
|
311
|
+
const stateBeforeDispatch = structuredClone(state)
|
|
186
312
|
|
|
187
|
-
const role = expectedRole(params.kind)
|
|
188
313
|
const cfg = yield* config.load(root)
|
|
189
314
|
const roleTimeoutMs = timeoutMsForKind(params.kind, cfg.timeouts_ms)
|
|
190
315
|
// Validate the orchestrator's current pane before creating task artifacts.
|
|
191
316
|
// A stale inherited environment is equivalent to running outside Herdr;
|
|
192
317
|
// other CLI failures remain typed errors and stop dispatch.
|
|
193
|
-
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
|
+
}
|
|
194
379
|
|
|
195
380
|
// --- Round numbers (increment ONLY on rework after CHANGES_REQUIRED) ---
|
|
196
381
|
let round = 1
|
|
197
382
|
if (params.kind === "plan" || params.kind === "plan_review") {
|
|
198
383
|
const key = roundKey(0, "plan_review")
|
|
199
|
-
if (
|
|
384
|
+
if (isRework && params.kind === "plan") {
|
|
200
385
|
// starting a new plan revision after CHANGES_REQUIRED → next review round
|
|
201
386
|
setRound(state, key, getRound(state, key) + 1)
|
|
202
387
|
} else if (!state.rounds[key]) {
|
|
@@ -209,12 +394,9 @@ export const dispatchWorkflow = (
|
|
|
209
394
|
params.kind === "phase_package"
|
|
210
395
|
) {
|
|
211
396
|
const key = codeReviewRoundKey(state.phase_index)
|
|
212
|
-
if (
|
|
397
|
+
if (isRework && params.kind === "code") {
|
|
213
398
|
setRound(state, key, getRound(state, key) + 1)
|
|
214
|
-
} else if (
|
|
215
|
-
params.kind === "phase_package" &&
|
|
216
|
-
state.phase_package_rework
|
|
217
|
-
) {
|
|
399
|
+
} else if (isRework && params.kind === "phase_package") {
|
|
218
400
|
setRound(state, key, getRound(state, key) + 1)
|
|
219
401
|
} else if (!state.rounds[key]) {
|
|
220
402
|
setRound(state, key, 1)
|
|
@@ -228,6 +410,7 @@ export const dispatchWorkflow = (
|
|
|
228
410
|
? roundKey(0, "plan_review")
|
|
229
411
|
: codeReviewRoundKey(state.phase_index)
|
|
230
412
|
if (
|
|
413
|
+
!redeliver &&
|
|
231
414
|
(params.kind === "plan" ||
|
|
232
415
|
params.kind === "code" ||
|
|
233
416
|
params.kind === "phase_package" ||
|
|
@@ -264,7 +447,7 @@ export const dispatchWorkflow = (
|
|
|
264
447
|
artifactAbs = path.join(d, "phase-package.md")
|
|
265
448
|
extra =
|
|
266
449
|
extra ||
|
|
267
|
-
(state.
|
|
450
|
+
(isRework || (redeliver && state.current_code_review !== null)
|
|
268
451
|
? `Revise the phase ${state.phase_index} package after code review \`${state.current_code_review}\`. Preserve approved-plan scope and address package findings.`
|
|
269
452
|
: `Emit phase package for phase ${state.phase_index} only from approved plan \`${rel(planPath(root), root)}\`.`)
|
|
270
453
|
break
|
|
@@ -304,6 +487,20 @@ export const dispatchWorkflow = (
|
|
|
304
487
|
break
|
|
305
488
|
}
|
|
306
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
|
+
|
|
307
504
|
// Resolve the brief BEFORE clear-before-dispatch. This block can refuse,
|
|
308
505
|
// and the rename below is a mutation: refusing after it left the prior
|
|
309
506
|
// artifact stranded at a timestamped .bak path, so a failed dispatch
|
|
@@ -359,15 +556,14 @@ export const dispatchWorkflow = (
|
|
|
359
556
|
}
|
|
360
557
|
|
|
361
558
|
// clear-before-dispatch
|
|
362
|
-
yield* fs.
|
|
559
|
+
yield* fs.mkdirProject(root, path.dirname(artifactAbs))
|
|
363
560
|
let backupAbs: string | null = null
|
|
364
|
-
if (yield* fs.
|
|
561
|
+
if (yield* fs.projectPathExists(root, artifactAbs)) {
|
|
365
562
|
const backupMillis = yield* Clock.currentTimeMillis
|
|
366
563
|
backupAbs = `${artifactAbs}.bak.${backupMillis}`
|
|
367
|
-
yield* fs.
|
|
564
|
+
yield* fs.renameProjectFile(root, artifactAbs, backupAbs)
|
|
368
565
|
}
|
|
369
566
|
|
|
370
|
-
const artifactRel = rel(artifactAbs, root)
|
|
371
567
|
const body = taskBody({
|
|
372
568
|
kind: params.kind,
|
|
373
569
|
role,
|
|
@@ -382,14 +578,14 @@ export const dispatchWorkflow = (
|
|
|
382
578
|
tasksDir(root),
|
|
383
579
|
`${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
|
|
384
580
|
)
|
|
385
|
-
while (yield* fs.
|
|
581
|
+
while (yield* fs.projectPathExists(root, taskFile)) {
|
|
386
582
|
taskFileMillis += 1
|
|
387
583
|
taskFile = path.join(
|
|
388
584
|
tasksDir(root),
|
|
389
585
|
`${params.kind}-p${state.phase_index}-r${round}-${taskFileMillis}.md`,
|
|
390
586
|
)
|
|
391
587
|
}
|
|
392
|
-
yield* fs.
|
|
588
|
+
yield* fs.writeProjectFile(root, taskFile, body)
|
|
393
589
|
const taskRef = {
|
|
394
590
|
task: rel(taskFile, root),
|
|
395
591
|
artifact: artifactRel,
|
|
@@ -410,19 +606,25 @@ export const dispatchWorkflow = (
|
|
|
410
606
|
const rollbackLaunch = (restoreState = true) =>
|
|
411
607
|
Effect.gen(function* () {
|
|
412
608
|
const errors: string[] = []
|
|
413
|
-
const attempt = (
|
|
609
|
+
const attempt = (
|
|
610
|
+
label: string,
|
|
611
|
+
operation: Effect.Effect<void, unknown>,
|
|
612
|
+
) =>
|
|
414
613
|
Effect.gen(function* () {
|
|
415
614
|
const exit = yield* Effect.exit(operation)
|
|
416
615
|
if (Exit.isFailure(exit)) {
|
|
417
616
|
errors.push(`${label}: ${Cause.pretty(exit.cause)}`)
|
|
418
617
|
}
|
|
419
618
|
})
|
|
420
|
-
yield* attempt("remove task", fs.
|
|
421
|
-
yield* attempt(
|
|
619
|
+
yield* attempt("remove task", fs.removeProjectFile(root, taskFile))
|
|
620
|
+
yield* attempt(
|
|
621
|
+
"remove replacement artifact",
|
|
622
|
+
fs.removeProjectFile(root, artifactAbs),
|
|
623
|
+
)
|
|
422
624
|
if (backupAbs != null) {
|
|
423
625
|
yield* attempt(
|
|
424
626
|
"restore prior artifact",
|
|
425
|
-
fs.
|
|
627
|
+
fs.renameProjectFile(root, backupAbs, artifactAbs),
|
|
426
628
|
)
|
|
427
629
|
}
|
|
428
630
|
if (restoreState) {
|
|
@@ -434,19 +636,26 @@ export const dispatchWorkflow = (
|
|
|
434
636
|
return errors
|
|
435
637
|
})
|
|
436
638
|
|
|
437
|
-
const markPending = (
|
|
639
|
+
const markPending = (
|
|
640
|
+
launchedAt: number,
|
|
641
|
+
delivery: "manual" | "interactive",
|
|
642
|
+
): void => {
|
|
438
643
|
state.pending_artifact = artifactRel
|
|
439
644
|
state.pending_role = role
|
|
645
|
+
state.pending_delivery = delivery
|
|
440
646
|
state.pending_started_at = launchedAt
|
|
441
|
-
state.pending_deadline_ms = launchedAt
|
|
442
|
-
if (
|
|
647
|
+
state.pending_deadline_ms = deadlineAfter(launchedAt, roleTimeoutMs)
|
|
648
|
+
if (isRework) state.required_rework = null
|
|
443
649
|
resetRecoveryLadder(state)
|
|
444
650
|
}
|
|
445
651
|
|
|
446
652
|
// Persist ownership before crossing an external launch boundary. If this
|
|
447
653
|
// process dies after Herdr accepts work, a retry must not start a duplicate.
|
|
448
654
|
const preparedAt = yield* Clock.currentTimeMillis
|
|
449
|
-
markPending(
|
|
655
|
+
markPending(
|
|
656
|
+
preparedAt,
|
|
657
|
+
herdrAvailability === "unavailable" ? "manual" : "interactive",
|
|
658
|
+
)
|
|
450
659
|
state.pending_pane_id = null
|
|
451
660
|
state.pending_pane_label = null
|
|
452
661
|
const preparedState = yield* Effect.exit(store.save(state, root))
|
|
@@ -492,19 +701,53 @@ export const dispatchWorkflow = (
|
|
|
492
701
|
const remembered = state.role_panes[role] ?? null
|
|
493
702
|
const prefer =
|
|
494
703
|
remembered?.profile_fingerprint === profileFingerprint ? remembered : null
|
|
704
|
+
const recordPaneOwnership = (pane: RolePaneRef) => {
|
|
705
|
+
state.pending_pane_id = pane.pane_id
|
|
706
|
+
state.pending_pane_label = pane.label
|
|
707
|
+
state.role_panes[role] = {
|
|
708
|
+
pane_id: pane.pane_id,
|
|
709
|
+
label: pane.label,
|
|
710
|
+
profile_fingerprint: profileFingerprint,
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
let acquisitionRollbackErrors: string[] | undefined
|
|
495
714
|
const launched = yield* Effect.result(
|
|
496
|
-
herdr.runInteractivePrompt(
|
|
715
|
+
herdr.runInteractivePrompt(
|
|
716
|
+
role,
|
|
717
|
+
cmd,
|
|
718
|
+
prompt,
|
|
719
|
+
prefer,
|
|
720
|
+
(pane) =>
|
|
721
|
+
Effect.gen(function* () {
|
|
722
|
+
recordPaneOwnership(pane)
|
|
723
|
+
const persisted = yield* Effect.exit(store.save(state, root))
|
|
724
|
+
if (Exit.isFailure(persisted)) {
|
|
725
|
+
// Restore proven non-delivery while the adapter still masks
|
|
726
|
+
// cancellation. A pending interruption can hide the typed failure.
|
|
727
|
+
acquisitionRollbackErrors = yield* rollbackLaunch()
|
|
728
|
+
return yield* new HerdrError({
|
|
729
|
+
message: `failed to persist pane ownership before delivery: ${Cause.pretty(persisted.cause)}`,
|
|
730
|
+
})
|
|
731
|
+
}
|
|
732
|
+
}),
|
|
733
|
+
() =>
|
|
734
|
+
Effect.gen(function* () {
|
|
735
|
+
acquisitionRollbackErrors = yield* rollbackLaunch()
|
|
736
|
+
}),
|
|
737
|
+
),
|
|
497
738
|
)
|
|
498
739
|
if (Result.isFailure(launched)) {
|
|
499
740
|
if (launched.failure.details?.delivery === "unknown") {
|
|
500
|
-
const paneId =
|
|
501
|
-
const paneLabel =
|
|
502
|
-
|
|
503
|
-
state
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
741
|
+
const paneId = launched.failure.details.pane_id
|
|
742
|
+
const paneLabel = launched.failure.details.pane_label
|
|
743
|
+
// Only a pane-backed save actually preserves pending ownership; a
|
|
744
|
+
// split failure never reached the state machine, so claiming
|
|
745
|
+
// `pending_preserved` here would send operators to redeliver against
|
|
746
|
+
// ownership that was never written.
|
|
747
|
+
const preserved =
|
|
748
|
+
typeof paneId === "string" && typeof paneLabel === "string"
|
|
749
|
+
if (preserved) {
|
|
750
|
+
recordPaneOwnership({ pane_id: paneId, label: paneLabel })
|
|
508
751
|
}
|
|
509
752
|
yield* store.save(state, root)
|
|
510
753
|
return yield* new HerdrError({
|
|
@@ -516,11 +759,12 @@ export const dispatchWorkflow = (
|
|
|
516
759
|
...(launched.failure.details ?? {}),
|
|
517
760
|
task_attempted: taskRef.task,
|
|
518
761
|
artifact: artifactRel,
|
|
519
|
-
pending_preserved:
|
|
762
|
+
pending_preserved: preserved,
|
|
520
763
|
},
|
|
521
764
|
})
|
|
522
765
|
}
|
|
523
|
-
const rollbackErrors =
|
|
766
|
+
const rollbackErrors =
|
|
767
|
+
acquisitionRollbackErrors ?? (yield* rollbackLaunch())
|
|
524
768
|
return yield* herdrAfterRollback(
|
|
525
769
|
launched.failure,
|
|
526
770
|
{
|
|
@@ -542,20 +786,14 @@ export const dispatchWorkflow = (
|
|
|
542
786
|
prompt_attempts: r.prompt_attempts,
|
|
543
787
|
last_status: r.last_status ?? null,
|
|
544
788
|
}
|
|
545
|
-
|
|
546
|
-
state.pending_pane_label = r.label
|
|
547
|
-
state.role_panes[role] = {
|
|
548
|
-
pane_id: r.pane_id,
|
|
549
|
-
label: r.label,
|
|
550
|
-
profile_fingerprint: profileFingerprint,
|
|
551
|
-
}
|
|
789
|
+
recordPaneOwnership(r)
|
|
552
790
|
|
|
553
791
|
// After the launch, not before it: `runInteractivePrompt` blocks in
|
|
554
792
|
// `waitAgentReady` (up to 90s) plus prompt-submit retries. Anchoring at
|
|
555
793
|
// the top of the workflow charged that startup against the role's own
|
|
556
794
|
// deadline and burned wait's 12s liveness grace before the first poll.
|
|
557
795
|
const launchedAt = yield* Clock.currentTimeMillis
|
|
558
|
-
markPending(launchedAt)
|
|
796
|
+
markPending(launchedAt, "interactive")
|
|
559
797
|
yield* store.save(state, root)
|
|
560
798
|
|
|
561
799
|
return ok(
|