larkin 0.4.6 → 0.4.8
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/dist/app/lark-cli.mjs
CHANGED
|
@@ -61,7 +61,7 @@ function hasCanonicalUnprotectedCommandPath(argv) {
|
|
|
61
61
|
return false;
|
|
62
62
|
if (service !== "im")
|
|
63
63
|
return service !== "api" && service !== "larkin-draft";
|
|
64
|
-
if (command === "+messages-send" || command === "+messages-reply" || command === "api") {
|
|
64
|
+
if (command === "+messages-send" || command === "+messages-reply" || command === "+messages-urgent-app" || command === "api") {
|
|
65
65
|
return false;
|
|
66
66
|
}
|
|
67
67
|
if (command.startsWith("+"))
|
|
@@ -200,6 +200,14 @@ export function classifyLarkCliCommand(argv) {
|
|
|
200
200
|
return uniqueProtectedOperation(protectedPaths, "reply")
|
|
201
201
|
? (parsed.commandArgv.includes("--dry-run") ? { kind: "passthrough" } : { kind: "guarded", operation: "reply" })
|
|
202
202
|
: noncanonicalProtectedDecision();
|
|
203
|
+
if (exactPath(parsed.commandArgv, ["im", "+messages-urgent-app"])) {
|
|
204
|
+
return { kind: "denied", reason: "不要使用合成入口 +messages-urgent-app;请使用官方 im messages urgent_app" };
|
|
205
|
+
}
|
|
206
|
+
if (exactPath(parsed.commandArgv, ["im", "messages", "urgent_app"])) {
|
|
207
|
+
return uniqueProtectedOperation(protectedPaths, "raw-urgent_app")
|
|
208
|
+
? (parsed.commandArgv.includes("--dry-run") || parsed.help ? { kind: "passthrough" } : { kind: "guarded", operation: "urgent-app" })
|
|
209
|
+
: noncanonicalProtectedDecision();
|
|
210
|
+
}
|
|
203
211
|
if (exactPath(parsed.commandArgv, ["im", "messages", "patch"]) || exactPath(parsed.commandArgv, ["im", "messages", "update"])) {
|
|
204
212
|
const expected = parsed.commandArgv[2] === "patch" ? "card-patch" : "card-update";
|
|
205
213
|
return uniqueProtectedOperation(protectedPaths, expected)
|
|
@@ -212,7 +220,7 @@ export function classifyLarkCliCommand(argv) {
|
|
|
212
220
|
? { kind: "denied", reason: "该原始 IM 写入口会旁路 target freshness;请使用 +messages-send/+messages-reply" }
|
|
213
221
|
: noncanonicalProtectedDecision();
|
|
214
222
|
}
|
|
215
|
-
if (["forward", "merge_forward", "delete", "
|
|
223
|
+
if (["forward", "merge_forward", "delete", "urgent_phone", "urgent_sms"]
|
|
216
224
|
.some((operation) => exactPath(parsed.commandArgv, ["im", "messages", operation]))) {
|
|
217
225
|
const expected = `raw-${parsed.commandArgv[2]}`;
|
|
218
226
|
return uniqueProtectedOperation(protectedPaths, expected)
|
|
@@ -436,8 +444,9 @@ function guardedTarget(decision, argv, store) {
|
|
|
436
444
|
if (decision.operation === "send") {
|
|
437
445
|
const chatId = policyFlagValue(argv, "--chat-id");
|
|
438
446
|
const userId = policyFlagValue(argv, "--user-id");
|
|
439
|
-
if (!chatId || userId)
|
|
447
|
+
if (!chatId || userId) {
|
|
440
448
|
throw new Error("Runtime +messages-send 必须只使用 Inbox 已确认的 --chat-id;--user-id 无法建立 freshness target");
|
|
449
|
+
}
|
|
441
450
|
return feishuImTarget(`chat:${chatId}`);
|
|
442
451
|
}
|
|
443
452
|
const messageId = policyFlagValue(argv, "--message-id");
|
|
@@ -448,7 +457,62 @@ function guardedTarget(decision, argv, store) {
|
|
|
448
457
|
throw new Error(`无法从 Inbox 状态确定 ${messageId} 的 target;先 poll 对应消息,禁止旁路 freshness`);
|
|
449
458
|
return feishuImTarget(target);
|
|
450
459
|
}
|
|
451
|
-
function
|
|
460
|
+
function uniqueRawFlagValue(argv, flag) {
|
|
461
|
+
const nativeArgv = nativeArgvBeforeBoundary(argv);
|
|
462
|
+
const values = [];
|
|
463
|
+
for (let index = 0; index < nativeArgv.length; index += 1) {
|
|
464
|
+
const argument = nativeArgv[index];
|
|
465
|
+
if (argument === flag) {
|
|
466
|
+
values.push(nativeArgv[index + 1] ?? "");
|
|
467
|
+
index += 1;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (argument.startsWith(`${flag}=`))
|
|
471
|
+
values.push(argument.slice(flag.length + 1));
|
|
472
|
+
}
|
|
473
|
+
if (values.length > 1)
|
|
474
|
+
throw new Error(`Runtime im messages urgent_app 不允许重复 ${flag};官方 CLI 会采用最后一次赋值`);
|
|
475
|
+
return values[0] || null;
|
|
476
|
+
}
|
|
477
|
+
function resolveUrgentAppTarget(argv, env, io, dependencies) {
|
|
478
|
+
const messageId = policyFlagValue(argv, "--message-id");
|
|
479
|
+
if (!messageId || !/^om_/.test(messageId)) {
|
|
480
|
+
throw new Error("Runtime im messages urgent_app 只接受真实 Feishu om_ message_id");
|
|
481
|
+
}
|
|
482
|
+
const result = callNative([
|
|
483
|
+
"im", "+messages-mget",
|
|
484
|
+
"--message-ids", messageId,
|
|
485
|
+
"--no-reactions",
|
|
486
|
+
"--json",
|
|
487
|
+
"--as", "bot",
|
|
488
|
+
], env, io, dependencies);
|
|
489
|
+
if (result.error)
|
|
490
|
+
throw new Error(`urgent_app message lookup failed: ${result.error.message}`);
|
|
491
|
+
if (result.status !== 0)
|
|
492
|
+
throw new Error(`urgent_app message lookup exited ${result.status ?? "without status"}: ${result.stderr || "no details"}`);
|
|
493
|
+
let value;
|
|
494
|
+
try {
|
|
495
|
+
value = JSON.parse(result.stdout || "");
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
throw new Error("urgent_app message lookup returned non-JSON output");
|
|
499
|
+
}
|
|
500
|
+
const root = value;
|
|
501
|
+
if (!root || root.ok !== true || !root.data)
|
|
502
|
+
throw new Error("urgent_app message lookup returned an unsuccessful payload");
|
|
503
|
+
if (root.identity !== "bot")
|
|
504
|
+
throw new Error("urgent_app message lookup did not confirm Bot identity");
|
|
505
|
+
const rows = root.data.messages;
|
|
506
|
+
if (!Array.isArray(rows))
|
|
507
|
+
throw new Error("urgent_app message lookup omitted messages");
|
|
508
|
+
const message = rows.find((row) => row && typeof row === "object" && !Array.isArray(row)
|
|
509
|
+
&& row.message_id === messageId);
|
|
510
|
+
if (!message || typeof message.chat_id !== "string" || !message.chat_id) {
|
|
511
|
+
throw new Error(`无法从官方 +messages-mget 确认 ${messageId} 的 chat;禁止旁路加急`);
|
|
512
|
+
}
|
|
513
|
+
return { target: feishuImTarget(`chat:${message.chat_id}`), message };
|
|
514
|
+
}
|
|
515
|
+
function botArgv(argv, intentId, decision) {
|
|
452
516
|
const next = [...argv];
|
|
453
517
|
const parsed = parsePolicyArgv(next);
|
|
454
518
|
const boundary = next.indexOf("--");
|
|
@@ -456,10 +520,145 @@ function botArgv(argv, intentId) {
|
|
|
456
520
|
const injected = [];
|
|
457
521
|
if (!parsed.flags.has("--as"))
|
|
458
522
|
injected.push("--as", "bot");
|
|
459
|
-
if (!parsed.flags.has("--idempotency-key"))
|
|
523
|
+
if (decision?.operation !== "urgent-app" && !parsed.flags.has("--idempotency-key"))
|
|
460
524
|
injected.push("--idempotency-key", intentId);
|
|
461
525
|
next.splice(insertion, 0, ...injected);
|
|
462
|
-
|
|
526
|
+
if (decision?.operation !== "urgent-app")
|
|
527
|
+
return next;
|
|
528
|
+
const rewritten = [];
|
|
529
|
+
for (let index = 0; index < next.length; index += 1) {
|
|
530
|
+
const argument = next[index];
|
|
531
|
+
if (argument === "--idempotency-key") {
|
|
532
|
+
index += 1;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (argument.startsWith("--idempotency-key="))
|
|
536
|
+
continue;
|
|
537
|
+
rewritten.push(argument);
|
|
538
|
+
}
|
|
539
|
+
return rewritten;
|
|
540
|
+
}
|
|
541
|
+
function parseJsonObject(value, label) {
|
|
542
|
+
let parsed;
|
|
543
|
+
try {
|
|
544
|
+
parsed = JSON.parse(value);
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
throw new Error(`${label} 必须是 JSON 对象`);
|
|
548
|
+
}
|
|
549
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
550
|
+
throw new Error(`${label} 必须是 JSON 对象`);
|
|
551
|
+
return parsed;
|
|
552
|
+
}
|
|
553
|
+
function senderRecord(message) {
|
|
554
|
+
const sender = message.sender;
|
|
555
|
+
return sender && typeof sender === "object" && !Array.isArray(sender) ? sender : null;
|
|
556
|
+
}
|
|
557
|
+
function isOwnBotMessage(message, feishuAppId) {
|
|
558
|
+
const sender = senderRecord(message);
|
|
559
|
+
if (!sender)
|
|
560
|
+
return false;
|
|
561
|
+
const senderType = sender.sender_type;
|
|
562
|
+
const senderId = sender.id ?? sender.sender_id;
|
|
563
|
+
return senderType === "app" && senderId === feishuAppId;
|
|
564
|
+
}
|
|
565
|
+
function assertUrgentAppPreconditions(argv, message, target, feishuAppId) {
|
|
566
|
+
const messageId = policyFlagValue(argv, "--message-id");
|
|
567
|
+
if (!messageId || !/^om_/.test(messageId) || message.message_id !== messageId) {
|
|
568
|
+
throw new Error("Runtime im messages urgent_app 只接受真实 Feishu om_ message_id");
|
|
569
|
+
}
|
|
570
|
+
const userIdType = uniqueRawFlagValue(argv, "--user-id-type");
|
|
571
|
+
if (userIdType !== "open_id")
|
|
572
|
+
throw new Error("Runtime im messages urgent_app 必须使用 --user-id-type open_id");
|
|
573
|
+
const data = uniqueRawFlagValue(argv, "--data");
|
|
574
|
+
if (!data)
|
|
575
|
+
throw new Error("Runtime im messages urgent_app 缺少 --data");
|
|
576
|
+
const body = parseJsonObject(data, "--data");
|
|
577
|
+
const userIds = body.user_id_list;
|
|
578
|
+
if (!Array.isArray(userIds) || userIds.length === 0 || userIds.some((value) => typeof value !== "string" || !value.startsWith("ou_"))) {
|
|
579
|
+
throw new Error("Runtime im messages urgent_app 的 user_id_list 必须是非空 open_id 列表");
|
|
580
|
+
}
|
|
581
|
+
if (target.resourceKind === "chat" && message.chat_id && message.chat_id !== target.resourceId) {
|
|
582
|
+
throw new Error("加急目标消息不属于当前 chat freshness target");
|
|
583
|
+
}
|
|
584
|
+
if (!isOwnBotMessage(message, feishuAppId))
|
|
585
|
+
throw new Error("Runtime im messages urgent_app 只能加急当前 Bot 自己发出的消息");
|
|
586
|
+
return userIds.filter((value) => typeof value === "string");
|
|
587
|
+
}
|
|
588
|
+
function parseChatMemberOpenIds(result) {
|
|
589
|
+
if (result.error)
|
|
590
|
+
throw new Error(`urgent-app member probe failed: ${result.error.message}`);
|
|
591
|
+
if (result.status !== 0)
|
|
592
|
+
throw new Error(`urgent-app member probe exited ${result.status ?? "without status"}: ${result.stderr || "no details"}`);
|
|
593
|
+
let value;
|
|
594
|
+
try {
|
|
595
|
+
value = JSON.parse(result.stdout || "");
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
throw new Error("urgent-app member probe returned non-JSON output");
|
|
599
|
+
}
|
|
600
|
+
const root = value;
|
|
601
|
+
if (!root || root.ok !== true || !root.data)
|
|
602
|
+
throw new Error("urgent-app member probe returned an unsuccessful payload");
|
|
603
|
+
if (root.identity !== "bot")
|
|
604
|
+
throw new Error("urgent-app member probe did not confirm Bot identity");
|
|
605
|
+
if (root.data.has_more === true) {
|
|
606
|
+
throw new Error("urgent-app member probe is incomplete; refusing to guess chat membership");
|
|
607
|
+
}
|
|
608
|
+
if (Array.isArray(root.data.truncations) && root.data.truncations.length > 0) {
|
|
609
|
+
throw new Error("urgent-app member probe was truncated; refusing to guess chat membership");
|
|
610
|
+
}
|
|
611
|
+
const users = root.data.users;
|
|
612
|
+
if (!Array.isArray(users))
|
|
613
|
+
throw new Error("urgent-app member probe omitted users");
|
|
614
|
+
const ids = [];
|
|
615
|
+
for (const user of users) {
|
|
616
|
+
if (!user || typeof user !== "object" || Array.isArray(user))
|
|
617
|
+
continue;
|
|
618
|
+
const memberId = user.member_id;
|
|
619
|
+
if (typeof memberId === "string" && memberId.startsWith("ou_"))
|
|
620
|
+
ids.push(memberId);
|
|
621
|
+
}
|
|
622
|
+
return ids;
|
|
623
|
+
}
|
|
624
|
+
function assertUrgentAppMembers(argv, target, userIds, env, io, dependencies) {
|
|
625
|
+
if (target.resourceKind !== "chat" || !target.resourceId) {
|
|
626
|
+
throw new Error("Runtime im messages urgent_app 只能对照已确认的 chat freshness target 校验成员");
|
|
627
|
+
}
|
|
628
|
+
const members = new Set(parseChatMemberOpenIds(callNative([
|
|
629
|
+
"im", "+chat-members-list",
|
|
630
|
+
"--chat-id", target.resourceId,
|
|
631
|
+
"--member-id-type", "open_id",
|
|
632
|
+
"--member-types", "user",
|
|
633
|
+
"--page-all",
|
|
634
|
+
"--page-limit", "0",
|
|
635
|
+
"--json",
|
|
636
|
+
"--as", "bot",
|
|
637
|
+
], env, io, dependencies)));
|
|
638
|
+
const unknown = userIds.filter((userId) => !members.has(userId));
|
|
639
|
+
if (unknown.length > 0) {
|
|
640
|
+
throw new Error(`Runtime im messages urgent_app 拒绝非本会话成员: ${unknown.join(",")}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function assertUrgentAppNativeAccepted(result) {
|
|
644
|
+
if (result.error || result.status !== 0)
|
|
645
|
+
return;
|
|
646
|
+
let value;
|
|
647
|
+
try {
|
|
648
|
+
value = JSON.parse(result.stdout || "");
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
654
|
+
return;
|
|
655
|
+
const data = value.data;
|
|
656
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
657
|
+
return;
|
|
658
|
+
const invalid = data.invalid_user_id_list;
|
|
659
|
+
if (!Array.isArray(invalid) || invalid.length === 0)
|
|
660
|
+
return;
|
|
661
|
+
throw new Error(`Runtime im messages urgent_app 收到 invalid_user_id_list,已 fail-closed: ${invalid.join(",")}`);
|
|
463
662
|
}
|
|
464
663
|
function probeArgv(target) {
|
|
465
664
|
if (target.provider !== "feishu.im")
|
|
@@ -840,7 +1039,10 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
840
1039
|
if (decision.kind === "passthrough")
|
|
841
1040
|
return passthroughWithObservation(effectiveArgv, privateEnv, io, nativeDependencies, store);
|
|
842
1041
|
try {
|
|
843
|
-
const
|
|
1042
|
+
const urgent = decision.operation === "urgent-app"
|
|
1043
|
+
? resolveUrgentAppTarget(effectiveArgv, privateEnv, io, nativeDependencies)
|
|
1044
|
+
: null;
|
|
1045
|
+
const target = urgent?.target ?? guardedTarget(decision, effectiveArgv, store);
|
|
844
1046
|
const targetKey = serializeFeishuImTarget(target);
|
|
845
1047
|
const generation = freshnessGeneration(privateEnv);
|
|
846
1048
|
const seen = store.readFreshnessCursor(targetKey, generation);
|
|
@@ -858,8 +1060,27 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
858
1060
|
store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, generation);
|
|
859
1061
|
return 3;
|
|
860
1062
|
}
|
|
1063
|
+
if (decision.operation === "urgent-app") {
|
|
1064
|
+
const userIds = assertUrgentAppPreconditions(effectiveArgv, urgent.message, target, agent.feishuAppId);
|
|
1065
|
+
assertUrgentAppMembers(effectiveArgv, target, userIds, privateEnv, io, nativeDependencies);
|
|
1066
|
+
}
|
|
861
1067
|
const intentKey = policyFlagValue(effectiveArgv, "--idempotency-key") ?? intentId(targetKey, effectiveArgv);
|
|
862
|
-
const write = callNative(botArgv(effectiveArgv, intentKey), privateEnv, io, nativeDependencies);
|
|
1068
|
+
const write = callNative(botArgv(effectiveArgv, intentKey, decision), privateEnv, io, nativeDependencies);
|
|
1069
|
+
if (decision.operation === "urgent-app") {
|
|
1070
|
+
try {
|
|
1071
|
+
assertUrgentAppNativeAccepted(write);
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
io.stderr(`${JSON.stringify({
|
|
1075
|
+
ok: false,
|
|
1076
|
+
identity: "bot",
|
|
1077
|
+
committed: true,
|
|
1078
|
+
error: { type: "validation", message: error instanceof Error ? error.message : String(error) },
|
|
1079
|
+
})}\n`);
|
|
1080
|
+
return 2;
|
|
1081
|
+
}
|
|
1082
|
+
return emitNativeResult(write, io);
|
|
1083
|
+
}
|
|
863
1084
|
const writeMessage = writeResponseMessage(write);
|
|
864
1085
|
const duplicate = !write.error && write.status === 0 && writeMessage
|
|
865
1086
|
? recordImWriteMemo(store, intentKey, writeMessage.message_id) : false;
|
|
@@ -1165,6 +1165,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
1165
1165
|
}, 80);
|
|
1166
1166
|
hostState.updateStatus(agent, { droughtReconnectAt, droughtReconnectAbandonedAt: null });
|
|
1167
1167
|
reconnectFns.get(agent.agentId)?.();
|
|
1168
|
+
void runtimeHost.scanPendingInboxUpdates?.(agent.agentId)?.catch((error) => {
|
|
1169
|
+
log(`drought reconnect inbox scan failed agent=${agent.name}: ${errorMessage(error)}`);
|
|
1170
|
+
});
|
|
1168
1171
|
}).catch((error) => {
|
|
1169
1172
|
maintenance.inFlight = false;
|
|
1170
1173
|
if (shuttingDown || fataling)
|
|
@@ -1125,6 +1125,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
1125
1125
|
}, 80);
|
|
1126
1126
|
hostState.updateStatus(agent, { droughtReconnectAt, droughtReconnectAbandonedAt: null });
|
|
1127
1127
|
reconnectFns.get(agent.agentId)?.();
|
|
1128
|
+
void runtimeHost.scanPendingInboxUpdates?.(agent.agentId)?.catch((error) => {
|
|
1129
|
+
log(`drought reconnect inbox scan failed agent=${agent.name}: ${errorMessage(error)}`);
|
|
1130
|
+
});
|
|
1128
1131
|
}).catch((error) => {
|
|
1129
1132
|
maintenance.inFlight = false;
|
|
1130
1133
|
if (shuttingDown || fataling)
|
|
@@ -120,8 +120,20 @@ function boundedRecords(agent) {
|
|
|
120
120
|
const bounded = [...active, ...terminal].slice(-MAX_DELIVERIES);
|
|
121
121
|
agent.records = new Map(bounded.map((record) => [record.deliveryId, record]));
|
|
122
122
|
agent.byMessage = new Map(bounded.map((record) => [record.messageId, record.deliveryId]));
|
|
123
|
+
prunePromotedInboxUpdateIds(agent);
|
|
123
124
|
return bounded;
|
|
124
125
|
}
|
|
126
|
+
function prunePromotedInboxUpdateIds(agent) {
|
|
127
|
+
if (!agent.promotedInboxUpdateIds?.size)
|
|
128
|
+
return;
|
|
129
|
+
for (const id of agent.promotedInboxUpdateIds) {
|
|
130
|
+
if (!agent.records.has(id))
|
|
131
|
+
agent.promotedInboxUpdateIds.delete(id);
|
|
132
|
+
}
|
|
133
|
+
if (agent.promotedInboxUpdateIds.size <= MAX_DELIVERIES)
|
|
134
|
+
return;
|
|
135
|
+
agent.promotedInboxUpdateIds = new Set([...agent.promotedInboxUpdateIds].slice(-MAX_DELIVERIES));
|
|
136
|
+
}
|
|
125
137
|
function createRuntimeHost(options) {
|
|
126
138
|
const managed = new Map();
|
|
127
139
|
const listeners = new Set();
|
|
@@ -294,6 +306,20 @@ function createRuntimeHost(options) {
|
|
|
294
306
|
}
|
|
295
307
|
};
|
|
296
308
|
const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
|
|
309
|
+
const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
|
|
310
|
+
const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
|
|
311
|
+
const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
|
|
312
|
+
const acceptedInboxStillPending = (agent, record) => {
|
|
313
|
+
if (!agent.stateStore?.readNdjson)
|
|
314
|
+
return true;
|
|
315
|
+
try {
|
|
316
|
+
return agent.stateStore.readNdjson("inbox")
|
|
317
|
+
.some((row) => row?.message_id === record.messageId);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
297
323
|
/**
|
|
298
324
|
* Runtime acceptance acknowledges only the notification. At the safe turn
|
|
299
325
|
* boundary, canonical Inbox presence decides whether that same delivery
|
|
@@ -494,6 +520,7 @@ function createRuntimeHost(options) {
|
|
|
494
520
|
agent.retryAfterSubmit = false;
|
|
495
521
|
queueMicrotask(() => { void retryPending(agent); });
|
|
496
522
|
}
|
|
523
|
+
queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
|
|
497
524
|
}
|
|
498
525
|
};
|
|
499
526
|
const retryPending = async (agent) => {
|
|
@@ -533,6 +560,45 @@ function createRuntimeHost(options) {
|
|
|
533
560
|
agent.retryPendingInFlight = null;
|
|
534
561
|
}
|
|
535
562
|
};
|
|
563
|
+
/**
|
|
564
|
+
* After busy/submitting clears, accepted inbox_update notices have no later
|
|
565
|
+
* turn boundary. Promote each delivery id at most once to a wake.
|
|
566
|
+
*/
|
|
567
|
+
const scanAndPromoteAcceptedInboxUpdates = async (agent) => {
|
|
568
|
+
if (!agentIsIdleForInboxScan(agent))
|
|
569
|
+
return;
|
|
570
|
+
if (agent.compactionMachines.size > 0 || agent.compactionRecoveryInFlight.size > 0)
|
|
571
|
+
return;
|
|
572
|
+
const candidates = [...agent.records.values()].filter((record) => record.status === "accepted"
|
|
573
|
+
&& isInboxUpdateKind(record.input?.kind) && !agent.promotedInboxUpdateIds.has(record.deliveryId));
|
|
574
|
+
if (!candidates.length)
|
|
575
|
+
return;
|
|
576
|
+
reconcileExternalConsumption(agent);
|
|
577
|
+
let promoted = 0;
|
|
578
|
+
for (const record of candidates) {
|
|
579
|
+
const current = agent.records.get(record.deliveryId);
|
|
580
|
+
if (current?.status !== "accepted")
|
|
581
|
+
continue;
|
|
582
|
+
if (!acceptedInboxStillPending(agent, current)) {
|
|
583
|
+
reconcileAbsentCanonical(agent, current);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
agent.promotedInboxUpdateIds.add(current.deliveryId);
|
|
587
|
+
current.reason = INBOX_UPDATE_PROMOTE_REASON;
|
|
588
|
+
current.retryable = true;
|
|
589
|
+
const finalRecord = setRecord(agent, current, "pending");
|
|
590
|
+
if (finalRecord.status === "consumed")
|
|
591
|
+
continue;
|
|
592
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: finalRecord.deliveryId,
|
|
593
|
+
messageId: finalRecord.messageId, status: "deferred", reason: INBOX_UPDATE_PROMOTE_REASON });
|
|
594
|
+
promoted += 1;
|
|
595
|
+
}
|
|
596
|
+
if (!promoted)
|
|
597
|
+
return;
|
|
598
|
+
if (agent.retryPendingInFlight)
|
|
599
|
+
agent.retryAfterSubmit = true;
|
|
600
|
+
await retryPending(agent);
|
|
601
|
+
};
|
|
536
602
|
const scheduleRecreate = (agent, reason) => {
|
|
537
603
|
if (agent.stopped || agent.retryTimer || agent.starting || agent.session)
|
|
538
604
|
return;
|
|
@@ -870,6 +936,7 @@ function createRuntimeHost(options) {
|
|
|
870
936
|
if (outcome !== "failed")
|
|
871
937
|
return retryPending(agent);
|
|
872
938
|
});
|
|
939
|
+
queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
|
|
873
940
|
}
|
|
874
941
|
else if (event.type === "activity") {
|
|
875
942
|
if (agent.turnInProgress && event.activity !== "internal")
|
|
@@ -1230,6 +1297,14 @@ function createRuntimeHost(options) {
|
|
|
1230
1297
|
};
|
|
1231
1298
|
},
|
|
1232
1299
|
isBusy(agentId) { const agent = managed.get(agentId); return Boolean(agent?.busy || agent?.submitting || agent?.starting); },
|
|
1300
|
+
async scanPendingInboxUpdates(agentId) {
|
|
1301
|
+
const agents = agentId ? [managed.get(agentId)] : [...managed.values()];
|
|
1302
|
+
for (const agent of agents) {
|
|
1303
|
+
if (!agent || agent.stopped)
|
|
1304
|
+
continue;
|
|
1305
|
+
await scanAndPromoteAcceptedInboxUpdates(agent);
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1233
1308
|
async resetSession(agentId) {
|
|
1234
1309
|
const agent = managed.get(agentId);
|
|
1235
1310
|
if (!agent || agent.stopped)
|
|
@@ -1546,7 +1621,8 @@ function createRuntimeHost(options) {
|
|
|
1546
1621
|
compactionMachines: new Map(), compactionRecoveryInFlight: new Set(), piOverflowCompactionFailed: new Set(),
|
|
1547
1622
|
piProactiveCompaction: null, piProactiveCompactionGeneration: null, piProactiveCompactionSession: null,
|
|
1548
1623
|
piProactiveCompactionFailedGeneration: null, readiness: null,
|
|
1549
|
-
turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false
|
|
1624
|
+
turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false,
|
|
1625
|
+
promotedInboxUpdateIds: new Set() };
|
|
1550
1626
|
const startupConsumed = [];
|
|
1551
1627
|
const startupQuarantined = [];
|
|
1552
1628
|
for (const record of agent.records.values()) {
|
|
@@ -1648,6 +1724,7 @@ function createRuntimeHost(options) {
|
|
|
1648
1724
|
: { inputId: existing.deliveryId, deliveryId: existing.deliveryId, kind: "wake", text: "", attempt: priorAttempt + 1 };
|
|
1649
1725
|
delete existing.reason;
|
|
1650
1726
|
delete existing.retryable;
|
|
1727
|
+
agent.promotedInboxUpdateIds.delete(existing.deliveryId);
|
|
1651
1728
|
setRecord(agent, existing, "pending");
|
|
1652
1729
|
const receipt = await (telemetry?.phase(messageId, "runtime.deliver", api_1.SpanKind.PRODUCER, () => submit(agent, existing, agent.busy || agent.submitting)) ?? submit(agent, existing, agent.busy || agent.submitting));
|
|
1653
1730
|
telemetry?.delivery(agentId, messageId, receipt.status);
|
|
@@ -111,8 +111,20 @@ function boundedRecords(agent) {
|
|
|
111
111
|
const bounded = [...active, ...terminal].slice(-MAX_DELIVERIES);
|
|
112
112
|
agent.records = new Map(bounded.map((record) => [record.deliveryId, record]));
|
|
113
113
|
agent.byMessage = new Map(bounded.map((record) => [record.messageId, record.deliveryId]));
|
|
114
|
+
prunePromotedInboxUpdateIds(agent);
|
|
114
115
|
return bounded;
|
|
115
116
|
}
|
|
117
|
+
function prunePromotedInboxUpdateIds(agent) {
|
|
118
|
+
if (!agent.promotedInboxUpdateIds?.size)
|
|
119
|
+
return;
|
|
120
|
+
for (const id of agent.promotedInboxUpdateIds) {
|
|
121
|
+
if (!agent.records.has(id))
|
|
122
|
+
agent.promotedInboxUpdateIds.delete(id);
|
|
123
|
+
}
|
|
124
|
+
if (agent.promotedInboxUpdateIds.size <= MAX_DELIVERIES)
|
|
125
|
+
return;
|
|
126
|
+
agent.promotedInboxUpdateIds = new Set([...agent.promotedInboxUpdateIds].slice(-MAX_DELIVERIES));
|
|
127
|
+
}
|
|
116
128
|
export function createRuntimeHost(options) {
|
|
117
129
|
const managed = new Map();
|
|
118
130
|
const listeners = new Set();
|
|
@@ -285,6 +297,20 @@ export function createRuntimeHost(options) {
|
|
|
285
297
|
}
|
|
286
298
|
};
|
|
287
299
|
const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
|
|
300
|
+
const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
|
|
301
|
+
const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
|
|
302
|
+
const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
|
|
303
|
+
const acceptedInboxStillPending = (agent, record) => {
|
|
304
|
+
if (!agent.stateStore?.readNdjson)
|
|
305
|
+
return true;
|
|
306
|
+
try {
|
|
307
|
+
return agent.stateStore.readNdjson("inbox")
|
|
308
|
+
.some((row) => row?.message_id === record.messageId);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
288
314
|
/**
|
|
289
315
|
* Runtime acceptance acknowledges only the notification. At the safe turn
|
|
290
316
|
* boundary, canonical Inbox presence decides whether that same delivery
|
|
@@ -485,6 +511,7 @@ export function createRuntimeHost(options) {
|
|
|
485
511
|
agent.retryAfterSubmit = false;
|
|
486
512
|
queueMicrotask(() => { void retryPending(agent); });
|
|
487
513
|
}
|
|
514
|
+
queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
|
|
488
515
|
}
|
|
489
516
|
};
|
|
490
517
|
const retryPending = async (agent) => {
|
|
@@ -524,6 +551,45 @@ export function createRuntimeHost(options) {
|
|
|
524
551
|
agent.retryPendingInFlight = null;
|
|
525
552
|
}
|
|
526
553
|
};
|
|
554
|
+
/**
|
|
555
|
+
* After busy/submitting clears, accepted inbox_update notices have no later
|
|
556
|
+
* turn boundary. Promote each delivery id at most once to a wake.
|
|
557
|
+
*/
|
|
558
|
+
const scanAndPromoteAcceptedInboxUpdates = async (agent) => {
|
|
559
|
+
if (!agentIsIdleForInboxScan(agent))
|
|
560
|
+
return;
|
|
561
|
+
if (agent.compactionMachines.size > 0 || agent.compactionRecoveryInFlight.size > 0)
|
|
562
|
+
return;
|
|
563
|
+
const candidates = [...agent.records.values()].filter((record) => record.status === "accepted"
|
|
564
|
+
&& isInboxUpdateKind(record.input?.kind) && !agent.promotedInboxUpdateIds.has(record.deliveryId));
|
|
565
|
+
if (!candidates.length)
|
|
566
|
+
return;
|
|
567
|
+
reconcileExternalConsumption(agent);
|
|
568
|
+
let promoted = 0;
|
|
569
|
+
for (const record of candidates) {
|
|
570
|
+
const current = agent.records.get(record.deliveryId);
|
|
571
|
+
if (current?.status !== "accepted")
|
|
572
|
+
continue;
|
|
573
|
+
if (!acceptedInboxStillPending(agent, current)) {
|
|
574
|
+
reconcileAbsentCanonical(agent, current);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
agent.promotedInboxUpdateIds.add(current.deliveryId);
|
|
578
|
+
current.reason = INBOX_UPDATE_PROMOTE_REASON;
|
|
579
|
+
current.retryable = true;
|
|
580
|
+
const finalRecord = setRecord(agent, current, "pending");
|
|
581
|
+
if (finalRecord.status === "consumed")
|
|
582
|
+
continue;
|
|
583
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: finalRecord.deliveryId,
|
|
584
|
+
messageId: finalRecord.messageId, status: "deferred", reason: INBOX_UPDATE_PROMOTE_REASON });
|
|
585
|
+
promoted += 1;
|
|
586
|
+
}
|
|
587
|
+
if (!promoted)
|
|
588
|
+
return;
|
|
589
|
+
if (agent.retryPendingInFlight)
|
|
590
|
+
agent.retryAfterSubmit = true;
|
|
591
|
+
await retryPending(agent);
|
|
592
|
+
};
|
|
527
593
|
const scheduleRecreate = (agent, reason) => {
|
|
528
594
|
if (agent.stopped || agent.retryTimer || agent.starting || agent.session)
|
|
529
595
|
return;
|
|
@@ -861,6 +927,7 @@ export function createRuntimeHost(options) {
|
|
|
861
927
|
if (outcome !== "failed")
|
|
862
928
|
return retryPending(agent);
|
|
863
929
|
});
|
|
930
|
+
queueMicrotask(() => { void scanAndPromoteAcceptedInboxUpdates(agent); });
|
|
864
931
|
}
|
|
865
932
|
else if (event.type === "activity") {
|
|
866
933
|
if (agent.turnInProgress && event.activity !== "internal")
|
|
@@ -1221,6 +1288,14 @@ export function createRuntimeHost(options) {
|
|
|
1221
1288
|
};
|
|
1222
1289
|
},
|
|
1223
1290
|
isBusy(agentId) { const agent = managed.get(agentId); return Boolean(agent?.busy || agent?.submitting || agent?.starting); },
|
|
1291
|
+
async scanPendingInboxUpdates(agentId) {
|
|
1292
|
+
const agents = agentId ? [managed.get(agentId)] : [...managed.values()];
|
|
1293
|
+
for (const agent of agents) {
|
|
1294
|
+
if (!agent || agent.stopped)
|
|
1295
|
+
continue;
|
|
1296
|
+
await scanAndPromoteAcceptedInboxUpdates(agent);
|
|
1297
|
+
}
|
|
1298
|
+
},
|
|
1224
1299
|
async resetSession(agentId) {
|
|
1225
1300
|
const agent = managed.get(agentId);
|
|
1226
1301
|
if (!agent || agent.stopped)
|
|
@@ -1537,7 +1612,8 @@ export function createRuntimeHost(options) {
|
|
|
1537
1612
|
compactionMachines: new Map(), compactionRecoveryInFlight: new Set(), piOverflowCompactionFailed: new Set(),
|
|
1538
1613
|
piProactiveCompaction: null, piProactiveCompactionGeneration: null, piProactiveCompactionSession: null,
|
|
1539
1614
|
piProactiveCompactionFailedGeneration: null, readiness: null,
|
|
1540
|
-
turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false
|
|
1615
|
+
turnInProgress: false, turnHadFailure: false, turnHadAuthenticatedOutput: false, authFailureActive: false,
|
|
1616
|
+
promotedInboxUpdateIds: new Set() };
|
|
1541
1617
|
const startupConsumed = [];
|
|
1542
1618
|
const startupQuarantined = [];
|
|
1543
1619
|
for (const record of agent.records.values()) {
|
|
@@ -1639,6 +1715,7 @@ export function createRuntimeHost(options) {
|
|
|
1639
1715
|
: { inputId: existing.deliveryId, deliveryId: existing.deliveryId, kind: "wake", text: "", attempt: priorAttempt + 1 };
|
|
1640
1716
|
delete existing.reason;
|
|
1641
1717
|
delete existing.retryable;
|
|
1718
|
+
agent.promotedInboxUpdateIds.delete(existing.deliveryId);
|
|
1642
1719
|
setRecord(agent, existing, "pending");
|
|
1643
1720
|
const receipt = await (telemetry?.phase(messageId, "runtime.deliver", SpanKind.PRODUCER, () => submit(agent, existing, agent.busy || agent.submitting)) ?? submit(agent, existing, agent.busy || agent.submitting));
|
|
1644
1721
|
telemetry?.delivery(agentId, messageId, receipt.status);
|