Skip to content

Commit d56b7e6

Browse files
committed
fix: move blacksmith testbox sync guard into crabbox
1 parent 03ee54a commit d56b7e6

5 files changed

Lines changed: 181 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
### Changed
1414

1515
- Aligned direct GCP provisioning with Google's official Compute Go SDK (`cloud.google.com/go/compute/apiv1`) and project-wide aggregated instance discovery.
16+
- Moved OpenClaw Blacksmith Testbox run safeguards into Crabbox, including one-shot slug reporting and stalled sync termination.
1617
- Improved `crabbox media preview` and `artifacts collect --gif` defaults to generate higher-quality 1000px/24fps GIFs with Floyd-Steinberg palette dithering and optional gifsicle optimization. Thanks @obviyus.
1718

1819
### Fixed

docs/providers/blacksmith-testbox.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ when Crabbox must own SSH sync and interactive access.
1919

2020
## Commands
2121

22+
One-shot run:
23+
24+
```sh
25+
crabbox run \
26+
--provider blacksmith-testbox \
27+
--blacksmith-org openclaw \
28+
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
29+
--blacksmith-job test \
30+
--blacksmith-ref main \
31+
--timing-json \
32+
-- pnpm test
33+
```
34+
2235
Reuse an existing Testbox:
2336

2437
```sh
@@ -84,6 +97,13 @@ Daytona until Blacksmith service, billing, or org limits are healthy again.
8497

8598
Crabbox stores a per-Testbox SSH key locally, claims the Testbox for the current
8699
repo, maps IDs to friendly slugs, and prints a normal Crabbox timing summary.
100+
One-shot runs clean up the local claim/key and stop the Testbox after command
101+
completion unless `--keep` is set.
102+
103+
Crabbox terminates a local Blacksmith CLI invocation that remains in the sync
104+
phase for five minutes without post-sync output. Set
105+
`CRABBOX_BLACKSMITH_SYNC_TIMEOUT_MS=0` to disable the guard, or set a larger
106+
millisecond value for intentionally huge local diffs.
87107

88108
When coordinator auth is configured, `crabbox list --provider blacksmith-testbox`
89109
also syncs visibility-only Testbox rows into the portal lease table. If Crabbox

internal/providers/blacksmith/backend.go

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"flag"
66
"fmt"
77
"io"
8+
"os"
89
"strings"
10+
"sync"
911
"time"
1012

1113
core "github.com/openclaw/crabbox/internal/cli"
@@ -94,10 +96,11 @@ func (b *blacksmithBackend) Run(ctx context.Context, req RunRequest) (RunResult,
9496
}
9597
started := b.rt.Clock.Now()
9698
leaseID := req.ID
99+
slug := ""
97100
acquired := false
98101
var err error
99102
if leaseID == "" {
100-
leaseID, _, err = b.warmupLease(ctx, req.Repo, req.Reclaim)
103+
leaseID, slug, err = b.warmupLease(ctx, req.Repo, req.Reclaim)
101104
if err != nil {
102105
return RunResult{}, err
103106
}
@@ -107,7 +110,7 @@ func (b *blacksmithBackend) Run(ctx context.Context, req RunRequest) (RunResult,
107110
if err != nil {
108111
return RunResult{}, err
109112
}
110-
slug, err := blacksmithClaimSlug(req.ID, leaseID)
113+
slug, err = blacksmithClaimSlug(req.ID, leaseID)
111114
if err != nil {
112115
return RunResult{}, err
113116
}
@@ -135,6 +138,7 @@ func (b *blacksmithBackend) Run(ctx context.Context, req RunRequest) (RunResult,
135138
if err := writeTimingJSON(b.rt.Stderr, timingReport{
136139
Provider: blacksmithTestboxProvider,
137140
LeaseID: leaseID,
141+
Slug: slug,
138142
SyncPhases: []timingPhase{{Name: "delegated", Skipped: true, Reason: "blacksmith-testbox owns sync"}},
139143
SyncDelegated: true,
140144
CommandMs: commandDuration.Milliseconds(),
@@ -263,7 +267,16 @@ func (b *blacksmithBackend) runTestbox(ctx context.Context, leaseID string, comm
263267
return 2
264268
}
265269
args := blacksmithRunArgs(b.cfg, leaseID, keyPath, command, debug || b.cfg.Blacksmith.Debug, shellMode)
266-
result, err := b.runCommand(ctx, args, b.rt.Stdout, b.rt.Stderr)
270+
result, timedOut, err := b.runCommandWithSyncGuard(ctx, args, b.rt.Stdout, b.rt.Stderr)
271+
if timedOut {
272+
fmt.Fprintf(
273+
b.rt.Stderr,
274+
"Blacksmith Testbox sync produced no post-sync output for %s; terminating local runner. "+
275+
"Rerun with CRABBOX_BLACKSMITH_SYNC_TIMEOUT_MS=0 to disable this guard.\n",
276+
blacksmithSyncTimeout(os.Getenv),
277+
)
278+
return 124
279+
}
267280
if err != nil {
268281
return result.ExitCode
269282
}
@@ -286,6 +299,95 @@ func (b *blacksmithBackend) runCommand(ctx context.Context, args []string, stdou
286299
return result, nil
287300
}
288301

302+
func (b *blacksmithBackend) runCommandWithSyncGuard(ctx context.Context, args []string, stdout, stderr io.Writer) (LocalCommandResult, bool, error) {
303+
timeout := blacksmithSyncTimeout(os.Getenv)
304+
if timeout <= 0 {
305+
result, err := b.runCommand(ctx, args, stdout, stderr)
306+
return result, false, err
307+
}
308+
guardCtx, cancel := context.WithCancel(ctx)
309+
defer cancel()
310+
tracker := &blacksmithSyncTracker{}
311+
resultCh := make(chan struct {
312+
result LocalCommandResult
313+
err error
314+
}, 1)
315+
go func() {
316+
result, err := b.runCommand(
317+
guardCtx,
318+
args,
319+
blacksmithSyncGuardWriter{w: stdout, tracker: tracker},
320+
blacksmithSyncGuardWriter{w: stderr, tracker: tracker},
321+
)
322+
resultCh <- struct {
323+
result LocalCommandResult
324+
err error
325+
}{result: result, err: err}
326+
}()
327+
ticker := time.NewTicker(minBlacksmithDuration(timeout, time.Second))
328+
defer ticker.Stop()
329+
timedOut := false
330+
for {
331+
select {
332+
case result := <-resultCh:
333+
return result.result, timedOut, result.err
334+
case <-ticker.C:
335+
if !tracker.syncStalled(timeout, b.rt.Clock.Now()) {
336+
continue
337+
}
338+
timedOut = true
339+
cancel()
340+
}
341+
}
342+
}
343+
344+
type blacksmithSyncTracker struct {
345+
mu sync.Mutex
346+
syncingSince time.Time
347+
}
348+
349+
func (t *blacksmithSyncTracker) observe(text string, now time.Time) {
350+
t.mu.Lock()
351+
defer t.mu.Unlock()
352+
if strings.Contains(text, "Syncing...") {
353+
if t.syncingSince.IsZero() {
354+
t.syncingSince = now
355+
}
356+
return
357+
}
358+
if !t.syncingSince.IsZero() && blacksmithPostSyncPattern.MatchString(text) {
359+
t.syncingSince = time.Time{}
360+
}
361+
}
362+
363+
func (t *blacksmithSyncTracker) syncStalled(timeout time.Duration, now time.Time) bool {
364+
t.mu.Lock()
365+
defer t.mu.Unlock()
366+
return !t.syncingSince.IsZero() && now.Sub(t.syncingSince) >= timeout
367+
}
368+
369+
type blacksmithSyncGuardWriter struct {
370+
w io.Writer
371+
tracker *blacksmithSyncTracker
372+
}
373+
374+
func (w blacksmithSyncGuardWriter) Write(chunk []byte) (int, error) {
375+
if w.tracker != nil {
376+
w.tracker.observe(string(chunk), time.Now())
377+
}
378+
if w.w == nil {
379+
return len(chunk), nil
380+
}
381+
return w.w.Write(chunk)
382+
}
383+
384+
func minBlacksmithDuration(left, right time.Duration) time.Duration {
385+
if left < right {
386+
return left
387+
}
388+
return right
389+
}
390+
289391
func (b *blacksmithBackend) listIDsBestEffort(ctx context.Context) map[string]bool {
290392
out, err := b.commandOutput(ctx, blacksmithListAllArgs(b.cfg))
291393
if err != nil {

internal/providers/blacksmith/backend_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package blacksmith
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"io"
@@ -29,6 +30,16 @@ func (r *blacksmithFuncRunner) Run(_ context.Context, req LocalCommandRequest) (
2930
return LocalCommandResult{}, nil
3031
}
3132

33+
type blockingSyncRunner struct{}
34+
35+
func (blockingSyncRunner) Run(ctx context.Context, req LocalCommandRequest) (LocalCommandResult, error) {
36+
if req.Stdout != nil {
37+
_, _ = req.Stdout.Write([]byte("Syncing... waiting for remote workspace\n"))
38+
}
39+
<-ctx.Done()
40+
return LocalCommandResult{ExitCode: 1}, ctx.Err()
41+
}
42+
3243
func newTestBlacksmithBackend(cfg Config, runner CommandRunner) *blacksmithBackend {
3344
return &blacksmithBackend{
3445
spec: Provider{}.Spec(),
@@ -285,6 +296,34 @@ func TestBlacksmithOneShotRunRemovesClaimAfterStop(t *testing.T) {
285296
}
286297
}
287298

299+
func TestBlacksmithRunTerminatesSyncStall(t *testing.T) {
300+
home := t.TempDir()
301+
t.Setenv("HOME", home)
302+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
303+
t.Setenv("CRABBOX_BLACKSMITH_SYNC_TIMEOUT_MS", "1")
304+
if _, _, err := ensureTestboxKey("tbx_syncstall"); err != nil {
305+
t.Fatal(err)
306+
}
307+
var stderr bytes.Buffer
308+
backend := &blacksmithBackend{
309+
spec: Provider{}.Spec(),
310+
cfg: baseConfig(),
311+
rt: Runtime{
312+
Stdout: io.Discard,
313+
Stderr: &stderr,
314+
Clock: testClock{},
315+
Exec: blockingSyncRunner{},
316+
},
317+
}
318+
code := backend.runTestbox(context.Background(), "tbx_syncstall", []string{"pnpm", "test"}, false, false)
319+
if code != 124 {
320+
t.Fatalf("exit=%d want 124", code)
321+
}
322+
if !strings.Contains(stderr.String(), "Blacksmith Testbox sync produced no post-sync output") {
323+
t.Fatalf("stderr=%q", stderr.String())
324+
}
325+
}
326+
288327
func TestBlacksmithBackendUsesInjectedCommandRunnerForListAndStatus(t *testing.T) {
289328
runner := &blacksmithFuncRunner{fn: func(LocalCommandRequest) (LocalCommandResult, error) {
290329
return LocalCommandResult{

internal/providers/blacksmith/helpers.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"flag"
55
"fmt"
66
"regexp"
7+
"strconv"
78
"strings"
89
"time"
910

@@ -14,6 +15,7 @@ const blacksmithTestboxProvider = "blacksmith-testbox"
1415

1516
var (
1617
blacksmithIDPattern = regexp.MustCompile(`\btbx_[A-Za-z0-9_-]+\b`)
18+
blacksmithPostSyncPattern = regexp.MustCompile(`(?i)\b(running|executing|command|pnpm|npm|yarn|bun)\b`)
1719
blacksmithCleanupAttempts = 36
1820
blacksmithCleanupDelay = 5 * time.Second
1921
blacksmithCleanupQuiet = 12
@@ -191,6 +193,20 @@ func parseBlacksmithID(output string) string {
191193
return blacksmithIDPattern.FindString(output)
192194
}
193195

196+
func blacksmithSyncTimeout(env func(string) string) time.Duration {
197+
for _, key := range []string{"CRABBOX_BLACKSMITH_SYNC_TIMEOUT_MS", "OPENCLAW_TESTBOX_SYNC_TIMEOUT_MS"} {
198+
raw := strings.TrimSpace(env(key))
199+
if raw == "" {
200+
continue
201+
}
202+
parsed, err := strconv.Atoi(raw)
203+
if err == nil && parsed >= 0 {
204+
return time.Duration(parsed) * time.Millisecond
205+
}
206+
}
207+
return 5 * time.Minute
208+
}
209+
194210
func resolveBlacksmithLeaseID(identifier, repoRoot string, reclaim bool) (string, error) {
195211
if identifier == "" {
196212
return "", exit(2, "blacksmith-testbox requires --id <tbx-id-or-slug>")

0 commit comments

Comments
 (0)