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+
289391func (b * blacksmithBackend ) listIDsBestEffort (ctx context.Context ) map [string ]bool {
290392 out , err := b .commandOutput (ctx , blacksmithListAllArgs (b .cfg ))
291393 if err != nil {
0 commit comments