@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.
- package/README.md +25 -10
- package/SECURITY.md +32 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8571 -15324
- package/docs/adr/0005-harness-profiles.md +1 -1
- package/docs/adr/0010-package-split.md +1 -1
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +22 -25
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +18 -5
- 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/herdr.ts +0 -86
- package/extension/domain/paths.ts +3 -13
- package/extension/domain/setup.ts +0 -20
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +64 -11
- package/extension/domain/verify-commands.ts +200 -108
- 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 +86 -30
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +226 -16
- 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 +393 -402
- package/extension/services/operation-lock.ts +418 -0
- package/extension/services/process.ts +477 -0
- package/extension/services/run-store.ts +38 -16
- package/extension/services/vcs.ts +1388 -86
- package/extension/workflows/commit.ts +222 -18
- package/extension/workflows/dispatch.ts +320 -220
- package/extension/workflows/setup.ts +61 -141
- package/extension/workflows/start.ts +6 -5
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +63 -134
- package/package.json +2 -3
- package/schemas/config.schema.json +11 -7
- package/schemas/state.schema.json +170 -12
- package/herdr-plugin/herdr-plugin.toml +0 -15
- package/herdr-plugin/scripts/run-task.sh +0 -8
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# Harness abstraction: profiles with mode variants
|
|
2
2
|
|
|
3
|
-
Roles do not carry freeform model strings. Global **profiles** define `cmd_interactive` (required for dispatch) and optional `cmd_oneshot` (unused by
|
|
3
|
+
Roles do not carry freeform model strings. Global **profiles** define `cmd_interactive` (required for dispatch) and optional `cmd_oneshot` (unused by Apnea dispatch). Every worker role opens the interactive harness TUI in a labeled Herdr pane so humans can watch the session. Binding a role to a profile missing `cmd_interactive` hard-errors at start. Project config may only rebind role→profile names.
|
|
4
4
|
|
|
5
5
|
Each remembered role pane stores a deterministic fingerprint of the effective profile name and interactive command. Dispatch reuses the pane only when that fingerprint still matches resolved config. Legacy pane records have no fingerprint and are recreated once rather than risking an old harness.
|
|
@@ -16,7 +16,7 @@ Both adapters bind the operation registry exported by `@naxodev/apnea`. The Pi a
|
|
|
16
16
|
|
|
17
17
|
The core exposes only the operation registry, result formatting, command parsing, resource-root lookup, and the `ApneaHostAdapter` launch hooks. Pi supplies those hooks when it creates its registry.
|
|
18
18
|
|
|
19
|
-
`@naxodev/pi-apnea` depends on `@naxodev/apnea` with `^0.
|
|
19
|
+
The current incompatible `@naxodev/pi-apnea` line depends on `@naxodev/apnea` with `^0.2.0`. Compatible core patch releases flow through that range. For an incompatible line, the core manifest is pre-staged at the new minor so the workspace remains installable while Nx coordinates the adapter's minor release.
|
|
20
20
|
|
|
21
21
|
## Consequences
|
|
22
22
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
```text
|
|
6
6
|
.apnea/ # runtime; ignore in VCS
|
|
7
7
|
config.json # optional project role→profile bindings only
|
|
8
|
-
state.json # step, phase index, rounds
|
|
8
|
+
state.json # step, phase index, rounds, required rework target, slug
|
|
9
9
|
tasks/
|
|
10
10
|
<dispatch-id>.md # human/agent-readable task payload
|
|
11
11
|
artifacts/
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
Package-owned briefs live in the installed package (`briefs/`), not under `.apnea/`.
|
|
27
27
|
|
|
28
|
+
Persisted artifact references in `state.json` must be repository-relative paths below `.apnea/`.
|
|
29
|
+
Absolute paths and paths containing NULs, empty, `.`, `..`, or backslash components are corrupt
|
|
30
|
+
state. State and artifacts must be regular UTF-8 files no larger than 1 MiB.
|
|
31
|
+
|
|
28
32
|
## Front-matter schema
|
|
29
33
|
|
|
30
34
|
Required on every role-produced artifact:
|
|
@@ -61,7 +65,19 @@ For a given target path, `dispatch_role` deletes or renames away any existing fi
|
|
|
61
65
|
|
|
62
66
|
On rework after CHANGES_REQUIRED, the **round number increases** and the path changes (`round-2/...`), so history is preserved.
|
|
63
67
|
|
|
64
|
-
On crash
|
|
68
|
+
On crash redelivery, `dispatch_role` requires `redeliver=true`, matching pending ownership, and proof that any recorded pane is dead. It clears and reuses the same path without advancing the round. Manual/no-Herdr ownership requires an explicit operator request because no pane can provide liveness evidence.
|
|
69
|
+
|
|
70
|
+
`state.json.pending_delivery` records whether pending ownership crossed a `manual` or `interactive` delivery boundary. Dispatch persists `interactive` before calling `runInteractivePrompt`, even though `pending_pane_id` is still null. This closes the crash window where the prompt may be accepted but the pane id cannot be saved: redelivery refuses that ambiguous state. Completion clears the mode with every other pending field, and rollback restores the prior mode.
|
|
71
|
+
|
|
72
|
+
Version-1 state lacks this mode. A recorded pending pane safely migrates to `interactive`; a null pane cannot distinguish manual work from an accepted interactive prompt whose final save failed. It migrates to null and redelivery fails closed instead of guessing.
|
|
73
|
+
|
|
74
|
+
Before checking pane liveness or renaming the artifact, redelivery reads the pending path and applies the complete `workflow_wait` acceptance boundary: completeness, rework legality, frontmatter schema decoding, and state transition. An accepted artifact refuses redelivery with an explicit `workflow_wait` next step and remains unchanged. Review `status: done` without a valid verdict is incomplete. Illegal or unknown rework metadata is malformed. Both proceed to normal redelivery validation.
|
|
75
|
+
|
|
76
|
+
## Persisted rework ownership
|
|
77
|
+
|
|
78
|
+
After a review rejects work, `state.json.required_rework` names the exact next target: `plan`, `code`, or `phase_package`. Dispatch consumes this marker only when it saves pending ownership. Refusals and launch rollback preserve it. A recovered same-round dispatch sees a cleared marker and does not advance the round again.
|
|
79
|
+
|
|
80
|
+
Version-1 state without this field migrates package rework only from `phase_package_rework: true`. Planning state and coding state with `current_code_review` are ambiguous: either may be initial work after a completed prior stage. Only an explicit, matching deprecated assertion (`kind=plan` or `kind=code` with `rework=true`) converts that legacy ambiguity into one authoritative rework dispatch. Omitted assertions remain ordinary work. A matching coder dispatch already pending is not ambiguous and never re-arms code rework.
|
|
65
81
|
|
|
66
82
|
## Human markers (optional)
|
|
67
83
|
|
package/docs/protocol/config.md
CHANGED
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
Unknown keys and unimplemented values (`isolation: "worktree"`) **hard-error** at start.
|
|
11
11
|
|
|
12
|
+
`review_round_cap` is a safe integer from 1 through 20. Every `timeouts_ms` value is a safe
|
|
13
|
+
integer from 1000 through 9007199254740991. For 0.2 compatibility, a numeric value outside these
|
|
14
|
+
ranges falls back per field instead of rejecting the whole config. Wrong types still hard-error.
|
|
15
|
+
|
|
12
16
|
## Global profiles
|
|
13
17
|
|
|
14
18
|
```jsonc
|
|
@@ -52,8 +56,6 @@ Unknown keys and unimplemented values (`isolation: "worktree"`) **hard-error** a
|
|
|
52
56
|
"coder": { "profile": "pi-grok" },
|
|
53
57
|
},
|
|
54
58
|
"review_round_cap": 3,
|
|
55
|
-
// "regular" (default) or "floating"; omit the key to keep the default
|
|
56
|
-
"pane_style": "regular",
|
|
57
59
|
"timeouts_ms": {
|
|
58
60
|
"planning": 1500000,
|
|
59
61
|
"plan_review": 900000,
|
|
@@ -115,9 +117,12 @@ that gets killed. Passing `--budget` explicitly still works at any poll: it is a
|
|
|
115
117
|
statement that your shell allows a longer call. An explicit budget under the floor is still
|
|
116
118
|
refused.
|
|
117
119
|
|
|
118
|
-
`poll_ms` must be at least 250ms. Every poll spawns two herdr subprocesses, so a
|
|
119
|
-
interval is a busy-spin rather than a faster wait. `budget_ms` must be
|
|
120
|
-
|
|
120
|
+
`poll_ms` must be a safe integer of at least 250ms. Every poll spawns two herdr subprocesses, so a
|
|
121
|
+
smaller interval is a busy-spin rather than a faster wait. `budget_ms` must be a positive safe
|
|
122
|
+
integer. Human flags use JavaScript numeric syntax: hexadecimal and scientific forms are accepted
|
|
123
|
+
when they evaluate to an exact safe integer, while fractional values are refused. The sleep
|
|
124
|
+
between polls is clamped to the remaining budget, so a large `poll_ms` cannot make a call outlive
|
|
125
|
+
it.
|
|
121
126
|
|
|
122
127
|
The idle nudge and the final grace are independent rungs. A role nudged early for going idle
|
|
123
128
|
still receives its 180000ms grace at the deadline; it just is not prompted twice.
|
|
@@ -129,29 +134,16 @@ a slow test run can read `idle` for a minute. The nudge is one-shot, so a false
|
|
|
129
134
|
105000ms, leaving roughly 15000ms of margin under a 120000ms shell. Raise one and you must raise
|
|
130
135
|
the other.
|
|
131
136
|
|
|
132
|
-
## Pane style
|
|
133
|
-
|
|
134
|
-
`pane_style` controls how role dispatches appear in Herdr:
|
|
135
|
-
|
|
136
|
-
- Values: `"regular"` | `"floating"`. Default is `"regular"` — **omitting the key changes nothing**.
|
|
137
|
-
- Allowed in global config and overridable per project.
|
|
138
|
-
- `"floating"` applies to **planner/reviewer oneshot dispatches only**. It requires herdr ≥ 0.7.4 **and** the linked `apnea` plugin (provisioned by `/apnea setup`). The popup is **session-modal**: it takes keyboard input until the worker exits, and dismissing it early kills the dispatch.
|
|
139
|
-
- Floating oneshot scripts resolve `cmd_oneshot[0]` to an **absolute path** in the orchestrator environment and pass an augmented `PATH` into the popup. Bare names must be findable via the orchestrator's `PATH` (e.g. `~/.local/bin`); otherwise dispatch fails immediately instead of the popup exiting 127. Prefer absolute `cmd_oneshot` if your orchestrator process has a stripped PATH.
|
|
140
|
-
- **Lifecycle (single popup):** herdr allows only one popup. Dispatch refuses while a prior floating oneshot is still live. The worker writes an exit-status file; `workflow_wait` fails closed when the popup dies without a complete artifact (instead of hanging until timeout). A second open while a popup is up surfaces `popup already open` immediately.
|
|
141
|
-
- Interactive roles (orchestrator, coder) always use regular panes; the dispatch result reports `pane_style_effective: "regular (interactive role)"`.
|
|
142
|
-
- Misconfiguration (old herdr, missing plugin, missing `cmd_oneshot`, unresolved oneshot binary) fails fast at dispatch with an actionable error.
|
|
143
|
-
- Non-goal: per-role `pane_style` is future work. Setup never writes or flips this key — it only preserves a valid existing value on re-run.
|
|
144
|
-
|
|
145
137
|
## Role modes (fixed)
|
|
146
138
|
|
|
147
|
-
|
|
139
|
+
Every worker role launches the **interactive** harness TUI in a reusable Herdr pane. Apnea dispatch always resolves `cmd_interactive`; a profile that only has `cmd_oneshot` cannot satisfy any role. `cmd_oneshot` remains part of the profile contract for manual and external workflows.
|
|
148
140
|
|
|
149
|
-
| Role | Required profile capability
|
|
150
|
-
| ------------ |
|
|
151
|
-
| orchestrator | `cmd_interactive`
|
|
152
|
-
| planner | `cmd_interactive`
|
|
153
|
-
| reviewer | `cmd_interactive`
|
|
154
|
-
| coder | `cmd_interactive`
|
|
141
|
+
| Role | Required profile capability |
|
|
142
|
+
| ------------ | --------------------------- |
|
|
143
|
+
| orchestrator | `cmd_interactive` |
|
|
144
|
+
| planner | `cmd_interactive` |
|
|
145
|
+
| reviewer | `cmd_interactive` |
|
|
146
|
+
| coder | `cmd_interactive` |
|
|
155
147
|
|
|
156
148
|
Binding a role to a profile missing the required capability → **hard-error**.
|
|
157
149
|
|
|
@@ -184,3 +176,8 @@ Project entries that include `cmd`, `cmd_oneshot`, `cmd_interactive`, or `bin`
|
|
|
184
176
|
- Optionally write project role bindings
|
|
185
177
|
- Never write `cmd` into project config
|
|
186
178
|
- Point at the manual gate before claiming readiness
|
|
179
|
+
|
|
180
|
+
Existing global config is merged only after bounded JSON parsing. Malformed global JSON is left
|
|
181
|
+
unchanged unless the human passes `--force`; forced replacement is atomic and reported. Existing
|
|
182
|
+
project config is validated before setup overwrites role bindings and malformed content fails
|
|
183
|
+
closed.
|
|
@@ -17,14 +17,14 @@ Two phases, e.g. “CLI that greets by name + `--json` flag,” small enough to
|
|
|
17
17
|
|
|
18
18
|
## Required paths (must all pass)
|
|
19
19
|
|
|
20
|
-
| # | Path | Pass criteria
|
|
21
|
-
| --- | --------------------- |
|
|
22
|
-
| 1 | Happy path | Two phases committed/described; `pr-description.md` exists
|
|
23
|
-
| 2 | Plan CHANGES_REQUIRED | At least one plan rework; new plan-review round artifact; **no** manual verdict transcription
|
|
24
|
-
| 3 | Code CHANGES_REQUIRED | Live follow-up into existing coder pane; new `round-N` code-review; then APPROVED
|
|
25
|
-
| 4 | APPROVED + nits | One review with `nits` field; commit still proceeds
|
|
26
|
-
| 5 | Kill-and-resume | Kill coder pane mid-phase; human reconciles per protocol (artifact present? ingest :
|
|
27
|
-
| 6 | Mixed harness | Planner/reviewer Claude
|
|
20
|
+
| # | Path | Pass criteria |
|
|
21
|
+
| --- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| 1 | Happy path | Two phases committed/described; `pr-description.md` exists |
|
|
23
|
+
| 2 | Plan CHANGES_REQUIRED | At least one plan rework; new plan-review round artifact; **no** manual verdict transcription |
|
|
24
|
+
| 3 | Code CHANGES_REQUIRED | Live follow-up into existing coder pane; new `round-N` code-review; then APPROVED |
|
|
25
|
+
| 4 | APPROVED + nits | One review with `nits` field; commit still proceeds |
|
|
26
|
+
| 5 | Kill-and-resume | Kill coder pane mid-phase; human reconciles per protocol (artifact present? ingest : dispatch same kind with `redeliver=true`); continue to success |
|
|
27
|
+
| 6 | Mixed harness | Planner/reviewer Claude interactive; coder Pi interactive |
|
|
28
28
|
|
|
29
29
|
## Hard rules during the gate
|
|
30
30
|
|
|
@@ -33,7 +33,7 @@ Illegal tool calls refuse with the legal next call named in the error.
|
|
|
33
33
|
| Tool | Purpose |
|
|
34
34
|
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
35
35
|
| `workflow_start` | Resolve config; clean-tree check (unless allow-dirty / resume); create git branch or prepare jj; label panes; write state |
|
|
36
|
-
| `dispatch_role` | Write task file; open live harness TUI
|
|
36
|
+
| `dispatch_role` | Write task file; claim pending ownership; consume persisted rework; open live harness TUI and submit a short pointer |
|
|
37
37
|
| `workflow_wait` | Wait for artifact front-matter; treat agent-status as liveness only |
|
|
38
38
|
| `workflow_commit_phase` | Require APPROVED + verify commands (log to `verify.log`) + VCS backend; advance phase |
|
|
39
39
|
| `workflow_status` | **Read-only** snapshot |
|
|
@@ -67,26 +67,39 @@ nits: | # optional; never blocks alone
|
|
|
67
67
|
---
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane
|
|
70
|
+
Pane markers are human decoration. Herdr `agent_status` is liveness (dead pane without artifact → escalate).
|
|
71
71
|
|
|
72
72
|
## Rework
|
|
73
73
|
|
|
74
74
|
- **All roles:** live follow-up on the same pane when the harness is still idle; if pane missing or busy → new pane + cold TUI launch, then pointer.
|
|
75
75
|
- Pointer always includes prior artifact paths for context.
|
|
76
|
-
-
|
|
77
|
-
-
|
|
76
|
+
- `workflow_wait` records the exact required target (`plan`, `code`, or `phase_package`) after `CHANGES_REQUIRED`.
|
|
77
|
+
- `dispatch_role` derives round advancement only from that persisted target and consumes it when pending ownership is saved.
|
|
78
|
+
- The deprecated `rework` dispatch parameter remains an assertion through 0.2.x. It grants authority only when migrating ambiguous version-1 planning or coding state.
|
|
79
|
+
- Round increments **only** when the required target is dispatched on the same (phase, gate).
|
|
80
|
+
- Crash / timeout / resume: after proving the prior pane is dead, explicitly dispatch the same kind with `redeliver=true`. It validates pending kind, role, phase, and round, then clears the same artifact without advancing the round. A live or ambiguous pane refuses. Manual/no-Herdr work has no pane proof, so the operator must explicitly request redelivery.
|
|
81
|
+
- Pending ownership records `pending_delivery: manual | interactive`. The prepared save writes this before crossing `runInteractivePrompt`, while the pane id may still be null. Interactive null-pane ownership refuses redelivery because prompt acceptance may have happened before the final save. Version-1 ownership without this field migrates to interactive only when a pane id is already recorded; legacy null-pane ownership remains ambiguous and refuses.
|
|
82
|
+
- Before liveness checks or clear-before-dispatch, redelivery parses the pending artifact with the same acceptance rule as `workflow_wait`. `status: done` completes non-review artifacts; review artifacts also require `verdict: APPROVED | CHANGES_REQUIRED`, legal rework placement, schema-valid rework values, and a valid state transition. Accepted artifacts remain byte-identical and refuse with guidance to call `workflow_wait`. Malformed or incomplete artifacts continue to liveness validation.
|
|
78
83
|
|
|
79
84
|
## Resume
|
|
80
85
|
|
|
81
86
|
1. Re-resolve panes by role label (respawn if needed).
|
|
82
87
|
2. If expected artifact has valid front-matter → ingest and advance.
|
|
83
|
-
3. Else report state; **offer**
|
|
88
|
+
3. Else report state; **offer** explicit `redeliver=true` after liveness checks — never auto-dispatch.
|
|
84
89
|
4. Skip clean-tree check on resume.
|
|
85
90
|
|
|
86
91
|
## Commit
|
|
87
92
|
|
|
88
93
|
- Orchestrator only.
|
|
89
94
|
- `workflow_commit_phase` runs phase package verify commands; non-zero → refuse.
|
|
95
|
+
- Commits are crash-recoverable transactions. After verification, the run
|
|
96
|
+
prepares the commit and persists a durable `pending_commit` anchor in
|
|
97
|
+
`state.json` (version 2) before completing it; every commit message body
|
|
98
|
+
carries an `Apnea-Transaction: <uuid>` trailer line. A later
|
|
99
|
+
`workflow_commit_phase` call that finds a pending transaction skips gates
|
|
100
|
+
and verification, completes or recognizes the exact commit once (drift is
|
|
101
|
+
refused), then advances. Cancellation after the anchor is saved does not
|
|
102
|
+
undo the transaction.
|
|
90
103
|
- jj: `jj describe` + `jj new` after APPROVED; **bookmark `apnea/<slug>` at terminus**, not start.
|
|
91
104
|
- git: branch `apnea/<slug>` at start; one commit per phase.
|
|
92
105
|
- No push / remote PR in v1. Terminus artifact: `pr-description.md` (planner).
|
|
@@ -3,13 +3,17 @@ import { runToolResult } from "../run-tool.ts"
|
|
|
3
3
|
import { makeAppLive } from "../services/app-live.ts"
|
|
4
4
|
import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
|
|
5
5
|
import { commitWorkflow, type CommitParams } from "../workflows/commit.ts"
|
|
6
|
+
import { withRepositoryLock } from "../services/operation-lock.ts"
|
|
7
|
+
import type { OperationHooks } from "../operation-hooks.ts"
|
|
6
8
|
|
|
7
9
|
export async function workflowCommitPhase(
|
|
8
10
|
params: CommitParams,
|
|
9
11
|
hostAdapter: ApneaHostAdapter = neutralHostAdapter,
|
|
12
|
+
hooks: OperationHooks = {},
|
|
10
13
|
): Promise<ToolResult> {
|
|
11
14
|
return runToolResult(
|
|
12
|
-
commitWorkflow(params, process.cwd()),
|
|
15
|
+
withRepositoryLock(process.cwd(), commitWorkflow(params, process.cwd())),
|
|
13
16
|
makeAppLive(hostAdapter),
|
|
17
|
+
{ signal: hooks.signal, operation: "workflow_commit_phase" },
|
|
14
18
|
)
|
|
15
19
|
}
|
|
@@ -3,13 +3,21 @@ import { runToolResult } from "../run-tool.ts"
|
|
|
3
3
|
import { makeAppLive } from "../services/app-live.ts"
|
|
4
4
|
import { neutralHostAdapter, type ApneaHostAdapter } from "../host-adapter.ts"
|
|
5
5
|
import { dispatchWorkflow, type DispatchParams } from "../workflows/dispatch.ts"
|
|
6
|
+
import { withRepositoryLock } from "../services/operation-lock.ts"
|
|
7
|
+
import type { OperationHooks } from "../operation-hooks.ts"
|
|
6
8
|
|
|
7
9
|
export async function workflowDispatch(
|
|
8
10
|
params: DispatchParams,
|
|
9
11
|
hostAdapter: ApneaHostAdapter = neutralHostAdapter,
|
|
12
|
+
hooks: OperationHooks = {},
|
|
10
13
|
): Promise<ToolResult> {
|
|
11
14
|
return runToolResult(
|
|
12
|
-
dispatchWorkflow(params, process.cwd()),
|
|
15
|
+
withRepositoryLock(process.cwd(), dispatchWorkflow(params, process.cwd())),
|
|
13
16
|
makeAppLive(hostAdapter),
|
|
17
|
+
{
|
|
18
|
+
signal: hooks.signal,
|
|
19
|
+
operation: "dispatch_role",
|
|
20
|
+
abortDetails: { delivery: "unknown" },
|
|
21
|
+
},
|
|
14
22
|
)
|
|
15
23
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolResult } from "../result.ts"
|
|
2
|
+
import * as os from "node:os"
|
|
2
3
|
import { runToolResult } from "../run-tool.ts"
|
|
3
4
|
import { makeAppLive } from "../services/app-live.ts"
|
|
4
5
|
import { resolveExecutable } from "../services/herdr.ts"
|
|
@@ -8,6 +9,8 @@ import {
|
|
|
8
9
|
type SetupDeps,
|
|
9
10
|
type SetupParams,
|
|
10
11
|
} from "../workflows/setup.ts"
|
|
12
|
+
import { withSetupLocks } from "../services/operation-lock.ts"
|
|
13
|
+
import type { OperationHooks } from "../operation-hooks.ts"
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Walk PATH directly rather than spawning `which` once per binary — `which`
|
|
@@ -21,14 +24,25 @@ function onPath(bin: string): boolean {
|
|
|
21
24
|
export async function apneaSetup(
|
|
22
25
|
params: SetupParams,
|
|
23
26
|
hostAdapter: ApneaHostAdapter = neutralHostAdapter,
|
|
27
|
+
hooks: OperationHooks = {},
|
|
24
28
|
): Promise<ToolResult> {
|
|
29
|
+
const trustedHome = os.homedir()
|
|
25
30
|
const prodDeps: SetupDeps = {
|
|
26
31
|
onPath,
|
|
32
|
+
trustedHome: () => trustedHome,
|
|
27
33
|
materializeRoleAgentDir: () =>
|
|
28
34
|
hostAdapter.materializeRoleAgentDir?.() ?? null,
|
|
29
35
|
}
|
|
36
|
+
const root = process.cwd()
|
|
37
|
+
const workflow = setupWorkflow(params, root, prodDeps)
|
|
30
38
|
return runToolResult(
|
|
31
|
-
|
|
39
|
+
withSetupLocks(
|
|
40
|
+
trustedHome,
|
|
41
|
+
root,
|
|
42
|
+
params.project === true || params.agents_md === true,
|
|
43
|
+
workflow,
|
|
44
|
+
),
|
|
32
45
|
makeAppLive(hostAdapter),
|
|
46
|
+
{ signal: hooks.signal, operation: "setup" },
|
|
33
47
|
)
|
|
34
48
|
}
|
|
@@ -4,13 +4,17 @@ import { neutralHostAdapter } from "../host-adapter.ts"
|
|
|
4
4
|
import type { ToolResult } from "../result.ts"
|
|
5
5
|
import { runToolResult } from "../run-tool.ts"
|
|
6
6
|
import { startWorkflow, type StartParams } from "../workflows/start.ts"
|
|
7
|
+
import { withRepositoryLock } from "../services/operation-lock.ts"
|
|
8
|
+
import type { OperationHooks } from "../operation-hooks.ts"
|
|
7
9
|
|
|
8
10
|
export async function workflowStart(
|
|
9
11
|
params: StartParams,
|
|
10
12
|
hostAdapter: ApneaHostAdapter = neutralHostAdapter,
|
|
13
|
+
hooks: OperationHooks = {},
|
|
11
14
|
): Promise<ToolResult> {
|
|
12
15
|
return runToolResult(
|
|
13
|
-
startWorkflow(params, process.cwd()),
|
|
16
|
+
withRepositoryLock(process.cwd(), startWorkflow(params, process.cwd())),
|
|
14
17
|
makeAppLive(hostAdapter),
|
|
18
|
+
{ signal: hooks.signal, operation: "workflow_start" },
|
|
15
19
|
)
|
|
16
20
|
}
|
|
@@ -4,11 +4,21 @@ import type { ToolResult } from "../result.ts"
|
|
|
4
4
|
import { runToolResult } from "../run-tool.ts"
|
|
5
5
|
import { resetRoundsWorkflow } from "../workflows/reset.ts"
|
|
6
6
|
import { statusWorkflow } from "../workflows/status.ts"
|
|
7
|
+
import { withRepositoryLock } from "../services/operation-lock.ts"
|
|
8
|
+
import type { OperationHooks } from "../operation-hooks.ts"
|
|
7
9
|
|
|
8
10
|
export async function workflowStatus(
|
|
9
11
|
hostAdapter: ApneaHostAdapter = neutralHostAdapter,
|
|
12
|
+
hooks: OperationHooks = {},
|
|
10
13
|
): Promise<ToolResult> {
|
|
11
|
-
return runToolResult(
|
|
14
|
+
return runToolResult(
|
|
15
|
+
statusWorkflow(process.cwd()),
|
|
16
|
+
makeAppLive(hostAdapter),
|
|
17
|
+
{
|
|
18
|
+
signal: hooks.signal,
|
|
19
|
+
operation: "workflow_status",
|
|
20
|
+
},
|
|
21
|
+
)
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
export async function workflowResetRounds(
|
|
@@ -16,9 +26,14 @@ export async function workflowResetRounds(
|
|
|
16
26
|
gate: string
|
|
17
27
|
},
|
|
18
28
|
hostAdapter: ApneaHostAdapter = neutralHostAdapter,
|
|
29
|
+
hooks: OperationHooks = {},
|
|
19
30
|
): Promise<ToolResult> {
|
|
20
31
|
return runToolResult(
|
|
21
|
-
|
|
32
|
+
withRepositoryLock(
|
|
33
|
+
process.cwd(),
|
|
34
|
+
resetRoundsWorkflow(params, process.cwd()),
|
|
35
|
+
),
|
|
22
36
|
makeAppLive(hostAdapter),
|
|
37
|
+
{ signal: hooks.signal, operation: "workflow_reset_rounds" },
|
|
23
38
|
)
|
|
24
39
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type WaitHooks,
|
|
8
8
|
type WaitParams,
|
|
9
9
|
} from "../workflows/wait.ts"
|
|
10
|
+
import { withRepositoryLock } from "../services/operation-lock.ts"
|
|
10
11
|
|
|
11
12
|
export async function workflowWait(
|
|
12
13
|
params: WaitParams,
|
|
@@ -14,7 +15,11 @@ export async function workflowWait(
|
|
|
14
15
|
hooks: WaitHooks = {},
|
|
15
16
|
): Promise<ToolResult> {
|
|
16
17
|
return runToolResult(
|
|
17
|
-
|
|
18
|
+
withRepositoryLock(
|
|
19
|
+
process.cwd(),
|
|
20
|
+
waitWorkflow(params, process.cwd(), hooks),
|
|
21
|
+
),
|
|
18
22
|
makeAppLive(hostAdapter),
|
|
23
|
+
// waitWorkflow owns signal cancellation so it can preserve WaitAborted.
|
|
19
24
|
)
|
|
20
25
|
}
|
package/extension/api.ts
CHANGED
|
@@ -9,7 +9,13 @@ export {
|
|
|
9
9
|
type Operation,
|
|
10
10
|
type ExecuteOperation,
|
|
11
11
|
} from "./registry.ts"
|
|
12
|
-
export {
|
|
12
|
+
export type { OperationHooks } from "./operation-hooks.ts"
|
|
13
|
+
export {
|
|
14
|
+
parseFlags,
|
|
15
|
+
parseNumFlag,
|
|
16
|
+
parseOperationArgs,
|
|
17
|
+
type ParsedOperationArgs,
|
|
18
|
+
} from "./cli/parse.ts"
|
|
13
19
|
export { DISPATCH_KINDS, type DispatchKind } from "./domain/state-machine.ts"
|
|
14
20
|
export { packageRoot } from "./domain/paths.ts"
|
|
15
21
|
export { formatResult, toolContent, type ToolResult } from "./result.ts"
|
package/extension/cli/main.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { parseFlags, parseNumFlag } from "./parse.ts"
|
|
2
|
+
import { parseFlags, parseNumFlag, parseOperationArgs } from "./parse.ts"
|
|
3
3
|
import {
|
|
4
4
|
EXIT_ERROR,
|
|
5
5
|
EXIT_USAGE,
|
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
renderJson,
|
|
9
9
|
} from "./format.ts"
|
|
10
10
|
import { confirmHuman, prodHumanGateDeps } from "./human-gate.ts"
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
OPERATIONS,
|
|
13
|
+
executeOperation,
|
|
14
|
+
findByVerb,
|
|
15
|
+
type ExecuteOperation,
|
|
16
|
+
} from "../registry.ts"
|
|
12
17
|
import { DISPATCH_KINDS } from "../domain/state-machine.ts"
|
|
13
18
|
import type { ToolResult } from "../result.ts"
|
|
14
19
|
|
|
@@ -48,14 +53,18 @@ function printUsageError(message: string, json: boolean): void {
|
|
|
48
53
|
|
|
49
54
|
export async function main(argv: string[]): Promise<number> {
|
|
50
55
|
const [verbRaw, ...rest] = argv
|
|
51
|
-
const
|
|
52
|
-
const json = flags.has("json")
|
|
56
|
+
const json = parseFlags(rest).flags.has("json")
|
|
53
57
|
|
|
54
58
|
if (!verbRaw) {
|
|
55
59
|
printUsageError("usage: apnea <command> [args] [--json]", json)
|
|
56
60
|
return EXIT_USAGE
|
|
57
61
|
}
|
|
58
62
|
if (verbRaw === "help" || verbRaw === "--help") {
|
|
63
|
+
const parsed = parseOperationArgs("help", rest, { surface: "cli" })
|
|
64
|
+
if (!parsed.ok) {
|
|
65
|
+
printUsageError(parsed.message, json)
|
|
66
|
+
return EXIT_USAGE
|
|
67
|
+
}
|
|
59
68
|
console.log(usage())
|
|
60
69
|
return 0
|
|
61
70
|
}
|
|
@@ -68,6 +77,13 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
68
77
|
return EXIT_USAGE
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
const parsed = parseOperationArgs(verbRaw, rest, { surface: "cli" })
|
|
81
|
+
if (!parsed.ok) {
|
|
82
|
+
printUsageError(parsed.message, json)
|
|
83
|
+
return EXIT_USAGE
|
|
84
|
+
}
|
|
85
|
+
const { flags, values, positional } = parsed
|
|
86
|
+
|
|
71
87
|
const built = buildParams(
|
|
72
88
|
op.verb,
|
|
73
89
|
isAction ? verbRaw : null,
|
|
@@ -97,13 +113,53 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
97
113
|
}
|
|
98
114
|
}
|
|
99
115
|
|
|
100
|
-
const result = await
|
|
116
|
+
const result = await executeWithSignals(
|
|
117
|
+
executeOperation,
|
|
118
|
+
op.verb,
|
|
119
|
+
built.params,
|
|
120
|
+
)
|
|
101
121
|
const text = json ? renderJson(result) : renderHuman(result)
|
|
102
122
|
if (result.ok) console.log(text)
|
|
103
123
|
else console.error(text)
|
|
104
124
|
return exitCodeFor(result)
|
|
105
125
|
}
|
|
106
126
|
|
|
127
|
+
export type SignalTarget = {
|
|
128
|
+
on: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown
|
|
129
|
+
off: (signal: "SIGINT" | "SIGTERM", listener: () => void) => unknown
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function executeWithSignals(
|
|
133
|
+
execute: ExecuteOperation,
|
|
134
|
+
verb: string,
|
|
135
|
+
params: Record<string, unknown>,
|
|
136
|
+
target: SignalTarget = process,
|
|
137
|
+
exit: (code: number) => void = (code) => process.exit(code),
|
|
138
|
+
): Promise<ToolResult> {
|
|
139
|
+
const controller = new AbortController()
|
|
140
|
+
let interrupts = 0
|
|
141
|
+
const abort = () => controller.abort()
|
|
142
|
+
const onInterrupt = () => {
|
|
143
|
+
interrupts += 1
|
|
144
|
+
if (interrupts >= 2) {
|
|
145
|
+
// A second Ctrl+C must always terminate, even if an operation stopped
|
|
146
|
+
// observing the abort signal.
|
|
147
|
+
controller.abort()
|
|
148
|
+
exit(130)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
abort()
|
|
152
|
+
}
|
|
153
|
+
target.on("SIGINT", onInterrupt)
|
|
154
|
+
target.on("SIGTERM", abort)
|
|
155
|
+
try {
|
|
156
|
+
return await execute(verb, params, { signal: controller.signal })
|
|
157
|
+
} finally {
|
|
158
|
+
target.off("SIGINT", onInterrupt)
|
|
159
|
+
target.off("SIGTERM", abort)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
107
163
|
export type BuildParamsResult =
|
|
108
164
|
| { ok: true; params: Record<string, unknown> }
|
|
109
165
|
| { ok: false; message?: string }
|
|
@@ -140,12 +196,16 @@ export function buildParams(
|
|
|
140
196
|
if (!kind || !DISPATCH_KINDS.includes(kind as never)) {
|
|
141
197
|
return {
|
|
142
198
|
ok: false,
|
|
143
|
-
message: `usage: apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework]`,
|
|
199
|
+
message: `usage: apnea dispatch <${DISPATCH_KINDS.join("|")}> [--rework] [--redeliver]`,
|
|
144
200
|
}
|
|
145
201
|
}
|
|
146
202
|
return {
|
|
147
203
|
ok: true,
|
|
148
|
-
params: {
|
|
204
|
+
params: {
|
|
205
|
+
kind,
|
|
206
|
+
rework: flags.has("rework") || undefined,
|
|
207
|
+
redeliver: flags.has("redeliver") || undefined,
|
|
208
|
+
},
|
|
149
209
|
}
|
|
150
210
|
}
|
|
151
211
|
case "wait": {
|