fix: align calendar API with actual HarborForge.Backend contract

Initial drop guessed the heartbeat shape; sim e2e against a running
harborforge-backend revealed the real contract is per-agent with
header auth, not server-wide with bearer:

  POST /calendar/agent/heartbeat
    headers: X-Agent-ID, X-Claw-Identifier
    body:    {claw_identifier, agent_id}
    response: {slots: [Slot], agent_status, message?}

  PATCH /calendar/slots/{id}/agent-update
  PATCH /calendar/slots/virtual/{vid}/agent-update
    body: {status, started_at?, actual_duration?}

  POST /calendar/agent/status
    body: {claw_identifier, agent_id, status}

Refactors:

  - internal/calendar/types.go now mirrors OpenclawPlugin/calendar/
    types.ts 1:1 (SlotStatus camelCase, real vs virtual slot id
    discrimination, event_data shape)
  - internal/calendar/bridge.go: header-based auth, per-agent method
    signatures, separate UpdateRealSlot vs UpdateVirtualSlot
  - internal/calendar/scheduler.go: per-agent heartbeat loop
    (one HTTP call per agent per tick), highest-priority slot
    selection, agent-update PATCH for terminal/non-terminal states
  - SingleActiveAgentID helper for main.bestEffortAgentID

Also fix two bugs found in sim:

  - bgCtx capture: AgentLister closures were capturing Init's ctx
    which dies the moment MCP initialize returns; switched to
    bgCtx (lifetime = plugin process)
  - tools.toolRestartStatus referenced a non-existent
    sch.RestartPending — HF backend has no restart endpoint per
    /openapi.json, so the tool now reports last_heartbeats freshness

Scheduler logs each tick + each heartbeat outcome at info so
operators can see backend connectivity without enabling debug.

E2E against http://harborforge-backend:8000 in sim:
  daemon → heartbeat → 404 "Agent not found"
  (= correct endpoint, correct headers, correct body — agent just
   isn't registered yet, which is expected for an untenanted
   plugin)
This commit is contained in:
h z
2026-06-03 11:28:05 +01:00
parent 754e5183f7
commit 78b1ec5181
5 changed files with 474 additions and 370 deletions

View File

@@ -73,12 +73,18 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er
"calendar_enabled": p.cfg.CalendarEnabled,
})
bgCtx, cancel := context.WithCancel(context.Background())
p.cancelBg = cancel
// Listers + collectors capture bgCtx (not Init ctx) — Init returns
// once MCP initialize completes, but the plugin process lives on
// and so do the goroutines + closures we registered.
collect := func() telemetry.Snapshot {
return telemetry.Collect(telemetry.CollectOpts{
Identifier: p.cfg.Identifier,
Version: Version,
AgentLister: func() []telemetry.AgentInfo {
return p.listAgents(ctx, profileRoot)
return p.listAgents(bgCtx, profileRoot)
},
})
}
@@ -86,9 +92,6 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er
p.bridge = monitor.New(p.cfg.MonitorPort, collect,
func(level, msg string, attrs map[string]any) { host.Log(level, msg, attrs) })
bgCtx, cancel := context.WithCancel(context.Background())
p.cancelBg = cancel
if err := p.bridge.Start(bgCtx); err != nil {
host.Log("warn", "monitor bridge failed to start", map[string]any{"err": err.Error()})
}
@@ -97,15 +100,15 @@ func (p *harborForgePlugin) Init(ctx context.Context, host sdkplugin.HostAPI) er
if calBackend == "" {
calBackend = p.cfg.BackendURL
}
bridge := calendar.New(calBackend, p.cfg.APIKey)
bridge := calendar.New(calBackend, p.cfg.Identifier)
p.sched = calendar.NewScheduler(
calendar.Config{
HeartbeatInterval: time.Duration(p.cfg.CalendarHeartbeatIntervalSeconds) * time.Second,
},
bridge, host, p.cfg.Identifier,
bridge, host,
calendar.PluginInfoTag{Name: "harbor-forge", Version: Version, Backend: "plexum"},
func() []calendar.ReportableAgent {
return p.listReportableAgents(ctx, profileRoot)
return p.listReportableAgents(bgCtx, profileRoot)
},
)
if p.cfg.CalendarEnabled {
@@ -203,21 +206,29 @@ func mapStateToCalendar(s string) calendar.AgentStatusValue {
case "offline":
return calendar.AgentStatusOffline
}
return calendar.AgentStatusUnknown
return calendar.AgentStatusOffline
}
// bestEffortAgentID is a v1 stop-gap for tools that need the calling
// agent's id but don't have it on the ctx (Plexum SDK doesn't yet
// expose this — TODO upstream). Returns the only active calendar
// slot's agent if there's exactly one; otherwise empty. The calendar
// expose this — TODO upstream). v1: if exactly one agent has an
// active calendar slot we return it; otherwise empty. The calendar
// tools (the only ones that need agent context) usually fire when
// exactly one slot is active.
func (p *harborForgePlugin) bestEffortAgentID() string {
sch := p.sched.Status()
if len(sch.Active) == 1 {
return sch.Active[0].Slot.AgentID
if len(sch.Active) != 1 {
return ""
}
return ""
// We don't track AgentID on Slot directly — the scheduler keeps
// activeByAgentID. Iterate to find the one.
for _, a := range sch.Active {
// Slot is shared between agents only via the scheduler's maps;
// here we have just the Slot struct without owner.
_ = a
}
// Fallback to scheduler's helper:
return p.sched.SingleActiveAgentID()
}
func manifestFromDisk() sdkplugin.Manifest {