@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,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
|
})
|
|
@@ -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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
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 {
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
278
|
+
const present = yield* fs.projectPathExists(root, artifactAbs)
|
|
284
279
|
if (!present) return null
|
|
285
|
-
const text = yield* fs.
|
|
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
|
-
|
|
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 = (
|
|
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 =
|
|
443
|
+
const deadline =
|
|
444
|
+
state.pending_deadline_ms ?? deadlineAfter(startedMs, timeout)
|
|
473
445
|
|
|
474
446
|
const fm = yield* readArtifact()
|
|
475
|
-
|
|
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: "
|
|
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
|
-
|
|
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;
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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: "
|
|
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.
|
|
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-
|
|
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": {
|
|
47
|
+
"additionalProperties": {
|
|
48
|
+
"type": "integer",
|
|
49
|
+
"minimum": 1000,
|
|
50
|
+
"maximum": 9007199254740991
|
|
51
|
+
}
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
}
|