4 Commits

Author SHA1 Message Date
c2d00c18a7 feat(hf-plugin): __hfAgentStatus.hasOnCallCovering(agentId, from, to)
Cross-plugin accessor for "does agent have on_call slot covering this
window?" — first consumer is Dialectic.OpenclawPlugin signup pre-check
(its hf-precheck.ts has been degrading to "skipped" since Phase 3
ship pending this).

v1 honest scope: same-day windows only (scheduleCache is today-only
from /calendar/sync). Cross-day or empty-cache windows return undefined
which the caller treats as "skipped" (Dialectic backend stores
pre_validated:false as audit signal — same as before, just now we
actually validate when we can).

Logic: for each cached slot where slot_type=on_call AND status not in
{aborted,cancelled}, parse scheduled_at (HH:MM:SS or full ISO) and
estimated_duration to compute end; return true iff start<=from AND
end>=to. Returns false (not undefined) when cache has slots for the
agent on this date but none covers — that means "actually no coverage"
vs "I dont know".

Pairs with Dialectic.OpenclawPlugin/src/hf-precheck.ts which already
calls hf.hasOnCallCovering and handles all 3 return shapes.

No backend change required.
2026-05-23 14:58:37 +01:00
709f7e09ab feat(hf-plugin): expose globalThis.__hfAgentStatus.get(agentId)
Cross-plugin agent-status accessor for use by Fabric.OpenclawPlugin's
presence-sync loop (and any future plugin needing 'is agent X busy
right now'). Backed by CalendarBridgeClient.getAgentStatus() with a
30s in-memory TTL cache to avoid hammering the HF backend.

Returns one of 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline'
or undefined when the agent isn't known to HF. Cache miss + bridge
failure returns the last cached value (stale-data better than no
data for delivery-decision use cases).

Part of DIALECTIC-V2 Phase 1 (Fabric announce channel + busy-discard).
See /home/hzhang/arch/DIALECTIC-V2-DESIGN.md sections 7+8.
2026-05-23 11:31:27 +01:00
1c4cf773e5 fix(hf-plugin): wrap tool returns in MCP {content:[...]} shape
OpenClaw's Codex tool dispatcher (thread-lifecycle:255) expects every
tool execute() to return { content: [...] } and calls result.content.reduce()
to compute total text length. All 9 harborforge_* tools returned bare
objects ({ running, processing, currentSlot, ... }) which has no
.content field — so .reduce of undefined threw, and the agent saw the
cryptic 'Cannot read properties of undefined (reading reduce)' on
every call. This silently blocked every calendar slot transition on
prod for hours: agents could call harborforge_calendar_complete but
it always errored, so slots never moved out of not_started.

Fix is at the registerTool boundary: api.registerTool is wrapped once
to coerce every tool's execute return through ensureMcpContentShape.
Tools that already return the correct shape are unchanged. No per-tool
edits needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 08:48:05 +01:00
h z
ba420e858a Merge pull request 'fix: wakeup message says 'continue in same session', not 'only reply WAKEUP_OK'' (#9) from fix/wakeup-message-no-ack-only into main 2026-05-21 10:05:34 +00:00

View File

@@ -73,6 +73,30 @@ interface PluginAPI {
getAgentStatus?: () => Promise<{ status: string } | null>;
}
/**
* Coerce a tool execute() return value into the MCP `{ content: [...] }`
* shape that the openclaw Codex tool dispatcher requires.
*
* Background: openclaw's `convertToolContents()` does `result.content.reduce(...)`
* to compute total text length before flattening. Every HF tool here returned a
* bare object (`{ running, processing, currentSlot, ... }`) which has no
* `.content` field, so `undefined.reduce` threw and every call to
* `harborforge_*` from a Codex-harness agent surfaced as the cryptic
* `Cannot read properties of undefined (reading 'reduce')`. The fix is to
* wrap every tool's execute return; doing it at the `registerTool` boundary
* keeps each tool body unchanged.
*/
function ensureMcpContentShape(result: unknown): { content: Array<{ type: 'text'; text: string }> } {
if (
result && typeof result === 'object' &&
Array.isArray((result as { content?: unknown }).content)
) {
return result as { content: Array<{ type: 'text'; text: string }> };
}
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
return { content: [{ type: 'text', text }] };
}
function register(api: PluginAPI): void {
const logger = api.logger || {
info: (...args: any[]) => console.log('[HarborForge]', ...args),
@@ -81,6 +105,22 @@ function register(api: PluginAPI): void {
warn: (...args: any[]) => console.warn('[HarborForge]', ...args),
};
// Wrap api.registerTool so every tool's execute() return is coerced into
// the MCP `{ content: [...] }` shape openclaw expects. See
// `ensureMcpContentShape` above.
const _origRegisterTool = api.registerTool.bind(api);
api.registerTool = (factory: (ctx: any) => any) => {
_origRegisterTool((ctx: any) => {
const def = factory(ctx);
if (!def || typeof def.execute !== 'function') return def;
const origExecute = def.execute;
return {
...def,
execute: async (...args: any[]) => ensureMcpContentShape(await origExecute(...args)),
};
});
};
function resolveConfig() {
return getPluginConfig(api);
}
@@ -356,6 +396,94 @@ function register(api: PluginAPI): void {
}
}
// Cross-plugin exposure: agent status lookup for other plugins
// (currently Fabric.OpenclawPlugin uses this to skip delivering
// `announce` channel messages to busy agents — see DIALECTIC-V2
// design doc, Phase 1). Backed by calendarBridge.getAgentStatus
// with a small TTL cache to avoid hammering the HF backend.
type HfStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline';
const HF_STATUS_CACHE_TTL_MS = 30_000;
const hfStatusCache = new Map<string, { status: HfStatus; at: number }>();
const _G = globalThis as Record<string, unknown>;
_G['__hfAgentStatus'] = {
async get(agentId: string): Promise<HfStatus | undefined> {
if (!agentId) return undefined;
const cached = hfStatusCache.get(agentId);
if (cached && Date.now() - cached.at < HF_STATUS_CACHE_TTL_MS) {
return cached.status;
}
try {
const status = await calendarBridge.getAgentStatus(agentId);
if (status) {
const typed = status as HfStatus;
hfStatusCache.set(agentId, { status: typed, at: Date.now() });
return typed;
}
} catch {
/* fall through to cached-or-undefined */
}
return cached?.status;
},
/**
* Approximate "does agent have an on_call slot covering [from, to]?"
* for cross-plugin pre-check use (currently:
* Dialectic.OpenclawPlugin's signup HF coverage).
*
* v1 honest scope: we only have today's slots in scheduleCache
* (synced from /calendar/sync which is today-only). Returns:
* - true iff window is same-day AND some cached on_call slot
* starts <= from AND ends >= to
* - false iff window is same-day AND no such slot
* - undefined for cross-day windows OR cache empty for this
* agent (caller treats undefined as "I don't know" — see
* Dialectic plugin's hf-precheck.ts which degrades to
* "skipped" gracefully)
*
* Phase TBD: when HF backend ships a `/calendar/slots?agent&from&to`
* endpoint, swap this to call it for arbitrary windows. Until then,
* same-day-only coverage gates ~all debates created by analyze-intel
* (which schedules <2h windows) without needing a backend change.
*/
async hasOnCallCovering(
agentId: string,
fromIso: string,
toIso: string,
): Promise<boolean | undefined> {
if (!agentId || !fromIso || !toIso) return undefined;
const from = new Date(fromIso);
const to = new Date(toIso);
if (isNaN(from.getTime()) || isNaN(to.getTime())) return undefined;
if (!(from < to)) return undefined;
// Cross-day → cache only has today; can't decide.
const fromDate = from.toISOString().slice(0, 10);
const toDate = to.toISOString().slice(0, 10);
if (fromDate !== toDate) return undefined;
// Cache's cachedDate must match our window's date.
const cacheStatus = scheduleCache.getStatus();
if (cacheStatus.cachedDate !== fromDate) return undefined;
const slots = scheduleCache.getAgentSlots(agentId);
if (slots.length === 0) return undefined; // cache empty for this agent — can't decide
for (const s of slots) {
if (s.slot_type !== 'on_call') continue;
// status: ignore aborted/cancelled, accept not_started / ongoing / finished
if (s.status === 'aborted' || s.status === 'cancelled') continue;
const startStr = s.scheduled_at;
if (typeof startStr !== 'string') continue;
// scheduled_at can be HH:MM:SS (cache-relative date) or full ISO
const start =
/^\d{2}:\d{2}(:\d{2})?$/.test(startStr)
? new Date(`${fromDate}T${startStr}Z`)
: new Date(startStr);
if (isNaN(start.getTime())) continue;
const dur = typeof s.estimated_duration === 'number' ? s.estimated_duration : 0;
const end = new Date(start.getTime() + dur * 60_000);
if (start <= from && end >= to) return true;
}
return false;
},
};
// 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