fix: wake dedupe + inline slot context + complete contracts.tools
Three issues making HF→agent wakeup unusable in practice, surfaced by
DinD sim end-to-end test (recruiter agent + slot for 招募 manager task):
1. **Plugin re-woke the same slot every 30s.** The inline runCheck only
destructured agentId from scheduleCache.getAgentsWithDueSlots() and
dropped the slots array, then called wakeAgent without recording the
wake. The simplified inline scheduler also never PATCHes slot status
server-side from not_started→ongoing, so the next 30s check sees the
slot still due and wakes again. After 4 wakes the agent's wakeup
session was full of WAKEUP_OK noise.
Fix: keep slots in runCheck, add an in-memory wakedSlotKeys set
keyed by (agentId, slotId|virtual_id|scheduled_at). Dedupe on this
set; clear it inside the sync interval (fresh wake budget per sync).
Server-side slot transition still TODO (requires re-introducing the
CalendarScheduler class path or PATCH /calendar/slots/.../agent-update
here); the dedupe at least stops the wake spam.
2. **Wakeup message had no slot context.** The wakeup body just said
'follow hf-wakeup workflow' with no slot id/event_data/task_code.
The agent then had to call harborforge_calendar_status to learn
anything — which itself is broken in the simplified scheduler (it
queries a CalendarScheduler instance that never gets created).
Fix: pass dueSlots into wakeAgent and inline the highest-priority
slot's {slot_id, scheduled_at, priority, slot_type, event_data} as
a JSON block in the wakeup message. The agent reads event_data.
task_code directly and routes via workflow_lookup without any
round-trip. Per PLG-CAL-001 docs in hf-hangman-lab SKILL.md, this
is the documented contract; we are bringing the message in line.
3. **contracts.tools listed 5 of the 9 registered tools.** Manifest had
harborforge_status/telemetry/monitor_telemetry/calendar_status/
calendar_complete. Code also registers calendar_abort, calendar_pause,
calendar_resume, harborforge_restart_status. With the new OpenClaw
plugin host enforcement (same gotcha that bit Meridian — see
zhi/Meridian#2), undeclared tools are silently dropped from the
agent's tool list, so abort/pause/resume cannot be called by the
agent. plugin doctor was emitting:
'plugin tool is undeclared (harbor-forge): harborforge_calendar_abort'
for each missing tool.
Fix: add the 4 missing tool names to contracts.tools.
Also use api.config as the primary config source in wakeAgent (current
public API), falling back to runtime.config.loadConfig() for older
hosts — same pattern as the Meridian fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -197,7 +197,18 @@ function register(api: PluginAPI): void {
|
||||
* Wake agent via dispatchInboundMessage — same mechanism used by Discord plugin.
|
||||
* Direct in-process call, no WebSocket or CLI needed.
|
||||
*/
|
||||
async function wakeAgent(agentId: string): Promise<boolean> {
|
||||
async function wakeAgent(
|
||||
agentId: string,
|
||||
dueSlots?: Array<{
|
||||
id?: number | null;
|
||||
virtual_id?: string | null;
|
||||
event_data?: any;
|
||||
scheduled_at?: string;
|
||||
priority?: number;
|
||||
slot_type?: string;
|
||||
[k: string]: unknown;
|
||||
}>
|
||||
): Promise<boolean> {
|
||||
logger.info(`Waking agent ${agentId}: has due slots`);
|
||||
|
||||
const sessionKey = `agent:${agentId}:hf-wakeup`;
|
||||
@@ -208,13 +219,39 @@ function register(api: PluginAPI): void {
|
||||
/* webpackIgnore: true */ sdkPath
|
||||
);
|
||||
|
||||
const cfg = api.runtime?.config?.loadConfig?.();
|
||||
// api.config first (current public API). Fall back to deprecated
|
||||
// runtime.config.loadConfig() for older host versions. Both should
|
||||
// contain agents.list / channels for dispatch routing.
|
||||
const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
|
||||
if (!cfg) {
|
||||
logger.error('Cannot load OpenClaw config for dispatch');
|
||||
return false;
|
||||
}
|
||||
|
||||
const wakeupMessage = `You have due slots. Follow the \`hf-wakeup\` workflow of skill \`hf-hangman-lab\` to proceed. Only reply \`WAKEUP_OK\` in this session.`;
|
||||
// Inline the highest-priority due slot's context so the agent does
|
||||
// not need a second round-trip to harborforge_calendar_status. The
|
||||
// agent can read event_data.task_code / task_title etc. directly.
|
||||
let slotBlock = '';
|
||||
const top = dueSlots && dueSlots.length ? dueSlots[0] : undefined;
|
||||
if (top) {
|
||||
slotBlock = `\n\nMatching slot:\n\`\`\`json\n${JSON.stringify(
|
||||
{
|
||||
slot_id: top.id ?? null,
|
||||
virtual_id: top.virtual_id ?? null,
|
||||
scheduled_at: top.scheduled_at ?? null,
|
||||
priority: top.priority ?? null,
|
||||
slot_type: top.slot_type ?? null,
|
||||
event_data: top.event_data ?? null,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n\`\`\``;
|
||||
}
|
||||
|
||||
const wakeupMessage =
|
||||
`You have due slots. Follow the \`hf-wakeup\` workflow of skill ` +
|
||||
`\`hf-hangman-lab\` to proceed. Only reply \`WAKEUP_OK\` in this ` +
|
||||
`session.${slotBlock}`;
|
||||
|
||||
const result = await dispatchInboundMessageWithDispatcher({
|
||||
ctx: {
|
||||
@@ -308,28 +345,59 @@ function register(api: PluginAPI): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Track wakes already dispatched for a slot in the current sync
|
||||
// window — the simplified inline scheduler does not PATCH slot
|
||||
// status server-side, so without dedupe the check loop re-wakes
|
||||
// the same slot every 30s. Set is cleared by runSync (fresh wake
|
||||
// budget per sync).
|
||||
const wakedSlotKeys = new Set<string>();
|
||||
|
||||
// Check: find agents with due slots and wake them
|
||||
async function runCheck() {
|
||||
const now = new Date();
|
||||
const agentsWithDue = scheduleCache.getAgentsWithDueSlots(now);
|
||||
|
||||
for (const { agentId } of agentsWithDue) {
|
||||
// Check if agent is busy
|
||||
const status = await calendarBridge.getAgentStatus(agentId);
|
||||
for (const { agentId, slots } of agentsWithDue) {
|
||||
// Filter out slots we've already woken this sync window
|
||||
const fresh = slots.filter((s) => {
|
||||
const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`;
|
||||
if (wakedSlotKeys.has(key)) return false;
|
||||
return true;
|
||||
});
|
||||
if (fresh.length === 0) continue;
|
||||
|
||||
// Check if agent is busy (best effort; backend may 405 the GET
|
||||
// — treat unknown as not-busy so wakeup still fires)
|
||||
let status: string | null = null;
|
||||
try {
|
||||
status = await calendarBridge.getAgentStatus(agentId);
|
||||
} catch {
|
||||
status = null;
|
||||
}
|
||||
if (status === 'busy' || status === 'offline' || status === 'exhausted') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Wake the agent
|
||||
await wakeAgent(agentId);
|
||||
// Wake the agent with the slot context inlined
|
||||
const ok = await wakeAgent(agentId, fresh);
|
||||
if (ok) {
|
||||
for (const s of fresh) {
|
||||
const key = `${agentId}::${s.id ?? s.virtual_id ?? s.scheduled_at}`;
|
||||
wakedSlotKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
runSync();
|
||||
// Initial sync (also resets the wake-dedupe window)
|
||||
const runSyncReset = async () => {
|
||||
wakedSlotKeys.clear();
|
||||
await runSync();
|
||||
};
|
||||
runSyncReset();
|
||||
|
||||
// Start intervals
|
||||
const syncHandle = setInterval(runSync, SYNC_INTERVAL_MS);
|
||||
const syncHandle = setInterval(runSyncReset, SYNC_INTERVAL_MS);
|
||||
const checkHandle = setInterval(runCheck, CHECK_INTERVAL_MS);
|
||||
|
||||
// Store handles for cleanup (reuse calendarScheduler variable)
|
||||
|
||||
Reference in New Issue
Block a user