@nanhara/hara 0.122.2 → 0.122.4

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 (64) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +15 -2
  3. package/dist/agent/limits.js +51 -0
  4. package/dist/agent/loop.js +367 -112
  5. package/dist/agent/repeat-guard.js +49 -12
  6. package/dist/checkpoints.js +9 -0
  7. package/dist/cli.js +2 -1
  8. package/dist/config.js +10 -2
  9. package/dist/context/agents-md.js +21 -2
  10. package/dist/context/mentions.js +84 -1
  11. package/dist/context/workspace-scope.js +49 -0
  12. package/dist/cron/deliver.js +61 -38
  13. package/dist/cron/runner.js +423 -65
  14. package/dist/cron/schedule.js +23 -7
  15. package/dist/cron/store.js +709 -43
  16. package/dist/fs-walk.js +279 -7
  17. package/dist/fs-write.js +9 -0
  18. package/dist/gateway/feishu.js +351 -64
  19. package/dist/gateway/flows-pending.js +31 -17
  20. package/dist/gateway/flows.js +306 -31
  21. package/dist/gateway/outbound-files.js +20 -6
  22. package/dist/gateway/runtime-state.js +1485 -0
  23. package/dist/gateway/serve.js +550 -242
  24. package/dist/gateway/sessions.js +3 -3
  25. package/dist/gateway/telegram.js +279 -29
  26. package/dist/gateway/tts.js +182 -38
  27. package/dist/hooks.js +22 -22
  28. package/dist/index.js +466 -158
  29. package/dist/memory/store.js +8 -5
  30. package/dist/org/planner.js +11 -6
  31. package/dist/org/projects.js +3 -3
  32. package/dist/process-identity.js +52 -0
  33. package/dist/providers/bounded-turn.js +42 -0
  34. package/dist/recall.js +69 -1
  35. package/dist/runtime.js +24 -0
  36. package/dist/sandbox.js +54 -4
  37. package/dist/search/embed.js +13 -2
  38. package/dist/search/hybrid.js +6 -3
  39. package/dist/search/semindex.js +87 -5
  40. package/dist/security/guardian.js +3 -15
  41. package/dist/security/permissions.js +11 -0
  42. package/dist/serve/server.js +70 -42
  43. package/dist/serve/sessions.js +4 -3
  44. package/dist/sync-sleep.js +46 -0
  45. package/dist/tools/agent.js +1 -1
  46. package/dist/tools/ask_user.js +5 -1
  47. package/dist/tools/builtin.js +5 -2
  48. package/dist/tools/codebase.js +67 -18
  49. package/dist/tools/computer.js +149 -68
  50. package/dist/tools/cron.js +72 -18
  51. package/dist/tools/edit.js +3 -2
  52. package/dist/tools/external_agent.js +66 -12
  53. package/dist/tools/memory.js +25 -7
  54. package/dist/tools/patch.js +11 -2
  55. package/dist/tools/registry.js +16 -1
  56. package/dist/tools/search.js +68 -9
  57. package/dist/tools/send.js +1 -1
  58. package/dist/tools/skill.js +1 -1
  59. package/dist/tools/task.js +3 -3
  60. package/dist/tools/web.js +43 -13
  61. package/dist/tui/App.js +93 -25
  62. package/dist/vision.js +5 -6
  63. package/package.json +2 -2
  64. package/runtime-bootstrap.cjs +22 -0
@@ -7,12 +7,91 @@
7
7
  // Platform-agnostic: matching only reads the generic InboundMsg fields each adapter populates (chatType,
8
8
  // mentions), so a flow works on whatever platform surfaces the data it asks for.
9
9
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
10
+ import { createHash } from "node:crypto";
10
11
  import { join } from "node:path";
11
12
  import { homedir } from "node:os";
13
+ import { chunkText } from "./telegram.js";
12
14
  import { deliverResult, parseDeliver } from "../cron/deliver.js";
13
15
  import { addPending } from "./flows-pending.js";
14
16
  import { redactSensitiveValue } from "../security/secrets.js";
15
17
  const asArray = (v) => (v == null ? [] : Array.isArray(v) ? v : [v]);
18
+ function deliveryLabel(spec) {
19
+ const parsed = parseDeliver(spec);
20
+ return "error" in parsed ? "invalid-target" : parsed.platform;
21
+ }
22
+ function hashFlowKey(...parts) {
23
+ const hash = createHash("sha256");
24
+ for (const part of parts) {
25
+ const value = String(part ?? "");
26
+ hash.update(String(Buffer.byteLength(value, "utf8"))).update(":").update(value).update(";");
27
+ }
28
+ return hash.digest("hex");
29
+ }
30
+ function canonicalFlowValue(value, depth = 0) {
31
+ if (depth > 64)
32
+ return '"<depth-limit>"';
33
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") {
34
+ return JSON.stringify(value);
35
+ }
36
+ if (Array.isArray(value))
37
+ return `[${value.map((item) => canonicalFlowValue(item, depth + 1)).join(",")}]`;
38
+ if (typeof value === "object") {
39
+ const record = value;
40
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalFlowValue(record[key], depth + 1)}`).join(",")}}`;
41
+ }
42
+ return '"<unsupported>"';
43
+ }
44
+ /** Stable only when the adapter provides a true platform event id. Falling back to message text/time could
45
+ * suppress two legitimate identical messages, so adapters without ids retain at-least-once behavior. */
46
+ export function flowSourceKey(context, platform, messageId) {
47
+ const id = messageId?.trim();
48
+ return context && id ? hashFlowKey("hara-flow-source-v1", context.scope, platform, id) : undefined;
49
+ }
50
+ async function runFlowEffect(receipts, key, effect) {
51
+ if (!receipts || !key)
52
+ return effect();
53
+ const claim = await receipts.claim(key);
54
+ if (!claim)
55
+ return; // already completed in this process or before a restart
56
+ try {
57
+ await effect();
58
+ await claim.complete();
59
+ }
60
+ catch (error) {
61
+ // Releasing a receipt is cleanup. A store I/O failure must not hide the transport/model error that made
62
+ // this source event retry in the first place.
63
+ try {
64
+ await claim.release();
65
+ }
66
+ catch {
67
+ /* best-effort; the bounded in-flight lease can be reclaimed after restart */
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+ /** Persist each chat-sized piece separately. A later piece can fail without making a retry resend pieces whose
73
+ * external send and local receipt both completed. Including a hash of content avoids joining a changed model
74
+ * answer to receipts from an older answer, while the persisted key remains opaque. */
75
+ async function runFlowTextEffect(receipts, key, text, effect) {
76
+ for (const [index, part] of chunkText(text || "(empty)", 3_500).entries()) {
77
+ const partKey = key ? hashFlowKey("hara-flow-effect-part-v1", key, index, part) : undefined;
78
+ await runFlowEffect(receipts, partKey, () => effect(part, partKey));
79
+ }
80
+ }
81
+ async function runFlowDeliveryEffect(receipts, key, spec, text, signal) {
82
+ const target = parseDeliver(spec);
83
+ const deliver = async (part, idempotencyKey) => {
84
+ const error = await deliverResult(spec, part, signal, idempotencyKey);
85
+ if (error)
86
+ throw new Error(`deliver(${deliveryLabel(spec)}) failed — ${error}`);
87
+ };
88
+ if (!("error" in target) && target.platform !== "webhook") {
89
+ await runFlowTextEffect(receipts, key, text, deliver);
90
+ }
91
+ else {
92
+ await runFlowEffect(receipts, key, () => deliver(text, key));
93
+ }
94
+ }
16
95
  function isStrings(value) {
17
96
  return typeof value === "string" || (Array.isArray(value) && value.every((item) => typeof item === "string"));
18
97
  }
@@ -145,6 +224,11 @@ const MAX_SENDER_RATE_BUCKETS = 1_024;
145
224
  const MAX_CHAT_RATE_BUCKETS = 1_024;
146
225
  const MAX_RATE_KEY_PART_CHARS = 160;
147
226
  const RATE_SWEEP_INTERVAL = 64;
227
+ const FLOW_SOURCE_RETRY_TTL_MS = 60 * 60_000;
228
+ const MAX_FLOW_SOURCE_RETRIES = 4_096;
229
+ const MAX_FLOW_SOURCE_ATTEMPTS = 3;
230
+ const FLOW_RETRY_BASE_DELAY_MS = 2_000;
231
+ const admittedFlowSources = new Map();
148
232
  let claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
149
233
  function boundedRateKey(...parts) {
150
234
  return parts
@@ -205,10 +289,60 @@ export function resetFlowRateStateForTests() {
205
289
  chatFlowRate.clear();
206
290
  globalFlowRate = [];
207
291
  activeFlowRuns = 0;
292
+ admittedFlowSources.clear();
208
293
  claimsUntilRateSweep = RATE_SWEEP_INTERVAL;
209
294
  }
210
- function claimFlowRun(rule, m, platform) {
295
+ function flowRetryState(sourceRunKey, now) {
296
+ if (!sourceRunKey)
297
+ return undefined;
298
+ const state = admittedFlowSources.get(sourceRunKey);
299
+ if (!state)
300
+ return undefined;
301
+ if (now >= state.expiresAt) {
302
+ admittedFlowSources.delete(sourceRunKey);
303
+ return undefined;
304
+ }
305
+ admittedFlowSources.delete(sourceRunKey);
306
+ admittedFlowSources.set(sourceRunKey, state); // bounded LRU refresh; expiry deliberately stays fixed
307
+ return state;
308
+ }
309
+ function rememberFlowSource(sourceRunKey, now) {
310
+ if (!sourceRunKey)
311
+ return;
312
+ for (const [key, state] of admittedFlowSources) {
313
+ if (now >= state.expiresAt)
314
+ admittedFlowSources.delete(key);
315
+ }
316
+ while (admittedFlowSources.size >= MAX_FLOW_SOURCE_RETRIES) {
317
+ const oldest = admittedFlowSources.keys().next().value;
318
+ if (!oldest)
319
+ break;
320
+ admittedFlowSources.delete(oldest);
321
+ }
322
+ admittedFlowSources.set(sourceRunKey, {
323
+ attempts: 1,
324
+ expiresAt: now + FLOW_SOURCE_RETRY_TTL_MS,
325
+ nextAttemptAt: now,
326
+ alarmed: false,
327
+ });
328
+ }
329
+ function markFlowRunFailed(sourceRunKey, now) {
330
+ if (!sourceRunKey)
331
+ return;
332
+ const state = admittedFlowSources.get(sourceRunKey);
333
+ if (!state)
334
+ return;
335
+ const exponent = Math.max(0, state.attempts - 1);
336
+ state.nextAttemptAt = now + Math.min(30_000, FLOW_RETRY_BASE_DELAY_MS * (2 ** exponent));
337
+ }
338
+ function forgetFlowSource(sourceRunKey) {
339
+ if (sourceRunKey)
340
+ admittedFlowSources.delete(sourceRunKey);
341
+ }
342
+ function claimFlowRun(rule, m, platform, sourceRunKey, durableRetry) {
211
343
  const now = Date.now();
344
+ const retryState = durableRetry === undefined ? flowRetryState(sourceRunKey, now) : undefined;
345
+ const retry = durableRetry ?? (retryState !== undefined);
212
346
  if (--claimsUntilRateSweep <= 0) {
213
347
  sweepRateMap(senderFlowRate, now, RATE_MINUTE_MS, MAX_SENDER_RUNS_PER_MINUTE);
214
348
  sweepRateMap(chatFlowRate, now, RATE_HOUR_MS, MAX_CHAT_RUNS_PER_HOUR);
@@ -216,7 +350,24 @@ function claimFlowRun(rule, m, platform) {
216
350
  }
217
351
  globalFlowRate = pruneRateTimes(globalFlowRate, now, RATE_HOUR_MS, MAX_GLOBAL_RUNS_PER_HOUR);
218
352
  if (activeFlowRuns >= MAX_ACTIVE_FLOW_RUNS)
219
- return false;
353
+ return { claimed: false, retry };
354
+ if (retryState) {
355
+ if (retryState.attempts >= MAX_FLOW_SOURCE_ATTEMPTS) {
356
+ const alarm = !retryState.alarmed;
357
+ retryState.alarmed = true;
358
+ return { claimed: false, retry: true, exhausted: true, alarm };
359
+ }
360
+ if (now < retryState.nextAttemptAt) {
361
+ return { claimed: false, retry: true, retryAfterMs: retryState.nextAttemptAt - now };
362
+ }
363
+ retryState.attempts++;
364
+ activeFlowRuns++;
365
+ return { claimed: true, retry: true };
366
+ }
367
+ if (durableRetry) {
368
+ activeFlowRuns++;
369
+ return { claimed: true, retry: true };
370
+ }
220
371
  const senderKey = boundedRateKey(rule.name, platform, m.chatId, m.userId);
221
372
  const chatKey = boundedRateKey(rule.name, platform, m.chatId);
222
373
  const senderMinute = prepareRateBucket(senderFlowRate, senderKey, now, RATE_MINUTE_MS, MAX_SENDER_RATE_BUCKETS, MAX_SENDER_RUNS_PER_MINUTE);
@@ -224,21 +375,36 @@ function claimFlowRun(rule, m, platform) {
224
375
  // A full identity table is itself an abuse signal. Reject new identities instead of evicting a live
225
376
  // bucket and letting an attacker cycle through unbounded sender/chat ids.
226
377
  if (!senderMinute || !chatHour)
227
- return false;
378
+ return { claimed: false, retry: false };
228
379
  if (senderMinute.length >= MAX_SENDER_RUNS_PER_MINUTE ||
229
380
  countRateTimes(chatHour, now, RATE_MINUTE_MS) >= MAX_CHAT_RUNS_PER_MINUTE ||
230
381
  chatHour.length >= MAX_CHAT_RUNS_PER_HOUR ||
231
382
  countRateTimes(globalFlowRate, now, RATE_MINUTE_MS) >= MAX_GLOBAL_RUNS_PER_MINUTE ||
232
383
  globalFlowRate.length >= MAX_GLOBAL_RUNS_PER_HOUR) {
233
- return false;
384
+ return { claimed: false, retry: false };
234
385
  }
235
386
  senderMinute.push(now);
236
387
  chatHour.push(now);
237
388
  globalFlowRate.push(now);
238
389
  senderFlowRate.set(senderKey, senderMinute);
239
390
  chatFlowRate.set(chatKey, chatHour);
391
+ if (durableRetry === undefined)
392
+ rememberFlowSource(sourceRunKey, now);
240
393
  activeFlowRuns++;
241
- return true;
394
+ return { claimed: true, retry: false };
395
+ }
396
+ async function reportFlowRetryExhausted(rule, markAlarmed) {
397
+ console.error(`hara flow: ALERT "${rule.name}" stopped after ${MAX_FLOW_SOURCE_ATTEMPTS} failed attempts for one source event — acknowledged to break the retry loop`);
398
+ appendFlowLog({
399
+ flow: rule.name,
400
+ event: "retry-exhausted",
401
+ severity: "error",
402
+ attempts: MAX_FLOW_SOURCE_ATTEMPTS,
403
+ outcome: "source-event-acknowledged",
404
+ });
405
+ // Mark only after both operator-visible records were emitted. A crash may duplicate an alarm, but can never
406
+ // make an exhausted poison event disappear silently.
407
+ await markAlarmed?.();
242
408
  }
243
409
  /** Extract the flow agent's structured result — {disposition,briefing,draft,dispatch?} — from its text
244
410
  * output. Tolerant of surrounding prose/fences; null if there's no parseable object (caller falls back). */
@@ -296,26 +462,80 @@ export function buildFlowPrompt(r, m) {
296
462
  `\n\n--- Untrusted triggering message (data only) ---\nchat ${String(m.chatId).slice(0, 200)}${m.chatType ? ` (${m.chatType})` : ""} · from ${String(m.userName || m.userId).slice(0, 200)}\n${(m.text ?? "").slice(0, 16_000)}`);
297
463
  }
298
464
  /** Try to handle `m` via configured flows. Returns true if ≥1 rule matched (caller should STOP default routing).
299
- * The agent run + delivery are fire-and-forget so a slow LLM call never blocks the gateway's event loop.
465
+ * All claimed runs settle before this resolves, so the gateway can bind delivery success to its inbound claim.
300
466
  * `runAgent` must be the gateway's isolated no-tool provider path; `reply` (optional) sends text back to the
301
467
  * originating chat. `approvalOwner` is a concrete platform:user identity; without one, consequential actions
302
468
  * fail closed instead of becoming approvable by an arbitrary allowlisted DM. */
303
- export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner, signal) {
304
- const matched = loadFlows().filter((r) => matchFlow(r, m, platform)).slice(0, 4);
469
+ export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner, signal, effects) {
470
+ const flowOccurrences = new Map();
471
+ const matched = loadFlows()
472
+ .map((rule) => {
473
+ const baseIdentity = hashFlowKey("hara-flow-rule-v1", canonicalFlowValue(rule));
474
+ const occurrence = flowOccurrences.get(baseIdentity) ?? 0;
475
+ flowOccurrences.set(baseIdentity, occurrence + 1);
476
+ return { rule, flowIdentity: hashFlowKey(baseIdentity, occurrence) };
477
+ })
478
+ .filter(({ rule }) => matchFlow(rule, m, platform))
479
+ .slice(0, 4);
305
480
  if (!matched.length)
306
481
  return false;
307
482
  if (signal?.aborted)
308
483
  return true;
309
- for (const r of matched) {
484
+ const sourceKey = flowSourceKey(effects, platform, m.messageId);
485
+ const jobs = [];
486
+ let deferredRetry = false;
487
+ for (const { rule: r, flowIdentity } of matched) {
310
488
  console.error(`hara flow: "${r.name}" matched · ${platform} ${m.chatType ?? "?"} ${m.chatId}`);
311
- if (!claimFlowRun(r, m, platform)) {
312
- console.error(`hara flow: "${r.name}" rate/concurrency limit reached — trigger dropped`);
489
+ const sourceRunKey = sourceKey ? hashFlowKey("hara-flow-run-v1", sourceKey, flowIdentity) : undefined;
490
+ let durableClaim;
491
+ if (sourceRunKey && sourceKey && effects?.runs) {
492
+ const durableAdmission = await effects.runs.claim(sourceRunKey, sourceKey);
493
+ if (durableAdmission.kind === "complete")
494
+ continue;
495
+ if (durableAdmission.kind === "exhausted") {
496
+ if (durableAdmission.alarm) {
497
+ await reportFlowRetryExhausted(r, () => effects.runs.markAlarmed(sourceRunKey));
498
+ }
499
+ continue;
500
+ }
501
+ if (durableAdmission.kind === "backoff") {
502
+ console.error(`hara flow: "${r.name}" retry deferred — backoff has ${Math.ceil(durableAdmission.retryAfterMs / 1_000)}s left`);
503
+ deferredRetry = true;
504
+ continue;
505
+ }
506
+ durableClaim = durableAdmission.claim;
507
+ }
508
+ const admission = claimFlowRun(r, m, platform, sourceRunKey, durableClaim?.retry);
509
+ if (!admission.claimed) {
510
+ await durableClaim?.release();
511
+ if (admission.exhausted) {
512
+ if (admission.alarm)
513
+ await reportFlowRetryExhausted(r);
514
+ }
515
+ else if (admission.retry) {
516
+ const wait = admission.retryAfterMs === undefined ? "capacity is full" : `backoff has ${Math.ceil(admission.retryAfterMs / 1_000)}s left`;
517
+ console.error(`hara flow: "${r.name}" retry deferred — ${wait}`);
518
+ deferredRetry = true;
519
+ }
520
+ else {
521
+ console.error(`hara flow: "${r.name}" rate/concurrency limit reached — trigger dropped`);
522
+ }
313
523
  continue;
314
524
  }
315
525
  const home = r.cwd ? (r.cwd === "~" ? homedir() : r.cwd.replace(/^~\//, homedir() + "/")) : undefined;
316
- void (async () => {
526
+ jobs.push((async () => {
527
+ let failed = false;
528
+ let durableSettled = false;
317
529
  try {
318
- const output = (await runAgent(buildFlowPrompt(r, m), home, r.schema, signal)).trim();
530
+ const effectKey = (stage, target = "", targetIndex = 0) => sourceKey
531
+ ? hashFlowKey("hara-flow-effect-v1", sourceKey, flowIdentity, stage, targetIndex, target)
532
+ : undefined;
533
+ let output = durableClaim?.output;
534
+ if (output === undefined) {
535
+ output = (await runAgent(buildFlowPrompt(r, m), home, r.schema, signal)).trim();
536
+ if (!signal?.aborted)
537
+ await durableClaim?.saveOutput(output);
538
+ }
319
539
  if (signal?.aborted)
320
540
  return;
321
541
  if (!output)
@@ -378,28 +598,36 @@ export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner,
378
598
  context: `派单 ${parsed.dispatch.agent}: ${parsed.dispatch.task.slice(0, 30)}`,
379
599
  notify: r.deliver,
380
600
  origin: `${platform}:${m.chatId}`,
601
+ sourceKey: effectKey("pending-org", `${platform}:${m.chatId}\0${parsed.dispatch.agent}`),
381
602
  });
382
603
  if (pendingDispatch)
383
604
  briefing += `\n\n[${pendingDispatch.id}] 拟派单:${parsed.dispatch.agent} ← ${parsed.dispatch.task}\n—— /approve ${pendingDispatch.id} 派出 · /reject ${pendingDispatch.id} 取消`;
384
605
  }
385
606
  if (draft && (route.needsApproval || route.replyInChat) && (!canAutoReply || route.needsApproval || wantsDispatch)) {
386
- const pendingSend = park({ kind: "send", target: `${platform}:${m.chatId}`, draft, context: (parsed.briefing || parsed.draft).slice(0, 40), notify: r.deliver });
607
+ const pendingTarget = `${platform}:${m.chatId}`;
608
+ const pendingSend = park({
609
+ kind: "send",
610
+ target: pendingTarget,
611
+ draft,
612
+ context: (parsed.briefing || parsed.draft).slice(0, 40),
613
+ notify: r.deliver,
614
+ sourceKey: effectKey("pending-send", pendingTarget),
615
+ });
387
616
  if (pendingSend) {
388
617
  briefing += `\n\n[${pendingSend.id}] 拟发到群:${draft}\n—— /approve ${pendingSend.id} 发出 · /edit ${pendingSend.id} <内容> · /reject ${pendingSend.id}`;
389
618
  }
390
619
  }
391
620
  else if (route.replyInChat && canAutoReply && reply) {
392
- if (!signal?.aborted)
393
- await reply(draft).catch(() => { });
621
+ if (!signal?.aborted) {
622
+ await runFlowTextEffect(effects?.receipts, effectKey("reply-in-chat", `${platform}:${m.chatId}`), draft, reply);
623
+ }
394
624
  console.error(`hara flow "${r.name}": answered in-chat (route)`);
395
625
  }
396
626
  if (route.notifyOwner || route.needsApproval || wantsDispatch || (route.replyInChat && !canAutoReply)) {
397
- for (const d of asArray(r.deliver)) {
627
+ for (const [targetIndex, d] of asArray(r.deliver).entries()) {
398
628
  if (signal?.aborted)
399
629
  return;
400
- const err = await deliverResult(d, briefing);
401
- if (err)
402
- console.error(`hara flow "${r.name}": deliver(${d}) failed — ${err}`);
630
+ await runFlowDeliveryEffect(effects?.receipts, effectKey("notify-owner", d, targetIndex), d, briefing, signal);
403
631
  }
404
632
  }
405
633
  else
@@ -409,8 +637,9 @@ export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner,
409
637
  // Auto-answer lane (replyOn): safe Q&A goes straight back to the origin chat — no owner round-trip.
410
638
  // Runs BEFORE the noise gate so an answered question needs no WeChat push at all.
411
639
  if (r.replyOn?.length && dispo && r.replyOn.includes(dispo) && parsed?.draft?.trim() && reply) {
412
- if (!signal?.aborted)
413
- await reply(parsed.draft.trim()).catch(() => { });
640
+ if (!signal?.aborted) {
641
+ await runFlowTextEffect(effects?.receipts, effectKey("reply-in-chat", `${platform}:${m.chatId}`), parsed.draft.trim(), reply);
642
+ }
414
643
  console.error(`hara flow "${r.name}": ${dispo} — answered in-chat (replyOn)`);
415
644
  if (!r.notifyOn?.length || !r.notifyOn.includes(dispo))
416
645
  return; // answered; only ALSO brief the owner if asked to
@@ -423,7 +652,15 @@ export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner,
423
652
  }
424
653
  // Drafted reply → park for approval; approved = posted back to the ORIGIN chat.
425
654
  if (parsed?.draft?.trim() && (dispo === "reply" || dispo === "handle")) {
426
- const pendingSend = park({ kind: "send", target: `${platform}:${m.chatId}`, draft: parsed.draft.trim(), context: (parsed.briefing || parsed.draft).slice(0, 40), notify: r.deliver });
655
+ const pendingTarget = `${platform}:${m.chatId}`;
656
+ const pendingSend = park({
657
+ kind: "send",
658
+ target: pendingTarget,
659
+ draft: parsed.draft.trim(),
660
+ context: (parsed.briefing || parsed.draft).slice(0, 40),
661
+ notify: r.deliver,
662
+ sourceKey: effectKey("pending-send", pendingTarget),
663
+ });
427
664
  if (pendingSend) {
428
665
  briefing += `\n\n[${pendingSend.id}] 拟发到群:${parsed.draft.trim()}\n—— /approve ${pendingSend.id} 发出 · /edit ${pendingSend.id} <内容> · /reject ${pendingSend.id}`;
429
666
  }
@@ -438,27 +675,65 @@ export async function dispatchFlows(m, platform, runAgent, reply, approvalOwner,
438
675
  context: `派单 ${parsed.dispatch.agent}: ${parsed.dispatch.task.slice(0, 30)}`,
439
676
  notify: r.deliver,
440
677
  origin: `${platform}:${m.chatId}`, // the asker's chat — an approved task's result goes back here
678
+ sourceKey: effectKey("pending-org", `${platform}:${m.chatId}\0${parsed.dispatch.agent}`),
441
679
  });
442
680
  if (pendingDispatch)
443
681
  briefing += `\n\n[${pendingDispatch.id}] 拟派单:${parsed.dispatch.agent} ← ${parsed.dispatch.task}\n—— /approve ${pendingDispatch.id} 派出 · /reject ${pendingDispatch.id} 取消`;
444
682
  }
445
- for (const d of asArray(r.deliver)) {
683
+ for (const [targetIndex, d] of asArray(r.deliver).entries()) {
446
684
  if (signal?.aborted)
447
685
  return;
448
- const err = await deliverResult(d, briefing);
449
- if (err)
450
- console.error(`hara flow "${r.name}": deliver(${d}) failed ${err}`);
686
+ await runFlowDeliveryEffect(effects?.receipts, effectKey("notify-owner", d, targetIndex), d, briefing, signal);
687
+ }
688
+ if (r.reply && reply && !signal?.aborted) {
689
+ await runFlowTextEffect(effects?.receipts, effectKey("reply-briefing", `${platform}:${m.chatId}`), briefing, reply);
451
690
  }
452
- if (r.reply && reply && !signal?.aborted)
453
- await reply(briefing).catch(() => { });
454
691
  }
455
692
  catch (e) {
693
+ failed = true;
456
694
  console.error(`hara flow "${r.name}": ${e instanceof Error ? e.message : String(e)}`);
695
+ if (durableClaim) {
696
+ if (signal?.aborted) {
697
+ await durableClaim.release();
698
+ durableSettled = true;
699
+ }
700
+ else {
701
+ const failure = await durableClaim.fail();
702
+ durableSettled = true;
703
+ if (failure.exhausted) {
704
+ if (failure.alarm)
705
+ await reportFlowRetryExhausted(r, () => durableClaim.markAlarmed());
706
+ return;
707
+ }
708
+ }
709
+ }
710
+ else {
711
+ markFlowRunFailed(sourceRunKey, Date.now());
712
+ }
713
+ throw e;
457
714
  }
458
715
  finally {
459
- activeFlowRuns = Math.max(0, activeFlowRuns - 1);
716
+ try {
717
+ if (durableClaim && !durableSettled) {
718
+ if (signal?.aborted)
719
+ await durableClaim.release();
720
+ else if (!failed)
721
+ await durableClaim.complete();
722
+ }
723
+ if (!durableClaim && !failed && !signal?.aborted)
724
+ forgetFlowSource(sourceRunKey);
725
+ }
726
+ finally {
727
+ activeFlowRuns = Math.max(0, activeFlowRuns - 1);
728
+ }
460
729
  }
461
- })();
730
+ })());
462
731
  }
732
+ const outcomes = await Promise.allSettled(jobs);
733
+ const failed = outcomes.find((outcome) => outcome.status === "rejected");
734
+ if (failed)
735
+ throw failed.reason;
736
+ if (deferredRetry)
737
+ throw new Error("a previously admitted flow retry is deferred by backoff or capacity; retry the source event");
463
738
  return true;
464
739
  }
@@ -17,6 +17,10 @@ const SNAPSHOT_SUFFIX = new RegExp(`-${UUID}(?=\\.|$)`, "i");
17
17
  // a later path replacement is never mistaken for the consumed snapshot.
18
18
  const consumedSnapshotIdentities = new Map();
19
19
  const outboundQueueLocks = new Map();
20
+ function throwIfOutboundCancelled(signal) {
21
+ if (signal?.aborted)
22
+ throw new Error("gateway file queue cancelled before commit");
23
+ }
20
24
  export function outboundSnapshotDir(outbox) {
21
25
  return `${resolve(outbox)}.files`;
22
26
  }
@@ -72,7 +76,8 @@ function existingBatchUsage(dir) {
72
76
  }
73
77
  return { bytes, files };
74
78
  }
75
- async function appendOutbox(outbox, snapshot) {
79
+ async function appendOutbox(outbox, snapshot, signal) {
80
+ throwIfOutboundCancelled(signal);
76
81
  const path = resolve(outbox);
77
82
  let before;
78
83
  try {
@@ -99,6 +104,9 @@ async function appendOutbox(outbox, snapshot) {
99
104
  || !ownedByProcess(path))
100
105
  throw new Error(`unsafe gateway outbox: ${outbox}`);
101
106
  await handle.chmod(0o600);
107
+ // This append makes the snapshot visible to the adapter. Re-check after all descriptor validation and
108
+ // immediately before that irreversible queue mutation.
109
+ throwIfOutboundCancelled(signal);
102
110
  await handle.writeFile(snapshot + "\n", "utf8");
103
111
  await handle.sync();
104
112
  }
@@ -107,7 +115,8 @@ async function appendOutbox(outbox, snapshot) {
107
115
  }
108
116
  }
109
117
  /** Copy a verified source fd to a private immutable snapshot, then append only that snapshot to the queue. */
110
- async function queueOutboundSnapshotLocked(sourcePath, outbox) {
118
+ async function queueOutboundSnapshotLocked(sourcePath, outbox, signal) {
119
+ throwIfOutboundCancelled(signal);
111
120
  const source = resolve(sourcePath);
112
121
  const verified = await openVerifiedRegularFileNoFollow(source, {
113
122
  action: "send",
@@ -138,6 +147,7 @@ async function queueOutboundSnapshotLocked(sourcePath, outbox) {
138
147
  const buffer = Buffer.allocUnsafe(64 * 1024);
139
148
  let position = 0;
140
149
  while (position < verified.info.size) {
150
+ throwIfOutboundCancelled(signal);
141
151
  const want = Math.min(buffer.length, verified.info.size - position);
142
152
  const { bytesRead } = await verified.handle.read(buffer, 0, want, position);
143
153
  if (bytesRead <= 0)
@@ -167,9 +177,12 @@ async function queueOutboundSnapshotLocked(sourcePath, outbox) {
167
177
  await destination.chmod(0o600);
168
178
  await destination.close();
169
179
  destination = undefined;
180
+ // Publishing the private snapshot is reversible until appendOutbox succeeds; cancellation after this
181
+ // check is caught below and removes the published inode before returning.
182
+ throwIfOutboundCancelled(signal);
170
183
  renameSync(temporary, snapshot);
171
184
  published = true;
172
- await appendOutbox(outbox, snapshot);
185
+ await appendOutbox(outbox, snapshot, signal);
173
186
  return snapshot;
174
187
  }
175
188
  catch (error) {
@@ -193,16 +206,17 @@ async function queueOutboundSnapshotLocked(sourcePath, outbox) {
193
206
  }
194
207
  }
195
208
  /** Serialize admissions for one outbox so parallel send_file calls cannot race past the aggregate budget. */
196
- export async function queueOutboundSnapshot(sourcePath, outbox) {
209
+ export async function queueOutboundSnapshot(sourcePath, outbox, signal) {
197
210
  const key = resolve(outbox);
198
211
  const previous = outboundQueueLocks.get(key) ?? Promise.resolve();
199
212
  let release;
200
213
  const gate = new Promise((resolveGate) => { release = resolveGate; });
201
214
  const current = previous.then(() => gate, () => gate);
202
215
  outboundQueueLocks.set(key, current);
203
- await previous.catch(() => { });
204
216
  try {
205
- return await queueOutboundSnapshotLocked(sourcePath, outbox);
217
+ await previous.catch(() => { });
218
+ throwIfOutboundCancelled(signal);
219
+ return await queueOutboundSnapshotLocked(sourcePath, outbox, signal);
206
220
  }
207
221
  finally {
208
222
  release();