@nanhara/hara 0.122.3 → 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.
- package/CHANGELOG.md +98 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +28 -14
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/gateway/flows.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
526
|
+
jobs.push((async () => {
|
|
527
|
+
let failed = false;
|
|
528
|
+
let durableSettled = false;
|
|
317
529
|
try {
|
|
318
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
+
await previous.catch(() => { });
|
|
218
|
+
throwIfOutboundCancelled(signal);
|
|
219
|
+
return await queueOutboundSnapshotLocked(sourcePath, outbox, signal);
|
|
206
220
|
}
|
|
207
221
|
finally {
|
|
208
222
|
release();
|