Compare commits
3 Commits
de18f372d3
...
edf00761fd
| Author | SHA1 | Date | |
|---|---|---|---|
| edf00761fd | |||
| 1330ad9943 | |||
| cb80dd1952 |
@@ -2848,3 +2848,102 @@ unchanged.
|
||||
"ready_for_verification"}`. It is the whole lever for this rung.
|
||||
- Ingest turns every issue label into a required capability, so a rig issue
|
||||
must carry no labels or it will not schedule.
|
||||
|
||||
## Run 20, 2026-08-29: the mismatch and replan ladder, and three defects
|
||||
|
||||
Task `06G4WJ9T4F35NZC4Z8QQXM9Z6G` (issue 29) on `de18f37`, plus a first attempt
|
||||
on `06G4WDJ06G7FK00QF73Z9DDBT4` (issue 27) on `c587f2c` that found F65.
|
||||
|
||||
The lever throughout is the agent's own file, written by the operator with the
|
||||
worker stopped: `.orchestra/plan-mismatch.json`, carrying `phase_id`,
|
||||
`observed`, `contradicts`, `evidence` and `requested_action`. The worker fills
|
||||
`plan_ref` and `at_sha` from what it can verify, never from the file.
|
||||
|
||||
### F65: a task that stops for a human never stopped
|
||||
|
||||
Found on the first attempt, live:
|
||||
|
||||
```text
|
||||
16:04:25 PlanMismatchRecorded phase-2 human_decision
|
||||
16:04:25 worker: plan mismatch refused: task version conflict
|
||||
16:04:48 PlanPhaseVerified phase-1 work continues as if nothing happened
|
||||
```
|
||||
|
||||
`Store.Append` fences every lifecycle event on a leased task against the
|
||||
current owner and epoch. `blockForPlanMismatch` carried neither `harness_id`
|
||||
nor `lease_epoch`, so the block was refused on every task that was actually
|
||||
running, which is every task that can produce a mismatch. The observation was
|
||||
durable and the transition silently did not happen: the repo's own documented
|
||||
failure shape. `raiseTrajectoryGate` had the identical omission, so the
|
||||
trajectory gate could never have stopped a live task either.
|
||||
|
||||
`RecordPlanMismatch` also returns that error after the record is already
|
||||
appended, so the worker told the agent its report was refused for a report
|
||||
Orchestra had accepted.
|
||||
|
||||
Fixed in `de18f37` with one `fenceToLease` helper, called by all three stops.
|
||||
The human-decision path already did it correctly and explained why in a
|
||||
comment; that comment is now the helper.
|
||||
|
||||
**Why no test caught it.** `planWith` never leased its task, so every
|
||||
plan-progress and plan-mismatch test ran in a state no agent can be in. The
|
||||
`lease` helper in the same package carries a comment warning about exactly
|
||||
that. It leases now, and the mismatch block test fails without the fence with
|
||||
the same `task version conflict`.
|
||||
|
||||
### Proven: human_decision stops, and the reply resumes the same plan
|
||||
|
||||
```text
|
||||
16:24:21 PlanMismatchRecorded phase-2 human_decision
|
||||
16:24:21 TaskBlocked plan_mismatch, lifecycle awaiting_human
|
||||
18:24:46 HumanDecisionRecorded operator_instruction (Gitea's timestamp)
|
||||
16:25:16 TaskCorrected resumed
|
||||
16:25:16 TaskLeased plan_ref unchanged at 0bc8ef84
|
||||
16:25:21 TaskPickupValidated
|
||||
```
|
||||
|
||||
55 seconds from block to resumed, on the same plan, with no reseal. The
|
||||
blocker packet states the observation, the plan's claim and the evidence.
|
||||
|
||||
This is also the live proof of F64: before `6cb2f93` nothing read a blocked
|
||||
task's comments, because the reconciler ran only before a lease and at a turn
|
||||
boundary, and a blocked task is never leased.
|
||||
|
||||
### Proven: replan keeps the old plan until a replacement seals
|
||||
|
||||
```text
|
||||
16:26:07 PlanMismatchRecorded phase-3 replan
|
||||
16:26:07 WorkPhaseChanged plan reopen=PlanMismatchRecorded
|
||||
during planning: plan_ref 0bc8ef84 accepted, phase-1 and phase-2 counted
|
||||
after the seal:
|
||||
plan_ref 922f293b
|
||||
plan_history [0bc8ef84]
|
||||
progress empty
|
||||
successor in implement: four phases, all "not started"
|
||||
```
|
||||
|
||||
The successor's launch context carries the replacement whole, with files,
|
||||
changes and verification per phase. The old progress stops counting because
|
||||
`PlanPhases()` drops records whose plan ref is not the accepted one. An
|
||||
abandoned replan would have cost nothing.
|
||||
|
||||
### F66: the planner replans blind
|
||||
|
||||
The planning session convened by a replan is given the ordinary plan brief and
|
||||
an empty template. Its launch context says nothing about the plan that already
|
||||
exists, the contradiction that reopened the phase, the phase id, the evidence,
|
||||
or the fact that its output supersedes an accepted plan. The only human input
|
||||
it carried was an unrelated earlier correction.
|
||||
|
||||
Everything it needs is already durable. `PlanMismatchRecorded` holds the phase,
|
||||
the observation, the contradiction and the evidence, and `reopenPhase` writes
|
||||
`reopen_phase_id` onto the `WorkPhaseChanged` payload. None of it reaches the
|
||||
agent, so nothing stops the replacement from sealing with the same
|
||||
contradiction in it.
|
||||
|
||||
### Smaller observation
|
||||
|
||||
The implement session kept working after the reopen. It verified phase-2
|
||||
against the old plan 40 seconds after the task had moved to `plan`. Harmless,
|
||||
because supersession discards it, but the reopen does not end the session that
|
||||
reported the contradiction. That session rotates at its own next boundary.
|
||||
|
||||
@@ -314,6 +314,9 @@ func renderTask(in Input) string {
|
||||
fmt.Fprintf(&b, "- head: %s\n", fallback(in.Git.HeadSHA))
|
||||
fmt.Fprintf(&b, "- uncommitted changes: %t\n", in.Git.Dirty)
|
||||
|
||||
// Above the sealed artifacts, because it is the reason this session exists
|
||||
// and it changes how the artifacts below should be read.
|
||||
b.WriteString(renderReopen(in))
|
||||
b.WriteString(renderSealed(in))
|
||||
// Below the plan, above continuity. Progress is a fact about the plan, so
|
||||
// it follows the plan; continuity is one predecessor's account, so it
|
||||
@@ -499,6 +502,35 @@ func renderPlanProgress(in Input) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderReopen states the contradiction that reopened this phase. Without it
|
||||
// the planning session a replan convenes gets the ordinary plan brief and an
|
||||
// empty template, with nothing saying a plan already exists, what contradicted
|
||||
// it, or that its output supersedes an accepted plan (F66). Everything here is
|
||||
// durable on PlanMismatchRecorded; it simply never reached the agent.
|
||||
//
|
||||
// Only the phases a mismatch can reopen render it. An implementer that finds
|
||||
// the next contradiction is looking at the code, not at this account of the
|
||||
// last one.
|
||||
func renderReopen(in Input) string {
|
||||
m := in.Task.PlanMismatch
|
||||
if m == nil || (in.Phase != domain.WorkPhasePlan && in.Phase != domain.WorkPhaseResearch) {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n## Why this phase reopened\n\n")
|
||||
fmt.Fprintf(&b, "A plan was already accepted and the code contradicted it. Orchestra reopened this phase to settle that, and the session that found it is gone.\n\n")
|
||||
fmt.Fprintf(&b, "- phase: %s\n", collapse(m.PhaseID))
|
||||
fmt.Fprintf(&b, "- observed: %s\n", collapse(m.Observed))
|
||||
fmt.Fprintf(&b, "- the plan says: %s\n", collapse(m.Contradicts))
|
||||
for _, e := range m.Evidence {
|
||||
fmt.Fprintf(&b, "- evidence: %s\n", collapse(e))
|
||||
}
|
||||
if in.Phase == domain.WorkPhasePlan {
|
||||
b.WriteString("\nThe accepted plan stays accepted until you seal a replacement, and sealing one supersedes it along with every phase it had verified. Address the contradiction above: a replacement that repeats it will be contradicted again.\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderSealed(in Input) string {
|
||||
var b strings.Builder
|
||||
research := in.Research
|
||||
|
||||
@@ -734,3 +734,61 @@ func TestPendingManualGateRendersStale(t *testing.T) {
|
||||
t.Errorf("a pending manual gate at HEAD rendered stale:\n%s", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// F66, found live on run 20. A replan convenes a planning session to settle a
|
||||
// contradiction, and that session was told nothing about it: the ordinary plan
|
||||
// brief, an empty template, and no mention that a plan already exists.
|
||||
func TestAReopenedPlanPhaseIsToldWhatContradictedThePlan(t *testing.T) {
|
||||
in := input()
|
||||
in.Phase = domain.WorkPhasePlan
|
||||
in.Task.PlanRef = "plan-a"
|
||||
in.Task.PlanMismatch = &domain.PlanMismatch{
|
||||
PlanRef: "plan-a", PhaseID: "phase-3", AtSHA: "18ccaf00000000000000000000000000000000aa",
|
||||
Observed: "the aggregation runs per figure, not per person",
|
||||
Contradicts: "the plan states the pipeline already aggregates per person",
|
||||
Evidence: []string{"internal/figures/aggregate.go:88"},
|
||||
RequestedAction: domain.PlanMismatchReplan,
|
||||
}
|
||||
out, err := Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"## Why this phase reopened",
|
||||
"phase: phase-3",
|
||||
"the aggregation runs per figure",
|
||||
"the plan states the pipeline already aggregates per person",
|
||||
"internal/figures/aggregate.go:88",
|
||||
"stays accepted until you seal a replacement",
|
||||
} {
|
||||
if !strings.Contains(out.Task, want) {
|
||||
t.Fatalf("the planner was not told %q:\n%s", want, out.Task)
|
||||
}
|
||||
}
|
||||
// It must come before the artifacts it changes the reading of.
|
||||
in.Research = &workphase.Research{Findings: []workphase.Finding{{
|
||||
ID: "r1", Confidence: "fact", Claim: "aggregation is per figure",
|
||||
Evidence: "internal/figures/aggregate.go:88",
|
||||
}}}
|
||||
out, err = Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.Task, "## Accepted research") {
|
||||
t.Fatal("the research this planner works from is missing")
|
||||
}
|
||||
if strings.Index(out.Task, "## Why this phase reopened") > strings.Index(out.Task, "## Accepted research") {
|
||||
t.Fatal("the reason this session exists is rendered below the material it qualifies")
|
||||
}
|
||||
|
||||
// An implementer is looking at the code, not at an account of the last
|
||||
// contradiction, so it renders nowhere else.
|
||||
in.Phase = domain.WorkPhaseImplement
|
||||
out, err = Build(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out.Task, "## Why this phase reopened") {
|
||||
t.Fatal("a settled contradiction reached the implementation phase")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,13 @@ type Task struct {
|
||||
// current one, oldest first. A superseded plan stays queryable: the
|
||||
// verification recorded against it is provenance, not garbage.
|
||||
PlanHistory []string `json:"plan_history,omitempty"`
|
||||
// PlanMismatch is the contradiction that reopened this task's phase. The
|
||||
// planning session convened by a replan has to be told what it is there to
|
||||
// fix, and the implementer that found it is gone by then (F66). It is
|
||||
// cleared when a replacement plan is accepted, or when the stop it caused
|
||||
// is answered, because after either it is history rather than a live
|
||||
// instruction.
|
||||
PlanMismatch *PlanMismatch `json:"plan_mismatch,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ func TestReplanKeepsTheOldPlanUntilAReplacementIsSealed(t *testing.T) {
|
||||
if len(during.PlanHistory) != 0 {
|
||||
t.Fatalf("the plan was moved to history early: %v", during.PlanHistory)
|
||||
}
|
||||
// F66: the planning session this reopen convenes has to be told what it is
|
||||
// there to fix, and the implementer that found it is gone by then.
|
||||
if during.PlanMismatch == nil || during.PlanMismatch.PhaseID != "phase-1" {
|
||||
t.Fatalf("the contradiction did not survive to the phase it reopened: %+v", during.PlanMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealing the replacement is the moment the old plan is superseded. Progress
|
||||
@@ -151,6 +156,11 @@ func TestSealingTheReplacementSupersedesThePlanAndItsProgress(t *testing.T) {
|
||||
if _, err := s.Artifact(oldRef); err != nil {
|
||||
t.Fatalf("the superseded plan is unreadable: %v", err)
|
||||
}
|
||||
// The replacement answers the contradiction, so it stops being a live
|
||||
// instruction and stays in the log as history.
|
||||
if after, _ := s.Task(id); after.PlanMismatch != nil {
|
||||
t.Fatalf("a settled contradiction is still live: %+v", after.PlanMismatch)
|
||||
}
|
||||
assertPhase(t, s, id, domain.WorkPhaseImplement)
|
||||
}
|
||||
|
||||
@@ -225,6 +235,15 @@ func TestHumanAnswerResumesTheSamePlanWithoutResealing(t *testing.T) {
|
||||
if after.State != domain.StateQueued {
|
||||
t.Fatalf("state = %s after the human answered, want queued", after.State)
|
||||
}
|
||||
// F67: the stop is over, so the projection must not keep reporting it.
|
||||
if after.BlockReason != "" || after.Blocker != "" {
|
||||
t.Fatalf("a resumed task still reports its blocker: %q %q", after.BlockReason, after.Blocker)
|
||||
}
|
||||
// The answer outranks the plan and stands as an ordinary decision, so the
|
||||
// contradiction it settled is not carried into later sessions either.
|
||||
if after.PlanMismatch != nil {
|
||||
t.Fatalf("an answered contradiction is still live: %+v", after.PlanMismatch)
|
||||
}
|
||||
if after.PlanRef != planRef {
|
||||
t.Fatal("answering the question replaced the plan")
|
||||
}
|
||||
|
||||
@@ -312,6 +312,9 @@ func (s *Store) apply(e domain.Event) error {
|
||||
}
|
||||
t.PlanRef = p.ArtifactRef
|
||||
t.PlanProgress = nil
|
||||
// The replacement answers the contradiction. Carrying it
|
||||
// further would put a solved problem in every later context.
|
||||
t.PlanMismatch = nil
|
||||
}
|
||||
}
|
||||
t.WorkPhase = p.Phase
|
||||
@@ -487,6 +490,19 @@ func (s *Store) apply(e domain.Event) error {
|
||||
t.Version = e.Version
|
||||
s.replaceTask(e.TaskID, t)
|
||||
return nil
|
||||
case domain.EventPlanMismatchRecorded:
|
||||
// Projected so the phase this reopens can be told what reopened it.
|
||||
// The event is the record; this is the live instruction derived from
|
||||
// it, and it stops being live as soon as a replacement seals or the
|
||||
// stop is answered.
|
||||
var m domain.PlanMismatch
|
||||
if err := json.Unmarshal(e.Payload, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
t.PlanMismatch = &m
|
||||
t.Version = e.Version
|
||||
s.replaceTask(e.TaskID, t)
|
||||
return nil
|
||||
case domain.EventPlanPhaseVerified:
|
||||
var pp domain.PlanPhaseRecord
|
||||
if err := json.Unmarshal(e.Payload, &pp); err != nil {
|
||||
@@ -588,8 +604,17 @@ func (s *Store) apply(e domain.Event) error {
|
||||
// A question only stands while the task is blocked on it. Afterwards the
|
||||
// answer is an ordinary standing decision and the log still holds the
|
||||
// question, so keeping it on the task would put it in every later context.
|
||||
//
|
||||
// The blocker itself has the same lifetime and was outliving it (F67). A
|
||||
// task resumed from a plan mismatch kept reporting block_reason
|
||||
// plan_mismatch while it ran, so every surface reading the projection
|
||||
// showed a stop that had already been answered.
|
||||
if t.State != domain.StateBlocked {
|
||||
t.DecisionRequest = nil
|
||||
t.Blocker, t.BlockReason = "", ""
|
||||
// The human's answer outranks the plan and stands as an ordinary
|
||||
// decision, so the contradiction it settled is no longer live.
|
||||
t.PlanMismatch = nil
|
||||
}
|
||||
if phase, ok := p["lifecycle_phase"].(string); ok && phase != "" {
|
||||
t.LifecyclePhase = phase
|
||||
|
||||
Reference in New Issue
Block a user