@pi-in-go/pigpen-herdr 0.1.0
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/LICENSE +21 -0
- package/README.md +20 -0
- package/extensions/herdr/bench_test.go +55 -0
- package/extensions/herdr/diagnostics_test.go +92 -0
- package/extensions/herdr/extension.go +554 -0
- package/extensions/herdr/extension_test.go +772 -0
- package/extensions/herdr/go.mod +6 -0
- package/extensions/herdr/go.sum +2 -0
- package/extensions/herdr/harness_test.go +449 -0
- package/extensions/herdr/procattr_other.go +13 -0
- package/extensions/herdr/procattr_windows.go +16 -0
- package/package.json +35 -0
- package/provenance.json +7 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
// Package herdr reports PiG's agent state to herdr, the terminal workspace manager.
|
|
2
|
+
//
|
|
3
|
+
// It follows herdr's published "Add Herdr support to your agent" contract
|
|
4
|
+
// (https://herdr.dev/docs/add-herdr-support/): inside a herdr pane, report idle,
|
|
5
|
+
// working or blocked through `"$HERDR_BIN_PATH" pane report-agent`, keep the
|
|
6
|
+
// report order with an increasing --seq, report the command that resumes the
|
|
7
|
+
// session (herdr 0.9.2+ restores the pane with it after a server restart), and
|
|
8
|
+
// release the pane when the user quits. Outside herdr the factory registers
|
|
9
|
+
// nothing.
|
|
10
|
+
//
|
|
11
|
+
// It is written against PiG's public Go extension SDK only, so it builds as a
|
|
12
|
+
// source extension and fuses into a Piglet Binary.
|
|
13
|
+
package herdr
|
|
14
|
+
|
|
15
|
+
import (
|
|
16
|
+
"context"
|
|
17
|
+
"errors"
|
|
18
|
+
"fmt"
|
|
19
|
+
"os"
|
|
20
|
+
"os/exec"
|
|
21
|
+
"strconv"
|
|
22
|
+
"strings"
|
|
23
|
+
"sync"
|
|
24
|
+
"time"
|
|
25
|
+
"unicode"
|
|
26
|
+
|
|
27
|
+
sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const (
|
|
31
|
+
// source is the stable integration identity. herdr reserves the `herdr:` prefix for its own sources.
|
|
32
|
+
source = "custom:pig"
|
|
33
|
+
agent = "pig"
|
|
34
|
+
// callTimeout bounds every herdr CLI call: herdr must never slow the agent down.
|
|
35
|
+
callTimeout = 3 * time.Second
|
|
36
|
+
// maxMessageLength bounds the sidebar message, in characters.
|
|
37
|
+
maxMessageLength = 120
|
|
38
|
+
// command is the first word of the resume command: a plain command name on PATH, as herdr requires.
|
|
39
|
+
command = "pig"
|
|
40
|
+
// herdr refuses a resume command of more than 64 arguments or 8 KiB in total.
|
|
41
|
+
maxResumeArgs = 64
|
|
42
|
+
maxResumeBytes = 8 * 1024
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
type agentState string
|
|
46
|
+
|
|
47
|
+
const (
|
|
48
|
+
stateIdle agentState = "idle"
|
|
49
|
+
stateWorking agentState = "working"
|
|
50
|
+
stateBlocked agentState = "blocked"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
type report struct {
|
|
54
|
+
state agentState
|
|
55
|
+
message string
|
|
56
|
+
seq int64
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var (
|
|
60
|
+
seqMu sync.Mutex
|
|
61
|
+
lastSeq int64
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// nextSeq is monotonic across every reporter in this process and, because it is
|
|
65
|
+
// seeded from the clock (microseconds), across restarts and session
|
|
66
|
+
// replacements. herdr drops reports whose seq is not higher than the last one it
|
|
67
|
+
// accepted.
|
|
68
|
+
func nextSeq() int64 {
|
|
69
|
+
seqMu.Lock()
|
|
70
|
+
defer seqMu.Unlock()
|
|
71
|
+
lastSeq = max(lastSeq+1, time.Now().UnixMicro())
|
|
72
|
+
return lastSeq
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type herdrEnv struct {
|
|
76
|
+
bin string
|
|
77
|
+
paneID string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// readEnv reports whether this process runs inside a herdr pane. The contract
|
|
81
|
+
// is HERDR_ENV=1 and HERDR_PANE_ID, HERDR_BIN_PATH and HERDR_SOCKET_PATH all set;
|
|
82
|
+
// otherwise the integration does nothing. The reports go through the CLI, which
|
|
83
|
+
// finds the socket itself from the same environment.
|
|
84
|
+
func readEnv() (herdrEnv, bool) {
|
|
85
|
+
bin, pane, socket := os.Getenv("HERDR_BIN_PATH"), os.Getenv("HERDR_PANE_ID"), os.Getenv("HERDR_SOCKET_PATH")
|
|
86
|
+
if os.Getenv("HERDR_ENV") != "1" || bin == "" || pane == "" || socket == "" {
|
|
87
|
+
return herdrEnv{}, false
|
|
88
|
+
}
|
|
89
|
+
return herdrEnv{bin: bin, paneID: pane}, true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Extension is the conventional Go factory. Outside a herdr pane it registers nothing.
|
|
93
|
+
func Extension() *sdk.Extension {
|
|
94
|
+
e := sdk.New("herdr")
|
|
95
|
+
env, ok := readEnv()
|
|
96
|
+
if !ok {
|
|
97
|
+
return e
|
|
98
|
+
}
|
|
99
|
+
r := &reporter{env: env}
|
|
100
|
+
|
|
101
|
+
// PiG tells every extension when a blocking UI prompt (select, confirm,
|
|
102
|
+
// input, editor, custom) opens and settles.
|
|
103
|
+
e.OnEvent(sdk.EventUIPromptStart, func(_ sdk.Context, data map[string]any) (any, error) {
|
|
104
|
+
title, _ := data["title"].(string)
|
|
105
|
+
if title == "" {
|
|
106
|
+
title, _ = data["kind"].(string)
|
|
107
|
+
}
|
|
108
|
+
r.block(title)
|
|
109
|
+
return nil, nil
|
|
110
|
+
})
|
|
111
|
+
e.OnEvent(sdk.EventUIPromptEnd, func(sdk.Context, map[string]any) (any, error) {
|
|
112
|
+
r.unblock()
|
|
113
|
+
return nil, nil
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
e.OnSessionStart(func(ctx sdk.Context, _ map[string]any) (any, error) {
|
|
117
|
+
// herdr can only show a pane's terminal, so report the interactive session
|
|
118
|
+
// and stay silent in RPC, JSON and print runs.
|
|
119
|
+
if ctx.Mode() != "tui" {
|
|
120
|
+
return nil, nil
|
|
121
|
+
}
|
|
122
|
+
path, id := sessionIdentity(ctx)
|
|
123
|
+
// A reload can start this reporter in the middle of a turn.
|
|
124
|
+
idle, err := ctx.IsIdle()
|
|
125
|
+
r.start(path, id, err == nil && !idle)
|
|
126
|
+
return nil, nil
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
e.OnEvent(sdk.EventAgentStart, func(ctx sdk.Context, _ map[string]any) (any, error) {
|
|
130
|
+
if !r.isRoot() {
|
|
131
|
+
return nil, nil
|
|
132
|
+
}
|
|
133
|
+
path, id := sessionIdentity(ctx)
|
|
134
|
+
r.agentStarted(path, id)
|
|
135
|
+
return nil, nil
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// agent_settled fires once the run is fully over: no retry, compaction or
|
|
139
|
+
// queued continuation follows, so it is the right moment to report idle.
|
|
140
|
+
e.OnEvent(sdk.EventAgentSettled, func(ctx sdk.Context, _ map[string]any) (any, error) {
|
|
141
|
+
if !r.isRoot() {
|
|
142
|
+
return nil, nil
|
|
143
|
+
}
|
|
144
|
+
if idle, err := ctx.IsIdle(); err == nil && !idle {
|
|
145
|
+
return nil, nil
|
|
146
|
+
}
|
|
147
|
+
r.agentSettled()
|
|
148
|
+
return nil, nil
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
e.OnSessionShutdown(func(_ sdk.Context, data map[string]any) (any, error) {
|
|
152
|
+
if !r.isRoot() {
|
|
153
|
+
return nil, nil
|
|
154
|
+
}
|
|
155
|
+
if reason, _ := data["reason"].(string); reason != "quit" {
|
|
156
|
+
// A successor reporter in this pane re-reports on its own. Releasing here
|
|
157
|
+
// would race that report, and a late release would clear the pane.
|
|
158
|
+
r.silence()
|
|
159
|
+
return nil, nil
|
|
160
|
+
}
|
|
161
|
+
r.release()
|
|
162
|
+
return nil, nil
|
|
163
|
+
})
|
|
164
|
+
return e
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// sessionIdentity reads the session file and id. A host failure or a value herdr
|
|
168
|
+
// cannot use is omitted, not guessed.
|
|
169
|
+
func sessionIdentity(ctx sdk.Context) (path, id string) {
|
|
170
|
+
if file, err := ctx.GetSessionFile(); err == nil && file != nil && isAbsolutePath(*file) {
|
|
171
|
+
path = *file
|
|
172
|
+
}
|
|
173
|
+
if v, err := ctx.GetSessionID(); err == nil {
|
|
174
|
+
id = v
|
|
175
|
+
}
|
|
176
|
+
return path, id
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// isAbsolutePath accepts POSIX and Windows absolute paths, so a session file
|
|
180
|
+
// from any host is reported.
|
|
181
|
+
func isAbsolutePath(p string) bool {
|
|
182
|
+
if p == "" {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
if p[0] == '/' || p[0] == '\\' {
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
isLetter := p[0] >= 'a' && p[0] <= 'z' || p[0] >= 'A' && p[0] <= 'Z'
|
|
189
|
+
return len(p) >= 3 && isLetter && p[1] == ':' && (p[2] == '/' || p[2] == '\\')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// resumeArgv is the command that reopens this session in the pane's directory,
|
|
193
|
+
// mirroring herdr's built-in pi integration (`pi --session <file>`, no model
|
|
194
|
+
// flag) with pig substituted. It is the same on PiG 0.3.x and 0.4.0: a
|
|
195
|
+
// `--session` argument with a path separator or a .jsonl suffix opens that exact
|
|
196
|
+
// file whatever the directory or session dir. When the file cannot be passed
|
|
197
|
+
// (herdr refuses apostrophes, control characters and more than 8 KiB) the
|
|
198
|
+
// session id goes to `--session-id`, which resumes the id in the pane's project
|
|
199
|
+
// session dir and never asks to fork the way `--session <id>` does from another
|
|
200
|
+
// directory. With neither, there is no resume command. An in-memory session
|
|
201
|
+
// (`pig --no-session`) has an id but no file, and nothing to resume:
|
|
202
|
+
// `--session-id` would create a new, persisted session with that id.
|
|
203
|
+
func resumeArgv(path, id string) []string {
|
|
204
|
+
if path == "" {
|
|
205
|
+
return nil
|
|
206
|
+
}
|
|
207
|
+
for _, argv := range [][]string{
|
|
208
|
+
{command, "--session", path},
|
|
209
|
+
{command, "--session-id", id},
|
|
210
|
+
} {
|
|
211
|
+
if argv[2] != "" && resumeArgvValid(argv) && (argv[1] == "--session" || validSessionID(id)) {
|
|
212
|
+
return argv
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return nil
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// resumeArgvValid applies herdr's own rules for a resume command.
|
|
219
|
+
func resumeArgvValid(argv []string) bool {
|
|
220
|
+
total := 0
|
|
221
|
+
for _, arg := range argv {
|
|
222
|
+
total += len(arg)
|
|
223
|
+
if strings.ContainsRune(arg, '\'') || strings.ContainsFunc(arg, unicode.IsControl) {
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return len(argv) <= maxResumeArgs && total <= maxResumeBytes
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// validSessionID is PiG's rule for a `--session-id` value: letters, digits, `-`,
|
|
231
|
+
// `_` and `.`, starting and ending with a letter or digit.
|
|
232
|
+
func validSessionID(id string) bool {
|
|
233
|
+
if id == "" {
|
|
234
|
+
return false
|
|
235
|
+
}
|
|
236
|
+
alnum := func(c byte) bool { return c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
|
|
237
|
+
for i := 0; i < len(id); i++ {
|
|
238
|
+
c := id[i]
|
|
239
|
+
if !alnum(c) && (i == 0 || i == len(id)-1 || c != '-' && c != '_' && c != '.') {
|
|
240
|
+
return false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// messageArgs passes the message in the form every herdr parses: `--message
|
|
247
|
+
// <text>`. Every herdr CLI takes the next argument as the value, even one that
|
|
248
|
+
// starts with "-"; herdr before 0.9.0 does not know the attached
|
|
249
|
+
// `--message=<text>` form and would drop the whole report. Only a message that
|
|
250
|
+
// is exactly `--` goes attached (herdr 0.9.0+), because herdr 0.9.2+ splits the
|
|
251
|
+
// resume command off at the first `--` argument.
|
|
252
|
+
func messageArgs(message string) []string {
|
|
253
|
+
if message == "--" {
|
|
254
|
+
return []string{"--message=" + message}
|
|
255
|
+
}
|
|
256
|
+
return []string{"--message", message}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// messageFrom makes one short line for herdr's sidebar; prompt titles can be long and multi-line.
|
|
260
|
+
func messageFrom(value string) string {
|
|
261
|
+
line := strings.Join(strings.FieldsFunc(value, func(r rune) bool { return unicode.IsSpace(r) || r == '\uFEFF' }), " ")
|
|
262
|
+
runes := []rune(line)
|
|
263
|
+
if len(runes) > maxMessageLength {
|
|
264
|
+
return string(runes[:maxMessageLength-1]) + "…"
|
|
265
|
+
}
|
|
266
|
+
return line
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// reporter is one herdr reporting session. Handlers may run concurrently, so
|
|
270
|
+
// every field is guarded by mu; host and herdr calls run outside it.
|
|
271
|
+
type reporter struct {
|
|
272
|
+
env herdrEnv
|
|
273
|
+
|
|
274
|
+
mu sync.Mutex
|
|
275
|
+
rootSession bool
|
|
276
|
+
sessionPath string
|
|
277
|
+
sessionID string
|
|
278
|
+
agentActive bool
|
|
279
|
+
blockedMessage string
|
|
280
|
+
// Prompts currently open: PiG reports only the outermost prompt of a kind,
|
|
281
|
+
// and producers can overlap, so count.
|
|
282
|
+
blockedCount int
|
|
283
|
+
hasLast bool
|
|
284
|
+
// resumeOff is set once herdr refused a resume command (herdr before 0.9.2,
|
|
285
|
+
// or invalid_resume_argv): later reports leave it out.
|
|
286
|
+
resumeOff bool
|
|
287
|
+
// lastDiagnostic is the last failure written to stderr, so a repeat is not.
|
|
288
|
+
lastDiagnostic string
|
|
289
|
+
lastState agentState
|
|
290
|
+
lastMessage string
|
|
291
|
+
released bool
|
|
292
|
+
|
|
293
|
+
// Only the newest state matters: while a call is in flight, newer states
|
|
294
|
+
// replace the queued one, and calls never overlap, so herdr sees them in order.
|
|
295
|
+
queued *report
|
|
296
|
+
sending bool
|
|
297
|
+
drained chan struct{} // closed when the drain goroutine exits; valid while sending
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
func (r *reporter) isRoot() bool {
|
|
301
|
+
r.mu.Lock()
|
|
302
|
+
defer r.mu.Unlock()
|
|
303
|
+
return r.rootSession
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
func (r *reporter) start(path, id string, active bool) {
|
|
307
|
+
r.mu.Lock()
|
|
308
|
+
defer r.mu.Unlock()
|
|
309
|
+
// A session start opens a fresh reporting epoch.
|
|
310
|
+
r.rootSession = true
|
|
311
|
+
r.released = false
|
|
312
|
+
r.blockedCount, r.blockedMessage = 0, ""
|
|
313
|
+
r.sessionPath, r.sessionID = path, id
|
|
314
|
+
r.agentActive = active
|
|
315
|
+
r.publishLocked(true)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
func (r *reporter) agentStarted(path, id string) {
|
|
319
|
+
r.mu.Lock()
|
|
320
|
+
defer r.mu.Unlock()
|
|
321
|
+
r.sessionPath, r.sessionID = path, id
|
|
322
|
+
r.agentActive = true
|
|
323
|
+
r.publishLocked(false)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
func (r *reporter) agentSettled() {
|
|
327
|
+
r.mu.Lock()
|
|
328
|
+
defer r.mu.Unlock()
|
|
329
|
+
r.agentActive = false
|
|
330
|
+
r.publishLocked(false)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
func (r *reporter) block(label string) {
|
|
334
|
+
r.mu.Lock()
|
|
335
|
+
defer r.mu.Unlock()
|
|
336
|
+
if !r.rootSession {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
r.blockedCount++
|
|
340
|
+
r.blockedMessage = messageFrom(label)
|
|
341
|
+
r.publishLocked(false)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
func (r *reporter) unblock() {
|
|
345
|
+
r.mu.Lock()
|
|
346
|
+
defer r.mu.Unlock()
|
|
347
|
+
if !r.rootSession {
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
r.blockedCount = max(0, r.blockedCount-1)
|
|
351
|
+
if r.blockedCount == 0 {
|
|
352
|
+
r.blockedMessage = ""
|
|
353
|
+
}
|
|
354
|
+
r.publishLocked(false)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// silence stops reporting without releasing the pane.
|
|
358
|
+
func (r *reporter) silence() {
|
|
359
|
+
r.mu.Lock()
|
|
360
|
+
defer r.mu.Unlock()
|
|
361
|
+
r.released = true
|
|
362
|
+
r.queued = nil
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
func (r *reporter) desiredLocked() (agentState, string) {
|
|
366
|
+
if r.blockedCount > 0 {
|
|
367
|
+
return stateBlocked, r.blockedMessage
|
|
368
|
+
}
|
|
369
|
+
if r.agentActive {
|
|
370
|
+
return stateWorking, ""
|
|
371
|
+
}
|
|
372
|
+
return stateIdle, ""
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
func (r *reporter) publishLocked(force bool) {
|
|
376
|
+
state, message := r.desiredLocked()
|
|
377
|
+
if !force && r.hasLast && state == r.lastState && message == r.lastMessage {
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
r.hasLast, r.lastState, r.lastMessage = true, state, message
|
|
381
|
+
r.queueLocked(state, message)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
func (r *reporter) queueLocked(state agentState, message string) {
|
|
385
|
+
if r.released {
|
|
386
|
+
// A report after the release would reclaim a pane whose agent has exited.
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
r.queued = &report{state: state, message: message, seq: nextSeq()}
|
|
390
|
+
if !r.sending {
|
|
391
|
+
r.sending = true
|
|
392
|
+
r.drained = make(chan struct{})
|
|
393
|
+
go r.drain(r.drained)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// drain sends queued reports one at a time until none is left.
|
|
398
|
+
func (r *reporter) drain(done chan struct{}) {
|
|
399
|
+
defer close(done)
|
|
400
|
+
for {
|
|
401
|
+
r.mu.Lock()
|
|
402
|
+
next := r.queued
|
|
403
|
+
r.queued = nil
|
|
404
|
+
if next == nil {
|
|
405
|
+
r.sending = false
|
|
406
|
+
r.mu.Unlock()
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
args := r.reportArgsLocked(*next, !r.resumeOff)
|
|
410
|
+
r.mu.Unlock()
|
|
411
|
+
out, err := callHerdr(r.env, args)
|
|
412
|
+
switch {
|
|
413
|
+
case err == nil:
|
|
414
|
+
r.healthy()
|
|
415
|
+
case resumeRefused(out, err):
|
|
416
|
+
// herdr did not apply the report. Send it once more without the command,
|
|
417
|
+
// silently, and stop attaching one: state and release keep working.
|
|
418
|
+
r.mu.Lock()
|
|
419
|
+
r.resumeOff = true
|
|
420
|
+
args = r.reportArgsLocked(*next, false)
|
|
421
|
+
r.mu.Unlock()
|
|
422
|
+
if out, err = callHerdr(r.env, args); err != nil {
|
|
423
|
+
r.diagnose(args, out, err)
|
|
424
|
+
}
|
|
425
|
+
default:
|
|
426
|
+
r.diagnose(args, out, err)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// diagnose writes one line to stderr, which PiG collects as the extension's
|
|
432
|
+
// output, when herdr did not take a call: a herdr that never hears about a pane
|
|
433
|
+
// is otherwise indistinguishable from one that was told. The same failure is
|
|
434
|
+
// written once until a call succeeds again, so a herdr that is gone does not
|
|
435
|
+
// flood the output. The agent never waits on it and the transcript never shows it.
|
|
436
|
+
func (r *reporter) diagnose(args []string, out []byte, err error) {
|
|
437
|
+
msg := "herdr: pane " + args[1] + " failed: " + err.Error()
|
|
438
|
+
if line := firstLine(out); line != "" {
|
|
439
|
+
msg += ": " + line
|
|
440
|
+
}
|
|
441
|
+
r.mu.Lock()
|
|
442
|
+
repeat := msg == r.lastDiagnostic
|
|
443
|
+
r.lastDiagnostic = msg
|
|
444
|
+
r.mu.Unlock()
|
|
445
|
+
if !repeat {
|
|
446
|
+
fmt.Fprintln(os.Stderr, msg)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// healthy forgets the last failure, so the next one is written again.
|
|
451
|
+
func (r *reporter) healthy() {
|
|
452
|
+
r.mu.Lock()
|
|
453
|
+
r.lastDiagnostic = ""
|
|
454
|
+
r.mu.Unlock()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// firstLine is the first non-empty line of herdr's output, cut to a sensible length.
|
|
458
|
+
func firstLine(out []byte) string {
|
|
459
|
+
for _, line := range strings.Split(string(out), "\n") {
|
|
460
|
+
if line = strings.TrimSpace(line); line != "" {
|
|
461
|
+
if len(line) > 200 {
|
|
462
|
+
line = line[:200] + "..."
|
|
463
|
+
}
|
|
464
|
+
return line
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return ""
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// resumeRefused reports whether a failed report failed because herdr does not
|
|
471
|
+
// take a resume command: herdr before 0.9.2 does not know the `--` separator
|
|
472
|
+
// ("unknown option: --", exit 2), and 0.9.2+ answers `invalid_resume_argv` (exit
|
|
473
|
+
// 1) without applying the report. A timeout or any other failure is not a verdict
|
|
474
|
+
// on resume support.
|
|
475
|
+
func resumeRefused(output []byte, err error) bool {
|
|
476
|
+
var exit *exec.ExitError
|
|
477
|
+
if !errors.As(err, &exit) {
|
|
478
|
+
return false
|
|
479
|
+
}
|
|
480
|
+
text := strings.ToLower(string(output))
|
|
481
|
+
if strings.Contains(text, "invalid_resume_argv") {
|
|
482
|
+
return true
|
|
483
|
+
}
|
|
484
|
+
// "unknown option: --" names the separator itself; "unknown option: --message=..." does not.
|
|
485
|
+
for _, line := range strings.Split(text, "\n") {
|
|
486
|
+
if strings.TrimSpace(line) == "unknown option: --" {
|
|
487
|
+
return true
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return false
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
func (r *reporter) reportArgsLocked(rep report, withResume bool) []string {
|
|
494
|
+
args := []string{
|
|
495
|
+
"pane", "report-agent", r.env.paneID,
|
|
496
|
+
"--source", source, "--agent", agent,
|
|
497
|
+
"--state", string(rep.state), "--seq", strconv.FormatInt(rep.seq, 10),
|
|
498
|
+
}
|
|
499
|
+
if rep.message != "" {
|
|
500
|
+
args = append(args, messageArgs(rep.message)...)
|
|
501
|
+
}
|
|
502
|
+
if r.sessionPath != "" {
|
|
503
|
+
args = append(args, "--agent-session-path", r.sessionPath)
|
|
504
|
+
}
|
|
505
|
+
if r.sessionID != "" {
|
|
506
|
+
args = append(args, "--agent-session-id", r.sessionID)
|
|
507
|
+
}
|
|
508
|
+
// The command goes last, after `--`. The state report in the same call is what
|
|
509
|
+
// makes this source hold the pane, which herdr requires before it takes one.
|
|
510
|
+
if withResume {
|
|
511
|
+
if argv := resumeArgv(r.sessionPath, r.sessionID); argv != nil {
|
|
512
|
+
args = append(args, "--")
|
|
513
|
+
args = append(args, argv...)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return args
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// release stops new reports, drops the queued one and lets the in-flight call
|
|
520
|
+
// finish so the release is the last thing herdr hears from this pane.
|
|
521
|
+
func (r *reporter) release() {
|
|
522
|
+
r.mu.Lock()
|
|
523
|
+
r.released = true
|
|
524
|
+
r.queued = nil
|
|
525
|
+
var inFlight chan struct{}
|
|
526
|
+
if r.sending {
|
|
527
|
+
inFlight = r.drained
|
|
528
|
+
}
|
|
529
|
+
seq := nextSeq()
|
|
530
|
+
r.mu.Unlock()
|
|
531
|
+
if inFlight != nil {
|
|
532
|
+
<-inFlight
|
|
533
|
+
}
|
|
534
|
+
args := []string{
|
|
535
|
+
"pane", "release-agent", r.env.paneID,
|
|
536
|
+
"--source", source, "--agent", agent, "--seq", strconv.FormatInt(seq, 10),
|
|
537
|
+
}
|
|
538
|
+
if out, err := callHerdr(r.env, args); err != nil {
|
|
539
|
+
r.diagnose(args, out, err)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// callHerdr runs one herdr command and returns its output. Callers do not wait on a
|
|
544
|
+
// failed or slow herdr call (a failure is written once to stderr): the reporter is best effort, and it must never fail
|
|
545
|
+
// or stall the agent.
|
|
546
|
+
func callHerdr(env herdrEnv, args []string) ([]byte, error) {
|
|
547
|
+
ctx, cancel := context.WithTimeout(context.Background(), callTimeout)
|
|
548
|
+
defer cancel()
|
|
549
|
+
cmd := newCommand(ctx, env.bin, args)
|
|
550
|
+
// A herdr that hangs is killed at the deadline; a grandchild holding the pipe
|
|
551
|
+
// must not keep Wait from returning.
|
|
552
|
+
cmd.WaitDelay = time.Second
|
|
553
|
+
return cmd.CombinedOutput()
|
|
554
|
+
}
|