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:
kami
2026-07-28 00:58:20 +04:00
parent 6fd7738a02
commit 636ed8a811
3 changed files with 696 additions and 1692 deletions
+54 -8
View File
@@ -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