feat: multi-session webhook claim system with EADDRINUSE handling

- Server gracefully handles EADDRINUSE (logs notice, continues without server)
- POST /claim endpoint with referral-based async handoff:
  202 (pending) -> poll with referral_id -> 200/401/408
- GET /claim shows current owner and pending queue
- DOS protection: 503 when MAX_PENDING_CLAIMS (10) reached
- Claims expire after 5 minutes
- Pi commands: /webhook:status, /webhook:claim, /webhook:release
This commit is contained in:
2026-03-14 13:47:40 -07:00
parent aa39af1c66
commit ba88fa50f9
2 changed files with 413 additions and 15 deletions

View File

@@ -3,6 +3,7 @@
*
* Registers Gitea tools (read + write) and optional webhook server.
* Supports @mention routing for multi-bot coordination.
* Supports multi-session claim system for webhook ownership.
*/
import registerReadTools from "./tools/read-tools.js";
@@ -11,6 +12,8 @@ import {
startWebhookServer, stopWebhookServer,
startPolling, stopPolling,
setSendMessage, getTrackedRepos, setRepoConfig,
setOnClaimRequested, approvePendingClaim, getClaimStatus,
SESSION_ID, WEBHOOK_PORT, WEBHOOK_HOST,
} from "./webhook/server.js";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
@@ -42,7 +45,7 @@ export default function (pi: ExtensionAPI) {
}
const config = setRepoConfig(params.repo, { respondTo: mode });
return {
content: [{ type: "text", text: `${params.repo}: respondTo = ${config.respondTo}` }],
content: [{ type: "text", text: `${params.repo}: respondTo = ${config.respondTo}` }],
details: { repo: params.repo, config },
};
},
@@ -59,11 +62,11 @@ export default function (pi: ExtensionAPI) {
for (const [name] of webhook) {
const mode = configs.get(name)?.respondTo ?? "all";
lines.push(`${name} — webhook (respondTo: ${mode})`);
lines.push(`${name} — webhook (respondTo: ${mode})`);
}
for (const name of collab) {
const mode = configs.get(name)?.respondTo ?? "mention";
lines.push(`📋 ${name} — collab/notification (respondTo: ${mode})`);
lines.push(`${name} — collab/notification (respondTo: ${mode})`);
}
return {
@@ -73,6 +76,157 @@ export default function (pi: ExtensionAPI) {
},
});
// ── Webhook claim commands ───────────────────────────────────────────────
pi.registerCommand("webhook:status", {
description: "Show webhook server status and claim ownership",
handler: async (_args, _ctx) => {
const status = getClaimStatus();
if (status.isOwner && status.pending.length === 0) {
console.log(`Webhook: you own the server (session: ${status.sessionId})`);
console.log(` port: ${WEBHOOK_PORT}, cwd: ${status.ownerCwd}`);
} else if (status.isOwner) {
console.log(`Webhook: you own the server (session: ${status.sessionId})`);
console.log(` port: ${WEBHOOK_PORT}, cwd: ${status.ownerCwd}`);
console.log(` ${status.pending.length} pending claim(s):`);
for (const c of status.pending) {
const age = Math.round((Date.now() - c.createdAt) / 1000);
console.log(` ${c.referralId} from ${c.sessionId} (${c.cwd}) — ${age}s ago`);
}
console.log(` Use /webhook:release to hand off to next in queue`);
} else {
// Not the server owner — query the server
try {
const res = await fetch(`http://127.0.0.1:${WEBHOOK_PORT}/claim`);
if (res.ok) {
const data = await res.json() as Record<string, unknown>;
console.log(`Webhook: owned by another session`);
console.log(` owner: ${data.owner} (cwd: ${data.owner_cwd})`);
console.log(` server session: ${data.server_session}`);
const pending = data.pending as Array<Record<string, unknown>>;
if (pending?.length) {
console.log(` ${pending.length} pending claim(s)`);
}
console.log(` Use /webhook:claim to request ownership`);
} else {
console.log(`Webhook: no server running on port ${WEBHOOK_PORT}`);
}
} catch {
console.log(`Webhook: no server reachable on port ${WEBHOOK_PORT}`);
}
}
},
});
pi.registerCommand("webhook:claim", {
description: "Request webhook ownership from the current server owner",
handler: async (_args, ctx) => {
const status = getClaimStatus();
if (status.isOwner) {
console.log("You already own the webhook server.");
return;
}
// POST to the running server's /claim endpoint
const url = `http://127.0.0.1:${WEBHOOK_PORT}/claim`;
let referralId: string | undefined;
try {
// Initial request
const initRes = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: SESSION_ID, cwd: process.cwd() }),
});
if (initRes.status === 503) {
console.log("Server has too many pending claims. Try again later.");
return;
}
const initData = await initRes.json() as Record<string, unknown>;
if (initRes.status === 200) {
console.log("Claimed immediately (no contention).");
return;
}
if (initRes.status !== 202) {
console.log(`Unexpected response: ${initRes.status} ${JSON.stringify(initData)}`);
return;
}
referralId = initData.referral_id as string;
console.log(`Claim submitted (referral: ${referralId})`);
console.log(` Current owner: ${initData.owner} (${initData.owner_cwd})`);
console.log(` Queue position: ${initData.position}`);
console.log(` Waiting for owner to /webhook:release ...`);
// Poll every 3s for up to 5 minutes
const deadline = Date.now() + 5 * 60 * 1000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
const pollRes = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: SESSION_ID, referral_id: referralId }),
});
if (pollRes.status === 200) {
console.log("Claim approved — you now own the webhook server.");
return;
}
if (pollRes.status === 401) {
console.log("Claim denied by the current owner.");
return;
}
if (pollRes.status === 408) {
console.log("Claim expired (timed out).");
return;
}
// 202 = still pending, continue polling
}
console.log("Claim timed out (client-side deadline).");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("ECONNREFUSED")) {
console.log(`No webhook server running on port ${WEBHOOK_PORT}.`);
} else {
console.log(`Claim failed: ${msg}`);
}
}
},
});
pi.registerCommand("webhook:release", {
description: "Release webhook ownership to the next pending claimer",
handler: async (_args, _ctx) => {
const status = getClaimStatus();
if (!status.isOwner) {
console.log("You don't own the webhook server — nothing to release.");
return;
}
if (status.pending.length === 0) {
console.log("No pending claims to hand off to.");
return;
}
const { approved, newOwner } = approvePendingClaim();
if (approved) {
console.log(`Ownership transferred to ${approved.sessionId} (${approved.cwd})`);
console.log(`New owner session: ${newOwner}`);
} else {
console.log("No pending claims (they may have expired).");
}
},
});
// ── Lifecycle ────────────────────────────────────────────────────────────
// GITEA_ENABLE_POLLING=1 opts in to running the webhook server + notification poller.
@@ -82,7 +236,13 @@ export default function (pi: ExtensionAPI) {
(!process.env.GITEA_HOOKS_URL && !process.env.OPENCLAW_HOOKS_URL);
pi.on("session_start", async (_event, ctx) => {
console.log("[pi-gitea] Session started");
console.log(`[pi-gitea] Session started (session: ${SESSION_ID})`);
// Set up claim notification — alert the user when another session wants ownership
setOnClaimRequested((claim) => {
console.log(`[pi-gitea] Claim request from ${claim.sessionId} (${claim.cwd})`);
console.log(`[pi-gitea] Use /webhook:release to hand off, or ignore to let it expire`);
});
// Auto-detect runtime: pi-bot (persistent session) vs openclaw (hooks endpoint)
if (ctx.sendUserMessage) {
@@ -115,12 +275,8 @@ export default function (pi: ExtensionAPI) {
}
if (enablePolling) {
try {
await startWebhookServer(pi);
await startPolling(pi);
} catch (err) {
console.error("[pi-gitea] Failed to start webhook/polling:", err);
}
await startWebhookServer(pi);
await startPolling(pi);
} else {
console.log("[pi-gitea] Webhook server + polling disabled (openclaw mode — tools only)");
}