Skip to content

Commit 083992c

Browse files
committed
fix(squads): a broken seam becomes a ticket somebody owns
Every squad green and the assembled application broken is the defect this whole design exists to catch: both halves passed their own tests and the seam between them is wrong. It arrived as the least useful ticket the harness can produce. The failure was routed through the QA path with a synthetic verdict reading `qa_gate command still failing` / `qa_gate red`, so the integration command, the output that names the seam, the contract clauses at stake and the team that owes them were all discarded. What landed was a generic `worker` task with no files, owned by nobody, whose entire context was that a gate was red — on the one failure mode where the harness knows more than anywhere else. Now the provider owes the clause. A consumer built against text it was handed; the provider is the one that either implemented that text or drifted from it. The ticket carries the command, the output, the clauses and the implicated files, and the files are kept to the owing team's lane — if the output named nothing there, the ticket ships unscoped rather than pointing at the other half's files, because scoping a fix to the wrong half is worse than not scoping it. With several providers the failure text decides, by naming a team or a path inside its lane; when it names neither, the ticket stays unassigned, since an unassigned ticket with real evidence beats one parked on a guess. A seam that fails again bumps the existing ticket rather than stacking a second, and the QA path no longer re-enters the tester rewrite for an integration failure: its reopen pass would reopen halves that are green by definition, which is precisely what "every squad passed and the seam is wrong" means. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UiLDxnEu1Gf8Q5hWAFhaAd
1 parent 22a02ae commit 083992c

7 files changed

Lines changed: 453 additions & 3 deletions

File tree

docs/changelog.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,30 @@ list, because that list is widened by the very task matches the check exists to
215215
catch: computing from it would let the contamination declare the defect a
216216
straddle and disable the check exactly when it is needed.
217217

218+
### Fixed — an integration failure raised the worst ticket in the harness
219+
220+
Every squad green and the assembled application broken is the defect the whole
221+
squads design exists to catch. It arrived as the *least* useful ticket the
222+
harness can produce: the failure was routed through the QA path with a synthetic
223+
verdict reading `qa_gate command still failing` / `qa_gate red`, so the
224+
integration command, the output that names the seam, the contract clauses at
225+
stake and the team that owes them were all discarded. What landed was a generic
226+
`worker` task with no files, owned by nobody, whose entire context was that a
227+
gate was red.
228+
229+
It now raises a real correction ticket: the provider owes the clause (a consumer
230+
built against text it was handed), the evidence rides along, and the implicated
231+
files are kept to the owing team's lane — if the output named nothing there, the
232+
ticket ships unscoped rather than pointing at the other half's files. With
233+
several providers the failure text decides; when it names neither a team nor a
234+
path in one's lane, the ticket stays unassigned, because an unassigned ticket
235+
with real evidence beats one parked on the wrong team.
236+
237+
A seam that fails again bumps the existing ticket instead of stacking a second,
238+
and the QA path no longer re-enters the tester rewrite for it — its reopen pass
239+
would have reopened halves that are green by definition, which is exactly what
240+
"every squad passed and the seam is wrong" means.
241+
218242
### Added — the run summary says what it repaired
219243

220244
`Todo app: Go API + React SPA — 2/2 tasks done, 0 failed` read exactly the same

docs/squads.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,34 @@ hands the team back to the run's default manager.
293293
294294
---
295295
296+
## When the halves do not fit 🔗
297+
298+
Every squad green and the assembled application broken is the defect this whole
299+
design exists to catch: both halves passed their own tests and the seam between
300+
them is wrong.
301+
302+
The integration command runs as soon as every squad is complete, and a failure
303+
raises a **ticket somebody owns** rather than a warning at the end of a
304+
"successful" run:
305+
306+
- the **provider owes the clause** — a consumer built against text it was
307+
handed, so the team that either implemented that text or drifted from it is
308+
the one that gets the ticket;
309+
- the ticket carries the integration command, the output that names the seam,
310+
the contract clauses at stake and the implicated files;
311+
- files are kept to the owing team's lane. If the output named nothing there,
312+
the ticket ships unscoped rather than pointing at the other half's files.
313+
314+
With several providers, the failure text decides — by naming a team or a path
315+
inside its lane. When it names neither, the ticket stays unassigned: a guess is
316+
worse than nothing, because an unassigned ticket with real evidence beats one
317+
parked on the wrong team.
318+
319+
A seam that fails again is the same defect: the existing ticket's attempt count
320+
goes up rather than a second ticket appearing.
321+
322+
---
323+
296324
## Safety: disjoint ownership 🛡️
297325
298326
**Two squads may never own the same path.** They write concurrently; an overlap

pkg/orchestrator/resume.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -679,11 +679,21 @@ func (o *Orchestrator) runQualityGates(ctx context.Context, query string, board
679679
integrationFailed := o.runSquadIntegration(ctx, out.Board)
680680

681681
out.QACmd = o.qaCommand()
682-
out.QAFailed = o.runQAGate(ctx, query, out.Board) || integrationFailed
682+
qaFailed := o.runQAGate(ctx, query, out.Board)
683+
out.QAFailed = qaFailed || integrationFailed
683684
if out.QAFailed {
684685
out.TesterRejected = true
685-
fake := `{"passed":false,"summary":"qa_gate command still failing","failures":["qa_gate red"]}`
686-
_ = o.applyTesterFeedback(ctx, query, out.Board, fake)
686+
// An integration failure has already raised its own ticket, carrying
687+
// the command, the output that names the seam, the contract clauses at
688+
// stake and the team that owes them. Re-entering the tester path with
689+
// a synthetic `qa_gate red` verdict would throw all of that away and
690+
// stack a second, generic ticket on top of the specific one — and its
691+
// reopen pass would reopen halves that are green by definition, which
692+
// is what "every squad passed and the seam is wrong" means.
693+
if qaFailed {
694+
fake := `{"passed":false,"summary":"qa_gate command still failing","failures":["qa_gate red"]}`
695+
_ = o.applyTesterFeedback(ctx, query, out.Board, fake)
696+
}
687697
snap := o.boardStore.Snapshot()
688698
out.Board = &snap
689699
return out

pkg/orchestrator/squads.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,89 @@ func (o *Orchestrator) runSquadIntegration(ctx context.Context, board *plan.Boar
226226
o.emitWarn("integrate", "INTEGRATION FAILED — every squad is green but the assembled "+
227227
"application is not. The seam is wrong: "+res.Summary, res.Output)
228228
o.recordGate("integration", false, res.Summary)
229+
o.raiseIntegrationTicket(board, gate.Command, res.Summary, res.Output)
229230
return true
230231
}
231232

233+
// raiseIntegrationTicket turns a failed join into a ticket somebody owns.
234+
//
235+
// This is the defect the whole squads design exists to catch — every half green
236+
// and the assembled application broken — and it used to arrive as the least
237+
// useful ticket the harness can produce. The QA path raised it with a synthetic
238+
// verdict reading `qa_gate command still failing` / `qa_gate red`, so the
239+
// command that failed, the output naming the seam, the interfaces at stake and
240+
// the team that owes them were all discarded. What landed was a generic
241+
// `worker` task with no files, owned by nobody, whose entire context was that a
242+
// gate was red.
243+
//
244+
// The contract knows who owes each clause, so the ticket goes to that team with
245+
// the evidence attached.
246+
func (o *Orchestrator) raiseIntegrationTicket(board *plan.Board, cmd, summary, output string) {
247+
if o == nil || board == nil {
248+
return
249+
}
250+
squad, clauses := squads.SeamOwner(o.squadPlan, output)
251+
failures := []string{"the halves do not fit together: " + firstSentence(summary)}
252+
for _, c := range clauses {
253+
failures = append(failures, "contract clause at stake: "+c)
254+
}
255+
256+
in := plan.CorrectionInput{
257+
Source: plan.SourceIntegration,
258+
Failures: failures,
259+
Summary: summary,
260+
Command: cmd,
261+
Output: output,
262+
// Files the integration output actually named, kept to the owing team's
263+
// lane: a bundler error listing half the tree would otherwise scope the
264+
// ticket to everything.
265+
Files: o.seamFiles(output, squad),
266+
Squad: squad,
267+
}
268+
key := plan.CorrectionKey(in)
269+
in.Attempt = board.CorrectionAttempts(key)
270+
if board.NoteRepeatedRejection(key) > 0 {
271+
o.persistBoard(board)
272+
return
273+
}
274+
var hasRole func(string) bool
275+
if o.factory != nil {
276+
hasRole = o.factory.HasRole
277+
}
278+
ticket := plan.NewCorrectionTicket(in, hasRole)
279+
plan.StampCorrectionKey(&ticket, key)
280+
plan.StampCorrectionAttempt(&ticket, in.Attempt+1)
281+
board.AddTask(ticket)
282+
o.persistBoard(board)
283+
284+
where := "unassigned"
285+
if squad != "" {
286+
where = squad
287+
}
288+
o.emit("integrate", fmt.Sprintf("raised an integration ticket for %s → %s", where, ticket.Role), ticket.ID)
289+
}
290+
291+
// seamFiles is the paths the integration output named, narrowed to one team's
292+
// lane when a team owes the seam.
293+
func (o *Orchestrator) seamFiles(output, squad string) []string {
294+
named := squads.PathsIn(output)
295+
if squad == "" || o.squadPlan == nil {
296+
return limitList(named, 8)
297+
}
298+
var mine []string
299+
for _, f := range named {
300+
if owner, ok := o.squadPlan.Owner(f); ok && owner == squad {
301+
mine = append(mine, f)
302+
}
303+
}
304+
if len(mine) == 0 {
305+
// The output named nothing in this team's lane. Better an unscoped
306+
// ticket with the evidence than one scoped to the other half's files.
307+
return nil
308+
}
309+
return limitList(mine, 8)
310+
}
311+
232312
// routeBoardToSpecialists assigns every task the specialist its own files call
233313
// for, after squads have been assigned.
234314
//

pkg/orchestrator/squads_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,3 +1099,111 @@ func TestATicketIsReStaffedAtMostOnce(t *testing.T) {
10991099
t.Errorf("asked %q for a second verdict on a ticket that already changed hands", exec.askedAgent)
11001100
}
11011101
}
1102+
1103+
// ── An integration failure is a ticket, not a warning ────────────────────
1104+
//
1105+
// Every squad green and the assembled application broken is the defect the
1106+
// whole design exists to catch. It used to arrive as the least useful ticket
1107+
// the harness can produce: the QA path raised it with a synthetic
1108+
// `qa_gate command still failing` verdict, so the command, the output naming
1109+
// the seam, the contract clauses and the team that owes them were all
1110+
// discarded — leaving a generic `worker` task with no files, owned by nobody.
1111+
1112+
func seamOrchestrator(t *testing.T) *Orchestrator {
1113+
t.Helper()
1114+
p := &squads.Plan{
1115+
Squads: []squads.Squad{
1116+
{ID: "backend", Owns: []string{"cmd/**", "internal/**"}, Acceptance: "go test ./...", Worker: "go-worker"},
1117+
{ID: "frontend", Owns: []string{"web/**"}, Acceptance: "npm run build", Worker: "react-worker"},
1118+
},
1119+
Contract: squads.Contract{Interfaces: []squads.Interface{
1120+
{ID: "GET /api/todos", Provider: "backend", Consumers: []string{"frontend"},
1121+
Spec: "200 -> [{id,title,done}]"},
1122+
}},
1123+
}
1124+
p.Normalize()
1125+
o, _ := routingOrchestrator(t, p)
1126+
return o
1127+
}
1128+
1129+
func TestAnIntegrationFailureRaisesATicketWithTheEvidence(t *testing.T) {
1130+
o := seamOrchestrator(t)
1131+
board := &plan.Board{Tasks: []plan.Task{
1132+
{ID: "T1", Role: "go-worker", Squad: "backend", Column: plan.ColDone, Files: []string{"cmd/server/main.go"}},
1133+
{ID: "T2", Role: "react-worker", Squad: "frontend", Column: plan.ColDone, Files: []string{"web/src/App.tsx"}},
1134+
}}
1135+
const output = "cmd/server/main.go:41: returned {items:[]}, the contract says a bare array"
1136+
1137+
o.raiseIntegrationTicket(board, "go test ./... && npm run build", "the halves do not agree on the response shape", output)
1138+
1139+
var ticket *plan.Task
1140+
for i := range board.Tasks {
1141+
if plan.CorrectionKeyOf(board.Tasks[i]) != "" {
1142+
ticket = &board.Tasks[i]
1143+
}
1144+
}
1145+
if ticket == nil {
1146+
t.Fatal("an integration failure must produce a ticket somebody owns")
1147+
}
1148+
// The provider owes the clause: the consumer built against text it was handed.
1149+
if ticket.Squad != "backend" {
1150+
t.Errorf("Squad = %q, want the team that provides the broken interface", ticket.Squad)
1151+
}
1152+
// A .go file means the Go specialist, not the generic worker.
1153+
if !strings.HasPrefix(ticket.Role, "go-") {
1154+
t.Errorf("Role = %q, want the specialist the implicated file calls for", ticket.Role)
1155+
}
1156+
// The evidence that used to be thrown away.
1157+
for _, want := range []string{
1158+
"go test ./... && npm run build", // how to reproduce it
1159+
"{items:[]}", // what it printed
1160+
"GET /api/todos", // which clause is at stake
1161+
"cmd/server/main.go", // where
1162+
} {
1163+
if !strings.Contains(ticket.Description, want) {
1164+
t.Errorf("the ticket is missing %q:\n%s", want, ticket.Description)
1165+
}
1166+
}
1167+
if len(ticket.Files) == 0 {
1168+
t.Error("the ticket has no files, so nothing scopes the fix")
1169+
}
1170+
}
1171+
1172+
// The same broken seam on the next round is the same defect. Stacking a ticket
1173+
// per integration run is how a board loses the plot.
1174+
func TestARepeatedSeamFailureBumpsTheTicketRatherThanStackingOne(t *testing.T) {
1175+
o := seamOrchestrator(t)
1176+
board := &plan.Board{}
1177+
const output = "cmd/server/main.go:41: returned {items:[]}"
1178+
1179+
o.raiseIntegrationTicket(board, "make check", "shape mismatch", output)
1180+
o.raiseIntegrationTicket(board, "make check", "shape mismatch", output)
1181+
1182+
tickets := 0
1183+
for _, task := range board.Tasks {
1184+
if plan.CorrectionKeyOf(task) != "" {
1185+
tickets++
1186+
if got := plan.CorrectionAttemptOf(task); got != 2 {
1187+
t.Errorf("attempt = %d, want the repeat recorded on the one ticket", got)
1188+
}
1189+
}
1190+
}
1191+
if tickets != 1 {
1192+
t.Errorf("%d tickets on the board, want 1", tickets)
1193+
}
1194+
}
1195+
1196+
// Scoping a ticket to the OTHER half's files is worse than not scoping it.
1197+
func TestASeamTicketNeverScopesToTheOtherTeamsFiles(t *testing.T) {
1198+
o := seamOrchestrator(t)
1199+
if got := o.seamFiles("web/src/App.tsx:12: boom", "backend"); len(got) != 0 {
1200+
t.Errorf("seamFiles = %v, want nothing rather than the frontend's files", got)
1201+
}
1202+
if got := o.seamFiles("cmd/server/main.go:41: boom", "backend"); len(got) != 1 || got[0] != "cmd/server/main.go" {
1203+
t.Errorf("seamFiles = %v, want the owning team's file", got)
1204+
}
1205+
// With nobody owing the seam, everything named is still useful context.
1206+
if got := o.seamFiles("web/src/App.tsx:12: boom", ""); len(got) != 1 {
1207+
t.Errorf("seamFiles(unowned) = %v, want the named path kept", got)
1208+
}
1209+
}

0 commit comments

Comments
 (0)