Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4374aa6
fix(web): 频道消息首拉失败呈错误态 + 重试入口,不再呈假空态 (#482)
dommaker Sep 11, 2026
067885c
fix(web): 待回复 chip 定位复用 #439 翻页定位循环,提问掉出分页不再静默 (#483)
dommaker Sep 11, 2026
1356f19
fix(web): 引导片 dismiss 收片粒度——每片独立 ✕ 只关本片,不再一键清全部 (#484)
dommaker Sep 11, 2026
c3ceea1
feat(web): composer 文件引用台账 chip 可视化,失效标灰不再静默丢弃 (#485)
dommaker Sep 11, 2026
b236511
feat(web): 频道发送乐观回显——pending 即插、成功原位替换、失败回滚 (#486)
dommaker Sep 11, 2026
e1cee35
fix(api): suggestions currentWu 拣选加粘性——多单并行交替簿写不再抖动 (#487)
dommaker Sep 11, 2026
afe2e7c
fix(web): ChannelWorkBar 占位区分加载/空闲/有工单三态 (#488)
dommaker Sep 11, 2026
92d7420
fix(web): 建议端点重拉触发面补齐——message_sent/requirement.* 纳入防抖重拉 (#489)
dommaker Sep 11, 2026
a05399b
feat(api,web): 建议端点加 degraded 标志——fail-closed 被吞与「真无建议」可区分 (#490)
dommaker Sep 11, 2026
c42ab76
feat(api): SSE 断线窗口事件 replay——消费 Last-Event-ID + 环形内存 buffer (#491)
dommaker Sep 11, 2026
ac7eb7e
fix(channel): 回复冷层父消息不再静默失效,频道发归档提示 (#492)
dommaker Sep 11, 2026
6ac541f
docs(channel): CONTEXT.md 沉淀 #492 冷层父回复归档提示 (#492)
dommaker Sep 11, 2026
48a359c
fix(channel): 人类回复唤醒加固(闩锁+同毫秒边界)+ 前端送达即时反馈 (#493)
dommaker Sep 11, 2026
2164724
fix(channel): 派单锚点显式传递,认领播报不再竞态落独立根 (#494)
dommaker Sep 11, 2026
00261e2
feat(channel): 决策12 派单合并窗口——连发闲聊并入在途 WU 线程 (#495)
dommaker Sep 11, 2026
7437995
fix(channel): mention 手打中文连写兜底——成员名最长前缀匹配 (#496)
dommaker Sep 11, 2026
face4f0
fix(channel): routing/members 配置漂移收敛——删 profile 清 routing + 移出 warnin…
dommaker Sep 11, 2026
7c8076a
fix(channel): 合并窗口 metadata 解析归 parseWuMetadata 容错口径——畸形 JSON 不再抛错拖垮路…
dommaker Sep 11, 2026
8b199f5
docs(channel): CONTEXT.md 沉淀合并目标 metadata parseWuMetadata 容错口径 (#495 尾随)
dommaker Sep 11, 2026
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
4 changes: 2 additions & 2 deletions apps/api/src/modules/agents/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop)

- **周期循环 scan-sharing(候选 3,2026-09-08)**:monitor `check()` 一轮开头一次 `getIndex()`,快照作 caller-private 传给 6 个 WU 探针(收 `snapshots` 参数、内存 filter,不再各自 getIndex;动作前新鲜度复核 `getIndex({id})` 点读不受影响)——**新 WU 探针一律收快照不自己读**;auditor generateSuggestions 的 4 周事件窗口同样每轮一次读、多消费方共享(原 skill 循环内 N+1 全窗口扫描)。跨 job 不共享(各自的轮各自读,非快照层)
- **路由分叉坑(#391 实测)**:`/api/v1/agents` 的 GET / 由 legacy `routes.ts`(agents-registry,响应条目**无 id 字段**)处理;前端 profile 列表走 `/api/v1/agent-profiles`(agent-profile.routes.ts,有 id)。发现 profileId 勿用 `/agents`
- **AgentProfile 持久化**:`~/.studio/data/agents/{id}/profile.json` + `state.json`;原子写+mkdir 锁,仅可显式 DELETE;保留名 `studio`
- **AgentProfile 持久化**:`~/.studio/data/agents/{id}/profile.json` + `state.json`;原子写+mkdir 锁,仅可显式 DELETE;保留名 `studio`。删除时反向收敛引用(agent-profile.service.delete):清各频道 members + #497 起同步把 channel.routing 中指名该 profile 的档归一化为 null(其他档不动;无引用的频道零写入,幂等)
- **studio 系统角色语义(2026-09-10 修正)**:AC-1.3「studio 不挂 loop」已废除——trigger `assigneeRole` 指名 = 独占认领(仅该角色 loop 可见),studio 无 loop 期间指名 WU 全部结构性死单(doc-semantic-review 滞留 143h 事故)。现 studio = 系统维护任务执行角色(正常挂 loop,`channels=[]` 只看得见指名给它的单)+ systemExecutor 直调身份(读其 provider)双重用途;保留名保护扩展到**禁止停用**(update status 非 active 拒绝),删除仍拒。存量旧 description 文案由 `ensureStudioProfile` 按 `STUDIO_ROLE_LEGACY_DESCRIPTION` 匹配迁移,用户自定义不覆盖
- **prompt 注入 = index-on-demand**:skills 只注入 name+description+triggers+指针,正文不注入;注入索引候选 = +skill 点名(scope 解析)+ role.skills 显式声明(#462,同权、按名去重)+ 域匹配(acceptedTypes ∪ wu.type 归一化 ∩ skill.agentTypes)三类;知识分层(rule/context 全量、signal 索引、reference 报条数);分段软定额+池内余量共享截断(persona 300/roster 400/skills 600/map 800/memory 300/knowledge 1000/files 400/contract 200/handoff 800);段序 persona->roster->skills->map->memory->knowledge->files->base->contract->handoff->hint
- **三层超时**:步墙钟 1800s 兜底+静默看门狗(300s warn/600s 杀进程组)+maxTurns=50
Expand All @@ -42,7 +42,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop)
- **多实例单活**:`STUDIO_AGENT_LOOP_ENABLED=false` 实例 standby;`AgentLoop.start()` 内置同角色单活守卫
- **SSE 负载含 channelId(2026-08-24 SSE 负载加深,批 1)**:`workunit.execution.step` / `workunit.execution.stream`(含 step-start)负载与 `workunit.tokens` SSE 信封 data 均携带 `channelId`(wu.channelId 透传,无频道 WU 缺省该键)——前端按频道过滤 step/token 事件的数据源;`workunit:tokens` 落盘后顺带经 eventBus.publish 发 SSE(best-effort,不落盘二次)
- **派单链**:WorkUnitService.create -> workunit.created -> TriggerScheduler -> AgentLoop.observe(15s 轮询兜底)-> 过滤 -> claim -> agentStep -> LocalExecutor -> spawn CLI -> recordResult -> 回帖(EventBus/SSE)。#445 起「认领即发声」(#175 决策 1)不再是 loop 私有实现:claimAndAnnounce 薄封装委托 workunit/claim-announce.ts 的 claimWorkUnitAndAnnounce 原语,REST claim 端点同路径(契约测试双入口间谍锁定,见 workunit/__tests__/claim-announce.test.ts)
- **observe 读路径优化(#330)**:observe 调 `queryAllMessages` 传 myActive WU 的 channelId 集合做频道预过滤(任一活跃 WU 无 channelId 退全扫;已接受盲区 = WU 换频道后旧频道新回复不扫);loop start 订阅 `channel.message_sent`(eventBus 同进程,human 且 workUnitId ∈ myActive 命中即打断空闲 sleep 立即 observe,stop 退订)——空闲兜底轮询维持 15s 不变(事件 fire-and-forget 无持久,防跨进程写者/重启间隙)
- **observe 读路径优化(#330)**:observe 调 `queryAllMessages` 传 myActive WU 的 channelId 集合做频道预过滤(任一活跃 WU 无 channelId 退全扫;已接受盲区 = WU 换频道后旧频道新回复不扫);loop start 订阅 `channel.message_sent`(eventBus 同进程,human 且 workUnitId ∈ myActive 命中即打断空闲 sleep 立即 observe,stop 退订)——空闲兜底轮询维持 15s 不变(事件 fire-and-forget 无持久,防跨进程写者/重启间隙)。**#493 唤醒加固**:①唤醒闩锁 `pendingWake`——事件到达时 loop 不在 idleSleep(执行中/步骤间,wakeIdle 为 null)则置闩,下一次 idleSleep 入口消费并立即放行重跑 observe,唤醒不再丢(无事件时 15s 调度不变);②新回复检测 `msg.createdAt > wu.updatedAt` 改 `>=`——同毫秒落盘(含重启间隙首扫捞回)不再漏检,消费后 recordResult 簿记推进 updatedAt 不会误循环
- **F4 review 派发**:父 in_review -> 建未指派 review 子 WU 走 claim 涌现;excludeAssignee 禁自领;同父唯一性 flock 锁。#466 起 review-dispatcher 先查频道路由表 review 档(channels/routing.ts):命中则指名评审角色(assigneeId 硬约束取代涌现+排除);路由=实现者本人/角色 inactive/移出频道 → 回池涌现(现状语义不变)+ 频道出声提醒
- **R3 评审契约**:评审子 WU scope = diff-only+`+code-review`;needs-info -> 转人工
- **不派评审类型**:decision/spec/analysis 走人工 in_review
Expand Down
93 changes: 93 additions & 0 deletions apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,96 @@ describe('#330: observe 扫描裁剪 + 事件驱动唤醒', () => {
await agentLoop.waitForStop();
});
});

// #493: 新回复检测同毫秒边界(>=)+ 唤醒闩锁(事件到达时不在 idleSleep 不丢唤醒)
// 均不 start()——纯 seam 直驱(observe / onChannelMessageSent / idleSleep),确定性无竞态
describe('#493: 新回复同毫秒边界 + 唤醒闩锁', () => {
let testDir: string;
let fileStore: FileStore;
let wuService: WorkUnitService;
let channelId: string;

beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-493-'));
fileStore = new FileStore(testDir);
wuService = new WorkUnitService(fileStore);
channelId = `ch-493-${Date.now()}`;
});

afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});

/** seam:私有成员访问(TS private 仅编译期) */
interface Seam493 {
alive: boolean;
lastActiveWuIds: Set<string>;
pendingWake: boolean;
onChannelMessageSent(payload: { message?: { authorType?: string; workUnitId?: string | null } }): void;
idleSleep(ms: number): Promise<void>;
observe(): Promise<{ newReplies: { id: string }[] }>;
}
const seamOf = (loop: AgentLoop) => loop as unknown as Seam493;

const humanMsg = (id: string, workUnitId: string, createdAt: string) => ({
id, channelId, authorType: 'human' as const, agentName: null,
content: `回复-${id}`, replyToId: null, meta: '{}', workUnitId, createdAt,
});

it('同毫秒边界:msg.createdAt == wu.updatedAt 的回复被检为新回复(>=)', async () => {
const loop = new AgentLoop(mockRole, fileStore);
const wu = await wuService.create({
scope: '挂起等回复', channelId, type: 'task',
status: 'blocked', assigneeId: 'some-instance',
metadata: { waitingForInput: true },
});
const sameMs = wu.updatedAt.toISOString();
const olderMs = new Date(wu.updatedAt.getTime() - 1).toISOString();
await fileStore.appendMessage(channelId, humanMsg('m-same-ms', wu.id, sameMs));
await fileStore.appendMessage(channelId, humanMsg('m-older', wu.id, olderMs));

const obs = await seamOf(loop).observe();

expect(obs.newReplies.some(m => m.id === 'm-same-ms')).toBe(true);
expect(obs.newReplies.some(m => m.id === 'm-older')).toBe(false);
});

it('唤醒闩锁:事件到达时不在 idleSleep → 置闩,下一次 idleSleep 立即放行并消费', async () => {
const loop = new AgentLoop(mockRole, fileStore);
const seam = seamOf(loop);
seam.alive = true;
seam.lastActiveWuIds = new Set(['wu-1']);

seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: 'wu-1' } });
expect(seam.pendingWake).toBe(true);

const t0 = Date.now();
await seam.idleSleep(15_000); // 闩锁命中 → 不睡满 15s
expect(Date.now() - t0).toBeLessThan(1000);
expect(seam.pendingWake).toBe(false); // 已消费,不残留
});

it('闩锁不误置:非 human / 非 myActive / 无 workUnitId 的事件不置闩', () => {
const loop = new AgentLoop(mockRole, fileStore);
const seam = seamOf(loop);
seam.alive = true;
seam.lastActiveWuIds = new Set(['wu-1']);

seam.onChannelMessageSent({ message: { authorType: 'agent', workUnitId: 'wu-1' } });
seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: 'wu-stranger' } });
seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: null } });
seam.onChannelMessageSent({});
expect(seam.pendingWake).toBe(false);
});

it('无闩时 idleSleep 正常睡足(闩锁不改变无事件时的空闲调度)', async () => {
const loop = new AgentLoop(mockRole, fileStore);
const seam = seamOf(loop);
seam.alive = true;

const t0 = Date.now();
await seam.idleSleep(60);
expect(Date.now() - t0).toBeGreaterThanOrEqual(50);
expect(seam.pendingWake).toBe(false);
});
});
19 changes: 13 additions & 6 deletions apps/api/src/modules/agents/agent-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { randomUUID } from 'crypto';
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { eventBus, FileStore, parseChannels, stringifyChannels, type AgentProfileData } from '@dommaker/studio-shared';
import { eventBus, FileStore, parseChannels, stringifyChannels, type AgentProfileData, type ChannelData } from '@dommaker/studio-shared';
import { resolveDefaultProvider } from './default-provider.js';
import { summarizeRoleStates } from './agent-instance.service.js';

Expand Down Expand Up @@ -345,14 +345,21 @@ export class AgentProfileService {

await this.fileStore.deleteProfile(id);
// 清理 channel.members 中的悬空引用(channel.members 是成员关系唯一事实源)
// #497: 同步收敛 channel.routing 中指名该 profile 的档(归一化为 null,其他档不动)——
// 否则悬空指名此后每次派生单都触发 fallback 提醒,配置漂移无人修。
const channels = await this.fileStore.listChannels();
for (const ch of channels) {
const ids = parseChannels(ch.members);
if (ids.includes(id)) {
await this.fileStore.updateChannel(ch.id, {
members: JSON.stringify(ids.filter(m => m !== id)),
});
}
const memberHit = ids.includes(id);
const routing = ch.routing;
const routingHit = routing != null && Object.values(routing).some(v => v === id);
if (!memberHit && !routingHit) continue;
await this.fileStore.updateChannel(ch.id, {
...(memberHit ? { members: JSON.stringify(ids.filter(m => m !== id)) } : {}),
...(routingHit
? { routing: Object.fromEntries(Object.entries(routing!).map(([k, v]) => [k, v === id ? null : v])) as ChannelData['routing'] }
: {}),
});
}
// F1: notify AgentLoopRegistry (unmounts the loop)
eventBus.publish('agent-profile.deleted', { profileId: id });
Expand Down
35 changes: 27 additions & 8 deletions apps/api/src/modules/agents/loop/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ export class AgentLoop {
private lastActiveWuIds = new Set<string>();
/** #330:channel.message_sent 订阅句柄(start 挂、stop 退) */
private messageSentHandler: ((payload: { message?: { authorType?: string; workUnitId?: string | null } }) => void) | null = null;
/** #493:唤醒闩锁——事件到达时 loop 不在 idleSleep(执行中/步骤间)则置 true,
* 下一次 idleSleep 入口消费并立即放行重跑 observe,唤醒不再丢失(原 fire-and-forget 丢此类唤醒) */
private pendingWake = false;
private executor: Executor;
/** 2026-07 PMO-flow UX(§6-2):最后一次已发布的 instance 状态(SSE 去重——状态不变不发) */
private lastPublishedStatus: string | null = null;
Expand Down Expand Up @@ -284,13 +287,10 @@ export class AgentLoop {
// #330: 事件驱动唤醒——订阅 channel.message_sent(同进程 eventBus,先例 channel-review)。
// 人类消息且 workUnitId 命中当前 myActive 时打断空闲 sleep、立即跑一轮 observe;
// 窄过滤天然限流,不去抖。事件 fire-and-forget 无持久,15s 空闲兜底轮询保留
// (防未来跨进程写者与重启间隙——当前生产写消息路径全部经 channel-message.service 发事件)。
this.messageSentHandler = (payload) => {
const msg = payload?.message;
if (!msg || msg.authorType !== 'human' || !msg.workUnitId) return;
if (!this.lastActiveWuIds.has(msg.workUnitId)) return;
this.wakeIdle?.();
};
// (防未来跨进程写者与重启间隙——当前生产写消息路径全部经 channel-message.service 发事件;
// 重启间隙的回复由启动首轮 observe 经 newReplies >= 口径捞回,#493)。
// #493:过滤+闩锁逻辑收进 onChannelMessageSent(seam 可测)。
this.messageSentHandler = (payload) => this.onChannelMessageSent(payload);
eventBus.subscribe('channel.message_sent', this.messageSentHandler);

// Main loop (non-blocking — fire and forget like original)
Expand Down Expand Up @@ -658,11 +658,28 @@ export class AgentLoop {
}
}

/** #330/#493:channel.message_sent 唤醒过滤 + 闩锁(独立方法供测试 seam 直驱) */
private onChannelMessageSent(payload: { message?: { authorType?: string; workUnitId?: string | null } }): void {
const msg = payload?.message;
if (!msg || msg.authorType !== 'human' || !msg.workUnitId) return;
if (!this.lastActiveWuIds.has(msg.workUnitId)) return;
// #493:闩锁先行——不在 idleSleep(执行中/步骤间 sleep)时 wakeIdle 为 null,
// 唤醒原样会丢;置闩后下一次 idleSleep 入口消费并立即放行重跑 observe
this.pendingWake = true;
this.wakeIdle?.();
}

/** #330: 可中断的空闲 sleep——channel.message_sent 命中 myActive(或 stop)时提前返回 */
private idleSleep(ms: number): Promise<void> {
if (!this.alive) return Promise.resolve(); // stop 与进入 sleep 的竞态:已停则立即返回
// #493:消费唤醒闩锁——挂起期间有唤醒意图未送达,不睡直接重跑一轮 observe
if (this.pendingWake) {
this.pendingWake = false;
return Promise.resolve();
}
return new Promise(resolve => {
const wake = () => {
this.pendingWake = false; // #493:打断即消费——紧随的 observe 已覆盖本次唤醒意图
clearTimeout(timer);
if (this.wakeIdle === wake) this.wakeIdle = null;
resolve();
Expand Down Expand Up @@ -769,7 +786,9 @@ export class AgentLoop {
channelIds: channelFilter,
})).filter(msg => {
const wu = myActive.find(w => w.id === msg.workUnitId);
return wu && new Date(msg.createdAt).getTime() > wu.updatedAt.getTime();
// #493:>= 同毫秒边界——回复与 WU 簿记同毫秒落盘(含重启间隙首扫捞回)不再漏检;
// 不误循环:回复被消费后 recordResult 簿记推进 updatedAt,下一轮自然越界
return wu && new Date(msg.createdAt).getTime() >= wu.updatedAt.getTime();
})
: [];

Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/modules/agents/loop/review-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { MANUAL_GATE_TYPES } from '../../workunit/workunit.types.js';
import { readCollab } from '../../workunit/delegation-gate.js';
import { postWuSystemMessage } from '../../workunit/wu-messenger.js';
import { parseWuMetadata, clearSessionBookkeeping } from '../../workunit/wu-metadata.js';
import { resolveStageRouting, routingFallbackText } from '../../channels/routing.js';
import { resolveStageRouting, routingFallbackText, shouldEmitFallbackReminder } from '../../channels/routing.js';
import type { ParsedReviewReport } from './review-contract.js';

export class ReviewDispatcher {
Expand Down Expand Up @@ -169,7 +169,10 @@ export class ReviewDispatcher {
pinnedReviewer = routing.profileId;
}
} else if (routing.fallback) {
routingNotice = routingFallbackText('review', routing);
// #497: 同频道同档同原因冷却窗内不重复出声(建单回池不受影响)
routingNotice = shouldEmitFallbackReminder(parent.channelId!, 'review', routing)
? routingFallbackText('review', routing)
: null;
}

const eligible = members?.filter(p => p.id !== implementerId) ?? null;
Expand Down
Loading
Loading