Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!-- docket:backlink:start (generated — do not hand-edit) -->
> ↩ **[Change 0459 — Worker's gate.drive.acknowledge is refused scope-closed after the parent claims its WAITING drive](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0459-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md)**
<!-- docket:backlink:end -->
# Worker's gate.drive.acknowledge is refused scope-closed after the parent claims its WAITING drive — Results

**Human action:** No action is required before merge. Read the behavior change below: several existing gate-drive tests now expect the new `scope-transferred` refusal where they used to expect `scope-closed`.

## Outcome

Before this change, a build worker could report `BLOCKED` for work it had actually finished. The sequence was: the worker's test run outlived one observation slice, so it handed the run off (`WAITING`); the parent claimed the run and finished it; the worker then tried to acknowledge its original test scope. That scope had been closed when the parent claimed it, so the acknowledge was refused with `scope-closed`, and the refusal message told the worker to return `BLOCKED`.

What changed:

- **New refusal, `scope-transferred`.** A child-side acknowledge, scoped test start, or scope reservation on a scope that a parent claim or takeover closed now returns `scope-transferred`. Its message says the parent took over the scope's run and tells the worker to report on the verdict its continuation supplied. It never says "return BLOCKED". A scope that was closed by the worker's own final acknowledgement still returns `scope-closed`. Parent-side paths (takeover, bind-scope-change) are unchanged.
- **Worker contract** (`docket-build-task`): a worker that handed off never acknowledges or reuses its original scope. It reports on the continuation's terminal verdict and runs further tests only under a fresh scope.
- **Parent contract** (`docket-build`): the continuation carries the claimed run's id and verdict, states that the original scope is closed, and includes a freshly prepared scope when more test runs may be needed.
- **Guards**: `internal/repoguard` prose-contract rows pin the new contract sentences (mutation-tested).

Departures from the design:

- The spec said the existing takeover tests would stay unchanged. That was wrong: several tests assert what a *child* sees on a scope closed by takeover or claim, and by the spec's own rule that is now `scope-transferred`. Those assertions were updated (takeover, admission-successor, driver, driver-concurrency, scope, and handoff tests). The takeover path's own result is still `scope-closed`.
- Review fixes widened the change a little: the close race inside the worker's own acknowledge (it loses to a concurrent claim) now also returns `scope-transferred`, and a scoped start checks the child capability before revealing whether the scope was closed.
- The skill size budgets in `internal/repoguard/budgets_test.go` were raised to fit the new contract prose.

## Verification performed

- Each task ran its focused package tests through the gate driver; the claim → advance → acknowledge regression was red before the fix and green after.
- Full suite (`go run ./cmd/docket development test`) passed at 9ff69611 before review: 54/54 files. `BUDGET WATCH` lines were reported for the long integration/race files under parallel load; there was no serial-confirmed breach.
- Whole-branch review (standard rung) returned three minor findings, all fixed in-branch (551b8344, f257fc1d). The final certification suite run is recorded in the PR's build-evidence block.
- Guard mutation probes: deleting or rewording each guarded contract sentence turned `TestProseContracts` red.

## Known issues and follow-ups

### Spec's "takeover tests unchanged" line was inaccurate

This matters when someone reads the spec against the diff. They will see takeover-related tests edited even though the spec said they would not be. Confirmed. Only child-facing assertions changed; the parent takeover path still halts with `scope-closed`. No action is needed beyond knowing this.

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion internal/app/gate_drive.go
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,9 @@ func ownershipNextAction(kind gatedrive.OwnershipErrorKind) string {
case gatedrive.ErrHandoffOutstanding:
return "claim the outstanding handoff instead of starting or taking over"
case gatedrive.ErrScopeClosed:
return "scope authority was transferred or finished; stop and return BLOCKED"
return "this scope was already finished by its terminal acknowledgement; stop and return BLOCKED"
case gatedrive.ErrScopeTransferred:
return "the parent claimed or took over this scope's drive; this scope is no longer yours — report on the verdict your continuation supplied, and run further tests only under a fresh scope"
case gatedrive.ErrStalePredecessor:
return "the presented predecessor is not the scope's current drive"
case gatedrive.ErrPredecessorNotReusable:
Expand Down
35 changes: 35 additions & 0 deletions internal/app/gate_drive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,41 @@ func TestAcknowledgeForwardsArgsAndMapsDoc(t *testing.T) {
}
}

// TestAcknowledgeScopeTransferredEnvelope is the change-0459 app-layer pin: a
// scope-transferred ownership rejection surfaces reason "scope-transferred"
// under invalid-input, with a next-action message that names the real state
// (parent claimed/took over; report on the continuation's verdict; fresh scope
// for further tests) and never says BLOCKED — while the reworded scope-closed
// message keeps BLOCKED and drops the old "transferred or" wording.
func TestAcknowledgeScopeTransferredEnvelope(t *testing.T) {
bad := &fakeDriveEngine{err: &gatedrive.OwnershipError{Kind: gatedrive.ErrScopeTransferred, Op: "acknowledge"}}
svc := newGateDriveService(bad, 0, "", "")
got := svc.Acknowledge("sc-x", "childcap", "dx", "genx")
if got.Result != ResultInvalidInput || got.Drive != nil {
t.Fatalf("scope-transferred must map to invalid-input with no drive, got result=%s", got.Result)
}
if got.Reason != string(gatedrive.ErrScopeTransferred) {
t.Fatalf("reason = %q, want %q", got.Reason, string(gatedrive.ErrScopeTransferred))
}
if strings.Contains(got.Message, "BLOCKED") {
t.Fatalf("the scope-transferred message must never direct the worker to BLOCKED, got %q", got.Message)
}
for _, want := range []string{"parent claimed or took over", "verdict your continuation supplied", "fresh scope"} {
if !strings.Contains(got.Message, want) {
t.Fatalf("scope-transferred message must contain %q, got %q", want, got.Message)
}
}

// The finished-scope message: still directs BLOCKED, no longer claims a transfer.
closedMsg := ownershipNextAction(gatedrive.ErrScopeClosed)
if !strings.Contains(closedMsg, "BLOCKED") {
t.Fatalf("the scope-closed message must keep directing BLOCKED, got %q", closedMsg)
}
if strings.Contains(closedMsg, "transferred") {
t.Fatalf("the scope-closed message must no longer say transferred, got %q", closedMsg)
}
}

// TestTakeoverMapsDoc proves Takeover delegates to the engine, carries a
// successful document verbatim under the takeover operation name, and maps a
// command failure through the shared mapDriveFailure classifier.
Expand Down
10 changes: 5 additions & 5 deletions internal/assets/embedded/manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions internal/assets/embedded/tree/skills/docket-build-task/SKILL.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions internal/assets/embedded/tree/skills/docket-build/SKILL.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 19 additions & 5 deletions internal/gatedrive/acknowledge.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string)
// successful acknowledgement: a scope that is Closed AND FinalAcked, still naming
// this drive, whose drive record is already owner-cleared with a durable
// PASSED/FAILED verdict, is the recorded terminal — return its document with no
// write. A scope closed by a claim or takeover (FinalAcked false), or a
// non-matching drive, is a fail-closed ErrScopeClosed.
// write. A scope closed by a claim or takeover (FinalAcked false) is
// ErrScopeTransferred — authority moved to the parent; a FinalAcked scope with a
// non-matching drive is a fail-closed ErrScopeClosed.
//
// Intentional ownerGen asymmetry (change 0405): unlike the normal path, this
// branch does NOT verify the presented ownerGen — childCapability (checked above)
Expand All @@ -73,6 +74,11 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string)
return d.recordedDoc(driveID, ownerGen, rec), nil
}
}
if !scope.FinalAcked {
// Closed by a claim or takeover, not by a terminal acknowledgement:
// scope authority transferred to the parent (change 0459).
return DriveDoc{}, ownershipErr(ErrScopeTransferred, "acknowledge")
}
return DriveDoc{}, ownershipErr(ErrScopeClosed, "acknowledge")
}

Expand Down Expand Up @@ -139,7 +145,8 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string)
// Close the scope as terminally acknowledged, revalidating the slot under the
// scope lock (revalidate-after-authority). The owner is already retired, so a
// concurrent transition that moved the slot fails this close closed rather than
// closing over the wrong drive.
// closing over the wrong drive; a claim or takeover that closed the scope in
// between surfaces as ErrScopeTransferred, not ErrScopeClosed.
if cerr := d.store.closeScopeFinal(scopeID, driveID); cerr != nil {
return DriveDoc{}, cerr
}
Expand All @@ -156,11 +163,18 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string)
// FinalAcked, but ONLY when driveID is still the scope's current drive — the
// revalidation-after-authority the lock order requires (a concurrent transition
// that moved the slot between the caller's read and this close is caught here). A
// mismatched drive id is a fail-closed ErrStalePredecessor; an already-closed
// scope is ErrScopeClosed. On any rejection the persisted record is untouched.
// mismatched drive id is a fail-closed ErrStalePredecessor. An already-closed
// scope is ErrScopeClosed when it was finished by its terminal acknowledgement
// (FinalAcked), and ErrScopeTransferred when a claim or takeover closed it
// (!FinalAcked) — the worker's own acknowledgement lost the race between
// retirePredecessor and this close, so authority moved to the parent (change
// 0459). On any rejection the persisted record is untouched.
func (s *Store) closeScopeFinal(scopeID, driveID string) error {
return s.scopeCAS(scopeID, func(rec *scopeRecord) error {
if rec.Closed {
if !rec.FinalAcked {
return ownershipErr(ErrScopeTransferred, "acknowledge-close")
}
return ownershipErr(ErrScopeClosed, "acknowledge-close")
}
if rec.CurrentDriveID != driveID {
Expand Down
Loading