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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user