fix herdr launch safety and task context

This commit is contained in:
kami
2026-07-28 13:20:28 +04:00
parent 636ed8a811
commit 58793a5aa3
8 changed files with 826 additions and 74 deletions
+49 -14
View File
@@ -238,7 +238,7 @@ func (c *Coordinator) requestReasonedHandoff(ctx context.Context, taskID string,
if session.HandoffRequested {
return
}
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr == nil {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffReportFile)); statErr == nil {
return
}
requester, ok := a.(herdr.ReasonedHandoffRequester)
@@ -417,7 +417,7 @@ func (c *Coordinator) Reconcile(ctx context.Context) error {
c.mu.Lock()
for taskID, session := range c.sessions {
t, ok := c.Store.Task(taskID)
if ok && t.State == domain.StateLeased {
if ok && (t.State == domain.StateLeased || t.State == domain.StateBlocked) {
continue
}
if a, err := c.adapterFor(taskID, session); err == nil {
@@ -592,6 +592,7 @@ func (c *Coordinator) expire(ctx context.Context) ([]domain.Event, error) {
}
return events, nil
}
// handoffReason reads HandoffFile from the worktree, if present, and returns
// its meta.reason ("threshold|milestone|thrash|manual" per §6.1). An unread­
// able or invalid file returns "" — callers treat that as "no signal yet",
@@ -648,7 +649,7 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
// Soft threshold (§5.3): request a handoff early, advisory
// only — no release, no turn-boundary requirement.
if requester, ok := a.(herdr.HandoffRequester); ok {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil && !session.HandoffRequested {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffReportFile)); statErr != nil && !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
c.mu.Lock()
@@ -683,7 +684,7 @@ func (c *Coordinator) rotate(ctx context.Context, hard float64) {
}
}
if requester, ok := a.(herdr.HandoffRequester); ok {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffReportFile)); statErr != nil {
if !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
@@ -779,7 +780,7 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
// preparing a handoff well before Hard forces one, but don't block
// the turn on a boundary check — the agent is free to keep working.
if requester, ok := a.(herdr.HandoffRequester); ok {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffReportFile)); statErr != nil {
if !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
@@ -806,7 +807,7 @@ func (c *Coordinator) TurnDecision(ctx context.Context, taskID string) (string,
c.recordTurnBoundaryDegraded()
}
if requester, ok := a.(herdr.HandoffRequester); ok {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffFile)); statErr != nil {
if _, statErr := os.Stat(filepath.Join(session.Worktree, herdr.HandoffReportFile)); statErr != nil {
if !session.HandoffRequested {
if reqErr := requester.RequestHandoff(ctx, session); reqErr == nil {
session.HandoffRequested = true
@@ -895,8 +896,23 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
// machine (WorktreeCreator path) is the same cross-host gap named in
// AUDIT.md's federation-fork section — not solved here.
taskFileSHA, _ := continuity.TaskFileHash(w)
s, err := a.Lease(ctx, t.ID, w)
prompt := taskLaunchPrompt(t)
var s herdr.Session
if promptLeaser, ok := a.(herdr.PromptLeaser); ok {
s, err = promptLeaser.LeasePrompt(ctx, t.ID, w, prompt)
} else {
s, err = a.Lease(ctx, t.ID, w)
}
if err != nil {
// A UI-changing prompt can time out after herdr accepted it. Keep the
// live pane mapped before recording TaskBlocked so a later completion
// can reconcile the lifecycle instead of becoming an orphan (B15).
if s.PaneID != "" {
s.HerdrID = p.HarnessID
s.TaskFileSHA = taskFileSHA
s.ConventionsHash, _ = continuity.ConventionsHash(w)
_ = c.rememberSession(t.ID, s)
}
return c.block(t, "lease: "+err.Error())
}
if p.HandoffRef != "" {
@@ -926,13 +942,7 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
// state this session starts trusting; checkConventions notices drift
// from here, not from whatever the agent's own cached view is (§6.3).
s.ConventionsHash, _ = continuity.ConventionsHash(w)
c.mu.Lock()
if c.sessions == nil {
c.sessions = map[string]herdr.Session{}
}
c.sessions[t.ID] = s
err = c.saveSessionsLocked()
c.mu.Unlock()
err = c.rememberSession(t.ID, s)
c.healthMu.Lock()
if c.health.Sessions == nil {
c.health.Sessions = map[string]SessionHealth{}
@@ -942,6 +952,31 @@ func (c *Coordinator) Start(ctx context.Context, e domain.Event) error {
return err
}
func taskLaunchPrompt(t domain.Task) string {
var b strings.Builder
fmt.Fprintf(&b, "Begin Orchestra task %s.\n", t.ID)
if t.Title != "" {
fmt.Fprintf(&b, "Title: %s\n", t.Title)
}
if t.Description != "" {
fmt.Fprintf(&b, "Instructions:\n%s\n", t.Description)
} else {
b.WriteString("Inspect the repository, understand the task context, and proceed with the requested work.\n")
}
b.WriteString("This is the authoritative task instruction. Work only within this task's worktree. Do not edit TASK.md if it exists.")
return b.String()
}
func (c *Coordinator) rememberSession(taskID string, s herdr.Session) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.sessions == nil {
c.sessions = map[string]herdr.Session{}
}
c.sessions[taskID] = s
return c.saveSessionsLocked()
}
func (c *Coordinator) block(t domain.Task, reason string) error {
b, _ := json.Marshal(map[string]string{"blocker": reason})
return c.Store.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})