Skip to content

Commit d84c84d

Browse files
committed
fix(imessage): preserve catchup recovery on upgrade
1 parent d0f03e3 commit d84c84d

23 files changed

Lines changed: 2684 additions & 140 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
7e6b3bbed482cdb6d80910ff8406e691e0be99458fe19c227df3f3dd26a6f576 config-baseline.json
1+
37b56008790612b8293930b6a29d74490e98daa90f954fca9d133fcc28645c4c config-baseline.json
22
75b64c2ea081369ba4306493313a8a4cd48b784145f92fed995e6b77a5df350d config-baseline.core.json
3-
75687e82cb976df1a9f219d077412ce208c049d2a6017e57b867d7ae1cd3dbc8 config-baseline.channel.json
3+
17d64c9799dfa239a49493413f1100bdd9237e9b67aaeae331a4604dbc227023 config-baseline.channel.json
44
f9d1f50bfa8403891e76cd99dc1357cdece4a71e8ae18a39b190c2a14e6f97b0 config-baseline.plugin.json

docs/channels/imessage.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,7 @@ The "Flag on" column shows behavior on an `imsg` build that emits `balloon_bundl
727727

728728
## Inbound recovery after a bridge or gateway restart
729729

730-
iMessage recovers messages missed while the gateway was down, and at the same time suppresses the stale "backlog bomb" Apple can flush after a Push recovery. There is **no config** — the behavior is always on, built on the inbound dedupe.
730+
iMessage recovers messages missed while the gateway was down, and at the same time suppresses the stale "backlog bomb" Apple can flush after a Push recovery. The default behavior is always on, built on the inbound dedupe.
731731

732732
- **Replay dedupe.** Every dispatched inbound message is recorded by its Apple GUID in persistent plugin state (`imessage.inbound-dedupe`), claimed at ingestion and committed after handling (released on a transient failure so it can retry). Anything already handled is dropped instead of dispatched twice. This is what lets recovery replay aggressively without per-message bookkeeping.
733733
- **Downtime recovery.** On startup the monitor remembers the last dispatched `chat.db` rowid (a persisted per-account cursor) and passes it to `imsg watch.subscribe` as `since_rowid`, so imsg replays the rows that landed while the gateway was down, then tails live. Replay is bounded to the most recent rows and to messages up to ~2 hours old, and the dedupe drops anything already handled.
@@ -745,7 +745,7 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
745745

746746
### Migration
747747

748-
`channels.imessage.catchup.*` is retired — downtime recovery is now automatic and needs no config or cursor tuning. If your config still has the `catchup` block, `openclaw doctor --fix` removes it; the runtime ignores it in the meantime.
748+
`channels.imessage.catchup.*` is deprecated — downtime recovery is now automatic and needs no config for new setups. Existing configs with `catchup.enabled: true` remain honored as a compatibility profile for the recovery replay window. Disabled catchup blocks (`enabled: false` or no `enabled: true`) are retired; `openclaw doctor --fix` removes those.
749749

750750
## Troubleshooting
751751

docs/gateway/config-channels.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -639,7 +639,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
639639
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
640640
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
641641
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
642-
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence); there is no catchup config to enable.
642+
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence). Existing `channels.imessage.catchup.enabled: true` configs are still honored as a deprecated compatibility profile.
643643
- `channels.imessage.groups`: group registry and per-group settings. With `groupPolicy: "allowlist"`, configure either explicit `chat_id` keys or a `"*"` wildcard entry so group messages can pass the registry gate.
644644
- Top-level `bindings[]` entries with `type: "acp"` can bind iMessage conversations to persistent ACP sessions. Use a normalized handle or explicit chat target (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`) in `match.peer.id`. Shared field semantics: [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
645645

extensions/imessage/doctor-contract-api.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,38 +5,42 @@ import type {
55
} from "openclaw/plugin-sdk/channel-contract";
66
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
77

8-
// `channels.imessage.catchup` was retired: iMessage now recovers messages
9-
// missed during downtime automatically (since_rowid replay on the always-on
10-
// inbound dedupe), so the opt-in catchup replay subsystem and its config no
11-
// longer exist. Detect the stale key so doctor reports it, and strip it in
12-
// normalizeCompatibilityConfig so `openclaw doctor --fix` removes it from disk.
8+
// Disabled `channels.imessage.catchup` blocks are retired. Enabled blocks stay
9+
// as a compatibility contract: older configs that opted into replay still get
10+
// downtime recovery, while new/default installs use the always-on recovery
11+
// cursor plus stale-backlog fence.
1312
function isRecord(value: unknown): value is Record<string, unknown> {
1413
return typeof value === "object" && value !== null && !Array.isArray(value);
1514
}
1615

17-
function imessageEntryHasCatchup(entry: unknown): boolean {
16+
function isEnabledCatchup(value: unknown): boolean {
17+
return isRecord(value) && value.enabled === true;
18+
}
19+
20+
function imessageEntryHasRetiredCatchup(entry: unknown): boolean {
1821
if (!isRecord(entry)) {
1922
return false;
2023
}
21-
if (Object.hasOwn(entry, "catchup")) {
24+
if (Object.hasOwn(entry, "catchup") && !isEnabledCatchup(entry.catchup)) {
2225
return true;
2326
}
2427
const accounts = entry.accounts;
2528
if (!isRecord(accounts)) {
2629
return false;
2730
}
2831
return Object.values(accounts).some(
29-
(account) => isRecord(account) && Object.hasOwn(account, "catchup"),
32+
(account) =>
33+
isRecord(account) && Object.hasOwn(account, "catchup") && !isEnabledCatchup(account.catchup),
3034
);
3135
}
3236

3337
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
3438
{
3539
path: ["channels", "imessage"],
3640
message:
37-
"channels.imessage.catchup is retired; iMessage now recovers missed messages automatically (no config). " +
38-
'Run "openclaw doctor --fix" to remove the stale key.',
39-
match: (value) => imessageEntryHasCatchup(value),
41+
"disabled channels.imessage.catchup config is retired; iMessage now recovers via always-on inbound dedupe and a stale-backlog age fence. " +
42+
'Run "openclaw doctor --fix" to remove disabled catchup blocks.',
43+
match: (value) => imessageEntryHasRetiredCatchup(value),
4044
},
4145
];
4246

@@ -47,25 +51,29 @@ export function normalizeCompatibilityConfig({
4751
}): ChannelDoctorConfigMutation {
4852
const channels = cfg.channels as Record<string, unknown> | undefined;
4953
const imessage = channels?.imessage;
50-
if (!imessageEntryHasCatchup(imessage) || !isRecord(imessage)) {
54+
if (!imessageEntryHasRetiredCatchup(imessage) || !isRecord(imessage)) {
5155
return { config: cfg, changes: [] };
5256
}
5357
const changes: string[] = [];
5458
const nextImessage: Record<string, unknown> = { ...imessage };
55-
if (Object.hasOwn(nextImessage, "catchup")) {
59+
if (Object.hasOwn(nextImessage, "catchup") && !isEnabledCatchup(nextImessage.catchup)) {
5660
delete nextImessage.catchup;
57-
changes.push("Removed retired channels.imessage.catchup.");
61+
changes.push("Removed disabled retired channels.imessage.catchup.");
5862
}
5963
if (isRecord(nextImessage.accounts)) {
6064
let accountsChanged = false;
6165
const nextAccounts: Record<string, unknown> = { ...nextImessage.accounts };
6266
for (const [id, account] of Object.entries(nextImessage.accounts)) {
63-
if (isRecord(account) && Object.hasOwn(account, "catchup")) {
67+
if (
68+
isRecord(account) &&
69+
Object.hasOwn(account, "catchup") &&
70+
!isEnabledCatchup(account.catchup)
71+
) {
6472
const nextAccount = { ...account };
6573
delete nextAccount.catchup;
6674
nextAccounts[id] = nextAccount;
6775
accountsChanged = true;
68-
changes.push(`Removed retired channels.imessage.accounts.${id}.catchup.`);
76+
changes.push(`Removed disabled retired channels.imessage.accounts.${id}.catchup.`);
6977
}
7078
}
7179
if (accountsChanged) {

extensions/imessage/src/doctor-contract-api.test.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,61 @@
1-
// Imessage tests cover the doctor contract for the retired catchup config.
1+
// Imessage tests cover the doctor contract for deprecated catchup config.
22
import { describe, expect, it } from "vitest";
33
import { legacyConfigRules, normalizeCompatibilityConfig } from "../doctor-contract-api.js";
44

5-
describe("iMessage doctor contract: retired catchup config", () => {
6-
it("detects a top-level catchup block", () => {
7-
const cfg = { channels: { imessage: { catchup: { enabled: true } } } } as never;
5+
describe("iMessage doctor contract: deprecated catchup config", () => {
6+
it("detects a disabled top-level catchup block", () => {
7+
const cfg = { channels: { imessage: { catchup: { enabled: false } } } } as never;
88
const rule = legacyConfigRules[0];
99
expect(rule?.match?.((cfg as { channels: { imessage: unknown } }).channels.imessage, cfg)).toBe(
1010
true,
1111
);
1212
});
1313

14-
it("detects a per-account catchup block", () => {
14+
it("detects a disabled per-account catchup block", () => {
1515
const imessage = { accounts: { work: { catchup: { enabled: false } } } };
1616
const cfg = { channels: { imessage } } as never;
1717
expect(legacyConfigRules[0]?.match?.(imessage, cfg)).toBe(true);
1818
});
1919

20+
it("does not flag enabled catchup because replay remains compatibility-supported", () => {
21+
const imessage = {
22+
catchup: { enabled: true, maxAgeMinutes: 360 },
23+
accounts: { work: { catchup: { enabled: true, perRunLimit: 25 } } },
24+
};
25+
const cfg = { channels: { imessage } } as never;
26+
expect(legacyConfigRules[0]?.match?.(imessage, cfg)).toBe(false);
27+
});
28+
2029
it("does not flag a config without catchup", () => {
2130
const imessage = { dmPolicy: "pairing", accounts: { work: { cliPath: "imsg" } } };
2231
const cfg = { channels: { imessage } } as never;
2332
expect(legacyConfigRules[0]?.match?.(imessage, cfg)).toBe(false);
2433
});
2534

26-
it("strips top-level and per-account catchup and reports each change", () => {
35+
it("strips disabled catchup and preserves enabled catchup", () => {
2736
const cfg = {
2837
channels: {
2938
imessage: {
30-
catchup: { enabled: true },
39+
catchup: { enabled: true, maxAgeMinutes: 360 },
3140
dmPolicy: "pairing",
32-
accounts: { work: { catchup: { enabled: false }, cliPath: "imsg" } },
41+
accounts: {
42+
work: { catchup: { enabled: false }, cliPath: "imsg" },
43+
home: { catchup: { enabled: true, perRunLimit: 25 }, cliPath: "imsg-home" },
44+
},
3345
},
3446
},
3547
} as never;
3648
const mutation = normalizeCompatibilityConfig({ cfg });
37-
expect(mutation.changes).toHaveLength(2);
49+
expect(mutation.changes).toHaveLength(1);
3850
const imessage = (mutation.config as { channels: { imessage: Record<string, unknown> } })
3951
.channels.imessage;
40-
expect("catchup" in imessage).toBe(false);
41-
const accounts = imessage.accounts as { work: Record<string, unknown> };
52+
expect(imessage.catchup).toEqual({ enabled: true, maxAgeMinutes: 360 });
53+
const accounts = imessage.accounts as {
54+
work: Record<string, unknown>;
55+
home: Record<string, unknown>;
56+
};
4257
expect("catchup" in accounts.work).toBe(false);
58+
expect(accounts.home.catchup).toEqual({ enabled: true, perRunLimit: 25 });
4359
expect(accounts.work.cliPath).toBe("imsg");
4460
});
4561

0 commit comments

Comments
 (0)