feat(agent): event-driven uplink — sync on every state transition

The agent reported its state only on the adaptive sync tick (3s watching /
10s idle), so a resolving→downloading→verifying→organizing→completed
transition could lag up to a full interval before the server (and the web
UI) saw it. Now every successful Task.Transition fires an onChange hook
wired to TriggerSync, pushing the new state immediately. Bursts are safe:
TriggerSync is a buffered-1 send, so clustered transitions coalesce into
one sync.

- Task gains an onChange hook fired AFTER the status mutex is released
  (so a future heavier hook can't deadlock on task.mu); nil is a no-op.
- Manager.OnStateChange is set on each task at Submit; the daemon wires it
  to TriggerSync alongside the existing OnTaskDone.
- Stream tasks transition outside the Manager, so handleStreamTask wires
  the same hook explicitly (gap found in review) — resolving/downloading/
  completed/failed on the stream path now push too.

The adaptive ticker stays as a reconciliation heartbeat; it's just no
longer the latency floor for state changes.
This commit is contained in:
Deivid Soto 2026-06-01 19:09:44 +02:00
parent 1052529ca2
commit 864b6ea832
5 changed files with 83 additions and 4 deletions

View file

@ -78,6 +78,12 @@ type Task struct {
ClaimedAt time.Time
StartedAt time.Time
CompletedAt time.Time
// onChange, when set, is called after every successful status Transition so
// the daemon can push the new state to the server immediately (event-driven
// uplink) instead of waiting for the next sync tick. Must be non-blocking —
// it's a coalescing TriggerSync. Set by the Manager at submit time.
onChange func()
}
// NewTaskFromAgent creates a Task from a server-claimed agent.Task.
@ -111,13 +117,15 @@ func NewTaskFromAgent(at agent.Task) *Task {
}
}
// Transition validates and performs a state transition.
// Transition validates and performs a state transition. On success it invokes
// the onChange hook (outside the lock) so the daemon can push the new state to
// the server immediately rather than waiting for the next sync tick.
func (t *Task) Transition(to TaskStatus) error {
t.mu.Lock()
defer t.mu.Unlock()
allowed, ok := validTransitions[t.Status]
if !ok {
t.mu.Unlock()
return fmt.Errorf("no transitions from %s", t.Status)
}
for _, a := range allowed {
@ -129,12 +137,28 @@ func (t *Task) Transition(to TaskStatus) error {
if to == StatusCompleted || to == StatusFailed {
t.CompletedAt = time.Now()
}
cb := t.onChange
t.mu.Unlock()
// Fire the event-driven uplink AFTER releasing the lock so a future
// heavier hook can't deadlock on the task mutex.
if cb != nil {
cb()
}
return nil
}
}
t.mu.Unlock()
return fmt.Errorf("invalid transition: %s -> %s", t.Status, to)
}
// SetOnChange wires the post-transition hook. Call before the task starts
// transitioning (the Manager sets it at submit time).
func (t *Task) SetOnChange(fn func()) {
t.mu.Lock()
t.onChange = fn
t.mu.Unlock()
}
// GetStatus returns current status thread-safely.
func (t *Task) GetStatus() TaskStatus {
t.mu.RLock()