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.
Files changed (84) hide show
  1. package/package.json +2 -1
  2. package/scripts/build-runtime-windows.ps1 +242 -242
  3. package/scripts/explore-bench-tmp.mjs +1 -1
  4. package/scripts/recall-usecase-cases.json +1 -1
  5. package/scripts/smoke-runtime-negative.ps1 +106 -106
  6. package/scripts/steering-fold-provenance-test.mjs +71 -0
  7. package/scripts/tool-efficiency-diag.mjs +1 -1
  8. package/scripts/webhook-smoke.mjs +46 -53
  9. package/src/defaults/cycle3-review-prompt.md +11 -4
  10. package/src/defaults/memory-promote-prompt.md +9 -0
  11. package/src/defaults/skills/setup/SKILL.md +6 -1
  12. package/src/rules/lead/02-channels.md +3 -3
  13. package/src/rules/shared/01-tool.md +8 -4
  14. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
  15. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
  17. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
  18. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
  20. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
  22. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
  26. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
  27. package/src/runtime/channels/backends/discord.mjs +10 -3
  28. package/src/runtime/channels/lib/config.mjs +13 -11
  29. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  30. package/src/runtime/channels/lib/memory-client.mjs +22 -2
  31. package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
  32. package/src/runtime/channels/lib/scheduler.mjs +226 -208
  33. package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
  34. package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
  35. package/src/runtime/channels/lib/webhook.mjs +98 -150
  36. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  37. package/src/runtime/memory/index.mjs +50 -25
  38. package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
  39. package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
  40. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  41. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  42. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  43. package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
  44. package/src/runtime/memory/lib/pg/process.mjs +19 -1
  45. package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
  46. package/src/runtime/memory/lib/query-handlers.mjs +102 -0
  47. package/src/runtime/memory/lib/recall-format.mjs +60 -22
  48. package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
  49. package/src/runtime/memory/tool-defs.mjs +1 -1
  50. package/src/runtime/shared/child-guardian.mjs +2 -2
  51. package/src/runtime/shared/open-url.mjs +2 -1
  52. package/src/runtime/shared/schedules-db.mjs +350 -0
  53. package/src/runtime/shared/service-discovery.mjs +169 -0
  54. package/src/runtime/shared/spawn-flags.mjs +27 -0
  55. package/src/runtime/shared/tool-primitives.mjs +3 -1
  56. package/src/runtime/shared/tool-surface.mjs +19 -3
  57. package/src/runtime/shared/update-checker.mjs +54 -10
  58. package/src/runtime/shared/webhooks-db.mjs +405 -0
  59. package/src/session-runtime/channel-config-api.mjs +13 -13
  60. package/src/session-runtime/lifecycle-api.mjs +14 -0
  61. package/src/session-runtime/runtime-core.mjs +39 -20
  62. package/src/standalone/agent-tool.mjs +42 -11
  63. package/src/standalone/channel-admin.mjs +173 -121
  64. package/src/standalone/channel-worker.mjs +2 -2
  65. package/src/standalone/memory-runtime-proxy.mjs +10 -5
  66. package/src/tui/App.jsx +32 -10
  67. package/src/tui/app/channel-pickers.mjs +9 -9
  68. package/src/tui/app/clipboard.mjs +14 -0
  69. package/src/tui/app/doctor.mjs +1 -1
  70. package/src/tui/app/maintenance-pickers.mjs +17 -7
  71. package/src/tui/app/settings-picker.mjs +2 -2
  72. package/src/tui/app/transcript-window.mjs +37 -1
  73. package/src/tui/app/use-mouse-input.mjs +19 -6
  74. package/src/tui/components/ToolExecution.jsx +12 -4
  75. package/src/tui/display-width.mjs +10 -4
  76. package/src/tui/dist/index.mjs +166 -58
  77. package/src/tui/engine/agent-job-feed.mjs +42 -3
  78. package/src/tui/engine/session-api-ext.mjs +12 -12
  79. package/src/tui/index.jsx +12 -2
  80. package/src/ui/statusline-segments.mjs +12 -1
  81. package/src/workflows/default/WORKFLOW.md +29 -38
  82. package/src/workflows/solo/WORKFLOW.md +15 -20
  83. package/vendor/ink/build/display-width.js +5 -4
  84. 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
- appendDelivery,
16
- deliveryExists,
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
- // disk lookup so probes / scans cannot reach later stages.
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
- const _endpointPreCheck = loadEndpointConfig(name) || this.config.endpoints?.[name] || null;
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
- const _registeredPre = !!(
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 JSON-parse `catch` at the bottom of this method can
150
- // emit the terminal `failed` delivery row using the delivery id we
151
- // assigned before parsing. Declaring it inside the try would leave
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
- // Secret lookup: per-endpoint (folder config.json) global (webhook config) → warn+accept.
162
- // Parser likewise prefers per-endpoint, falls back to global endpoints map.
163
- const endpoint = loadEndpointConfig(name) || this.config.endpoints?.[name] || null;
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. Reject unknown endpoint names
171
- // before any disk write appendDelivery's mkdirSync would
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
- const _registered = !!(
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
- if (!this._verifySignatureGate(name, endpoint, rawBody, headers, res)) return;
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
- // Any existing delivery row (pending / processing / done /
196
- // failed) means we have already accepted this event id at least
197
- // once. Reject the replay flat so a fast-retrying sender cannot
198
- // double-dispatch while the first run is still in flight.
199
- if (deliveryExists(name, deliveryId)) {
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: the `received` claim above must be resolved
218
- // by a terminal status. Without it, deliveryExists() keeps the row
219
- // visible and dedupes every future retry of the same id forever.
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. This is not a user-name
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
- appendDelivery(name, {
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
- appendDelivery(name, {
256
- id: deliveryId,
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: as with the 415 branch above, a 400 return
266
- // must close out the `received` row so retries don't loop on dedup.
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 && !appendDelivery(name, { id: _id, status: "failed", error: `invalid JSON: ${err?.message || err}` })) {
269
- // The terminal `failed` write failed (appendDelivery swallowed it).
270
- // The `received` row now lingers and will dedup this delivery id
271
- // forever; surface it so the stuck row is diagnosable.
272
- process.stderr.write(`mixdog webhook: stuck received row will dedup this delivery id ${name}/${deliveryId} \u2014 terminal 'failed' row write failed\n`);
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
- // Per-endpoint secret lives in the side file WEBHOOKS_DIR/<name>/secret
282
- // (plaintext, one line) NOT in WEBHOOK.md frontmatter, so a user
283
- // secret containing quotes/colons/newlines round-trips losslessly and
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
- // WEBHOOK.md folder endpoint with no resolved signature
312
- // mode. handleWebhook's instructions.md branch enqueues the body as
313
- // an interactive prompt (or dispatches a delegate) both are
314
- // privileged. With no per-endpoint secret/parser AND no global
315
- // secret/parser, there is no signature mode to fall back on, so
316
- // accepting the request would inject attacker-controlled input.
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 WEBHOOK.md endpoint" }));
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
- windowsHide: true,
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
- appendDelivery(name, { id: deliveryId, status: "processing" });
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
- appendDelivery(name, { id: deliveryId, status: "done" });
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
- appendDelivery(name, { id: deliveryId, status: "failed", error: String(err?.message || err) });
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
- const folderPath = join(WEBHOOKS_DIR, name);
674
- const mdPath = join(folderPath, "WEBHOOK.md");
675
- if (existsSync(mdPath)) {
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 { channel, role, model, instructions } = this._readFolderHandler(folderPath);
625
+ const { channelId, role, model, instructions } = endpoint;
678
626
  const payloadContent = this._buildFencedPayload(body, headers);
679
- if (channel) {
627
+ if (channelId) {
680
628
  if (!role) {
681
- appendDelivery(name, { id: deliveryId, status: "failed", error: "delegate mode requires role in WEBHOOK.md" });
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
- appendDelivery(name, { id: deliveryId, status: "failed", error: "delegate mode requires model in WEBHOOK.md" });
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, channel);
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
- appendDelivery(name, { id: deliveryId, status: "processing" });
702
- this.eventPipeline.enqueueDirect(name, payloadContent, channel, "interactive", instructions);
703
- appendDelivery(name, { id: deliveryId, status: "done" });
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
- appendDelivery(name, { id: deliveryId, status: "failed", error: String(err?.message || err) });
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
- appendDelivery(name, { id: deliveryId, status: "done" });
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
- appendDelivery(name, { id: deliveryId, status: "failed", error: "unknown endpoint" });
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
- fs.mkdirSync(dir, { recursive: true })
278
- updateJsonAtomicSync(file, (curRaw) => {
279
- const cur = curRaw ?? {}
280
- const curMemPort = Number(cur?.memory_port)
281
- const curMemPid = parsePositivePid(cur?.memory_server_pid)
282
- const portConflict = Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== boundPort
283
- const otherOwnerAlive =
284
- curMemPid != null &&
285
- curMemPid !== MEMORY_SERVER_PID &&
286
- _isPidAliveLocal(curMemPid)
287
- if (portConflict && otherOwnerAlive) {
288
- __mixdogMemoryLog(`[memory-service] skip memory_port advertise port=${boundPort} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${MEMORY_SERVER_PID}\n`)
289
- return undefined
290
- }
291
- const next = {
292
- ...cur,
293
- memory_port: boundPort,
294
- ...(MEMORY_SERVER_PID ? { memory_server_pid: MEMORY_SERVER_PID } : {}),
295
- updatedAt: Date.now(),
296
- }
297
- return next
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
- _cycleRunning = { cycle, started_at: Date.now() }
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 () => { await setCycleLastRun('cycle3', Date.now()); markCycleDone('cycle3', true) },
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 = JSON.parse(fs.readFileSync(path.join(runtimeRoot, 'active-instance.json'), 'utf8'))
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'))