larkin 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +15 -0
- package/dist/agent/agent-cli-capabilities.cjs +1 -1
- package/dist/agent/agent-cli-capabilities.mjs +1 -1
- package/dist/agent/config-cli-contract.cjs +3 -1
- package/dist/agent/config-cli-contract.mjs +3 -1
- package/dist/agent/inbox-audit-heartbeat.cjs +157 -37
- package/dist/agent/inbox-audit-heartbeat.mjs +122 -36
- package/dist/agent/missed-outbound-scan.cjs +326 -54
- package/dist/agent/missed-outbound-scan.mjs +319 -53
- package/dist/app/agent-cli.mjs +37 -6
- package/dist/app/agent-config.mjs +36 -5
- package/dist/app/cli.mjs +1 -0
- package/dist/dashboard/dashboard-config-controller.mjs +15 -0
- package/dist/dashboard/web/assets/dashboard.css +1 -1
- package/dist/dashboard/web/assets/dashboard.js +15 -15
- package/dist/feishu/host-shell.cjs +110 -19
- package/dist/feishu/host-shell.mjs +113 -22
- package/dist/platform/config.cjs +173 -5
- package/dist/platform/config.mjs +167 -4
- package/dist/runtime/provider-error-classifier.cjs +14 -0
- package/dist/runtime/provider-error-classifier.mjs +13 -0
- package/dist/runtime/runtime-adapters.mjs +40 -13
- package/dist/runtime/runtime-host.cjs +46 -26
- package/dist/runtime/runtime-host.mjs +46 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.4
|
|
4
|
+
|
|
5
|
+
Inbox Audit is now optional and off by default. The Dashboard and CLI support global and per-Agent switches and inspection gaps. Saving a gap updates scheduling without replacing Runtime sessions; editing a gap alone does not enable auditing. Only originally wake-eligible human group/topic work is audited.
|
|
6
|
+
|
|
7
|
+
Audit reads no longer mark work complete. Agents explicitly confirm a receipt after checking; receipts are scoped to an observation generation, and durable canonical sequence ordering protects newer work from stale completion or deferred registration. Contended registrations are retried durably. Unproven legacy audit-index rows are ignored while ordinary Inbox and conversation history remain intact.
|
|
8
|
+
|
|
9
|
+
## 0.5.3
|
|
10
|
+
|
|
11
|
+
External Pi initialization now runs with bounded concurrency and recovers from isolated startup-probe timeouts through the existing retry policy. Shutdown waits for pending initialization and closes late sessions. Generic Runtime and delivery failures show unavailable instead of incompatible, while explicit prerequisite failures and useful provider diagnostics are preserved. Recovery workflow tests wait for delivery consumption and Inbox watermarks to converge and isolate temporary state.
|
|
12
|
+
|
|
3
13
|
## 0.5.2
|
|
4
14
|
|
|
5
15
|
External pi now runs with the user's own Pi home; compaction settings move to the Agent workspace's `.pi/settings.json`; 0.5.0/0.5.1 started external pi with an empty agent dir and saw no logins. Stock pi 0.84.x does not emit `get_state.compactionCapabilities` (that handshake only existed for the bundled build); Larkin accepts the absence and keeps native compaction via the workspace settings file, while a present handshake must still match exactly.
|
package/README.md
CHANGED
|
@@ -93,6 +93,21 @@ During setup, choose one of the three externally installed runtimes: Pi (`pi`),
|
|
|
93
93
|
|
|
94
94
|
`larkin setup --model <id>` optionally stores a catalog model for that runtime. After setup, use `larkin model` and `larkin runtime` to inspect or switch. For Pi, Larkin talks to your installed `pi --mode rpc`, verifies the RPC handshake and compaction capability contract, and injects its supported extensions through `pi -e` (background subagents and the 60-second foreground bash timeout guard).
|
|
95
95
|
|
|
96
|
+
### Optional Inbox Audit
|
|
97
|
+
|
|
98
|
+
Inbox Audit is **off by default**. In the Dashboard, use **Global settings → Inbox 巡检** to configure the switch and inspection gap, or the selected Agent's **Configuration → Inbox 巡检** to override either value. The gap defaults to 15 minutes and supports 1 minute to 24 hours. Saving a gap alone keeps auditing disabled; saved settings survive restart and update later scheduling without replacing the Agent Runtime session.
|
|
99
|
+
|
|
100
|
+
The same settings are available through the CLI:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
larkin config inbox-audit global on --interval 15m
|
|
104
|
+
larkin config inbox-audit agent off --agent <App-ID>
|
|
105
|
+
larkin config inbox-audit agent inherit --agent <App-ID> --interval inherit
|
|
106
|
+
larkin config inbox-audit global off
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
When enabled, audit only revisits originally wake-eligible human group/topic work and does not wake a model for an empty work list. Reading an audit list does not complete it: the managed Agent follows the returned inspection instructions and explicitly confirms the receipt after checking. A failed check remains retryable; newer messages cannot be completed by an older receipt. Old audit-index records without proven eligibility or observation identity are ignored; ordinary Inbox messages and conversation history are preserved.
|
|
110
|
+
|
|
96
111
|
<details>
|
|
97
112
|
<summary>Windows support and optional autostart</summary>
|
|
98
113
|
|
|
@@ -32,7 +32,7 @@ function agentCliPromptCapabilities(executable = "larkin") {
|
|
|
32
32
|
const commands = Object.entries(exports.AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
|
|
33
33
|
command: `${group} ${operation}`,
|
|
34
34
|
purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
|
|
35
|
-
: operation === "audit" ? "Read bounded observed group/topic audit targets
|
|
35
|
+
: operation === "audit" ? "Read bounded observed group/topic audit targets; inspect first, then explicitly confirm the returned receipt."
|
|
36
36
|
: "Poll full messages and direct-ack the returned batch.") :
|
|
37
37
|
group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
|
|
38
38
|
group === "reminder" ? "Manage this Agent's durable reminders." :
|
|
@@ -24,7 +24,7 @@ export function agentCliPromptCapabilities(executable = "larkin") {
|
|
|
24
24
|
const commands = Object.entries(AGENT_CLI_CAPABILITIES.commands).flatMap(([group, operations]) => operations.map((operation) => ({
|
|
25
25
|
command: `${group} ${operation}`,
|
|
26
26
|
purpose: group === "inbox" ? (operation === "check" ? "Read pending target summaries without consuming messages."
|
|
27
|
-
: operation === "audit" ? "Read bounded observed group/topic audit targets
|
|
27
|
+
: operation === "audit" ? "Read bounded observed group/topic audit targets; inspect first, then explicitly confirm the returned receipt."
|
|
28
28
|
: "Poll full messages and direct-ack the returned batch.") :
|
|
29
29
|
group === "comment" ? "Reply once to the exact cloud-document comment bound by a polled Inbox message id." :
|
|
30
30
|
group === "reminder" ? "Manage this Agent's durable reminders." :
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CONFIG_CLI_VALUES = exports.CONFIG_CLI_USAGE = exports.CONFIG_CLI_OPERATIONS = void 0;
|
|
4
|
-
exports.CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "apply"]);
|
|
4
|
+
exports.CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "inbox-audit", "apply"]);
|
|
5
5
|
exports.CONFIG_CLI_USAGE = Object.freeze([
|
|
6
6
|
"larkin config show [--agent <App ID>] [--chat <oc_id>] [--json]",
|
|
7
7
|
"larkin config runtime <runtime> [--model <model>] [--agent <App ID>]",
|
|
@@ -10,6 +10,8 @@ exports.CONFIG_CLI_USAGE = Object.freeze([
|
|
|
10
10
|
"larkin config mention global <require|free>",
|
|
11
11
|
"larkin config mention agent <inherit|require|free> [--agent <App ID>]",
|
|
12
12
|
"larkin config mention chat <oc_id> <inherit|require|free> [--agent <App ID>]",
|
|
13
|
+
"larkin config inbox-audit global <on|off> [--interval <15m|1h>]",
|
|
14
|
+
"larkin config inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]",
|
|
13
15
|
"larkin config apply [--agent <App ID>]",
|
|
14
16
|
]);
|
|
15
17
|
exports.CONFIG_CLI_VALUES = Object.freeze({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "apply"]);
|
|
1
|
+
export const CONFIG_CLI_OPERATIONS = Object.freeze(["show", "runtime", "model", "effort", "mention", "inbox-audit", "apply"]);
|
|
2
2
|
export const CONFIG_CLI_USAGE = Object.freeze([
|
|
3
3
|
"larkin config show [--agent <App ID>] [--chat <oc_id>] [--json]",
|
|
4
4
|
"larkin config runtime <runtime> [--model <model>] [--agent <App ID>]",
|
|
@@ -7,6 +7,8 @@ export const CONFIG_CLI_USAGE = Object.freeze([
|
|
|
7
7
|
"larkin config mention global <require|free>",
|
|
8
8
|
"larkin config mention agent <inherit|require|free> [--agent <App ID>]",
|
|
9
9
|
"larkin config mention chat <oc_id> <inherit|require|free> [--agent <App ID>]",
|
|
10
|
+
"larkin config inbox-audit global <on|off> [--interval <15m|1h>]",
|
|
11
|
+
"larkin config inbox-audit agent <inherit|on|off> [--agent <App ID>] [--interval <15m|inherit>]",
|
|
10
12
|
"larkin config apply [--agent <App ID>]",
|
|
11
13
|
]);
|
|
12
14
|
export const CONFIG_CLI_VALUES = Object.freeze({
|
|
@@ -1,62 +1,182 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.InboxAuditHeartbeat = exports.INBOX_AUDIT_CADENCE_MS = void 0;
|
|
36
|
+
exports.InboxAuditHeartbeat = exports.MAX_INBOX_AUDIT_DIAGNOSTIC_CHARS = exports.INBOX_AUDIT_CADENCE_MS = void 0;
|
|
37
|
+
exports.boundedInboxAuditDiagnostic = boundedInboxAuditDiagnostic;
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
const path = __importStar(require("node:path"));
|
|
4
40
|
const inbox_projection_js_1 = require("./inbox-projection.cjs");
|
|
5
41
|
exports.INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
|
|
6
|
-
|
|
42
|
+
exports.MAX_INBOX_AUDIT_DIAGNOSTIC_CHARS = 120;
|
|
43
|
+
function boundedInboxAuditDiagnostic(error) {
|
|
44
|
+
const max = exports.MAX_INBOX_AUDIT_DIAGNOSTIC_CHARS;
|
|
45
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
46
|
+
? error.code.slice(0, max) : "";
|
|
47
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
48
|
+
const text = String(raw).slice(0, max).replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
49
|
+
return [code, text].filter(Boolean).join(" ").slice(0, max);
|
|
50
|
+
}
|
|
51
|
+
function safeIntervalMs(intervalMs) {
|
|
52
|
+
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1)
|
|
53
|
+
throw new Error("inbox audit cadence must be a positive integer");
|
|
54
|
+
return intervalMs;
|
|
55
|
+
}
|
|
56
|
+
/** A Host-owned, config-file-reactive single timer per enabled Agent. */
|
|
7
57
|
class InboxAuditHeartbeat {
|
|
8
58
|
options;
|
|
9
|
-
|
|
59
|
+
timers = new Map();
|
|
60
|
+
generations = new Map();
|
|
10
61
|
running = false;
|
|
11
62
|
sequence = 0;
|
|
12
|
-
|
|
63
|
+
watcher = null;
|
|
64
|
+
refreshTimer = null;
|
|
13
65
|
constructor(options) {
|
|
14
66
|
this.options = options;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
throw new Error("inbox audit cadence must be a positive integer");
|
|
67
|
+
for (const agent of options.agents)
|
|
68
|
+
safeIntervalMs(options.schedule(agent).intervalMs);
|
|
18
69
|
}
|
|
19
70
|
start() {
|
|
20
71
|
if (this.running)
|
|
21
72
|
return;
|
|
22
73
|
this.running = true;
|
|
23
|
-
this.
|
|
74
|
+
for (const agent of this.options.agents)
|
|
75
|
+
this.reschedule(agent);
|
|
76
|
+
this.watchConfig();
|
|
24
77
|
}
|
|
25
78
|
stop() {
|
|
26
79
|
this.running = false;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
80
|
+
const clearTimer = this.options.clearTimer ?? clearTimeout;
|
|
81
|
+
for (const timer of this.timers.values())
|
|
82
|
+
clearTimer(timer);
|
|
83
|
+
this.timers.clear();
|
|
84
|
+
if (this.refreshTimer)
|
|
85
|
+
clearTimer(this.refreshTimer);
|
|
86
|
+
this.refreshTimer = null;
|
|
87
|
+
this.watcher?.close();
|
|
88
|
+
this.watcher = null;
|
|
89
|
+
}
|
|
90
|
+
/** Safe for a Host config watcher or a direct in-process config mutation hook. */
|
|
91
|
+
refresh() {
|
|
92
|
+
if (!this.running)
|
|
93
|
+
return;
|
|
94
|
+
for (const agent of this.options.agents)
|
|
95
|
+
this.reschedule(agent);
|
|
30
96
|
}
|
|
31
|
-
|
|
32
|
-
if (!this.
|
|
97
|
+
watchConfig() {
|
|
98
|
+
if (!this.options.configFile || this.watcher)
|
|
33
99
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
100
|
+
const file = path.resolve(this.options.configFile);
|
|
101
|
+
const parent = path.dirname(file);
|
|
102
|
+
const basename = path.basename(file);
|
|
103
|
+
try {
|
|
104
|
+
this.watcher = (this.options.watch ?? fs.watch)(parent, (_event, changed) => {
|
|
105
|
+
if (changed && String(changed) !== basename)
|
|
106
|
+
return;
|
|
107
|
+
this.scheduleRefresh();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
this.options.log?.(`inbox audit config watch failed: ${boundedInboxAuditDiagnostic(error)}`);
|
|
112
|
+
}
|
|
40
113
|
}
|
|
41
|
-
|
|
114
|
+
scheduleRefresh() {
|
|
115
|
+
if (!this.running || this.refreshTimer)
|
|
116
|
+
return;
|
|
117
|
+
this.refreshTimer = (this.options.setTimer ?? setTimeout)(() => {
|
|
118
|
+
this.refreshTimer = null;
|
|
119
|
+
this.refresh();
|
|
120
|
+
}, 30);
|
|
121
|
+
this.refreshTimer.unref?.();
|
|
122
|
+
}
|
|
123
|
+
reschedule(agent) {
|
|
124
|
+
const clearTimer = this.options.clearTimer ?? clearTimeout;
|
|
125
|
+
const prior = this.timers.get(agent.agentId);
|
|
126
|
+
if (prior)
|
|
127
|
+
clearTimer(prior);
|
|
128
|
+
this.timers.delete(agent.agentId);
|
|
129
|
+
const generation = (this.generations.get(agent.agentId) ?? 0) + 1;
|
|
130
|
+
this.generations.set(agent.agentId, generation);
|
|
131
|
+
let schedule;
|
|
132
|
+
try {
|
|
133
|
+
schedule = this.options.schedule(agent);
|
|
134
|
+
safeIntervalMs(schedule.intervalMs);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
this.options.log?.(`inbox audit schedule failed agent=${agent.agentId}: ${boundedInboxAuditDiagnostic(error)}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Disabled means no future audit timer or model wake at all.
|
|
141
|
+
if (!schedule.enabled || !this.running)
|
|
142
|
+
return;
|
|
143
|
+
let timer;
|
|
144
|
+
timer = (this.options.setTimer ?? setTimeout)(() => {
|
|
145
|
+
// A cancelled callback can still be queued. It must not remove the
|
|
146
|
+
// replacement timer installed by a newer config revision.
|
|
147
|
+
if (this.timers.get(agent.agentId) === timer)
|
|
148
|
+
this.timers.delete(agent.agentId);
|
|
149
|
+
void this.fire(agent, generation).finally(() => {
|
|
150
|
+
if (this.running && this.generations.get(agent.agentId) === generation)
|
|
151
|
+
this.reschedule(agent);
|
|
152
|
+
});
|
|
153
|
+
}, schedule.intervalMs);
|
|
154
|
+
timer.unref?.();
|
|
155
|
+
this.timers.set(agent.agentId, timer);
|
|
156
|
+
}
|
|
157
|
+
async fire(agent, generation) {
|
|
158
|
+
if (this.generations.get(agent.agentId) !== generation)
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
const schedule = this.options.schedule(agent);
|
|
162
|
+
if (!schedule.enabled || !this.options.shouldDispatch(agent))
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
this.options.log?.(`inbox audit schedule failed agent=${agent.agentId}: ${boundedInboxAuditDiagnostic(error)}`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
42
169
|
const now = this.options.now ?? Date.now;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
|
|
54
|
-
if (appended.status === "appended")
|
|
55
|
-
await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
|
|
56
|
-
}
|
|
57
|
-
catch (error) {
|
|
58
|
-
this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
-
}
|
|
170
|
+
const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
|
|
171
|
+
const envelope = { kind: "reminder", message_id, target: inbox_projection_js_1.RUNTIME_REMINDER_TARGET, wake: true,
|
|
172
|
+
content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. Inspect first and explicitly complete its receipt; no finding stays silent." };
|
|
173
|
+
try {
|
|
174
|
+
const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
|
|
175
|
+
if (appended.status === "appended")
|
|
176
|
+
await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${boundedInboxAuditDiagnostic(error)}`);
|
|
60
180
|
}
|
|
61
181
|
}
|
|
62
182
|
}
|
|
@@ -1,59 +1,145 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
1
3
|
import { RUNTIME_REMINDER_TARGET } from "./inbox-projection.mjs";
|
|
2
4
|
export const INBOX_AUDIT_CADENCE_MS = 15 * 60_000;
|
|
3
|
-
|
|
5
|
+
export const MAX_INBOX_AUDIT_DIAGNOSTIC_CHARS = 120;
|
|
6
|
+
export function boundedInboxAuditDiagnostic(error) {
|
|
7
|
+
const max = MAX_INBOX_AUDIT_DIAGNOSTIC_CHARS;
|
|
8
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
9
|
+
? error.code.slice(0, max) : "";
|
|
10
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
11
|
+
const text = String(raw).slice(0, max).replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
12
|
+
return [code, text].filter(Boolean).join(" ").slice(0, max);
|
|
13
|
+
}
|
|
14
|
+
function safeIntervalMs(intervalMs) {
|
|
15
|
+
if (!Number.isSafeInteger(intervalMs) || intervalMs < 1)
|
|
16
|
+
throw new Error("inbox audit cadence must be a positive integer");
|
|
17
|
+
return intervalMs;
|
|
18
|
+
}
|
|
19
|
+
/** A Host-owned, config-file-reactive single timer per enabled Agent. */
|
|
4
20
|
export class InboxAuditHeartbeat {
|
|
5
21
|
options;
|
|
6
|
-
|
|
22
|
+
timers = new Map();
|
|
23
|
+
generations = new Map();
|
|
7
24
|
running = false;
|
|
8
25
|
sequence = 0;
|
|
9
|
-
|
|
26
|
+
watcher = null;
|
|
27
|
+
refreshTimer = null;
|
|
10
28
|
constructor(options) {
|
|
11
29
|
this.options = options;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
throw new Error("inbox audit cadence must be a positive integer");
|
|
30
|
+
for (const agent of options.agents)
|
|
31
|
+
safeIntervalMs(options.schedule(agent).intervalMs);
|
|
15
32
|
}
|
|
16
33
|
start() {
|
|
17
34
|
if (this.running)
|
|
18
35
|
return;
|
|
19
36
|
this.running = true;
|
|
20
|
-
this.
|
|
37
|
+
for (const agent of this.options.agents)
|
|
38
|
+
this.reschedule(agent);
|
|
39
|
+
this.watchConfig();
|
|
21
40
|
}
|
|
22
41
|
stop() {
|
|
23
42
|
this.running = false;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
const clearTimer = this.options.clearTimer ?? clearTimeout;
|
|
44
|
+
for (const timer of this.timers.values())
|
|
45
|
+
clearTimer(timer);
|
|
46
|
+
this.timers.clear();
|
|
47
|
+
if (this.refreshTimer)
|
|
48
|
+
clearTimer(this.refreshTimer);
|
|
49
|
+
this.refreshTimer = null;
|
|
50
|
+
this.watcher?.close();
|
|
51
|
+
this.watcher = null;
|
|
52
|
+
}
|
|
53
|
+
/** Safe for a Host config watcher or a direct in-process config mutation hook. */
|
|
54
|
+
refresh() {
|
|
55
|
+
if (!this.running)
|
|
56
|
+
return;
|
|
57
|
+
for (const agent of this.options.agents)
|
|
58
|
+
this.reschedule(agent);
|
|
59
|
+
}
|
|
60
|
+
watchConfig() {
|
|
61
|
+
if (!this.options.configFile || this.watcher)
|
|
62
|
+
return;
|
|
63
|
+
const file = path.resolve(this.options.configFile);
|
|
64
|
+
const parent = path.dirname(file);
|
|
65
|
+
const basename = path.basename(file);
|
|
66
|
+
try {
|
|
67
|
+
this.watcher = (this.options.watch ?? fs.watch)(parent, (_event, changed) => {
|
|
68
|
+
if (changed && String(changed) !== basename)
|
|
69
|
+
return;
|
|
70
|
+
this.scheduleRefresh();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.options.log?.(`inbox audit config watch failed: ${boundedInboxAuditDiagnostic(error)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
scheduleRefresh() {
|
|
78
|
+
if (!this.running || this.refreshTimer)
|
|
79
|
+
return;
|
|
80
|
+
this.refreshTimer = (this.options.setTimer ?? setTimeout)(() => {
|
|
81
|
+
this.refreshTimer = null;
|
|
82
|
+
this.refresh();
|
|
83
|
+
}, 30);
|
|
84
|
+
this.refreshTimer.unref?.();
|
|
27
85
|
}
|
|
28
|
-
|
|
29
|
-
|
|
86
|
+
reschedule(agent) {
|
|
87
|
+
const clearTimer = this.options.clearTimer ?? clearTimeout;
|
|
88
|
+
const prior = this.timers.get(agent.agentId);
|
|
89
|
+
if (prior)
|
|
90
|
+
clearTimer(prior);
|
|
91
|
+
this.timers.delete(agent.agentId);
|
|
92
|
+
const generation = (this.generations.get(agent.agentId) ?? 0) + 1;
|
|
93
|
+
this.generations.set(agent.agentId, generation);
|
|
94
|
+
let schedule;
|
|
95
|
+
try {
|
|
96
|
+
schedule = this.options.schedule(agent);
|
|
97
|
+
safeIntervalMs(schedule.intervalMs);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
this.options.log?.(`inbox audit schedule failed agent=${agent.agentId}: ${boundedInboxAuditDiagnostic(error)}`);
|
|
30
101
|
return;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.
|
|
102
|
+
}
|
|
103
|
+
// Disabled means no future audit timer or model wake at all.
|
|
104
|
+
if (!schedule.enabled || !this.running)
|
|
105
|
+
return;
|
|
106
|
+
let timer;
|
|
107
|
+
timer = (this.options.setTimer ?? setTimeout)(() => {
|
|
108
|
+
// A cancelled callback can still be queued. It must not remove the
|
|
109
|
+
// replacement timer installed by a newer config revision.
|
|
110
|
+
if (this.timers.get(agent.agentId) === timer)
|
|
111
|
+
this.timers.delete(agent.agentId);
|
|
112
|
+
void this.fire(agent, generation).finally(() => {
|
|
113
|
+
if (this.running && this.generations.get(agent.agentId) === generation)
|
|
114
|
+
this.reschedule(agent);
|
|
115
|
+
});
|
|
116
|
+
}, schedule.intervalMs);
|
|
117
|
+
timer.unref?.();
|
|
118
|
+
this.timers.set(agent.agentId, timer);
|
|
37
119
|
}
|
|
38
|
-
async fire() {
|
|
120
|
+
async fire(agent, generation) {
|
|
121
|
+
if (this.generations.get(agent.agentId) !== generation)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
const schedule = this.options.schedule(agent);
|
|
125
|
+
if (!schedule.enabled || !this.options.shouldDispatch(agent))
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.options.log?.(`inbox audit schedule failed agent=${agent.agentId}: ${boundedInboxAuditDiagnostic(error)}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
39
132
|
const now = this.options.now ?? Date.now;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
|
|
51
|
-
if (appended.status === "appended")
|
|
52
|
-
await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
|
|
53
|
-
}
|
|
54
|
-
catch (error) {
|
|
55
|
-
this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
-
}
|
|
133
|
+
const message_id = `rem_inbox_audit_${now()}_${++this.sequence}_${agent.agentId}`;
|
|
134
|
+
const envelope = { kind: "reminder", message_id, target: RUNTIME_REMINDER_TARGET, wake: true,
|
|
135
|
+
content: "Internal inbox audit wake. Poll runtime:reminder, then run larkin inbox audit --json. Inspect first and explicitly complete its receipt; no finding stays silent." };
|
|
136
|
+
try {
|
|
137
|
+
const appended = this.options.stateStore(agent).appendCanonicalInboxOnce(envelope);
|
|
138
|
+
if (appended.status === "appended")
|
|
139
|
+
await this.options.runtimeHost.deliver(agent.agentId, appended.envelope);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.options.log?.(`inbox audit heartbeat failed agent=${agent.agentId}: ${boundedInboxAuditDiagnostic(error)}`);
|
|
57
143
|
}
|
|
58
144
|
}
|
|
59
145
|
}
|