@naxodev/apnea 0.2.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 +18 -1
- package/SECURITY.md +32 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8283 -15058
- 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 +389 -242
- 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 +1258 -328
- package/extension/workflows/commit.ts +214 -13
- package/extension/workflows/dispatch.ts +274 -57
- 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,16 +21,22 @@ 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
42
|
import { Herdr } from "../services/herdr.ts"
|
|
@@ -36,8 +46,10 @@ import { Vcs } from "../services/vcs.ts"
|
|
|
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
|
+
})
|
|
185
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
|
+
})
|
|
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))
|
|
@@ -497,14 +706,22 @@ export const dispatchWorkflow = (
|
|
|
497
706
|
)
|
|
498
707
|
if (Result.isFailure(launched)) {
|
|
499
708
|
if (launched.failure.details?.delivery === "unknown") {
|
|
500
|
-
const paneId =
|
|
501
|
-
const paneLabel =
|
|
502
|
-
|
|
503
|
-
state
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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) {
|
|
718
|
+
state.pending_pane_id = paneId
|
|
719
|
+
state.pending_pane_label = paneLabel
|
|
720
|
+
state.role_panes[role] = {
|
|
721
|
+
pane_id: paneId,
|
|
722
|
+
label: paneLabel,
|
|
723
|
+
profile_fingerprint: profileFingerprint,
|
|
724
|
+
}
|
|
508
725
|
}
|
|
509
726
|
yield* store.save(state, root)
|
|
510
727
|
return yield* new HerdrError({
|
|
@@ -516,7 +733,7 @@ export const dispatchWorkflow = (
|
|
|
516
733
|
...(launched.failure.details ?? {}),
|
|
517
734
|
task_attempted: taskRef.task,
|
|
518
735
|
artifact: artifactRel,
|
|
519
|
-
pending_preserved:
|
|
736
|
+
pending_preserved: preserved,
|
|
520
737
|
},
|
|
521
738
|
})
|
|
522
739
|
}
|
|
@@ -555,7 +772,7 @@ export const dispatchWorkflow = (
|
|
|
555
772
|
// the top of the workflow charged that startup against the role's own
|
|
556
773
|
// deadline and burned wait's 12s liveness grace before the first poll.
|
|
557
774
|
const launchedAt = yield* Clock.currentTimeMillis
|
|
558
|
-
markPending(launchedAt)
|
|
775
|
+
markPending(launchedAt, "interactive")
|
|
559
776
|
yield* store.save(state, root)
|
|
560
777
|
|
|
561
778
|
return ok(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path"
|
|
2
|
+
import * as os from "node:os"
|
|
2
3
|
import { Effect, Result } from "effect"
|
|
3
4
|
import { globalConfigPath, projectConfigPath } from "../domain/paths.ts"
|
|
4
5
|
import {
|
|
@@ -9,7 +10,7 @@ import {
|
|
|
9
10
|
} from "../domain/setup.ts"
|
|
10
11
|
import { ConfigError, type AppError } from "../errors.ts"
|
|
11
12
|
import { ok, type ToolResult } from "../result.ts"
|
|
12
|
-
import { decodeGlobalConfig } from "../schema/config.ts"
|
|
13
|
+
import { decodeGlobalConfig, decodeProjectConfig } from "../schema/config.ts"
|
|
13
14
|
import { FileSystem } from "../services/file-system.ts"
|
|
14
15
|
|
|
15
16
|
export type SetupParams = {
|
|
@@ -70,6 +71,8 @@ export type SetupDeps = {
|
|
|
70
71
|
onPath: (bin: string) => boolean
|
|
71
72
|
/** Prepare host-specific role resources, or return null when none are needed. */
|
|
72
73
|
materializeRoleAgentDir: () => string | null
|
|
74
|
+
/** Trusted account home. Production uses node:os.homedir(). */
|
|
75
|
+
trustedHome?: () => string
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -84,24 +87,6 @@ export const setupWorkflow = (
|
|
|
84
87
|
Effect.gen(function* () {
|
|
85
88
|
const fs = yield* FileSystem
|
|
86
89
|
|
|
87
|
-
const readJsonSafe = (
|
|
88
|
-
filePath: string,
|
|
89
|
-
): Effect.Effect<Record<string, unknown>> =>
|
|
90
|
-
Effect.gen(function* () {
|
|
91
|
-
const present = yield* fs.exists(filePath)
|
|
92
|
-
if (!present) return {}
|
|
93
|
-
const text = yield* fs.readFile(filePath)
|
|
94
|
-
try {
|
|
95
|
-
const v = JSON.parse(text)
|
|
96
|
-
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
97
|
-
return v as Record<string, unknown>
|
|
98
|
-
}
|
|
99
|
-
return {}
|
|
100
|
-
} catch {
|
|
101
|
-
return {}
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
|
|
105
90
|
const has: Detected = {
|
|
106
91
|
pi: deps.onPath("pi"),
|
|
107
92
|
claude: deps.onPath("claude"),
|
|
@@ -118,27 +103,64 @@ export const setupWorkflow = (
|
|
|
118
103
|
})
|
|
119
104
|
}
|
|
120
105
|
|
|
121
|
-
const
|
|
122
|
-
|
|
106
|
+
const trustedHome = deps.trustedHome?.() ?? os.homedir()
|
|
107
|
+
const gPath = globalConfigPath(trustedHome)
|
|
123
108
|
|
|
124
|
-
const prev = yield* readJsonSafe(gPath)
|
|
125
109
|
const force = params.force === true
|
|
110
|
+
let replacedMalformedGlobal = false
|
|
111
|
+
let prev: Record<string, unknown> = {}
|
|
112
|
+
if (yield* fs.exists(gPath)) {
|
|
113
|
+
const text = yield* fs.readTrustedGlobalFile(trustedHome, gPath)
|
|
114
|
+
try {
|
|
115
|
+
const parsed: unknown = JSON.parse(text)
|
|
116
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
117
|
+
throw new Error("global config must be a JSON object")
|
|
118
|
+
}
|
|
119
|
+
prev = parsed as Record<string, unknown>
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!force) {
|
|
122
|
+
return yield* new ConfigError({
|
|
123
|
+
message: `existing global config is malformed; refusing to replace it without --force: ${error instanceof Error ? error.message : String(error)}`,
|
|
124
|
+
path: gPath,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
replacedMalformedGlobal = true
|
|
128
|
+
}
|
|
129
|
+
}
|
|
126
130
|
const globalConfig = buildGlobalConfig({ has, prev, force })
|
|
127
131
|
|
|
128
132
|
const serialized = `${JSON.stringify(globalConfig, null, 2)}\n`
|
|
129
|
-
yield* fs.writeFile(gPath, serialized)
|
|
130
133
|
|
|
131
134
|
let projectPath: string | null = null
|
|
132
135
|
if (params.project) {
|
|
133
136
|
const pPath = projectConfigPath(root)
|
|
137
|
+
if (yield* fs.projectPathExists(root, pPath)) {
|
|
138
|
+
const text = yield* fs.readProjectFile(root, pPath)
|
|
139
|
+
let parsed: unknown
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(text)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return yield* new ConfigError({
|
|
144
|
+
message: `invalid JSON at ${pPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
145
|
+
path: pPath,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
const decoded = decodeProjectConfig(parsed)
|
|
149
|
+
if (Result.isFailure(decoded)) return yield* decoded.failure
|
|
150
|
+
}
|
|
151
|
+
projectPath = pPath
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
yield* fs.writeTrustedGlobalFile(trustedHome, gPath, serialized)
|
|
155
|
+
|
|
156
|
+
if (projectPath) {
|
|
134
157
|
// project: roles only — never cmd
|
|
135
158
|
const projectCfg = { roles: pickRoles(has) }
|
|
136
159
|
yield* fs.writeProjectFile(
|
|
137
160
|
root,
|
|
138
|
-
|
|
161
|
+
projectPath,
|
|
139
162
|
`${JSON.stringify(projectCfg, null, 2)}\n`,
|
|
140
163
|
)
|
|
141
|
-
projectPath = pPath
|
|
142
164
|
}
|
|
143
165
|
|
|
144
166
|
const missing = detectionNotes(has)
|
|
@@ -162,15 +184,14 @@ export const setupWorkflow = (
|
|
|
162
184
|
let agentsMdPath: string | null = null
|
|
163
185
|
if (params.agents_md) {
|
|
164
186
|
const target = path.join(root, "AGENTS.md")
|
|
165
|
-
const present = yield* fs.
|
|
166
|
-
const existing = present ? yield* fs.
|
|
167
|
-
yield* fs.
|
|
187
|
+
const present = yield* fs.projectPathExists(root, target)
|
|
188
|
+
const existing = present ? yield* fs.readProjectFile(root, target) : null
|
|
189
|
+
yield* fs.writeProjectFile(root, target, mergeAgentsMd(existing))
|
|
168
190
|
agentsMdPath = target
|
|
169
191
|
}
|
|
170
192
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// actionable note, never a hard refusal where today there is none.
|
|
193
|
+
// Generated defaults can still inherit malformed profile or role shapes
|
|
194
|
+
// from valid JSON. Report those separately from JSON parse refusals.
|
|
174
195
|
const decoded = decodeGlobalConfig(globalConfig)
|
|
175
196
|
if (Result.isFailure(decoded)) {
|
|
176
197
|
missing.push(
|
|
@@ -190,5 +211,11 @@ export const setupWorkflow = (
|
|
|
190
211
|
if (params.agents_md) {
|
|
191
212
|
data.agents_md = agentsMdPath
|
|
192
213
|
}
|
|
193
|
-
|
|
214
|
+
if (replacedMalformedGlobal) data.replaced_malformed_global = true
|
|
215
|
+
return ok(
|
|
216
|
+
replacedMalformedGlobal
|
|
217
|
+
? `replaced malformed global config ${gPath}`
|
|
218
|
+
: `wrote global config ${gPath}`,
|
|
219
|
+
data,
|
|
220
|
+
)
|
|
194
221
|
})
|