Skip to content

Commit ebf70f6

Browse files
semantic-score: strengthen lobby queue aging and extend Resume wait to 5m
Longer door-queue wait now boosts admit score so low-priority knockers eventually win, and SEMANTIC_WAIT_SEC defaults to 300s to cut not-best timeouts. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 74f20d7 commit ebf70f6

4 files changed

Lines changed: 128 additions & 22 deletions

File tree

internal/policy/policy.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package policy
55

66
import (
77
"context"
8+
"time"
89

910
"github.com/actordock/actordock/internal/signals"
1011
"github.com/actordock/actordock/internal/types"
@@ -37,6 +38,9 @@ type ResumeRequest struct {
3738
// semantic-score uses this so only the highest-score knocker may Place/Evict;
3839
// other policies ignore it.
3940
Waiting []types.Sandbox
41+
// WaitingSince maps sandbox ID → when it joined the Resume lobby.
42+
// Used for queue-age boost so long waiters eventually become top-ranked.
43+
WaitingSince map[string]time.Time
4044
}
4145

4246
// Policy chooses placement, eviction, and resume targets.

internal/policy/semantic_score.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ var ErrNotBestWaiter = errors.New("semantic-score: not highest-score Resume wait
2828
// running sandbox with the lowest agent-semantic keepScore after a phase-lock filter.
2929
// See docs/architecture/semantic-score.md.
3030
type SemanticScore struct {
31-
WL, WU, WF, WC float64
32-
Override bool
33-
PriorMix float64
34-
EmbedAlpha float64
35-
Now func() time.Time
31+
WL, WU, WF, WC, WQ float64
32+
Override bool
33+
PriorMix float64
34+
EmbedAlpha float64
35+
Now func() time.Time
3636
}
3737

3838
func NewSemanticScore() *SemanticScore {
@@ -41,6 +41,7 @@ func NewSemanticScore() *SemanticScore {
4141
WU: envFloat("SEMANTIC_W_U", 2),
4242
WF: envFloat("SEMANTIC_W_F", 2),
4343
WC: envFloat("SEMANTIC_W_C", 1),
44+
WQ: envFloat("SEMANTIC_W_Q", 2),
4445
Override: envBool("SEMANTIC_OVERRIDE", false),
4546
PriorMix: envFloat("SEMANTIC_PRIOR_MIX", 0.3),
4647
EmbedAlpha: envFloat("SEMANTIC_EMBED_ALPHA", 0.2),
@@ -87,7 +88,7 @@ func (p *SemanticScore) Resume(ctx context.Context, req ResumeRequest) (PlaceRes
8788
}
8889

8990
// RequireBestWaiter returns ErrNotBestWaiter unless req.Sandbox has the highest
90-
// admit keepScore among Waiting (continuous single-knocker ranking).
91+
// admit score among Waiting (keepScore + queue-age boost; single-knocker ranking).
9192
func (p *SemanticScore) RequireBestWaiter(req ResumeRequest) error {
9293
if len(req.Waiting) == 0 {
9394
return nil
@@ -96,12 +97,12 @@ func (p *SemanticScore) RequireBestWaiter(req ResumeRequest) error {
9697
if p.Now != nil {
9798
now = p.Now()
9899
}
99-
mine := keepScore(req.Sandbox, req.SandboxSignals, p, now)
100+
mine := admitScore(req.Sandbox, req.SandboxSignals, p, now, req.WaitingSince)
100101
for _, other := range req.Waiting {
101102
if other.ID == "" || other.ID == req.Sandbox.ID {
102103
continue
103104
}
104-
sc := keepScore(other, req.SandboxSignals, p, now)
105+
sc := admitScore(other, req.SandboxSignals, p, now, req.WaitingSince)
105106
if sc > mine {
106107
return ErrNotBestWaiter
107108
}
@@ -117,6 +118,26 @@ func (p *SemanticScore) RequireBestWaiter(req ResumeRequest) error {
117118
return nil
118119
}
119120

121+
// admitScore is keepScore plus lobby queue aging so long waiters eventually win.
122+
func admitScore(sb types.Sandbox, sandboxSig map[string]signals.SandboxSignals, p *SemanticScore, now time.Time, waitingSince map[string]time.Time) float64 {
123+
base := keepScore(sb, sandboxSig, p, now)
124+
queueSec := 0.0
125+
if waitingSince != nil {
126+
if t, ok := waitingSince[sb.ID]; ok && !t.IsZero() {
127+
queueSec = now.Sub(t).Seconds()
128+
if queueSec < 0 {
129+
queueSec = 0
130+
}
131+
}
132+
}
133+
return base + p.WQ*queueAging(queueSec)
134+
}
135+
136+
// queueAging maps lobby wait seconds → unscaled score units (per minute of wait).
137+
func queueAging(sec float64) float64 {
138+
return sec / 60.0
139+
}
140+
120141
func pickSemanticVictim(running []types.Sandbox, sandboxSig map[string]signals.SandboxSignals, p *SemanticScore, now time.Time) (types.Sandbox, string, error) {
121142
var candidates []types.Sandbox
122143
for _, sb := range running {

internal/policy/semantic_score_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,3 +324,69 @@ func TestSemanticScoreResumeBestWaiterCanPreemptLLMWait(t *testing.T) {
324324
t.Fatalf("victim=%q want hold (llm_wait preempt)", res.VictimID)
325325
}
326326
}
327+
328+
func TestSemanticScoreQueueAgingPromotesLongWaiter(t *testing.T) {
329+
p := policy.NewSemanticScore()
330+
now := time.Date(2026, 7, 23, 12, 0, 0, 0, time.UTC)
331+
p.Now = func() time.Time { return now }
332+
333+
low := -0.4
334+
high := 0.4
335+
longWait := types.Sandbox{ID: "long", State: types.SandboxSuspended, CreatedAt: now}
336+
shortWait := types.Sandbox{ID: "short", State: types.SandboxSuspended, CreatedAt: now.Add(-time.Minute)}
337+
sandboxSig := map[string]signals.SandboxSignals{
338+
"long": {SandboxID: "long", Semantic: signals.SemanticResource{
339+
Phase: signals.PhaseLLMWait,
340+
TaskProfile: &signals.TaskProfile{
341+
ComplexitySignal: &low, Confidence: 0.9,
342+
},
343+
}},
344+
"short": {SandboxID: "short", Semantic: signals.SemanticResource{
345+
Phase: signals.PhaseLLMWait,
346+
TaskProfile: &signals.TaskProfile{
347+
ComplexitySignal: &high, Confidence: 0.9,
348+
},
349+
}},
350+
}
351+
waiting := []types.Sandbox{longWait, shortWait}
352+
353+
// Fresh lobby: high static score still wins.
354+
err := p.RequireBestWaiter(policy.ResumeRequest{
355+
Sandbox: longWait,
356+
SandboxSignals: sandboxSig,
357+
Waiting: waiting,
358+
WaitingSince: map[string]time.Time{
359+
"long": now,
360+
"short": now,
361+
},
362+
})
363+
if !errors.Is(err, policy.ErrNotBestWaiter) {
364+
t.Fatalf("fresh long waiter err=%v want ErrNotBestWaiter", err)
365+
}
366+
367+
// After ~3 minutes in lobby, queue aging should promote the low static score.
368+
err = p.RequireBestWaiter(policy.ResumeRequest{
369+
Sandbox: longWait,
370+
SandboxSignals: sandboxSig,
371+
Waiting: waiting,
372+
WaitingSince: map[string]time.Time{
373+
"long": now.Add(-3 * time.Minute),
374+
"short": now.Add(-10 * time.Second),
375+
},
376+
})
377+
if err != nil {
378+
t.Fatalf("aged long waiter err=%v want nil", err)
379+
}
380+
err = p.RequireBestWaiter(policy.ResumeRequest{
381+
Sandbox: shortWait,
382+
SandboxSignals: sandboxSig,
383+
Waiting: waiting,
384+
WaitingSince: map[string]time.Time{
385+
"long": now.Add(-3 * time.Minute),
386+
"short": now.Add(-10 * time.Second),
387+
},
388+
})
389+
if !errors.Is(err, policy.ErrNotBestWaiter) {
390+
t.Fatalf("short waiter err=%v want ErrNotBestWaiter after aging", err)
391+
}
392+
}

internal/scheduler/scheduler.go

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,19 @@ type Scheduler struct {
4141
goldenMu sync.Mutex
4242

4343
// resumeWaiters tracks sandboxes blocked in Resume (candidates to knock).
44-
// Under semantic-score only the highest keepScore waiter may knock at a time.
44+
// Under semantic-score only the highest admit-score waiter may knock at a time.
4545
resumeWaitMu sync.Mutex
46-
resumeWaiters map[string]types.Sandbox
46+
resumeWaiters map[string]resumeWaiter
4747

4848
// knockMu serializes the single active knocker's Place/Evict/Restore.
4949
knockMu sync.Mutex
5050
}
5151

52+
type resumeWaiter struct {
53+
Sandbox types.Sandbox
54+
Since time.Time
55+
}
56+
5257
func New(st store.Store, pol policy.Policy, snapRoot string, log *slog.Logger, m *metrics.Metrics, sig *signals.Store) *Scheduler {
5358
if log == nil {
5459
log = slog.Default()
@@ -64,7 +69,7 @@ func New(st store.Store, pol policy.Policy, snapRoot string, log *slog.Logger, m
6469
log: log,
6570
metrics: m,
6671
signals: sig,
67-
resumeWaiters: make(map[string]types.Sandbox),
72+
resumeWaiters: make(map[string]resumeWaiter),
6873
}
6974
if m != nil {
7075
m.SetPoolStats(s)
@@ -344,10 +349,11 @@ func (s *Scheduler) Resume(ctx context.Context, id string) (types.Sandbox, error
344349
}
345350
now := time.Now().UTC()
346351
sandboxSig, workerSig := s.signalViews(now)
352+
waiting, waitingSince := s.listResumeWaitState()
347353
res, err := s.policy.Resume(ctx, policy.ResumeRequest{
348354
Sandbox: sb, Workers: workers, Running: running,
349355
SandboxSignals: sandboxSig, WorkerSignals: workerSig,
350-
Waiting: s.listResumeWaiters(),
356+
Waiting: waiting, WaitingSince: waitingSince,
351357
})
352358
if err != nil {
353359
lastErr = err
@@ -373,7 +379,7 @@ func (s *Scheduler) Resume(ctx context.Context, id string) (types.Sandbox, error
373379
"err", err.Error(),
374380
"running", len(running),
375381
"workers", len(workers),
376-
"waiters", len(s.listResumeWaiters()),
382+
"waiters", len(waiting),
377383
)
378384
select {
379385
case <-ctx.Done():
@@ -512,10 +518,12 @@ func (s *Scheduler) ensureKnockerTurn(sb types.Sandbox) error {
512518
}
513519
now := time.Now().UTC()
514520
sandboxSig, _ := s.signalViews(now)
521+
waiting, waitingSince := s.listResumeWaitState()
515522
return ss.RequireBestWaiter(policy.ResumeRequest{
516523
Sandbox: sb,
517524
SandboxSignals: sandboxSig,
518-
Waiting: s.listResumeWaiters(),
525+
Waiting: waiting,
526+
WaitingSince: waitingSince,
519527
})
520528
}
521529

@@ -692,9 +700,14 @@ func (s *Scheduler) addResumeWaiter(sb types.Sandbox) {
692700
s.resumeWaitMu.Lock()
693701
defer s.resumeWaitMu.Unlock()
694702
if s.resumeWaiters == nil {
695-
s.resumeWaiters = make(map[string]types.Sandbox)
703+
s.resumeWaiters = make(map[string]resumeWaiter)
696704
}
697-
s.resumeWaiters[sb.ID] = sb
705+
if w, ok := s.resumeWaiters[sb.ID]; ok {
706+
w.Sandbox = sb
707+
s.resumeWaiters[sb.ID] = w
708+
return
709+
}
710+
s.resumeWaiters[sb.ID] = resumeWaiter{Sandbox: sb, Since: time.Now()}
698711
}
699712

700713
func (s *Scheduler) removeResumeWaiter(id string) {
@@ -703,14 +716,16 @@ func (s *Scheduler) removeResumeWaiter(id string) {
703716
delete(s.resumeWaiters, id)
704717
}
705718

706-
func (s *Scheduler) listResumeWaiters() []types.Sandbox {
719+
func (s *Scheduler) listResumeWaitState() ([]types.Sandbox, map[string]time.Time) {
707720
s.resumeWaitMu.Lock()
708721
defer s.resumeWaitMu.Unlock()
709722
out := make([]types.Sandbox, 0, len(s.resumeWaiters))
710-
for _, sb := range s.resumeWaiters {
711-
out = append(out, sb)
723+
since := make(map[string]time.Time, len(s.resumeWaiters))
724+
for id, w := range s.resumeWaiters {
725+
out = append(out, w.Sandbox)
726+
since[id] = w.Since
712727
}
713-
return out
728+
return out, since
714729
}
715730

716731
// resumeRetryable reports Place/Resume errors that should keep the knocker retrying.
@@ -729,11 +744,11 @@ func resumeRetryable(err error) bool {
729744
func semanticWaitDuration() time.Duration {
730745
v := os.Getenv("SEMANTIC_WAIT_SEC")
731746
if v == "" {
732-
return 120 * time.Second
747+
return 300 * time.Second
733748
}
734749
sec, err := strconv.Atoi(v)
735750
if err != nil || sec < 0 {
736-
return 120 * time.Second
751+
return 300 * time.Second
737752
}
738753
return time.Duration(sec) * time.Second
739754
}

0 commit comments

Comments
 (0)