larkin 0.5.15 → 0.5.17
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/agent/context-prompt.cjs +7 -7
- package/dist/agent/context-prompt.mjs +7 -7
- package/dist/app/agent-cli.mjs +1 -1
- package/dist/app/agent-readiness.cjs +10 -5
- package/dist/app/agent-readiness.mjs +9 -5
- package/dist/app/cli.mjs +15 -0
- package/dist/app/lark-cli.mjs +77 -119
- package/dist/app/lark.mjs +14 -0
- package/dist/app/runtime-agent-config.mjs +15 -4
- package/dist/app/runtime-context.mjs +17 -0
- package/dist/dashboard/dashboard-view-model.mjs +2 -4
- package/dist/feishu/host-shell.cjs +6 -6
- package/dist/feishu/host-shell.mjs +7 -7
- package/dist/feishu/im-freshness-adapter.cjs +2 -2
- package/dist/feishu/im-freshness-adapter.mjs +2 -2
- package/dist/runtime/runtime-host.cjs +112 -9
- package/dist/runtime/runtime-host.mjs +112 -9
- package/package.json +1 -1
|
@@ -12,11 +12,11 @@ const agent_cli_capabilities_js_1 = require("./agent-cli-capabilities.cjs");
|
|
|
12
12
|
* 1. 明确目标与边界:每条规则写明“何时触发 / 做什么 / 禁止什么”,避免歧义与过度推断。
|
|
13
13
|
* 2. 结构性信号优先于推断:能用来源/目标字段(如 thread target)做权威判定的,不要依赖模型从措辞推断。
|
|
14
14
|
* 3. 精确 recipe:关键操作给完整命令模板 + 约束(如 exact literal、fail-closed、确定性 --jq 数据流)。
|
|
15
|
-
* 4. 幂等与安全:idempotency
|
|
15
|
+
* 4. 幂等与安全:idempotency、非阻塞 freshness 提示、不可重复提交、脱敏、失败可见(fail visibly)。
|
|
16
16
|
* 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
|
|
17
17
|
* 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
|
|
18
18
|
*/
|
|
19
|
-
exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-
|
|
19
|
+
exports.LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v34";
|
|
20
20
|
/**
|
|
21
21
|
* Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
|
|
22
22
|
* 不声明 tmux 一定可用,也不复述扩展的工具细节。
|
|
@@ -156,7 +156,7 @@ class ContextPromptBuilder {
|
|
|
156
156
|
"Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
|
|
157
157
|
"If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
|
|
158
158
|
"Only a real Feishu `message_id` beginning with `om_` may be passed to `+messages-reply`; `rem_`, `redeliver_`, and every other synthetic ID must never be replied to. Send with a confirmed `chat_id` and never guess a chat id from a display name.",
|
|
159
|
-
"
|
|
159
|
+
"Freshness is a non-blocking reminder: a send, reply, card, recall, or urgent write is never rejected for freshness. After a successful write, stderr may contain a `larkin_notice` with `freshness`; the write already happened. Re-read the exact chat or thread with the Larkin command, and send a short follow-up only when newer context changes the answer. Never resend the same content merely because of this notice.",
|
|
160
160
|
"For ordinary plain-text message bodies, use `--text`, including brief one-line replies, multiline status lines, and paragraphs. Use `--markdown` only when you intentionally need Markdown rendering, such as headings, lists, emphasis, blockquotes, fenced code, or Markdown links.",
|
|
161
161
|
"When a URL must be visible, clickable, or openable by the recipient, include the complete bare `https://...` URL as visible text. Do not rely solely on `[label](URL)`, because Feishu client rendering is unreliable. A label may also be included, but the bare URL must remain present.",
|
|
162
162
|
"Use native `--text` for ordinary plain text and for verbatim preservation, such as logs, literal code, or exact whitespace. Both `--markdown` and `--text` remain supported in the Larkin Runtime.",
|
|
@@ -166,7 +166,7 @@ class ContextPromptBuilder {
|
|
|
166
166
|
`For a common exact send with a confirmed chat id, use the complete schematic recipe \`${executable} im +messages-send --chat-id <confirmed_chat_id> --text '<exact_body_as_one_literal_argument>' --json\`; replace each placeholder with the corresponding confirmed or exact literal value.`,
|
|
167
167
|
`A reply's target is governed by the source's thread membership, a structural Inbox fact, not a guess. When the current Inbox event or poll identifies the source as a thread (\`thread:<chat_id>:<thread_id>\` target, or the polled message carries a \`thread_id\`), your reply MUST stay in that same thread: after the required poll use \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. For a chat-level source (no thread) with no explicit in-thread request, reply to the main timeline with the same recipe but omit \`--reply-in-thread\`; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
|
|
168
168
|
`Use the \`--reply-in-thread\` recipe only when the source is a thread or the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply. Never invent a topic request from ordinary reply wording or a bare source message id: the thread decision comes only from the source message's actual thread membership (\`thread:\` target or polled \`thread_id\`) or an explicit request, never from wording alone.`,
|
|
169
|
-
"This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery.
|
|
169
|
+
"This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. It uses two total model tool calls including the poll. A later freshness notice is advisory and must not cause a duplicate send.",
|
|
170
170
|
"For tool-sourced exact or verbatim text from a Feishu result, do not copy or retype it into `--text`; you must use the deterministic native `--jq` to `--content` dataflow below.",
|
|
171
171
|
"Choose exactly one source selector from the identifier the task starts with, and never switch selectors after reading. The inner read in the selected composite replaces any separate scoped history read; it must not follow a preview read.",
|
|
172
172
|
"For a task that starts from a known thread and asks for its latest human content, after the required Inbox poll invoke the thread composite directly as the first and only post-poll model tool call. For this thread source, never preview or read the thread separately, extract a message id, switch to `+messages-mget`, or add a second source read.",
|
|
@@ -181,10 +181,10 @@ class ContextPromptBuilder {
|
|
|
181
181
|
`The complete canonical exact send and reply paths above must not be preceded by \`${executable} im +messages-send --help\` or \`${executable} im +messages-reply --help\`; run these two known recipes directly.`,
|
|
182
182
|
"For the known canonical Inbox poll, scoped chat/thread history, and exact send/reply recipes specified here, execute them directly; you must not read or re-read a skill file or reference merely to rediscover or confirm their command syntax.",
|
|
183
183
|
`For a task that starts from an exact group name and asks for user and bot counts, after the required Inbox poll use this known canonical two-read recipe directly: first run \`${executable} im +chat-search --query '<exact_group_name>' --json\`, require one result with an exact name match and confirm its \`oc_\` chat id, then run \`${executable} im chats get --chat-id <confirmed_oc_chat_id> --json\` and answer from its \`user_count\` and \`bot_count\`. This group user/bot counts recipe uses exactly two post-poll business read calls; you must not read or re-read a skill or reference, open help or schema, invoke bare \`lark-cli\`, call \`chat.members\` or \`+chat-members-list\`, fall back to \`+chat-list\`, or add shell filters or output truncation. If the exact name is not a unique visible match, the chat id is not confirmed, or either count is missing, fail visibly without another discovery path or a guess.`,
|
|
184
|
-
"This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity,
|
|
185
|
-
`To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target,
|
|
184
|
+
"This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
|
|
185
|
+
`To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target, and refuses cross-Agent or third-party messages. A \`committed=true\` or \`duplicate=true\` result must not be retried; an ambiguous result fails closed and requires user inspection. Never use generic API or bare \`lark-cli\` to bypass recall protection.`,
|
|
186
186
|
"The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe. A successful write result carrying `duplicate: true` means the provider returned the earlier delivery for the same derived key: the message was already delivered and no new message was created. Do not resend the same command after a `duplicate: true` result; treat the message as delivered. To deliberately send identical content again as a fresh intent (e.g., a new reminder), pass an explicit new `--idempotency-key`, which the wrapper respects instead of deriving its own.",
|
|
187
|
-
"
|
|
187
|
+
"A `larkin_notice` freshness reminder is written to stderr only after the provider write has completed. Keep the successful write; re-read the target before any follow-up, and only send a revised follow-up if the new context materially changes the answer. A result with `committed=true` or `duplicate=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
|
|
188
188
|
"For multiline `--markdown` or `--text` content in zsh or bash, prefer shell ANSI-C quoting so the command passes one argument with real newline characters: `--markdown $'First line\\nSecond line'` or `--text $'First line\\nSecond line'`. Putting `\"First line\\nSecond line\"` in ordinary double quotes is wrong: ordinary quotes do not decode `\\n`, so lark-cli and Feishu receive a backslash followed by the letter `n`. A literal multiline argument containing real newline characters is also valid. If ANSI-C-quoted content contains an apostrophe, use a safe shell single-quote splice or the literal-newline form; never use `eval`, `echo`, a temporary file, or unsafe variable interpolation to construct the body.",
|
|
189
189
|
...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
|
|
190
190
|
`- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
|
|
@@ -9,11 +9,11 @@ import { agentCliPromptCapabilities } from "./agent-cli-capabilities.mjs";
|
|
|
9
9
|
* 1. 明确目标与边界:每条规则写明“何时触发 / 做什么 / 禁止什么”,避免歧义与过度推断。
|
|
10
10
|
* 2. 结构性信号优先于推断:能用来源/目标字段(如 thread target)做权威判定的,不要依赖模型从措辞推断。
|
|
11
11
|
* 3. 精确 recipe:关键操作给完整命令模板 + 约束(如 exact literal、fail-closed、确定性 --jq 数据流)。
|
|
12
|
-
* 4. 幂等与安全:idempotency
|
|
12
|
+
* 4. 幂等与安全:idempotency、非阻塞 freshness 提示、不可重复提交、脱敏、失败可见(fail visibly)。
|
|
13
13
|
* 5. 版本化:每次实质改动 bump LARKIN_STANDING_PROMPT_VERSION,并同步 eval 数据集/graders/断言。
|
|
14
14
|
* 6. 用 eval 验证:行为变化必须配套固定场景 + rubric(evals/*、test/support/*-grader.mjs、live 测试)。
|
|
15
15
|
*/
|
|
16
|
-
export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-
|
|
16
|
+
export const LARKIN_STANDING_PROMPT_VERSION = "larkin-standing-v34";
|
|
17
17
|
/**
|
|
18
18
|
* Pi 长命令引导的唯一来源。仅在当前工具列表出现 tmux-backed bash 时适用;
|
|
19
19
|
* 不声明 tmux 一定可用,也不复述扩展的工具细节。
|
|
@@ -153,7 +153,7 @@ export class ContextPromptBuilder {
|
|
|
153
153
|
"Successful history response messages are always at `data.messages`. Never use a chat-wide fallback for a thread target, never merge stderr with `2>&1` before parsing JSON, and never truncate structured output before parsing it.",
|
|
154
154
|
"If the scoped history read fails or its schema is invalid, fail visibly. Do not reuse remembered or hard-coded text to make the task appear successful.",
|
|
155
155
|
"Only a real Feishu `message_id` beginning with `om_` may be passed to `+messages-reply`; `rem_`, `redeliver_`, and every other synthetic ID must never be replied to. Send with a confirmed `chat_id` and never guess a chat id from a display name.",
|
|
156
|
-
"
|
|
156
|
+
"Freshness is a non-blocking reminder: a send, reply, card, recall, or urgent write is never rejected for freshness. After a successful write, stderr may contain a `larkin_notice` with `freshness`; the write already happened. Re-read the exact chat or thread with the Larkin command, and send a short follow-up only when newer context changes the answer. Never resend the same content merely because of this notice.",
|
|
157
157
|
"For ordinary plain-text message bodies, use `--text`, including brief one-line replies, multiline status lines, and paragraphs. Use `--markdown` only when you intentionally need Markdown rendering, such as headings, lists, emphasis, blockquotes, fenced code, or Markdown links.",
|
|
158
158
|
"When a URL must be visible, clickable, or openable by the recipient, include the complete bare `https://...` URL as visible text. Do not rely solely on `[label](URL)`, because Feishu client rendering is unreliable. A label may also be included, but the bare URL must remain present.",
|
|
159
159
|
"Use native `--text` for ordinary plain text and for verbatim preservation, such as logs, literal code, or exact whitespace. Both `--markdown` and `--text` remain supported in the Larkin Runtime.",
|
|
@@ -163,7 +163,7 @@ export class ContextPromptBuilder {
|
|
|
163
163
|
`For a common exact send with a confirmed chat id, use the complete schematic recipe \`${executable} im +messages-send --chat-id <confirmed_chat_id> --text '<exact_body_as_one_literal_argument>' --json\`; replace each placeholder with the corresponding confirmed or exact literal value.`,
|
|
164
164
|
`A reply's target is governed by the source's thread membership, a structural Inbox fact, not a guess. When the current Inbox event or poll identifies the source as a thread (\`thread:<chat_id>:<thread_id>\` target, or the polled message carries a \`thread_id\`), your reply MUST stay in that same thread: after the required poll use \`${executable} im +messages-reply --message-id <real_om_message_id> --text '<exact_body_as_one_literal_argument>' --reply-in-thread --json\`. For a chat-level source (no thread) with no explicit in-thread request, reply to the main timeline with the same recipe but omit \`--reply-in-thread\`; replace the placeholders only with the real \`om_\` id returned by that poll and the unchanged exact literal.`,
|
|
165
165
|
`Use the \`--reply-in-thread\` recipe only when the source is a thread or the user or current Inbox event explicitly asks for a topic, in-thread, or thread reply. Never invent a topic request from ordinary reply wording or a bare source message id: the thread decision comes only from the source message's actual thread membership (\`thread:\` target or polled \`thread_id\`) or an explicit request, never from wording alone.`,
|
|
166
|
-
"This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery.
|
|
166
|
+
"This exact topic-reply recipe has exactly one post-poll model tool call and must not read or re-read a skill or reference, open help, or perform any other discovery. It uses two total model tool calls including the poll. A later freshness notice is advisory and must not cause a duplicate send.",
|
|
167
167
|
"For tool-sourced exact or verbatim text from a Feishu result, do not copy or retype it into `--text`; you must use the deterministic native `--jq` to `--content` dataflow below.",
|
|
168
168
|
"Choose exactly one source selector from the identifier the task starts with, and never switch selectors after reading. The inner read in the selected composite replaces any separate scoped history read; it must not follow a preview read.",
|
|
169
169
|
"For a task that starts from a known thread and asks for its latest human content, after the required Inbox poll invoke the thread composite directly as the first and only post-poll model tool call. For this thread source, never preview or read the thread separately, extract a message id, switch to `+messages-mget`, or add a second source read.",
|
|
@@ -178,10 +178,10 @@ export class ContextPromptBuilder {
|
|
|
178
178
|
`The complete canonical exact send and reply paths above must not be preceded by \`${executable} im +messages-send --help\` or \`${executable} im +messages-reply --help\`; run these two known recipes directly.`,
|
|
179
179
|
"For the known canonical Inbox poll, scoped chat/thread history, and exact send/reply recipes specified here, execute them directly; you must not read or re-read a skill file or reference merely to rediscover or confirm their command syntax.",
|
|
180
180
|
`For a task that starts from an exact group name and asks for user and bot counts, after the required Inbox poll use this known canonical two-read recipe directly: first run \`${executable} im +chat-search --query '<exact_group_name>' --json\`, require one result with an exact name match and confirm its \`oc_\` chat id, then run \`${executable} im chats get --chat-id <confirmed_oc_chat_id> --json\` and answer from its \`user_count\` and \`bot_count\`. This group user/bot counts recipe uses exactly two post-poll business read calls; you must not read or re-read a skill or reference, open help or schema, invoke bare \`lark-cli\`, call \`chat.members\` or \`+chat-members-list\`, fall back to \`+chat-list\`, or add shell filters or output truncation. If the exact name is not a unique visible match, the chat id is not confirmed, or either count is missing, fail visibly without another discovery path or a guess.`,
|
|
181
|
-
"This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity,
|
|
182
|
-
`To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target,
|
|
181
|
+
"This narrow direct-recipe rule does not waive required skill safety gates, authorization, identity, or other safety checks. Unknown commands or high-risk operations still require the applicable skill/reference guidance or help.",
|
|
182
|
+
`To recall a message after the required explicit user approval, use \`${executable} im messages delete --message-id <bot-owned_om_id> --yes --json\`. Runtime verifies the original sender is this exact Bot, derives the exact chat/thread target, and refuses cross-Agent or third-party messages. A \`committed=true\` or \`duplicate=true\` result must not be retried; an ambiguous result fails closed and requires user inspection. Never use generic API or bare \`lark-cli\` to bypass recall protection.`,
|
|
183
183
|
"The Larkin wrapper derives the stable per-intent idempotency key; do not pass `--idempotency-key` in an ordinary send or reply recipe. A successful write result carrying `duplicate: true` means the provider returned the earlier delivery for the same derived key: the message was already delivered and no new message was created. Do not resend the same command after a `duplicate: true` result; treat the message as delivered. To deliberately send identical content again as a fresh intent (e.g., a new reminder), pass an explicit new `--idempotency-key`, which the wrapper respects instead of deriving its own.",
|
|
184
|
-
"
|
|
184
|
+
"A `larkin_notice` freshness reminder is written to stderr only after the provider write has completed. Keep the successful write; re-read the target before any follow-up, and only send a revised follow-up if the new context materially changes the answer. A result with `committed=true` or `duplicate=true` must not be repeated; ambiguous termination follows the existing wrapper same-key recovery contract.",
|
|
185
185
|
"For multiline `--markdown` or `--text` content in zsh or bash, prefer shell ANSI-C quoting so the command passes one argument with real newline characters: `--markdown $'First line\\nSecond line'` or `--text $'First line\\nSecond line'`. Putting `\"First line\\nSecond line\"` in ordinary double quotes is wrong: ordinary quotes do not decode `\\n`, so lark-cli and Feishu receive a backslash followed by the letter `n`. A literal multiline argument containing real newline characters is also valid. If ANSI-C-quoted content contains an apostrophe, use a safe shell single-quote splice or the literal-newline form; never use `eval`, `echo`, a temporary file, or unsafe variable interpolation to construct the body.",
|
|
186
186
|
...FEISHU_IM_COMMAND_GROUPS.map(([label, suffixes]) => `- ${label}: ${suffixes.map((suffix) => `\`${executable} ${suffix}\``).join(", ")}.`),
|
|
187
187
|
`- Attachments: for an attachment-only send/reply, use its native attachment flag without a text body flag; download with \`${executable} im +messages-resources-download\`.`,
|
package/dist/app/agent-cli.mjs
CHANGED
|
@@ -119,7 +119,7 @@ function migrationError(group, subcommand) {
|
|
|
119
119
|
if (group === "server")
|
|
120
120
|
return "server 已移除;飞书(Lark)群与消息信息请通过 `larkin im ...` 查询。";
|
|
121
121
|
if (group === "im")
|
|
122
|
-
return "请通过 `larkin im ...` 使用飞书(Lark)命令;Runtime 会自动绑定当前 Bot identity
|
|
122
|
+
return "请通过 `larkin im ...` 使用飞书(Lark)命令;Runtime 会自动绑定当前 Bot identity,并在成功写入后给出非阻塞 freshness 提示。可运行 `larkin im --help` 查看帮助。";
|
|
123
123
|
if (group === "profile" && subcommand !== "show") {
|
|
124
124
|
return "profile 只保留只读的 `larkin profile show`;身份和凭证由 `larkin setup` 管理,不支持 update。";
|
|
125
125
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isRuntimeReadinessCurrent = isRuntimeReadinessCurrent;
|
|
4
|
+
exports.isSessionCurrent = isSessionCurrent;
|
|
4
5
|
exports.isCurrentOwnedDaemon = isCurrentOwnedDaemon;
|
|
5
6
|
exports.isChannelReconnecting = isChannelReconnecting;
|
|
6
7
|
exports.projectAgentReadiness = projectAgentReadiness;
|
|
@@ -16,6 +17,14 @@ function isRuntimeReadinessCurrent(readiness, daemonStartedAt) {
|
|
|
16
17
|
const epoch = timestamp(daemonStartedAt);
|
|
17
18
|
return observedAt !== null && epoch !== null && observedAt >= epoch;
|
|
18
19
|
}
|
|
20
|
+
function isSessionCurrent(session, daemonStartedAt) {
|
|
21
|
+
// A resumed session deliberately retains its original creation time. Its
|
|
22
|
+
// current daemon-epoch proof is the fresh session observation, not that
|
|
23
|
+
// historic creation timestamp.
|
|
24
|
+
const epoch = timestamp(daemonStartedAt);
|
|
25
|
+
const sessionObservedAt = timestamp(session?.lastSeenAt) ?? timestamp(session?.startedAt);
|
|
26
|
+
return epoch !== null && sessionObservedAt !== null && sessionObservedAt >= epoch;
|
|
27
|
+
}
|
|
19
28
|
function isCurrentOwnedDaemon(daemon) {
|
|
20
29
|
return daemon?.state === "owned"
|
|
21
30
|
&& Number(daemon.pid) > 0
|
|
@@ -34,11 +43,7 @@ function projectAgentReadiness(input) {
|
|
|
34
43
|
const daemonOwned = isCurrentOwnedDaemon(input.daemon) && (0, process_state_js_1.daemonHasAgent)(input.daemon, input.agentId);
|
|
35
44
|
const daemonStartedAt = timestamp(input.daemon.startedAt);
|
|
36
45
|
const connectedAt = timestamp(status?.connectedAt);
|
|
37
|
-
|
|
38
|
-
// current daemon-epoch proof is the fresh session observation, not that
|
|
39
|
-
// historic creation timestamp.
|
|
40
|
-
const sessionObservedAt = timestamp(status?.session?.lastSeenAt) ?? timestamp(status?.session?.startedAt);
|
|
41
|
-
const sessionCurrent = daemonStartedAt !== null && sessionObservedAt !== null && sessionObservedAt >= daemonStartedAt;
|
|
46
|
+
const sessionCurrent = isSessionCurrent(status?.session, input.daemon.startedAt);
|
|
42
47
|
const channelConnected = daemonOwned
|
|
43
48
|
&& status?.connectedVia === "channel"
|
|
44
49
|
&& daemonStartedAt !== null
|
|
@@ -10,6 +10,14 @@ export function isRuntimeReadinessCurrent(readiness, daemonStartedAt) {
|
|
|
10
10
|
const epoch = timestamp(daemonStartedAt);
|
|
11
11
|
return observedAt !== null && epoch !== null && observedAt >= epoch;
|
|
12
12
|
}
|
|
13
|
+
export function isSessionCurrent(session, daemonStartedAt) {
|
|
14
|
+
// A resumed session deliberately retains its original creation time. Its
|
|
15
|
+
// current daemon-epoch proof is the fresh session observation, not that
|
|
16
|
+
// historic creation timestamp.
|
|
17
|
+
const epoch = timestamp(daemonStartedAt);
|
|
18
|
+
const sessionObservedAt = timestamp(session?.lastSeenAt) ?? timestamp(session?.startedAt);
|
|
19
|
+
return epoch !== null && sessionObservedAt !== null && sessionObservedAt >= epoch;
|
|
20
|
+
}
|
|
13
21
|
export function isCurrentOwnedDaemon(daemon) {
|
|
14
22
|
return daemon?.state === "owned"
|
|
15
23
|
&& Number(daemon.pid) > 0
|
|
@@ -28,11 +36,7 @@ export function projectAgentReadiness(input) {
|
|
|
28
36
|
const daemonOwned = isCurrentOwnedDaemon(input.daemon) && daemonHasAgent(input.daemon, input.agentId);
|
|
29
37
|
const daemonStartedAt = timestamp(input.daemon.startedAt);
|
|
30
38
|
const connectedAt = timestamp(status?.connectedAt);
|
|
31
|
-
|
|
32
|
-
// current daemon-epoch proof is the fresh session observation, not that
|
|
33
|
-
// historic creation timestamp.
|
|
34
|
-
const sessionObservedAt = timestamp(status?.session?.lastSeenAt) ?? timestamp(status?.session?.startedAt);
|
|
35
|
-
const sessionCurrent = daemonStartedAt !== null && sessionObservedAt !== null && sessionObservedAt >= daemonStartedAt;
|
|
39
|
+
const sessionCurrent = isSessionCurrent(status?.session, input.daemon.startedAt);
|
|
36
40
|
const channelConnected = daemonOwned
|
|
37
41
|
&& status?.connectedVia === "channel"
|
|
38
42
|
&& daemonStartedAt !== null
|
package/dist/app/cli.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { CONFIG_CLI_USAGE } from "../agent/config-cli-contract.mjs";
|
|
9
9
|
import { internalCommandSpec } from "./internal-command.mjs";
|
|
10
10
|
import { packageVersion } from "../platform/build-info.mjs";
|
|
11
|
+
import { isLarkinRuntimeContext } from "./runtime-context.mjs";
|
|
11
12
|
const command = process.argv[2] || "help";
|
|
12
13
|
let rest = process.argv.slice(3);
|
|
13
14
|
const PACKAGE_VERSION = packageVersion(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."));
|
|
@@ -36,6 +37,18 @@ const routes = {
|
|
|
36
37
|
};
|
|
37
38
|
const runtimeAgentAuthority = typeof process.env.LARKIN_AGENT_ID === "string"
|
|
38
39
|
&& process.env.LARKIN_AGENT_ID.trim().length > 0;
|
|
40
|
+
const runtimeContext = isLarkinRuntimeContext(process.env);
|
|
41
|
+
// `config` deliberately remains a public operator surface: it supports global
|
|
42
|
+
// and cross-Agent configuration, and it never falls through to `activeAgent`.
|
|
43
|
+
const runtimeScopedCommands = new Set(["inbox", "reminder", "interaction", "profile", "comment"]);
|
|
44
|
+
const missingRuntimeAuthority = (name) => {
|
|
45
|
+
console.error(`larkin: Runtime Agent authority is missing for "${command}" (LARKIN_AGENT_ID is required). `
|
|
46
|
+
+ `Runtime context was detected (${name}); it will not fall back to the active Agent.`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
};
|
|
49
|
+
if (!runtimeAgentAuthority && runtimeScopedCommands.has(command)) {
|
|
50
|
+
missingRuntimeAuthority("agent-scoped command");
|
|
51
|
+
}
|
|
39
52
|
const runtimeAgentCommand = runtimeAgentAuthority
|
|
40
53
|
&& ["inbox", "reminder", "interaction", "profile", "config"].includes(command);
|
|
41
54
|
if (runtimeAgentCommand)
|
|
@@ -107,6 +120,8 @@ const retiredPublicCommands = new Set(["pi-auth", "pi-distribution"]);
|
|
|
107
120
|
const wantsHelp = command === "help" || command === "--help" || command === "-h"
|
|
108
121
|
|| (!runtimeAgentAuthority && command.startsWith("-"));
|
|
109
122
|
if (!routes[command] && !wantsHelp && !retiredPublicCommands.has(command)) {
|
|
123
|
+
if (runtimeContext && !runtimeAgentAuthority)
|
|
124
|
+
missingRuntimeAuthority("Runtime marker");
|
|
110
125
|
routes[command] = runtimeAgentAuthority ? ["lark-cli", command] : ["lark", command];
|
|
111
126
|
}
|
|
112
127
|
if (!routes[command]) {
|
package/dist/app/lark-cli.mjs
CHANGED
|
@@ -190,7 +190,7 @@ export function classifyLarkCliCommand(argv) {
|
|
|
190
190
|
return { kind: "passthrough" };
|
|
191
191
|
const protectedPaths = protectedOperations(argv);
|
|
192
192
|
if (command === "larkin-draft")
|
|
193
|
-
return { kind: "denied", reason: "larkin-draft
|
|
193
|
+
return { kind: "denied", reason: "larkin-draft 已移除;请使用受 Runtime 身份与目标绑定保护的普通写命令" };
|
|
194
194
|
if (exactPath(parsed.commandArgv, ["im", "+messages-send"]))
|
|
195
195
|
return uniqueProtectedOperation(protectedPaths, "send")
|
|
196
196
|
? (parsed.flags.has("--thread-id")
|
|
@@ -223,21 +223,21 @@ export function classifyLarkCliCommand(argv) {
|
|
|
223
223
|
if (exactPath(parsed.commandArgv, ["im", "messages", "create"]) || exactPath(parsed.commandArgv, ["im", "messages", "reply"])) {
|
|
224
224
|
const expected = parsed.commandArgv[2] === "create" ? "raw-create" : "raw-reply";
|
|
225
225
|
return uniqueProtectedOperation(protectedPaths, expected)
|
|
226
|
-
? { kind: "denied", reason: "该原始 IM 写入口会旁路 target
|
|
226
|
+
? { kind: "denied", reason: "该原始 IM 写入口会旁路 Runtime target binding;请使用 +messages-send/+messages-reply" }
|
|
227
227
|
: noncanonicalProtectedDecision();
|
|
228
228
|
}
|
|
229
229
|
if (["forward", "merge_forward", "urgent_phone", "urgent_sms"]
|
|
230
230
|
.some((operation) => exactPath(parsed.commandArgv, ["im", "messages", operation]))) {
|
|
231
231
|
const expected = `raw-${parsed.commandArgv[2]}`;
|
|
232
232
|
return uniqueProtectedOperation(protectedPaths, expected)
|
|
233
|
-
? { kind: "denied", reason: "该 IM 写入口无法建立 target
|
|
233
|
+
? { kind: "denied", reason: "该 IM 写入口无法建立 Runtime target binding;请先用 larkin inbox poll 读取目标,再使用受保护的 +messages-send/+messages-reply" }
|
|
234
234
|
: noncanonicalProtectedDecision();
|
|
235
235
|
}
|
|
236
236
|
if (["forward", "merge_forward"]
|
|
237
237
|
.some((operation) => exactPath(parsed.commandArgv, ["im", "threads", operation]))) {
|
|
238
238
|
const expected = `thread-${parsed.commandArgv[2]}`;
|
|
239
239
|
return uniqueProtectedOperation(protectedPaths, expected)
|
|
240
|
-
? { kind: "denied", reason: "该 IM forwarding 入口无法建立 target
|
|
240
|
+
? { kind: "denied", reason: "该 IM forwarding 入口无法建立 Runtime target binding;请先用 larkin inbox poll 读取目标,再使用受保护的 +messages-send/+messages-reply" }
|
|
241
241
|
: noncanonicalProtectedDecision();
|
|
242
242
|
}
|
|
243
243
|
if (command === "api")
|
|
@@ -347,8 +347,7 @@ function runCommentReply(argv, privateEnv, io, dependencies, store, agentId) {
|
|
|
347
347
|
const target = targetKey ? parseDocumentCommentTarget(targetKey) : null;
|
|
348
348
|
if (!target)
|
|
349
349
|
throw new Error("comment reply 无法从当前 Agent Inbox 绑定文档评论 locator;先 poll 该消息且不得跨 Agent/评论回复");
|
|
350
|
-
|
|
351
|
-
throw new Error("comment reply 需要先 poll 当前 document-comment target 的最新 Inbox 消息");
|
|
350
|
+
const staleCommentTarget = !store.inboxTargetIsFresh(targetKey);
|
|
352
351
|
const digest = createHash("sha256").update(input.text).digest("hex");
|
|
353
352
|
const ledgerKey = `${input.messageId}::${digest}`;
|
|
354
353
|
const claim = store.mutateJson("freshnessState", { version: 1, cursors: {} }, (state) => {
|
|
@@ -421,6 +420,13 @@ function runCommentReply(argv, privateEnv, io, dependencies, store, agentId) {
|
|
|
421
420
|
});
|
|
422
421
|
}
|
|
423
422
|
const committedWrite = !result.error && result.status === 0;
|
|
423
|
+
if (committedWrite && staleCommentTarget) {
|
|
424
|
+
io.stderr(`${JSON.stringify({
|
|
425
|
+
larkin_notice: "freshness",
|
|
426
|
+
target: targetKey,
|
|
427
|
+
hint: "Newer Inbox messages exist. Re-read the document comment context with larkin before sending a follow-up. Do not resend the same content.",
|
|
428
|
+
})}\n`);
|
|
429
|
+
}
|
|
424
430
|
const currentReminder = committedWrite
|
|
425
431
|
? store.resolveCurrentReminders().filter((reminder) => reminder.deliveryTarget === targetKey).at(-1) ?? null
|
|
426
432
|
: null;
|
|
@@ -464,9 +470,9 @@ function definitiveProviderRejection(result) {
|
|
|
464
470
|
}
|
|
465
471
|
return false;
|
|
466
472
|
}
|
|
467
|
-
function callNative(argv, env, io, dependencies) {
|
|
473
|
+
function callNative(argv, env, io, dependencies, timeoutMs) {
|
|
468
474
|
const native = dependencies.nativeCommand;
|
|
469
|
-
const result = (dependencies.spawn ?? spawnSync)(native?.command ?? resolveOfficialLarkCli({ spawn: dependencies.spawn, env }).command, [...(native?.argsPrefix ?? []), ...argv], { encoding: "utf8", env: { ...process.env, ...env } });
|
|
475
|
+
const result = (dependencies.spawn ?? spawnSync)(native?.command ?? resolveOfficialLarkCli({ spawn: dependencies.spawn, env }).command, [...(native?.argsPrefix ?? []), ...argv], { encoding: "utf8", env: { ...process.env, ...env }, ...(timeoutMs ? { timeout: timeoutMs } : {}) });
|
|
470
476
|
return result;
|
|
471
477
|
}
|
|
472
478
|
function spawnNative(argv, env, io, dependencies) {
|
|
@@ -524,7 +530,7 @@ function guardedTarget(decision, argv, store) {
|
|
|
524
530
|
const chatId = policyFlagValue(argv, "--chat-id");
|
|
525
531
|
const userId = policyFlagValue(argv, "--user-id");
|
|
526
532
|
if (!chatId || userId) {
|
|
527
|
-
throw new Error("Runtime +messages-send
|
|
533
|
+
throw new Error("Runtime +messages-send 必须使用已确认的 --chat-id;--user-id 无法建立受限 target binding");
|
|
528
534
|
}
|
|
529
535
|
return feishuImTarget(`chat:${chatId}`);
|
|
530
536
|
}
|
|
@@ -533,7 +539,7 @@ function guardedTarget(decision, argv, store) {
|
|
|
533
539
|
throw new Error(`${decision.operation} 写入缺少 --message-id`);
|
|
534
540
|
const target = store.resolveInboxMessageTarget(messageId);
|
|
535
541
|
if (!target)
|
|
536
|
-
throw new Error(`无法从 Inbox 状态确定 ${messageId} 的 target;先 poll 对应消息,禁止旁路
|
|
542
|
+
throw new Error(`无法从 Inbox 状态确定 ${messageId} 的 target;先 poll 对应消息,禁止旁路 target binding`);
|
|
537
543
|
return feishuImTarget(target);
|
|
538
544
|
}
|
|
539
545
|
function recallLedgerEntry(store, messageId) {
|
|
@@ -889,22 +895,62 @@ function intentId(target, argv) {
|
|
|
889
895
|
.update(JSON.stringify([target, argv])).digest("hex");
|
|
890
896
|
return `larkin-${fingerprint.slice(0, 32)}`;
|
|
891
897
|
}
|
|
892
|
-
function
|
|
898
|
+
function emitFreshnessNotice(io, input) {
|
|
893
899
|
io.stderr(`${JSON.stringify({
|
|
894
|
-
|
|
895
|
-
identity: "bot",
|
|
896
|
-
error: { type: input.subtype === "freshness_conflict" ? "conflict" : "unavailable", subtype: input.subtype,
|
|
897
|
-
...(input.reason ? { message: input.reason } : {}) },
|
|
900
|
+
larkin_notice: "freshness",
|
|
898
901
|
target: input.target,
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
+
newer_messages: input.messages.length,
|
|
903
|
+
latest_message_ids: input.messages.map((message) => message.message_id),
|
|
904
|
+
hint: "Newer messages exist. Re-read this target with larkin and send a short follow-up only if they change your answer. Do not resend the same content.",
|
|
902
905
|
})}\n`);
|
|
903
906
|
}
|
|
904
907
|
function freshnessGeneration(env) {
|
|
905
908
|
return typeof env.LARKIN_RUNTIME_OBSERVATION_GENERATION === "string" && env.LARKIN_RUNTIME_OBSERVATION_GENERATION
|
|
906
909
|
? env.LARKIN_RUNTIME_OBSERVATION_GENERATION : "external";
|
|
907
910
|
}
|
|
911
|
+
// The post-write observation is advisory: cap it tightly so success is not
|
|
912
|
+
// held hostage by a slow provider history response.
|
|
913
|
+
const FRESHNESS_PROBE_TIMEOUT_MS = 500;
|
|
914
|
+
function emitSoftFreshnessNotice(target, targetKey, seen, ownMessageId, env, io, dependencies, store) {
|
|
915
|
+
let timedOut = false;
|
|
916
|
+
try {
|
|
917
|
+
const gated = evaluateFreshness({
|
|
918
|
+
seen,
|
|
919
|
+
adapter: feishuImFreshnessAdapter,
|
|
920
|
+
probe: () => {
|
|
921
|
+
const result = callNative(probeArgv(target), env, io, dependencies, FRESHNESS_PROBE_TIMEOUT_MS);
|
|
922
|
+
const error = result.error;
|
|
923
|
+
timedOut = error?.code === "ETIMEDOUT"
|
|
924
|
+
|| error?.message.includes("ETIMEDOUT") === true
|
|
925
|
+
|| (result.status === null && result.signal === "SIGTERM");
|
|
926
|
+
return parseHistory(result, target, true);
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
if (gated.status === "unavailable") {
|
|
930
|
+
if (timedOut)
|
|
931
|
+
io.stderr(`lark-cli: freshness observation timed out after ${FRESHNESS_PROBE_TIMEOUT_MS}ms; no read position was recorded\n`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (!seen && gated.status === "fresh" && gated.current) {
|
|
935
|
+
// A first authoritative observation establishes a quiet baseline from
|
|
936
|
+
// the provider's latest 20-message window, including messages that
|
|
937
|
+
// arrived while this write was in flight. This does not claim the Agent
|
|
938
|
+
// read those messages; it only enables change detection on later writes.
|
|
939
|
+
store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, freshnessGeneration(env));
|
|
940
|
+
}
|
|
941
|
+
if (gated.status !== "conflict")
|
|
942
|
+
return;
|
|
943
|
+
store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, freshnessGeneration(env));
|
|
944
|
+
const newer = gated.context.filter((message) => message.message_id !== ownMessageId);
|
|
945
|
+
if (newer.length)
|
|
946
|
+
emitFreshnessNotice(io, { target: targetKey, messages: newer });
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
// freshness 仅作提示:观察失败绝不能改变已完成写入的结果。
|
|
950
|
+
if (timedOut)
|
|
951
|
+
io.stderr(`lark-cli: freshness observation timed out after ${FRESHNESS_PROBE_TIMEOUT_MS}ms; no read position was recorded\n`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
908
954
|
function rawFlagValue(argv, flag) {
|
|
909
955
|
const nativeArgv = nativeArgvBeforeBoundary(argv);
|
|
910
956
|
const direct = nativeArgv.indexOf(flag);
|
|
@@ -1066,23 +1112,6 @@ function writeResponseMessage(result) {
|
|
|
1066
1112
|
return null;
|
|
1067
1113
|
}
|
|
1068
1114
|
}
|
|
1069
|
-
function observeSuccessfulWrite(result, target, targetKey, store, generation) {
|
|
1070
|
-
if (result.error || result.status !== 0)
|
|
1071
|
-
return false;
|
|
1072
|
-
try {
|
|
1073
|
-
const candidate = writeResponseMessage(result);
|
|
1074
|
-
if (!candidate)
|
|
1075
|
-
return false;
|
|
1076
|
-
const snapshot = parseHistory({ ...result, stdout: JSON.stringify({ ok: true, data: { messages: [candidate] } }) }, target);
|
|
1077
|
-
const cursor = feishuImFreshnessAdapter.cursor(snapshot);
|
|
1078
|
-
if (cursor)
|
|
1079
|
-
store.mergeFreshnessCursor(targetKey, cursor, mergeFeishuImCursor, generation);
|
|
1080
|
-
return cursor !== null;
|
|
1081
|
-
}
|
|
1082
|
-
catch {
|
|
1083
|
-
return false;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
1115
|
function emitNativeResult(result, io) {
|
|
1087
1116
|
if (result.stdout)
|
|
1088
1117
|
io.stdout(result.stdout);
|
|
@@ -1125,35 +1154,6 @@ function emitDuplicatedRecall(io, messageId, target) {
|
|
|
1125
1154
|
})}\n`);
|
|
1126
1155
|
return 0;
|
|
1127
1156
|
}
|
|
1128
|
-
function emitCommittedUnverified(result, io, input) {
|
|
1129
|
-
let providerResponse;
|
|
1130
|
-
try {
|
|
1131
|
-
providerResponse = JSON.parse(result.stdout || "");
|
|
1132
|
-
}
|
|
1133
|
-
catch {
|
|
1134
|
-
providerResponse = { raw_stdout: result.stdout || "" };
|
|
1135
|
-
}
|
|
1136
|
-
const providerDocument = providerResponse && typeof providerResponse === "object" && !Array.isArray(providerResponse)
|
|
1137
|
-
? providerResponse
|
|
1138
|
-
: { provider_response: providerResponse };
|
|
1139
|
-
io.stdout(`${JSON.stringify({
|
|
1140
|
-
...providerDocument,
|
|
1141
|
-
ok: true,
|
|
1142
|
-
committed: true,
|
|
1143
|
-
verified: false,
|
|
1144
|
-
cursor_advanced: false,
|
|
1145
|
-
target: input.target,
|
|
1146
|
-
verification: {
|
|
1147
|
-
status: "unverified",
|
|
1148
|
-
subtype: "post_write_unverified",
|
|
1149
|
-
message: input.reason,
|
|
1150
|
-
...(input.current ? { current_cursor: input.current } : {}),
|
|
1151
|
-
...(input.messages ? { unseen_messages: input.messages } : {}),
|
|
1152
|
-
},
|
|
1153
|
-
...(result.stderr ? { provider_stderr_present: true } : {}),
|
|
1154
|
-
})}\n`);
|
|
1155
|
-
return 0;
|
|
1156
|
-
}
|
|
1157
1157
|
export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
1158
1158
|
const io = dependencies.io ?? defaultIo();
|
|
1159
1159
|
let effectiveArgv = argv;
|
|
@@ -1235,20 +1235,6 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
1235
1235
|
const targetKey = serializeFeishuImTarget(target);
|
|
1236
1236
|
const generation = freshnessGeneration(privateEnv);
|
|
1237
1237
|
const seen = store.readFreshnessCursor(targetKey, generation);
|
|
1238
|
-
const gated = evaluateFreshness({
|
|
1239
|
-
seen,
|
|
1240
|
-
adapter: feishuImFreshnessAdapter,
|
|
1241
|
-
probe: () => parseHistory(callNative(probeArgv(target), privateEnv, io, nativeDependencies), target, true),
|
|
1242
|
-
});
|
|
1243
|
-
if (gated.status === "unavailable") {
|
|
1244
|
-
emitFreshnessError(io, { subtype: "freshness_unavailable", target: targetKey, reason: gated.reason });
|
|
1245
|
-
return 3;
|
|
1246
|
-
}
|
|
1247
|
-
if (gated.status === "conflict") {
|
|
1248
|
-
emitFreshnessError(io, { subtype: "freshness_conflict", target: targetKey, current: gated.current, messages: gated.context });
|
|
1249
|
-
store.mergeFreshnessCursor(targetKey, gated.current, mergeFeishuImCursor, generation);
|
|
1250
|
-
return 3;
|
|
1251
|
-
}
|
|
1252
1238
|
if (decision.operation === "recall") {
|
|
1253
1239
|
const claim = claimRecall(store, recallMessageId, targetKey);
|
|
1254
1240
|
if (claim === "deleted")
|
|
@@ -1258,16 +1244,15 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
1258
1244
|
}
|
|
1259
1245
|
const recallWrite = callNative(botArgv(effectiveArgv, "", decision), privateEnv, io, nativeDependencies);
|
|
1260
1246
|
if (!recallWrite.error && recallWrite.status === 0) {
|
|
1261
|
-
|
|
1262
|
-
messages: gated.snapshot.messages.filter((message) => message.message_id !== recallMessageId),
|
|
1263
|
-
};
|
|
1264
|
-
const predictedCursor = feishuImFreshnessAdapter.cursor(predictedSnapshot);
|
|
1265
|
-
finalizeSuccessfulRecall(store, recallMessageId, targetKey, predictedCursor, generation);
|
|
1247
|
+
finalizeSuccessfulRecall(store, recallMessageId, targetKey, null, generation);
|
|
1266
1248
|
}
|
|
1267
1249
|
else if (definitiveProviderRejection(recallWrite)) {
|
|
1268
1250
|
finalizeFailedRecall(store, recallMessageId, targetKey);
|
|
1269
1251
|
}
|
|
1270
|
-
|
|
1252
|
+
const code = emitNativeResult(recallWrite, io);
|
|
1253
|
+
if (code === 0)
|
|
1254
|
+
emitSoftFreshnessNotice(target, targetKey, seen, undefined, privateEnv, io, nativeDependencies, store);
|
|
1255
|
+
return code;
|
|
1271
1256
|
}
|
|
1272
1257
|
if (decision.operation === "urgent-app") {
|
|
1273
1258
|
const userIds = assertUrgentAppPreconditions(effectiveArgv, urgent.message, target, agent.feishuAppId);
|
|
@@ -1291,7 +1276,10 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
1291
1276
|
})}\n`);
|
|
1292
1277
|
return 2;
|
|
1293
1278
|
}
|
|
1294
|
-
|
|
1279
|
+
const code = emitNativeResult(write, io);
|
|
1280
|
+
if (code === 0)
|
|
1281
|
+
emitSoftFreshnessNotice(target, targetKey, seen, writeResponseMessage(write)?.message_id, privateEnv, io, nativeDependencies, store);
|
|
1282
|
+
return code;
|
|
1295
1283
|
}
|
|
1296
1284
|
const writeMessage = writeResponseMessage(write);
|
|
1297
1285
|
const deliveryTarget = decision.operation === "send"
|
|
@@ -1331,40 +1319,10 @@ export function runLarkCli(argv, env = process.env, dependencies = {}) {
|
|
|
1331
1319
|
io.stderr(`lark-cli: reminder delivery audit failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1332
1320
|
}
|
|
1333
1321
|
const duplicate = memo.duplicate;
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
}
|
|
1339
|
-
if (!write.error && write.status === 0 && !observeSuccessfulWrite(write, target, targetKey, store, generation)) {
|
|
1340
|
-
const responseMessage = writeResponseMessage(write);
|
|
1341
|
-
try {
|
|
1342
|
-
const postSnapshot = parseHistory(callNative(probeArgv(target), privateEnv, io, nativeDependencies), target, true);
|
|
1343
|
-
const postCursor = feishuImFreshnessAdapter.cursor(postSnapshot);
|
|
1344
|
-
const unseenAfterWrite = feishuImFreshnessAdapter.unseen(gated.current, postSnapshot);
|
|
1345
|
-
const confirmedOwnWrite = responseMessage && postCursor
|
|
1346
|
-
&& unseenAfterWrite.some((message) => message.message_id === responseMessage.message_id)
|
|
1347
|
-
&& unseenAfterWrite.every((message) => message.message_id === responseMessage.message_id);
|
|
1348
|
-
if (!confirmedOwnWrite) {
|
|
1349
|
-
return emitCommittedUnverified(write, io, {
|
|
1350
|
-
target: targetKey,
|
|
1351
|
-
...(postCursor ? { current: postCursor } : {}),
|
|
1352
|
-
messages: unseenAfterWrite,
|
|
1353
|
-
reason: responseMessage
|
|
1354
|
-
? "provider write succeeded but bounded post-write probe found an additional concurrent update; cursor was not advanced"
|
|
1355
|
-
: "provider write succeeded without a message id/revision and bounded post-write probe could not identify the write; cursor was not advanced",
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
store.mergeFreshnessCursor(targetKey, postCursor, mergeFeishuImCursor, generation);
|
|
1359
|
-
}
|
|
1360
|
-
catch {
|
|
1361
|
-
return emitCommittedUnverified(write, io, {
|
|
1362
|
-
target: targetKey,
|
|
1363
|
-
reason: "provider write succeeded but bounded post-write confirmation was unavailable; cursor was not advanced",
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
return emitNativeResult(write, io);
|
|
1322
|
+
const code = duplicate ? emitDuplicatedWrite(write, io, { target: targetKey }) : emitNativeResult(write, io);
|
|
1323
|
+
if (code === 0)
|
|
1324
|
+
emitSoftFreshnessNotice(target, targetKey, seen, writeMessage?.message_id, privateEnv, io, nativeDependencies, store);
|
|
1325
|
+
return code;
|
|
1368
1326
|
}
|
|
1369
1327
|
catch (error) {
|
|
1370
1328
|
io.stderr(`lark-cli: ${error instanceof Error ? error.message : String(error)}\n`);
|
package/dist/app/lark.mjs
CHANGED
|
@@ -4,6 +4,20 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import * as larkinConfig from "../platform/config.mjs";
|
|
5
5
|
import { assessPassthrough, PASSTHROUGH_USAGE } from "../feishu/lark-passthrough.mjs";
|
|
6
6
|
import { managedOfficialLarkCli } from "./agent-lark-cli-workspace.mjs";
|
|
7
|
+
import { isLarkinRuntimeContext } from "./runtime-context.mjs";
|
|
8
|
+
const runtimeScopedCommands = new Set(["inbox", "reminder", "interaction", "profile", "comment"]);
|
|
9
|
+
const requestedCommand = process.argv.slice(2).find((argument) => !argument.startsWith("-"));
|
|
10
|
+
const runtimeContext = isLarkinRuntimeContext(process.env);
|
|
11
|
+
if (!process.env.LARKIN_AGENT_ID && runtimeContext) {
|
|
12
|
+
console.error(`larkin: Runtime Agent authority is missing for "${requestedCommand ?? "lark-cli"}" (LARKIN_AGENT_ID is required). `
|
|
13
|
+
+ "Runtime commands cannot fall back to the active Agent.");
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
if (!process.env.LARKIN_AGENT_ID && requestedCommand && runtimeScopedCommands.has(requestedCommand)) {
|
|
17
|
+
console.error(`larkin: Runtime Agent authority is missing for "${requestedCommand}" (LARKIN_AGENT_ID is required). `
|
|
18
|
+
+ "Agent-scoped commands cannot fall back to the active Agent.");
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
7
21
|
const decision = assessPassthrough(process.argv.slice(2), process.env);
|
|
8
22
|
if (!decision.ok) {
|
|
9
23
|
console.error(`larkin: ${decision.reason}`);
|
|
@@ -240,18 +240,29 @@ function runOfficialLarkCliAsync(command, args, env, dependencies) {
|
|
|
240
240
|
});
|
|
241
241
|
});
|
|
242
242
|
}
|
|
243
|
-
function expectedRuntimeCommandShim() {
|
|
243
|
+
function expectedRuntimeCommandShim(agent) {
|
|
244
244
|
const standalone = process.env.LARKIN_STANDALONE === "1";
|
|
245
245
|
const binaryEntry = fileURLToPath(new URL("./binary-entry.mjs", import.meta.url));
|
|
246
246
|
const argumentsPrefix = standalone ? [] : [binaryEntry];
|
|
247
247
|
const command = [process.execPath, ...argumentsPrefix].map(shellQuote).join(" ");
|
|
248
|
-
|
|
248
|
+
const stateDir = path.resolve(agent.stateDir);
|
|
249
|
+
const configDir = path.resolve(stateDir, "../../..");
|
|
250
|
+
return [
|
|
251
|
+
"#!/bin/sh",
|
|
252
|
+
"export LARKIN_RUNTIME=1",
|
|
253
|
+
`export LARKIN_AGENT_ID=${shellQuote(agent.agentId)}`,
|
|
254
|
+
`export LARKIN_STATE_DIR=${shellQuote(stateDir)}`,
|
|
255
|
+
`export LARKIN_CONFIG_DIR=${shellQuote(configDir)}`,
|
|
256
|
+
`export LARKIN_HOME=${shellQuote(configDir)}`,
|
|
257
|
+
`exec ${command} "$@"`,
|
|
258
|
+
"",
|
|
259
|
+
].join("\n");
|
|
249
260
|
}
|
|
250
261
|
export function installRuntimeCommandShims(agent) {
|
|
251
262
|
const stateDir = path.resolve(agent.stateDir);
|
|
252
263
|
const commandDir = path.join(stateDir, "runtime-bin");
|
|
253
264
|
assertSecureRuntimeCommandDirectory(commandDir);
|
|
254
|
-
const fileContents = expectedRuntimeCommandShim();
|
|
265
|
+
const fileContents = expectedRuntimeCommandShim(agent);
|
|
255
266
|
for (const name of ["larkin"]) {
|
|
256
267
|
const file = path.join(commandDir, name);
|
|
257
268
|
const temporary = path.join(commandDir, `.${name}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
@@ -274,7 +285,7 @@ function validateRuntimeCommandShims(agent) {
|
|
|
274
285
|
|| (typeof process.getuid === "function" && stat.uid !== process.getuid())
|
|
275
286
|
|| (!exactMode(stat, 0o700) && !exactMode(stat, 0o500)))
|
|
276
287
|
throw new Error("Runtime command shim 不安全");
|
|
277
|
-
if (fs.readFileSync(file, "utf8") !== expectedRuntimeCommandShim()) {
|
|
288
|
+
if (fs.readFileSync(file, "utf8") !== expectedRuntimeCommandShim(agent)) {
|
|
278
289
|
throw new Error("Runtime command shim 内容无效");
|
|
279
290
|
}
|
|
280
291
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function hasRuntimeBin(pathValue) {
|
|
2
|
+
if (!pathValue)
|
|
3
|
+
return false;
|
|
4
|
+
return pathValue.split(process.platform === "win32" ? ";" : ":")
|
|
5
|
+
.some((entry) => /(?:^|[\\/])runtime-bin$/.test(entry));
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Runtime markers are deliberately independent of LARKIN_AGENT_ID. They keep a
|
|
9
|
+
* damaged Runtime environment from falling through to a terminal's activeAgent
|
|
10
|
+
* selection when its per-Agent identity has been stripped.
|
|
11
|
+
*/
|
|
12
|
+
export function isLarkinRuntimeContext(env = process.env) {
|
|
13
|
+
return env.LARKIN_RUNTIME === "1"
|
|
14
|
+
|| Boolean(env.LARKIN_RUNTIME_OBSERVATION_GENERATION)
|
|
15
|
+
|| Boolean(env.LARKIN_STATE_DIR)
|
|
16
|
+
|| hasRuntimeBin(env.PATH);
|
|
17
|
+
}
|
|
@@ -7,7 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { StringDecoder } from "node:string_decoder";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { readJson, readProcessState } from "../platform/process-state.mjs";
|
|
10
|
-
import { isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
|
|
10
|
+
import { isRuntimeReadinessCurrent, isSessionCurrent } from "../app/agent-readiness.mjs";
|
|
11
11
|
import { isAllowedDashboardAvatarUrl } from "./dashboard-avatar.mjs";
|
|
12
12
|
import { collectWorkspaceEntry as collectTypedWorkspaceEntry } from "./dashboard-workspace.mjs";
|
|
13
13
|
import { buildFingerprint, packageVersion } from "../platform/build-info.mjs";
|
|
@@ -480,9 +480,7 @@ async function collectAgentStatus(a, configDir, daemonStartedAt, piModelResolver
|
|
|
480
480
|
// 本 agent 专属时间线:三路历史合并按时间倒序,不是全局一条汇总——每个 agent 只看自己的故事。
|
|
481
481
|
const { recentErrors, lastActivity, lastDeliver, feed } = projectStatusTimeline(status);
|
|
482
482
|
const statusReadiness = status.runtimeReadiness;
|
|
483
|
-
const
|
|
484
|
-
const daemonEpoch = finiteTime(daemonStartedAt);
|
|
485
|
-
const sessionCurrent = sessionStartedAt !== null && daemonEpoch !== null && sessionStartedAt >= daemonEpoch;
|
|
483
|
+
const sessionCurrent = isSessionCurrent(status.session, daemonStartedAt);
|
|
486
484
|
const runtimeReadiness = sessionCurrent && isRuntimeReadinessCurrent(statusReadiness, daemonStartedAt)
|
|
487
485
|
|| statusReadiness?.state !== "ready"
|
|
488
486
|
? statusReadiness || null
|
|
@@ -1599,9 +1599,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
1599
1599
|
const status = hostState.readStatus(agent);
|
|
1600
1600
|
const turns = status.session && typeof status.session === "object"
|
|
1601
1601
|
? Math.max(0, Number(status.session.turns) || 0) : 0;
|
|
1602
|
-
const
|
|
1603
|
-
?
|
|
1604
|
-
|
|
1602
|
+
const sessionCurrent = (0, agent_readiness_js_1.isSessionCurrent)(status.session && typeof status.session === "object"
|
|
1603
|
+
? status.session
|
|
1604
|
+
: null, daemonStartedAt);
|
|
1605
1605
|
const runtimeReady = sessionCurrent && (0, agent_readiness_js_1.isRuntimeReadinessCurrent)(status.runtimeReadiness, daemonStartedAt);
|
|
1606
1606
|
const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
|
|
1607
1607
|
return { turns, runtimeReady, ...connectionState(status), pendingCount };
|
|
@@ -1686,9 +1686,9 @@ function createHostShell({ env = process.env, runtimeHost, channelPackage, event
|
|
|
1686
1686
|
const status = hostState.readStatus(agent);
|
|
1687
1687
|
const turns = status.session && typeof status.session === "object"
|
|
1688
1688
|
? Math.max(0, Number(status.session.turns) || 0) : 0;
|
|
1689
|
-
const
|
|
1690
|
-
?
|
|
1691
|
-
|
|
1689
|
+
const sessionCurrent = (0, agent_readiness_js_1.isSessionCurrent)(status.session && typeof status.session === "object"
|
|
1690
|
+
? status.session
|
|
1691
|
+
: null, daemonStartedAt);
|
|
1692
1692
|
const runtimeReady = sessionCurrent && (0, agent_readiness_js_1.isRuntimeReadinessCurrent)(status.runtimeReadiness, daemonStartedAt);
|
|
1693
1693
|
const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
|
|
1694
1694
|
return { turns, runtimeReady, ...connectionState(status), pendingCount };
|
|
@@ -23,7 +23,7 @@ import { readDocumentCommentSubscription, verifyCallbackProbe } from "../platfor
|
|
|
23
23
|
import { loadConfig, resolveInboxAuditSchedule, resolveMentionPolicy } from "../platform/config.mjs";
|
|
24
24
|
import { processCommandToken } from "../app/internal-command.mjs";
|
|
25
25
|
import { managedOfficialLarkCli } from "../app/agent-lark-cli-workspace.mjs";
|
|
26
|
-
import { isChannelReconnecting, isRuntimeReadinessCurrent } from "../app/agent-readiness.mjs";
|
|
26
|
+
import { isChannelReconnecting, isRuntimeReadinessCurrent, isSessionCurrent } from "../app/agent-readiness.mjs";
|
|
27
27
|
import { documentCommentMessageId, documentCommentNoticeType, projectDocumentCommentEnvelope, resolveDocumentCommentContext, } from "./document-comment.mjs";
|
|
28
28
|
import { isOpenPlatformHost, requireOpenDomain } from "./platform-hosts.mjs";
|
|
29
29
|
const PENDING_DOCUMENT_COMMENT_LIMIT = 256;
|
|
@@ -1558,9 +1558,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
1558
1558
|
const status = hostState.readStatus(agent);
|
|
1559
1559
|
const turns = status.session && typeof status.session === "object"
|
|
1560
1560
|
? Math.max(0, Number(status.session.turns) || 0) : 0;
|
|
1561
|
-
const
|
|
1562
|
-
?
|
|
1563
|
-
|
|
1561
|
+
const sessionCurrent = isSessionCurrent(status.session && typeof status.session === "object"
|
|
1562
|
+
? status.session
|
|
1563
|
+
: null, daemonStartedAt);
|
|
1564
1564
|
const runtimeReady = sessionCurrent && isRuntimeReadinessCurrent(status.runtimeReadiness, daemonStartedAt);
|
|
1565
1565
|
const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
|
|
1566
1566
|
return { turns, runtimeReady, ...connectionState(status), pendingCount };
|
|
@@ -1645,9 +1645,9 @@ export function createHostShell({ env = process.env, runtimeHost, channelPackage
|
|
|
1645
1645
|
const status = hostState.readStatus(agent);
|
|
1646
1646
|
const turns = status.session && typeof status.session === "object"
|
|
1647
1647
|
? Math.max(0, Number(status.session.turns) || 0) : 0;
|
|
1648
|
-
const
|
|
1649
|
-
?
|
|
1650
|
-
|
|
1648
|
+
const sessionCurrent = isSessionCurrent(status.session && typeof status.session === "object"
|
|
1649
|
+
? status.session
|
|
1650
|
+
: null, daemonStartedAt);
|
|
1651
1651
|
const runtimeReady = sessionCurrent && isRuntimeReadinessCurrent(status.runtimeReadiness, daemonStartedAt);
|
|
1652
1652
|
const pendingCount = stateStore(agent).withInboxTransaction(() => stateStore(agent).readNdjson("inbox").length);
|
|
1653
1653
|
return { turns, runtimeReady, ...connectionState(status), pendingCount };
|
|
@@ -60,7 +60,7 @@ exports.feishuImFreshnessAdapter = {
|
|
|
60
60
|
if (!current)
|
|
61
61
|
return seen ? "gap" : "fresh";
|
|
62
62
|
if (!seen)
|
|
63
|
-
return "
|
|
63
|
+
return "fresh";
|
|
64
64
|
const seenTime = integerMillis(seen.revisionTime);
|
|
65
65
|
const currentTime = integerMillis(current.revisionTime);
|
|
66
66
|
if (seen.schema !== 1 || seenTime === null || currentTime === null)
|
|
@@ -74,7 +74,7 @@ exports.feishuImFreshnessAdapter = {
|
|
|
74
74
|
unseen(seen, snapshot) {
|
|
75
75
|
const messages = normalizedMessages(snapshot);
|
|
76
76
|
if (!seen)
|
|
77
|
-
return
|
|
77
|
+
return [];
|
|
78
78
|
const seenTime = integerMillis(seen.revisionTime);
|
|
79
79
|
if (seenTime === null)
|
|
80
80
|
return messages.map(({ revision: _revision, ...message }) => message);
|
|
@@ -53,7 +53,7 @@ export const feishuImFreshnessAdapter = {
|
|
|
53
53
|
if (!current)
|
|
54
54
|
return seen ? "gap" : "fresh";
|
|
55
55
|
if (!seen)
|
|
56
|
-
return "
|
|
56
|
+
return "fresh";
|
|
57
57
|
const seenTime = integerMillis(seen.revisionTime);
|
|
58
58
|
const currentTime = integerMillis(current.revisionTime);
|
|
59
59
|
if (seen.schema !== 1 || seenTime === null || currentTime === null)
|
|
@@ -67,7 +67,7 @@ export const feishuImFreshnessAdapter = {
|
|
|
67
67
|
unseen(seen, snapshot) {
|
|
68
68
|
const messages = normalizedMessages(snapshot);
|
|
69
69
|
if (!seen)
|
|
70
|
-
return
|
|
70
|
+
return [];
|
|
71
71
|
const seenTime = integerMillis(seen.revisionTime);
|
|
72
72
|
if (seenTime === null)
|
|
73
73
|
return messages.map(({ revision: _revision, ...message }) => message);
|
|
@@ -308,6 +308,16 @@ function createRuntimeHost(options) {
|
|
|
308
308
|
maxAttempts: options.retryPolicy?.maxAttempts ?? 6,
|
|
309
309
|
stableWindowMs: options.retryPolicy?.stableWindowMs ?? 30_000,
|
|
310
310
|
};
|
|
311
|
+
const turnEndRetryPolicy = {
|
|
312
|
+
baseDelayMs: options.turnEndRetryPolicy?.baseDelayMs ?? 1_000,
|
|
313
|
+
maxDelayMs: options.turnEndRetryPolicy?.maxDelayMs ?? 30_000,
|
|
314
|
+
maxAttempts: options.turnEndRetryPolicy?.maxAttempts ?? 3,
|
|
315
|
+
};
|
|
316
|
+
const turnEndRetryScheduler = options.turnEndRetryScheduler ?? {
|
|
317
|
+
now: Date.now,
|
|
318
|
+
setTimeout,
|
|
319
|
+
clearTimeout,
|
|
320
|
+
};
|
|
311
321
|
const emit = (event) => {
|
|
312
322
|
if (event.type === "delivery")
|
|
313
323
|
telemetry?.delivery(event.agentId, event.messageId, event.status);
|
|
@@ -316,6 +326,7 @@ function createRuntimeHost(options) {
|
|
|
316
326
|
};
|
|
317
327
|
const runtimeEnv = (config, generation) => {
|
|
318
328
|
const base = {
|
|
329
|
+
LARKIN_RUNTIME: "1",
|
|
319
330
|
LARKIN_AGENT_ID: config.agentId,
|
|
320
331
|
...(generation ? {
|
|
321
332
|
LARKIN_RUNTIME_OBSERVATION_GENERATION: generation,
|
|
@@ -361,6 +372,8 @@ function createRuntimeHost(options) {
|
|
|
361
372
|
record.updatedAt = now();
|
|
362
373
|
agent.records.set(record.deliveryId, record);
|
|
363
374
|
agent.byMessage.set(record.messageId, record.deliveryId);
|
|
375
|
+
if (status === "consumed" || status === "error")
|
|
376
|
+
clearPendingRetryTimerWhenIdle(agent);
|
|
364
377
|
emitConsumed(agent, persist(agent));
|
|
365
378
|
return agent.records.get(record.deliveryId) ?? record;
|
|
366
379
|
};
|
|
@@ -466,8 +479,42 @@ function createRuntimeHost(options) {
|
|
|
466
479
|
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: next.deliveryId, messageId: next.messageId, status: "consumed" });
|
|
467
480
|
}
|
|
468
481
|
}
|
|
482
|
+
clearPendingRetryTimerWhenIdle(agent);
|
|
469
483
|
};
|
|
470
484
|
const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
|
|
485
|
+
const turnEndRetryDelayMs = (attempt) => Math.min(turnEndRetryPolicy.maxDelayMs, turnEndRetryPolicy.baseDelayMs * 2 ** (attempt - 1));
|
|
486
|
+
const isRetryDue = (record) => !record.retryNotBefore || Number.isNaN(Date.parse(record.retryNotBefore))
|
|
487
|
+
|| Date.parse(record.retryNotBefore) <= turnEndRetryScheduler.now();
|
|
488
|
+
const clearDeliveryRetryTimer = (agent) => {
|
|
489
|
+
if (!agent.deliveryRetryTimer)
|
|
490
|
+
return;
|
|
491
|
+
turnEndRetryScheduler.clearTimeout(agent.deliveryRetryTimer);
|
|
492
|
+
agent.deliveryRetryTimer = null;
|
|
493
|
+
};
|
|
494
|
+
function clearPendingRetryTimerWhenIdle(agent) {
|
|
495
|
+
if (!agent.deliveryRetryTimer)
|
|
496
|
+
return;
|
|
497
|
+
if ([...agent.records.values()].some((record) => record.status === "pending" && record.retryNotBefore))
|
|
498
|
+
return;
|
|
499
|
+
clearDeliveryRetryTimer(agent);
|
|
500
|
+
}
|
|
501
|
+
const schedulePendingRetry = (agent) => {
|
|
502
|
+
if (agent.stopped || agent.deliveryRetryTimer)
|
|
503
|
+
return;
|
|
504
|
+
const next = [...agent.records.values()]
|
|
505
|
+
.filter((record) => record.status === "pending" && record.retryNotBefore)
|
|
506
|
+
.map((record) => Date.parse(record.retryNotBefore))
|
|
507
|
+
.filter((at) => Number.isFinite(at))
|
|
508
|
+
.sort((left, right) => left - right)[0];
|
|
509
|
+
if (next === undefined)
|
|
510
|
+
return;
|
|
511
|
+
const delay = Math.max(0, next - turnEndRetryScheduler.now());
|
|
512
|
+
agent.deliveryRetryTimer = turnEndRetryScheduler.setTimeout(() => {
|
|
513
|
+
agent.deliveryRetryTimer = null;
|
|
514
|
+
void retryPending(agent);
|
|
515
|
+
}, delay);
|
|
516
|
+
agent.deliveryRetryTimer.unref?.();
|
|
517
|
+
};
|
|
471
518
|
const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
|
|
472
519
|
const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
|
|
473
520
|
const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
|
|
@@ -495,13 +542,32 @@ function createRuntimeHost(options) {
|
|
|
495
542
|
for (const record of agent.records.values()) {
|
|
496
543
|
if (record.status !== "accepted")
|
|
497
544
|
continue;
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
545
|
+
const attempts = record.turnEndRetryAttempts ?? 0;
|
|
546
|
+
if (attempts >= turnEndRetryPolicy.maxAttempts) {
|
|
547
|
+
record.reason = `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`;
|
|
548
|
+
record.retryable = false;
|
|
549
|
+
const terminal = setRecord(agent, record, "error");
|
|
550
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: terminal.deliveryId,
|
|
551
|
+
messageId: terminal.messageId, status: "error", reason: terminal.reason });
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const attempt = attempts + 1;
|
|
555
|
+
const delay = turnEndRetryDelayMs(attempt);
|
|
556
|
+
record.turnEndRetryAttempts = attempt;
|
|
557
|
+
record.retryNotBefore = new Date(turnEndRetryScheduler.now() + delay).toISOString();
|
|
558
|
+
record.reason = TURN_END_RETRY_REASON;
|
|
559
|
+
record.retryable = true;
|
|
560
|
+
record.input = { ...record.input, attempt: (record.input.attempt ?? 0) + 1 };
|
|
561
|
+
const deferred = setRecord(agent, record, "pending");
|
|
562
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: deferred.deliveryId,
|
|
563
|
+
messageId: deferred.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
|
|
564
|
+
}
|
|
565
|
+
schedulePendingRetry(agent);
|
|
501
566
|
return;
|
|
502
567
|
}
|
|
503
568
|
const consumed = [];
|
|
504
569
|
const deferred = [];
|
|
570
|
+
const exhausted = [];
|
|
505
571
|
try {
|
|
506
572
|
store.withInboxTransaction(() => {
|
|
507
573
|
const inboxIds = new Set(store.readNdjson("inbox").flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
|
|
@@ -522,8 +588,31 @@ function createRuntimeHost(options) {
|
|
|
522
588
|
if (current.status !== "accepted" || candidate.status !== "accepted" || !inboxIds.has(candidate.messageId)) {
|
|
523
589
|
return candidate;
|
|
524
590
|
}
|
|
525
|
-
const
|
|
526
|
-
|
|
591
|
+
const attempts = candidate.turnEndRetryAttempts ?? 0;
|
|
592
|
+
if (attempts >= turnEndRetryPolicy.maxAttempts) {
|
|
593
|
+
const terminal = { ...candidate, status: "error", updatedAt: now(),
|
|
594
|
+
reason: `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`,
|
|
595
|
+
retryable: false };
|
|
596
|
+
changed = true;
|
|
597
|
+
pendingUpdates.push(terminal);
|
|
598
|
+
return terminal;
|
|
599
|
+
}
|
|
600
|
+
const attempt = attempts + 1;
|
|
601
|
+
const delay = turnEndRetryDelayMs(attempt);
|
|
602
|
+
const staleInput = candidate.input && typeof candidate.input === "object" ? candidate.input : null;
|
|
603
|
+
const next = {
|
|
604
|
+
...candidate,
|
|
605
|
+
status: "pending",
|
|
606
|
+
updatedAt: now(),
|
|
607
|
+
reason: TURN_END_RETRY_REASON,
|
|
608
|
+
retryable: true,
|
|
609
|
+
turnEndRetryAttempts: attempt,
|
|
610
|
+
retryNotBefore: new Date(turnEndRetryScheduler.now() + delay).toISOString(),
|
|
611
|
+
input: {
|
|
612
|
+
...(staleInput ?? { inputId: candidate.deliveryId, deliveryId: candidate.deliveryId, kind: "wake", text: "" }),
|
|
613
|
+
attempt: (Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0) + 1,
|
|
614
|
+
},
|
|
615
|
+
};
|
|
527
616
|
changed = true;
|
|
528
617
|
pendingUpdates.push(next);
|
|
529
618
|
return next;
|
|
@@ -532,7 +621,10 @@ function createRuntimeHost(options) {
|
|
|
532
621
|
store.writeJson("runtimeDeliveries", { ...disk, records });
|
|
533
622
|
for (const next of pendingUpdates) {
|
|
534
623
|
agent.records.set(next.deliveryId, next);
|
|
535
|
-
|
|
624
|
+
if (next.status === "error")
|
|
625
|
+
exhausted.push(next);
|
|
626
|
+
else
|
|
627
|
+
deferred.push(next);
|
|
536
628
|
}
|
|
537
629
|
}
|
|
538
630
|
});
|
|
@@ -545,6 +637,11 @@ function createRuntimeHost(options) {
|
|
|
545
637
|
for (const record of deferred)
|
|
546
638
|
emit({ type: "delivery", agentId: agent.config.agentId,
|
|
547
639
|
deliveryId: record.deliveryId, messageId: record.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
|
|
640
|
+
for (const record of exhausted)
|
|
641
|
+
emit({ type: "delivery", agentId: agent.config.agentId,
|
|
642
|
+
deliveryId: record.deliveryId, messageId: record.messageId, status: "error", reason: record.reason });
|
|
643
|
+
if (deferred.length)
|
|
644
|
+
schedulePendingRetry(agent);
|
|
548
645
|
};
|
|
549
646
|
// Production callers persist the canonical Inbox before calling deliver(). If a
|
|
550
647
|
// concurrent drain wins between that append and ledger creation, close only the
|
|
@@ -743,9 +840,11 @@ function createRuntimeHost(options) {
|
|
|
743
840
|
if (idleGate)
|
|
744
841
|
await idleGate;
|
|
745
842
|
}
|
|
746
|
-
const record = [...agent.records.values()].find((candidate) => candidate.status === "pending" || candidate.status === "submitting");
|
|
843
|
+
const record = [...agent.records.values()].find((candidate) => (candidate.status === "pending" && isRetryDue(candidate)) || candidate.status === "submitting");
|
|
747
844
|
if (record)
|
|
748
845
|
await submit(agent, record, false);
|
|
846
|
+
else
|
|
847
|
+
schedulePendingRetry(agent);
|
|
749
848
|
})();
|
|
750
849
|
agent.retryPendingInFlight = run;
|
|
751
850
|
try {
|
|
@@ -1519,6 +1618,8 @@ function createRuntimeHost(options) {
|
|
|
1519
1618
|
clearInterval(previous.poller);
|
|
1520
1619
|
if (previous.retryTimer)
|
|
1521
1620
|
clearTimeout(previous.retryTimer);
|
|
1621
|
+
if (previous.deliveryRetryTimer)
|
|
1622
|
+
turnEndRetryScheduler.clearTimeout(previous.deliveryRetryTimer);
|
|
1522
1623
|
if (previous.stabilityTimer)
|
|
1523
1624
|
clearTimeout(previous.stabilityTimer);
|
|
1524
1625
|
const previousFailure = currentAuthFailure(previous);
|
|
@@ -1530,7 +1631,7 @@ function createRuntimeHost(options) {
|
|
|
1530
1631
|
}, previousFailure));
|
|
1531
1632
|
const candidate = {
|
|
1532
1633
|
...previous, config, adapter, session, launchId: node_crypto_1.default.randomUUID(), busy: false, submitting: false,
|
|
1533
|
-
starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null,
|
|
1634
|
+
starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null,
|
|
1534
1635
|
recreateAttempts: 0, stabilityTimer: null, recreateReason: null, stopped: false,
|
|
1535
1636
|
disabledReason: null, configurationRecovery: null,
|
|
1536
1637
|
authFailureActive: scoped && previous.authFailureActive,
|
|
@@ -1623,6 +1724,7 @@ function createRuntimeHost(options) {
|
|
|
1623
1724
|
if (pendingAfterCreate > 0) {
|
|
1624
1725
|
throw new RuntimeSessionResetError("inbox_backlog", `Agent ${agentId} received Inbox backlog during reset`, pendingAfterCreate);
|
|
1625
1726
|
}
|
|
1727
|
+
clearDeliveryRetryTimer(agent);
|
|
1626
1728
|
agent.generation += 1;
|
|
1627
1729
|
agent.launchId = node_crypto_1.default.randomUUID();
|
|
1628
1730
|
agent.config.sessionId = null;
|
|
@@ -1893,7 +1995,7 @@ function createRuntimeHost(options) {
|
|
|
1893
1995
|
retryPendingInFlight: null,
|
|
1894
1996
|
records: new Map(persisted.records.map((record) => [record.deliveryId, record])),
|
|
1895
1997
|
byMessage: new Map(persisted.records.map((record) => [record.messageId, record.deliveryId])),
|
|
1896
|
-
generation: 0, poller: null, retryTimer: null, recreateAttempts: 0,
|
|
1998
|
+
generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null, recreateAttempts: 0,
|
|
1897
1999
|
stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
|
|
1898
2000
|
configurationRecovery: null, stateStore,
|
|
1899
2001
|
compactionBreaker: config.runtime === "pi" && stateStore
|
|
@@ -2065,6 +2167,7 @@ function createRuntimeHost(options) {
|
|
|
2065
2167
|
clearInterval(agent.poller);
|
|
2066
2168
|
if (agent.retryTimer)
|
|
2067
2169
|
clearTimeout(agent.retryTimer);
|
|
2170
|
+
clearDeliveryRetryTimer(agent);
|
|
2068
2171
|
if (agent.stabilityTimer)
|
|
2069
2172
|
clearTimeout(agent.stabilityTimer);
|
|
2070
2173
|
if (agent.busy)
|
|
@@ -299,6 +299,16 @@ export function createRuntimeHost(options) {
|
|
|
299
299
|
maxAttempts: options.retryPolicy?.maxAttempts ?? 6,
|
|
300
300
|
stableWindowMs: options.retryPolicy?.stableWindowMs ?? 30_000,
|
|
301
301
|
};
|
|
302
|
+
const turnEndRetryPolicy = {
|
|
303
|
+
baseDelayMs: options.turnEndRetryPolicy?.baseDelayMs ?? 1_000,
|
|
304
|
+
maxDelayMs: options.turnEndRetryPolicy?.maxDelayMs ?? 30_000,
|
|
305
|
+
maxAttempts: options.turnEndRetryPolicy?.maxAttempts ?? 3,
|
|
306
|
+
};
|
|
307
|
+
const turnEndRetryScheduler = options.turnEndRetryScheduler ?? {
|
|
308
|
+
now: Date.now,
|
|
309
|
+
setTimeout,
|
|
310
|
+
clearTimeout,
|
|
311
|
+
};
|
|
302
312
|
const emit = (event) => {
|
|
303
313
|
if (event.type === "delivery")
|
|
304
314
|
telemetry?.delivery(event.agentId, event.messageId, event.status);
|
|
@@ -307,6 +317,7 @@ export function createRuntimeHost(options) {
|
|
|
307
317
|
};
|
|
308
318
|
const runtimeEnv = (config, generation) => {
|
|
309
319
|
const base = {
|
|
320
|
+
LARKIN_RUNTIME: "1",
|
|
310
321
|
LARKIN_AGENT_ID: config.agentId,
|
|
311
322
|
...(generation ? {
|
|
312
323
|
LARKIN_RUNTIME_OBSERVATION_GENERATION: generation,
|
|
@@ -352,6 +363,8 @@ export function createRuntimeHost(options) {
|
|
|
352
363
|
record.updatedAt = now();
|
|
353
364
|
agent.records.set(record.deliveryId, record);
|
|
354
365
|
agent.byMessage.set(record.messageId, record.deliveryId);
|
|
366
|
+
if (status === "consumed" || status === "error")
|
|
367
|
+
clearPendingRetryTimerWhenIdle(agent);
|
|
355
368
|
emitConsumed(agent, persist(agent));
|
|
356
369
|
return agent.records.get(record.deliveryId) ?? record;
|
|
357
370
|
};
|
|
@@ -457,8 +470,42 @@ export function createRuntimeHost(options) {
|
|
|
457
470
|
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: next.deliveryId, messageId: next.messageId, status: "consumed" });
|
|
458
471
|
}
|
|
459
472
|
}
|
|
473
|
+
clearPendingRetryTimerWhenIdle(agent);
|
|
460
474
|
};
|
|
461
475
|
const TURN_END_RETRY_REASON = "runtime turn ended before Inbox consumption was observed";
|
|
476
|
+
const turnEndRetryDelayMs = (attempt) => Math.min(turnEndRetryPolicy.maxDelayMs, turnEndRetryPolicy.baseDelayMs * 2 ** (attempt - 1));
|
|
477
|
+
const isRetryDue = (record) => !record.retryNotBefore || Number.isNaN(Date.parse(record.retryNotBefore))
|
|
478
|
+
|| Date.parse(record.retryNotBefore) <= turnEndRetryScheduler.now();
|
|
479
|
+
const clearDeliveryRetryTimer = (agent) => {
|
|
480
|
+
if (!agent.deliveryRetryTimer)
|
|
481
|
+
return;
|
|
482
|
+
turnEndRetryScheduler.clearTimeout(agent.deliveryRetryTimer);
|
|
483
|
+
agent.deliveryRetryTimer = null;
|
|
484
|
+
};
|
|
485
|
+
function clearPendingRetryTimerWhenIdle(agent) {
|
|
486
|
+
if (!agent.deliveryRetryTimer)
|
|
487
|
+
return;
|
|
488
|
+
if ([...agent.records.values()].some((record) => record.status === "pending" && record.retryNotBefore))
|
|
489
|
+
return;
|
|
490
|
+
clearDeliveryRetryTimer(agent);
|
|
491
|
+
}
|
|
492
|
+
const schedulePendingRetry = (agent) => {
|
|
493
|
+
if (agent.stopped || agent.deliveryRetryTimer)
|
|
494
|
+
return;
|
|
495
|
+
const next = [...agent.records.values()]
|
|
496
|
+
.filter((record) => record.status === "pending" && record.retryNotBefore)
|
|
497
|
+
.map((record) => Date.parse(record.retryNotBefore))
|
|
498
|
+
.filter((at) => Number.isFinite(at))
|
|
499
|
+
.sort((left, right) => left - right)[0];
|
|
500
|
+
if (next === undefined)
|
|
501
|
+
return;
|
|
502
|
+
const delay = Math.max(0, next - turnEndRetryScheduler.now());
|
|
503
|
+
agent.deliveryRetryTimer = turnEndRetryScheduler.setTimeout(() => {
|
|
504
|
+
agent.deliveryRetryTimer = null;
|
|
505
|
+
void retryPending(agent);
|
|
506
|
+
}, delay);
|
|
507
|
+
agent.deliveryRetryTimer.unref?.();
|
|
508
|
+
};
|
|
462
509
|
const INBOX_UPDATE_PROMOTE_REASON = "accepted inbox_update promoted after Agent became idle";
|
|
463
510
|
const isInboxUpdateKind = (kind) => kind === "inbox_update" || kind === "inbox-update";
|
|
464
511
|
const agentIsIdleForInboxScan = (agent) => !agent.stopped && !agent.busy && !agent.submitting && !agent.turnInProgress && !agent.starting && Boolean(agent.session);
|
|
@@ -486,13 +533,32 @@ export function createRuntimeHost(options) {
|
|
|
486
533
|
for (const record of agent.records.values()) {
|
|
487
534
|
if (record.status !== "accepted")
|
|
488
535
|
continue;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
536
|
+
const attempts = record.turnEndRetryAttempts ?? 0;
|
|
537
|
+
if (attempts >= turnEndRetryPolicy.maxAttempts) {
|
|
538
|
+
record.reason = `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`;
|
|
539
|
+
record.retryable = false;
|
|
540
|
+
const terminal = setRecord(agent, record, "error");
|
|
541
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: terminal.deliveryId,
|
|
542
|
+
messageId: terminal.messageId, status: "error", reason: terminal.reason });
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const attempt = attempts + 1;
|
|
546
|
+
const delay = turnEndRetryDelayMs(attempt);
|
|
547
|
+
record.turnEndRetryAttempts = attempt;
|
|
548
|
+
record.retryNotBefore = new Date(turnEndRetryScheduler.now() + delay).toISOString();
|
|
549
|
+
record.reason = TURN_END_RETRY_REASON;
|
|
550
|
+
record.retryable = true;
|
|
551
|
+
record.input = { ...record.input, attempt: (record.input.attempt ?? 0) + 1 };
|
|
552
|
+
const deferred = setRecord(agent, record, "pending");
|
|
553
|
+
emit({ type: "delivery", agentId: agent.config.agentId, deliveryId: deferred.deliveryId,
|
|
554
|
+
messageId: deferred.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
|
|
555
|
+
}
|
|
556
|
+
schedulePendingRetry(agent);
|
|
492
557
|
return;
|
|
493
558
|
}
|
|
494
559
|
const consumed = [];
|
|
495
560
|
const deferred = [];
|
|
561
|
+
const exhausted = [];
|
|
496
562
|
try {
|
|
497
563
|
store.withInboxTransaction(() => {
|
|
498
564
|
const inboxIds = new Set(store.readNdjson("inbox").flatMap((row) => typeof row?.message_id === "string" ? [row.message_id] : []));
|
|
@@ -513,8 +579,31 @@ export function createRuntimeHost(options) {
|
|
|
513
579
|
if (current.status !== "accepted" || candidate.status !== "accepted" || !inboxIds.has(candidate.messageId)) {
|
|
514
580
|
return candidate;
|
|
515
581
|
}
|
|
516
|
-
const
|
|
517
|
-
|
|
582
|
+
const attempts = candidate.turnEndRetryAttempts ?? 0;
|
|
583
|
+
if (attempts >= turnEndRetryPolicy.maxAttempts) {
|
|
584
|
+
const terminal = { ...candidate, status: "error", updatedAt: now(),
|
|
585
|
+
reason: `turn-end redelivery exhausted after ${turnEndRetryPolicy.maxAttempts} attempts`,
|
|
586
|
+
retryable: false };
|
|
587
|
+
changed = true;
|
|
588
|
+
pendingUpdates.push(terminal);
|
|
589
|
+
return terminal;
|
|
590
|
+
}
|
|
591
|
+
const attempt = attempts + 1;
|
|
592
|
+
const delay = turnEndRetryDelayMs(attempt);
|
|
593
|
+
const staleInput = candidate.input && typeof candidate.input === "object" ? candidate.input : null;
|
|
594
|
+
const next = {
|
|
595
|
+
...candidate,
|
|
596
|
+
status: "pending",
|
|
597
|
+
updatedAt: now(),
|
|
598
|
+
reason: TURN_END_RETRY_REASON,
|
|
599
|
+
retryable: true,
|
|
600
|
+
turnEndRetryAttempts: attempt,
|
|
601
|
+
retryNotBefore: new Date(turnEndRetryScheduler.now() + delay).toISOString(),
|
|
602
|
+
input: {
|
|
603
|
+
...(staleInput ?? { inputId: candidate.deliveryId, deliveryId: candidate.deliveryId, kind: "wake", text: "" }),
|
|
604
|
+
attempt: (Number.isSafeInteger(staleInput?.attempt) && Number(staleInput?.attempt) >= 0 ? Number(staleInput.attempt) : 0) + 1,
|
|
605
|
+
},
|
|
606
|
+
};
|
|
518
607
|
changed = true;
|
|
519
608
|
pendingUpdates.push(next);
|
|
520
609
|
return next;
|
|
@@ -523,7 +612,10 @@ export function createRuntimeHost(options) {
|
|
|
523
612
|
store.writeJson("runtimeDeliveries", { ...disk, records });
|
|
524
613
|
for (const next of pendingUpdates) {
|
|
525
614
|
agent.records.set(next.deliveryId, next);
|
|
526
|
-
|
|
615
|
+
if (next.status === "error")
|
|
616
|
+
exhausted.push(next);
|
|
617
|
+
else
|
|
618
|
+
deferred.push(next);
|
|
527
619
|
}
|
|
528
620
|
}
|
|
529
621
|
});
|
|
@@ -536,6 +628,11 @@ export function createRuntimeHost(options) {
|
|
|
536
628
|
for (const record of deferred)
|
|
537
629
|
emit({ type: "delivery", agentId: agent.config.agentId,
|
|
538
630
|
deliveryId: record.deliveryId, messageId: record.messageId, status: "deferred", reason: TURN_END_RETRY_REASON });
|
|
631
|
+
for (const record of exhausted)
|
|
632
|
+
emit({ type: "delivery", agentId: agent.config.agentId,
|
|
633
|
+
deliveryId: record.deliveryId, messageId: record.messageId, status: "error", reason: record.reason });
|
|
634
|
+
if (deferred.length)
|
|
635
|
+
schedulePendingRetry(agent);
|
|
539
636
|
};
|
|
540
637
|
// Production callers persist the canonical Inbox before calling deliver(). If a
|
|
541
638
|
// concurrent drain wins between that append and ledger creation, close only the
|
|
@@ -734,9 +831,11 @@ export function createRuntimeHost(options) {
|
|
|
734
831
|
if (idleGate)
|
|
735
832
|
await idleGate;
|
|
736
833
|
}
|
|
737
|
-
const record = [...agent.records.values()].find((candidate) => candidate.status === "pending" || candidate.status === "submitting");
|
|
834
|
+
const record = [...agent.records.values()].find((candidate) => (candidate.status === "pending" && isRetryDue(candidate)) || candidate.status === "submitting");
|
|
738
835
|
if (record)
|
|
739
836
|
await submit(agent, record, false);
|
|
837
|
+
else
|
|
838
|
+
schedulePendingRetry(agent);
|
|
740
839
|
})();
|
|
741
840
|
agent.retryPendingInFlight = run;
|
|
742
841
|
try {
|
|
@@ -1510,6 +1609,8 @@ export function createRuntimeHost(options) {
|
|
|
1510
1609
|
clearInterval(previous.poller);
|
|
1511
1610
|
if (previous.retryTimer)
|
|
1512
1611
|
clearTimeout(previous.retryTimer);
|
|
1612
|
+
if (previous.deliveryRetryTimer)
|
|
1613
|
+
turnEndRetryScheduler.clearTimeout(previous.deliveryRetryTimer);
|
|
1513
1614
|
if (previous.stabilityTimer)
|
|
1514
1615
|
clearTimeout(previous.stabilityTimer);
|
|
1515
1616
|
const previousFailure = currentAuthFailure(previous);
|
|
@@ -1521,7 +1622,7 @@ export function createRuntimeHost(options) {
|
|
|
1521
1622
|
}, previousFailure));
|
|
1522
1623
|
const candidate = {
|
|
1523
1624
|
...previous, config, adapter, session, launchId: crypto.randomUUID(), busy: false, submitting: false,
|
|
1524
|
-
starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null,
|
|
1625
|
+
starting: null, retryAfterSubmit: false, generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null,
|
|
1525
1626
|
recreateAttempts: 0, stabilityTimer: null, recreateReason: null, stopped: false,
|
|
1526
1627
|
disabledReason: null, configurationRecovery: null,
|
|
1527
1628
|
authFailureActive: scoped && previous.authFailureActive,
|
|
@@ -1614,6 +1715,7 @@ export function createRuntimeHost(options) {
|
|
|
1614
1715
|
if (pendingAfterCreate > 0) {
|
|
1615
1716
|
throw new RuntimeSessionResetError("inbox_backlog", `Agent ${agentId} received Inbox backlog during reset`, pendingAfterCreate);
|
|
1616
1717
|
}
|
|
1718
|
+
clearDeliveryRetryTimer(agent);
|
|
1617
1719
|
agent.generation += 1;
|
|
1618
1720
|
agent.launchId = crypto.randomUUID();
|
|
1619
1721
|
agent.config.sessionId = null;
|
|
@@ -1884,7 +1986,7 @@ export function createRuntimeHost(options) {
|
|
|
1884
1986
|
retryPendingInFlight: null,
|
|
1885
1987
|
records: new Map(persisted.records.map((record) => [record.deliveryId, record])),
|
|
1886
1988
|
byMessage: new Map(persisted.records.map((record) => [record.messageId, record.deliveryId])),
|
|
1887
|
-
generation: 0, poller: null, retryTimer: null, recreateAttempts: 0,
|
|
1989
|
+
generation: 0, poller: null, retryTimer: null, deliveryRetryTimer: null, recreateAttempts: 0,
|
|
1888
1990
|
stabilityTimer: null, recreateReason: null, stopped: false, disabledReason: null,
|
|
1889
1991
|
configurationRecovery: null, stateStore,
|
|
1890
1992
|
compactionBreaker: config.runtime === "pi" && stateStore
|
|
@@ -2056,6 +2158,7 @@ export function createRuntimeHost(options) {
|
|
|
2056
2158
|
clearInterval(agent.poller);
|
|
2057
2159
|
if (agent.retryTimer)
|
|
2058
2160
|
clearTimeout(agent.retryTimer);
|
|
2161
|
+
clearDeliveryRetryTimer(agent);
|
|
2059
2162
|
if (agent.stabilityTimer)
|
|
2060
2163
|
clearTimeout(agent.stabilityTimer);
|
|
2061
2164
|
if (agent.busy)
|