Announce the build agent's verbs, let a waiting build hear its cancel, and retry a stopped rollout (hq ADR 0219)

The holder's verbs were served and found by nothing; it now answers discovery
with its machine's four, as a runtime announces a seat's verb, so the console
finds <node>/node-build-agent.kill. A cancel publishes no outcome, so a build
waited for also looks at the cancelled set. A plan that stopped at its first
machine is retried by sending that machine the module again, unless a newer
plan holds it.
This commit is contained in:
jochen
2026-10-05 19:26:40 +02:00
parent 106507b1d3
commit 541603c15c
7 changed files with 273 additions and 7 deletions
+17 -1
View File
@@ -106,7 +106,20 @@ func (b *natsBuilds) Submit(ctx context.Context, request BuildRequest,
waiting, cancelWait := context.WithTimeout(ctx, wait)
defer cancelWait()
for {
msg, err := outcomes.NextMsgWithContext(waiting)
// **A wait that hears a cancel** (novox/hq ADR 0219). A person cancelling an ask records its
// failure without publishing the role's outcome — that subject is the holders', and the
// controller may not speak for them — so the waiter also looks, every while, at the cancelled
// set the cancel wrote first. A kill needs no look: the holder announces it as any outcome.
slice, endSlice := context.WithTimeout(waiting, cancelLook)
msg, err := outcomes.NextMsgWithContext(slice)
endSlice()
if errors.Is(err, context.DeadlineExceeded) && waiting.Err() == nil {
if cancelled, _ := IsCancelled(b.js.Conn(), b.role(), request.ID); cancelled {
return BuildResult{ID: request.ID, Repository: request.Repository, Path: request.Path,
Ref: request.Ref, Source: request.Source, DryRun: request.DryRun, Failed: CancelledByHand}, nil
}
continue
}
switch {
case errors.Is(err, context.DeadlineExceeded):
return BuildResult{}, waitingFor(wait)
@@ -125,6 +138,9 @@ func (b *natsBuilds) Submit(ctx context.Context, request BuildRequest,
}
}
// cancelLook is how often a waiting asker looks whether its ask was cancelled.
var cancelLook = 2 * time.Second
// --- the machine's side ---------------------------------------------------------------------
type natsMachine struct {
+36
View File
@@ -229,3 +229,39 @@ func TestNatsAHoldersPausedStateIsReadBack(t *testing.T) {
t.Fatalf("read back %+v", said)
}
}
// A `build` waiting on its outcome hears a cancel — which publishes no outcome — from the cancelled
// set, and a kill from the outcome the holder announces (novox/hq ADR 0219).
func TestNatsAWaitingAskerHearsItsAskCancelledOrKilled(t *testing.T) {
js := aBusWithACancelledSet(t)
was := cancelLook
cancelLook = 200 * time.Millisecond
t.Cleanup(func() { cancelLook = was })
ask := &natsBuilds{js: js, seat: TheBuildMachine}
go func() {
time.Sleep(500 * time.Millisecond)
_ = MarkCancelled(context.Background(), js, TheBuildMachine, "build-waited-cancelled")
}()
result, err := ask.Submit(context.Background(), BuildRequest{ID: "build-waited-cancelled", Repository: "/r"}, 10*time.Second)
if err != nil || result.Failed != CancelledByHand || result.ID != "build-waited-cancelled" {
t.Fatalf("the waiter heard %+v (%v)", result, err)
}
// Killed: the holder announces the failure as any outcome, and the waiter has it.
ctx, stop := context.WithCancel(context.Background())
defer stop()
machine := MachineOverNATS(js, "ace")
defer machine.Close()
go func() {
_ = machine.Take(ctx, func(ctx context.Context, work Build) {
r := work.Request()
_ = work.Announce(ctx, BuildResult{ID: r.ID, Repository: r.Repository, On: "ace", Failed: KilledByHand})
_ = work.Done()
})
}()
result, err = ask.Submit(context.Background(), BuildRequest{ID: "build-waited-killed", Repository: "/r"}, 10*time.Second)
if err != nil || result.Failed != KilledByHand || result.On != "ace" {
t.Fatalf("the waiter heard %+v (%v)", result, err)
}
}