mixdog 0.9.24 → 0.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench-tmp.mjs +1 -1
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +166 -58
- package/src/tui/engine/agent-job-feed.mjs +42 -3
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/vendor/ink/build/display-width.js +5 -4
- package/src/runtime/shared/schedules-store.mjs +0 -82
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import * as http from "http";
|
|
2
|
-
import { join } from "path";
|
|
3
2
|
import { spawn, spawnSync } from "child_process";
|
|
4
|
-
import { existsSync, readFileSync } from "fs";
|
|
5
3
|
import { randomUUID } from "crypto";
|
|
6
4
|
import { getWebhookAuthtoken } from "../../shared/config.mjs";
|
|
7
|
-
import { readMarkdownDocument } from "../../shared/markdown-frontmatter.mjs";
|
|
8
5
|
import { logWebhook } from "./webhook/log.mjs";
|
|
9
6
|
import { SIGNATURE_HEADERS, extractSignature, STRIPE_TOLERANCE_MS, verifySignature } from "./webhook/signature.mjs";
|
|
7
|
+
import { detachedSpawnOpts } from "../../shared/spawn-flags.mjs";
|
|
10
8
|
import {
|
|
11
|
-
WEBHOOKS_DIR,
|
|
12
|
-
_readEndpointSecret,
|
|
13
|
-
_closeEndpointWatcher,
|
|
14
9
|
loadEndpointConfig,
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
readEndpointSecret,
|
|
11
|
+
claimDelivery,
|
|
12
|
+
updateDeliveryStatus,
|
|
13
|
+
} from "../../shared/webhooks-db.mjs";
|
|
14
|
+
import {
|
|
17
15
|
extractDeliveryId,
|
|
18
16
|
buildHeadersSummary,
|
|
19
17
|
} from "./webhook/deliveries.mjs";
|
|
@@ -72,12 +70,12 @@ class WebhookServer {
|
|
|
72
70
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
73
71
|
res.end("Not Found");
|
|
74
72
|
}
|
|
75
|
-
_handleWebhookPost(req, res) {
|
|
73
|
+
async _handleWebhookPost(req, res) {
|
|
76
74
|
const rawName = req.url.slice("/webhook/".length).split("?")[0];
|
|
77
75
|
// Strict name sanitize. Invariant: endpoint names are [a-zA-Z0-9_-]
|
|
78
76
|
// up to 64 chars. Anything else (path traversal "..", NUL,
|
|
79
77
|
// encoded slashes, empty) is rejected before any body read or
|
|
80
|
-
//
|
|
78
|
+
// table lookup so probes / scans cannot reach later stages.
|
|
81
79
|
let name = "";
|
|
82
80
|
try { name = decodeURIComponent(rawName); } catch { name = rawName; }
|
|
83
81
|
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
|
|
@@ -90,7 +88,19 @@ class WebhookServer {
|
|
|
90
88
|
// Registration pre-gate. Reject unknown endpoint names before
|
|
91
89
|
// streaming up to MAX_BODY_BYTES of payload. Body-dependent checks
|
|
92
90
|
// (signature verify, JSON parse, dedup) remain inside req.on("end").
|
|
93
|
-
|
|
91
|
+
// Endpoint defs now come from the PG webhooks.endpoints table. The
|
|
92
|
+
// request stream stays paused (no 'data' listener attached yet) across
|
|
93
|
+
// the await, so no body bytes are lost.
|
|
94
|
+
let _endpointPreCheck = null;
|
|
95
|
+
try {
|
|
96
|
+
_endpointPreCheck = (await loadEndpointConfig(name)) || this.config.endpoints?.[name] || null;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logWebhook(`${name}: endpoint lookup failed \u2014 ${err?.message || err}`);
|
|
99
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
100
|
+
res.end(JSON.stringify({ error: "internal error" }));
|
|
101
|
+
try { req.destroy(); } catch {}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
94
104
|
if (_endpointPreCheck?.enabled === false) {
|
|
95
105
|
logWebhook(`rejected: disabled endpoint ${name}`);
|
|
96
106
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
@@ -98,11 +108,7 @@ class WebhookServer {
|
|
|
98
108
|
try { req.destroy(); } catch {}
|
|
99
109
|
return;
|
|
100
110
|
}
|
|
101
|
-
|
|
102
|
-
_endpointPreCheck
|
|
103
|
-
|| existsSync(join(WEBHOOKS_DIR, name, "WEBHOOK.md"))
|
|
104
|
-
);
|
|
105
|
-
if (!_registeredPre) {
|
|
111
|
+
if (!_endpointPreCheck) {
|
|
106
112
|
logWebhook(`rejected: unknown endpoint ${name}`);
|
|
107
113
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
108
114
|
res.end(JSON.stringify({ error: "unknown endpoint" }));
|
|
@@ -145,67 +151,60 @@ class WebhookServer {
|
|
|
145
151
|
this._processWebhookBody(req, res, name, rawBody);
|
|
146
152
|
});
|
|
147
153
|
}
|
|
148
|
-
_processWebhookBody(req, res, name, rawBody) {
|
|
149
|
-
// Hoisted so the
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
// the catch with `typeof deliveryId === "undefined"`, so the recovery
|
|
153
|
-
// path would skip appendDelivery and leak the `received` claim row
|
|
154
|
-
// forever (dedup loop on every retry).
|
|
154
|
+
async _processWebhookBody(req, res, name, rawBody) {
|
|
155
|
+
// Hoisted so the catch at the bottom can mark the claimed delivery row
|
|
156
|
+
// `failed` using the id assigned before parsing; declaring it inside the
|
|
157
|
+
// try would leave the catch with `typeof deliveryId === "undefined"`.
|
|
155
158
|
let deliveryId;
|
|
156
159
|
try {
|
|
157
160
|
const headers = {};
|
|
158
161
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
159
162
|
if (typeof v === "string") headers[k.toLowerCase()] = v;
|
|
160
163
|
}
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
const
|
|
164
|
+
// Endpoint def from the PG webhooks.endpoints table; a global
|
|
165
|
+
// config.endpoints entry is the fallback for parser-only endpoints.
|
|
166
|
+
const dbEndpoint = await loadEndpointConfig(name);
|
|
167
|
+
const endpoint = dbEndpoint || this.config.endpoints?.[name] || null;
|
|
164
168
|
if (endpoint?.enabled === false) {
|
|
165
169
|
logWebhook(`rejected: disabled endpoint ${name}`);
|
|
166
170
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
167
171
|
res.end(JSON.stringify({ error: "disabled endpoint" }));
|
|
168
172
|
return;
|
|
169
173
|
}
|
|
170
|
-
// Endpoint registration gate.
|
|
171
|
-
//
|
|
172
|
-
// otherwise create WEBHOOKS_DIR/<name>/ for arbitrary probes
|
|
173
|
-
// (e.g. hostile scans, mistyped paths). Invariant: an endpoint
|
|
174
|
-
// is registered iff per-endpoint config exists OR an
|
|
175
|
-
// instructions.md folder handler is present. eventPipeline
|
|
174
|
+
// Endpoint registration gate. An endpoint is registered iff a table
|
|
175
|
+
// row exists OR a global config.endpoints entry is present. eventPipeline
|
|
176
176
|
// routing is reachable only through a registered endpoint.
|
|
177
|
-
|
|
178
|
-
endpoint
|
|
179
|
-
|| existsSync(join(WEBHOOKS_DIR, name, "WEBHOOK.md"))
|
|
180
|
-
);
|
|
181
|
-
if (!_registered) {
|
|
177
|
+
if (!endpoint) {
|
|
182
178
|
logWebhook(`rejected: unknown endpoint ${name}`);
|
|
183
179
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
184
180
|
res.end(JSON.stringify({ error: "unknown endpoint" }));
|
|
185
181
|
return;
|
|
186
182
|
}
|
|
187
|
-
|
|
183
|
+
// Raw secret is fetched via the single explicit secret-read path;
|
|
184
|
+
// loadEndpointConfig only exposes a `secretSet` flag, never the value.
|
|
185
|
+
const secret = (await readEndpointSecret(name)) || this.config.secret;
|
|
186
|
+
if (!this._verifySignatureGate(name, endpoint, !!dbEndpoint, secret, rawBody, headers, res)) return;
|
|
188
187
|
// Signature has accepted the raw bytes; decode to a UTF-8 string for
|
|
189
188
|
// content-type / JSON / preview handling below.
|
|
190
189
|
const body = rawBody.length === 0 ? "" : rawBody.toString("utf8");
|
|
191
|
-
// Delivery ID + dedup. If a prior delivery with status=done
|
|
192
|
-
// exists for this ID, skip with 200 {status:"dedup"} so the
|
|
193
|
-
// sender (GitHub etc.) stops retrying the same event.
|
|
194
190
|
deliveryId = extractDeliveryId(headers) || `gen-${randomUUID()}`;
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// double-
|
|
199
|
-
|
|
191
|
+
// Atomic claim + dedup in one step: INSERT ... ON CONFLICT DO NOTHING.
|
|
192
|
+
// A concurrent duplicate POST of the same id loses the race
|
|
193
|
+
// (claimed:false) and is rejected flat, so the first run is never
|
|
194
|
+
// double-dispatched. All summary fields are captured on this single
|
|
195
|
+
// INSERT; later transitions are status-only updates.
|
|
196
|
+
const claim = await claimDelivery(name, deliveryId, {
|
|
197
|
+
status: "received",
|
|
198
|
+
event: headers["x-github-event"] || null,
|
|
199
|
+
headersSummary: buildHeadersSummary(headers),
|
|
200
|
+
payloadPreview: String(body || "").slice(0, 512),
|
|
201
|
+
});
|
|
202
|
+
if (!claim.claimed) {
|
|
200
203
|
logWebhook(`${name}: dedup ${deliveryId}`);
|
|
201
204
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
202
205
|
res.end(JSON.stringify({ status: "dedup", id: deliveryId }));
|
|
203
206
|
return;
|
|
204
207
|
}
|
|
205
|
-
// Atomic claim: write a `received` row before any further work so
|
|
206
|
-
// a concurrent duplicate POST that arrives after this point hits
|
|
207
|
-
// deliveryExists() above and is rejected.
|
|
208
|
-
appendDelivery(name, { id: deliveryId, endpoint: name, status: "received" });
|
|
209
208
|
// JSON content-type gate. Webhook handlers below assume parsed is
|
|
210
209
|
// a plain object; an x-www-form-urlencoded body would parse to a
|
|
211
210
|
// string and let downstream `parsed?.action` lookups silently miss
|
|
@@ -214,12 +213,9 @@ class WebhookServer {
|
|
|
214
213
|
const looksJson = ctype.includes("application/json") || ctype.includes("+json");
|
|
215
214
|
if (body && !looksJson) {
|
|
216
215
|
logWebhook(`${name}: rejected — non-JSON content-type "${ctype || "<none>"}"`);
|
|
217
|
-
// Terminal failed row
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
appendDelivery(name, {
|
|
221
|
-
id: deliveryId,
|
|
222
|
-
status: "failed",
|
|
216
|
+
// Terminal failed row resolves the `received` claim so retries don't
|
|
217
|
+
// dedup forever.
|
|
218
|
+
await updateDeliveryStatus(name, deliveryId, "failed", {
|
|
223
219
|
error: `unsupported content-type: ${ctype || "<none>"}`,
|
|
224
220
|
});
|
|
225
221
|
res.writeHead(415, { "Content-Type": "application/json" });
|
|
@@ -228,48 +224,35 @@ class WebhookServer {
|
|
|
228
224
|
}
|
|
229
225
|
const parsed = body ? JSON.parse(body) : {};
|
|
230
226
|
const eventType = headers["x-github-event"] || null;
|
|
231
|
-
const eventAction = parsed?.action || null;
|
|
232
227
|
// Invariant: skip self-generated GitHub issue_comment events. All
|
|
233
228
|
// mixdog-authored issue comments are prefixed with "[mixdog "
|
|
234
229
|
// (e.g. "[mixdog reviewer] ..."), so a comment.body starting with
|
|
235
230
|
// that marker is guaranteed to be our own dispatch and forwarding
|
|
236
|
-
// it would create a self-trigger loop.
|
|
237
|
-
// heuristic — it is a marker the dispatcher itself stamps on every
|
|
238
|
-
// comment it posts.
|
|
231
|
+
// it would create a self-trigger loop.
|
|
239
232
|
if (
|
|
240
233
|
eventType === "issue_comment" &&
|
|
241
234
|
typeof parsed?.comment?.body === "string" &&
|
|
242
235
|
parsed.comment.body.startsWith("[mixdog ")
|
|
243
236
|
) {
|
|
244
|
-
|
|
245
|
-
id: deliveryId,
|
|
246
|
-
status: "self-comment-skip",
|
|
247
|
-
event: eventType,
|
|
248
|
-
headersSummary: buildHeadersSummary(headers),
|
|
249
|
-
});
|
|
237
|
+
await updateDeliveryStatus(name, deliveryId, "self-comment-skip");
|
|
250
238
|
logWebhook(`${name}: self-comment-skip ${deliveryId}`);
|
|
251
239
|
res.writeHead(202, { "Content-Type": "application/json" });
|
|
252
240
|
res.end(JSON.stringify({ status: "self-comment-skip", id: deliveryId }));
|
|
253
241
|
return;
|
|
254
242
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
status: "pending",
|
|
258
|
-
event: eventType,
|
|
259
|
-
headersSummary: buildHeadersSummary(headers),
|
|
260
|
-
payloadPreview: String(body || "").slice(0, 512),
|
|
261
|
-
});
|
|
262
|
-
this.handleWebhook(name, parsed, headers, res, deliveryId);
|
|
243
|
+
await updateDeliveryStatus(name, deliveryId, "pending");
|
|
244
|
+
await this.handleWebhook(name, parsed, headers, res, deliveryId, dbEndpoint);
|
|
263
245
|
} catch (err) {
|
|
264
246
|
logWebhook(`JSON parse error for ${name}: ${err}`);
|
|
265
|
-
// Terminal failed row:
|
|
266
|
-
//
|
|
247
|
+
// Terminal failed row: a 400 return must close out the `received` claim
|
|
248
|
+
// so retries don't loop on dedup.
|
|
267
249
|
const _id = typeof deliveryId === "string" && deliveryId ? deliveryId : null;
|
|
268
|
-
if (_id
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
250
|
+
if (_id) {
|
|
251
|
+
try {
|
|
252
|
+
await updateDeliveryStatus(name, _id, "failed", { error: `invalid JSON: ${err?.message || err}` });
|
|
253
|
+
} catch (e2) {
|
|
254
|
+
process.stderr.write(`mixdog webhook: failed to mark delivery ${name}/${_id} failed \u2014 ${e2?.message || e2}\n`);
|
|
255
|
+
}
|
|
273
256
|
}
|
|
274
257
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
275
258
|
res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
@@ -277,12 +260,10 @@ class WebhookServer {
|
|
|
277
260
|
}
|
|
278
261
|
// Returns true when the request may proceed; otherwise writes the
|
|
279
262
|
// appropriate 401/403 response and returns false.
|
|
280
|
-
_verifySignatureGate(name, endpoint, body, headers, res) {
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// setWebhookEnabled (which only rewrites WEBHOOK.md) cannot corrupt it.
|
|
285
|
-
const secret = _readEndpointSecret(name) || this.config.secret;
|
|
263
|
+
_verifySignatureGate(name, endpoint, isTableEndpoint, secret, body, headers, res) {
|
|
264
|
+
// `secret` is pre-resolved by the caller (readEndpointSecret → global
|
|
265
|
+
// fallback); the per-endpoint secret now lives in the webhooks.endpoints
|
|
266
|
+
// row and is never projected through loadEndpointConfig.
|
|
286
267
|
const parser = endpoint?.parser || this.config.endpoints?.[name]?.parser;
|
|
287
268
|
if (secret) {
|
|
288
269
|
const signature = extractSignature(headers, parser);
|
|
@@ -308,18 +289,14 @@ class WebhookServer {
|
|
|
308
289
|
res.end(JSON.stringify({ error: "webhook secret required for signed parser" }));
|
|
309
290
|
return false;
|
|
310
291
|
}
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
// Fail closed. (Endpoints that DO carry a WEBHOOK.md with a
|
|
318
|
-
// secret/parser are handled by the branches above.)
|
|
319
|
-
if (!secret && !parser && existsSync(join(WEBHOOKS_DIR, name, "WEBHOOK.md"))) {
|
|
320
|
-
logWebhook(`${name}: rejected (WEBHOOK.md endpoint requires a webhook secret)`);
|
|
292
|
+
// A registered table endpoint (interactive enqueue / delegate dispatch)
|
|
293
|
+
// is privileged. With no per-endpoint secret/parser AND no global
|
|
294
|
+
// secret/parser there is no signature mode to fall back on, so accepting
|
|
295
|
+
// would inject attacker-controlled input. Fail closed.
|
|
296
|
+
if (!secret && !parser && isTableEndpoint) {
|
|
297
|
+
logWebhook(`${name}: rejected (table endpoint requires a webhook secret)`);
|
|
321
298
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
322
|
-
res.end(JSON.stringify({ error: "webhook secret required for
|
|
299
|
+
res.end(JSON.stringify({ error: "webhook secret required for endpoint" }));
|
|
323
300
|
return false;
|
|
324
301
|
}
|
|
325
302
|
if (!this.noSecretWarned) {
|
|
@@ -505,8 +482,7 @@ class WebhookServer {
|
|
|
505
482
|
// on EADDRINUSE is the guaranteed safety net.
|
|
506
483
|
this.ngrokProcess = spawn(ngrokBin, ["http", String(this.boundPort), "--url=" + domain], {
|
|
507
484
|
stdio: ["ignore", "ignore", "ignore"],
|
|
508
|
-
|
|
509
|
-
detached: true
|
|
485
|
+
...detachedSpawnOpts,
|
|
510
486
|
});
|
|
511
487
|
this.ngrokProcess.unref();
|
|
512
488
|
if (this.ngrokProcess.pid) {
|
|
@@ -549,10 +525,6 @@ class WebhookServer {
|
|
|
549
525
|
if (this.ngrokProcess) {
|
|
550
526
|
this.ngrokProcess = null;
|
|
551
527
|
}
|
|
552
|
-
// Close the module-level fs.watch handle so the watcher does not leak
|
|
553
|
-
// across stop() / restart cycles. The next loadEndpointConfig() will
|
|
554
|
-
// re-arm it via _ensureEndpointWatcher().
|
|
555
|
-
_closeEndpointWatcher();
|
|
556
528
|
let closed = Promise.resolve();
|
|
557
529
|
if (this.server) {
|
|
558
530
|
const srv = this.server;
|
|
@@ -581,33 +553,6 @@ class WebhookServer {
|
|
|
581
553
|
if (options.autoStart !== false && config.enabled) this.start();
|
|
582
554
|
}
|
|
583
555
|
// ── Webhook handler ───────────────────────────────────────────────
|
|
584
|
-
_readFolderHandler(folderPath) {
|
|
585
|
-
const mdPath = join(folderPath, "WEBHOOK.md");
|
|
586
|
-
// Routing by channel presence (no `mode` field): an endpoint WITH a
|
|
587
|
-
// channel dispatches to the hidden webhook-handler role and reports to
|
|
588
|
-
// that channel; an endpoint WITHOUT a channel injects into the current
|
|
589
|
-
// (Lead) session. `channel` starts NULL so its absence is detectable;
|
|
590
|
-
// `role` defaults to the mandatory webhook-handler for the direct path.
|
|
591
|
-
// The signature gate (below) fails closed on any WEBHOOK.md
|
|
592
|
-
// endpoint lacking a secret, so dropping `mode` does not weaken auth.
|
|
593
|
-
// `instructions` carries the markdown body (the prompt) so callers read
|
|
594
|
-
// one file instead of a config.json + instructions.md pair. Frontmatter
|
|
595
|
-
// `name`/`description` (SKILL.md convention) are display-only: dir name
|
|
596
|
-
// stays the canonical identity/URL routing key, so `name` is ignored here.
|
|
597
|
-
const handler = { channel: null, role: "webhook-handler", model: null, description: "", instructions: "" };
|
|
598
|
-
if (existsSync(mdPath)) {
|
|
599
|
-
try {
|
|
600
|
-
const { frontmatter, body } = readMarkdownDocument(readFileSync(mdPath, "utf8"));
|
|
601
|
-
if (frontmatter.channel) handler.channel = frontmatter.channel;
|
|
602
|
-
if (typeof frontmatter.role === "string" && frontmatter.role) handler.role = frontmatter.role;
|
|
603
|
-
if (typeof frontmatter.model === "string" && frontmatter.model) handler.model = frontmatter.model;
|
|
604
|
-
handler.description = String(frontmatter.description || "");
|
|
605
|
-
handler.instructions = String(body || "").trim();
|
|
606
|
-
} catch {
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
return handler;
|
|
610
|
-
}
|
|
611
556
|
_buildFencedPayload(body, headers) {
|
|
612
557
|
// Trust boundary: webhook body + headers are external, attacker-
|
|
613
558
|
// controllable input and must be treated as DATA, never instructions.
|
|
@@ -630,8 +575,8 @@ ${headersSummary}
|
|
|
630
575
|
${payload}
|
|
631
576
|
<<<${_UNTRUSTED}_END>>>`;
|
|
632
577
|
}
|
|
633
|
-
_dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channel) {
|
|
634
|
-
|
|
578
|
+
async _dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channel) {
|
|
579
|
+
await updateDeliveryStatus(name, deliveryId, "processing");
|
|
635
580
|
// Bridge dispatch must not be allowed to hang forever — without a
|
|
636
581
|
// ceiling a stuck LLM call leaves the delivery in `processing`
|
|
637
582
|
// for the lifetime of the process and dedup keeps re-running
|
|
@@ -659,32 +604,35 @@ ${payload}
|
|
|
659
604
|
});
|
|
660
605
|
Promise.race([dispatchP, timeoutP]).then(() => {
|
|
661
606
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
662
|
-
|
|
607
|
+
void updateDeliveryStatus(name, deliveryId, "done").catch((e) => logWebhook(`${name}: delivery status update failed: ${e?.message || e}`));
|
|
663
608
|
logWebhook(`${name}: delegate dispatched to bridge (role=${role}, id=${deliveryId})`);
|
|
664
609
|
}).catch((err) => {
|
|
665
610
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
666
|
-
|
|
611
|
+
void updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) }).catch(() => {});
|
|
667
612
|
logWebhook(`${name}: delegate dispatch failed: ${err?.message || err}`);
|
|
668
613
|
});
|
|
669
614
|
res.writeHead(202, { "Content-Type": "application/json" });
|
|
670
615
|
res.end(JSON.stringify({ status: "accepted", handler: "delegate", id: deliveryId }));
|
|
671
616
|
}
|
|
672
|
-
handleWebhook(name, body, headers, res, deliveryId) {
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
617
|
+
async handleWebhook(name, body, headers, res, deliveryId, endpoint) {
|
|
618
|
+
// A PG endpoint row is the folder-handler equivalent: routing is by
|
|
619
|
+
// channel_id presence — WITH a channel → delegate dispatch to the row's
|
|
620
|
+
// role/model and report to that channel; WITHOUT → interactive enqueue
|
|
621
|
+
// into the current (Lead) session. Parser-only endpoints (no table row)
|
|
622
|
+
// fall through to the event pipeline below.
|
|
623
|
+
if (endpoint) {
|
|
676
624
|
try {
|
|
677
|
-
const {
|
|
625
|
+
const { channelId, role, model, instructions } = endpoint;
|
|
678
626
|
const payloadContent = this._buildFencedPayload(body, headers);
|
|
679
|
-
if (
|
|
627
|
+
if (channelId) {
|
|
680
628
|
if (!role) {
|
|
681
|
-
|
|
629
|
+
await updateDeliveryStatus(name, deliveryId, "failed", { error: "delegate mode requires role" });
|
|
682
630
|
logWebhook(`${name}: delegate mode requires role - rejected`);
|
|
683
631
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
684
632
|
res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires role" }));
|
|
685
633
|
return;
|
|
686
634
|
} else if (!model) {
|
|
687
|
-
|
|
635
|
+
await updateDeliveryStatus(name, deliveryId, "failed", { error: "delegate mode requires model" });
|
|
688
636
|
logWebhook(`${name}: delegate mode requires model - rejected`);
|
|
689
637
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
690
638
|
res.end(JSON.stringify({ status: "rejected", error: "delegate mode requires model" }));
|
|
@@ -693,32 +641,32 @@ ${payload}
|
|
|
693
641
|
throw new Error(`[webhook] delegate mode requires bridgeDispatch`);
|
|
694
642
|
} else {
|
|
695
643
|
const fullPrompt = `${instructions}\n\n${payloadContent}`;
|
|
696
|
-
this._dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res,
|
|
644
|
+
await this._dispatchDelegate(name, role, model, fullPrompt, headers, deliveryId, res, channelId);
|
|
697
645
|
return;
|
|
698
646
|
}
|
|
699
647
|
}
|
|
700
648
|
if (this.eventPipeline) {
|
|
701
|
-
|
|
702
|
-
this.eventPipeline.enqueueDirect(name, payloadContent,
|
|
703
|
-
|
|
649
|
+
await updateDeliveryStatus(name, deliveryId, "processing");
|
|
650
|
+
this.eventPipeline.enqueueDirect(name, payloadContent, channelId, "interactive", instructions);
|
|
651
|
+
await updateDeliveryStatus(name, deliveryId, "done");
|
|
704
652
|
logWebhook(`${name}: interactive enqueued (id=${deliveryId})`);
|
|
705
653
|
}
|
|
706
654
|
res.writeHead(202, { "Content-Type": "application/json" });
|
|
707
655
|
res.end(JSON.stringify({ status: "accepted", handler: "interactive", id: deliveryId }));
|
|
708
656
|
return;
|
|
709
657
|
} catch (err) {
|
|
710
|
-
|
|
658
|
+
await updateDeliveryStatus(name, deliveryId, "failed", { error: String(err?.message || err) });
|
|
711
659
|
logWebhook(`${name}: folder handler error: ${err}`);
|
|
712
660
|
}
|
|
713
661
|
}
|
|
714
662
|
if (this.eventPipeline?.handleWebhook(name, body, headers)) {
|
|
715
|
-
|
|
663
|
+
await updateDeliveryStatus(name, deliveryId, "done");
|
|
716
664
|
logWebhook(`${name}: routed to event pipeline (id=${deliveryId})`);
|
|
717
665
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
718
666
|
res.end(JSON.stringify({ status: "accepted", id: deliveryId }));
|
|
719
667
|
return;
|
|
720
668
|
}
|
|
721
|
-
|
|
669
|
+
await updateDeliveryStatus(name, deliveryId, "failed", { error: "unknown endpoint" });
|
|
722
670
|
logWebhook(`unknown endpoint: ${name}`);
|
|
723
671
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
724
672
|
res.end(JSON.stringify({ error: "unknown endpoint" }));
|
|
@@ -134,7 +134,7 @@ if (isMixdogDebug()) {
|
|
|
134
134
|
`);
|
|
135
135
|
}
|
|
136
136
|
const _bootLog = path.join(DATA_DIR, "boot.log");
|
|
137
|
-
let config = loadConfig();
|
|
137
|
+
let config = await loadConfig();
|
|
138
138
|
let backend = createBackend(config);
|
|
139
139
|
const INSTANCE_ID = makeInstanceId();
|
|
140
140
|
const TERMINAL_LEAD_PID = getTerminalLeadPid();
|
|
@@ -37,6 +37,10 @@ import {
|
|
|
37
37
|
|
|
38
38
|
import { TOOL_DEFS } from './tool-defs.mjs'
|
|
39
39
|
|
|
40
|
+
// Static import (not the dynamic one in stop()) so the sync stop is available
|
|
41
|
+
// inside a process 'exit' hook, where dynamic import() cannot run.
|
|
42
|
+
import { stopPgForShutdownSync } from './lib/pg/supervisor.mjs'
|
|
43
|
+
|
|
40
44
|
import {
|
|
41
45
|
openDatabase,
|
|
42
46
|
closeDatabase,
|
|
@@ -128,6 +132,11 @@ import {
|
|
|
128
132
|
releaseLock as _releaseLock,
|
|
129
133
|
} from './lib/memory-process-lock.mjs'
|
|
130
134
|
|
|
135
|
+
import {
|
|
136
|
+
readServiceAdvert as _readServiceAdvert,
|
|
137
|
+
writeServiceAdvert as _writeServiceAdvert,
|
|
138
|
+
} from '../shared/service-discovery.mjs'
|
|
139
|
+
|
|
131
140
|
const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
|
|
132
141
|
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
133
142
|
: path.join(os.tmpdir(), 'mixdog')
|
|
@@ -271,31 +280,28 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
|
|
|
271
280
|
}, 30_000)
|
|
272
281
|
_periodicAdvertiseTimer.unref?.()
|
|
273
282
|
}
|
|
274
|
-
const dir = RUNTIME_ROOT
|
|
275
|
-
const file = path.join(dir, 'active-instance.json')
|
|
276
283
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
}, { compact: true, fsyncDir: true, renameFallback: 'truncate', timeoutMs: 1000 })
|
|
284
|
+
// Single-writer discovery file (discovery/memory.json), plain atomic rename
|
|
285
|
+
// with NO .lock: memory_port discovery can never be starved by the shared
|
|
286
|
+
// active-instance.json lock. Conflict guard preserved: a live OTHER memory
|
|
287
|
+
// owner advertising a different port is not clobbered.
|
|
288
|
+
const cur = _readServiceAdvert('memory')
|
|
289
|
+
const curMemPort = Number(cur?.port)
|
|
290
|
+
const curMemPid = parsePositivePid(cur?.pid)
|
|
291
|
+
const portConflict = Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== boundPort
|
|
292
|
+
const otherOwnerAlive =
|
|
293
|
+
curMemPid != null &&
|
|
294
|
+
curMemPid !== MEMORY_SERVER_PID &&
|
|
295
|
+
_isPidAliveLocal(curMemPid)
|
|
296
|
+
if (portConflict && otherOwnerAlive) {
|
|
297
|
+
__mixdogMemoryLog(`[memory-service] skip memory_port advertise port=${boundPort} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${MEMORY_SERVER_PID}\n`)
|
|
298
|
+
if (generation === _advertiseGeneration) _advertiseRetryTimer = null
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
_writeServiceAdvert('memory', {
|
|
302
|
+
port: boundPort,
|
|
303
|
+
...(MEMORY_SERVER_PID ? { pid: MEMORY_SERVER_PID } : {}),
|
|
304
|
+
})
|
|
299
305
|
if (generation === _advertiseGeneration) _advertiseRetryTimer = null
|
|
300
306
|
} catch (e) {
|
|
301
307
|
// Boot path must not serially block on the default 8s lock wait: use a short
|
|
@@ -819,8 +825,20 @@ export async function init() {
|
|
|
819
825
|
let boundPort = null
|
|
820
826
|
if (!memorySecondaryMode()) {
|
|
821
827
|
boundPort = await _startHttpServer()
|
|
822
|
-
await runtimeReady
|
|
823
828
|
advertiseMemoryPort(boundPort)
|
|
829
|
+
try {
|
|
830
|
+
await runtimeReady
|
|
831
|
+
} catch (e) {
|
|
832
|
+
// Runtime init failed AFTER we advertised the HTTP port. Leaving the
|
|
833
|
+
// listener up would answer discovery with a live 503, which
|
|
834
|
+
// memory-client treats as a delivered (non-buffered) write — silently
|
|
835
|
+
// dropping entries that would otherwise be buffered when no port is
|
|
836
|
+
// advertised. stop() withdraws the advert (clears _currentAdvertisedPort
|
|
837
|
+
// + cancels the periodic re-advertise) and closes the HTTP server, so
|
|
838
|
+
// clients see conn-refused and buffer/respawn instead.
|
|
839
|
+
try { await stop() } catch {}
|
|
840
|
+
throw e
|
|
841
|
+
}
|
|
824
842
|
} else {
|
|
825
843
|
await runtimeReady
|
|
826
844
|
}
|
|
@@ -1016,6 +1034,12 @@ if (process.env.MIXDOG_WORKER_MODE === '1' && process.send) {
|
|
|
1016
1034
|
process.on('SIGTERM', () => _workerSignalHandler('SIGTERM'))
|
|
1017
1035
|
process.on('SIGINT', () => _workerSignalHandler('SIGINT'))
|
|
1018
1036
|
|
|
1037
|
+
// Windows-safe last resort: SIGTERM may TerminateProcess before the async
|
|
1038
|
+
// stop() path runs, orphaning PG mid-write. Best-effort sync pg_ctl stop on
|
|
1039
|
+
// exit (no-op after a completed graceful stop). Skip in secondary mode — we
|
|
1040
|
+
// do not own the shared PG there.
|
|
1041
|
+
if (!memorySecondaryMode()) process.on('exit', () => { try { stopPgForShutdownSync() } catch {} })
|
|
1042
|
+
|
|
1019
1043
|
// callId → AbortController for in-flight IPC calls (cancel handler uses this).
|
|
1020
1044
|
const _inFlightCalls = new Map()
|
|
1021
1045
|
|
|
@@ -1089,6 +1113,7 @@ if (IS_MEMORY_ENTRY && process.env.MIXDOG_WORKER_MODE !== '1') {
|
|
|
1089
1113
|
;(async () => {
|
|
1090
1114
|
acquireLock()
|
|
1091
1115
|
process.on('exit', releaseLock)
|
|
1116
|
+
if (!memorySecondaryMode()) process.on('exit', () => { try { stopPgForShutdownSync() } catch {} })
|
|
1092
1117
|
process.on('SIGINT', () => { stop().finally(() => process.exit(0)) })
|
|
1093
1118
|
process.on('SIGTERM', () => { stop().finally(() => process.exit(0)) })
|
|
1094
1119
|
await init()
|
|
@@ -102,7 +102,9 @@ export function createCycleScheduler(deps) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function markCycleRunning(cycle) {
|
|
105
|
-
|
|
105
|
+
// pid lets the statusline drop a phantom "running" marker as soon as
|
|
106
|
+
// this daemon dies mid-cycle, instead of waiting out the stale guard.
|
|
107
|
+
_cycleRunning = { cycle, started_at: Date.now(), pid: process.pid }
|
|
106
108
|
_writeCycleStateFile()
|
|
107
109
|
}
|
|
108
110
|
|
|
@@ -333,7 +335,13 @@ export function createCycleScheduler(deps) {
|
|
|
333
335
|
try {
|
|
334
336
|
let c3Options = {
|
|
335
337
|
coalescedRetry: true,
|
|
336
|
-
onCoalescedSuccess: async () => {
|
|
338
|
+
onCoalescedSuccess: async (result) => {
|
|
339
|
+
// Only a real, error-free pass persists success; a run that returned
|
|
340
|
+
// an error (LLM/unparseable) must not stamp last_success_at.
|
|
341
|
+
if (result?.error) { markCycleDone('cycle3', false, result.error); return }
|
|
342
|
+
await setCycleLastRun('cycle3', Date.now())
|
|
343
|
+
markCycleDone('cycle3', true)
|
|
344
|
+
},
|
|
337
345
|
}
|
|
338
346
|
if (typeof c3Options?.callLlm !== 'function') {
|
|
339
347
|
c3Options = { ...c3Options, callLlm: getCycle3CallLlm() }
|
|
@@ -498,6 +506,16 @@ export function createCycleScheduler(deps) {
|
|
|
498
506
|
// so clear the marker (health/backlog reset to this process's state).
|
|
499
507
|
_cycleRunning = null
|
|
500
508
|
_writeCycleStateFile()
|
|
509
|
+
// Hydrate health success timestamps from the persisted per-cycle last-run
|
|
510
|
+
// meta. Without this, a restart re-inits _cycleHealth to last_success_at=0
|
|
511
|
+
// and the state file reports 0 until the next run — for cycle3 that is up
|
|
512
|
+
// to 24h later, so a genuinely-successful cycle3 looks like it never ran.
|
|
513
|
+
Promise.resolve(getCycleLastRun()).then((last) => {
|
|
514
|
+
if (last?.cycle1 > 0 && !_cycleHealth.cycle1.last_success_at) _cycleHealth.cycle1.last_success_at = last.cycle1
|
|
515
|
+
if (last?.cycle2 > 0 && !_cycleHealth.cycle2.last_success_at) _cycleHealth.cycle2.last_success_at = last.cycle2
|
|
516
|
+
if (last?.cycle3 > 0 && !_cycleHealth.cycle3.last_success_at) _cycleHealth.cycle3.last_success_at = last.cycle3
|
|
517
|
+
_writeCycleStateFile()
|
|
518
|
+
}).catch(() => {})
|
|
501
519
|
_scheduleNextCheck()
|
|
502
520
|
_startupTimeout = setTimeout(() => { void _runCheckCyclesGuarded() }, 30_000)
|
|
503
521
|
}
|
|
@@ -6,6 +6,7 @@ import os from 'node:os'
|
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import fs from 'node:fs'
|
|
8
8
|
import { readSection } from '../../shared/config.mjs'
|
|
9
|
+
import { readServiceAdvert } from '../../shared/service-discovery.mjs'
|
|
9
10
|
|
|
10
11
|
export function readMainConfig() {
|
|
11
12
|
return readSection('memory')
|
|
@@ -74,8 +75,19 @@ export function secondaryPgAdvertised(dataDir) {
|
|
|
74
75
|
const runtimeRoot = process.env.MIXDOG_RUNTIME_ROOT
|
|
75
76
|
? path.resolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
76
77
|
: path.join(os.tmpdir(), 'mixdog')
|
|
78
|
+
// Prefer the single-writer PG discovery advert (discovery/pg.json); fall back
|
|
79
|
+
// to the legacy active-instance.json pg_* fields for cross-version compat.
|
|
80
|
+
const readAdvert = () => {
|
|
81
|
+
const advert = readServiceAdvert('pg')
|
|
82
|
+
if (advert && Number(advert.pg_port) > 0) return advert
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(fs.readFileSync(path.join(runtimeRoot, 'active-instance.json'), 'utf8'))
|
|
85
|
+
} catch {
|
|
86
|
+
return advert
|
|
87
|
+
}
|
|
88
|
+
}
|
|
77
89
|
try {
|
|
78
|
-
const cur =
|
|
90
|
+
const cur = readAdvert()
|
|
79
91
|
const port = Number(cur?.pg_port)
|
|
80
92
|
const pgdata = cur?.pg_pgdata ? path.resolve(String(cur.pg_pgdata)) : ''
|
|
81
93
|
return Number.isInteger(port) && port > 0 && pgdata === path.resolve(path.join(dataDir, 'pgdata'))
|