Skip to content

Commit 99116ff

Browse files
ddx-checkpointclaude
andcommitted
fix(worker): survive bead-only commits; hand off only on real binary replacement [ddx-65d3ba51]
ddx work --watch compared its build commit against the installed binary's ddx-version commit and exited whenever they differed. Because ddx bead create/update auto-commit to .ddx/beads.jsonl, the ddx repo HEAD advances constantly, so any path that rebaked the version commit made the worker self-terminate (without reliably spawning a replacement). Gate the hand-off on the replacement binary file actually being replaced on disk (size+mtime) since the worker started: bead-only commits no longer kill the worker, while an intentional reinstall still triggers a refresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d67b667 commit 99116ff

2 files changed

Lines changed: 71 additions & 1 deletion

File tree

cli/cmd/work_binary_refresh.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,21 @@ func (f *CommandFactory) buildWorkBinaryRefreshCheck(cmd *cobra.Command, project
2626
if currentCommit == "" {
2727
return nil
2828
}
29+
exe := resolveReplacementDDXPath()
30+
baseline, haveBaseline := snapshotBinary(exe)
2931
return func(ctx context.Context) (bool, error) {
30-
exe := resolveReplacementDDXPath()
32+
// Only hand off when the replacement binary file has actually been
33+
// replaced on disk since this worker started (an intentional
34+
// reinstall). Bead-only auto-commits (chore(beads): ... doc-stamp)
35+
// advance the ddx repo HEAD but never rewrite the binary, so they must
36+
// not terminate the worker (ddx-65d3ba51).
37+
if !haveBaseline {
38+
return false, nil
39+
}
40+
current, ok := snapshotBinary(exe)
41+
if !ok || current.equal(baseline) {
42+
return false, nil
43+
}
3144
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
3245
defer cancel()
3346
installedCommit, err := installedDDXCommit(checkCtx, exe)
@@ -100,6 +113,27 @@ func shouldRefreshDDXBinary(currentCommit, installedCommit string) bool {
100113
return true
101114
}
102115

116+
// binarySnapshot captures the on-disk identity of an executable so a worker can
117+
// tell whether the file was replaced (reinstalled) versus unchanged. It
118+
// deliberately does not look at git/version metadata — repo HEAD advances from
119+
// bead commits must not read as a binary change.
120+
type binarySnapshot struct {
121+
size int64
122+
modTime time.Time
123+
}
124+
125+
func snapshotBinary(path string) (binarySnapshot, bool) {
126+
info, err := os.Stat(path)
127+
if err != nil {
128+
return binarySnapshot{}, false
129+
}
130+
return binarySnapshot{size: info.Size(), modTime: info.ModTime()}, true
131+
}
132+
133+
func (b binarySnapshot) equal(o binarySnapshot) bool {
134+
return b.size == o.size && b.modTime.Equal(o.modTime)
135+
}
136+
103137
func resolveReplacementDDXPath() string {
104138
if len(os.Args) > 0 && os.Args[0] != "" {
105139
if path, err := exec.LookPath(os.Args[0]); err == nil {

cli/cmd/work_binary_refresh_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package cmd
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
7+
"time"
58

69
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
711
)
812

913
func TestParseDDXVersionCommit(t *testing.T) {
@@ -25,3 +29,35 @@ func TestWorkBinaryRefreshEnabledOnlyForInstalledDDX(t *testing.T) {
2529
assert.False(t, workBinaryRefreshEnabled([]string{"/tmp/go-build123/cmd.test", "-test.run=TestWork"}))
2630
assert.False(t, workBinaryRefreshEnabled(nil))
2731
}
32+
33+
// TestBinarySnapshotSurvivesBeadCommitsDetectsReinstall verifies the signal the
34+
// worker now uses to decide whether to hand off: a bead-only commit never
35+
// rewrites the binary (snapshot unchanged → worker stays put), while a real
36+
// reinstall rewrites it (snapshot differs → worker hands off). See
37+
// ddx-65d3ba51.
38+
func TestBinarySnapshotSurvivesBeadCommitsDetectsReinstall(t *testing.T) {
39+
dir := t.TempDir()
40+
path := filepath.Join(dir, "ddx")
41+
require.NoError(t, os.WriteFile(path, []byte("ddx-binary-v1"), 0o755))
42+
43+
base, ok := snapshotBinary(path)
44+
require.True(t, ok)
45+
46+
// A `ddx bead create` advances the ddx repo HEAD but does not touch the
47+
// worker's binary: the snapshot is unchanged, so the worker must not exit.
48+
unchanged, ok := snapshotBinary(path)
49+
require.True(t, ok)
50+
assert.True(t, base.equal(unchanged), "unchanged binary must compare equal so the worker survives bead commits")
51+
52+
// An intentional reinstall rewrites the binary: the snapshot differs, so the
53+
// worker still hands off to the new build.
54+
time.Sleep(10 * time.Millisecond)
55+
require.NoError(t, os.WriteFile(path, []byte("ddx-binary-v2-reinstalled"), 0o755))
56+
reinstalled, ok := snapshotBinary(path)
57+
require.True(t, ok)
58+
assert.False(t, base.equal(reinstalled), "a replaced binary must compare not-equal so the worker upgrades")
59+
60+
// A missing binary yields no snapshot; the worker stays put rather than dying.
61+
_, ok = snapshotBinary(filepath.Join(dir, "absent"))
62+
assert.False(t, ok)
63+
}

0 commit comments

Comments
 (0)