fix(herdr): retry agent.start/agent.prompt through pane-boot readiness race (B12)
herdr hands a freshly created pane/agent back before it's actually ready,
and rejects the very next call with a range of different transient errors
("not an available shell", "not an active named agent", "target ... not
found") depending on timing. String-matching each wording as it turned up
live proved unwinnable across three live redeploy-and-test rounds, so
StartAgent and Prompt now retry any error for up to 15s (bounded by
wall-clock time, not attempt count) rather than pattern-matching herdr's
error text.
Confirmed live against workpc: a fresh lease (wD:p1) now reaches a real
attached claude session instead of failing before the agent starts.
Live testing also exposed a second, separate defect (B13, documented in
AUDIT.md, not fixed here): agent.start can return success while never
actually starting an agent when two leases land close together, with no
error for a retry to catch. Left three test panes on workpc untouched
(wD:p1, wE:p1, wF:p1) pending manual cleanup, per the standing rule against
destructive herdr calls without asking first.
Also folds in the already-flattened AUDIT.md/progress.md merge that was
staged ahead of this session's changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
+54
-8
@@ -165,12 +165,39 @@ type Session struct {
|
||||
ConventionsHash string `json:"conventions_hash,omitempty"`
|
||||
}
|
||||
|
||||
// bootDeadline bounds the retry loops below. Freshly created panes/agents
|
||||
// have been observed (live, 2026-07-28) to reject the very next call for a
|
||||
// range of different transient reasons as herdr finishes bringing them up —
|
||||
// "not an available shell", "not an active named agent", "target ... not
|
||||
// found" — a new wording each time a prior one got fixed. There is no other
|
||||
// legitimate reason a call against state orchestra itself just created would
|
||||
// fail immediately, so these loops retry any error rather than pattern-match
|
||||
// an open-ended and apparently still-growing set of herdr wordings, bounded
|
||||
// by wall-clock time rather than attempt count so a slow-booting pane still
|
||||
// gets the same real budget as a fast-failing one.
|
||||
const (
|
||||
bootRetryWindow = 15 * time.Second
|
||||
bootRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
||||
p := map[string]any{"target": pane, "text": text}
|
||||
if wait > 0 {
|
||||
p["wait"] = map[string]any{"until": []string{"idle"}, "timeout_ms": wait.Milliseconds()}
|
||||
}
|
||||
return c.Call(ctx, "agent.prompt", p, nil)
|
||||
deadline := time.Now().Add(bootRetryWindow)
|
||||
var err error
|
||||
for {
|
||||
err = c.Call(ctx, "agent.prompt", p, nil)
|
||||
if err == nil || time.Now().After(deadline) {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(bootRetryDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
func (c *Client) Worktree(ctx context.Context, cwd, path, branch string) (string, error) {
|
||||
var r worktreeResponse
|
||||
@@ -204,15 +231,34 @@ func (c *Client) StartAgent(ctx context.Context, cwd, path, branch, harness, tas
|
||||
return Session{}, fmt.Errorf("herdr: no pane recorded for worktree %s", path)
|
||||
}
|
||||
var s Session
|
||||
if err := c.Call(ctx, "agent.start", map[string]any{
|
||||
"pane_id": paneID,
|
||||
"kind": harness,
|
||||
"name": harness,
|
||||
"args": []string{},
|
||||
}, &s); err != nil {
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "already") {
|
||||
deadline := time.Now().Add(bootRetryWindow)
|
||||
var err error
|
||||
for {
|
||||
s = Session{}
|
||||
err = c.Call(ctx, "agent.start", map[string]any{
|
||||
"pane_id": paneID,
|
||||
"kind": harness,
|
||||
"name": harness,
|
||||
"args": []string{},
|
||||
}, &s)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "already") {
|
||||
err = nil
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return Session{}, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Session{}, ctx.Err()
|
||||
case <-time.After(bootRetryDelay):
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
s.PaneID = paneID
|
||||
s.Worktree = path
|
||||
|
||||
Reference in New Issue
Block a user