diff --git a/.changeset/connector-degrade-cause.md b/.changeset/connector-degrade-cause.md new file mode 100644 index 0000000000..19082ee70a --- /dev/null +++ b/.changeset/connector-degrade-cause.md @@ -0,0 +1,52 @@ +--- +"@objectstack/service-automation": patch +--- + +fix(service-automation): connector 降级路径的两条日志改用结构化 `meta`,message 保持单行 (#5636) + +## 接缝 + +`degradeConnectorInstance`(#3017 的降级/重试路径)有两条记录报告的是**外来**失败,却把 +它插进了日志 message —— 与 #5048(flow 绑定)、#5575(`reconcileDeclaredConnectors` 的 +`fail()`)同一类,是那两单范围之外的第三个接缝: + +- **husk 注册失败**(`warn`):`err` 来自 `engine.registerDegradedConnector` → + `ConnectorSchema.parse`,catch 自己的注释就写着「the entry's def no longer parses」, + 也就是说这里预期接到的正是 `ZodError` —— 它的 `.message` 是 issue 数组的多行 JSON + dump,第一行只有一个 `[`。 +- **降级公告**(`error`):文本是 `ConnectorUpstreamUnavailableError.message`,由第三方 + provider factory 构造(ADR-0097 明确鼓励第三方去写)。spec 只定义错误类、不约束文本, + 所以上游 SDK 的多行失败会原样落在这里。 + +## 危害:这条 `warn` 的下游与 #5575 的 `error` 不同(实测) + +`ObjectLogger` 把 `warn` 送 stdout、`error`/`fatal` 送 stderr,而 `serve` 的启动静默窗口 +只包了 `process.stdout.write`。#5575 的接缝全是 `error`,所以那一单的结论是「启动缓冲根本 +看不到」;这一条不同,而且差别是**测出来**的,不是推的: + +- 它是 `warn` → stdout,缓冲**确实**看得到; +- 它在**冷启动**就会跑 —— `materializeDeclaredConnectors(ctx, { fatal: true })` 遇到上游 + 不可达是降级、不是抛错 —— 而窗口此时正开着(`serve` 在 config 加载前接管 stdout,直到 + banner 打印才恢复); +- `BootLogCapture.offer()` 只在 `classifyBootLogLine` 能在该物理行上找到 ` ` + 头时才保留它,所以插值 dump 的每一条续行是被**直接丢弃**,不只是难解析。 + +对一份 13 行的插值 ZodError 实测:写出 13 行物理行,缓冲保留 **1** 行(那条止于 Zod `[` +的头行)、丢弃 **12** 行 —— 唯一被留下的那行不含任何事实。这正是 cloud#971 的原始形态。 +`error` 那一条走 stderr,不经缓冲,危害是 #5575 那一串按行消费者(文件 sink、 +`docker logs`/journald 送采集、`grep ERROR`):一条诊断散成 N 个无法归属的碎片。 + +## 改法 + +两条都复用同包 `thrown-cause-diagnostics.ts` 的 `describeThrownForLog`(#5572/#5575 落地): +message 是不含换行的自足句子,cause 走 logger 的结构化 meta。位置按 `Logger` 契约区分, +并且是核对源码后确认的而非照抄:`warn(message, meta?)` 没有 `Error` 位,cause 就在**第二** +参;`error(message, error?, meta?)` 的 cause 在**第三**参(第二参塞原始 error 会让每次重试 +的记录都附带完整堆栈)。 + +## 刻意没有改的一件事 + +`degradedReason` —— `GET /connectors` 展示的、以及 `connector_action` 被拒时引用的那段文本 +—— 仍然逐字保留 provider 自己的 message,包含换行。它是人透过 JSON 读的字段,不经按行切分 +的消费者;重塑它属于另一次契约变更。因此调用点同时传 `reason`(那段文本)与 `cause`(抛出值 +本身):前者喂 husk 与重试簿记,后者只喂日志记录。测试双向钉住了这个分离。 diff --git a/packages/services/service-automation/src/connector-degrade-cause.test.ts b/packages/services/service-automation/src/connector-degrade-cause.test.ts new file mode 100644 index 0000000000..6cb5cb2844 --- /dev/null +++ b/packages/services/service-automation/src/connector-degrade-cause.test.ts @@ -0,0 +1,416 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Regression: #5636 — the #3017 connector DEGRADE path must report why an +// instance degraded in a form a line-oriented log consumer can read. +// +// `degradeConnectorInstance` is the third seam of the family #5048 (flow binding) +// and #5575 (`reconcileDeclaredConnectors`'s `fail()`) already closed, and it was +// out of both of their scopes. Two of its records interpolated a FOREIGN message +// into the log MESSAGE: +// +// ctx.logger.warn(`… could not register degraded husk for '${name}': ${(err as Error).message}`); +// ctx.logger.error(`… retrying with backoff, attempt ${n} (#3017): ${info.reason}`); +// +// Neither text is ours. The first `err` comes out of +// `engine.registerDegradedConnector` → `ConnectorSchema.parse`, so the catch's +// own comment ("the entry's def no longer parses") names a `ZodError` — whose +// `.message` is a multi-line JSON dump opening on the single character `[`. The +// second is `ConnectorUpstreamUnavailableError.message`, constructed by a +// third-party provider factory that ADR-0097 explicitly invites people to write; +// the spec defines the error class and says nothing about its text, so an SDK's +// multi-line failure lands there verbatim. +// +// ## Why the `warn` seam is worse than #5575's, and not by the same mechanism +// +// `ObjectLogger` routes `warn` to **stdout** and `error`/`fatal` to **stderr**, +// and `serve`'s boot-quiet window wraps `process.stdout.write` only. #5575's +// seams are all `error`, so its issue text was corrected: the boot buffer never +// sees them. This seam is different and the difference is measured, not assumed: +// +// • it is `warn` → stdout, so the buffer DOES see it; +// • it runs at **cold boot** — `materializeDeclaredConnectors(ctx, { fatal: +// true })` degrades instead of throwing when the upstream is unreachable — +// which is exactly when the window is open; +// • `BootLogCapture.offer()` retains a physical line only when +// `classifyBootLogLine` finds a ` ` head on it, so every +// continuation line of an interpolated dump is DROPPED, not merely mangled. +// +// That is cloud#971's original shape. The local reproduction at the bottom of +// this file measures it (13 physical lines in, 1 retained, 12 dropped) using the +// CLI's own predicate, re-stated here rather than imported — this package must +// not depend on `@objectstack/cli`, and the predicate is the general one every +// line-based consumer keys off, the CLI's buffer being the strictest example. +// +// The `error` seam has the #5575 harm instead (file sink, `docker logs` into a +// shipper, `grep ERROR` — one record read as N unattributable fragments). +// +// ## The fix, and the one thing it deliberately does NOT change +// +// Both records keep a newline-free message and hand the cause to the logger's +// `meta` (`warn`'s second argument; `error`'s THIRD, per the `Logger` contract's +// `error(message, error?, meta?)`), rendered by `describeThrownForLog`. +// +// `degradedReason` — what `GET /connectors` shows and what a `connector_action` +// refusal quotes — still carries the provider's message VERBATIM, newlines +// included. It is read by a human through JSON, not by a line splitter, and +// reshaping it would be a separate contract change. So the call site passes both +// `reason` (that text) and `cause` (the thrown value); the tests below pin the +// separation in both directions. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { LiteKernel, ObjectLogger } from '@objectstack/core'; +import type { ConnectorProviderFactory } from '@objectstack/spec/integration'; +import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration'; +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── fixtures ─────────────────────────────────────────────────────────────── + +/** + * What an SDK-wrapping provider factory throws when its upstream is down and it + * forwards the driver's own multi-line text. `connector-mcp` writes a single + * line today (which is why #5636 is a `finding`), but nothing in the spec makes + * that a rule — `ConnectorUpstreamUnavailableError`'s constructor takes whatever + * message the factory hands it. + */ +const MULTILINE_UPSTREAM = [ + "connector 'gh_mcp' could not reach its MCP server", + ' cause: connect ECONNREFUSED 127.0.0.1:8931', + ' hint: is the MCP server running?', +].join('\n'); + +/** A provider-bound declarative entry, as `registerApp` stores it. */ +function providerConnector(name: string, opts: { type?: string } = {}) { + return { + name, + label: name, + type: opts.type ?? 'api', + provider: 'fake', + providerConfig: {}, + }; +} + +/** A factory that is always down, throwing `message` with the #3017 marker. */ +function downFactory(message: string): ConnectorProviderFactory { + return () => { + throw new ConnectorUpstreamUnavailableError(message); + }; +} + +/** + * Boot a kernel with a declared connector set and a provider factory. Boot must + * NOT throw on an unreachable upstream — `{ fatal: true }` degrades (#3017) — + * which is what puts this seam inside `serve`'s boot-quiet window. + */ +async function bootDegraded(declared: unknown[], factory: ConnectorProviderFactory, logger?: unknown) { + const kernel = new LiteKernel({ logger: logger ?? { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + kernel.use({ + name: 'test.harness', + type: 'standard' as const, + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(ctx: any) { + ctx.registerService('objectql', { + registry: { listItems: (t: string) => (t === 'connector' ? declared : []) }, + }); + ctx.getService('automation').registerConnectorProvider('fake', factory); + }, + async start() {}, + } as never); + await kernel.bootstrap(); + return { kernel, engine: kernel.getService('automation') as AutomationEngine }; +} + +/** Capture everything written to one std stream while `fn` runs, split to lines. */ +async function captureStream( + which: 'stdout' | 'stderr', + fn: () => Promise, +): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process[which], 'write').mockImplementation(((c: string | Uint8Array) => { + chunks.push(String(c)); + return true; + }) as never); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return chunks.join('').split('\n').filter((l) => l.length > 0); +} + +/** + * `ObjectLogger`'s `pretty`/`text` record head — the same predicate + * `classifyBootLogLine` applies in `packages/cli/src/utils/boot-log-capture.ts`. + * Re-stated, not imported: see the file docblock. + */ +const RECORD_HEAD = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z(?: \|)? (DEBUG|INFO|WARN|ERROR|FATAL)\b/; + +const DEGRADE_PREFIX = '[Automation] connector instance'; +const HUSK_PREFIX = '[Automation] could not register degraded husk'; + +// ── the `error` seam: the degrade announcement ───────────────────────────── + +describe('#5636 — the degrade announcement is ONE record, cause in meta', () => { + it('a multi-line upstream message never reaches the log message', async () => { + const lines = await captureStream('stderr', async () => { + const { kernel } = await bootDegraded( + [providerConnector('gh_mcp')], + downFactory(MULTILINE_UPSTREAM), + { level: 'error', format: 'json' }, + ); + await kernel.shutdown(); + }); + + const mine = lines.filter((l) => l.includes(DEGRADE_PREFIX)); + expect(mine, 'the seam announced exactly once').toHaveLength(1); + // Pre-fix this was 3 physical lines, of which 2 carried no level head. + expect(lines, 'one call, one physical line').toHaveLength(1); + const record = JSON.parse(lines[0]) as { + level: string; + msg: string; + error?: string; + issues?: unknown; + }; + expect(record.level).toBe('error'); + expect(record.msg).not.toContain('\n'); + expect(record.msg).toContain("'gh_mcp'"); + expect(record.msg).toContain("provider 'fake'"); + expect(record.msg).toContain('instance registered degraded (no actions)'); + expect(record.msg).toContain('attempt 1 (#3017)'); + // Not a validation rejection → `error`, not `issues`; and the full text + // survives, newlines escaped by the logger's JSON.stringify. + expect(record.issues).toBeUndefined(); + expect(record.error).toBe(MULTILINE_UPSTREAM); + }); + + it('renders as a single head-bearing line in `pretty` too', async () => { + const lines = await captureStream('stderr', async () => { + const { kernel } = await bootDegraded( + [providerConnector('gh_mcp')], + downFactory(MULTILINE_UPSTREAM), + { level: 'error', format: 'pretty' }, + ); + await kernel.shutdown(); + }); + + expect(lines).toHaveLength(1); + expect(lines[0]).toMatch(RECORD_HEAD); + expect(lines[0]).toContain('ERROR'); + expect(lines[0]).toContain('ECONNREFUSED 127.0.0.1:8931'); + expect(lines[0]).toContain('is the MCP server running?'); + }); + + it("calls error(message, undefined, meta) — the contract's third slot", async () => { + const error = vi.spyOn(ObjectLogger.prototype, 'error'); + const { kernel } = await bootDegraded( + [providerConnector('gh_mcp')], + downFactory(MULTILINE_UPSTREAM), + ); + + const call = error.mock.calls.find((c) => String(c[0]).includes(DEGRADE_PREFIX)); + expect(call, 'the seam logged at error level').toBeDefined(); + const [message, errorSlot, meta] = call as [string, unknown, Record]; + expect(message).not.toContain('\n'); + // The second slot stays empty on purpose: the raw error there ships its + // stack on every retry record (#5575). + expect(errorSlot).toBeUndefined(); + expect(meta.error).toBe(MULTILINE_UPSTREAM); + expect(meta.issues).toBeUndefined(); + + await kernel.shutdown(); + }); + + it('leaves `degradedReason` verbatim — the husk field is not the log message', async () => { + // The API-facing field keeps the provider's own text, newlines included: + // `GET /connectors` and the `connector_action` refusal are read as JSON + // by a human, not split by a line-oriented consumer. Moving the cause out + // of the log MESSAGE must not reshape it. + const { kernel, engine } = await bootDegraded( + [providerConnector('gh_mcp')], + downFactory(MULTILINE_UPSTREAM), + ); + + const desc = engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp'); + expect(desc?.state).toBe('degraded'); + expect(desc?.degradedReason).toBe(MULTILINE_UPSTREAM); + expect(engine.getConnectorDegradedReason('gh_mcp')).toBe(MULTILINE_UPSTREAM); + + await kernel.shutdown(); + }); +}); + +// ── the `warn` seam: the husk could not even be registered ───────────────── + +describe('#5636 — a husk-registration rejection reports its issues, on one line', () => { + // `buildDegradedHuskDef` copies `entry.type` through a cast, so a declared + // `type` outside `ConnectorTypeSchema`'s enum makes `registerDegradedConnector`'s + // `ConnectorSchema.parse` throw — the "entry's def no longer parses" case the + // catch was written for, reached without stubbing the engine. + const badTypeEntry = () => providerConnector('gh_mcp', { type: 'mcp_server' }); + + it('the ZodError becomes structured meta, not a 13-line stdout spill', async () => { + const lines = await captureStream('stdout', async () => { + const { kernel } = await bootDegraded([badTypeEntry()], downFactory('upstream down'), { + level: 'warn', + format: 'json', + }); + await kernel.shutdown(); + }); + + const mine = lines.filter((l) => l.includes(HUSK_PREFIX)); + expect(mine, 'the husk failure was reported exactly once').toHaveLength(1); + const record = JSON.parse(mine[0]) as { + msg: string; + issues?: Array>; + }; + expect(record.msg).not.toContain('\n'); + expect(record.msg).toContain("'gh_mcp'"); + expect(record.msg).toContain('#3017'); + // The facts a reader came for. + const issues = record.issues; + expect(Array.isArray(issues)).toBe(true); + expect(issues!.some((i) => i.path === 'type')).toBe(true); + expect(JSON.stringify(issues)).not.toContain('REDACTED'); + // Every line on stdout still carries a level head — nothing for the boot + // buffer to drop. + for (const line of lines) { + expect(classifyLine(line), line).not.toBeNull(); + } + }); + + it('calls warn(message, meta) — `warn` has no Error slot', async () => { + // Verified against the contract rather than assumed: `Logger.warn` is + // `warn(message, meta?)`, so the cause belongs in argument TWO here even + // though the `error` seam above must use argument THREE. + const warn = vi.spyOn(ObjectLogger.prototype, 'warn'); + const { kernel } = await bootDegraded([badTypeEntry()], downFactory('upstream down')); + + const call = warn.mock.calls.find((c) => String(c[0]).includes(HUSK_PREFIX)); + expect(call, 'the seam logged at warn level').toBeDefined(); + const [message, meta] = call as [string, Record]; + expect(message).not.toContain('\n'); + expect(call).toHaveLength(2); + const issues = meta.issues as Array>; + expect(Array.isArray(issues)).toBe(true); + expect(issues.some((i) => i.path === 'type')).toBe(true); + + await kernel.shutdown(); + }); + + it('the retry bookkeeping survives a husk that could not register', async () => { + // The whole reason the catch only logs: recovery is driven by + // `degradedInstances`, which was written before the husk attempt. + const { kernel, engine } = await bootDegraded([badTypeEntry()], downFactory('upstream down')); + expect(engine.getRegisteredConnectors()).not.toContain('gh_mcp'); + await kernel.shutdown(); + }); +}); + +// ── reverse verification, direction predicted before running ─────────────── + +/** + * `classifyBootLogLine`'s verdict, reduced to what matters here: a line either + * carries an `ObjectLogger` level head (retained by `BootLogCapture`) or it does + * not (dropped by `offer()`). + */ +function classifyLine(raw: string): string | null { + const line = raw.replace(/\u001B\[[0-9;]*m/g, '').trim(); + if (!line) return null; + if (line.startsWith('{')) { + try { + const rec = JSON.parse(line) as { level?: unknown; time?: unknown }; + return typeof rec.time === 'string' && typeof rec.level === 'string' ? String(rec.level) : null; + } catch { + return null; + } + } + const m = RECORD_HEAD.exec(line); + return m ? m[1].toLowerCase() : null; +} + +describe('#5636 — what the interpolated rendering cost, measured', () => { + it('the pre-fix `warn` shape loses its every fact to the boot buffer', async () => { + // Predicted BEFORE running, and it is the plain red direction: rendering + // the OLD shape — a multi-line cause inside the message — must produce + // MANY physical lines of which exactly ONE classifies (the head line, + // truncated at Zod's `[`), so a boot-quiet window retains that one and + // drops the rest. The fixed shape must produce exactly one line that + // classifies AND carries the facts. (A local reproduction: the seam's own + // one-line guarantee is asserted end-to-end above.) + const zodDump = JSON.stringify( + [ + { + code: 'invalid_value', + values: ['saas', 'database', 'file_storage', 'message_queue', 'api', 'custom'], + path: ['type'], + message: 'Invalid option', + }, + ], + null, + 2, + ); + expect(zodDump.split('\n').length, 'fixture must be multi-line').toBeGreaterThan(1); + expect(zodDump.split('\n')[0].trim(), "…and open with Zod's `[`").toBe('['); + + const log = new ObjectLogger({ level: 'warn', format: 'pretty' }); + + const before = await captureStream('stdout', async () => { + log.warn(`[Automation] could not register degraded husk for 'gh_mcp': ${zodDump}`); + }); + expect(before.length, 'one call, many physical lines').toBeGreaterThan(1); + const beforeKept = before.filter((l) => classifyLine(l) !== null); + expect(beforeKept).toHaveLength(1); + // What the boot buffer would keep: a warning that names the instance and + // then stops at a bracket. Every fact is on a dropped line. + expect(beforeKept[0].trimEnd().endsWith('[')).toBe(true); + expect(beforeKept[0]).not.toContain('Invalid option'); + expect(before.length - beforeKept.length, 'lines the buffer drops').toBeGreaterThan(1); + + const after = await captureStream('stdout', async () => { + log.warn(`[Automation] could not register degraded husk for 'gh_mcp' (#3017).`, { + issues: [{ code: 'invalid_value', path: 'type', message: 'Invalid option' }], + }); + }); + expect(after).toHaveLength(1); + expect(classifyLine(after[0])).toBe('warn'); + expect(after[0]).toContain('Invalid option'); + expect(after[0]).toContain('type'); + }); + + it('the pre-fix `error` shape splits one degrade into unattributable fragments', async () => { + // Same prediction on the stderr side, where nothing buffers: the record + // is not dropped, it is mis-read — the continuation lines carry no level + // and no timestamp, so a file sink stores them as their own records and a + // `grep ERROR` returns the one line that holds no facts. + const log = new ObjectLogger({ level: 'error', format: 'pretty' }); + + const before = await captureStream('stderr', async () => { + log.error( + `[Automation] connector instance 'gh_mcp' (provider 'fake') upstream unavailable — ` + + `instance registered degraded (no actions); retrying with backoff, attempt 1 (#3017): ${MULTILINE_UPSTREAM}`, + ); + }); + expect(before).toHaveLength(3); + expect(before.filter((l) => classifyLine(l) !== null)).toHaveLength(1); + expect(before[1]).not.toMatch(RECORD_HEAD); + expect(before[1]).toContain('ECONNREFUSED'); + + const after = await captureStream('stderr', async () => { + log.error( + `[Automation] connector instance 'gh_mcp' (provider 'fake') upstream unavailable — ` + + `instance registered degraded (no actions); retrying with backoff, attempt 1 (#3017).`, + undefined, + { error: MULTILINE_UPSTREAM }, + ); + }); + expect(after).toHaveLength(1); + expect(after[0]).toMatch(RECORD_HEAD); + expect(after[0]).toContain('ECONNREFUSED'); + }); +}); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index c49b903cbf..434563e6f3 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -1184,7 +1184,18 @@ export class AutomationServicePlugin implements Plugin { provider, signature, hasLive: existing !== undefined, + // #5636 — `reason` and `cause` are the SAME failure rendered + // for two audiences, and both are needed. `reason` is the + // husk's operator-facing text: it becomes `degradedReason` + // in `GET /connectors` and the `connector_action` refusal, + // where the provider's own wording (newlines included) is + // read by a human, not a line-oriented parser. `cause` is + // the thrown value itself, so the LOG record can render it + // as structured `meta` instead of interpolating it into a + // message. Passing only the flattened string here is what + // forced the interpolation this issue removes. reason: (err as Error).message, + cause: err, }); continue; } @@ -1242,6 +1253,19 @@ export class AutomationServicePlugin implements Plugin { * and a `connector_action` dispatch fails with the reason, rather than the * instance silently missing. A config edit (signature change) resets the * backoff — it is a different upstream/config now. + * + * #5636 — both log records here report a FOREIGN failure, so neither may + * interpolate it into its message; the cause travels in the logger's `meta` + * instead. See ./thrown-cause-diagnostics.ts for the mechanism, and note + * that this path's `warn` reaches a downstream #5575's `error` seams do not: + * a cold-boot degrade (`materializeDeclaredConnectors(ctx, { fatal: true })` + * degrades rather than throwing when the upstream is unreachable) happens + * inside `serve`'s boot-quiet window, which wraps `process.stdout.write` + * only — so a `warn` there is buffered by `BootLogCapture`, which keeps a + * physical line ONLY when `classifyBootLogLine` finds a level head on it. + * Measured on a 13-line interpolated ZodError dump: 1 line retained (the + * head line, ending at Zod's `[`) and 12 dropped outright. That is cloud#971 + * in its original form, not merely a hard-to-parse record. */ private degradeConnectorInstance( engine: AutomationEngine, @@ -1252,7 +1276,13 @@ export class AutomationServicePlugin implements Plugin { provider: string; signature: string; hasLive: boolean; + /** + * Operator-facing text for the husk (`degradedReason`) — the + * provider's own message, kept verbatim for human readers. + */ reason: string; + /** The thrown value, for the log record's structured `meta` (#5636). */ + cause: unknown; }, ): void { const prior = this.degradedInstances.get(info.name); @@ -1269,17 +1299,29 @@ export class AutomationServicePlugin implements Plugin { } catch (err) { // Can't even register the husk (e.g. the entry's def no longer // parses) — the retry bookkeeping above still drives recovery. + // + // #5636 — the catch comment names the expected failure as a parse + // rejection, i.e. exactly the multi-line `ZodError.message` this + // must not interpolate. `warn`'s SECOND argument is `meta` (the + // `Logger` contract has no `Error` slot below `error`), so the + // cause goes there. ctx.logger.warn( - `[Automation] could not register degraded husk for '${info.name}': ${(err as Error).message}`, + `[Automation] could not register degraded husk for '${info.name}' — the instance stays absent from the ` + + `connector registry until a retry succeeds (#3017).`, + describeThrownForLog(err), ); } } + // Third argument, per `error(message, error?, meta?)` — NOT the second, + // which would ship the thrown value's stack on every retry (#5575). ctx.logger.error( `[Automation] connector instance '${info.name}' (provider '${info.provider}') upstream unavailable — ` + (info.hasLive ? 'the previously-materialized connector keeps serving' : 'instance registered degraded (no actions)') + - `; retrying with backoff, attempt ${attempts} (#3017): ${info.reason}`, + `; retrying with backoff, attempt ${attempts} (#3017).`, + undefined, + describeThrownForLog(info.cause), ); }