Skip to content

Commit c54475a

Browse files
committed
feat: unify status update settings and harden IM runtime behavior
1 parent 015f370 commit c54475a

File tree

20 files changed

+372
-119
lines changed

20 files changed

+372
-119
lines changed

packages/config/dashboard-config.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
3+
parseStatusMessageFrequencyMs,
4+
type StatusMessageFrequencyMs,
5+
} from "./status-message-frequency";
6+
17
export type DashboardConfig = {
28
completeOnboarding: boolean;
39
user: {
@@ -8,7 +14,7 @@ export type DashboardConfig = {
814
gitStrategy: "default" | "worktree";
915
defaultStatusMessageFormat: "aggressive" | "medium" | "minimum";
1016
defaultMessageFrequency?: "aggressive" | "medium" | "minimum";
11-
statusMessageFrequencyMs?: 2000 | 5000 | 10000;
17+
statusMessageFrequencyMs?: StatusMessageFrequencyMs;
1218
};
1319
updates: {
1420
autoUpgrade: boolean;
@@ -91,7 +97,7 @@ export const defaultDashboardConfig: DashboardConfig = {
9197
email: "",
9298
gitStrategy: "worktree",
9399
defaultStatusMessageFormat: "medium",
94-
statusMessageFrequencyMs: 2000,
100+
statusMessageFrequencyMs: DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
95101
},
96102
updates: {
97103
autoUpgrade: true,
@@ -135,10 +141,8 @@ const asFrequency = (
135141
return "medium";
136142
};
137143

138-
const asStatusMessageFrequencyMs = (value: unknown): 2000 | 5000 | 10000 => {
139-
if (value === 5000 || value === 10000) return value;
140-
return 2000;
141-
};
144+
const asStatusMessageFrequencyMs = (value: unknown): StatusMessageFrequencyMs =>
145+
parseStatusMessageFrequencyMs(value);
142146

143147
const asGitStrategy = (
144148
value: unknown

packages/config/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ export {
6868
type StatusMessageFormat,
6969
} from "./status-message-format";
7070

71+
export {
72+
STATUS_MESSAGE_FREQUENCY_OPTIONS,
73+
DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
74+
isStatusMessageFrequencyMs,
75+
parseStatusMessageFrequencyMs,
76+
isStatusMessageFrequencyValue,
77+
parseStatusMessageFrequencyValue,
78+
toStatusMessageFrequencyValue,
79+
type StatusMessageFrequencyMs,
80+
type StatusMessageFrequencyValue,
81+
} from "./status-message-frequency";
82+
7183
export { resolveMessageUpdateIntervalMs } from "./message-update-interval";
7284

7385
export { resolveGitStrategy, type GitStrategy } from "./git-strategy";

packages/config/local/ode.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {
77
sanitizeDashboardConfig,
88
type DashboardConfig,
99
} from "../dashboard-config";
10+
import {
11+
DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
12+
parseStatusMessageFrequencyMs,
13+
type StatusMessageFrequencyMs,
14+
} from "../status-message-frequency";
1015

1116
const existsSync = fs.existsSync;
1217
const mkdirSync = fs.mkdirSync;
@@ -40,7 +45,7 @@ const userSchema = z.object({
4045
"high",
4146
]).optional(),
4247
messageUpdateIntervalMs: z.number().optional(),
43-
IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(2000),
48+
IM_MESSAGE_UPDATE_INTERVAL_MS: z.number().optional().default(DEFAULT_STATUS_MESSAGE_FREQUENCY_MS),
4449
});
4550

4651
const agentProviderSchema = z.enum(["opencode", "claudecode", "codex", "kimi", "kiro", "kilo", "qwen", "goose", "gemini"]);
@@ -103,7 +108,7 @@ const channelDetailSchema = z.object({
103108

104109
const DEFAULT_UPDATE_INTERVAL_MS = 60 * 60 * 1000;
105110
const MIN_UPDATE_INTERVAL_MS = 5 * 60 * 1000;
106-
const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = 2000;
111+
const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = DEFAULT_STATUS_MESSAGE_FREQUENCY_MS;
107112
const MIN_MESSAGE_UPDATE_INTERVAL_MS = 250;
108113
export const DEFAULT_CODEX_MODEL = "gpt-5.3-codex";
109114

@@ -170,7 +175,7 @@ const EMPTY_TEMPLATE: OdeConfig = {
170175
avatar: "",
171176
gitStrategy: "worktree",
172177
defaultStatusMessageFormat: "medium",
173-
IM_MESSAGE_UPDATE_INTERVAL_MS: 2000,
178+
IM_MESSAGE_UPDATE_INTERVAL_MS: DEFAULT_STATUS_MESSAGE_FREQUENCY_MS,
174179
},
175180
githubInfos: {},
176181
agents: {
@@ -366,10 +371,7 @@ function toDashboardConfig(config: OdeConfig): DashboardConfig {
366371
avatar: config.user.avatar,
367372
gitStrategy: config.user.gitStrategy,
368373
defaultStatusMessageFormat,
369-
statusMessageFrequencyMs:
370-
config.user.IM_MESSAGE_UPDATE_INTERVAL_MS === 5000 || config.user.IM_MESSAGE_UPDATE_INTERVAL_MS === 10000
371-
? config.user.IM_MESSAGE_UPDATE_INTERVAL_MS
372-
: 2000,
374+
statusMessageFrequencyMs: parseStatusMessageFrequencyMs(config.user.IM_MESSAGE_UPDATE_INTERVAL_MS),
373375
},
374376
updates: {
375377
autoUpgrade: config.updates.autoUpgrade,
@@ -405,10 +407,7 @@ function mergeDashboardConfig(config: OdeConfig, dashboardConfig: DashboardConfi
405407
user: {
406408
...config.user,
407409
...dashboardUser,
408-
IM_MESSAGE_UPDATE_INTERVAL_MS:
409-
statusMessageFrequencyMs === 5000 || statusMessageFrequencyMs === 10000
410-
? statusMessageFrequencyMs
411-
: 2000,
410+
IM_MESSAGE_UPDATE_INTERVAL_MS: parseStatusMessageFrequencyMs(statusMessageFrequencyMs),
412411
},
413412
updates: {
414413
...config.updates,
@@ -630,7 +629,7 @@ export type GitHubInfo = {
630629
export type UserGeneralSettings = {
631630
defaultStatusMessageFormat: "minimum" | "medium" | "aggressive";
632631
gitStrategy: "default" | "worktree";
633-
statusMessageFrequencyMs: 2000 | 5000 | 10000;
632+
statusMessageFrequencyMs: StatusMessageFrequencyMs;
634633
autoUpdate: boolean;
635634
};
636635

@@ -658,10 +657,7 @@ export function getUserGeneralSettings(): UserGeneralSettings {
658657
? user.defaultStatusMessageFormat
659658
: "medium",
660659
gitStrategy: user.gitStrategy === "default" ? "default" : "worktree",
661-
statusMessageFrequencyMs:
662-
user.IM_MESSAGE_UPDATE_INTERVAL_MS === 5000 || user.IM_MESSAGE_UPDATE_INTERVAL_MS === 10000
663-
? user.IM_MESSAGE_UPDATE_INTERVAL_MS
664-
: 2000,
660+
statusMessageFrequencyMs: parseStatusMessageFrequencyMs(user.IM_MESSAGE_UPDATE_INTERVAL_MS),
665661
autoUpdate: updates.autoUpgrade !== false,
666662
};
667663
}
@@ -673,10 +669,7 @@ export function setUserGeneralSettings(settings: UserGeneralSettings): void {
673669
...config.user,
674670
defaultStatusMessageFormat: settings.defaultStatusMessageFormat,
675671
gitStrategy: settings.gitStrategy,
676-
IM_MESSAGE_UPDATE_INTERVAL_MS:
677-
settings.statusMessageFrequencyMs === 5000 || settings.statusMessageFrequencyMs === 10000
678-
? settings.statusMessageFrequencyMs
679-
: 2000,
672+
IM_MESSAGE_UPDATE_INTERVAL_MS: parseStatusMessageFrequencyMs(settings.statusMessageFrequencyMs),
680673
},
681674
updates: {
682675
...config.updates,

packages/config/message-update-interval.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { loadOdeConfig } from "./local/ode";
2+
import { DEFAULT_STATUS_MESSAGE_FREQUENCY_MS } from "./status-message-frequency";
23

3-
const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = 2000;
4+
const DEFAULT_MESSAGE_UPDATE_INTERVAL_MS = DEFAULT_STATUS_MESSAGE_FREQUENCY_MS;
45
const MIN_MESSAGE_UPDATE_INTERVAL_MS = 250;
56

67
export function resolveMessageUpdateIntervalMs(): number {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export const STATUS_MESSAGE_FREQUENCY_OPTIONS = [
2+
{ ms: 5000, value: "5000", label: "5 seconds" },
3+
{ ms: 8000, value: "8000", label: "8 seconds" },
4+
{ ms: 12000, value: "12000", label: "12 seconds" },
5+
] as const;
6+
7+
export type StatusMessageFrequencyMs = (typeof STATUS_MESSAGE_FREQUENCY_OPTIONS)[number]["ms"];
8+
export type StatusMessageFrequencyValue = (typeof STATUS_MESSAGE_FREQUENCY_OPTIONS)[number]["value"];
9+
10+
export const DEFAULT_STATUS_MESSAGE_FREQUENCY_MS: StatusMessageFrequencyMs = 5000;
11+
12+
export function isStatusMessageFrequencyMs(value: unknown): value is StatusMessageFrequencyMs {
13+
return value === 5000 || value === 8000 || value === 12000;
14+
}
15+
16+
export function parseStatusMessageFrequencyMs(value: unknown): StatusMessageFrequencyMs {
17+
return isStatusMessageFrequencyMs(value) ? value : DEFAULT_STATUS_MESSAGE_FREQUENCY_MS;
18+
}
19+
20+
export function isStatusMessageFrequencyValue(value: string): value is StatusMessageFrequencyValue {
21+
return value === "5000" || value === "8000" || value === "12000";
22+
}
23+
24+
export function parseStatusMessageFrequencyValue(value: string): StatusMessageFrequencyValue | null {
25+
const normalized = value.trim();
26+
return isStatusMessageFrequencyValue(normalized) ? normalized : null;
27+
}
28+
29+
export function toStatusMessageFrequencyValue(ms: StatusMessageFrequencyMs): StatusMessageFrequencyValue {
30+
return String(ms) as StatusMessageFrequencyValue;
31+
}

packages/core/cli.ts

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env bun
22

33
import { spawn } from "child_process";
4-
import { closeSync, openSync, readSync, statSync } from "fs";
54
import packageJson from "../../package.json" with { type: "json" };
65
import { getWebHost, getWebPort } from "@/config";
76
import { runDaemon } from "@/core/daemon/manager";
@@ -21,8 +20,6 @@ const READY_POLL_MS = 500;
2120
const STOP_WAIT_MS = 30 * 1000;
2221
const STOP_POLL_MS = 500;
2322
const DAEMON_SPAWN_THROTTLE_MS = 3000;
24-
const LOG_TAIL_BYTES = 200_000;
25-
const LOG_TAIL_LINES = 40;
2623
let lastDaemonSpawnAttemptAt = 0;
2724

2825
const foregroundRequested = rawArgs.includes("--foreground");
@@ -159,52 +156,27 @@ async function startBackground(): Promise<void> {
159156
console.log(`Ode daemon is still starting. Follow logs at ${getDaemonLogPath()}`);
160157
}
161158

162-
function tailLogs(maxLines: number): string[] {
163-
const logPath = getDaemonLogPath();
164-
try {
165-
const stats = statSync(logPath);
166-
if (stats.size === 0) return [];
167-
const bytes = Math.min(LOG_TAIL_BYTES, stats.size);
168-
const buffer = Buffer.alloc(Number(bytes));
169-
const fd = openSync(logPath, "r");
170-
try {
171-
readSync(fd, buffer, 0, Number(bytes), stats.size - bytes);
172-
} finally {
173-
closeSync(fd);
174-
}
175-
const content = buffer.toString("utf-8");
176-
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0);
177-
return lines.slice(-maxLines);
178-
} catch {
179-
return [];
180-
}
181-
}
182-
183159
function formatTimestamp(value: number | null): string {
184160
if (!value) return "n/a";
185161
return new Date(value).toLocaleString();
186162
}
187163

188164
async function showStatus(): Promise<void> {
189165
const state = daemonState();
190-
const daemonStatus = managerRunning(state) ? `running (pid ${state.managerPid})` : "stopped";
191-
const runtimeStatus = runtimeRunning(state) ? `running (pid ${state.runtimePid})` : "stopped";
192-
console.log(`Daemon: ${daemonStatus}`);
193-
console.log(`Runtime: ${runtimeStatus}`);
194-
console.log(`Last start: ${formatTimestamp(state.lastStartAt)}`);
195-
console.log(`Last ready: ${formatTimestamp(state.lastReadyAt)}`);
166+
const daemonIsRunning = managerRunning(state);
167+
console.log(`Daemon: ${daemonIsRunning ? "running" : "stopped"}`);
196168
if (state.pendingUpgradeRestart) {
197169
console.log(
198-
`Pending upgrade restart since ${formatTimestamp(state.pendingUpgradeRestart.scheduledAt)} (${state.pendingUpgradeRestart.reason})`,
170+
`Upgrade: pending restart since ${formatTimestamp(state.pendingUpgradeRestart.scheduledAt)} (${state.pendingUpgradeRestart.reason})`,
199171
);
172+
} else {
173+
console.log("Upgrade: none pending");
200174
}
201-
const logs = tailLogs(LOG_TAIL_LINES);
202-
if (logs.length === 0) {
203-
console.log(`No logs yet. Log file: ${getDaemonLogPath()}`);
175+
if (daemonIsRunning) {
176+
console.log("ode is running, setting UI is running on localhost:9293...");
204177
return;
205178
}
206-
console.log(`Recent logs (${getDaemonLogPath()}):`);
207-
console.log(logs.join("\n"));
179+
console.log("ode is installed but not running, can run it with ode");
208180
}
209181

210182
async function restartDaemonCommand(): Promise<void> {

packages/core/runtime.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,17 @@ export function createCoreRuntime(deps: RuntimeDeps) {
147147
const statusFormat = resolveStatusMessageFormat();
148148
const finalChunks = splitResultMessage(text);
149149
const singleChunk = finalChunks[0] ?? text;
150+
const statusRateLimited = runtimeDeps.im.wasRateLimited?.(channelId, statusTs) ?? false;
150151

151152
if (finalChunks.length > 1) {
152-
if (statusFormat !== "aggressive") {
153+
if (statusFormat !== "aggressive" && !statusRateLimited) {
153154
await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below in multiple messages.", false);
155+
} else if (statusRateLimited) {
156+
log.warn("Skipping final status update due to prior 429; posting final chunks as new messages", {
157+
channelId,
158+
threadId,
159+
statusTs,
160+
});
154161
}
155162

156163
for (const chunk of finalChunks) {
@@ -164,6 +171,16 @@ export function createCoreRuntime(deps: RuntimeDeps) {
164171
return;
165172
}
166173

174+
if (statusRateLimited) {
175+
log.warn("Skipping final status edit due to prior 429; posting final result as new message", {
176+
channelId,
177+
threadId,
178+
statusTs,
179+
});
180+
await runtimeDeps.im.sendMessage(channelId, threadId, singleChunk, true);
181+
return;
182+
}
183+
167184
const maxEditableMessageChars = runtimeDeps.im.maxEditableMessageChars;
168185
if (typeof maxEditableMessageChars === "number" && singleChunk.length > maxEditableMessageChars) {
169186
await runtimeDeps.im.updateMessage(channelId, statusTs, "Final result posted below.", false);

packages/core/runtime/message-updates.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,23 @@ type QueuedUpdate = {
1010
resolve: () => void;
1111
};
1212

13+
function isRateLimitError(error: unknown): boolean {
14+
const message = String(error || "").toLowerCase();
15+
return message.includes("429") || message.includes("rate limit") || message.includes("ratelimit") || message.includes("rate_limited");
16+
}
17+
1318
export function createRateLimitedImAdapter(
1419
im: IMAdapter,
1520
intervalMs = resolveMessageUpdateIntervalMs()
1621
): IMAdapter {
1722
let globalLastUpdateAt = 0;
1823
const queue: QueuedUpdate[] = [];
1924
let processing = false;
25+
const rateLimitedMessages = new Set<string>();
26+
27+
function key(channelId: string, messageTs: string): string {
28+
return `${channelId}:${messageTs}`;
29+
}
2030

2131
async function processQueue(): Promise<void> {
2232
if (processing || queue.length === 0) return;
@@ -35,6 +45,14 @@ export function createRateLimitedImAdapter(
3545
try {
3646
await im.updateMessage(item.channelId, item.messageTs, item.text, item.asMarkdown);
3747
} catch (error) {
48+
if (isRateLimitError(error)) {
49+
rateLimitedMessages.add(key(item.channelId, item.messageTs));
50+
log.warn("IM message update hit rate limit (429)", {
51+
channelId: item.channelId,
52+
messageTs: item.messageTs,
53+
error: String(error),
54+
});
55+
}
3856
log.debug("IM message update failed", {
3957
channelId: item.channelId,
4058
messageTs: item.messageTs,
@@ -50,6 +68,12 @@ export function createRateLimitedImAdapter(
5068

5169
return {
5270
...im,
71+
wasRateLimited: (channelId: string, messageTs: string): boolean => {
72+
if (typeof im.wasRateLimited === "function" && im.wasRateLimited(channelId, messageTs)) {
73+
return true;
74+
}
75+
return rateLimitedMessages.has(key(channelId, messageTs));
76+
},
5377
updateMessage: async (
5478
channelId: string,
5579
messageTs: string,

packages/core/runtime/open-request.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export async function runOpenRequest(params: {
8686
stateKey: statusMessageKey,
8787
liveParsedState,
8888
startedAt: request.startedAt,
89+
onTitleGenerated: async (title) => {
90+
if (!deps.im.renameThread) return;
91+
await deps.im.renameThread(context.channelId, context.replyThreadId, title);
92+
},
8993
});
9094

9195
const progressIntervalMs = resolveMessageUpdateIntervalMs();

packages/core/runtime/selection-reply.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export async function handleSelectionReply(params: HandleSelectionReplyParams):
8686
stateKey: statusMessageKey,
8787
liveParsedState: state.liveParsedState,
8888
startedAt: request.startedAt,
89+
onTitleGenerated: async (title) => {
90+
if (!deps.im.renameThread) return;
91+
await deps.im.renameThread(channelId, threadId, title);
92+
},
8993
});
9094

9195
const session = loadSession(channelId, threadId);

0 commit comments

Comments
 (0)