@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.
Files changed (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +466 -260
  32. package/extension/services/operation-lock.ts +452 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +305 -67
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -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 gPath = globalConfigPath()
122
- yield* fs.mkdir(path.dirname(gPath), { recursive: true })
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
- pPath,
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.exists(target)
166
- const existing = present ? yield* fs.readFile(target) : null
167
- yield* fs.writeFile(target, mergeAgentsMd(existing))
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
- // A failed decode after writing is a note, not a failure — a user with a
172
- // malformed pre-existing config must still get a written config and an
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
- return ok(`wrote global config ${gPath}`, data)
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
  })
@@ -47,7 +47,7 @@ export const startWorkflow = (
47
47
  let pendingStatus: string = "none"
48
48
  if (pending) {
49
49
  const absPath = pending.startsWith("/") ? pending : `${root}/${pending}`
50
- const present = yield* fs.exists(absPath)
50
+ const present = yield* fs.projectPathExists(root, absPath)
51
51
  pendingStatus = present ? "artifact_exists" : "artifact_missing"
52
52
  }
53
53
  return ok(
@@ -59,7 +59,7 @@ export const startWorkflow = (
59
59
  pendingStatus === "artifact_exists"
60
60
  ? "call workflow_wait to ingest pending artifact"
61
61
  : pendingStatus === "artifact_missing"
62
- ? "offer re-dispatch same round via dispatch_role"
62
+ ? "offer same-round dispatch_role redeliver=true after proving the prior delivery is dead"
63
63
  : "inspect workflow_status and continue legal next step",
64
64
  },
65
65
  nextAfter(existing.step),
@@ -100,7 +100,7 @@ export const startWorkflow = (
100
100
  }
101
101
 
102
102
  const state: RunState = {
103
- version: 1,
103
+ version: 2,
104
104
  slug,
105
105
  step: "planning",
106
106
  phase_index: 1,
@@ -112,6 +112,7 @@ export const startWorkflow = (
112
112
  last_error: null,
113
113
  pending_artifact: null,
114
114
  pending_role: null,
115
+ pending_delivery: null,
115
116
  pending_pane_id: null,
116
117
  pending_pane_label: null,
117
118
  pending_started_at: null,
@@ -124,7 +125,8 @@ export const startWorkflow = (
124
125
  reviewer_tree_fingerprint: null,
125
126
  current_phase_package: null,
126
127
  current_code_review: null,
127
- phase_package_rework: false,
128
+ required_rework: null,
129
+ pending_commit: null,
128
130
  }
129
131
  // The literal above assigns the ladder's fields; this re-asserts them
130
132
  // through the shared helper so a rung added there cannot be missed here.
@@ -1,6 +1,6 @@
1
1
  import { Effect, Result } from "effect"
2
2
  import { LEGAL_TOOLS, nextAfter } from "../domain/state-machine.ts"
3
- import type { StateCorrupt } from "../errors.ts"
3
+ import type { AppError } from "../errors.ts"
4
4
  import { ok, type ToolResult } from "../result.ts"
5
5
  import { Config } from "../services/config.ts"
6
6
  import { RunStore } from "../services/run-store.ts"
@@ -12,7 +12,7 @@ import { Vcs } from "../services/vcs.ts"
12
12
  */
13
13
  export const statusWorkflow = (
14
14
  root: string,
15
- ): Effect.Effect<ToolResult, StateCorrupt, RunStore | Config | Vcs> =>
15
+ ): Effect.Effect<ToolResult, AppError, RunStore | Config | Vcs> =>
16
16
  Effect.gen(function* () {
17
17
  const store = yield* RunStore
18
18
  const config = yield* Config
@@ -1,7 +1,7 @@
1
1
  import { Clock, Effect, Exit, Option, Result } from "effect"
2
2
  import { resetRecoveryLadder } from "../domain/recovery.ts"
3
3
  import { inferKind } from "../domain/artifact-kind.ts"
4
- import { timeoutMsForKind } from "../domain/timeouts.ts"
4
+ import { deadlineAfter, timeoutMsForKind } from "../domain/timeouts.ts"
5
5
  import {
6
6
  asVerdict,
7
7
  isCompleteArtifact,
@@ -9,11 +9,7 @@ import {
9
9
  } from "../domain/frontmatter.ts"
10
10
  import { looksLikeShellOnly, shellJoin } from "../domain/herdr.ts"
11
11
  import { abs, rel } from "../domain/paths.ts"
12
- import {
13
- nextAfter,
14
- stepAfterArtifact,
15
- toolAllowed,
16
- } from "../domain/state-machine.ts"
12
+ import { nextAfter, toolAllowed } from "../domain/state-machine.ts"
17
13
  import {
18
14
  ArtifactInvalid,
19
15
  GateRefused,
@@ -24,12 +20,16 @@ import {
24
20
  } from "../errors.ts"
25
21
  import type { FrontMatter } from "../domain/types.ts"
26
22
  import { ok, type ToolResult } from "../result.ts"
27
- import { decodeFrontMatterResult } from "../schema/frontmatter.ts"
23
+ import {
24
+ validateArtifactCompletion,
25
+ type AcceptedArtifactCompletion,
26
+ } from "../schema/frontmatter.ts"
28
27
  import { Config } from "../services/config.ts"
29
28
  import { FileSystem } from "../services/file-system.ts"
30
29
  import { Herdr, paneReadRecentArgs } from "../services/herdr.ts"
31
30
  import { RunStore } from "../services/run-store.ts"
32
31
  import { Vcs } from "../services/vcs.ts"
32
+ import type { OperationHooks } from "../operation-hooks.ts"
33
33
 
34
34
  export type WaitParams = {
35
35
  poll_ms?: number
@@ -151,12 +151,7 @@ export const MAX_AUTO_POLL_MS =
151
151
  export const fitsHostShell = (pollMs: number): boolean =>
152
152
  defaultBudgetFor(pollMs) < HOST_SHELL_TIMEOUT_MS
153
153
 
154
- export type WaitHooks = {
155
- signal?: AbortSignal
156
- onUpdate?: (partial: {
157
- content: Array<{ type: "text"; text: string }>
158
- }) => void
159
- }
154
+ export type WaitHooks = OperationHooks
160
155
 
161
156
  /**
162
157
  * Async wait — polls via `Clock` + `Effect.sleep` so Pi stays responsive and
@@ -202,10 +197,10 @@ export const waitWorkflow = (
202
197
  const cfg = yield* config.load(root)
203
198
 
204
199
  const poll = params.poll_ms ?? 2000
205
- if (!Number.isFinite(poll) || poll < MIN_POLL_MS) {
200
+ if (!Number.isSafeInteger(poll) || poll < MIN_POLL_MS) {
206
201
  return yield* new GateRefused({
207
202
  gate: "poll_interval",
208
- message: `poll_ms must be a finite number >= ${MIN_POLL_MS}; got ${poll}. Every poll spawns two herdr subprocesses, so a tiny interval is a busy-spin, not a faster wait.`,
203
+ message: `poll_ms must be a safe integer >= ${MIN_POLL_MS}; got ${poll}. Every poll spawns two herdr subprocesses, so a tiny interval is a busy-spin, not a faster wait.`,
209
204
  })
210
205
  }
211
206
  // Refuse to PICK a budget that a default host shell would kill; still
@@ -232,10 +227,10 @@ export const waitWorkflow = (
232
227
  // NaN, and the call blocks until the role's deadline instead of
233
228
  // returning exit 3. The CLI is protected by `parseNumFlag`; a Pi tool
234
229
  // call reaches `run` through a raw cast with no runtime coercion.
235
- if (!Number.isFinite(budget)) {
230
+ if (!Number.isSafeInteger(budget) || budget <= 0) {
236
231
  return yield* new GateRefused({
237
232
  gate: "budget_floor",
238
- message: `budget_ms must be a finite number; got ${budget}`,
233
+ message: `budget_ms must be a positive safe integer; got ${budget}`,
239
234
  })
240
235
  }
241
236
  const floor = minBudgetFor(poll)
@@ -273,35 +268,33 @@ export const waitWorkflow = (
273
268
  const dispatchedAt = state.pending_started_at ?? startedMs
274
269
  if (state.pending_deadline_ms == null) {
275
270
  state.pending_started_at = dispatchedAt
276
- state.pending_deadline_ms = dispatchedAt + timeout
271
+ state.pending_deadline_ms = deadlineAfter(dispatchedAt, timeout)
277
272
  yield* store.save(state, root)
278
273
  }
279
274
  const requireVerdict = kind === "plan_review" || kind === "code_review"
280
275
 
281
- const readArtifact = (): Effect.Effect<FrontMatter | null> =>
276
+ const readArtifact = (): Effect.Effect<FrontMatter | null, AppError> =>
282
277
  Effect.gen(function* () {
283
- const present = yield* fs.exists(artifactAbs)
278
+ const present = yield* fs.projectPathExists(root, artifactAbs)
284
279
  if (!present) return null
285
- const text = yield* fs.readFile(artifactAbs)
280
+ const text = yield* fs.readProjectFile(root, artifactAbs).pipe(
281
+ Effect.mapError(
282
+ (error) =>
283
+ new ArtifactInvalid({
284
+ artifact: pendingArtifact,
285
+ message: error.message,
286
+ }),
287
+ ),
288
+ )
286
289
  return parseFrontMatter(text)
287
290
  })
288
291
 
289
292
  const advanceOnComplete = (
290
293
  fm: FrontMatter,
294
+ completion: AcceptedArtifactCompletion,
291
295
  msg = "artifact ready",
292
296
  ): Effect.Effect<ToolResult, AppError> =>
293
297
  Effect.gen(function* () {
294
- if (
295
- fm.rework !== undefined &&
296
- (kind !== "code_review" || fm.verdict !== "CHANGES_REQUIRED")
297
- ) {
298
- return yield* new ArtifactInvalid({
299
- artifact: pendingArtifact,
300
- message:
301
- "rework is valid only on a code_review artifact with verdict CHANGES_REQUIRED",
302
- })
303
- }
304
-
305
298
  if (
306
299
  state.pending_role === "reviewer" &&
307
300
  state.reviewer_tree_fingerprint != null
@@ -323,51 +316,26 @@ export const waitWorkflow = (
323
316
  }
324
317
  }
325
318
 
326
- // Post-completion Schema guard only isCompleteArtifact above is the
327
- // completeness test. A bad/absent verdict must keep waiting, not fail
328
- // here (that already happened before advanceOnComplete was called).
329
- //
330
- // Only review kinds carry a meaningful verdict. A stray `verdict:` on
331
- // a plan/code/package artifact is noise the old parser ignored;
332
- // validating it here would wedge the run permanently (state is not
333
- // saved on failure, so every retry fails identically).
334
- const decoded = decodeFrontMatterResult(
335
- {
336
- status: fm.status,
337
- ...(requireVerdict ? { verdict: fm.verdict } : {}),
338
- nits: fm.nits,
339
- ...(kind === "code_review" && fm.rework
340
- ? { rework: fm.rework }
341
- : {}),
342
- },
343
- pendingArtifact,
344
- )
345
- if (Result.isFailure(decoded)) {
346
- return yield* decoded.failure
347
- }
348
-
349
- const rework =
350
- kind === "code_review" ? decoded.success.rework : undefined
351
- const next = stepAfterArtifact(kind, fm.verdict, rework)
352
- if (typeof next === "object") {
353
- return yield* new ArtifactInvalid({
354
- artifact: pendingArtifact,
355
- message: next.error,
356
- })
357
- }
319
+ const { next, rework } = completion
358
320
 
359
321
  if (kind === "phase_package") {
360
322
  state.current_phase_package = pendingArtifact
361
323
  }
362
324
  if (kind === "code_review") {
363
325
  state.current_code_review = pendingArtifact
364
- state.phase_package_rework =
365
- fm.verdict === "CHANGES_REQUIRED" && rework === "phase_package"
366
326
  }
367
327
  const verdict = asVerdict(fm.verdict)
328
+ if (kind === "plan_review") {
329
+ state.required_rework = verdict === "CHANGES_REQUIRED" ? "plan" : null
330
+ }
331
+ if (kind === "code_review") {
332
+ state.required_rework =
333
+ verdict === "CHANGES_REQUIRED" ? (rework ?? "code") : null
334
+ }
368
335
  state.step = next
369
336
  state.pending_artifact = null
370
337
  state.pending_role = null
338
+ state.pending_delivery = null
371
339
  state.pending_pane_id = null
372
340
  state.pending_pane_label = null
373
341
  state.pending_started_at = null
@@ -422,7 +390,10 @@ export const waitWorkflow = (
422
390
  * without the second chance. `pending_final_grace` already makes the
423
391
  * deadline rung one-shot per run, so forcing it cannot spam a pane.
424
392
  */
425
- const tryNudge = (why: string, force = false): Effect.Effect<void> =>
393
+ const tryNudge = (
394
+ why: string,
395
+ force = false,
396
+ ): Effect.Effect<void, AppError> =>
426
397
  Effect.gen(function* () {
427
398
  if (
428
399
  (nudged && !force) ||
@@ -469,12 +440,16 @@ export const waitWorkflow = (
469
440
  const loop: Effect.Effect<ToolResult, AppError> = Effect.gen(function* () {
470
441
  while (true) {
471
442
  const now = yield* Clock.currentTimeMillis
472
- const deadline = state.pending_deadline_ms ?? startedMs + timeout
443
+ const deadline =
444
+ state.pending_deadline_ms ?? deadlineAfter(startedMs, timeout)
473
445
 
474
446
  const fm = yield* readArtifact()
475
- if (isCompleteArtifact(fm, { requireVerdict })) {
447
+ const completion = validateArtifactCompletion(kind, fm, pendingArtifact)
448
+ if (Result.isFailure(completion)) return yield* completion.failure
449
+ if (completion.success !== null) {
476
450
  return yield* advanceOnComplete(
477
451
  fm!,
452
+ completion.success,
478
453
  nudged ? "artifact ready after nudge" : "artifact ready",
479
454
  )
480
455
  }
@@ -490,7 +465,7 @@ export const waitWorkflow = (
490
465
  message: `role pane gone and artifact incomplete: ${pendingArtifact}`,
491
466
  details: {
492
467
  last_agent_status: lastStatus,
493
- hint: "re-dispatch same round after investigate",
468
+ hint: "after investigate, dispatch the same kind with redeliver=true",
494
469
  },
495
470
  })
496
471
  }
@@ -502,9 +477,18 @@ export const waitWorkflow = (
502
477
  // The artifact may have completed between the read above and this
503
478
  // terminal status, so fail the contract only after one fresh read.
504
479
  const artifactAfterDone = yield* readArtifact()
505
- if (isCompleteArtifact(artifactAfterDone, { requireVerdict })) {
480
+ const completionAfterDone = validateArtifactCompletion(
481
+ kind,
482
+ artifactAfterDone,
483
+ pendingArtifact,
484
+ )
485
+ if (Result.isFailure(completionAfterDone)) {
486
+ return yield* completionAfterDone.failure
487
+ }
488
+ if (completionAfterDone.success !== null) {
506
489
  return yield* advanceOnComplete(
507
490
  artifactAfterDone!,
491
+ completionAfterDone.success,
508
492
  "artifact ready as role exited",
509
493
  )
510
494
  }
@@ -568,7 +552,7 @@ export const waitWorkflow = (
568
552
  details: {
569
553
  last_agent_status: lastStatus,
570
554
  foreground: names,
571
- hint: "check pane transcript; re-dispatch same round",
555
+ hint: "check pane transcript; then dispatch the same kind with redeliver=true",
572
556
  },
573
557
  })
574
558
  }
@@ -605,7 +589,7 @@ export const waitWorkflow = (
605
589
  extendedOnce = true
606
590
  const extra = Math.max(Math.floor(timeout * 0.5), 120_000)
607
591
  state.pending_extended = true
608
- state.pending_deadline_ms = now + extra
592
+ state.pending_deadline_ms = deadlineAfter(now, extra)
609
593
  yield* store.save(state, root)
610
594
  hooks.onUpdate?.({
611
595
  content: [
@@ -639,7 +623,7 @@ export const waitWorkflow = (
639
623
  // grace is not.
640
624
  finalNudgeGrace = true
641
625
  state.pending_final_grace = true
642
- state.pending_deadline_ms = now + 180_000
626
+ state.pending_deadline_ms = deadlineAfter(now, 180_000)
643
627
  yield* store.save(state, root)
644
628
  yield* tryNudge("timeout idle", true)
645
629
  continue
@@ -681,7 +665,8 @@ export const waitWorkflow = (
681
665
  // was never the problem. `timeout` still sizes the extension,
682
666
  // because that has to come from config to stay stable across calls.
683
667
  const grantedMs =
684
- (state.pending_deadline_ms ?? dispatchedAt + timeout) - dispatchedAt
668
+ (state.pending_deadline_ms ?? deadlineAfter(dispatchedAt, timeout)) -
669
+ dispatchedAt
685
670
  return yield* new WaitTimeout({
686
671
  artifact: pendingArtifact,
687
672
  timeoutMs: grantedMs,
@@ -690,7 +675,7 @@ export const waitWorkflow = (
690
675
  nudged,
691
676
  extended_once: extendedOnce,
692
677
  configured_timeout_ms: timeout,
693
- hint: "inspect pane transcript; re-dispatch same round or nudge via herdr pane run",
678
+ hint: "inspect pane transcript; use redeliver=true for the same kind only after proving delivery is dead, or nudge via herdr pane run",
694
679
  },
695
680
  })
696
681
  })
@@ -707,7 +692,7 @@ export const waitWorkflow = (
707
692
  artifact,
708
693
  details: {
709
694
  last_agent_status: lastStatus,
710
- hint: "re-dispatch same round or wait again after investigate",
695
+ hint: "after investigate, use redeliver=true for the same kind or wait again",
711
696
  },
712
697
  }),
713
698
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/apnea",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Host-neutral multi-role workflow engine and standalone Bun CLI",
5
5
  "license": "MIT",
6
6
  "author": "Nacho Vazquez",
@@ -62,7 +62,7 @@
62
62
  "smoke:package": "bun scripts/package-smoke.ts"
63
63
  },
64
64
  "dependencies": {
65
- "effect": "4.0.0-beta.101",
65
+ "effect": "4.0.0-rc.111",
66
66
  "typebox": "^1.3.6"
67
67
  },
68
68
  "devDependencies": {
@@ -44,7 +44,11 @@
44
44
  "review_round_cap": { "type": "integer", "minimum": 1, "maximum": 20 },
45
45
  "timeouts_ms": {
46
46
  "type": "object",
47
- "additionalProperties": { "type": "integer", "minimum": 1000 }
47
+ "additionalProperties": {
48
+ "type": "integer",
49
+ "minimum": 1000,
50
+ "maximum": 9007199254740991
51
+ }
48
52
  }
49
53
  }
50
54
  }