fix herdr launch safety and task context
This commit is contained in:
@@ -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)})
|
||||
|
||||
Reference in New Issue
Block a user