@haaaiawd/second-nature 0.2.4 → 0.2.6

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.
Files changed (39) hide show
  1. package/openclaw.plugin.json +1 -1
  2. package/package.json +1 -1
  3. package/runtime/cli/commands/index.d.ts +4 -0
  4. package/runtime/cli/commands/index.js +179 -5
  5. package/runtime/cli/index.js +2 -0
  6. package/runtime/cli/ops/ops-router.js +27 -17
  7. package/runtime/connectors/base/contract.d.ts +1 -0
  8. package/runtime/connectors/base/failure-taxonomy.js +45 -26
  9. package/runtime/connectors/services/connector-cooldown-port.d.ts +22 -0
  10. package/runtime/connectors/services/connector-cooldown-port.js +123 -0
  11. package/runtime/connectors/services/connector-executor-adapter.js +10 -4
  12. package/runtime/connectors/services/credential-route-context.d.ts +3 -2
  13. package/runtime/connectors/services/credential-route-context.js +19 -3
  14. package/runtime/core/second-nature/action/action-closure-recorder.d.ts +4 -0
  15. package/runtime/core/second-nature/action/action-closure-recorder.js +5 -0
  16. package/runtime/core/second-nature/action/action-proposal-builder.js +1 -0
  17. package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.d.ts +2 -0
  18. package/runtime/core/second-nature/control-plane/heartbeat-orchestrator.js +76 -0
  19. package/runtime/core/second-nature/control-plane/real-runtime-spine.d.ts +2 -0
  20. package/runtime/core/second-nature/control-plane/real-runtime-spine.js +1 -0
  21. package/runtime/core/second-nature/quiet-dream/daily-rhythm-scheduler.d.ts +1 -1
  22. package/runtime/core/second-nature/quiet-dream/daily-rhythm-scheduler.js +10 -5
  23. package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.d.ts +2 -2
  24. package/runtime/core/second-nature/quiet-dream/memory-projection-lifecycle.js +10 -28
  25. package/runtime/observability/db/index.d.ts +2 -0
  26. package/runtime/observability/db/index.js +6 -0
  27. package/runtime/observability/living-loop-health-gate.d.ts +6 -2
  28. package/runtime/observability/living-loop-health-gate.js +52 -5
  29. package/runtime/observability/loop-status.d.ts +19 -0
  30. package/runtime/observability/loop-status.js +121 -7
  31. package/runtime/observability/services/heartbeat-digest-assembler.d.ts +9 -0
  32. package/runtime/observability/services/heartbeat-digest-assembler.js +44 -9
  33. package/runtime/shared/types/v8-contracts.d.ts +1 -1
  34. package/runtime/storage/db/index.d.ts +2 -0
  35. package/runtime/storage/db/index.js +28 -2
  36. package/runtime/storage/db/schema/v8-entities.d.ts +288 -0
  37. package/runtime/storage/db/schema/v8-entities.js +23 -1
  38. package/runtime/storage/v8-state-stores.d.ts +10 -1
  39. package/runtime/storage/v8-state-stores.js +86 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "second-nature",
3
3
  "name": "Second Nature",
4
- "version": "0.2.4",
4
+ "version": "0.2.6",
5
5
  "description": "OpenClaw native plugin with synchronous surface registration and bundled runtime spine. Set SECOND_NATURE_WORKSPACE_ROOT or tool workspaceRoot to the same path as the agent workspace. Agent inner guide is packaged as agent-inner-guide.md. v7 ops surface: self_health, tool_affordance, heartbeat_digest, snapshot:capture, narrative:diff, timeline, restore, runtime_secret_bootstrap, connector:run, guidance_payload.",
6
6
  "activation": {
7
7
  "onStartup": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haaaiawd/second-nature",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "OpenClaw native plugin with synchronous registration, a packaged runtime artifact, and operator-facing status/explain flows.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -1,5 +1,7 @@
1
1
  import type { ActionBridge } from "../action-bridge.js";
2
2
  import type { OpsRouter } from "../ops/ops-router.js";
3
+ import type { StateDatabase } from "../../storage/db/index.js";
4
+ import type { ObservabilityDatabase } from "../../observability/db/index.js";
3
5
  import type { CliReadModels } from "../read-models/index.js";
4
6
  export interface CliCommandDefinition {
5
7
  name: string;
@@ -10,5 +12,7 @@ export interface CliCommandDeps {
10
12
  readModels: CliReadModels;
11
13
  actionBridge: ActionBridge;
12
14
  opsRouter: OpsRouter;
15
+ stateDb?: StateDatabase;
16
+ observabilityDb?: ObservabilityDatabase;
13
17
  }
14
18
  export declare function createCliCommands(deps: CliCommandDeps): CliCommandDefinition[];
@@ -1,3 +1,5 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { credentialVerify } from "./credential.js";
2
4
  import { connectorInit } from "./connector-init.js";
3
5
  import { formatExplanation } from "../explain/format-explanation.js";
@@ -5,6 +7,139 @@ import { explainSurfaceSubject } from "../explain/explain-surface-subject.js";
5
7
  import { showOperatorFallback, OperatorFallbackNotFoundError, } from "../ops/show-operator-fallback.js";
6
8
  import { runStorageModeSmoke } from "../../storage/bootstrap/storage-mode-smoke.js";
7
9
  import { policySet } from "./policy.js";
10
+ const SETUP_MARKER_RELATIVE_PATH = path.join(".second-nature", "setup", "agent-inner-guide-ack.json");
11
+ function safeShortText(value, maxLen) {
12
+ if (typeof value !== "string")
13
+ return undefined;
14
+ const trimmed = value.trim();
15
+ if (trimmed.length === 0)
16
+ return undefined;
17
+ return trimmed.slice(0, maxLen);
18
+ }
19
+ function resolveWorkspaceRoot(input) {
20
+ if (typeof input?.workspaceRoot === "string" && input.workspaceRoot.trim().length > 0) {
21
+ return path.resolve(input.workspaceRoot);
22
+ }
23
+ if (typeof process.env.SECOND_NATURE_WORKSPACE_ROOT === "string" && process.env.SECOND_NATURE_WORKSPACE_ROOT.trim().length > 0) {
24
+ return path.resolve(process.env.SECOND_NATURE_WORKSPACE_ROOT);
25
+ }
26
+ return process.cwd();
27
+ }
28
+ function readSetupText(fileName) {
29
+ const candidates = fileName === "SKILL.md"
30
+ ? [path.resolve(process.cwd(), "SKILL.md"), path.resolve(process.cwd(), "..", "SKILL.md")]
31
+ : [path.resolve(process.cwd(), "plugin", "agent-inner-guide.md"), path.resolve(process.cwd(), "..", "plugin", "agent-inner-guide.md")];
32
+ for (const candidate of candidates) {
33
+ try {
34
+ const content = fs.readFileSync(candidate, "utf-8");
35
+ return { ok: true, path: candidate, content };
36
+ }
37
+ catch {
38
+ // try next candidate
39
+ }
40
+ }
41
+ return { ok: false, path: candidates[0] ?? fileName, error: `Could not read ${fileName}` };
42
+ }
43
+ function summarizeSetupText(content) {
44
+ const lines = content.split("\n");
45
+ const nonEmpty = lines.filter((line) => line.trim().length > 0);
46
+ const first = nonEmpty.slice(0, 3).join("\n");
47
+ const marker = content.length > first.length ? "\n\n[...]" : "";
48
+ return `${first}${marker}`;
49
+ }
50
+ function readSetupAckMarker(workspaceRoot) {
51
+ const markerPath = path.join(workspaceRoot, SETUP_MARKER_RELATIVE_PATH);
52
+ try {
53
+ const raw = fs.readFileSync(markerPath, "utf-8");
54
+ const marker = JSON.parse(raw);
55
+ if (marker.status === "acknowledged") {
56
+ return {
57
+ status: "acknowledged",
58
+ markerPath,
59
+ acknowledgedAt: typeof marker.acknowledgedAt === "string" ? marker.acknowledgedAt : undefined,
60
+ placedIn: typeof marker.placedIn === "string" ? marker.placedIn : undefined,
61
+ };
62
+ }
63
+ }
64
+ catch {
65
+ // marker missing or unreadable
66
+ }
67
+ return { status: "pending", markerPath };
68
+ }
69
+ async function buildSetupHintPayload(input) {
70
+ const format = input?.format === "full" ? "full" : "summary";
71
+ const includeSkill = input?.includeSkill !== false;
72
+ const includeGuide = input?.includeGuide !== false;
73
+ const workspaceRoot = resolveWorkspaceRoot(input);
74
+ const ack = readSetupAckMarker(workspaceRoot);
75
+ const data = {
76
+ status: ack.status,
77
+ workspaceRoot,
78
+ markerPath: ack.markerPath,
79
+ acknowledgedAt: ack.acknowledgedAt,
80
+ placedIn: ack.placedIn,
81
+ recommendedPlacement: [
82
+ "agent prompt",
83
+ "workspace/IDENTITY.md",
84
+ "workspace/USER.md",
85
+ ],
86
+ nextStep: ack.status === "acknowledged"
87
+ ? "setup_already_acknowledged"
88
+ : "read_returned_guidance_then_run_setup_ack",
89
+ };
90
+ if (includeSkill) {
91
+ const skill = readSetupText("SKILL.md");
92
+ data.skill = skill.ok
93
+ ? {
94
+ path: skill.path,
95
+ content: format === "full" ? skill.content : summarizeSetupText(skill.content),
96
+ }
97
+ : skill;
98
+ }
99
+ if (includeGuide) {
100
+ const guide = readSetupText("agent-inner-guide.md");
101
+ data.guide = guide.ok
102
+ ? {
103
+ path: guide.path,
104
+ content: format === "full" ? guide.content : summarizeSetupText(guide.content),
105
+ }
106
+ : guide;
107
+ }
108
+ return {
109
+ ok: true,
110
+ command: "setup_hint",
111
+ surfaceMode: "workspace_full_runtime",
112
+ message: "Read the SKILL and guide as a friendly setup note, then place the guidance where the agent naturally checks its working anchors.",
113
+ data,
114
+ };
115
+ }
116
+ async function buildSetupAckPayload(input) {
117
+ const workspaceRoot = resolveWorkspaceRoot(input);
118
+ const markerPath = path.join(workspaceRoot, SETUP_MARKER_RELATIVE_PATH);
119
+ const marker = {
120
+ acknowledgedAt: new Date().toISOString(),
121
+ acceptedBy: safeShortText(input?.acceptedBy, 80) ?? "agent",
122
+ placedIn: safeShortText(input?.placedIn, 160) ?? "unspecified",
123
+ note: safeShortText(input?.note, 240),
124
+ guideVersion: "0.2.5",
125
+ source: "second-nature-cli",
126
+ skillPath: "SKILL.md",
127
+ guidePath: "plugin/agent-inner-guide.md",
128
+ status: "acknowledged",
129
+ };
130
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
131
+ fs.writeFileSync(markerPath, `${JSON.stringify(marker, null, 2)}\n`, "utf-8");
132
+ return {
133
+ ok: true,
134
+ command: "setup_ack",
135
+ surfaceMode: "workspace_full_runtime",
136
+ message: "Setup guide acknowledgement persisted; setup nudge is now silent for this workspace.",
137
+ data: {
138
+ markerPath,
139
+ ...marker,
140
+ },
141
+ };
142
+ }
8
143
  const notImplemented = async (command) => ({
9
144
  ok: false,
10
145
  command,
@@ -22,16 +157,45 @@ function explainSubjectError(code, message) {
22
157
  };
23
158
  }
24
159
  export function createCliCommands(deps) {
25
- const { readModels, actionBridge, opsRouter } = deps;
160
+ const { readModels, actionBridge, opsRouter, stateDb, observabilityDb } = deps;
161
+ const flush = () => {
162
+ try {
163
+ stateDb?.flush();
164
+ }
165
+ catch {
166
+ // ignore flush errors to avoid masking command results
167
+ }
168
+ try {
169
+ observabilityDb?.flush();
170
+ }
171
+ catch {
172
+ // ignore flush errors to avoid masking command results
173
+ }
174
+ };
26
175
  const opsCommand = (name, description) => ({
27
176
  name,
28
177
  description,
29
178
  execute: async (input) => {
30
179
  const surface = await Promise.resolve(opsRouter.dispatch(name, input));
180
+ flush();
31
181
  return surface;
32
182
  },
33
183
  });
34
184
  return [
185
+ {
186
+ name: "setup_hint",
187
+ description: "Return the packaged setup SKILL and agent inner guide for first-run onboarding",
188
+ execute: async (input) => buildSetupHintPayload(input),
189
+ },
190
+ {
191
+ name: "setup_ack",
192
+ description: "Persist that the packaged setup guide was read and placed into working anchors",
193
+ execute: async (input) => {
194
+ const result = await buildSetupAckPayload(input);
195
+ flush();
196
+ return result;
197
+ },
198
+ },
35
199
  {
36
200
  name: "status",
37
201
  description: "T1.2.6 — Show v6 aggregated Second Nature status (narrative + dream + cycles + runtime)",
@@ -47,7 +211,9 @@ export function createCliCommands(deps) {
47
211
  execute: async (input) => {
48
212
  const action = typeof input?.action === "string" ? input.action : "show";
49
213
  if (action === "set") {
50
- return policySet(actionBridge, input);
214
+ const result = await policySet(actionBridge, input);
215
+ flush();
216
+ return result;
51
217
  }
52
218
  // T1.2.6 (SN-CODE-01): `policy show` (default) returns the current rhythm policy
53
219
  // snapshot. Returns workspace defaults when no policy row has been persisted yet.
@@ -159,6 +325,7 @@ export function createCliCommands(deps) {
159
325
  description: "Workspace heartbeat_check ops surface (v5 HeartbeatSurfaceResult)",
160
326
  execute: async (input) => {
161
327
  const surface = await Promise.resolve(opsRouter.dispatch("heartbeat_check", input));
328
+ flush();
162
329
  return surface;
163
330
  },
164
331
  },
@@ -174,6 +341,7 @@ export function createCliCommands(deps) {
174
341
  runRepairFixture,
175
342
  workspaceRoot,
176
343
  });
344
+ flush();
177
345
  return { ok: true, data };
178
346
  },
179
347
  },
@@ -218,6 +386,7 @@ export function createCliCommands(deps) {
218
386
  description: "T1.2.8 — probe host capabilities and persist report (static unknown adapter in CLI context)",
219
387
  execute: async (input) => {
220
388
  const surface = await Promise.resolve(opsRouter.dispatch("capability_probe", input));
389
+ flush();
221
390
  return surface;
222
391
  },
223
392
  },
@@ -226,6 +395,7 @@ export function createCliCommands(deps) {
226
395
  description: "T3.3.2 — run near-real connector smoke (sentinel Moltbook + EvoMap, no live HTTP)",
227
396
  execute: async (input) => {
228
397
  const surface = await Promise.resolve(opsRouter.dispatch("near_real_smoke", input));
398
+ flush();
229
399
  return surface;
230
400
  },
231
401
  },
@@ -249,6 +419,7 @@ export function createCliCommands(deps) {
249
419
  ? input.workspaceRoot
250
420
  : undefined,
251
421
  });
422
+ flush();
252
423
  return result;
253
424
  },
254
425
  },
@@ -261,10 +432,11 @@ export function createCliCommands(deps) {
261
432
  },
262
433
  },
263
434
  {
264
- name: "connector_status",
265
- description: "T1.2.3show connector inventory, trust/executable/conflict summary",
435
+ name: "credential",
436
+ description: "T1.4.1inspect or verify credential health without exposing plaintext",
266
437
  execute: async (input) => {
267
- const surface = await Promise.resolve(opsRouter.dispatch("connector_status", input));
438
+ const surface = await Promise.resolve(opsRouter.dispatch("credential", input));
439
+ flush();
268
440
  return surface;
269
441
  },
270
442
  },
@@ -273,6 +445,7 @@ export function createCliCommands(deps) {
273
445
  description: "T1.2.3 — dry-run test a connector by platformId (default dry-run)",
274
446
  execute: async (input) => {
275
447
  const surface = await Promise.resolve(opsRouter.dispatch("connector_test", input));
448
+ flush();
276
449
  return surface;
277
450
  },
278
451
  },
@@ -292,6 +465,7 @@ export function createCliCommands(deps) {
292
465
  description: "T1.2.4 — owner-governed goal operations: set, list, accept, reject",
293
466
  execute: async (input) => {
294
467
  const surface = await Promise.resolve(opsRouter.dispatch("goal", input));
468
+ flush();
295
469
  return surface;
296
470
  },
297
471
  },
@@ -290,6 +290,8 @@ export function createCommandRouter(options = {}) {
290
290
  readModels: runtime.readModels,
291
291
  actionBridge: runtime.actionBridge,
292
292
  opsRouter,
293
+ stateDb: runtime.stateDb,
294
+ observabilityDb: runtime.observabilityDb,
293
295
  });
294
296
  return {
295
297
  commands,
@@ -1067,6 +1067,8 @@ export function createOpsRouter(deps) {
1067
1067
  hasRealClosure: realRunResult.gate.hasRealClosure,
1068
1068
  hasQuietArtifact: realRunResult.gate.hasQuietArtifact,
1069
1069
  hasDreamArtifact: realRunResult.gate.hasDreamArtifact,
1070
+ hasFreshImpulseContext: realRunResult.gate.hasFreshImpulseContext,
1071
+ hasProjectionFeedback: realRunResult.gate.hasProjectionFeedback,
1070
1072
  missingStage: realRunResult.gate.missingStage,
1071
1073
  missingReason: realRunResult.gate.missingReason,
1072
1074
  };
@@ -1079,6 +1081,8 @@ export function createOpsRouter(deps) {
1079
1081
  hasRealClosure: false,
1080
1082
  hasQuietArtifact: false,
1081
1083
  hasDreamArtifact: false,
1084
+ hasFreshImpulseContext: false,
1085
+ hasProjectionFeedback: false,
1082
1086
  missingReason: "Real-run health check degraded: " + realRunResult.degraded.reason,
1083
1087
  };
1084
1088
  }
@@ -1140,24 +1144,30 @@ export function createOpsRouter(deps) {
1140
1144
  };
1141
1145
  return envelope;
1142
1146
  }
1143
- const fromVersion = typeof input?.from === "string" ? input.from : "";
1144
- const toVersion = typeof input?.to === "string" ? input.to : "";
1147
+ let fromVersion = typeof input?.from === "string" ? input.from : "";
1148
+ let toVersion = typeof input?.to === "string" ? input.to : "";
1145
1149
  if (!fromVersion || !toVersion) {
1146
- const envelope = {
1147
- ok: false,
1148
- command: "narrative:diff",
1149
- runtimeMode: "workspace_full_runtime",
1150
- surfaceMode: "cli",
1151
- generatedAt,
1152
- error: {
1153
- code: "MISSING_VERSIONS",
1154
- message: "narrative:diff requires 'from' and 'to' version arguments",
1155
- nextStep: "reinvoke_with_from_and_to",
1156
- },
1157
- warnings: [],
1158
- sourceRefs: [],
1159
- };
1160
- return envelope;
1150
+ // Auto-resolve the two most recent narrative timeline versions when not provided.
1151
+ const recent = await deps.narrativeTimelineDeps.stateMemoryPort.listNarrativeTimeline(new Date(0).toISOString(), new Date().toISOString(), { limit: 2 });
1152
+ if (recent.length < 2) {
1153
+ const envelope = {
1154
+ ok: false,
1155
+ command: "narrative:diff",
1156
+ runtimeMode: "workspace_full_runtime",
1157
+ surfaceMode: "cli",
1158
+ generatedAt,
1159
+ error: {
1160
+ code: "NARRATIVE_DIFF_REQUIRES_TWO_VERSIONS",
1161
+ message: `narrative:diff requires at least two timeline versions; found ${recent.length}. Pass explicit 'from' and 'to', or run snapshot:capture twice.`,
1162
+ nextStep: "run_snapshot_capture_twice_or_pass_from_and_to",
1163
+ },
1164
+ warnings: [],
1165
+ sourceRefs: [],
1166
+ };
1167
+ return envelope;
1168
+ }
1169
+ fromVersion = recent[1].version;
1170
+ toVersion = recent[0].version;
1161
1171
  }
1162
1172
  try {
1163
1173
  const diff = await queryNarrativeDiff(fromVersion, toVersion, deps.narrativeTimelineDeps);
@@ -73,6 +73,7 @@ export interface CooldownLedgerPort {
73
73
  loadCooldownState(platformId: string, intent: CapabilityIntent): Promise<{
74
74
  blocked: boolean;
75
75
  retryAfterMs?: number;
76
+ reason?: string;
76
77
  }>;
77
78
  }
78
79
  export interface RouteContextPort extends CredentialContextPort, CooldownLedgerPort {
@@ -61,6 +61,23 @@ function readRetryAfterMs(input) {
61
61
  }
62
62
  return undefined;
63
63
  }
64
+ function readStatusCode(record) {
65
+ if (typeof record.status === "number")
66
+ return record.status;
67
+ if (typeof record.statusCode === "number")
68
+ return record.statusCode;
69
+ if (typeof record.status === "string") {
70
+ const parsed = Number.parseInt(record.status, 10);
71
+ if (Number.isFinite(parsed))
72
+ return parsed;
73
+ }
74
+ if (typeof record.statusCode === "string") {
75
+ const parsed = Number.parseInt(record.statusCode, 10);
76
+ if (Number.isFinite(parsed))
77
+ return parsed;
78
+ }
79
+ return undefined;
80
+ }
64
81
  export function classifyFailure(error) {
65
82
  if (error instanceof ConnectorPolicyError) {
66
83
  return {
@@ -74,6 +91,34 @@ export function classifyFailure(error) {
74
91
  }
75
92
  if (error && typeof error === "object") {
76
93
  const record = error;
94
+ const status = readStatusCode(record);
95
+ if (status !== undefined) {
96
+ if (status === 429) {
97
+ return {
98
+ class: "rate_limited",
99
+ retryable: RETRYABLE_BY_CLASS.rate_limited,
100
+ retryAfterMs: readRetryAfterMs(record),
101
+ };
102
+ }
103
+ if (status === 401 || status === 403) {
104
+ return {
105
+ class: "auth_failure",
106
+ retryable: RETRYABLE_BY_CLASS.auth_failure,
107
+ };
108
+ }
109
+ if (status === 400 || status === 404 || status === 422) {
110
+ return {
111
+ class: "permanent_input_error",
112
+ retryable: RETRYABLE_BY_CLASS.permanent_input_error,
113
+ };
114
+ }
115
+ if (status >= 500 && status <= 599) {
116
+ return {
117
+ class: "transport_failure",
118
+ retryable: RETRYABLE_BY_CLASS.transport_failure,
119
+ };
120
+ }
121
+ }
77
122
  const code = record.code;
78
123
  if (typeof code === "string") {
79
124
  if (code === "auth_failure")
@@ -152,32 +197,6 @@ export function classifyFailure(error) {
152
197
  retryable: RETRYABLE_BY_CLASS.unknown_platform_change,
153
198
  };
154
199
  }
155
- const status = record.status;
156
- if (status === 429) {
157
- return {
158
- class: "rate_limited",
159
- retryable: RETRYABLE_BY_CLASS.rate_limited,
160
- retryAfterMs: readRetryAfterMs(record),
161
- };
162
- }
163
- if (status === 401 || status === 403) {
164
- return {
165
- class: "auth_failure",
166
- retryable: RETRYABLE_BY_CLASS.auth_failure,
167
- };
168
- }
169
- if (status === 400 || status === 404 || status === 422) {
170
- return {
171
- class: "permanent_input_error",
172
- retryable: RETRYABLE_BY_CLASS.permanent_input_error,
173
- };
174
- }
175
- if (status === 500 || status === 502 || status === 503 || status === 504) {
176
- return {
177
- class: "transport_failure",
178
- retryable: RETRYABLE_BY_CLASS.transport_failure,
179
- };
180
- }
181
200
  }
182
201
  return {
183
202
  class: "unknown_platform_change",
@@ -0,0 +1,22 @@
1
+ /**
2
+ * ConnectorCooldownPort — Durable cooldown ledger for repeated terminal failures.
3
+ *
4
+ * Core logic: Track terminal failures per platform/capability and block replay
5
+ * for a bounded window after repeated failures. Successful recovery is allowed
6
+ * to bypass stale cooldown.
7
+ *
8
+ * Design authority:
9
+ * - `.anws/v8/04_SYSTEM_DESIGN/connector-system.md §6`
10
+ * - `.anws/v8/04_SYSTEM_DESIGN/body-tool-system.md §4`
11
+ *
12
+ * Dependencies:
13
+ * - `src/storage/v8-state-stores.js` (read/write connector cooldown state)
14
+ * - `src/connectors/base/failure-taxonomy.js` (FailureClass, retryable lookup)
15
+ *
16
+ * Boundary:
17
+ * - Does not execute connectors; only records/read cooldown state.
18
+ * - Does not permanently blacklist platforms; cooldown expires.
19
+ */
20
+ import type { StateDatabase } from "../../storage/db/index.js";
21
+ import type { CooldownPort } from "../base/policy-layer.js";
22
+ export declare function createConnectorCooldownPort(db: StateDatabase): CooldownPort;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * ConnectorCooldownPort — Durable cooldown ledger for repeated terminal failures.
3
+ *
4
+ * Core logic: Track terminal failures per platform/capability and block replay
5
+ * for a bounded window after repeated failures. Successful recovery is allowed
6
+ * to bypass stale cooldown.
7
+ *
8
+ * Design authority:
9
+ * - `.anws/v8/04_SYSTEM_DESIGN/connector-system.md §6`
10
+ * - `.anws/v8/04_SYSTEM_DESIGN/body-tool-system.md §4`
11
+ *
12
+ * Dependencies:
13
+ * - `src/storage/v8-state-stores.js` (read/write connector cooldown state)
14
+ * - `src/connectors/base/failure-taxonomy.js` (FailureClass, retryable lookup)
15
+ *
16
+ * Boundary:
17
+ * - Does not execute connectors; only records/read cooldown state.
18
+ * - Does not permanently blacklist platforms; cooldown expires.
19
+ */
20
+ import { readConnectorCooldownState, writeConnectorCooldownState, } from "../../storage/v8-state-stores.js";
21
+ // ───────────────────────────────────────────────────────────────
22
+ // Config
23
+ // ───────────────────────────────────────────────────────────────
24
+ const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
25
+ const TERMINAL_FAILURE_THRESHOLD = 2;
26
+ const RETRYABLE_FAILURE_CLASSES = new Set([
27
+ "transport_failure",
28
+ "rate_limited",
29
+ "timeout",
30
+ "concurrency_conflict",
31
+ ]);
32
+ // ───────────────────────────────────────────────────────────────
33
+ // Helpers
34
+ // ───────────────────────────────────────────────────────────────
35
+ function makeCooldownId(platformId, capabilityId) {
36
+ return `cooldown_${platformId}_${capabilityId}`;
37
+ }
38
+ function addMs(iso, ms) {
39
+ return new Date(new Date(iso).getTime() + ms).toISOString();
40
+ }
41
+ function isAfter(a, b) {
42
+ return new Date(a).getTime() > new Date(b).getTime();
43
+ }
44
+ // ───────────────────────────────────────────────────────────────
45
+ // Public API
46
+ // ───────────────────────────────────────────────────────────────
47
+ export function createConnectorCooldownPort(db) {
48
+ return {
49
+ async isBlocked(platformId, intent) {
50
+ const read = await readConnectorCooldownState(db, platformId, intent);
51
+ if (read.degraded) {
52
+ // Fail-closed: if we cannot read cooldown state, prevent replay to avoid hammering
53
+ return {
54
+ blocked: true,
55
+ reason: "cooldown_state_unreadable",
56
+ };
57
+ }
58
+ if (!read.row) {
59
+ return { blocked: false };
60
+ }
61
+ const now = new Date().toISOString();
62
+ const blocked = isAfter(read.row.blockedUntil, now);
63
+ return {
64
+ blocked,
65
+ retryAfterMs: blocked
66
+ ? Math.max(0, new Date(read.row.blockedUntil).getTime() - new Date(now).getTime())
67
+ : undefined,
68
+ };
69
+ },
70
+ async markFailure(platformId, intent, failureClass, retryAfterMs) {
71
+ const id = makeCooldownId(platformId, intent);
72
+ const now = new Date().toISOString();
73
+ const existing = await readConnectorCooldownState(db, platformId, intent);
74
+ const isRetryable = RETRYABLE_FAILURE_CLASSES.has(failureClass);
75
+ let failureCount = 1;
76
+ let terminalCount = isRetryable ? 0 : 1;
77
+ let blockedUntil = now;
78
+ if (!existing.degraded && existing.row) {
79
+ failureCount = existing.row.failureCount + 1;
80
+ terminalCount = (existing.row.terminalCount ?? 0) + (isRetryable ? 0 : 1);
81
+ // Extend blocked window if already blocked
82
+ if (isAfter(existing.row.blockedUntil, now)) {
83
+ blockedUntil = existing.row.blockedUntil;
84
+ }
85
+ }
86
+ if (retryAfterMs && retryAfterMs > 0) {
87
+ // Rate-limit or explicit retry-after takes precedence
88
+ blockedUntil = addMs(now, retryAfterMs);
89
+ }
90
+ else if (!isRetryable && terminalCount >= TERMINAL_FAILURE_THRESHOLD) {
91
+ // Repeated terminal failures enter bounded cooldown
92
+ blockedUntil = addMs(now, DEFAULT_COOLDOWN_MS);
93
+ }
94
+ else if (isRetryable) {
95
+ // Retryable failures do not accumulate terminal cooldown
96
+ blockedUntil = now;
97
+ }
98
+ await writeConnectorCooldownState(db, {
99
+ id,
100
+ platformId,
101
+ capabilityId: intent,
102
+ failureClass,
103
+ retryAfterMs: retryAfterMs ?? null,
104
+ blockedUntil,
105
+ failureCount,
106
+ terminalCount,
107
+ sourceRefs: [
108
+ {
109
+ uri: `sn://cooldown/${platformId}/${intent}`,
110
+ family: "audit",
111
+ id,
112
+ redactionClass: "none",
113
+ resolveStatus: "resolvable",
114
+ },
115
+ ],
116
+ payloadJson: JSON.stringify({ markedAt: now, failureCount, terminalCount, isRetryable }),
117
+ createdAt: existing.row?.createdAt ?? now,
118
+ updatedAt: now,
119
+ redactionClass: "none",
120
+ });
121
+ },
122
+ };
123
+ }