P2-02 (server side): schedule reconciliation push + ack handling

Server is now the source of truth for the agent's cron set.

* Helpers in schedule_push.go:
  - loadScheduleSetPayload reads the host's schedules + canonical
    version into the wire shape.
  - pushScheduleSetOnConn writes directly to a just-handshaken conn
    (avoids racing against Hub.Register on a brand-new connection).
  - pushScheduleSetAsync is the post-CRUD flavour — no-op when the
    host is offline (the next reconnect's on-hello path catches it
    up, so a missed push is non-fatal).
  - applyScheduleAck records what version the agent has confirmed.

* onAgentHello restructured: was returning early when the host had
  no repo credentials, which made the schedule push unreachable for
  fresh hosts. Split into pushRepoCredsOnHello (silent no-op on
  ErrNotFound) + pushScheduleSetOnConn (always runs). Empty schedule
  list is a valid push: tells the agent to drop stale cron entries.

* WS dispatcher gains an OnScheduleAck hook on HandlerDeps; the
  http server wires it to applyScheduleAck. MsgScheduleAck moves
  out of the "TODO(P2)" group into a real case that decodes the
  payload and forwards to the callback.

* Schedule CRUD handlers each fire pushScheduleSetAsync after the
  audit-log write so the agent picks up changes within seconds.

Tests cover:
  - On-hello push of an already-created schedule, agent acks,
    applied_schedule_version flips on the host row.
  - Connect-then-CRUD: empty initial push (version 0), then a
    follow-on push at version 1 after the operator creates a
    schedule via REST.

Agent-side `schedule.set` handler (parse, replace local cron,
emit `schedule.ack`) is the remainder of P2-02 and lands with
P2-03's local scheduler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 11:22:06 +01:00
parent aa9fc330fc
commit a086b0eb75
7 changed files with 400 additions and 6 deletions
+14 -1
View File
@@ -27,6 +27,9 @@ type HandlerDeps struct {
// layer to push host_credentials down as a config.update before
// the agent starts asking for jobs. Optional; nil = no-op.
OnHello func(ctx context.Context, hostID string, conn *Conn)
// OnScheduleAck is called when an agent confirms it has applied
// a particular schedule version (P2-02 reconciliation). Optional.
OnScheduleAck func(ctx context.Context, hostID string, version int64, appliedAt time.Time)
}
// AgentHandler is the http.Handler that owns /ws/agent. Agents
@@ -255,7 +258,17 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
}
}
case api.MsgRepoStats, api.MsgScheduleAck, api.MsgCommandResult:
case api.MsgScheduleAck:
var p api.ScheduleAckPayload
if err := env.UnmarshalPayload(&p); err != nil {
slog.Warn("ws: bad schedule.ack payload", "host_id", hostID, "err", err)
break
}
if deps.OnScheduleAck != nil {
deps.OnScheduleAck(ctx, hostID, p.Version, p.AppliedAt)
}
case api.MsgRepoStats, api.MsgCommandResult:
// TODO(P2): persist these projections.
slog.Debug("ws msg not yet handled", "type", env.Type, "host_id", hostID)