@sebastianandreasson/pi-autonomous-agents 0.5.0 → 0.5.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 +250 -81
- package/docs/PI_SUPERVISOR.md +1 -0
- package/package.json +3 -3
- package/src/cli.mjs +49 -1
- package/src/pi-repo.mjs +92 -11
- package/src/pi-rpc-adapter.mjs +63 -1
- package/src/pi-supervisor.mjs +27 -4
package/README.md
CHANGED
|
@@ -1,58 +1,79 @@
|
|
|
1
|
-
# PI
|
|
1
|
+
# PI Autonomous Agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@sebastianandreasson/pi-autonomous-agents` is an npm package for running a bounded unattended [PI](https://pi.dev/) workflow inside another repository.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- a fast verification step
|
|
7
|
-
- a skeptical `tester` pass
|
|
8
|
-
- optional periodic multimodal visual review
|
|
9
|
-
- tester-owned final commit by default
|
|
5
|
+
It orchestrates:
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
- a `developer` turn
|
|
8
|
+
- a fast local verification step
|
|
9
|
+
- an independent `tester` turn
|
|
10
|
+
- an optional focused `developerFix` turn when verification/tester finds a real issue
|
|
11
|
+
- optional periodic visual review from screenshots
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The package is intentionally generic. It handles supervision, prompts, runtime state, telemetry, retries, and guardrails. The consuming repo still owns its own tasks, instructions, tests, model endpoints, and screenshot capture flow.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -D @sebastianandreasson/pi-autonomous-agents
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then in the consuming repo, tell your agent:
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
Find SETUP.md in @sebastianandreasson/pi-autonomous-agents and set everything up for this repository.
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package ships a top-level [SETUP.md](./SETUP.md) specifically for that workflow.
|
|
28
|
+
|
|
29
|
+
## What This Package Owns
|
|
30
|
+
|
|
31
|
+
- unattended loop orchestration
|
|
32
|
+
- PI adapter integration
|
|
17
33
|
- config loading
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
34
|
+
- prompt assembly
|
|
35
|
+
- verification/tester/visual-review handoff
|
|
36
|
+
- timeout and loop guards
|
|
37
|
+
- telemetry and run summaries
|
|
38
|
+
- runtime isolation and stale-run recovery
|
|
23
39
|
|
|
24
|
-
## What
|
|
40
|
+
## What Each Repo Must Provide
|
|
25
41
|
|
|
26
42
|
- `TODOS.md`
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
43
|
+
- repo-specific `pi/DEVELOPER.md`
|
|
44
|
+
- repo-specific `pi/TESTER.md`
|
|
45
|
+
- a fast bounded `testCommand`
|
|
46
|
+
- model configuration that actually matches the local/cloud providers in use
|
|
47
|
+
- optionally a screenshot capture command for visual review
|
|
48
|
+
|
|
49
|
+
## Quick Start In A Repo
|
|
32
50
|
|
|
33
|
-
|
|
51
|
+
The normal setup shape is:
|
|
34
52
|
|
|
35
53
|
```text
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
pi-
|
|
49
|
-
pi-
|
|
50
|
-
pi-
|
|
51
|
-
pi-
|
|
52
|
-
pi-visual-once
|
|
53
|
-
|
|
54
|
+
TODOS.md
|
|
55
|
+
pi.config.json
|
|
56
|
+
pi/
|
|
57
|
+
DEVELOPER.md
|
|
58
|
+
TESTER.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Typical scripts:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"scripts": {
|
|
66
|
+
"pi:mock": "PI_CONFIG_FILE=pi.config.json PI_TRANSPORT=mock PI_TEST_CMD= pi-harness once",
|
|
67
|
+
"pi:once": "PI_CONFIG_FILE=pi.config.json pi-harness once",
|
|
68
|
+
"pi:run": "PI_CONFIG_FILE=pi.config.json pi-harness run",
|
|
69
|
+
"pi:report": "PI_CONFIG_FILE=pi.config.json pi-harness report",
|
|
70
|
+
"pi:visual:once": "PI_CONFIG_FILE=pi.config.json pi-harness visual-once"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
54
73
|
```
|
|
55
74
|
|
|
75
|
+
Start from [templates/pi.config.example.json](./templates/pi.config.example.json), [templates/DEVELOPER.md](./templates/DEVELOPER.md), [templates/TESTER.md](./templates/TESTER.md), and [templates/gitignore.fragment](./templates/gitignore.fragment).
|
|
76
|
+
|
|
56
77
|
## CLI
|
|
57
78
|
|
|
58
79
|
```bash
|
|
@@ -65,65 +86,213 @@ pi-harness adapter
|
|
|
65
86
|
pi-harness visual-review-worker
|
|
66
87
|
```
|
|
67
88
|
|
|
68
|
-
Use `PI_CONFIG_FILE` to point
|
|
69
|
-
|
|
70
|
-
## Setup In Another Repo
|
|
71
|
-
|
|
72
|
-
After installing the package:
|
|
89
|
+
Use `PI_CONFIG_FILE` to point at the repo-local config file:
|
|
73
90
|
|
|
74
91
|
```bash
|
|
75
|
-
|
|
92
|
+
PI_CONFIG_FILE=pi.config.json pi-harness once
|
|
76
93
|
```
|
|
77
94
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
If `PI_CONFIG_FILE` is not set, the package falls back to the bundled generic [pi.config.json](./pi.config.json).
|
|
96
|
+
|
|
97
|
+
## Core Workflow
|
|
98
|
+
|
|
99
|
+
Each real iteration works like this:
|
|
100
|
+
|
|
101
|
+
1. `developer` implements one unchecked task from `TODOS.md`.
|
|
102
|
+
2. The harness runs the configured fast verification command.
|
|
103
|
+
3. If verification passes, `tester` reviews the change independently.
|
|
104
|
+
4. If tester or verification fails, the findings go back to `developerFix` for one focused repair pass.
|
|
105
|
+
5. If tester reaches `PASS`, tester creates the final commit directly by default.
|
|
106
|
+
6. Every `N` successful iterations, optional visual review can inspect screenshots and veto the success if it finds a real problem.
|
|
107
|
+
|
|
108
|
+
The default commit model is `commitMode: "agent"`. The older harness-managed parsed commit-plan flow still exists as `commitMode: "plan"`, but it is now a compatibility mode rather than the default.
|
|
109
|
+
|
|
110
|
+
## Recommended Model Setup
|
|
111
|
+
|
|
112
|
+
The package supports:
|
|
113
|
+
|
|
114
|
+
- one default text model via `piModel`
|
|
115
|
+
- one default visual-review model via `visualReviewModel`
|
|
116
|
+
- optional per-role overrides via `roleModels`
|
|
117
|
+
- per-model endpoint config in `models`
|
|
118
|
+
|
|
119
|
+
Typical pattern:
|
|
120
|
+
|
|
121
|
+
- local model for `developer`
|
|
122
|
+
- local model for `developerRetry`
|
|
123
|
+
- local model for `developerFix`
|
|
124
|
+
- local or slightly stronger model for `tester`
|
|
125
|
+
- stronger frontier model only for `visualReview`
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"piModel": "local/text-model",
|
|
132
|
+
"visualReviewModel": "cloud/vision-model",
|
|
133
|
+
"models": {
|
|
134
|
+
"local/text-model": {
|
|
135
|
+
"baseUrl": "http://localhost:8000/v1",
|
|
136
|
+
"apiKey": "local",
|
|
137
|
+
"vision": false
|
|
138
|
+
},
|
|
139
|
+
"local/tester-model": {
|
|
140
|
+
"baseUrl": "http://localhost:8000/v1",
|
|
141
|
+
"apiKey": "local",
|
|
142
|
+
"vision": false
|
|
143
|
+
},
|
|
144
|
+
"cloud/vision-model": {
|
|
145
|
+
"baseUrl": "https://api.openai.com/v1",
|
|
146
|
+
"apiKeyEnv": "OPENAI_API_KEY",
|
|
147
|
+
"vision": true
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
"roleModels": {
|
|
151
|
+
"developer": "local/text-model",
|
|
152
|
+
"developerRetry": "local/text-model",
|
|
153
|
+
"developerFix": "local/text-model",
|
|
154
|
+
"tester": "local/tester-model",
|
|
155
|
+
"visualReview": "cloud/vision-model"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
82
158
|
```
|
|
83
159
|
|
|
84
|
-
|
|
160
|
+
Important:
|
|
161
|
+
|
|
162
|
+
- do not guess model ids
|
|
163
|
+
- if using a custom OpenAI-compatible provider, verify `<baseUrl>/models`
|
|
164
|
+
- if using PI models directly, verify `pi --list-models`
|
|
165
|
+
- if `PI_CODING_AGENT_DIR` points at a repo-local PI home, make sure it is bootstrapped and contains `models.json`
|
|
166
|
+
|
|
167
|
+
The harness now preflights those checks before starting a real run.
|
|
168
|
+
|
|
169
|
+
## Important Config Fields
|
|
170
|
+
|
|
171
|
+
Common fields in `pi.config.json`:
|
|
85
172
|
|
|
86
|
-
|
|
173
|
+
- `taskFile`
|
|
174
|
+
- `developerInstructionsFile`
|
|
175
|
+
- `testerInstructionsFile`
|
|
176
|
+
- `transport`
|
|
177
|
+
- `adapterCommand`
|
|
178
|
+
- `piModel`
|
|
179
|
+
- `models`
|
|
180
|
+
- `roleModels`
|
|
181
|
+
- `commitMode`
|
|
182
|
+
- `promptMode`
|
|
183
|
+
- `testCommand`
|
|
184
|
+
- `visualReviewEnabled`
|
|
185
|
+
- `visualCaptureCommand`
|
|
186
|
+
- `continueAfterSeconds`
|
|
187
|
+
- `toolContinueAfterSeconds`
|
|
188
|
+
- `noEventTimeoutSeconds`
|
|
189
|
+
- `toolNoEventTimeoutSeconds`
|
|
190
|
+
- `largeFileWarningLines`
|
|
191
|
+
- `largeSpecWarningLines`
|
|
87
192
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
Key defaults:
|
|
194
|
+
|
|
195
|
+
- `transport`: `adapter`
|
|
196
|
+
- `commitMode`: `agent`
|
|
197
|
+
- `promptMode`: `compact`
|
|
198
|
+
- `piTools`: `read,edit,write,find,ls,bash`
|
|
199
|
+
- `continueAfterSeconds`: `300`
|
|
200
|
+
- `toolContinueAfterSeconds`: `900`
|
|
201
|
+
- `noEventTimeoutSeconds`: `900`
|
|
202
|
+
- `toolNoEventTimeoutSeconds`: `1800`
|
|
203
|
+
|
|
204
|
+
## Prompt and Tooling Behavior
|
|
205
|
+
|
|
206
|
+
The package is optimized for local models by default:
|
|
207
|
+
|
|
208
|
+
- prompts are compacted before handoff
|
|
209
|
+
- changed-file lists and feedback excerpts are capped
|
|
210
|
+
- prompts prefer `read` for source inspection
|
|
211
|
+
- shell is intended for `git`, tests, and narrow diagnostics
|
|
212
|
+
- the adapter warns on obvious oversized shell-based file reads
|
|
213
|
+
- the supervisor emits large-file/spec warnings when touched files are getting risky
|
|
214
|
+
|
|
215
|
+
This is deliberate. Large monolith files, huge e2e specs, and broad TODO items are one of the main causes of local-model drift and retry loops.
|
|
216
|
+
|
|
217
|
+
Recommended repo shape:
|
|
218
|
+
|
|
219
|
+
- keep TODO items very small and implementation-shaped
|
|
220
|
+
- split giant stores/modules before they become constant edit hotspots
|
|
221
|
+
- split ever-growing end-to-end specs into scenario files
|
|
222
|
+
- keep the default `testCommand` to a bounded smoke check, not a multi-minute happy-path run
|
|
91
223
|
|
|
92
|
-
|
|
224
|
+
## Runtime Isolation And Recovery
|
|
93
225
|
|
|
94
|
-
|
|
95
|
-
For flow debugging, it also writes a machine-readable `.pi-last-iteration.json` summary with the selected task, tester verdict, commit-plan state, and terminal reason.
|
|
96
|
-
For run isolation, the supervisor also maintains `.pi-runtime/active-run.json` and stores PI sessions plus per-run telemetry under `.pi-runtime/runs/<runId>/`.
|
|
226
|
+
Recent versions of the package isolate each run more aggressively:
|
|
97
227
|
|
|
98
|
-
|
|
228
|
+
- active ownership lock at `.pi-runtime/active-run.json`
|
|
229
|
+
- per-run runtime directory under `.pi-runtime/runs/<runId>/`
|
|
230
|
+
- per-run PI sessions and telemetry
|
|
231
|
+
- `runId` added to telemetry
|
|
232
|
+
- in-progress iteration state persisted before agent work starts
|
|
233
|
+
- stale run locks recovered when the owning PID is gone
|
|
234
|
+
- timeout cleanup kills the full spawned process group, not only the direct child
|
|
235
|
+
- parent-death watchers shut down orphaned supervisor and adapter layers instead of letting them continue under `PPID 1`
|
|
99
236
|
|
|
100
|
-
-
|
|
101
|
-
- `developerInstructionsFile`: per-project developer instructions
|
|
102
|
-
- `testerInstructionsFile`: per-project tester instructions
|
|
103
|
-
- `roleModels`: optional per-role model overrides
|
|
104
|
-
- `commitMode`: `agent` by default, `plan` only for legacy harness-managed commit parsing
|
|
105
|
-
- `promptMode`: `compact` by default
|
|
106
|
-
- `testCommand`: fast verification command
|
|
107
|
-
- `visualCaptureCommand`: project-defined screenshot capture command
|
|
108
|
-
- `visualFeedbackFile`: latest visual-review handoff
|
|
109
|
-
- `testerFeedbackFile`: latest tester-review handoff
|
|
237
|
+
That is meant to prevent orphaned timed-out agents or concurrent supervisors from corrupting shared state.
|
|
110
238
|
|
|
111
|
-
|
|
239
|
+
## Debugging Artifacts
|
|
112
240
|
|
|
113
|
-
|
|
241
|
+
Useful files during a run:
|
|
114
242
|
|
|
115
|
-
|
|
243
|
+
- `.pi-last-prompt.txt`
|
|
244
|
+
Exact assembled prompt for the current role.
|
|
245
|
+
- `.pi-last-output.txt`
|
|
246
|
+
Latest agent output snapshot.
|
|
247
|
+
- `.pi-last-verification.txt`
|
|
248
|
+
Latest verification output snapshot.
|
|
249
|
+
- `.pi-last-iteration.json`
|
|
250
|
+
Structured summary of the last completed iteration.
|
|
251
|
+
- `.pi-state.json`
|
|
252
|
+
Persistent harness state, including in-progress iteration data.
|
|
253
|
+
- `pi.log`
|
|
254
|
+
Main run log.
|
|
255
|
+
- `pi_telemetry.jsonl`
|
|
256
|
+
- `pi_telemetry.csv`
|
|
257
|
+
- `.pi-runtime/active-run.json`
|
|
258
|
+
- `.pi-runtime/runs/<runId>/...`
|
|
116
259
|
|
|
117
|
-
|
|
260
|
+
`pi-harness report` summarizes recent telemetry and surfaces things like terminal reasons and large-file warnings.
|
|
118
261
|
|
|
119
|
-
|
|
262
|
+
## Visual Review Contract
|
|
120
263
|
|
|
121
|
-
|
|
264
|
+
Visual review is optional and generic. The harness does not know how to navigate your app.
|
|
122
265
|
|
|
123
|
-
|
|
266
|
+
If enabled, your repo must provide a real screenshot capture command that writes a manifest under the configured capture directory. The manifest shape is documented in [docs/PI_SUPERVISOR.md](./docs/PI_SUPERVISOR.md).
|
|
124
267
|
|
|
125
|
-
|
|
268
|
+
Visual review should be used as a periodic audit, not as the default inner-loop gate.
|
|
269
|
+
|
|
270
|
+
## Resetting Harness State
|
|
271
|
+
|
|
272
|
+
If you want to wipe harness-generated state and start fresh:
|
|
126
273
|
|
|
127
|
-
|
|
274
|
+
```bash
|
|
275
|
+
PI_CONFIG_FILE=pi.config.json pi-harness clear-history
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
That clears configured harness runtime/history artifacts and verifies they are gone. It does not remove project source files.
|
|
279
|
+
|
|
280
|
+
## Docs
|
|
281
|
+
|
|
282
|
+
- [SETUP.md](./SETUP.md)
|
|
283
|
+
Agent-facing setup instructions for consuming repos.
|
|
284
|
+
- [docs/PI_SUPERVISOR.md](./docs/PI_SUPERVISOR.md)
|
|
285
|
+
More detailed flow, adapter, and runtime documentation.
|
|
286
|
+
- [templates/PROJECT_SETUP.md](./templates/PROJECT_SETUP.md)
|
|
287
|
+
Minimal consuming-repo layout summary.
|
|
288
|
+
|
|
289
|
+
## Development
|
|
290
|
+
|
|
291
|
+
In this package repo:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
npm run check
|
|
295
|
+
npm test
|
|
296
|
+
```
|
|
128
297
|
|
|
129
|
-
The
|
|
298
|
+
The package requires Node `>=20`.
|
package/docs/PI_SUPERVISOR.md
CHANGED
|
@@ -222,6 +222,7 @@ The built-in adapter mitigates obvious local loops by watching PI RPC tool event
|
|
|
222
222
|
- a soft `continue` can be sent after inactivity
|
|
223
223
|
- a separate tool-aware watchdog can tolerate long-running `bash` or browser work without treating the turn as dead
|
|
224
224
|
- a hard no-event timeout aborts a wedged turn instead of hanging indefinitely
|
|
225
|
+
- parent-loss shutdown tears down the owned supervisor/adapter/PI child tree instead of allowing orphaned background runs
|
|
225
226
|
|
|
226
227
|
Important: terminal streaming does not reset the heartbeat by itself. The watchdog keys off PI RPC events and active tool state, not raw shell output.
|
|
227
228
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sebastianandreasson/pi-autonomous-agents",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Portable unattended PI harness for developer/tester/visual-review loops.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"pi-harness": "./src/cli.mjs"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-rpc-adapter.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-telemetry.test.mjs",
|
|
20
|
-
"test": "node --test test/pi-heartbeat.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs test/pi-repo.test.mjs test/pi-telemetry.test.mjs"
|
|
19
|
+
"check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-rpc-adapter.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-lifecycle.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-telemetry.test.mjs && node --check test/fixtures/fake-pi.mjs",
|
|
20
|
+
"test": "node --test test/pi-heartbeat.test.mjs test/pi-lifecycle.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs test/pi-repo.test.mjs test/pi-telemetry.test.mjs"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,11 @@ import path from 'node:path'
|
|
|
4
4
|
import { spawn } from 'node:child_process'
|
|
5
5
|
import process from 'node:process'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import {
|
|
8
|
+
registerOwnedChildProcess,
|
|
9
|
+
signalChildProcess,
|
|
10
|
+
watchParentProcess,
|
|
11
|
+
} from './pi-repo.mjs'
|
|
7
12
|
|
|
8
13
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|
9
14
|
|
|
@@ -36,10 +41,53 @@ function main() {
|
|
|
36
41
|
env: process.env,
|
|
37
42
|
stdio: 'inherit',
|
|
38
43
|
})
|
|
44
|
+
registerOwnedChildProcess(child)
|
|
45
|
+
|
|
46
|
+
let shuttingDown = false
|
|
47
|
+
let forceKillTimer = null
|
|
48
|
+
const stopWatchingParent = watchParentProcess(() => {
|
|
49
|
+
shutdown({
|
|
50
|
+
signal: 'SIGTERM',
|
|
51
|
+
exitCode: 1,
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function shutdown({
|
|
56
|
+
signal,
|
|
57
|
+
exitCode,
|
|
58
|
+
}) {
|
|
59
|
+
if (shuttingDown) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
shuttingDown = true
|
|
64
|
+
stopWatchingParent()
|
|
65
|
+
signalChildProcess(child.pid, signal)
|
|
66
|
+
forceKillTimer = setTimeout(() => {
|
|
67
|
+
signalChildProcess(child.pid, 'SIGKILL')
|
|
68
|
+
}, 1000)
|
|
69
|
+
if (typeof forceKillTimer.unref === 'function') {
|
|
70
|
+
forceKillTimer.unref()
|
|
71
|
+
}
|
|
72
|
+
process.exitCode = exitCode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
76
|
+
process.on(signal, () => {
|
|
77
|
+
shutdown({
|
|
78
|
+
signal,
|
|
79
|
+
exitCode: 128,
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
39
83
|
|
|
40
84
|
child.on('exit', (code, signal) => {
|
|
85
|
+
stopWatchingParent()
|
|
86
|
+
if (forceKillTimer) {
|
|
87
|
+
clearTimeout(forceKillTimer)
|
|
88
|
+
}
|
|
41
89
|
if (signal) {
|
|
42
|
-
process.
|
|
90
|
+
process.exitCode = 128
|
|
43
91
|
return
|
|
44
92
|
}
|
|
45
93
|
process.exitCode = code ?? 1
|
package/src/pi-repo.mjs
CHANGED
|
@@ -114,6 +114,57 @@ export function isProcessRunning(pid) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
const ownedChildren = new Map()
|
|
118
|
+
|
|
119
|
+
export function registerOwnedChildProcess(child, options = {}) {
|
|
120
|
+
const pid = normalizePid(child?.pid)
|
|
121
|
+
if (pid <= 0) {
|
|
122
|
+
return () => {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entry = {
|
|
126
|
+
useProcessGroup: options.useProcessGroup === true && process.platform !== 'win32',
|
|
127
|
+
}
|
|
128
|
+
ownedChildren.set(pid, entry)
|
|
129
|
+
|
|
130
|
+
const unregister = () => {
|
|
131
|
+
ownedChildren.delete(pid)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof child?.once === 'function') {
|
|
135
|
+
child.once('exit', unregister)
|
|
136
|
+
child.once('close', unregister)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return unregister
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function signalChildProcess(pid, signal, options = {}) {
|
|
143
|
+
const normalizedPid = normalizePid(pid)
|
|
144
|
+
if (normalizedPid <= 0) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (options.useProcessGroup === true && process.platform !== 'win32') {
|
|
150
|
+
process.kill(-normalizedPid, signal)
|
|
151
|
+
} else {
|
|
152
|
+
process.kill(normalizedPid, signal)
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function signalOwnedChildProcesses(signal) {
|
|
161
|
+
let handled = false
|
|
162
|
+
for (const [pid, entry] of [...ownedChildren.entries()]) {
|
|
163
|
+
handled = signalChildProcess(pid, signal, entry) || handled
|
|
164
|
+
}
|
|
165
|
+
return handled
|
|
166
|
+
}
|
|
167
|
+
|
|
117
168
|
export async function readJsonFile(filePath, fallback = null) {
|
|
118
169
|
try {
|
|
119
170
|
const raw = await fs.readFile(filePath, 'utf8')
|
|
@@ -211,20 +262,45 @@ export async function releaseRunLock(lockFile, runId) {
|
|
|
211
262
|
}
|
|
212
263
|
|
|
213
264
|
export function signalProcessTree(pid, signal) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
265
|
+
return signalChildProcess(pid, signal, { useProcessGroup: true })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function watchParentProcess(onParentExit, options = {}) {
|
|
269
|
+
const expectedParentPid = normalizePid(options.parentPid ?? process.ppid)
|
|
270
|
+
if (expectedParentPid <= 0 || typeof onParentExit !== 'function') {
|
|
271
|
+
return () => {}
|
|
217
272
|
}
|
|
218
273
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
274
|
+
let active = true
|
|
275
|
+
const intervalMs = Number.isFinite(Number(options.intervalMs))
|
|
276
|
+
? Math.max(100, Number(options.intervalMs))
|
|
277
|
+
: 1000
|
|
278
|
+
|
|
279
|
+
const interval = setInterval(() => {
|
|
280
|
+
if (!active) {
|
|
281
|
+
return
|
|
224
282
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
283
|
+
|
|
284
|
+
const currentParentPid = normalizePid(process.ppid)
|
|
285
|
+
if (currentParentPid === expectedParentPid && currentParentPid > 1) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
active = false
|
|
290
|
+
clearInterval(interval)
|
|
291
|
+
onParentExit({
|
|
292
|
+
expectedParentPid,
|
|
293
|
+
currentParentPid,
|
|
294
|
+
})
|
|
295
|
+
}, intervalMs)
|
|
296
|
+
|
|
297
|
+
if (typeof interval.unref === 'function') {
|
|
298
|
+
interval.unref()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return () => {
|
|
302
|
+
active = false
|
|
303
|
+
clearInterval(interval)
|
|
228
304
|
}
|
|
229
305
|
}
|
|
230
306
|
|
|
@@ -474,6 +550,9 @@ export async function runShellCommand({
|
|
|
474
550
|
detached: process.platform !== 'win32',
|
|
475
551
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
476
552
|
})
|
|
553
|
+
const unregisterChild = registerOwnedChildProcess(child, {
|
|
554
|
+
useProcessGroup: process.platform !== 'win32',
|
|
555
|
+
})
|
|
477
556
|
|
|
478
557
|
let stdout = ''
|
|
479
558
|
let stderr = ''
|
|
@@ -506,6 +585,7 @@ export async function runShellCommand({
|
|
|
506
585
|
})
|
|
507
586
|
|
|
508
587
|
child.on('error', (error) => {
|
|
588
|
+
unregisterChild()
|
|
509
589
|
if (killTimer) {
|
|
510
590
|
clearTimeout(killTimer)
|
|
511
591
|
}
|
|
@@ -524,6 +604,7 @@ export async function runShellCommand({
|
|
|
524
604
|
})
|
|
525
605
|
|
|
526
606
|
child.on('close', (code) => {
|
|
607
|
+
unregisterChild()
|
|
527
608
|
if (killTimer) {
|
|
528
609
|
clearTimeout(killTimer)
|
|
529
610
|
}
|
package/src/pi-rpc-adapter.mjs
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
getHeartbeatDecision,
|
|
11
11
|
resolveHeartbeatConfig,
|
|
12
12
|
} from './pi-heartbeat.mjs'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
registerOwnedChildProcess,
|
|
15
|
+
signalOwnedChildProcesses,
|
|
16
|
+
signalProcessTree,
|
|
17
|
+
watchParentProcess,
|
|
18
|
+
} from './pi-repo.mjs'
|
|
14
19
|
|
|
15
20
|
function createJsonlReader(stream, onLine) {
|
|
16
21
|
const rl = createInterface({ input: stream })
|
|
@@ -155,6 +160,9 @@ async function run() {
|
|
|
155
160
|
detached: process.platform !== 'win32',
|
|
156
161
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
157
162
|
})
|
|
163
|
+
registerOwnedChildProcess(child, {
|
|
164
|
+
useProcessGroup: process.platform !== 'win32',
|
|
165
|
+
})
|
|
158
166
|
|
|
159
167
|
let stderr = ''
|
|
160
168
|
const events = []
|
|
@@ -197,6 +205,36 @@ async function run() {
|
|
|
197
205
|
let continueAttempted = false
|
|
198
206
|
let continueAccepted = false
|
|
199
207
|
let continueRejected = false
|
|
208
|
+
let shutdownRequested = false
|
|
209
|
+
let shutdownReason = ''
|
|
210
|
+
let shutdownEscalationTimer = null
|
|
211
|
+
|
|
212
|
+
const requestShutdown = (reason, signal = 'SIGTERM') => {
|
|
213
|
+
if (shutdownRequested) {
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
shutdownRequested = true
|
|
218
|
+
shutdownReason = String(reason ?? 'adapter_shutdown')
|
|
219
|
+
closeAssistantLine()
|
|
220
|
+
signalOwnedChildProcesses(signal)
|
|
221
|
+
shutdownEscalationTimer = setTimeout(() => {
|
|
222
|
+
signalOwnedChildProcesses('SIGKILL')
|
|
223
|
+
}, 1000)
|
|
224
|
+
if (typeof shutdownEscalationTimer.unref === 'function') {
|
|
225
|
+
shutdownEscalationTimer.unref()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const stopWatchingParent = watchParentProcess(() => {
|
|
230
|
+
requestShutdown('parent_exit', 'SIGTERM')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
234
|
+
process.on(signal, () => {
|
|
235
|
+
requestShutdown(signal, signal)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
200
238
|
|
|
201
239
|
const writeLive = (text) => {
|
|
202
240
|
if (!streamTerminal) {
|
|
@@ -460,6 +498,26 @@ async function run() {
|
|
|
460
498
|
|
|
461
499
|
await waitForAgentEnd()
|
|
462
500
|
|
|
501
|
+
if (shutdownRequested) {
|
|
502
|
+
console.log(JSON.stringify({
|
|
503
|
+
sessionId: request.sessionId ?? '',
|
|
504
|
+
sessionFile: request.sessionFile ?? '',
|
|
505
|
+
status: 'failed',
|
|
506
|
+
output: '',
|
|
507
|
+
notes: shutdownReason,
|
|
508
|
+
role: '',
|
|
509
|
+
model: requestedModel,
|
|
510
|
+
toolCalls: 0,
|
|
511
|
+
toolErrors: 0,
|
|
512
|
+
messageUpdates: 0,
|
|
513
|
+
stopReason: '',
|
|
514
|
+
loopDetected: false,
|
|
515
|
+
loopSignature: '',
|
|
516
|
+
terminalReason: 'adapter_shutdown',
|
|
517
|
+
}))
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
|
|
463
521
|
if (heartbeatTimedOut) {
|
|
464
522
|
const toolCalls = events.filter((event) => event.type === 'tool_execution_start').length
|
|
465
523
|
const toolErrors = events.filter((event) => event.type === 'tool_execution_end' && event.isError).length
|
|
@@ -571,9 +629,13 @@ async function run() {
|
|
|
571
629
|
terminalReason,
|
|
572
630
|
}))
|
|
573
631
|
} finally {
|
|
632
|
+
stopWatchingParent()
|
|
574
633
|
if (heartbeatInterval) {
|
|
575
634
|
clearInterval(heartbeatInterval)
|
|
576
635
|
}
|
|
636
|
+
if (shutdownEscalationTimer) {
|
|
637
|
+
clearTimeout(shutdownEscalationTimer)
|
|
638
|
+
}
|
|
577
639
|
stopReading()
|
|
578
640
|
for (const current of pending.values()) {
|
|
579
641
|
current.reject(new Error('RPC adapter shutting down'))
|
package/src/pi-supervisor.mjs
CHANGED
|
@@ -30,11 +30,13 @@ import {
|
|
|
30
30
|
releaseRunLock,
|
|
31
31
|
runVerification,
|
|
32
32
|
runShellCommand,
|
|
33
|
+
signalOwnedChildProcesses,
|
|
33
34
|
stageFiles,
|
|
34
35
|
unstageFiles,
|
|
35
36
|
updateRunLock,
|
|
36
37
|
runVisualCapture,
|
|
37
38
|
timestamp,
|
|
39
|
+
watchParentProcess,
|
|
38
40
|
writeChangedFiles,
|
|
39
41
|
writeSessionId,
|
|
40
42
|
writeState,
|
|
@@ -49,13 +51,30 @@ import {
|
|
|
49
51
|
import { runStartupPreflight } from './pi-preflight.mjs'
|
|
50
52
|
|
|
51
53
|
let stopRequested = false
|
|
54
|
+
let shutdownEscalationTimer = null
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
function requestStop() {
|
|
54
57
|
stopRequested = true
|
|
55
|
-
|
|
58
|
+
signalOwnedChildProcesses('SIGTERM')
|
|
59
|
+
|
|
60
|
+
if (!shutdownEscalationTimer) {
|
|
61
|
+
shutdownEscalationTimer = setTimeout(() => {
|
|
62
|
+
signalOwnedChildProcesses('SIGKILL')
|
|
63
|
+
}, 1000)
|
|
64
|
+
if (typeof shutdownEscalationTimer.unref === 'function') {
|
|
65
|
+
shutdownEscalationTimer.unref()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
56
69
|
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
71
|
+
process.on(signal, () => {
|
|
72
|
+
requestStop()
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stopWatchingParent = watchParentProcess(() => {
|
|
77
|
+
requestStop()
|
|
59
78
|
})
|
|
60
79
|
|
|
61
80
|
function sleep(seconds) {
|
|
@@ -1728,6 +1747,10 @@ async function main() {
|
|
|
1728
1747
|
await appendLog(config.logFile, 'Stop requested by signal')
|
|
1729
1748
|
}
|
|
1730
1749
|
} finally {
|
|
1750
|
+
stopWatchingParent()
|
|
1751
|
+
if (shutdownEscalationTimer) {
|
|
1752
|
+
clearTimeout(shutdownEscalationTimer)
|
|
1753
|
+
}
|
|
1731
1754
|
await updateRunOwnership(config, {
|
|
1732
1755
|
status: stopRequested ? 'stopped' : 'finished',
|
|
1733
1756
|
heartbeatAt: timestamp(),
|