@ouro.bot/cli 0.1.0-alpha.956 → 0.1.0-alpha.958
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.json +24 -0
- package/deploy/unraid/README.txt +2 -2
- package/deploy/unraid/sanctuary-replay-gate.mjs +206 -32
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +22 -8
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/core.js +33 -12
- package/dist/heart/external-events/router.js +20 -12
- package/dist/repertoire/house-sweep.js +10 -8
- package/dist/repertoire/tools-continuity.js +16 -10
- package/dist/senses/private-runtime-worker.js +53 -1
- package/dist/senses/sanctuary-health-acceptance-probe.js +1 -1
- package/dist/senses/sanctuary-health.js +31 -5
- package/dist/senses/telegram-handoff.js +75 -1
- package/dist/senses/telegram.js +35 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/heart/core.js
CHANGED
|
@@ -285,6 +285,7 @@ exports.STEP_BUDGET_EXTENSION = 4;
|
|
|
285
285
|
/** Hard ceiling on the per-turn provider-call budget, however much progress is made. */
|
|
286
286
|
exports.MAX_PROVIDER_ITERATIONS_CEILING = 24;
|
|
287
287
|
const STEP_BUDGET_REPORT_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call settle now with a short progress report: what is done (with evidence from tool results above), what is still in flight, and what you will do next. say you can continue where you left off when the conversation resumes.";
|
|
288
|
+
const STEP_BUDGET_REST_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call rest now with a short note: what is done, what is still open, and what to pick up next time.";
|
|
288
289
|
function continuationCallbacks(callbacks, text) {
|
|
289
290
|
const noop = () => undefined;
|
|
290
291
|
return {
|
|
@@ -1208,6 +1209,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1208
1209
|
let stepBudget = exports.MAX_PROVIDER_ITERATIONS;
|
|
1209
1210
|
let lastIterationMadeProgress = false;
|
|
1210
1211
|
let stepReportIteration = null;
|
|
1212
|
+
// The one tool a turn may call after its step budget is used up: settle for conversations, rest for private turns.
|
|
1213
|
+
let stepReportTool = "settle";
|
|
1214
|
+
// A pre-dispatch contract check may reject the same call twice in a turn; after that it is advisory, so a check no call can satisfy never traps the turn.
|
|
1215
|
+
const requiredDispatchRejectionCounts = new Map();
|
|
1211
1216
|
const budgetExhausted = () => {
|
|
1212
1217
|
if (providerIterations < stepBudget)
|
|
1213
1218
|
return false;
|
|
@@ -1444,7 +1449,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1444
1449
|
.filter((name) => candidateToolNames.has(name)));
|
|
1445
1450
|
const forcingHistoricalEffect = forcedHistoricalToolNames.size > 0;
|
|
1446
1451
|
const activeTools = stepReportIteration !== null
|
|
1447
|
-
? candidateActiveTools.filter((tool) => tool.function.name ===
|
|
1452
|
+
? candidateActiveTools.filter((tool) => tool.function.name === stepReportTool)
|
|
1448
1453
|
: forcingHistoricalEffect
|
|
1449
1454
|
? candidateActiveTools.filter((tool) => forcedHistoricalToolNames.has(tool.function.name))
|
|
1450
1455
|
: candidateActiveTools;
|
|
@@ -1605,8 +1610,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1605
1610
|
nextAttemptControls = [];
|
|
1606
1611
|
providerIterations += 1;
|
|
1607
1612
|
if (stepReportIteration !== null) {
|
|
1608
|
-
// The progress-report call is the last one: it must settle, or the turn errors.
|
|
1609
|
-
const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name ===
|
|
1613
|
+
// The progress-report call is the last one: it must settle (or rest), or the turn errors.
|
|
1614
|
+
const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name === stepReportTool;
|
|
1610
1615
|
if (providerIterations > stepReportIteration || (result.toolCalls.length > 0 && !settledReport)) {
|
|
1611
1616
|
throw new Error(`provider iteration limit exhausted at response ${providerIterations} before the progress report settled`);
|
|
1612
1617
|
}
|
|
@@ -1676,20 +1681,22 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
1676
1681
|
if (thinkingItems.length > 0) {
|
|
1677
1682
|
msg._thinking_blocks = thinkingItems;
|
|
1678
1683
|
}
|
|
1679
|
-
const settlingNow = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
|
|
1684
|
+
const settlingNow = result.toolCalls.length === 1 && (result.toolCalls[0].name === "settle" || result.toolCalls[0].name === "rest");
|
|
1680
1685
|
if (stepReportIteration === null && result.toolCalls.length > 0 && !settlingNow && budgetExhausted()) {
|
|
1681
|
-
|
|
1686
|
+
const wrapUpTool = candidateToolNames.has("settle") ? "settle" : candidateToolNames.has("rest") ? "rest" : null;
|
|
1687
|
+
if (!wrapUpTool) {
|
|
1682
1688
|
throw new Error(`provider iteration limit exhausted at response ${stepBudget} before tool execution`);
|
|
1683
1689
|
}
|
|
1684
|
-
// Do not error into the reply: give the model one settle-only call to report progress.
|
|
1690
|
+
// Do not error into the reply: give the model one settle-only (or rest-only) call to report progress.
|
|
1685
1691
|
streamCallbackBuffer?.discard();
|
|
1686
1692
|
stepReportIteration = providerIterations + 1;
|
|
1693
|
+
stepReportTool = wrapUpTool;
|
|
1687
1694
|
rejectAttempt(msg, [
|
|
1688
1695
|
...result.toolCalls.map((tc) => ({ role: "tool", tool_call_id: tc.id, content: "not executed: the step budget for this turn is used up." })),
|
|
1689
|
-
{ role: "user", content: STEP_BUDGET_REPORT_INSTRUCTION },
|
|
1696
|
+
{ role: "user", content: wrapUpTool === "settle" ? STEP_BUDGET_REPORT_INSTRUCTION : STEP_BUDGET_REST_INSTRUCTION },
|
|
1690
1697
|
]);
|
|
1691
1698
|
callbacks.onClearText?.();
|
|
1692
|
-
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message:
|
|
1699
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message: `step budget used up; requesting a ${wrapUpTool}-only progress report`, meta: { stepBudget, providerIterations } });
|
|
1693
1700
|
continue;
|
|
1694
1701
|
}
|
|
1695
1702
|
if (result.toolCalls.length > 0)
|
|
@@ -2166,14 +2173,16 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
2166
2173
|
const restArgs = validatedCallArguments.get(result.toolCalls[0]);
|
|
2167
2174
|
callbacks.onToolStart("rest", restArgs);
|
|
2168
2175
|
// Attention queue gate: reject rest if items remain
|
|
2176
|
+
// A budget wrap-up rest is final: held work and fresh messages wait for the next turn.
|
|
2177
|
+
const wrappingUp = stepReportIteration !== null;
|
|
2169
2178
|
const attentionQueue = augmentedToolContext?.delegatedOrigins;
|
|
2170
|
-
if (attentionQueue && attentionQueue.length > 0) {
|
|
2179
|
+
if (!wrappingUp && attentionQueue && attentionQueue.length > 0) {
|
|
2171
2180
|
callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs, toolSelection), false);
|
|
2172
2181
|
const gateMessage = "current held-work frame still has unsurfaced items — return each listed item with surface(delegationId=...) before you rest. Older transcript claims are historical; only the current held-work frame is the gate.";
|
|
2173
2182
|
rejectToolBatch(msg, [gateMessage]);
|
|
2174
2183
|
continue;
|
|
2175
2184
|
}
|
|
2176
|
-
if (hasFreshPendingWork(options) && !freshWorkGateFired) {
|
|
2185
|
+
if (!wrappingUp && hasFreshPendingWork(options) && !freshWorkGateFired) {
|
|
2177
2186
|
freshWorkGateFired = true;
|
|
2178
2187
|
callbacks.onToolEnd("rest", (0, tools_1.summarizeArgs)("rest", restArgs, toolSelection), false);
|
|
2179
2188
|
const gateMessage = "fresh work arrived for me this turn — inspect the pending messages above and take the next concrete action before you rest.";
|
|
@@ -2206,8 +2215,20 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
2206
2215
|
for (const entry of validCalls) {
|
|
2207
2216
|
const requiredArgs = entry.validated.arguments;
|
|
2208
2217
|
const rejection = options?.requiredToolCalls?.validateToolCallBeforeDispatch?.(entry.call.name, requiredArgs);
|
|
2209
|
-
if (rejection)
|
|
2210
|
-
|
|
2218
|
+
if (!rejection)
|
|
2219
|
+
continue;
|
|
2220
|
+
const rejectionKey = `${entry.call.name}\0${rejection}`;
|
|
2221
|
+
const priorRejections = requiredDispatchRejectionCounts.get(rejectionKey) ?? 0;
|
|
2222
|
+
if (priorRejections >= 2) {
|
|
2223
|
+
(0, runtime_1.emitNervesEvent)({
|
|
2224
|
+
level: "warn", component: "engine", event: "engine.required_tool_dispatch_advisory",
|
|
2225
|
+
message: "required tool dependency rejected twice this turn; letting the call through",
|
|
2226
|
+
meta: { toolName: entry.call.name },
|
|
2227
|
+
});
|
|
2228
|
+
continue;
|
|
2229
|
+
}
|
|
2230
|
+
requiredDispatchRejectionCounts.set(rejectionKey, priorRejections + 1);
|
|
2231
|
+
requiredDispatchRejections.set(entry.call.id, { name: entry.call.name, args: requiredArgs, message: rejection });
|
|
2211
2232
|
}
|
|
2212
2233
|
if (requiredDispatchRejections.size > 0) {
|
|
2213
2234
|
streamCallbackBuffer?.discard();
|
|
@@ -813,8 +813,13 @@ function recordExternalEventInternal(input, options = {}) {
|
|
|
813
813
|
&& existing.recoveryGrant?.generation === existing.generation && existing.failureProvenance !== undefined
|
|
814
814
|
&& Date.parse(now) - Date.parse(existing.failureProvenance.failedAt) >= EXHAUSTED_RECOVERY_REOPEN_MS
|
|
815
815
|
&& Math.floor(Date.parse(now) / 3_600_000) % EXHAUSTED_RECOVERY_SLOTS === exhaustedRecoverySlot(existing);
|
|
816
|
-
|
|
816
|
+
// A recovery is only news to an owner who was told about the incident, or to a turn that asked to watch for it; any other recovery settles here without a model turn.
|
|
817
|
+
const silentRecovery = input.transition === "recovered" && existing !== null
|
|
818
|
+
&& existing.disposition?.decision !== "ask" && existing.disposition?.decision !== "report" && existing.disposition?.nextWake.kind !== "on_recovery";
|
|
819
|
+
const settledRecoveryReopened = existing?.executionState === "handled" && !existing.disposition && existing.transition === "recovered" && input.transition !== "recovered";
|
|
820
|
+
const shouldWake = !quietInitialReceipt && !silentRecovery && (!existing || quietBaselineChanged || settledRecoveryReopened
|
|
817
821
|
|| (existing.disposition ? predicateMatches(existing.disposition, input, revision) : existing.executionState === "dead_letter" && (revision !== existing.observationRevision || exhaustedRecoveryReopens)));
|
|
822
|
+
const settles = quietInitialReceipt || silentRecovery;
|
|
818
823
|
const generation = existing ? existing.generation + (shouldWake ? 1 : 0) : 1;
|
|
819
824
|
const record = {
|
|
820
825
|
schemaVersion: 2,
|
|
@@ -834,17 +839,17 @@ function recordExternalEventInternal(input, options = {}) {
|
|
|
834
839
|
observationRevision: revision,
|
|
835
840
|
observationDigest: digest,
|
|
836
841
|
transition: input.transition ?? (existing ? "unchanged" : "opened"),
|
|
837
|
-
executionState:
|
|
842
|
+
executionState: settles ? "handled" : shouldWake ? "received" : existing.executionState,
|
|
838
843
|
generation,
|
|
839
|
-
attemptCount: shouldWake ||
|
|
840
|
-
claimOwner: shouldWake ||
|
|
841
|
-
claimExpiresAt: shouldWake ||
|
|
842
|
-
nextAttemptAt: shouldWake ||
|
|
843
|
-
lastError: shouldWake ||
|
|
844
|
-
...(!shouldWake && !
|
|
845
|
-
...(!shouldWake && !
|
|
846
|
-
disposition: shouldWake ||
|
|
847
|
-
pendingObservation: shouldWake ||
|
|
844
|
+
attemptCount: shouldWake || settles ? 0 : existing.attemptCount,
|
|
845
|
+
claimOwner: shouldWake || settles ? null : existing.claimOwner,
|
|
846
|
+
claimExpiresAt: shouldWake || settles ? null : existing.claimExpiresAt,
|
|
847
|
+
nextAttemptAt: shouldWake || settles ? null : existing.nextAttemptAt,
|
|
848
|
+
lastError: shouldWake || settles ? null : existing.lastError,
|
|
849
|
+
...(!shouldWake && !settles && existing?.failureProvenance ? { failureProvenance: existing.failureProvenance } : {}),
|
|
850
|
+
...(!shouldWake && !settles && existing?.recoveryGrant ? { recoveryGrant: existing.recoveryGrant } : {}),
|
|
851
|
+
disposition: shouldWake || settles ? null : existing.disposition,
|
|
852
|
+
pendingObservation: shouldWake || settles ? null : existing.pendingObservation,
|
|
848
853
|
dispatchEnabled: options.dispatchEnabled ?? existing?.dispatchEnabled ?? true,
|
|
849
854
|
shouldWake,
|
|
850
855
|
...(persistedRetention ? { retentionSummary: persistedRetention } : {}),
|
|
@@ -1399,7 +1404,10 @@ function settleExternalEventFailureIfOwned(recordPath, input) {
|
|
|
1399
1404
|
|| record.generation !== input.expectedGeneration) {
|
|
1400
1405
|
return { settled: false, record };
|
|
1401
1406
|
}
|
|
1402
|
-
const
|
|
1407
|
+
const next = input.deferUntil
|
|
1408
|
+
? { ...record, executionState: "retry_wait", claimOwner: null, claimExpiresAt: null, nextAttemptAt: input.deferUntil, attemptCount: Math.max(0, record.attemptCount - 1), lastError: input.error.slice(0, 1_000), shouldWake: false }
|
|
1409
|
+
: retryState(record, now, 5, 1_000, input.error, input.failureClass);
|
|
1410
|
+
const settled = commitMutation(recordPath, next, now);
|
|
1403
1411
|
return { settled: true, record: settled };
|
|
1404
1412
|
});
|
|
1405
1413
|
}
|
|
@@ -185,7 +185,7 @@ function summarizeArr(service, queuePayload, missingPayload, historyPayload, now
|
|
|
185
185
|
id, area: "downloads", severity: "warn", refs,
|
|
186
186
|
next: series ? "fix" : "owner",
|
|
187
187
|
summary: `${label} download of ${name} is stuck at ${percent}% (${release}): ${problem}`,
|
|
188
|
-
fingerprint: fingerprintOf(id,
|
|
188
|
+
fingerprint: fingerprintOf(id, "stuck"),
|
|
189
189
|
...(series ? { fix: { tool: "media_fill_missing", args: { service_id: subjectId }, note: "keeps the partial download; replaces it only when a seeded alternative is grabbed" } } : {}),
|
|
190
190
|
});
|
|
191
191
|
}
|
|
@@ -228,7 +228,7 @@ function summarizeArr(service, queuePayload, missingPayload, historyPayload, now
|
|
|
228
228
|
findings.push({
|
|
229
229
|
id, area: "missing", severity: "info", next: "fix",
|
|
230
230
|
summary: series ? `${entry.name}: ${entry.count} monitored episode${entry.count === 1 ? "" : "s"} missing, the oldest aired ${days} days ago` : `${entry.name}: monitored movie still missing after ${days} days`,
|
|
231
|
-
fingerprint: fingerprintOf(id,
|
|
231
|
+
fingerprint: fingerprintOf(id, "missing"),
|
|
232
232
|
fix: series
|
|
233
233
|
? { tool: "media_fill_missing", args: { service_id: subjectId }, note: "searches, grabs a seeded release, imports what finished" }
|
|
234
234
|
: { tool: "media_search_now", args: { kind: "movie", service_id: subjectId }, note: "triggers a Radarr search" },
|
|
@@ -272,7 +272,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
|
|
|
272
272
|
const capacity = (id, label, percent) => {
|
|
273
273
|
if (percent === null || percent < exports.CAPACITY_PERCENT)
|
|
274
274
|
return;
|
|
275
|
-
findings.push({ id, area: "disk", severity: percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "warn", next: "owner", summary: `${label} is ${percent}% full`, fingerprint: fingerprintOf(id,
|
|
275
|
+
findings.push({ id, area: "disk", severity: percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "warn", next: "owner", summary: `${label} is ${percent}% full`, fingerprint: fingerprintOf(id, percent >= exports.CRITICAL_CAPACITY_PERCENT ? "critical" : "full") });
|
|
276
276
|
};
|
|
277
277
|
capacity("disk:array:capacity", "the array", summary.arrayUsedPercent);
|
|
278
278
|
for (const share of list(storage?.shares).map(dict))
|
|
@@ -285,7 +285,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
|
|
|
285
285
|
findings.push({ id: `${id}:smart`, area: "disk", severity: "critical", next: "owner", summary: `${text(disk.name)} reports a failed SMART status`, fingerprint: fingerprintOf(`${id}:smart`, "failed") });
|
|
286
286
|
const temperature = num(disk.temperatureC);
|
|
287
287
|
if (temperature !== null && temperature >= exports.TEMPERATURE_C)
|
|
288
|
-
findings.push({ id: `${id}:temperature`, area: "disk", severity: "warn", next: "owner", summary: `${text(disk.name)} is ${temperature}C`, fingerprint: fingerprintOf(`${id}:temperature`,
|
|
288
|
+
findings.push({ id: `${id}:temperature`, area: "disk", severity: "warn", next: "owner", summary: `${text(disk.name)} is ${temperature}C`, fingerprint: fingerprintOf(`${id}:temperature`, "hot") });
|
|
289
289
|
}
|
|
290
290
|
const parity = dict(disks?.parity);
|
|
291
291
|
summary.parity = text(parity.result) || null;
|
|
@@ -294,7 +294,7 @@ async function sweepHost(sanctuary, policyStates, nowMs, findings) {
|
|
|
294
294
|
if (disks) {
|
|
295
295
|
const result = summary.parity;
|
|
296
296
|
if (result === "failed")
|
|
297
|
-
findings.push({ id: "parity:result", area: "parity", severity: "critical", next: "owner", summary: `the last parity check failed (${num(parity.errors) ?? "unknown"} errors)`, fingerprint: fingerprintOf("parity:result",
|
|
297
|
+
findings.push({ id: "parity:result", area: "parity", severity: "critical", next: "owner", summary: `the last parity check failed (${num(parity.errors) ?? "unknown"} errors)`, fingerprint: fingerprintOf("parity:result", "failed") });
|
|
298
298
|
else if (result !== "in_progress" && (summary.parityAgeDays === null || summary.parityAgeDays >= exports.PARITY_MAX_AGE_DAYS))
|
|
299
299
|
findings.push({ id: "parity:age", area: "parity", severity: "warn", next: "owner", summary: summary.parityAgeDays === null ? "parity check age is unknown" : `the last parity check was ${summary.parityAgeDays} days ago`, fingerprint: fingerprintOf("parity:age", summary.parityAgeDays === null ? "unknown" : "old") });
|
|
300
300
|
}
|
|
@@ -434,10 +434,12 @@ async function runHouseSweep(deps) {
|
|
|
434
434
|
})
|
|
435
435
|
.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.id.localeCompare(b.id));
|
|
436
436
|
if (!deps.replay) {
|
|
437
|
-
// A finding that
|
|
438
|
-
//
|
|
437
|
+
// A finding that has been gone for REMIND_DAYS is forgotten, so it is told again if it comes back after that.
|
|
438
|
+
// A brief disappearance (or an unreadable source) keeps the entry, so a flickering finding never returns as "new".
|
|
439
439
|
const present = new Set(ordered.map((finding) => finding.id));
|
|
440
|
-
const kept = Object.fromEntries(Object.entries(ledger).filter(([id]) => present.has(id)
|
|
440
|
+
const kept = Object.fromEntries(Object.entries(ledger).filter(([id, told]) => present.has(id)
|
|
441
|
+
|| sourcesFor(id).some((source) => sources[source] !== "ok")
|
|
442
|
+
|| nowMs - Date.parse(told.reportedAt) < exports.REMIND_DAYS * DAY_MS));
|
|
441
443
|
if (Object.keys(kept).length !== Object.keys(ledger).length)
|
|
442
444
|
(0, exports.writeLedger)(deps.agentRoot, kept);
|
|
443
445
|
}
|
|
@@ -50,11 +50,12 @@ const externalEventDispositionProperties = {
|
|
|
50
50
|
expectedGeneration: { type: "number", description: "Exact generation shown in the external-event turn" },
|
|
51
51
|
classifiedRevision: { type: "string", description: "Exact observation revision investigated in this turn" },
|
|
52
52
|
classification: { type: "string", enum: ["expected", "needs_attention", "adopted", "snoozed", "dismissed_until_change", "resolved"] },
|
|
53
|
-
stewardPolicyKind: { type: "string", enum: ["current", "none"], description: "Use current with the exact live policy key/version
|
|
53
|
+
stewardPolicyKind: { type: "string", enum: ["current", "none"], description: "Use current with the exact live policy key/version when one applies; otherwise use none. Any event may be closed with none." },
|
|
54
54
|
stewardPolicyKey: { type: "string", description: "Exact current policy key used for this decision" },
|
|
55
55
|
stewardPolicyVersion: { type: "number", description: "Exact current policy version used for this decision" },
|
|
56
56
|
decision: { type: "string", enum: ["silent", "act", "ask", "report"] },
|
|
57
|
-
reason: { type: "string", description: "
|
|
57
|
+
reason: { type: "string", description: "Internal audit note for the decision; never shown to the owner" },
|
|
58
|
+
owner_message: { type: "string", description: "Required for ask or report: the exact short plain message the owner reads, as you would text a friend. No ids, leases, generations, revisions or receipts." },
|
|
58
59
|
nextWake: { type: "string", enum: ["on_change", "on_escalation", "on_recovery", "at"], description: "Use on_change unless you already called await_condition for this exact event and are supplying its awaitId" },
|
|
59
60
|
wakeAt: { type: "string", description: "ISO time required only when nextWake=at" },
|
|
60
61
|
awaitId: { type: "string", description: "Existing await_condition receipt required when nextWake=at; never invent one" },
|
|
@@ -70,6 +71,8 @@ const externalEventDispositionBatchSchema = {
|
|
|
70
71
|
description: "All exact leases from one coalesced external-event turn",
|
|
71
72
|
items: { type: "object", properties: externalEventDispositionProperties, required: externalEventDispositionRequired, additionalProperties: false },
|
|
72
73
|
};
|
|
74
|
+
// The owner reads owner_message verbatim, so internal bookkeeping words never reach them.
|
|
75
|
+
const OWNER_MESSAGE_FORBIDDEN = /\b(generation|lease|revision|receipt|disposition|claimOwner|recordPath)s?\b|[\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/iu;
|
|
73
76
|
function prepareExternalEventDisposition(a, ctx) {
|
|
74
77
|
const agentName = (0, identity_1.getAgentName)();
|
|
75
78
|
const agentEventRoot = path.resolve((0, router_1.getExternalEventRoot)(), agentName);
|
|
@@ -99,6 +102,7 @@ function prepareExternalEventDisposition(a, ctx) {
|
|
|
99
102
|
const policyVersion = Number(a.stewardPolicyVersion);
|
|
100
103
|
const policyKind = String(a.stewardPolicyKind ?? "");
|
|
101
104
|
const reason = String(a.reason ?? "").trim();
|
|
105
|
+
const ownerText = String(a.owner_message ?? "").trim();
|
|
102
106
|
if (!reason || (policyKind !== "none" && (!Number.isSafeInteger(policyVersion) || policyVersion < 1))
|
|
103
107
|
|| !["current", "none"].includes(policyKind)
|
|
104
108
|
|| !["expected", "needs_attention", "adopted", "snoozed", "dismissed_until_change", "resolved"].includes(classification)
|
|
@@ -113,8 +117,6 @@ function prepareExternalEventDisposition(a, ctx) {
|
|
|
113
117
|
const policy = (0, steward_policy_1.readStewardPolicy)(agentRoot);
|
|
114
118
|
let stewardPolicy;
|
|
115
119
|
if (policyKind === "none") {
|
|
116
|
-
if (record.transition !== "opened")
|
|
117
|
-
throw new Error("External event no-policy disposition requires a fresh observation");
|
|
118
120
|
stewardPolicy = { kind: "none" };
|
|
119
121
|
}
|
|
120
122
|
else {
|
|
@@ -178,15 +180,19 @@ function prepareExternalEventDisposition(a, ctx) {
|
|
|
178
180
|
finish: async () => {
|
|
179
181
|
let ownerMessage;
|
|
180
182
|
if (decision === "ask" || decision === "report") {
|
|
181
|
-
if (
|
|
183
|
+
if (!ownerText)
|
|
184
|
+
throw new Error("ask and report need owner_message: the short plain text the owner will read");
|
|
185
|
+
if (Buffer.byteLength(ownerText, "utf8") > 1_200)
|
|
182
186
|
throw new Error("External event owner message must be phone-sized");
|
|
187
|
+
if (OWNER_MESSAGE_FORBIDDEN.test(ownerText))
|
|
188
|
+
throw new Error("owner_message must be plain words for the owner, with no internal ids, leases, generations, revisions, receipts or hidden characters");
|
|
183
189
|
if (!ctx?.externalEventEffects)
|
|
184
190
|
throw new Error("External event owner delivery is unavailable");
|
|
185
191
|
// The whole dispatched batch shares one owner message; the delivery port settles later decisions as covered by it.
|
|
186
192
|
const relatedEvents = [turnContext, ...(turnContext.relatedEvents ?? [])]
|
|
187
193
|
.filter((event) => path.resolve(event.recordPath) !== recordPath)
|
|
188
194
|
.map((event) => ({ source: event.source, eventId: event.eventId, generation: event.generation }));
|
|
189
|
-
const delivered = await ctx.externalEventEffects.deliverOwnerDecision({ source: record.source, eventId: record.eventId, generation: record.generation, text:
|
|
195
|
+
const delivered = await ctx.externalEventEffects.deliverOwnerDecision({ source: record.source, eventId: record.eventId, generation: record.generation, text: ownerText, relatedEvents });
|
|
190
196
|
ownerMessage = { status: delivered.status, effectKey: delivered.effectKey };
|
|
191
197
|
}
|
|
192
198
|
const handled = (0, router_1.commitExternalEventDisposition)(recordPath, {
|
|
@@ -220,7 +226,7 @@ exports.continuityToolDefinitions = [
|
|
|
220
226
|
type: "function",
|
|
221
227
|
function: {
|
|
222
228
|
name: "external_event_disposition",
|
|
223
|
-
description: "Classify the exact external-event generation I just investigated. An ask or report is the sole owner-delivery path and sends
|
|
229
|
+
description: "Classify the exact external-event generation I just investigated. An ask or report is the sole owner-delivery path and sends owner_message once for this receipt; reason stays internal. silent and act only record the disposition. In a batch, only the first ask or report sends a message and the later ones are recorded as covered by it, so write that first owner_message to cover every listed incident.",
|
|
224
230
|
parameters: {
|
|
225
231
|
type: "object",
|
|
226
232
|
properties: {
|
|
@@ -396,7 +402,7 @@ exports.continuityToolDefinitions = [
|
|
|
396
402
|
incidentKey: { type: "string", description: "Stable incident key within the source" },
|
|
397
403
|
classifiedRevision: { type: "string", description: "Observation revision classified by the agent" },
|
|
398
404
|
correlationKey: { type: "string", description: "Optional key relating several incidents to one concern" },
|
|
399
|
-
expectedUpdatedAt: { type: "string", description: "
|
|
405
|
+
expectedUpdatedAt: { type: "string", description: "The Care's current updatedAt, copied from the last care_manage result. Required for bind_incident and resolve_incident; optional for update." },
|
|
400
406
|
currentRisk: { type: "string", description: "Current material risk for this Care, or an empty string to clear it" },
|
|
401
407
|
nextCheckAt: { type: "string", description: "Exact ISO time of the next owned check" },
|
|
402
408
|
},
|
|
@@ -442,7 +448,7 @@ exports.continuityToolDefinitions = [
|
|
|
442
448
|
}
|
|
443
449
|
else if (a.action === "bind_incident") {
|
|
444
450
|
if (!a.expectedUpdatedAt)
|
|
445
|
-
throw new Error("Care
|
|
451
|
+
throw new Error("bind_incident requires expectedUpdatedAt: copy the Care's current updatedAt from the last care_manage result");
|
|
446
452
|
result = (0, cares_1.bindCareIncident)(agentRoot, a.id, {
|
|
447
453
|
source: a.source,
|
|
448
454
|
incidentKey: a.incidentKey,
|
|
@@ -452,7 +458,7 @@ exports.continuityToolDefinitions = [
|
|
|
452
458
|
}
|
|
453
459
|
else if (a.action === "resolve_incident") {
|
|
454
460
|
if (!a.expectedUpdatedAt)
|
|
455
|
-
throw new Error("Care
|
|
461
|
+
throw new Error("resolve_incident requires expectedUpdatedAt: copy the Care's current updatedAt from the last care_manage result");
|
|
456
462
|
result = (0, cares_1.resolveCareIncident)(agentRoot, a.id, {
|
|
457
463
|
source: a.source,
|
|
458
464
|
incidentKey: a.incidentKey,
|
|
@@ -40,6 +40,7 @@ const fs = __importStar(require("fs"));
|
|
|
40
40
|
const path = __importStar(require("path"));
|
|
41
41
|
const private_runtime_1 = require("./private-runtime");
|
|
42
42
|
const router_1 = require("../heart/external-events/router");
|
|
43
|
+
const telegram_handoff_1 = require("./telegram-handoff");
|
|
43
44
|
const runtime_1 = require("../nerves/runtime");
|
|
44
45
|
const identity_1 = require("../heart/identity");
|
|
45
46
|
const pending_1 = require("../mind/pending");
|
|
@@ -167,6 +168,50 @@ function reserveHabitAutonomyBudget(habitRun, nowIso) {
|
|
|
167
168
|
now: nowIso,
|
|
168
169
|
}, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(habitRun.agentRoot, agentName));
|
|
169
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* External-event turns are paid model work, so they draw on their own daily allowance (the agent's
|
|
173
|
+
* habitPaidTurnsPerDay, counted under the "external-events" name so a busy event day cannot starve a
|
|
174
|
+
* habit) and share one storm breaker: three failed event turns within ten minutes pause event turns
|
|
175
|
+
* for thirty minutes.
|
|
176
|
+
*/
|
|
177
|
+
const EXTERNAL_EVENT_BUDGET_NAME = "external-events";
|
|
178
|
+
function externalEventStorm(agentName) {
|
|
179
|
+
return {
|
|
180
|
+
agent: agentName,
|
|
181
|
+
triggerType: "habit",
|
|
182
|
+
sourceKind: "private-runtime",
|
|
183
|
+
senseOrHabit: EXTERNAL_EVENT_BUDGET_NAME,
|
|
184
|
+
provider: "model",
|
|
185
|
+
target: { kind: EXTERNAL_EVENT_BUDGET_NAME },
|
|
186
|
+
normalizedErrorName: "external_event_turn_failed",
|
|
187
|
+
normalizedErrorCode: "turn_failed",
|
|
188
|
+
codeLocation: "private-runtime-worker",
|
|
189
|
+
idempotencyBucket: EXTERNAL_EVENT_BUDGET_NAME,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** A blocked event turn waits this long (the breaker's block length) and keeps its retry attempts. */
|
|
193
|
+
const EXTERNAL_EVENT_BLOCKED_DEFER_MS = 30 * 60_000;
|
|
194
|
+
function reserveExternalEventAutonomyBudget(event, nowIso) {
|
|
195
|
+
// Disk, parity and array failures always get a turn: the owner must hear about them even on a busy day.
|
|
196
|
+
if ([event, ...(event.relatedEvents ?? [])].some((member) => (0, telegram_handoff_1.isCriticalNoticeKey)(member.eventId)))
|
|
197
|
+
return null;
|
|
198
|
+
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
199
|
+
const agentName = (0, identity_1.getAgentName)();
|
|
200
|
+
return (0, autonomy_budget_1.reserveAutonomyBudget)(agentRoot, {
|
|
201
|
+
agent: agentName,
|
|
202
|
+
triggerType: "habit",
|
|
203
|
+
sourceKind: "private-runtime",
|
|
204
|
+
senseOrHabit: EXTERNAL_EVENT_BUDGET_NAME,
|
|
205
|
+
target: { kind: EXTERNAL_EVENT_BUDGET_NAME },
|
|
206
|
+
idempotencyKey: `event:${event.recordPath}:${event.generation}:${event.claimOwner}`,
|
|
207
|
+
now: nowIso,
|
|
208
|
+
storm: externalEventStorm(agentName),
|
|
209
|
+
}, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(agentRoot, agentName));
|
|
210
|
+
}
|
|
211
|
+
function recordExternalEventTurnFailure(nowIso) {
|
|
212
|
+
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
213
|
+
(0, autonomy_budget_1.recordAutonomyFailure)(agentRoot, { ...externalEventStorm((0, identity_1.getAgentName)()), occurredAt: nowIso }, (0, autonomy_budget_1.resolveAutonomyBudgetPolicy)(agentRoot, (0, identity_1.getAgentName)()));
|
|
214
|
+
}
|
|
170
215
|
/**
|
|
171
216
|
* Habit recursion detector thresholds. This protects against the *external*
|
|
172
217
|
* IPC self-loop where heartbeat-shaped messages get re-issued faster
|
|
@@ -537,7 +582,9 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
|
|
|
537
582
|
const turnStartedAt = new Date(nowSource()).toISOString();
|
|
538
583
|
const autonomyDecision = currentHabitRun && !currentHabitRun.blockedReason
|
|
539
584
|
? reserveHabitAutonomyBudget(currentHabitRun, turnStartedAt)
|
|
540
|
-
:
|
|
585
|
+
: currentExternalEvent
|
|
586
|
+
? reserveExternalEventAutonomyBudget(currentExternalEvent, turnStartedAt)
|
|
587
|
+
: null;
|
|
541
588
|
if (currentHabitRun)
|
|
542
589
|
recordHabitStartIfNeeded(currentHabitRun);
|
|
543
590
|
if (currentHabitRun?.blockedReason) {
|
|
@@ -620,15 +667,20 @@ function createPrivateRuntimeWorker(runTurn = (options) => (0, private_runtime_1
|
|
|
620
667
|
: blockedAutonomyTurn || turnOutcome === "blocked" || turnOutcome === "suspended"
|
|
621
668
|
? "external-event turn was blocked"
|
|
622
669
|
: "external-event turn completed without a terminal disposition";
|
|
670
|
+
const settledAt = nowSource();
|
|
671
|
+
const deferUntil = blockedAutonomyTurn ? new Date(settledAt + EXTERNAL_EVENT_BLOCKED_DEFER_MS).toISOString() : undefined;
|
|
623
672
|
let settledIncomplete = false;
|
|
624
673
|
for (const member of [currentExternalEvent, ...(currentExternalEvent.relatedEvents ?? [])]) {
|
|
625
674
|
const result = (0, router_1.settleExternalEventFailureIfOwned)(member.recordPath, {
|
|
626
675
|
owner: member.claimOwner,
|
|
627
676
|
expectedGeneration: member.generation,
|
|
628
677
|
error: failureReason,
|
|
678
|
+
...(deferUntil ? { deferUntil } : {}),
|
|
629
679
|
});
|
|
630
680
|
settledIncomplete = result.settled || settledIncomplete;
|
|
631
681
|
}
|
|
682
|
+
if (!blockedAutonomyTurn && (turnErrors.length > 0 || turnOutcome === "errored" || settledIncomplete))
|
|
683
|
+
recordExternalEventTurnFailure(new Date(settledAt).toISOString());
|
|
632
684
|
if (settledIncomplete && turnErrors.length === 0)
|
|
633
685
|
turnErrors.push(failureReason);
|
|
634
686
|
}
|
|
@@ -547,7 +547,7 @@ async function runSanctuaryHealthAcceptanceProbe(input, dependencies = createSan
|
|
|
547
547
|
}
|
|
548
548
|
const runPhase = async (name, trigger, fixtureStatus, fetchImpl, now) => {
|
|
549
549
|
const sweep = (0, sanctuary_health_1.createSanctuaryHealthSweep)({
|
|
550
|
-
toolContext: dependencies.toolContext, statePath, fetch: fetchImpl, now: () => now, lease,
|
|
550
|
+
toolContext: dependencies.toolContext, statePath, fetch: fetchImpl, now: () => now, lease, endpointDebounce: false,
|
|
551
551
|
acceptanceEventMeta: () => ({ scenarioHandleDigest: input.scenarioHandleDigest }),
|
|
552
552
|
});
|
|
553
553
|
await (0, sanctuary_health_runner_1.runSanctuaryHealthHabit)("sanctuary", {
|
|
@@ -226,6 +226,7 @@ function load(filePath) {
|
|
|
226
226
|
lastDigestDay: value.lastDigestDay,
|
|
227
227
|
updatedAt: value.updatedAt,
|
|
228
228
|
...(value.pendingRecovered?.length ? { pendingRecovered: value.pendingRecovered } : {}),
|
|
229
|
+
...(endpointFailuresOf(value.endpointFailures) ? { endpointFailures: endpointFailuresOf(value.endpointFailures) } : {}),
|
|
229
230
|
outbox: value.outbox ? { ...value.outbox, kind: value.outbox.kind ?? "legacy_unknown" } : null,
|
|
230
231
|
indeterminateDeliveries: (value.indeterminateDeliveries ?? []).map((delivery) => ({ ...delivery, kind: delivery.kind ?? "legacy_unknown" })),
|
|
231
232
|
deliveredReceipts: (value.deliveredReceipts ?? []).map((receipt) => ({ ...receipt, kind: receipt.kind ?? "legacy_unknown" })),
|
|
@@ -242,6 +243,13 @@ function load(filePath) {
|
|
|
242
243
|
function readSanctuaryHealthState(filePath) {
|
|
243
244
|
return load(filePath);
|
|
244
245
|
}
|
|
246
|
+
// A malformed failure counter only delays an endpoint incident by one sweep, so it is dropped rather than treated as corrupt state.
|
|
247
|
+
function endpointFailuresOf(value) {
|
|
248
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
249
|
+
return null;
|
|
250
|
+
const entries = Object.entries(value).filter(([, count]) => Number.isSafeInteger(count) && count > 0);
|
|
251
|
+
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
|
252
|
+
}
|
|
245
253
|
function save(filePath, state) {
|
|
246
254
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
247
255
|
const temporary = `${filePath}.tmp-${process.pid}-${(0, node_crypto_1.randomUUID)()}`;
|
|
@@ -295,6 +303,16 @@ async function probeSanctuaryEndpoint(url, fetchImpl = fetch) {
|
|
|
295
303
|
}
|
|
296
304
|
function createSanctuaryHealthSweep(options) {
|
|
297
305
|
const fetchImpl = options.fetch ?? fetch;
|
|
306
|
+
const endpointRetryDelayMs = options.endpointRetryDelayMs ?? 3_000;
|
|
307
|
+
const endpointDebounce = options.endpointDebounce ?? true;
|
|
308
|
+
const endpointFailuresToOpen = endpointDebounce ? 2 : 1;
|
|
309
|
+
const probeWithRetry = async (url) => {
|
|
310
|
+
const first = await probeSanctuaryEndpoint(url, fetchImpl);
|
|
311
|
+
if (first.ok || !endpointDebounce)
|
|
312
|
+
return first;
|
|
313
|
+
await new Promise((resolve) => setTimeout(resolve, endpointRetryDelayMs));
|
|
314
|
+
return probeSanctuaryEndpoint(url, fetchImpl);
|
|
315
|
+
};
|
|
298
316
|
const now = options.now ?? (() => new Date());
|
|
299
317
|
const acceptanceEventMeta = options.acceptanceEventMeta ?? (() => (0, sanctuary_acceptance_marker_1.sanctuaryAcceptanceEventMeta)("sanctuary"));
|
|
300
318
|
const runSweep = async () => {
|
|
@@ -313,7 +331,7 @@ function createSanctuaryHealthSweep(options) {
|
|
|
313
331
|
throw new Error("Sanctuary health runtime is unavailable");
|
|
314
332
|
const [containersResult, storageResult, disksResult, notificationsResult, endpoints] = await Promise.all([
|
|
315
333
|
runtime.listContainers(), runtime.getStorage(), runtime.getDisks(), runtime.getNotifications(),
|
|
316
|
-
Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) =>
|
|
334
|
+
Promise.all(exports.SANCTUARY_PUBLIC_ENDPOINTS.map((url) => probeWithRetry(url))),
|
|
317
335
|
]);
|
|
318
336
|
const incidents = new Map();
|
|
319
337
|
const add = (id, rawSummary) => {
|
|
@@ -364,13 +382,20 @@ function createSanctuaryHealthSweep(options) {
|
|
|
364
382
|
const notifications = record(notificationsResult)?.ok ? record(record(notificationsResult)?.data)?.unacknowledged : null;
|
|
365
383
|
if (!Array.isArray(notifications))
|
|
366
384
|
add("notifications:unavailable", "notification status is unavailable");
|
|
385
|
+
// Informational (Unraid "normal") notifications are not incidents; warnings and alerts are.
|
|
367
386
|
else
|
|
368
387
|
for (const item of notifications)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (!endpoint.ok)
|
|
372
|
-
add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
|
|
388
|
+
if (item.severity !== "info")
|
|
389
|
+
add(`notification:${item.id}`, `unacknowledged notification: ${item.title || item.id}`);
|
|
373
390
|
const previous = load(options.statePath);
|
|
391
|
+
const endpointFailures = {};
|
|
392
|
+
for (const endpoint of endpoints) {
|
|
393
|
+
if (endpoint.ok)
|
|
394
|
+
continue;
|
|
395
|
+
endpointFailures[endpoint.url] = (previous.endpointFailures?.[endpoint.url] ?? 0) + 1;
|
|
396
|
+
if (endpointFailures[endpoint.url] >= endpointFailuresToOpen)
|
|
397
|
+
add(`endpoint:${endpoint.url}`, `${endpoint.url} returned ${endpoint.status || "no response"}`);
|
|
398
|
+
}
|
|
374
399
|
const current = Object.fromEntries([...incidents.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, incident]) => {
|
|
375
400
|
const prior = previous.incidents[id];
|
|
376
401
|
const transition = !prior ? "opened" : prior.observationRevision === incident.observationRevision ? "unchanged" : "changed";
|
|
@@ -401,6 +426,7 @@ function createSanctuaryHealthSweep(options) {
|
|
|
401
426
|
lastDigestDay: null,
|
|
402
427
|
updatedAt: now().toISOString(),
|
|
403
428
|
...(unacknowledgedRecovered.length > 0 ? { pendingRecovered: unacknowledgedRecovered } : {}),
|
|
429
|
+
...(Object.keys(endpointFailures).length > 0 ? { endpointFailures } : {}),
|
|
404
430
|
outbox: null,
|
|
405
431
|
indeterminateDeliveries: [],
|
|
406
432
|
deliveredReceipts: previous.deliveredReceipts,
|
|
@@ -33,10 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.OWNER_CHAT_HANDOFF_MS = void 0;
|
|
36
|
+
exports.QUIET_HOURS = exports.PROACTIVE_DAILY_CAP = exports.OWNER_CHAT_HANDOFF_MS = void 0;
|
|
37
37
|
exports.ownerChatHandedOff = ownerChatHandedOff;
|
|
38
38
|
exports.readHeldNotices = readHeldNotices;
|
|
39
39
|
exports.writeHeldNotices = writeHeldNotices;
|
|
40
|
+
exports.isQuietHour = isQuietHour;
|
|
41
|
+
exports.isCriticalNoticeKey = isCriticalNoticeKey;
|
|
42
|
+
exports.proactiveHoldReason = proactiveHoldReason;
|
|
43
|
+
exports.recordProactiveSend = recordProactiveSend;
|
|
44
|
+
exports.foldHeldNotices = foldHeldNotices;
|
|
45
|
+
exports.readReleasedHeldKeys = readReleasedHeldKeys;
|
|
46
|
+
exports.recordReleasedHeldKeys = recordReleasedHeldKeys;
|
|
40
47
|
/**
|
|
41
48
|
* Ari sometimes hands his Telegram chat to someone else ("I'm going to hand you to my wife, Rachel"). For a while after that,
|
|
42
49
|
* the chat is that person's conversation, so house notices (an outage, a recovery) wait instead of landing in it
|
|
@@ -45,6 +52,7 @@ exports.writeHeldNotices = writeHeldNotices;
|
|
|
45
52
|
*/
|
|
46
53
|
const fs = __importStar(require("node:fs"));
|
|
47
54
|
const path = __importStar(require("node:path"));
|
|
55
|
+
const node_crypto_1 = require("node:crypto");
|
|
48
56
|
const session_events_1 = require("../heart/session-events");
|
|
49
57
|
const runtime_1 = require("../nerves/runtime");
|
|
50
58
|
exports.OWNER_CHAT_HANDOFF_MS = 2 * 60 * 60 * 1000;
|
|
@@ -78,3 +86,69 @@ function writeHeldNotices(filePath, notices) {
|
|
|
78
86
|
fs.renameSync(temporary, filePath);
|
|
79
87
|
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_held_notices_saved", message: "saved the house notices held during a handoff", meta: { held: notices.length } });
|
|
80
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Proactive pacing: at most PROACTIVE_DAILY_CAP unprompted owner messages per local day, and none in quiet hours
|
|
91
|
+
* (23:00-08:00 local), except critical ones (array or disk failure, parity errors). A held message waits in the same
|
|
92
|
+
* held-notice file and goes out, folded into one message, at the next allowed moment.
|
|
93
|
+
*/
|
|
94
|
+
exports.PROACTIVE_DAILY_CAP = 3;
|
|
95
|
+
exports.QUIET_HOURS = { start: 23, end: 8 };
|
|
96
|
+
const CRITICAL_NOTICE = /\b(?:disk:[^"\s]*:smart|parity:|disk:array:state)/u;
|
|
97
|
+
function localParts(now, timeZone) {
|
|
98
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23" })
|
|
99
|
+
.formatToParts(now).map((part) => [part.type, part.value]));
|
|
100
|
+
return { day: `${parts.year}-${parts.month}-${parts.day}`, hour: Number(parts.hour) };
|
|
101
|
+
}
|
|
102
|
+
function isQuietHour(now, pacing) {
|
|
103
|
+
const { hour } = localParts(now, pacing.timeZone);
|
|
104
|
+
return hour >= exports.QUIET_HOURS.start || hour < exports.QUIET_HOURS.end;
|
|
105
|
+
}
|
|
106
|
+
function isCriticalNoticeKey(key) {
|
|
107
|
+
return CRITICAL_NOTICE.test(key);
|
|
108
|
+
}
|
|
109
|
+
function readProactiveLedger(filePath) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
112
|
+
return typeof parsed.day === "string" && Number.isSafeInteger(parsed.count) ? { day: parsed.day, count: parsed.count } : null;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Why a non-critical proactive message must wait now, or null when it may go. */
|
|
119
|
+
function proactiveHoldReason(ledgerPath, now, pacing) {
|
|
120
|
+
if (isQuietHour(now, pacing))
|
|
121
|
+
return "quiet_hours";
|
|
122
|
+
const ledger = readProactiveLedger(ledgerPath);
|
|
123
|
+
return ledger?.day === localParts(now, pacing.timeZone).day && ledger.count >= exports.PROACTIVE_DAILY_CAP ? "daily_cap" : null;
|
|
124
|
+
}
|
|
125
|
+
function recordProactiveSend(ledgerPath, now, pacing) {
|
|
126
|
+
const day = localParts(now, pacing.timeZone).day;
|
|
127
|
+
const ledger = readProactiveLedger(ledgerPath);
|
|
128
|
+
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
|
129
|
+
fs.writeFileSync(ledgerPath, JSON.stringify({ day, count: ledger?.day === day ? ledger.count + 1 : 1 }), { mode: 0o600 });
|
|
130
|
+
}
|
|
131
|
+
/** Several held notices go out as one message so a quiet night never becomes a morning burst. */
|
|
132
|
+
function foldHeldNotices(held) {
|
|
133
|
+
if (held.length === 1)
|
|
134
|
+
return held[0];
|
|
135
|
+
return { key: `held-batch:${(0, node_crypto_1.createHash)("sha256").update(held.map((notice) => notice.key).join("\0")).digest("hex").slice(0, 32)}`, text: ["While you were away:", ...held.map((notice) => `- ${notice.text}`)].join("\n") };
|
|
136
|
+
}
|
|
137
|
+
const RELEASED_KEYS_LIMIT = 500;
|
|
138
|
+
/** Keys of held notices that went out inside a folded batch, so a retried decision sees its message already exists. */
|
|
139
|
+
function readReleasedHeldKeys(filePath) {
|
|
140
|
+
try {
|
|
141
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
142
|
+
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function recordReleasedHeldKeys(filePath, keys) {
|
|
149
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
150
|
+
const next = [...readReleasedHeldKeys(filePath).filter((key) => !keys.includes(key)), ...keys].slice(-RELEASED_KEYS_LIMIT);
|
|
151
|
+
const temporary = `${filePath}.${process.pid}.tmp`;
|
|
152
|
+
fs.writeFileSync(temporary, JSON.stringify(next), { mode: 0o600 });
|
|
153
|
+
fs.renameSync(temporary, filePath);
|
|
154
|
+
}
|