@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
|
@@ -21,17 +21,8 @@ export function projectConfigPath(root = cwd()): string {
|
|
|
21
21
|
return path.join(apneaRoot(root), "config.json")
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function homedir(): string {
|
|
25
|
-
|
|
26
|
-
// passwd entry and ignores $HOME), then the passwd entry as the floor.
|
|
27
|
-
// Never "": an empty home makes globalConfigPath() cwd-relative, i.e. the
|
|
28
|
-
// *project* repo would be read as the trusted global config — the one place
|
|
29
|
-
// profiles/cmd_interactive are honoured. node:os is a pure read here.
|
|
30
|
-
return process.env.HOME || process.env.USERPROFILE || os.homedir()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function globalConfigPath(): string {
|
|
34
|
-
return path.join(homedir(), ".config", "apnea", "config.json")
|
|
24
|
+
export function globalConfigPath(home = os.homedir()): string {
|
|
25
|
+
return path.join(home, ".config", "apnea", "config.json")
|
|
35
26
|
}
|
|
36
27
|
|
|
37
28
|
export function artifactsDir(root = cwd()): string {
|
|
@@ -16,6 +16,10 @@ const STEP_KEY: Record<DispatchKind, string> = {
|
|
|
16
16
|
|
|
17
17
|
export const DEFAULT_TIMEOUT_MS = 900_000
|
|
18
18
|
|
|
19
|
+
export function deadlineAfter(start: number, duration: number): number {
|
|
20
|
+
return Math.min(Number.MAX_SAFE_INTEGER, start + duration)
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
export function timeoutMsForKind(
|
|
20
24
|
kind: DispatchKind,
|
|
21
25
|
timeouts: Record<string, number>,
|
|
@@ -12,10 +12,59 @@ export type Role = "orchestrator" | "planner" | "reviewer" | "coder"
|
|
|
12
12
|
|
|
13
13
|
export type RoleMode = "oneshot" | "interactive"
|
|
14
14
|
|
|
15
|
+
export type PendingDelivery = "manual" | "interactive"
|
|
16
|
+
|
|
15
17
|
export type Verdict = "APPROVED" | "CHANGES_REQUIRED"
|
|
16
18
|
|
|
17
19
|
export type ReworkTarget = "code" | "phase_package"
|
|
18
20
|
|
|
21
|
+
export type RequiredReworkTarget = "plan" | ReworkTarget
|
|
22
|
+
|
|
23
|
+
/** Fields every pending commit carries regardless of backend. */
|
|
24
|
+
export interface PendingCommitCore {
|
|
25
|
+
/** Transaction id; appears in the commit message as `Apnea-Transaction: <id>`. */
|
|
26
|
+
id: string
|
|
27
|
+
/** Phase whose completion this transaction commits. */
|
|
28
|
+
phase_index: number
|
|
29
|
+
/** Full commit message including the `Apnea-Transaction:` trailer line. */
|
|
30
|
+
message: string
|
|
31
|
+
/** Whether completion advances to finishing instead of the next phase. */
|
|
32
|
+
no_remaining_phases: boolean
|
|
33
|
+
/** Repo-relative `.apnea/` path of the verify log for this phase. */
|
|
34
|
+
verify_log: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Git anchor captured before any ref moves: the exact commit to create. */
|
|
38
|
+
export interface GitPendingCommit extends PendingCommitCore {
|
|
39
|
+
backend: "git"
|
|
40
|
+
/** Full ref (e.g. `refs/heads/apnea/slug`) the commit must land on. */
|
|
41
|
+
branch: string
|
|
42
|
+
/** Expected HEAD when completion starts; also the created commit's parent. */
|
|
43
|
+
parent_commit: string
|
|
44
|
+
/** Prepared tree id (staged with `.apnea` excluded). */
|
|
45
|
+
tree_id: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** jj anchor captured right after describing `@`. */
|
|
49
|
+
export interface JjPendingCommit extends PendingCommitCore {
|
|
50
|
+
backend: "jj"
|
|
51
|
+
/** Change id of the described working-copy change. */
|
|
52
|
+
change_id: string
|
|
53
|
+
/** Fingerprint of the change's non-.apnea diff at preparation time. */
|
|
54
|
+
content_fingerprint: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Durable record of an in-flight commit. Written after VCS preparation and
|
|
59
|
+
* before completion, so a crash anywhere afterwards can recognize and finish
|
|
60
|
+
* the transaction exactly once.
|
|
61
|
+
*/
|
|
62
|
+
export type PendingCommit = GitPendingCommit | JjPendingCommit
|
|
63
|
+
|
|
64
|
+
/** Internal decode marker for ambiguous version-1 planning state. */
|
|
65
|
+
export const LEGACY_PLAN_REWORK = Symbol("apnea.legacy-plan-rework")
|
|
66
|
+
export const LEGACY_CODE_REWORK = Symbol("apnea.legacy-code-rework")
|
|
67
|
+
|
|
19
68
|
export type VcsBackend = "jj" | "git"
|
|
20
69
|
|
|
21
70
|
export interface Profile {
|
|
@@ -35,7 +84,7 @@ export interface ApneaConfig {
|
|
|
35
84
|
}
|
|
36
85
|
|
|
37
86
|
export interface RunState {
|
|
38
|
-
version:
|
|
87
|
+
version: 2
|
|
39
88
|
slug: string
|
|
40
89
|
step: Step
|
|
41
90
|
phase_index: number
|
|
@@ -50,6 +99,8 @@ export interface RunState {
|
|
|
50
99
|
pending_artifact: string | null
|
|
51
100
|
/** Role for pending dispatch */
|
|
52
101
|
pending_role: Role | null
|
|
102
|
+
/** Delivery boundary crossed for the pending dispatch, if known. */
|
|
103
|
+
pending_delivery: PendingDelivery | null
|
|
53
104
|
/** Herdr pane id for the in-flight dispatch */
|
|
54
105
|
pending_pane_id: string | null
|
|
55
106
|
/** Label of that pane (apnea:role:unique) */
|
|
@@ -99,8 +150,19 @@ export interface RunState {
|
|
|
99
150
|
current_phase_package: string | null
|
|
100
151
|
/** Last code-review path for commit gate */
|
|
101
152
|
current_code_review: string | null
|
|
102
|
-
/** The
|
|
103
|
-
|
|
153
|
+
/** The exact dispatch that owns the next review round, if any. */
|
|
154
|
+
required_rework: RequiredReworkTarget | null
|
|
155
|
+
/**
|
|
156
|
+
* In-flight commit transaction, if any. Set after VCS preparation and
|
|
157
|
+
* cleared only after completion + bookmark succeed; a crash leaves it
|
|
158
|
+
* durable so the next `workflow_commit_phase` call resumes that
|
|
159
|
+
* transaction instead of re-running gates and verification.
|
|
160
|
+
*/
|
|
161
|
+
pending_commit: PendingCommit | null
|
|
162
|
+
/** Never serialized. An old planning state needs an explicit assertion. */
|
|
163
|
+
[LEGACY_PLAN_REWORK]?: true
|
|
164
|
+
/** Never serialized. Ambiguous old coding state needs an explicit assertion. */
|
|
165
|
+
[LEGACY_CODE_REWORK]?: true
|
|
104
166
|
}
|
|
105
167
|
|
|
106
168
|
export interface FrontMatter {
|
package/extension/errors.ts
CHANGED
|
@@ -2,13 +2,13 @@ import { Schema } from "effect"
|
|
|
2
2
|
import { err, type ToolErr } from "./result.ts"
|
|
3
3
|
|
|
4
4
|
/** No `.apnea/state.json` for the current project root. */
|
|
5
|
-
export class NoRunState extends Schema.
|
|
5
|
+
export class NoRunState extends Schema.TaggedError<NoRunState>()(
|
|
6
6
|
"NoRunState",
|
|
7
7
|
{},
|
|
8
8
|
) {}
|
|
9
9
|
|
|
10
10
|
/** Tool call refused by the step → legal-tools table. */
|
|
11
|
-
export class IllegalTool extends Schema.
|
|
11
|
+
export class IllegalTool extends Schema.TaggedError<IllegalTool>()(
|
|
12
12
|
"IllegalTool",
|
|
13
13
|
{
|
|
14
14
|
step: Schema.String,
|
|
@@ -18,7 +18,7 @@ export class IllegalTool extends Schema.TaggedErrorClass<IllegalTool>()(
|
|
|
18
18
|
) {}
|
|
19
19
|
|
|
20
20
|
/** Dispatch kind refused at the current step. */
|
|
21
|
-
export class IllegalKind extends Schema.
|
|
21
|
+
export class IllegalKind extends Schema.TaggedError<IllegalKind>()(
|
|
22
22
|
"IllegalKind",
|
|
23
23
|
{
|
|
24
24
|
step: Schema.String,
|
|
@@ -28,7 +28,7 @@ export class IllegalKind extends Schema.TaggedErrorClass<IllegalKind>()(
|
|
|
28
28
|
) {}
|
|
29
29
|
|
|
30
30
|
/** Global or project config missing, invalid, or untrusted. */
|
|
31
|
-
export class ConfigError extends Schema.
|
|
31
|
+
export class ConfigError extends Schema.TaggedError<ConfigError>()(
|
|
32
32
|
"ConfigError",
|
|
33
33
|
{
|
|
34
34
|
message: Schema.String,
|
|
@@ -38,7 +38,7 @@ export class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(
|
|
|
38
38
|
) {}
|
|
39
39
|
|
|
40
40
|
/** `state.json` present but not decodable / inconsistent. */
|
|
41
|
-
export class StateCorrupt extends Schema.
|
|
41
|
+
export class StateCorrupt extends Schema.TaggedError<StateCorrupt>()(
|
|
42
42
|
"StateCorrupt",
|
|
43
43
|
{
|
|
44
44
|
path: Schema.String,
|
|
@@ -47,23 +47,32 @@ export class StateCorrupt extends Schema.TaggedErrorClass<StateCorrupt>()(
|
|
|
47
47
|
) {}
|
|
48
48
|
|
|
49
49
|
/** VCS detect / dirty / commit / bookmark failure. */
|
|
50
|
-
export class VcsError extends Schema.
|
|
50
|
+
export class VcsError extends Schema.TaggedError<VcsError>()("VcsError", {
|
|
51
51
|
message: Schema.String,
|
|
52
52
|
command: Schema.optional(Schema.String),
|
|
53
53
|
}) {}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
export class
|
|
57
|
-
"
|
|
55
|
+
/** Another live Apnea process owns the repository mutation lock. */
|
|
56
|
+
export class OperationLocked extends Schema.TaggedError<OperationLocked>()(
|
|
57
|
+
"OperationLocked",
|
|
58
58
|
{
|
|
59
59
|
message: Schema.String,
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
repository: Schema.String,
|
|
61
|
+
lock_path: Schema.String,
|
|
62
|
+
reason: Schema.String,
|
|
63
|
+
pid: Schema.Number,
|
|
62
64
|
},
|
|
63
65
|
) {}
|
|
64
66
|
|
|
67
|
+
/** Herdr CLI or pane failure. */
|
|
68
|
+
export class HerdrError extends Schema.TaggedError<HerdrError>()("HerdrError", {
|
|
69
|
+
message: Schema.String,
|
|
70
|
+
command: Schema.optional(Schema.String),
|
|
71
|
+
details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
72
|
+
}) {}
|
|
73
|
+
|
|
65
74
|
/** Commit/review gate refused (e.g. verdict not APPROVED). */
|
|
66
|
-
export class GateRefused extends Schema.
|
|
75
|
+
export class GateRefused extends Schema.TaggedError<GateRefused>()(
|
|
67
76
|
"GateRefused",
|
|
68
77
|
{
|
|
69
78
|
gate: Schema.String,
|
|
@@ -73,7 +82,7 @@ export class GateRefused extends Schema.TaggedErrorClass<GateRefused>()(
|
|
|
73
82
|
) {}
|
|
74
83
|
|
|
75
84
|
/** `workflow_wait` hit its timeout without a complete artifact. */
|
|
76
|
-
export class WaitTimeout extends Schema.
|
|
85
|
+
export class WaitTimeout extends Schema.TaggedError<WaitTimeout>()(
|
|
77
86
|
"WaitTimeout",
|
|
78
87
|
{
|
|
79
88
|
artifact: Schema.String,
|
|
@@ -83,7 +92,7 @@ export class WaitTimeout extends Schema.TaggedErrorClass<WaitTimeout>()(
|
|
|
83
92
|
) {}
|
|
84
93
|
|
|
85
94
|
/** `workflow_wait` aborted (Esc / cancel signal). */
|
|
86
|
-
export class WaitAborted extends Schema.
|
|
95
|
+
export class WaitAborted extends Schema.TaggedError<WaitAborted>()(
|
|
87
96
|
"WaitAborted",
|
|
88
97
|
{
|
|
89
98
|
artifact: Schema.String,
|
|
@@ -91,8 +100,17 @@ export class WaitAborted extends Schema.TaggedErrorClass<WaitAborted>()(
|
|
|
91
100
|
},
|
|
92
101
|
) {}
|
|
93
102
|
|
|
103
|
+
/** A non-wait operation was interrupted by its host cancellation signal. */
|
|
104
|
+
export class OperationAborted extends Schema.TaggedError<OperationAborted>()(
|
|
105
|
+
"OperationAborted",
|
|
106
|
+
{
|
|
107
|
+
operation: Schema.String,
|
|
108
|
+
details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
|
109
|
+
},
|
|
110
|
+
) {}
|
|
111
|
+
|
|
94
112
|
/** Artifact exists but front-matter / shape is invalid. */
|
|
95
|
-
export class ArtifactInvalid extends Schema.
|
|
113
|
+
export class ArtifactInvalid extends Schema.TaggedError<ArtifactInvalid>()(
|
|
96
114
|
"ArtifactInvalid",
|
|
97
115
|
{
|
|
98
116
|
artifact: Schema.String,
|
|
@@ -101,7 +119,7 @@ export class ArtifactInvalid extends Schema.TaggedErrorClass<ArtifactInvalid>()(
|
|
|
101
119
|
) {}
|
|
102
120
|
|
|
103
121
|
/** Phase-package verify commands failed — commit refused. */
|
|
104
|
-
export class VerifyFailed extends Schema.
|
|
122
|
+
export class VerifyFailed extends Schema.TaggedError<VerifyFailed>()(
|
|
105
123
|
"VerifyFailed",
|
|
106
124
|
{
|
|
107
125
|
commands: Schema.Array(Schema.String),
|
|
@@ -119,10 +137,12 @@ export type AppError =
|
|
|
119
137
|
| ConfigError
|
|
120
138
|
| StateCorrupt
|
|
121
139
|
| VcsError
|
|
140
|
+
| OperationLocked
|
|
122
141
|
| HerdrError
|
|
123
142
|
| GateRefused
|
|
124
143
|
| WaitTimeout
|
|
125
144
|
| WaitAborted
|
|
145
|
+
| OperationAborted
|
|
126
146
|
| ArtifactInvalid
|
|
127
147
|
| VerifyFailed
|
|
128
148
|
|
|
@@ -133,10 +153,12 @@ const APP_ERROR_TAG_LIST = [
|
|
|
133
153
|
"ConfigError",
|
|
134
154
|
"StateCorrupt",
|
|
135
155
|
"VcsError",
|
|
156
|
+
"OperationLocked",
|
|
136
157
|
"HerdrError",
|
|
137
158
|
"GateRefused",
|
|
138
159
|
"WaitTimeout",
|
|
139
160
|
"WaitAborted",
|
|
161
|
+
"OperationAborted",
|
|
140
162
|
"ArtifactInvalid",
|
|
141
163
|
"VerifyFailed",
|
|
142
164
|
] as const satisfies readonly AppError["_tag"][]
|
|
@@ -204,6 +226,15 @@ export function toToolResult(e: AppError): ToolErr {
|
|
|
204
226
|
return err(e.message, {
|
|
205
227
|
data: e.command !== undefined ? { command: e.command } : undefined,
|
|
206
228
|
})
|
|
229
|
+
case "OperationLocked":
|
|
230
|
+
return err(e.message, {
|
|
231
|
+
data: {
|
|
232
|
+
repository: e.repository,
|
|
233
|
+
lock_path: e.lock_path,
|
|
234
|
+
reason: e.reason,
|
|
235
|
+
pid: e.pid,
|
|
236
|
+
},
|
|
237
|
+
})
|
|
207
238
|
case "HerdrError":
|
|
208
239
|
return err(e.message, {
|
|
209
240
|
data:
|
|
@@ -233,6 +264,10 @@ export function toToolResult(e: AppError): ToolErr {
|
|
|
233
264
|
return err("workflow_wait aborted (Esc / cancel)", {
|
|
234
265
|
data: { artifact: e.artifact, ...(e.details ?? {}) },
|
|
235
266
|
})
|
|
267
|
+
case "OperationAborted":
|
|
268
|
+
return err(`${e.operation} aborted (signal / cancel)`, {
|
|
269
|
+
data: { operation: e.operation, ...(e.details ?? {}) },
|
|
270
|
+
})
|
|
236
271
|
case "ArtifactInvalid":
|
|
237
272
|
return err(e.message, { data: { artifact: e.artifact } })
|
|
238
273
|
case "VerifyFailed":
|
package/extension/registry.ts
CHANGED
|
@@ -18,8 +18,8 @@ import {
|
|
|
18
18
|
MAX_AUTO_POLL_MS,
|
|
19
19
|
MIN_POLL_MS,
|
|
20
20
|
type WaitParams,
|
|
21
|
-
type WaitHooks,
|
|
22
21
|
} from "./workflows/wait.ts"
|
|
22
|
+
import type { OperationHooks } from "./operation-hooks.ts"
|
|
23
23
|
|
|
24
24
|
export type Operation = {
|
|
25
25
|
/** Pi tool name, or null when the operation is not model-facing. */
|
|
@@ -46,14 +46,14 @@ export type Operation = {
|
|
|
46
46
|
type RegisteredOperation = Operation & {
|
|
47
47
|
readonly run: (
|
|
48
48
|
params: Record<string, unknown>,
|
|
49
|
-
hooks?:
|
|
49
|
+
hooks?: OperationHooks,
|
|
50
50
|
) => Promise<ToolResult>
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export type ExecuteOperation = (
|
|
54
54
|
verb: string,
|
|
55
55
|
params: Record<string, unknown>,
|
|
56
|
-
hooks?:
|
|
56
|
+
hooks?: OperationHooks,
|
|
57
57
|
) => Promise<ToolResult>
|
|
58
58
|
|
|
59
59
|
// Sourced from domain/state-machine.ts (not hardcoded here) so a new kind
|
|
@@ -86,8 +86,8 @@ function createRegisteredOperations(
|
|
|
86
86
|
force: Type.Optional(Type.Boolean()),
|
|
87
87
|
agents_md: Type.Optional(Type.Boolean()),
|
|
88
88
|
}),
|
|
89
|
-
run: (p) =>
|
|
90
|
-
apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter),
|
|
89
|
+
run: (p, hooks) =>
|
|
90
|
+
apneaSetup(p as Parameters<typeof apneaSetup>[0], hostAdapter, hooks),
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
tool: "workflow_start",
|
|
@@ -115,7 +115,7 @@ function createRegisteredOperations(
|
|
|
115
115
|
// Mirrors the guard in index.ts's execute(): without it, action=start
|
|
116
116
|
// with no goal reaches slugify(undefined) in workflows/start.ts and
|
|
117
117
|
// throws instead of returning a clean refusal.
|
|
118
|
-
run: (p) => {
|
|
118
|
+
run: (p, hooks) => {
|
|
119
119
|
const params = p as Parameters<typeof workflowStart>[0]
|
|
120
120
|
const action = params.action ?? "start"
|
|
121
121
|
if (action === "start" && !params.goal?.trim()) {
|
|
@@ -132,16 +132,17 @@ function createRegisteredOperations(
|
|
|
132
132
|
action,
|
|
133
133
|
},
|
|
134
134
|
hostAdapter,
|
|
135
|
+
hooks,
|
|
135
136
|
)
|
|
136
137
|
},
|
|
137
138
|
},
|
|
138
139
|
{
|
|
139
140
|
tool: "dispatch_role",
|
|
140
141
|
verb: "dispatch",
|
|
141
|
-
usage: "<kind> [--rework]",
|
|
142
|
+
usage: "<kind> [--rework] [--redeliver]",
|
|
142
143
|
summary: "Write the task file and launch a role in a Herdr pane.",
|
|
143
144
|
guidance:
|
|
144
|
-
"One outstanding dispatch at a time.
|
|
145
|
+
"One outstanding dispatch at a time. Persisted review state selects rework and advances its round. rework=true is a deprecated assertion through 0.2.x and grants authority only for ambiguous version-1 plan or code migration. Use redeliver=true only to reuse matching pending ownership after proving the prior delivery is dead. A complete pending artifact refuses redelivery; call workflow_wait to ingest it.",
|
|
145
146
|
params: operationParams({
|
|
146
147
|
kind: DispatchKind,
|
|
147
148
|
task_markdown: Type.Optional(
|
|
@@ -149,14 +150,22 @@ function createRegisteredOperations(
|
|
|
149
150
|
),
|
|
150
151
|
rework: Type.Optional(
|
|
151
152
|
Type.Boolean({
|
|
152
|
-
description:
|
|
153
|
+
description:
|
|
154
|
+
"Deprecated assertion through 0.2.x; persisted state owns rework",
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
157
|
+
redeliver: Type.Optional(
|
|
158
|
+
Type.Boolean({
|
|
159
|
+
description:
|
|
160
|
+
"Reuse matching pending ownership after the prior delivery is demonstrably dead",
|
|
153
161
|
}),
|
|
154
162
|
),
|
|
155
163
|
}),
|
|
156
|
-
run: (p) =>
|
|
164
|
+
run: (p, hooks) =>
|
|
157
165
|
workflowDispatch(
|
|
158
166
|
p as Parameters<typeof workflowDispatch>[0],
|
|
159
167
|
hostAdapter,
|
|
168
|
+
hooks,
|
|
160
169
|
),
|
|
161
170
|
},
|
|
162
171
|
{
|
|
@@ -180,8 +189,9 @@ function createRegisteredOperations(
|
|
|
180
189
|
// moves, and then the schema promises a budget the runtime refuses.
|
|
181
190
|
params: operationParams({
|
|
182
191
|
poll_ms: Type.Optional(
|
|
183
|
-
Type.
|
|
192
|
+
Type.Integer({
|
|
184
193
|
minimum: MIN_POLL_MS,
|
|
194
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
185
195
|
description:
|
|
186
196
|
`Milliseconds between polls. At least ${MIN_POLL_MS} — each poll spawns two herdr subprocesses. ` +
|
|
187
197
|
`Keep it at or under ${MAX_AUTO_POLL_MS} unless you also pass budget_ms: above that, the floor ` +
|
|
@@ -189,7 +199,9 @@ function createRegisteredOperations(
|
|
|
189
199
|
}),
|
|
190
200
|
),
|
|
191
201
|
budget_ms: Type.Optional(
|
|
192
|
-
Type.
|
|
202
|
+
Type.Integer({
|
|
203
|
+
minimum: 1,
|
|
204
|
+
maximum: Number.MAX_SAFE_INTEGER,
|
|
193
205
|
description:
|
|
194
206
|
`How long THIS call may block — not the role's deadline, which comes from config. ` +
|
|
195
207
|
`Must be at least ${GRACE_MS} + max(${IDLE_NUDGE_AFTER_MS}, ${DEAD_POLLS_NEEDED} x poll_ms), so the call ` +
|
|
@@ -223,10 +235,11 @@ function createRegisteredOperations(
|
|
|
223
235
|
}),
|
|
224
236
|
),
|
|
225
237
|
}),
|
|
226
|
-
run: (p) =>
|
|
238
|
+
run: (p, hooks) =>
|
|
227
239
|
workflowCommitPhase(
|
|
228
240
|
p as Parameters<typeof workflowCommitPhase>[0],
|
|
229
241
|
hostAdapter,
|
|
242
|
+
hooks,
|
|
230
243
|
),
|
|
231
244
|
},
|
|
232
245
|
{
|
|
@@ -236,7 +249,7 @@ function createRegisteredOperations(
|
|
|
236
249
|
summary: "Read-only snapshot of run state and legal next calls.",
|
|
237
250
|
guidance: "Never mutates. Safe to call at any point.",
|
|
238
251
|
params: operationParams({}),
|
|
239
|
-
run: () => workflowStatus(hostAdapter),
|
|
252
|
+
run: (_p, hooks) => workflowStatus(hostAdapter, hooks),
|
|
240
253
|
},
|
|
241
254
|
{
|
|
242
255
|
tool: null,
|
|
@@ -254,10 +267,11 @@ function createRegisteredOperations(
|
|
|
254
267
|
description: "Round key, e.g. plan_review or phase-01/code_review",
|
|
255
268
|
}),
|
|
256
269
|
}),
|
|
257
|
-
run: (p) =>
|
|
270
|
+
run: (p, hooks) =>
|
|
258
271
|
workflowResetRounds(
|
|
259
272
|
p as Parameters<typeof workflowResetRounds>[0],
|
|
260
273
|
hostAdapter,
|
|
274
|
+
hooks,
|
|
261
275
|
),
|
|
262
276
|
},
|
|
263
277
|
]
|
package/extension/run-tool.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cause, Effect, Exit, Layer, Option, Result } from "effect"
|
|
2
|
-
import { isAppError, toToolResult } from "./errors.ts"
|
|
2
|
+
import { isAppError, OperationAborted, toToolResult } from "./errors.ts"
|
|
3
3
|
import type { ToolResult } from "./result.ts"
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -12,15 +12,32 @@ export async function runToolResult<E, R>(
|
|
|
12
12
|
// without the layer would type-check and then die as a service-not-found
|
|
13
13
|
// defect on every invocation. Pass `Layer.empty` explicitly when R is never.
|
|
14
14
|
layer: Layer.Layer<R, never, never>,
|
|
15
|
+
hooks: {
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
operation?: string
|
|
18
|
+
abortDetails?: Record<string, unknown>
|
|
19
|
+
} = {},
|
|
15
20
|
): Promise<ToolResult> {
|
|
16
21
|
const provided = Effect.provide(effect, layer) as Effect.Effect<ToolResult, E>
|
|
17
|
-
const exit = await Effect.runPromiseExit(provided)
|
|
22
|
+
const exit = await Effect.runPromiseExit(provided, { signal: hooks.signal })
|
|
18
23
|
|
|
19
24
|
if (Exit.isSuccess(exit)) {
|
|
20
25
|
return exit.value
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
const error = Exit.findErrorOption(exit)
|
|
29
|
+
if (
|
|
30
|
+
hooks.signal?.aborted ||
|
|
31
|
+
Result.isSuccess(Cause.findInterrupt(exit.cause))
|
|
32
|
+
) {
|
|
33
|
+
return toToolResult(
|
|
34
|
+
new OperationAborted({
|
|
35
|
+
operation: hooks.operation ?? "operation",
|
|
36
|
+
details: hooks.abortDetails,
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
if (Option.isSome(error) && isAppError(error.value)) {
|
|
25
42
|
return toToolResult(error.value)
|
|
26
43
|
}
|
|
@@ -20,18 +20,60 @@ const RoleBindingSchema = Schema.Struct({
|
|
|
20
20
|
profile: Schema.String.check(Schema.isMinLength(1)),
|
|
21
21
|
})
|
|
22
22
|
|
|
23
|
+
const ReviewRoundCapSchema = Schema.Int.check(
|
|
24
|
+
Schema.isBetween({ minimum: 1, maximum: 20 }),
|
|
25
|
+
)
|
|
26
|
+
const TimeoutMsSchema = Schema.Int.check(
|
|
27
|
+
Schema.isBetween({ minimum: 1_000, maximum: Number.MAX_SAFE_INTEGER }),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
function validReviewRoundCap(value: number): boolean {
|
|
31
|
+
return Number.isSafeInteger(value) && value >= 1 && value <= 20
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function validTimeout(value: number): boolean {
|
|
35
|
+
return Number.isSafeInteger(value) && value >= 1_000
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sanitizeConfigNumbers(
|
|
39
|
+
config: Record<string, unknown>,
|
|
40
|
+
): Record<string, unknown> {
|
|
41
|
+
const sanitized = { ...config }
|
|
42
|
+
if (
|
|
43
|
+
typeof sanitized.review_round_cap === "number" &&
|
|
44
|
+
!validReviewRoundCap(sanitized.review_round_cap)
|
|
45
|
+
) {
|
|
46
|
+
delete sanitized.review_round_cap
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
sanitized.timeouts_ms &&
|
|
50
|
+
typeof sanitized.timeouts_ms === "object" &&
|
|
51
|
+
!Array.isArray(sanitized.timeouts_ms)
|
|
52
|
+
) {
|
|
53
|
+
const timeouts = {
|
|
54
|
+
...(sanitized.timeouts_ms as Record<string, unknown>),
|
|
55
|
+
}
|
|
56
|
+
for (const [key, value] of Object.entries(timeouts)) {
|
|
57
|
+
if (typeof value === "number" && !validTimeout(value)) {
|
|
58
|
+
delete timeouts[key]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
sanitized.timeouts_ms = timeouts
|
|
62
|
+
}
|
|
63
|
+
return sanitized
|
|
64
|
+
}
|
|
65
|
+
|
|
23
66
|
/**
|
|
24
67
|
* Mirrors `schemas/config.schema.json` top-level keys.
|
|
25
68
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* hard failure here bricks every tool until the file is hand-edited.
|
|
69
|
+
* Numeric fields are strict here. The decoders remove only invalid numeric
|
|
70
|
+
* values before decoding so published 0.2 per-field fallback remains intact.
|
|
29
71
|
*/
|
|
30
72
|
export const GlobalConfigSchema = Schema.Struct({
|
|
31
73
|
profiles: Schema.optional(Schema.Record(Schema.String, ProfileSchema)),
|
|
32
74
|
roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
|
|
33
|
-
review_round_cap: Schema.optional(
|
|
34
|
-
timeouts_ms: Schema.optional(Schema.Record(Schema.String,
|
|
75
|
+
review_round_cap: Schema.optional(ReviewRoundCapSchema),
|
|
76
|
+
timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
|
|
35
77
|
})
|
|
36
78
|
|
|
37
79
|
const PROJECT_KNOWN = new Set([
|
|
@@ -56,8 +98,8 @@ const PROJECT_FORBIDDEN = new Set([
|
|
|
56
98
|
*/
|
|
57
99
|
export const ProjectConfigSchema = Schema.Struct({
|
|
58
100
|
roles: Schema.optional(Schema.Record(Schema.String, RoleBindingSchema)),
|
|
59
|
-
review_round_cap: Schema.optional(
|
|
60
|
-
timeouts_ms: Schema.optional(Schema.Record(Schema.String,
|
|
101
|
+
review_round_cap: Schema.optional(ReviewRoundCapSchema),
|
|
102
|
+
timeouts_ms: Schema.optional(Schema.Record(Schema.String, TimeoutMsSchema)),
|
|
61
103
|
isolation: Schema.optional(Schema.Literal("shared_cwd")),
|
|
62
104
|
})
|
|
63
105
|
|
|
@@ -125,7 +167,9 @@ export function decodeGlobalConfig(
|
|
|
125
167
|
}
|
|
126
168
|
|
|
127
169
|
const { pane_style: _legacy, ...globalConfig } = obj
|
|
128
|
-
const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(
|
|
170
|
+
const decoded = Schema.decodeUnknownResult(GlobalConfigSchema)(
|
|
171
|
+
sanitizeConfigNumbers(globalConfig),
|
|
172
|
+
)
|
|
129
173
|
if (Result.isFailure(decoded)) {
|
|
130
174
|
return configFail(decoded.failure.message)
|
|
131
175
|
}
|
|
@@ -152,17 +196,14 @@ export function decodeGlobalConfig(
|
|
|
152
196
|
const timeouts = { ...DEFAULT_TIMEOUTS }
|
|
153
197
|
if (d.timeouts_ms) {
|
|
154
198
|
for (const [k, v] of Object.entries(d.timeouts_ms)) {
|
|
155
|
-
if (
|
|
199
|
+
if (validTimeout(v)) timeouts[k] = v
|
|
156
200
|
}
|
|
157
201
|
}
|
|
158
202
|
|
|
159
203
|
return Result.succeed({
|
|
160
204
|
profiles,
|
|
161
205
|
roles,
|
|
162
|
-
review_round_cap:
|
|
163
|
-
typeof d.review_round_cap === "number" && d.review_round_cap >= 1
|
|
164
|
-
? d.review_round_cap
|
|
165
|
-
: 3,
|
|
206
|
+
review_round_cap: d.review_round_cap ?? 3,
|
|
166
207
|
timeouts_ms: timeouts,
|
|
167
208
|
})
|
|
168
209
|
}
|
|
@@ -228,7 +269,7 @@ export function decodeProjectConfig(
|
|
|
228
269
|
|
|
229
270
|
const { pane_style: _legacy, ...projectConfig } = obj
|
|
230
271
|
const decoded = Schema.decodeUnknownResult(ProjectConfigSchema)(
|
|
231
|
-
projectConfig,
|
|
272
|
+
sanitizeConfigNumbers(projectConfig),
|
|
232
273
|
{
|
|
233
274
|
onExcessProperty: "error",
|
|
234
275
|
},
|
|
@@ -256,14 +297,15 @@ export function applyProjectConfig(
|
|
|
256
297
|
const timeouts = { ...cfg.timeouts_ms }
|
|
257
298
|
if (overlay.timeouts_ms) {
|
|
258
299
|
for (const [k, v] of Object.entries(overlay.timeouts_ms)) {
|
|
259
|
-
if (
|
|
300
|
+
if (validTimeout(v)) timeouts[k] = v
|
|
260
301
|
}
|
|
261
302
|
}
|
|
262
303
|
return {
|
|
263
304
|
profiles: cfg.profiles,
|
|
264
305
|
roles,
|
|
265
306
|
review_round_cap:
|
|
266
|
-
overlay.review_round_cap !== undefined &&
|
|
307
|
+
overlay.review_round_cap !== undefined &&
|
|
308
|
+
validReviewRoundCap(overlay.review_round_cap)
|
|
267
309
|
? overlay.review_round_cap
|
|
268
310
|
: cfg.review_round_cap,
|
|
269
311
|
timeouts_ms: timeouts,
|