3 Commits

Author SHA1 Message Date
73e37df129 fix: add try-catch on agents.invoke and http.fetch in scheduled jobs
- stale-case-reminder: wrap http.fetch in try-catch so network errors
  log and return instead of crashing the job silently
- weekly-feedback-analysis: wrap agents.invoke in try-catch so CEO
  unavailability logs and returns instead of crashing the job

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 11:07:54 +00:00
6a93c606b9 fix: pick first mapped CEO for weekly-feedback-analysis; align reason string
- Replace companies[0] with first company that has a CEO_AGENT_IDS entry,
  so the job doesn't silently skip if the list order is non-deterministic
- Change reason to "weekly-feedback-job" to match the CEO routing condition
  added in legal-ceo.md (was "weekly-feedback-analysis scheduled job")
- decision-lessons.md is shared between companies so one CEO invocation suffices

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:54:39 +00:00
73078946f1 feat: add idempotency guard to onWebhook
Prevent duplicate comments on rapid Paperclip retries:
- Check plugin instance state for requestId before processing
- Skip if same requestId was seen within 5 minutes
- Store requestId with ISO timestamp after first successful delivery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 10:52:12 +00:00

View File

@@ -713,7 +713,15 @@ const plugin = definePlugin({
const apiBase = const apiBase =
(config.legalApiBaseUrl as string) ?? "http://localhost:8085"; (config.legalApiBaseUrl as string) ?? "http://localhost:8085";
const resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`); let resp: Awaited<ReturnType<typeof ctx.http.fetch>>;
try {
resp = await ctx.http.fetch(`${apiBase}/api/cases/stale?days=3`);
} catch (err) {
ctx.logger.error("stale-case-reminder: fetch failed", {
error: String(err),
});
return;
}
if (!resp.ok) { if (!resp.ok) {
ctx.logger.error(`stale-case-reminder: API error ${resp.status}`); ctx.logger.error(`stale-case-reminder: API error ${resp.status}`);
return; return;
@@ -799,26 +807,39 @@ const plugin = definePlugin({
return; return;
} }
// Pick the first company with a known CEO mapping — decision-lessons.md is
// shared between companies, so a single CEO invocation is sufficient.
const companies = await ctx.companies.list(); const companies = await ctx.companies.list();
const company = companies[0]; const mapped = companies
if (!company) return; .map((c) => ({ company: c, ceoId: CEO_AGENT_IDS[c.id] }))
.filter((x): x is { company: (typeof companies)[0]; ceoId: string } =>
Boolean(x.ceoId),
);
const ceoId = CEO_AGENT_IDS[company.id]; if (mapped.length === 0) {
if (!ceoId) {
ctx.logger.warn( ctx.logger.warn(
`weekly-feedback-analysis: no CEO agent for company ${company.id}`, "weekly-feedback-analysis: no company has a mapped CEO agent — skipping",
); );
return; return;
} }
await ctx.agents.invoke(ceoId, company.id, { const { company, ceoId } = mapped[0];
prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
reason: "weekly-feedback-analysis scheduled job",
});
ctx.logger.info( try {
`weekly-feedback-analysis: invoked CEO ${ceoId} with ${data.entry_count} feedback entries`, await ctx.agents.invoke(ceoId, company.id, {
); prompt: `ניתוח פידבק שבועי יו"ר (${data.entry_count} פריטים):\n\n${data.summary}\n\nהמשימה: עדכן את /home/chaim/legal-ai/docs/legal-decision-lessons.md עם הלקחים החדשים שעולים מהפידבק. הוסף רק לקחים חדשים שלא קיימים כבר. קבץ לפי נושא.`,
reason: "weekly-feedback-job",
});
ctx.logger.info(
`weekly-feedback-analysis: invoked CEO ${ceoId} (company ${company.id}) with ${data.entry_count} feedback entries`,
);
} catch (err) {
ctx.logger.error("weekly-feedback-analysis: failed to invoke CEO", {
error: String(err),
ceoId,
companyId: company.id,
});
}
}); });
ctx.logger.info("Legal AI plugin ready"); ctx.logger.info("Legal AI plugin ready");
@@ -831,8 +852,27 @@ const plugin = definePlugin({
async onWebhook(input: PluginWebhookInput): Promise<void> { async onWebhook(input: PluginWebhookInput): Promise<void> {
if (!pluginCtx) return; // not yet initialized if (!pluginCtx) return; // not yet initialized
// TODO: add idempotency guard using input.requestId to prevent duplicate // Idempotency guard: skip duplicate deliveries within 5 minutes
// comments on rapid retries (store requestId in plugin state with ~60s TTL) if (input.requestId) {
const idempKey = `webhook-idem-${input.requestId}`;
const seenAt = await pluginCtx.state.get({
scopeKind: "instance",
stateKey: idempKey,
});
if (seenAt && typeof seenAt === "string") {
const ageMs = Date.now() - new Date(seenAt).getTime();
if (ageMs < 5 * 60 * 1000) {
pluginCtx.logger.info(
`onWebhook: skipping duplicate requestId ${input.requestId} (age ${Math.round(ageMs / 1000)}s)`,
);
return;
}
}
await pluginCtx.state.set(
{ scopeKind: "instance", stateKey: idempKey },
new Date().toISOString(),
);
}
const { endpointKey, parsedBody } = input; const { endpointKey, parsedBody } = input;