Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,9 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (1222) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (95: 45 daemon_client + 20 conn-manager + 8 integration + 6 build-config + 7 gui-state + 3 preload-api + 4 boot-contract + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (490: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 19 transcript + 10 TranscriptView + 15 history + 22 composer + 38 Composer + 6 LinkDialog + 12 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 15 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
Python: `uv run pytest tests/ -v` (1223) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (497: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 19 transcript + 10 TranscriptView + 15 history + 22 composer + 38 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径)
Expand Down
26 changes: 23 additions & 3 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,16 @@ async def _reconnect():
term.render()
continue

if data.get("type") == "turn_start":
# Rant 2026-09-02T10:36:26:daemon 权威 turn 开始帧——把本地计时
# 对齐到实际执行时刻(排队请求目前从发送时刻起算,计时偏大)。
# 仅当本端 busy(该 turn 属于本会话)时对齐;started_at 为 epoch 秒。
started = data.get("started_at")
if busy and isinstance(started, (int, float)) and started > 0:
_request_start = float(started)
logger.info("turn_start aligned: _request_start=%.3f", _request_start)
continue

resp = TaskResponse.from_dict(data)
if resp.delta and resp.content:
if need_new_assistant:
Expand Down Expand Up @@ -701,11 +711,18 @@ async def _reconnect():
f"Compact complete — {compacted} messages compressed into summary.\n"
f"Summary: {summary[:200]}..."
)
busy = False
# Rant 2026-09-02T10:31:11:auto-compact 可在 tool loop round 之间
# 触发(turn 未结束)——此时 compact_result 是旁路事件,绝不能把
# busy 置 False(否则 _run_elapsed_timer 的 while busy 退出,计时器
# 死亡、状态栏 elapsed 被清空、终端标题残留停住)。仅当 turn 空闲
# (手动 /compact)时才按终态处理。msg_count 两种情况下都应减去。
if not busy:
busy = False
status.elapsed = ""
status.update(center=server_id or "emrg")
msg_count = max(0, msg_count - compacted)
_update_left_extra()
status.elapsed = ""
status.update(center=server_id or "emrg"); term.render()
term.render()
continue

# Sessions list
Expand Down Expand Up @@ -1248,6 +1265,9 @@ async def handle_key(data: bytes) -> bool:
_elapsed_task.cancel()
_elapsed_task = None
status.elapsed = ""
# Rant 2026-09-02T10:31:11:与 done 路径(:617)一致的标题复位——ESC 中断
# 漏清 term.set_title 会让标题残留 [m:ss] 计时停住。
term.set_title(f"{session_title or session_id} @ {project_name}")
# Send cancel to daemon so it stops tool/LLM processing
await conn.send_command("cancel")
chat.add("system", "⏸ Interrupted — response stopped. You can continue.")
Expand Down
1 change: 1 addition & 0 deletions emrg/gui/renderer/src/components/DialogHost.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function appState(over: Partial<DaemonAppState> = {}): DaemonAppState {
sessions: [],
openSessions: [],
busyBySid: {},
turnStartBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
Expand Down
11 changes: 11 additions & 0 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ export function Shell() {
busyStartRef.current = null;
setElapsed(0);
}, [activeBusy]);

// Rant 2026-09-02T10:36:26:侧边栏每个运行会话的 [m:ss] 计时——任意会话 busy 时
// 共享一个 1s tick 触发重渲染(Sidebar 读 Date.now() 计算 elapsed;多会话复用同一定时器)。
const [, setSidebarTick] = useState(0);
const hasRunningSessions = Object.keys(appState.turnStartBySid).length > 0;
useEffect(() => {
if (!hasRunningSessions) return;
const iv = setInterval(() => setSidebarTick((t) => t + 1), 1000);
return () => clearInterval(iv);
}, [hasRunningSessions]);
const activeSessionEntry = appState.openSessions.find((o) => o.sid === activeSid);
const activeKnown = appState.sessions.find((s) => s.session_id === activeSid);
const activeTitle = activeSessionEntry?.title || activeKnown?.title || t("app.unnamed");
Expand Down Expand Up @@ -655,6 +665,7 @@ export function Shell() {
knownSessions={appState.sessions}
activeSid={activeSid}
activeView={activeView}
turnStartBySid={appState.turnStartBySid}
onSelect={selectSession}
onSwitchView={switchView}
onNewChat={() => dialogHost.current?.openNewSession()}
Expand Down
13 changes: 12 additions & 1 deletion emrg/gui/renderer/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useI18n } from "../lib/i18n";
import {
formatElapsed,
isActive,
resolveEntryTitle,
sessionLabel,
Expand Down Expand Up @@ -41,6 +42,8 @@ export interface SidebarProps {
onContextMenu?: (entry: OpenSessionEntry, event: React.MouseEvent) => void;
/** 可注入的标题格式化函数(测试用;默认 sessionLabel) */
labelFn?: (project: string, title: string, sid: string) => string;
/** 每会话 turn 开始时刻(epoch ms;rant 2026-09-02T10:36:26 — busy 会话显示 [m:ss] 计时) */
turnStartBySid?: Record<string, number>;
/** 新对话按钮 → NewSessionDialog(Batch 5 slice 4) */
onNewChat?: () => void;
/** 打开会话按钮 → OpenSessionDialog(Batch 5 slice 4) */
Expand All @@ -58,13 +61,15 @@ export function Sidebar({
onSelect,
onContextMenu,
labelFn = sessionLabel,
turnStartBySid = {},
onNewChat,
onOpenChat,
activeView = "sessions",
onSwitchView,
}: SidebarProps) {
const { t } = useI18n();
const entries = sortOpenSessions(openSessions);
const nowMs = Date.now();

// vanilla #side-nav:五个导航按钮(sessions/projects/tasks/rants/settings)
const navRail = (
Expand Down Expand Up @@ -123,6 +128,9 @@ export function Sidebar({
const title = resolveEntryTitle(entry, knownSessions);
const label = labelFn(entry.projectName || "", title, entry.sid);
const active = isActive(entry.sid, activeSid);
// Rant 2026-09-02T10:36:26:daemon turn_start 权威计时——正在运行的会话
// 在标题后显示 [m:ss](与 TUI 状态栏同格式同基准)。
const timer = formatElapsed(turnStartBySid[entry.sid], nowMs);
return (
<div
key={entry.sid}
Expand All @@ -135,7 +143,10 @@ export function Sidebar({
onContextMenu && onContextMenu(entry, e);
}}
>
<span className="conv-title">{label}</span>
<span className="conv-title">
{label}
{timer ? <span className="open-session-timer" data-testid="open-session-timer">{timer}</span> : null}
</span>
</div>
);
})}
Expand Down
35 changes: 35 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,41 @@ describe("createDaemonBridge", () => {
expect(entriesText(transcript, "s2")).toContain("s:queued:3");
});

it("turn_start → busy + turnStartBySid(epoch ms 换算,含后台/其他客户端 turn)", () => {
const { emit, bridge } = setup();
emit({ type: "turn_start", data: { started_at: 1756785600.5 }, sid: "s1" });
const st = bridge.store.get();
expect(st.busyBySid["s1"]).toBe(true);
expect(st.turnStartBySid["s1"]).toBe(1756785600500);
});

it("turn_end → 清 busy + turnStartBySid(幂等,与 done 重复不炸)", () => {
const { emit, bridge } = setup();
emit({ type: "turn_start", data: { started_at: 1756785600.5 }, sid: "s1" });
emit({ type: "turn_end", data: {}, sid: "s1" });
let st = bridge.store.get();
expect(st.busyBySid["s1"]).toBe(false);
expect(st.turnStartBySid["s1"]).toBeUndefined();
// 幂等:done 后再 turn_end 不抛错、状态保持
emit({ type: "turn_end", data: {}, sid: "s1" });
st = bridge.store.get();
expect(st.busyBySid["s1"]).toBe(false);
expect(st.turnStartBySid["s1"]).toBeUndefined();
});

it("done / cancelled → 顺带清 turn 计时(幂等兜底)", () => {
const { emit, bridge } = setup();
emit({ type: "turn_start", data: { started_at: 1756785600 }, sid: "s1" });
emit({ type: "done", data: { request_id: "r1" }, sid: "s1" });
let st = bridge.store.get();
expect(st.turnStartBySid["s1"]).toBeUndefined();
emit({ type: "turn_start", data: { started_at: 1756785601 }, sid: "s1" });
emit({ type: "cancelled", data: {}, sid: "s1" });
st = bridge.store.get();
expect(st.busyBySid["s1"]).toBe(false);
expect(st.turnStartBySid["s1"]).toBeUndefined();
});

it("error → 错误系统消息 + 释放锁", () => {
const { emit, bridge, transcript } = setup();
bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "x" }] }, sid: "s1" });
Expand Down
44 changes: 44 additions & 0 deletions emrg/gui/renderer/src/lib/daemonBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export interface DaemonAppState {
openSessions: OpenSessionEntry[];
/** 每会话 busy 锁(P3 slice 1:done/cancelled 按 sid 释放,不误清激活会话) */
busyBySid: Record<string, boolean>;
/** 每会话 turn 开始时刻(epoch ms;rant 2026-09-02T10:36:26 daemon turn_start 权威广播) */
turnStartBySid: Record<string, number>;
/** 每会话 own stream request id(决定"来自其他客户端"标签) */
ownStreamRidBySid: Record<string, string | null>;
/** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */
Expand All @@ -112,6 +114,7 @@ export function createDaemonAppStore(): SnapshotStore<DaemonAppState> {
sessions: [],
openSessions: [],
busyBySid: {},
turnStartBySid: {},
ownStreamRidBySid: {},
disconnectedBySid: {},
upgradeBanner: null,
Expand Down Expand Up @@ -176,6 +179,19 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
const k = KEY(sid);
store.update((s) => ({ ...s, busyBySid: { ...s.busyBySid, [k]: busy } }));
}

/** 清除该会话的 turn 计时(turn_end/done/cancelled/disconnected 幂等调用)。 */
function clearTurnTimer(sid: string | null): void {
const k = KEY(sid);
const { [k]: _drop, ...rest } = store.get().turnStartBySid;
if (_drop !== undefined || store.get().busyBySid[k]) {
store.update((s) => ({
...s,
busyBySid: { ...s.busyBySid, [k]: false },
turnStartBySid: rest,
}));
}
}
function sidOwnRid(sid: string | null, rid: string | null): void {
const k = KEY(sid);
store.update((s) => ({ ...s, ownStreamRidBySid: { ...s.ownStreamRidBySid, [k]: rid } }));
Expand Down Expand Up @@ -235,12 +251,38 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
const { type, data } = frame;
const sid = frame.sid ?? null;
switch (type) {
case "turn_start": {
// Rant 2026-09-02T10:36:26:daemon 权威 turn 开始(含后台演化/其他客户端
// turn)——记 start 时刻并置 busy;计时基准与 TUI 同一来源。
const startedAt = (data as { started_at?: number }).started_at;
const k = KEY(sid);
if (typeof startedAt === "number" && startedAt > 0) {
store.update((s) => ({
...s,
busyBySid: { ...s.busyBySid, [k]: true },
turnStartBySid: { ...s.turnStartBySid, [k]: startedAt * 1000 },
}));
}
break;
}
case "turn_end": {
// daemon 权威 turn 结束——清 busy + 计时(与 done/cancelled 幂等)。
const k = KEY(sid);
const { [k]: _drop, ...rest } = store.get().turnStartBySid;
store.update((s) => ({
...s,
busyBySid: { ...s.busyBySid, [k]: false },
turnStartBySid: rest,
}));
break;
}
case "message_delta":
transcript.handleDelta(data.chunks || [data], sid);
break;
case "done":
transcript.handleDone(data as DoneData, sid);
releaseOwnStream(sid, (data as DoneData).request_id, Boolean((data as DoneData).timeout));
clearTurnTimer(sid);
break;
case "tool_started":
transcript.handleToolStart(data as ToolStartData, sid);
Expand All @@ -251,6 +293,7 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
case "cancelled":
transcript.clearTyping(sid);
releaseOwnStream(sid, null, true);
clearTurnTimer(sid);
break;
case "task_queued":
transcript.addSystemMessage(tt("app.queued", { pos: (data as QueuedData).position ?? 0 }), sid);
Expand Down Expand Up @@ -319,6 +362,7 @@ export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge {
sidBusy(sid, false);
sidOwnRid(sid, null);
sidDisconnected(sid, true);
clearTurnTimer(sid);
queuedSends.delete(KEY(sid));
if (!sid) {
store.update((s) => ({ ...s, connected: false }));
Expand Down
25 changes: 25 additions & 0 deletions emrg/gui/renderer/src/lib/sidebar.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatElapsed,
isActive,
resolveEntryTitle,
sessionLabel,
Expand Down Expand Up @@ -91,3 +92,27 @@ describe("isActive", () => {
expect(isActive(undefined, undefined)).toBe(false);
});
});

describe("formatElapsed(rant 2026-09-02T10:36:26:与 TUI 状态栏同格式 [m:ss])", () => {
const T0 = 1_756_800_000_000; // 任意 epoch ms 基准

it("无 startedAt(非运行)→ 空串", () => {
expect(formatElapsed(undefined, T0)).toBe("");
expect(formatElapsed(0, T0)).toBe("");
});

it("<60s → [0:ss](补零)", () => {
expect(formatElapsed(T0, T0 + 5000)).toBe("[0:05]");
expect(formatElapsed(T0, T0 + 59_000)).toBe("[0:59]");
});

it("≥60s → [m:ss](m 不补零,与 TUI divmod 一致)", () => {
expect(formatElapsed(T0, T0 + 60_000)).toBe("[1:00]");
expect(formatElapsed(T0, T0 + 97_800)).toBe("[1:37]"); // 宿主实测样例 97.8s
expect(formatElapsed(T0, T0 + 3_600_000)).toBe("[60:00]");
});

it("负 elapsed(时钟偏差)钳制为 0", () => {
expect(formatElapsed(T0 + 5000, T0)).toBe("[0:00]");
});
});
13 changes: 13 additions & 0 deletions emrg/gui/renderer/src/lib/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,16 @@ export function sortOpenSessions(entries: OpenSessionEntry[]): OpenSessionEntry[
export function isActive(sid: string | null | undefined, activeSid: string | null | undefined): boolean {
return !!sid && sid === activeSid;
}

/**
* 运行计时格式化(rant 2026-09-02T10:36:26):与 TUI 状态栏一致的 `[m:ss]`。
* startedAtMs/nowMs 均为 epoch 毫秒;startedAtMs 缺失(非运行中)→ ""(不显示)。
* 负 elapsed(时钟偏差)钳制为 0。
*/
export function formatElapsed(startedAtMs: number | undefined, nowMs: number): string {
if (!startedAtMs || startedAtMs <= 0) return "";
const elapsed = Math.max(0, Math.floor((nowMs - startedAtMs) / 1000));
const mins = Math.floor(elapsed / 60);
const secs = elapsed % 60;
return `[${mins}:${secs.toString().padStart(2, "0")}]`;
}
17 changes: 17 additions & 0 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2330,12 +2330,29 @@ async def _run_tool_loop_locked(
# Rant 2026-08-25T17:38:56 根因 3:广播按任务 cwd 过滤——记录本任务真实 cwd,
# 幽灵连接(错误 cwd 订阅)在任务期间收不到该会话实时流。
self._session_task_cwds[session_id] = str(session.cwd)
# Rant 2026-09-02T10:36:26:权威 turn 生命周期广播——started_at 为任务
# 实际开始执行时刻(队列等待之后)。TUI/GUI 据此对齐各自计时(排队请求
# 不再从发送时刻起算);覆盖所有 turn 来源:TUI/GUI 请求、演化任务
# (emrg-evolution-*)、upgrade 会话(都经此 locked 包装执行)。
await self._broadcast(session_id, {
"type": "turn_start",
"session_id": session_id,
"started_at": time.time(),
})
normal_end = False
try:
await self._run_tool_loop(req, ws, session, cancel_event, allow_tools)
normal_end = True
finally:
self._session_busy[session_id] = False
# Rant 2026-09-02T10:36:26:turn 生命周期结束——客户端清除该会话
# 运行计时(与 done/cancelled 帧幂等,仅作权威清理信号)。
# 必须在 _session_task_cwds 清空之前广播:turn 帧属于任务流,
# 需按任务 cwd 过滤(错误 cwd 的幽灵连接收不到,rant 2026-08-25T17:38:56)。
await self._broadcast(session_id, {
"type": "turn_end",
"session_id": session_id,
})
self._session_task_cwds.pop(session_id, None)
# P1 (rant 21:55:37): messages still queued when the loop ends are
# not lost. We do NOT start a follow-up task here (_tool_task /
Expand Down
Loading
Loading