@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.
Files changed (51) hide show
  1. package/README.md +25 -10
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8571 -15324
  5. package/docs/adr/0005-harness-profiles.md +1 -1
  6. package/docs/adr/0010-package-split.md +1 -1
  7. package/docs/protocol/artifacts.md +18 -2
  8. package/docs/protocol/config.md +22 -25
  9. package/docs/protocol/manual-gate.md +8 -8
  10. package/docs/protocol/overview.md +18 -5
  11. package/extension/adapters/commit.ts +5 -1
  12. package/extension/adapters/dispatch.ts +9 -1
  13. package/extension/adapters/setup.ts +15 -1
  14. package/extension/adapters/start.ts +5 -1
  15. package/extension/adapters/status.ts +17 -2
  16. package/extension/adapters/wait.ts +6 -1
  17. package/extension/api.ts +7 -1
  18. package/extension/cli/main.ts +67 -7
  19. package/extension/cli/parse.ts +172 -5
  20. package/extension/domain/herdr.ts +0 -86
  21. package/extension/domain/paths.ts +3 -13
  22. package/extension/domain/setup.ts +0 -20
  23. package/extension/domain/timeouts.ts +4 -0
  24. package/extension/domain/types.ts +64 -11
  25. package/extension/domain/verify-commands.ts +200 -108
  26. package/extension/errors.ts +51 -16
  27. package/extension/operation-hooks.ts +6 -0
  28. package/extension/registry.ts +29 -15
  29. package/extension/run-tool.ts +19 -2
  30. package/extension/schema/config.ts +86 -30
  31. package/extension/schema/frontmatter.ts +57 -0
  32. package/extension/schema/state.ts +226 -16
  33. package/extension/services/app-live.ts +2 -1
  34. package/extension/services/config.ts +6 -4
  35. package/extension/services/file-system.ts +346 -75
  36. package/extension/services/herdr.ts +393 -402
  37. package/extension/services/operation-lock.ts +418 -0
  38. package/extension/services/process.ts +477 -0
  39. package/extension/services/run-store.ts +38 -16
  40. package/extension/services/vcs.ts +1388 -86
  41. package/extension/workflows/commit.ts +222 -18
  42. package/extension/workflows/dispatch.ts +320 -220
  43. package/extension/workflows/setup.ts +61 -141
  44. package/extension/workflows/start.ts +6 -5
  45. package/extension/workflows/status.ts +2 -2
  46. package/extension/workflows/wait.ts +63 -134
  47. package/package.json +2 -3
  48. package/schemas/config.schema.json +11 -7
  49. package/schemas/state.schema.json +170 -12
  50. package/herdr-plugin/herdr-plugin.toml +0 -15
  51. package/herdr-plugin/scripts/run-task.sh +0 -8
@@ -3,7 +3,10 @@ import { Effect, Result } from "effect"
3
3
  import { asVerdict, parseFrontMatter } from "../domain/frontmatter.ts"
4
4
  import { abs, phaseDir, rel } from "../domain/paths.ts"
5
5
  import { nextAfter, toolAllowed } from "../domain/state-machine.ts"
6
- import { extractVerifyCommands } from "../domain/verify-commands.ts"
6
+ import {
7
+ extractVerifyBlocks,
8
+ formatVerifyCommand,
9
+ } from "../domain/verify-commands.ts"
7
10
  import {
8
11
  ArtifactInvalid,
9
12
  GateRefused,
@@ -13,8 +16,14 @@ import {
13
16
  import { ok, type ToolResult } from "../result.ts"
14
17
  import { Config } from "../services/config.ts"
15
18
  import { FileSystem } from "../services/file-system.ts"
16
- import { RunStore } from "../services/run-store.ts"
17
- import { Vcs } from "../services/vcs.ts"
19
+ import { RunStore, type RunStoreService } from "../services/run-store.ts"
20
+ import {
21
+ Vcs,
22
+ type VcsService,
23
+ withTransactionTrailer,
24
+ withoutTransactionTrailer,
25
+ } from "../services/vcs.ts"
26
+ import type { RunState } from "../domain/types.ts"
18
27
 
19
28
  export type CommitParams = {
20
29
  message?: string
@@ -25,6 +34,18 @@ export type CommitParams = {
25
34
  /**
26
35
  * Require APPROVED code review, run phase package verify commands,
27
36
  * jj/git commit, advance phase. Refusals are tagged failures only.
37
+ *
38
+ * The commit itself is a crash-recoverable transaction:
39
+ *
40
+ * gates → verify.log → prepare → save pending_commit (durable point)
41
+ * → complete or recognize → bookmark (jj, final phase) → advance + clear
42
+ *
43
+ * Once `pending_commit` is saved, cancellation does NOT undo the
44
+ * transaction — the prepared anchor is durable and a later commit call
45
+ * resumes or recognizes it. A call that loads an existing `pending_commit`
46
+ * skips the gates and verification entirely and resumes only that
47
+ * transaction; retry params conflicting with the persisted message or
48
+ * `--done` value are refused, not silently ignored.
28
49
  */
29
50
  export const commitWorkflow = (
30
51
  params: CommitParams,
@@ -43,6 +64,16 @@ export const commitWorkflow = (
43
64
  return yield* allowed.failure
44
65
  }
45
66
 
67
+ if (state.pending_commit) {
68
+ return yield* resumePendingCommit({
69
+ params,
70
+ root,
71
+ state,
72
+ store,
73
+ vcs,
74
+ })
75
+ }
76
+
46
77
  const reviewRel = state.current_code_review
47
78
  if (!reviewRel) {
48
79
  return yield* new GateRefused({
@@ -52,10 +83,18 @@ export const commitWorkflow = (
52
83
  }
53
84
 
54
85
  const reviewAbs = abs(reviewRel, root)
55
- const reviewPresent = yield* fs.exists(reviewAbs)
86
+ const reviewPresent = yield* fs.projectPathExists(root, reviewAbs)
56
87
  let fm: ReturnType<typeof parseFrontMatter> = null
57
88
  if (reviewPresent) {
58
- const reviewText = yield* fs.readFile(reviewAbs)
89
+ const reviewText = yield* fs.readProjectFile(root, reviewAbs).pipe(
90
+ Effect.mapError(
91
+ (error) =>
92
+ new ArtifactInvalid({
93
+ artifact: reviewRel,
94
+ message: error.message,
95
+ }),
96
+ ),
97
+ )
59
98
  fm = parseFrontMatter(reviewText)
60
99
  }
61
100
  const verdict = asVerdict(fm?.verdict)
@@ -77,7 +116,7 @@ export const commitWorkflow = (
77
116
  root,
78
117
  )
79
118
  const pkgAbs = abs(pkgRel, root)
80
- const pkgPresent = yield* fs.exists(pkgAbs)
119
+ const pkgPresent = yield* fs.projectPathExists(root, pkgAbs)
81
120
  if (!pkgPresent) {
82
121
  return yield* new GateRefused({
83
122
  gate: "commit",
@@ -85,9 +124,16 @@ export const commitWorkflow = (
85
124
  details: { package: pkgRel },
86
125
  })
87
126
  }
88
- const pkgText = yield* fs.readFile(pkgAbs)
89
- const cmds = extractVerifyCommands(pkgText)
90
- if (!cmds.length) {
127
+ const pkgText = yield* fs
128
+ .readProjectFile(root, pkgAbs)
129
+ .pipe(
130
+ Effect.mapError(
131
+ (error) =>
132
+ new ArtifactInvalid({ artifact: pkgRel, message: error.message }),
133
+ ),
134
+ )
135
+ const blocks = extractVerifyBlocks(pkgText)
136
+ if (!blocks.length) {
91
137
  return yield* new ArtifactInvalid({
92
138
  artifact: pkgRel,
93
139
  message: "no verify commands found in phase package (need ```sh block)",
@@ -96,15 +142,14 @@ export const commitWorkflow = (
96
142
 
97
143
  const cfg = yield* config.load(root)
98
144
  const verifyTimeout = cfg.timeouts_ms.verify ?? 900_000
99
- const verify = yield* vcs.runVerify(root, cmds, verifyTimeout)
145
+ const verify = yield* vcs.runVerify(root, blocks, verifyTimeout)
100
146
 
101
147
  const vlog = path.join(path.dirname(reviewAbs), "verify.log")
102
- yield* fs.mkdir(path.dirname(vlog), { recursive: true })
103
- yield* fs.writeFile(vlog, `${verify.log}\n`)
148
+ yield* fs.writeProjectFile(root, vlog, `${verify.log}\n`)
104
149
 
105
150
  if (!verify.ok) {
106
151
  return yield* new VerifyFailed({
107
- commands: cmds,
152
+ commands: blocks.map(formatVerifyCommand),
108
153
  outputs: [verify.log.slice(-2000)],
109
154
  // `outputs` is a tail — point the caller at the full log on disk.
110
155
  verify_log: rel(vlog, root),
@@ -115,9 +160,162 @@ export const commitWorkflow = (
115
160
  params.message?.trim() ||
116
161
  `feat: apnea phase ${state.phase_index} (${state.slug})`
117
162
 
118
- const detail = yield* vcs.commitPhase(root, state.vcs, message)
163
+ const prepared = yield* vcs.prepareCommit(root, state.vcs, message)
119
164
 
120
- if (params.no_remaining_phases) {
165
+ // Durable point. From here the transaction survives crashes and
166
+ // cancellation: the anchor below is everything completion needs to
167
+ // recognize or create the commit exactly once.
168
+ const noRemainingPhases = params.no_remaining_phases === true
169
+ const verifyLogRel = rel(vlog, root)
170
+ state.pending_commit =
171
+ prepared.backend === "git"
172
+ ? {
173
+ id: prepared.id,
174
+ backend: prepared.backend,
175
+ phase_index: state.phase_index,
176
+ message: prepared.message,
177
+ no_remaining_phases: noRemainingPhases,
178
+ verify_log: verifyLogRel,
179
+ branch: prepared.branch,
180
+ parent_commit: prepared.parent_commit,
181
+ tree_id: prepared.tree_id,
182
+ }
183
+ : {
184
+ id: prepared.id,
185
+ backend: prepared.backend,
186
+ phase_index: state.phase_index,
187
+ message: prepared.message,
188
+ no_remaining_phases: noRemainingPhases,
189
+ verify_log: verifyLogRel,
190
+ change_id: prepared.change_id,
191
+ content_fingerprint: prepared.content_fingerprint,
192
+ }
193
+ yield* store.save(state, root)
194
+
195
+ const committedId = yield* vcs.completeCommit(
196
+ root,
197
+ state.vcs,
198
+ state.pending_commit,
199
+ )
200
+
201
+ return yield* advanceAndSave({
202
+ state,
203
+ store,
204
+ vcs,
205
+ root,
206
+ noRemainingPhases,
207
+ committedId,
208
+ recovered: false,
209
+ transactionId: state.pending_commit.id,
210
+ verifyLog: verifyLogRel,
211
+ })
212
+ })
213
+
214
+ type ResumeArgs = {
215
+ params: CommitParams
216
+ root: string
217
+ state: RunState
218
+ store: RunStoreService
219
+ vcs: VcsService
220
+ }
221
+
222
+ /**
223
+ * Resume the durable transaction only: no gates, no verification.
224
+ * Explicit retry params conflicting with the persisted values are refused.
225
+ */
226
+ function resumePendingCommit({
227
+ params,
228
+ root,
229
+ state,
230
+ store,
231
+ vcs,
232
+ }: ResumeArgs): Effect.Effect<ToolResult, AppError> {
233
+ return Effect.gen(function* () {
234
+ const pending = state.pending_commit!
235
+ const requestedMessage = params.message?.trim()
236
+ // Compare against the exact trailer-augmented form the transaction will
237
+ // write, not a stripped persisted message: stripping only removes the
238
+ // LAST trailer line, so a user message that legitimately ends with a
239
+ // trailer-shaped line would otherwise refuse an identical plain retry.
240
+ if (
241
+ requestedMessage !== undefined &&
242
+ requestedMessage !== pending.message &&
243
+ withTransactionTrailer(requestedMessage, pending.id) !== pending.message
244
+ ) {
245
+ return yield* new GateRefused({
246
+ gate: "commit",
247
+ message:
248
+ "conflicting retry: this call already has a durable commit transaction with a different message",
249
+ details: {
250
+ transaction: pending.id,
251
+ persisted_message: withoutTransactionTrailer(pending.message),
252
+ requested_message: requestedMessage,
253
+ },
254
+ })
255
+ }
256
+ if (
257
+ params.no_remaining_phases !== undefined &&
258
+ params.no_remaining_phases !== pending.no_remaining_phases
259
+ ) {
260
+ return yield* new GateRefused({
261
+ gate: "commit",
262
+ message:
263
+ "conflicting retry: this call already has a durable commit transaction with a different no_remaining_phases value",
264
+ details: {
265
+ transaction: pending.id,
266
+ persisted_no_remaining_phases: pending.no_remaining_phases,
267
+ requested_no_remaining_phases: params.no_remaining_phases,
268
+ },
269
+ })
270
+ }
271
+
272
+ const committedId = yield* vcs.completeCommit(root, state.vcs, pending)
273
+
274
+ return yield* advanceAndSave({
275
+ state,
276
+ store,
277
+ vcs,
278
+ root,
279
+ noRemainingPhases: pending.no_remaining_phases,
280
+ committedId,
281
+ recovered: true,
282
+ transactionId: pending.id,
283
+ verifyLog: pending.verify_log,
284
+ })
285
+ })
286
+ }
287
+
288
+ type AdvanceArgs = {
289
+ state: RunState
290
+ store: RunStoreService
291
+ vcs: VcsService
292
+ root: string
293
+ noRemainingPhases: boolean
294
+ committedId: string
295
+ recovered: boolean
296
+ transactionId: string
297
+ /** Repo-relative verify.log path captured before the phase advanced. */
298
+ verifyLog: string
299
+ }
300
+
301
+ /**
302
+ * Advance the run after completion. A bookmark failure propagates BEFORE any
303
+ * state mutation is saved, so `pending_commit` stays durable and the
304
+ * transaction remains resumable on the next call.
305
+ */
306
+ function advanceAndSave({
307
+ state,
308
+ store,
309
+ vcs,
310
+ root,
311
+ noRemainingPhases,
312
+ committedId,
313
+ recovered,
314
+ transactionId,
315
+ verifyLog,
316
+ }: AdvanceArgs): Effect.Effect<ToolResult, AppError> {
317
+ return Effect.gen(function* () {
318
+ if (noRemainingPhases) {
121
319
  state.step = "finishing"
122
320
  if (state.vcs === "jj") {
123
321
  yield* vcs.setBookmarkAtTerminus(root, state.slug)
@@ -129,13 +327,18 @@ export const commitWorkflow = (
129
327
  state.current_code_review = null
130
328
  }
131
329
  state.last_error = null
330
+ state.pending_commit = null
132
331
  yield* store.save(state, root)
133
332
 
333
+ const prefix = recovered
334
+ ? `committed phase (recovered transaction ${transactionId})`
335
+ : "committed phase"
134
336
  return ok(
135
- `committed phase; step → ${state.step}`,
337
+ `${prefix}; step → ${state.step}`,
136
338
  {
137
- vcs_detail: detail,
138
- verify_log: rel(vlog, root),
339
+ vcs_detail: `${state.vcs} commit ${committedId}`,
340
+ transaction: transactionId,
341
+ verify_log: verifyLog,
139
342
  step: state.step,
140
343
  phase_index: state.phase_index,
141
344
  next:
@@ -146,3 +349,4 @@ export const commitWorkflow = (
146
349
  nextAfter(state.step),
147
350
  )
148
351
  })
352
+ }