@intentius/chant 0.72.3 → 0.72.5

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 (43) hide show
  1. package/dist/cli/commands/doctor.d.ts.map +1 -1
  2. package/dist/cli/commands/init.d.ts +1 -1
  3. package/dist/cli/commands/init.d.ts.map +1 -1
  4. package/dist/cli/commands/update.d.ts.map +1 -1
  5. package/dist/cli/handlers/init.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/mcp/server.d.ts +10 -0
  8. package/dist/cli/mcp/server.d.ts.map +1 -1
  9. package/dist/cli/mcp-config.d.ts +47 -0
  10. package/dist/cli/mcp-config.d.ts.map +1 -0
  11. package/dist/cli/registry.d.ts +6 -0
  12. package/dist/cli/registry.d.ts.map +1 -1
  13. package/dist/declarable.d.ts +46 -1
  14. package/dist/declarable.d.ts.map +1 -1
  15. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/fork.d.ts +0 -5
  17. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  18. package/dist/fold/subset.d.ts +15 -3
  19. package/dist/fold/subset.d.ts.map +1 -1
  20. package/dist/observation.d.ts +0 -1
  21. package/dist/observation.d.ts.map +1 -1
  22. package/dist/op/activities/converge.d.ts +16 -0
  23. package/dist/op/activities/converge.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/commands/doctor.ts +18 -6
  26. package/src/cli/commands/init.ts +9 -68
  27. package/src/cli/commands/update.ts +9 -0
  28. package/src/cli/handlers/init.ts +1 -0
  29. package/src/cli/main.ts +5 -0
  30. package/src/cli/mcp/docs-parity.test.ts +133 -0
  31. package/src/cli/mcp/server.ts +5 -1
  32. package/src/cli/mcp-config.test.ts +168 -0
  33. package/src/cli/mcp-config.ts +76 -0
  34. package/src/cli/registry.ts +6 -0
  35. package/src/declarable-host-marker.test.ts +88 -0
  36. package/src/declarable.ts +72 -7
  37. package/src/discovery/sandbox/driver.ts +25 -0
  38. package/src/discovery/sandbox/fork-diagnostic.test.ts +125 -0
  39. package/src/discovery/sandbox/fork.ts +92 -6
  40. package/src/fold/subset.ts +15 -3
  41. package/src/observation.ts +27 -8
  42. package/src/op/activities/converge-push.test.ts +101 -0
  43. package/src/op/activities/converge.ts +31 -1
@@ -0,0 +1,125 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { fork } from "node:child_process";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { runFallbackFilesSandboxed } from "./run";
7
+
8
+ /**
9
+ * chant#2461 — a child that ends without a usable result says which way it did.
10
+ *
11
+ * The parent had one sentence for three unrelated situations: `child exited
12
+ * before reporting results (code N, signal S)`. The one seen in the wild is the
13
+ * hardest to read — exit code 0, empty stderr, no message — because it reads as
14
+ * if the child was cut off, and it was not. It ran to completion and sent
15
+ * nothing.
16
+ *
17
+ * These tests pin the two mechanisms that produce that signature, against real
18
+ * forked children rather than a mock, because the whole question is what Node
19
+ * actually does:
20
+ *
21
+ * - an `await` that never settles, which drains the loop and exits 0
22
+ * - a payload the parent's `isResponse` refuses, which used to be dropped in
23
+ * silence and then reported as though nothing had been sent
24
+ *
25
+ * A third mechanism was proposed in the issue and is NOT covered, because it
26
+ * was measured and does not happen: `process.send` racing the child's reap so
27
+ * that `exit` is dispatched before an already-queued `message`. With the parent
28
+ * blocked so both were certainly pending, `message` won 60 times out of 60. The
29
+ * live IPC channel keeps the child alive until the payload flushes, and the
30
+ * driver has no `process.exit()` to cut that short. The last test here records
31
+ * that, so the disproof is not lost with the transcript.
32
+ */
33
+ function runChild(source: string): Promise<{ code: number | null; message: unknown; stderr: string }> {
34
+ const dir = mkdtempSync(join(tmpdir(), "chant-fork-diag-"));
35
+ const file = join(dir, "child.mjs");
36
+ writeFileSync(file, source);
37
+ return new Promise((resolve) => {
38
+ const child = fork(file, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] });
39
+ let stderr = "";
40
+ let message: unknown;
41
+ child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
42
+ child.on("message", (m) => { message = m; });
43
+ child.on("exit", (code) => {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ resolve({ code, message, stderr });
46
+ });
47
+ });
48
+ }
49
+
50
+ describe("why a sandboxed child ends without a result (chant#2461)", () => {
51
+ test("an await that never settles exits 0, silently, having sent nothing", async () => {
52
+ // The observed signature, reproduced. `main().catch(...)` catches a
53
+ // REJECTION; a promise that never settles is not one, so the driver's own
54
+ // fatal-payload path never runs either.
55
+ const r = await runChild(
56
+ 'async function main() { await new Promise(() => {}); process.send({ ok: true }); }\n' +
57
+ "main().catch((e) => process.send({ fatal: String(e) }));\n",
58
+ );
59
+
60
+ expect(r.code).toBe(0);
61
+ expect(r.message).toBeUndefined();
62
+ expect(r.stderr.trim()).toBe("");
63
+ });
64
+
65
+ test("a child that sends and falls off the end always gets its message through", async () => {
66
+ // The disproof, kept as a test. If this ever fails, the race the issue
67
+ // proposed is real after all and the diagnostic's wording needs revisiting.
68
+ const results = await Promise.all(
69
+ Array.from({ length: 12 }, () => runChild("process.send({ ok: true });\n")),
70
+ );
71
+
72
+ for (const r of results) {
73
+ expect(r.code).toBe(0);
74
+ expect(r.message).toEqual({ ok: true });
75
+ }
76
+ });
77
+
78
+ test("a rejection still reaches the parent, so the fatal path is not what broke", async () => {
79
+ // Establishes the contrast: the driver's catch works. It is specifically an
80
+ // unsettled promise that escapes it.
81
+ const r = await runChild(
82
+ 'async function main() { throw new Error("boom"); }\n' +
83
+ "main().catch((e) => process.send({ fatal: String(e) }));\n",
84
+ );
85
+
86
+ expect(r.message).toEqual({ fatal: "Error: boom" });
87
+ });
88
+
89
+ test("a project file with an unsettled top-level await reproduces it end to end", async () => {
90
+ // The run path's real mechanism, not a stand-in. `main()` does
91
+ // `await import(<project file>)` per run-fallback file, module scope is
92
+ // arbitrary project source, and a top level that awaits something which
93
+ // never settles makes that import never complete. Nothing keeps the loop
94
+ // alive, so Node exits 0 having sent nothing.
95
+ //
96
+ // This is what the diagnostic's wording is checked against. Before
97
+ // chant#2461 it said only "child exited before reporting results", which
98
+ // gave a reader nothing to look at.
99
+ const root = mkdtempSync(join(tmpdir(), "chant-tla-"));
100
+ try {
101
+ mkdirSync(join(root, "src"), { recursive: true });
102
+ writeFileSync(
103
+ join(root, "src", "hangs.ts"),
104
+ "await new Promise<void>(() => {});\nexport const never = { reached: true };\n",
105
+ );
106
+
107
+ const result = await runFallbackFilesSandboxed([join(root, "src", "hangs.ts")], root);
108
+
109
+ const message = result.errors.map((e) => e.message).join("\n");
110
+ expect(message).toContain("child exited before reporting results (code 0, signal null)");
111
+
112
+ // chant#2461's user-facing half: the child names the file it was
113
+ // importing when its loop drained. It cannot `process.send` from an exit
114
+ // handler — that is asynchronous and the channel will never be serviced —
115
+ // so it writes synchronously to stderr, which the parent captures and
116
+ // forwards. Without this the build dies silently and nothing anywhere
117
+ // says which file.
118
+ expect(message).toContain("never finished evaluating");
119
+ expect(message).toContain("hangs.ts");
120
+ expect(message).toContain("top-level");
121
+ } finally {
122
+ rmSync(root, { recursive: true, force: true });
123
+ }
124
+ }, 120_000);
125
+ });
@@ -125,6 +125,86 @@ function lineBuffered(emit: (line: string) => void) {
125
125
  * resolve with the first IPC message that satisfies `isResponse` (or reject
126
126
  * on crash / timeout / fork error).
127
127
  */
128
+ /**
129
+ * Why a child ended without a usable result, in terms a reader can act on.
130
+ *
131
+ * chant#2461 — this used to be one sentence, `child exited before reporting
132
+ * results (code N, signal S)`, for three unrelated situations. The one that
133
+ * was observed in the wild is the hardest to read: exit code 0, empty stderr,
134
+ * no message. That reads like the child was cut off, and it was not — it ran
135
+ * to completion and sent nothing.
136
+ *
137
+ * Two mechanisms produce it, and the driver decides which is possible.
138
+ *
139
+ * The first is an `await` that never settles. `main().catch(...)` catches a
140
+ * REJECTION, and a promise that never settles is not one, so if nothing keeps
141
+ * the loop alive Node drains it and exits 0 having sent nothing and written
142
+ * nothing. Nothing else in the process reports that.
143
+ *
144
+ * On the RUN path that is not hypothetical, and it is reproducible: the driver
145
+ * does `await import(<project file>)` for each run-fallback file, module scope
146
+ * is arbitrary project source, and a file whose top level awaits something that
147
+ * never settles makes that import never complete. A fixture doing exactly that
148
+ * yields this error, which is how the wording here was checked rather than
149
+ * guessed.
150
+ *
151
+ * The second is the payload being lost between `process.send` and exit.
152
+ *
153
+ * For the CONFIG driver the first is impossible, which is worth stating because
154
+ * it was the working hypothesis until the bundle was read. Its `await
155
+ * import(configPath)` bundles to `await Promise.resolve().then(() =>
156
+ * (init_chant_config(), chant_config_exports))` — one microtask over
157
+ * synchronous code — and the bundle has no runtime imports, no dynamic
158
+ * `import(`, and exactly one `process.send`. There is nothing there to hang on.
159
+ * So a config child that exits 0 with nothing sent DID send, and the payload
160
+ * did not arrive.
161
+ *
162
+ * The exit/message race originally proposed in chant#2461 is a third thing and
163
+ * is not it: with the parent blocked so a queued payload and the reap were both
164
+ * pending, `message` was dispatched first 60 times out of 60, because the live
165
+ * IPC channel keeps the child alive until the payload flushes.
166
+ */
167
+ function describeSilentExit(
168
+ label: string,
169
+ code: number | null,
170
+ signal: NodeJS.Signals | null,
171
+ stderrBuf: string,
172
+ unrecognised: readonly unknown[],
173
+ ): string {
174
+ const stderr = stderrBuf.trim();
175
+ const head = `${label}: child exited before reporting results (code ${code}, signal ${signal})`;
176
+
177
+ if (unrecognised.length > 0) {
178
+ // It DID send. The parent refused the shape, which is a bug in one of them
179
+ // and not the child dying early.
180
+ const shapes = unrecognised
181
+ .map((m) => (m && typeof m === "object" ? `{${Object.keys(m as object).join(", ")}}` : typeof m))
182
+ .join(", ");
183
+ return (
184
+ `${head}. It sent ${unrecognised.length} message(s) the parent did not recognise (${shapes}), ` +
185
+ `so the payload shape and the parent's check disagree` +
186
+ (stderr ? `: ${stderr}` : "")
187
+ );
188
+ }
189
+
190
+ if (stderr) return `${head}: ${stderr}`;
191
+ if (code !== 0 || signal !== null) return head;
192
+
193
+ // Exit 0, nothing on stderr, nothing sent. The child finished normally and
194
+ // the parent has nothing. Two mechanisms produce exactly this, and which one
195
+ // it is depends on the driver — see this function's doc.
196
+ return (
197
+ `${head}. It exited cleanly with nothing on stderr and sent no message, so the child drained ` +
198
+ `its loop without sending: something it awaited never settled. On the run path the usual ` +
199
+ `cause is a project file with a top-level \`await\` that does not settle — module scope is ` +
200
+ `arbitrary project source, and an import of such a file never completes. A promise that ` +
201
+ `never settles is not a rejection, so the driver's own \`main().catch\` does not see it ` +
202
+ `either, which is why nothing is written anywhere. The config driver bundles to one ` +
203
+ `microtask over synchronous code with no runtime I/O, so on THAT path nothing can hang and ` +
204
+ `the payload was lost between \`process.send\` and exit instead (chant#2461).`
205
+ );
206
+ }
207
+
128
208
  export function forkSandboxed<T>(
129
209
  options: SandboxForkOptions,
130
210
  isResponse: (value: unknown) => value is T,
@@ -144,6 +224,8 @@ export function forkSandboxed<T>(
144
224
 
145
225
  let settled = false;
146
226
  let stderrBuf = "";
227
+ /** Messages the child sent that `isResponse` refused — see the `message` handler. */
228
+ const unrecognised: unknown[] = [];
147
229
 
148
230
  const timeout = setTimeout(() => {
149
231
  if (settled) return;
@@ -185,7 +267,15 @@ export function forkSandboxed<T>(
185
267
  child.stderr?.on("end", () => stderrForwarder.flush());
186
268
 
187
269
  child.on("message", (msg: unknown) => {
188
- if (settled || !isResponse(msg)) return;
270
+ if (settled) return;
271
+ if (!isResponse(msg)) {
272
+ // chant#2461 — remember it rather than dropping it. A child that sent
273
+ // something the parent does not recognise is a different failure from
274
+ // a child that sent nothing, and both used to arrive as "exited before
275
+ // reporting results" with no way to tell them apart.
276
+ unrecognised.push(msg);
277
+ return;
278
+ }
189
279
  settled = true;
190
280
  clearTimeout(timeout);
191
281
  // chant #1131 — the child's entire job is to send this one message, so
@@ -212,11 +302,7 @@ export function forkSandboxed<T>(
212
302
  if (settled) return;
213
303
  settled = true;
214
304
  clearTimeout(timeout);
215
- reject(
216
- new Error(
217
- `${label}: child exited before reporting results (code ${code}, signal ${signal})${stderrBuf.trim() ? `: ${stderrBuf.trim()}` : ""}`,
218
- ),
219
- );
305
+ reject(new Error(describeSilentExit(label, code, signal, stderrBuf, unrecognised)));
220
306
  });
221
307
  });
222
308
  }
@@ -210,13 +210,25 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
210
210
  * binding whatever an invoked factory returns is what chant never checked
211
211
  * (`L8.19`), and a declarator reaching a call through a const alias is what
212
212
  * chant resolves while refusing a call nested elsewhere (`L2.18`).
213
+ * - **1.7** put `F-Eval-CallLocal` before the two registered call shapes, so a
214
+ * call through a name the project bound is the project function's call
215
+ * whatever the registry says. That is what chant's whole-build fold already
216
+ * did, and the reason the specification moved rather than chant: probes with
217
+ * chant's own `output` showed the binding-first order living in
218
+ * `resolveCallExpression`, while `L2.11` described the expression
219
+ * classifier. The two were never required to share an order.
220
+ * - **1.8** added `ι = executing`, and chant carries it as of `0.72.3`
221
+ * (#2455) — `FoldProjectOptions.executing`, mutually exclusive with
222
+ * `sandbox`. `open` stays the default and stays strict (#2453).
213
223
  *
214
224
  * `scripts/check-docs-citations.ts` refuses to run when this constant and the
215
225
  * pinned specification disagree, so `.github/workflows/docs-check.yml` pins the
216
- * `spec-1.6` commit and the two move together. That coupling is deliberate: it
217
- * is what stops the docs being gated against a rule set nobody writes against.
226
+ * `spec-1.8` commit and the two move together. That coupling is deliberate: it
227
+ * is what stops the docs being gated against a rule set nobody writes against
228
+ * — which is exactly what happened while this sat at `1.0` through six
229
+ * versions, passing because both sides were equally stale.
218
230
  */
219
- export const SPEC_VERSION = "1.6";
231
+ export const SPEC_VERSION = "1.8";
220
232
 
221
233
  /** The two EVL rule ids a shape violation can be attributed to. */
222
234
  export type SubsetRuleId = "EVL001" | "EVL003";
@@ -57,14 +57,33 @@ export type UnobservedReason =
57
57
  | "unsupported-kind"
58
58
  | "filtered";
59
59
 
60
- /** Every legal {@link UnobservedReason}, for validation and conformance checks. */
61
- export const UNOBSERVED_REASONS: readonly UnobservedReason[] = [
62
- "read-failed",
63
- "no-credentials",
64
- "no-binding",
65
- "unsupported-kind",
66
- "filtered",
67
- ];
60
+ /**
61
+ * Every legal {@link UnobservedReason}, for validation and conformance checks.
62
+ *
63
+ * Derived from a witness keyed off the union rather than written out beside it
64
+ * (chant#2366). A hand-maintained array is only ever checked for holding legal
65
+ * members, never for holding ALL of them, so a reason added to the type left
66
+ * the array silently short — `tsc` clean, every observation test green, and a
67
+ * value the type permits that `observation-conformance.ts` refuses. A lexicon
68
+ * could construct a value its own suite rejected.
69
+ *
70
+ * Keying a `Record` off the union makes the omission a compile error at the
71
+ * point of the omission. This is the construction #2365 applied to all four
72
+ * closed sets in `./behaviour.ts`; `BEHAVIOUR_UNPREDICTED_REASONS` derives from
73
+ * this very type, so that module was already protected against a change here
74
+ * while this module was not.
75
+ */
76
+ const UNOBSERVED_REASON_WITNESS: Record<UnobservedReason, true> = {
77
+ "read-failed": true,
78
+ "no-credentials": true,
79
+ "no-binding": true,
80
+ "unsupported-kind": true,
81
+ filtered: true,
82
+ };
83
+
84
+ export const UNOBSERVED_REASONS: readonly UnobservedReason[] = Object.keys(
85
+ UNOBSERVED_REASON_WITNESS,
86
+ ) as UnobservedReason[];
68
87
 
69
88
  /** True when `value` is a legal {@link UnobservedReason}. */
70
89
  export function isUnobservedReason(value: unknown): value is UnobservedReason {
@@ -0,0 +1,101 @@
1
+ import { describe, test, expect, vi, beforeEach } from "vitest";
2
+
3
+ /**
4
+ * chant#2337 — a converge tick says whether its record reached the remote.
5
+ *
6
+ * `convergeTick` ended with `await pushLifecycle().catch(() => undefined)`, the
7
+ * last instance of the idiom #2310 was filed about. The append is local-first
8
+ * and always lands, so the tick's own result was correct either way — what was
9
+ * missing is whether anyone else can see it.
10
+ *
11
+ * Softer than the gate's version of the same bug, which #2336 fixed: a lost
12
+ * tick record is an informational log rather than an approval, so nobody is
13
+ * left waiting on a fact they cannot see. It is still not success, and a
14
+ * converge loop reporting a clean tick while its ledger never leaves the
15
+ * machine is telling an operator something untrue.
16
+ */
17
+ const execMock = vi.fn();
18
+ vi.mock("node:child_process", () => ({
19
+ exec: (cmd: string, _opts: unknown, cb: (e: Error | null, r: { stdout: string; stderr: string }) => void) =>
20
+ cb(null, { stdout: execMock(cmd) as string, stderr: "" }),
21
+ }));
22
+
23
+ const pushLifecycle = vi.fn();
24
+ vi.mock("../../lifecycle/git", () => ({
25
+ fetchLifecycle: vi.fn(async () => undefined),
26
+ pushLifecycle: (...args: unknown[]) => pushLifecycle(...args) as Promise<boolean>,
27
+ }));
28
+
29
+ vi.mock("../../lifecycle/converge-ledger", async (orig) => {
30
+ const actual = (await orig()) as Record<string, unknown>;
31
+ return {
32
+ ...actual,
33
+ readConvergeLedger: vi.fn(async () => ({ records: [] })),
34
+ appendConvergeRecord: vi.fn(async (record: Record<string, unknown>) => ({
35
+ record: { ...record, id: "tick-1" },
36
+ })),
37
+ };
38
+ });
39
+
40
+ const { convergeTick } = await import("./converge");
41
+
42
+ /** A tick with nothing to do: no drift, no rules, so only the ledger write matters. */
43
+ async function tick(): Promise<Awaited<ReturnType<typeof convergeTick>>> {
44
+ return convergeTick({ opName: "demo", env: "prod", rules: [], dial: "report" } as never);
45
+ }
46
+
47
+ describe("a converge tick reports its push outcome (chant#2337)", () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ execMock.mockImplementation((cmd: string) =>
51
+ cmd.includes("lifecycle plan")
52
+ ? JSON.stringify({ env: "prod", entries: [] })
53
+ : JSON.stringify([]),
54
+ );
55
+ });
56
+
57
+ test("a push that lands reports pushed, with no warning", async () => {
58
+ pushLifecycle.mockResolvedValue(true);
59
+
60
+ const result = await tick();
61
+
62
+ expect(result.pushed).toBe(true);
63
+ expect(result.pushWarning).toBeUndefined();
64
+ });
65
+
66
+ test("a REJECTED push is reported, not swallowed", async () => {
67
+ // Red before chant#2337: `.catch(() => undefined)` meant the tick returned
68
+ // the same shape whether or not the record left the machine.
69
+ pushLifecycle.mockRejectedValue(new Error("remote rejected: chant/lifecycle has moved"));
70
+
71
+ const result = await tick();
72
+
73
+ expect(result.pushed).toBe(false);
74
+ expect(result.pushWarning).toContain("remote rejected");
75
+ });
76
+
77
+ test("no remote configured is reported too, and says which it is", async () => {
78
+ // `pushLifecycle` returns false rather than throwing when there is no
79
+ // remote. That is not an error, but it is not a push either, and the two
80
+ // reasons are worth telling apart in the warning.
81
+ pushLifecycle.mockResolvedValue(false);
82
+
83
+ const result = await tick();
84
+
85
+ expect(result.pushed).toBe(false);
86
+ expect(result.pushWarning).toMatch(/no remote/i);
87
+ });
88
+
89
+ test("the tick's own findings are unaffected by a failed push", async () => {
90
+ // The append is local-first and always lands. A push failure must not make
91
+ // the tick misreport what it observed, or the fix would have traded one
92
+ // wrong answer for another.
93
+ pushLifecycle.mockRejectedValue(new Error("nope"));
94
+
95
+ const result = await tick();
96
+
97
+ expect(result.id).toBe("tick-1");
98
+ expect(result.drifted).toBe(false);
99
+ expect(typeof result.log).toBe("string");
100
+ });
101
+ });
@@ -104,6 +104,22 @@ export interface ConvergeTickResult {
104
104
  gated: number;
105
105
  /** The one human-readable summary line this tick produced. */
106
106
  log: string;
107
+ /**
108
+ * Whether this tick's ledger record reached the remote (chant#2337).
109
+ *
110
+ * The append is local-first and always lands, so the tick's own result is
111
+ * correct either way — this says whether anyone else can see it. Before
112
+ * this, the push was `.catch(() => undefined)` and the tick reported success
113
+ * whether or not the record left the machine, which is the last instance of
114
+ * the idiom #2310 was filed about.
115
+ *
116
+ * Softer than the gate's version of the same bug (#2336): a lost tick record
117
+ * is an informational log rather than an approval, so nobody is stranded
118
+ * waiting on a fact they cannot see. It is still not success.
119
+ */
120
+ pushed: boolean;
121
+ /** Why the record did not reach the remote, when `pushed` is false. */
122
+ pushWarning?: string;
107
123
  }
108
124
 
109
125
  function shellQuote(s: string): string {
@@ -442,7 +458,19 @@ export async function convergeTick(args: ConvergeTickArgs, signal?: AbortSignal)
442
458
  },
443
459
  log,
444
460
  });
445
- await pushLifecycle().catch(() => undefined);
461
+ // chant#2337 — reported rather than swallowed, the way #2336 does it for the
462
+ // gate. A push that did not land leaves a correct local record nobody else
463
+ // can read, and the caller is the only one positioned to say so.
464
+ let pushed = false;
465
+ let pushWarning: string | undefined;
466
+ try {
467
+ pushed = await pushLifecycle();
468
+ if (!pushed) {
469
+ pushWarning = "no remote is configured for chant/lifecycle — the tick record was recorded locally only";
470
+ }
471
+ } catch (err) {
472
+ pushWarning = err instanceof Error ? err.message : String(err);
473
+ }
446
474
 
447
475
  return {
448
476
  id: record.id,
@@ -455,5 +483,7 @@ export async function convergeTick(args: ConvergeTickArgs, signal?: AbortSignal)
455
483
  unobserved: record.summary.unobserved,
456
484
  adopted: record.summary.adopted,
457
485
  log: record.log,
486
+ pushed,
487
+ ...(pushWarning !== undefined ? { pushWarning } : {}),
458
488
  };
459
489
  }