@yancyyu/openhermit 1.6.28 → 1.6.29

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 (96) hide show
  1. package/dist-renderer/assets/ProjectEditorOverlay-CQm6jUR1.js +52 -0
  2. package/dist-renderer/assets/{TeamGraphOverlay-Ba5njic5.js → TeamGraphOverlay-h0WDfifv.js} +1 -1
  3. package/dist-renderer/assets/{_basePickBy-BvnK-OC1.js → _basePickBy-CgG_tjgX.js} +1 -1
  4. package/dist-renderer/assets/{_baseUniq-DmFYXx9G.js → _baseUniq-DwPTU9lP.js} +1 -1
  5. package/dist-renderer/assets/{arc-DX4ZQFY4.js → arc-7nIrGRzY.js} +1 -1
  6. package/dist-renderer/assets/{architectureDiagram-VXUJARFQ-DfYr3vEN.js → architectureDiagram-VXUJARFQ-BYhA6Ev2.js} +1 -1
  7. package/dist-renderer/assets/{blockDiagram-VD42YOAC-DuXdVeWn.js → blockDiagram-VD42YOAC-BVpZUGDg.js} +1 -1
  8. package/dist-renderer/assets/{c4Diagram-YG6GDRKO-Bw2nixXe.js → c4Diagram-YG6GDRKO-DsdreMQ9.js} +1 -1
  9. package/dist-renderer/assets/channel-C0SqeFU7.js +1 -0
  10. package/dist-renderer/assets/{chunk-4BX2VUAB-DLiNGQoE.js → chunk-4BX2VUAB-CcoAs7Jd.js} +1 -1
  11. package/dist-renderer/assets/{chunk-55IACEB6-B1L_8VIF.js → chunk-55IACEB6-CGGAOoXd.js} +1 -1
  12. package/dist-renderer/assets/{chunk-B4BG7PRW-DaZMWKGk.js → chunk-B4BG7PRW-FhpTEPvD.js} +1 -1
  13. package/dist-renderer/assets/{chunk-DI55MBZ5-ku-dflJG.js → chunk-DI55MBZ5-DoYySbm1.js} +1 -1
  14. package/dist-renderer/assets/{chunk-FMBD7UC4-DV-mF1dP.js → chunk-FMBD7UC4-e9l2tGHG.js} +1 -1
  15. package/dist-renderer/assets/{chunk-QN33PNHL-ByGcDFQ0.js → chunk-QN33PNHL-DeiXVTCy.js} +1 -1
  16. package/dist-renderer/assets/{chunk-QZHKN3VN-7dv-Min8.js → chunk-QZHKN3VN-DC2UJLJM.js} +1 -1
  17. package/dist-renderer/assets/{chunk-TZMSLE5B-WdXL5fTu.js → chunk-TZMSLE5B-BHFD9eZI.js} +1 -1
  18. package/dist-renderer/assets/classDiagram-2ON5EDUG-DWew1HpM.js +1 -0
  19. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-DWew1HpM.js +1 -0
  20. package/dist-renderer/assets/clone-Dm-k63Yr.js +1 -0
  21. package/dist-renderer/assets/{cose-bilkent-S5V4N54A-CNcsvqPl.js → cose-bilkent-S5V4N54A-BdybQraU.js} +1 -1
  22. package/dist-renderer/assets/{dagre-6UL2VRFP-DBNx4qqx.js → dagre-6UL2VRFP-DdF3pwM3.js} +1 -1
  23. package/dist-renderer/assets/{diagram-PSM6KHXK-BfVlT6sT.js → diagram-PSM6KHXK-B9Ldd3nh.js} +1 -1
  24. package/dist-renderer/assets/{diagram-QEK2KX5R-HvVjs0K6.js → diagram-QEK2KX5R-XEqkrbpu.js} +1 -1
  25. package/dist-renderer/assets/{diagram-S2PKOQOG-DYb_KnWS.js → diagram-S2PKOQOG-CipwtY59.js} +1 -1
  26. package/dist-renderer/assets/{erDiagram-Q2GNP2WA-Ba-IgI5G.js → erDiagram-Q2GNP2WA-BB-2ISGo.js} +1 -1
  27. package/dist-renderer/assets/{flowDiagram-NV44I4VS-2iDN8Kpj.js → flowDiagram-NV44I4VS-B8XmJ0u2.js} +1 -1
  28. package/dist-renderer/assets/{ganttDiagram-JELNMOA3-Byjf8Fa3.js → ganttDiagram-JELNMOA3-D-8XglBb.js} +1 -1
  29. package/dist-renderer/assets/{gitGraphDiagram-V2S2FVAM-DbKvfZ_j.js → gitGraphDiagram-V2S2FVAM-DL4ChakD.js} +1 -1
  30. package/dist-renderer/assets/{graph-Enirf-f8.js → graph-BiFNoBjP.js} +1 -1
  31. package/dist-renderer/assets/{index-AjxP_rE_.js → index-6m1ZAymG.js} +1 -1
  32. package/dist-renderer/assets/index-BhellmRb.css +1 -0
  33. package/dist-renderer/assets/{index-DY1zqsb6.js → index-BowUl0Jb.js} +540 -536
  34. package/dist-renderer/assets/{index-CtlzGepK.js → index-Dp3kJTEe.js} +1 -1
  35. package/dist-renderer/assets/{index-COZPUWJW.js → index-TOpt_T7A.js} +1 -1
  36. package/dist-renderer/assets/{index-DdhqolqE.js → index-qNBNjW4K.js} +1 -1
  37. package/dist-renderer/assets/{index-ChR1D6ZF.js → index-vAykq1H1.js} +1 -1
  38. package/dist-renderer/assets/{infoDiagram-HS3SLOUP-D6uicwz1.js → infoDiagram-HS3SLOUP-DRIBfHDi.js} +1 -1
  39. package/dist-renderer/assets/{journeyDiagram-XKPGCS4Q-DqwZsXlQ.js → journeyDiagram-XKPGCS4Q-BOMiigU4.js} +1 -1
  40. package/dist-renderer/assets/{kanban-definition-3W4ZIXB7-fCDVhVUm.js → kanban-definition-3W4ZIXB7-DDxeyjod.js} +1 -1
  41. package/dist-renderer/assets/{layout-CPFgj98r.js → layout-DNANbrI4.js} +1 -1
  42. package/dist-renderer/assets/{linear-CYiQ7Y3M.js → linear-DxEJi1yT.js} +1 -1
  43. package/dist-renderer/assets/{mindmap-definition-VGOIOE7T-D31dS2KE.js → mindmap-definition-VGOIOE7T-nBfGriW8.js} +1 -1
  44. package/dist-renderer/assets/{pieDiagram-ADFJNKIX-BOsCJfds.js → pieDiagram-ADFJNKIX-Din5j6sV.js} +1 -1
  45. package/dist-renderer/assets/{quadrantDiagram-AYHSOK5B-CYTVQCfr.js → quadrantDiagram-AYHSOK5B-DMVK2BEQ.js} +1 -1
  46. package/dist-renderer/assets/{requirementDiagram-UZGBJVZJ-CODCFpkt.js → requirementDiagram-UZGBJVZJ-6SC94Gg_.js} +1 -1
  47. package/dist-renderer/assets/{sankeyDiagram-TZEHDZUN-Z4ce9ZtZ.js → sankeyDiagram-TZEHDZUN-CD2gghhu.js} +1 -1
  48. package/dist-renderer/assets/{sequenceDiagram-WL72ISMW-CmS9TxhW.js → sequenceDiagram-WL72ISMW-BnhkN7nZ.js} +1 -1
  49. package/dist-renderer/assets/{stateDiagram-FKZM4ZOC-o9k-ns3q.js → stateDiagram-FKZM4ZOC-Bn8XdYX-.js} +1 -1
  50. package/dist-renderer/assets/{stateDiagram-v2-4FDKWEC3-CxHMyEt1.js → stateDiagram-v2-4FDKWEC3-1b6sI1_g.js} +1 -1
  51. package/dist-renderer/assets/{timeline-definition-IT6M3QCI-B6T3zrde.js → timeline-definition-IT6M3QCI-CNs3RPoa.js} +1 -1
  52. package/dist-renderer/assets/treemap-GDKQZRPO-DU_yr827.js +162 -0
  53. package/dist-renderer/assets/{xychartDiagram-PRI3JC2R-CleBrdqc.js → xychartDiagram-PRI3JC2R-B8o5J2f3.js} +1 -1
  54. package/dist-renderer/index.html +2 -2
  55. package/package.json +1 -1
  56. package/src/main/server.ts +699 -179
  57. package/src/main/services/session-intelligence/UsageTelemetryService.ts +33 -18
  58. package/src/main/services/teams-mvp/CollaborationBoardService.ts +310 -0
  59. package/src/main/services/teams-mvp/TaskDispatchService.ts +880 -95
  60. package/src/main/services/teams-mvp/TeamProvisioningService.ts +58 -19
  61. package/src/main/services/teams-mvp/TeamWorkspaceService.ts +25 -2
  62. package/src/main/services/teams-mvp/index.ts +3 -0
  63. package/src/renderer/App.tsx +5 -0
  64. package/src/renderer/api/httpClient.ts +67 -0
  65. package/src/renderer/components/layout/PaneContent.tsx +2 -0
  66. package/src/renderer/components/layout/SortableTab.tsx +1 -0
  67. package/src/renderer/components/layout/TabBarActions.tsx +12 -12
  68. package/src/renderer/components/schedules/SchedulesView.tsx +54 -22
  69. package/src/renderer/components/settings/sections/AdvancedSection.tsx +1 -1
  70. package/src/renderer/components/settings/sections/TaskBusSection.tsx +129 -79
  71. package/src/renderer/components/tasks/TasksView.tsx +343 -0
  72. package/src/renderer/components/team/TeamDetailView.tsx +20 -98
  73. package/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +1 -1
  74. package/src/renderer/components/team/editor/EditorContextMenu.tsx +8 -23
  75. package/src/renderer/components/team/editor/EditorFileTree.tsx +0 -4
  76. package/src/renderer/components/team/editor/EditorSelectionMenu.tsx +1 -8
  77. package/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +0 -10
  78. package/src/renderer/components/team/kanban/KanbanBoard.tsx +5 -1
  79. package/src/renderer/components/team/members/MemberDetailDialog.tsx +8 -33
  80. package/src/renderer/components/team/messages/MessageComposer.tsx +39 -3
  81. package/src/renderer/components/team/messages/MessagesPanel.tsx +72 -2
  82. package/src/renderer/components/team/messages/StatusBlock.tsx +2 -24
  83. package/src/renderer/components/team/schedule/ScheduleEmptyState.tsx +1 -1
  84. package/src/renderer/components/ui/MentionableTextarea.tsx +0 -1
  85. package/src/renderer/store/slices/scheduleSlice.ts +21 -0
  86. package/src/renderer/store/slices/teamSlice.ts +59 -23
  87. package/src/renderer/types/tabs.ts +1 -0
  88. package/src/shared/types/api.ts +29 -0
  89. package/src/shared/types/team.ts +104 -1
  90. package/dist-renderer/assets/ProjectEditorOverlay-A4DZTvSy.js +0 -57
  91. package/dist-renderer/assets/channel-Pre42N5O.js +0 -1
  92. package/dist-renderer/assets/classDiagram-2ON5EDUG-CdJsTJsj.js +0 -1
  93. package/dist-renderer/assets/classDiagram-v2-WZHVMYZB-CdJsTJsj.js +0 -1
  94. package/dist-renderer/assets/clone-BjQBiNfj.js +0 -1
  95. package/dist-renderer/assets/index-BIOJremZ.css +0 -1
  96. package/dist-renderer/assets/treemap-GDKQZRPO-CVd5GNDw.js +0 -162
@@ -1,11 +1,15 @@
1
1
  import type {
2
+ AgentCapability,
3
+ CollabTask,
2
4
  DiscoverableTeam,
3
5
  DispatchMeta,
4
6
  TaskBusConfig,
5
7
  TaskDispatchPayload,
8
+ TaskHandshakeResponse,
6
9
  TaskStatusUpdate,
7
10
  } from '@shared/types/team';
8
11
  import type { TeamWorkspaceService, TeamManifest } from './TeamWorkspaceService';
12
+ import type { CollaborationBoardService } from './CollaborationBoardService';
9
13
  import type Redis from 'ioredis';
10
14
 
11
15
  const DISPATCH_RULES_DEFAULT = `When to dispatch a task to another team:
@@ -19,6 +23,14 @@ Do NOT dispatch:
19
23
  - Task can be completed with available tools
20
24
  - Task is a small change (< estimated 5 min)`;
21
25
 
26
+ interface PendingRequest {
27
+ payload: TaskDispatchPayload;
28
+ msgId: string;
29
+ groupName: string;
30
+ teamSlug: string;
31
+ localTaskId?: string;
32
+ }
33
+
22
34
  export interface DispatchResult {
23
35
  dispatchId: string;
24
36
  status: DispatchMeta['status'];
@@ -28,15 +40,23 @@ export interface DispatchResult {
28
40
 
29
41
  export class TaskDispatchService {
30
42
  private workspace: TeamWorkspaceService;
43
+ private collabBoard: CollaborationBoardService;
31
44
  private config: TaskBusConfig | null = null;
32
45
  private redis: Redis | null = null;
33
46
  private redisSub: Redis | null = null;
34
47
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
35
48
  private consumerTimers: ReturnType<typeof setInterval>[] = [];
49
+ private responseConsumerTimers: ReturnType<typeof setInterval>[] = [];
50
+ private consumerTeamSlugs = new Set<string>();
51
+ private responseConsumerTeamSlugs = new Set<string>();
36
52
  private disposed = false;
53
+ private pendingRequests: Map<string, PendingRequest> = new Map();
54
+ /** Callback fired when collab task state changes (for SSE broadcast). */
55
+ onCollabChange?: (dispatchId: string, status: string, fromTeam: string, toTeam: string) => void;
37
56
 
38
- constructor(workspace: TeamWorkspaceService) {
57
+ constructor(workspace: TeamWorkspaceService, collabBoard: CollaborationBoardService) {
39
58
  this.workspace = workspace;
59
+ this.collabBoard = collabBoard;
40
60
  }
41
61
 
42
62
  get dispatchRulesText(): string {
@@ -44,6 +64,7 @@ export class TaskDispatchService {
44
64
  }
45
65
 
46
66
  async start(config?: TaskBusConfig): Promise<void> {
67
+ this.disposed = false;
47
68
  this.config = config ?? null;
48
69
  if (config?.enabled && config.redis) {
49
70
  await this.connectRedis();
@@ -54,6 +75,7 @@ export class TaskDispatchService {
54
75
  this.disposed = true;
55
76
  this.stopHeartbeat();
56
77
  this.stopConsumers();
78
+ this.stopResponseConsumers();
57
79
  this.redis?.disconnect();
58
80
  this.redisSub?.disconnect();
59
81
  this.redis = null;
@@ -63,9 +85,12 @@ export class TaskDispatchService {
63
85
  // ── Agent-facing ──────────────────────────────────────────────
64
86
 
65
87
  async listTeams(): Promise<DiscoverableTeam[]> {
88
+ return this.discoverTeams();
89
+ }
90
+
91
+ async discoverTeams(): Promise<DiscoverableTeam[]> {
66
92
  const teams: DiscoverableTeam[] = [];
67
93
 
68
- // Local teams
69
94
  const localTeams = await this.workspace.listTeams();
70
95
  for (const team of localTeams) {
71
96
  teams.push({
@@ -74,26 +99,44 @@ export class TaskDispatchService {
74
99
  location: 'local',
75
100
  status: 'online',
76
101
  collaboration: team.collaboration !== false,
102
+ description: team.description,
103
+ harness: team.harness,
104
+ capabilities: this.inferCapabilities(team),
77
105
  });
78
106
  }
79
107
 
80
- // Remote teams (via Redis)
81
108
  if (this.redis) {
82
109
  try {
83
110
  const now = Date.now();
84
- const staleThreshold = 90_000; // 90s
111
+ const staleThreshold = 90_000;
85
112
  const entries = await this.redis.zrange('task:teams', 0, -1, 'WITHSCORES');
86
113
  const localSlugs = new Set(teams.map((t) => t.slug));
87
114
  for (let i = 0; i < entries.length; i += 2) {
88
115
  const slug = entries[i] as string;
89
116
  const ts = Number(entries[i + 1]);
90
117
  if (localSlugs.has(slug)) continue;
118
+ const isOnline = now - ts < staleThreshold;
119
+
120
+ let info: Record<string, string> | null = null;
121
+ try {
122
+ info = (await this.redis!.hgetall(`task:team:info:${slug}`)) as Record<string, string>;
123
+ } catch {
124
+ /* degraded */
125
+ }
126
+
127
+ const capabilities = info?.capabilities
128
+ ? (JSON.parse(info.capabilities) as AgentCapability[])
129
+ : undefined;
130
+
91
131
  teams.push({
92
132
  slug,
93
- displayName: slug,
133
+ displayName: info?.displayName ?? slug,
94
134
  location: 'remote',
95
- status: now - ts < staleThreshold ? 'online' : 'offline',
96
- collaboration: true,
135
+ status: isOnline ? 'online' : 'offline',
136
+ collaboration: info?.collaboration !== 'false',
137
+ description: info?.description || undefined,
138
+ harness: info?.harness || undefined,
139
+ capabilities,
97
140
  });
98
141
  }
99
142
  } catch {
@@ -107,7 +150,8 @@ export class TaskDispatchService {
107
150
  async dispatchTask(
108
151
  fromTeam: string,
109
152
  task: { subject: string; description?: string; prompt?: string },
110
- targetTeam: string
153
+ targetTeam: string,
154
+ opts?: { deadlineMinutes?: number; needsHumanReview?: boolean }
111
155
  ): Promise<DispatchResult> {
112
156
  if (fromTeam === targetTeam) {
113
157
  return {
@@ -119,35 +163,476 @@ export class TaskDispatchService {
119
163
  }
120
164
 
121
165
  const dispatchId = crypto.randomUUID();
166
+ const now = new Date();
167
+ const deadline = opts?.deadlineMinutes
168
+ ? new Date(now.getTime() + opts.deadlineMinutes * 60_000).toISOString()
169
+ : undefined;
170
+
122
171
  const dispatchMeta: DispatchMeta = {
123
172
  dispatchId,
124
173
  originTeam: fromTeam,
125
174
  targetTeam,
126
- status: 'dispatched',
127
- dispatchedAt: new Date().toISOString(),
175
+ status: 'pending_accept',
176
+ dispatchedAt: now.toISOString(),
177
+ deadline,
128
178
  };
129
179
 
130
- // Route: local or remote
131
- const isLocal = await this.isLocalTeam(targetTeam);
132
- if (isLocal) {
133
- await this.handleLocalDispatch(dispatchMeta, task);
134
- } else if (this.redis) {
135
- await this.handleRemoteDispatch(dispatchMeta, task);
136
- } else {
180
+ // Add to collaboration board before external delivery. Even failed dispatches
181
+ // must remain visible in the canonical task projection for diagnosis/retry.
182
+ const fromTeamManifest = await this.safeReadManifest(fromTeam);
183
+ const toTeamManifest = await this.safeReadManifest(targetTeam);
184
+ const collabTask: CollabTask = {
185
+ id: dispatchId,
186
+ dispatchId,
187
+ subject: task.subject,
188
+ description: task.description,
189
+ fromTeam,
190
+ fromTeamDisplay: fromTeamManifest?.displayName ?? fromTeam,
191
+ toTeam: targetTeam,
192
+ toTeamDisplay: toTeamManifest?.displayName ?? targetTeam,
193
+ status: 'pending_accept',
194
+ deadline,
195
+ needsHumanReview: opts?.needsHumanReview ?? false,
196
+ revisionCount: 0,
197
+ createdAt: now.toISOString(),
198
+ updatedAt: now.toISOString(),
199
+ };
200
+ this.collabBoard.addTask(collabTask);
201
+
202
+ // Route: all dispatches require Redis
203
+ if (!this.redis) {
204
+ const failedTask = this.collabBoard.transition({
205
+ dispatchId,
206
+ expected: 'pending_accept',
207
+ next: 'failed',
208
+ actor: { type: 'system', id: 'task-dispatch' },
209
+ eventType: 'task_failed',
210
+ payload: { reason: 'Redis not configured' },
211
+ extra: { reason: 'Redis not configured — cross-team dispatch requires task bus.' },
212
+ });
213
+ this.emitCollabChange(dispatchId, failedTask.status, fromTeam, targetTeam);
137
214
  return {
138
215
  dispatchId,
139
216
  status: 'failed',
140
217
  targetTeam,
141
- message: 'Redis not configured — remote dispatch unavailable.',
218
+ message: 'Redis not configured — cross-team dispatch requires task bus.',
142
219
  };
143
220
  }
144
221
 
222
+ try {
223
+ await this.handleRedisDispatch(dispatchMeta, task, opts?.needsHumanReview);
224
+ } catch (err) {
225
+ this.pendingRequests.delete(dispatchId);
226
+ const reason = err instanceof Error ? err.message : 'Unknown Redis dispatch failure';
227
+ const failedTask = this.collabBoard.transition({
228
+ dispatchId,
229
+ expected: 'pending_accept',
230
+ next: 'failed',
231
+ actor: { type: 'system', id: 'task-dispatch' },
232
+ eventType: 'task_failed',
233
+ payload: { reason },
234
+ extra: { reason },
235
+ });
236
+ this.emitCollabChange(dispatchId, failedTask.status, fromTeam, targetTeam);
237
+ return {
238
+ dispatchId,
239
+ status: 'failed',
240
+ targetTeam,
241
+ message: `Task dispatch failed: ${reason}`,
242
+ };
243
+ }
244
+
245
+ this.emitCollabChange(dispatchId, 'pending_accept', fromTeam, targetTeam);
246
+
145
247
  return {
146
248
  dispatchId,
147
- status: 'dispatched',
249
+ status: 'pending_accept',
148
250
  targetTeam,
149
- message: `Task dispatched to ${targetTeam}`,
251
+ message: `Task dispatched to ${targetTeam}, awaiting acceptance.`,
252
+ };
253
+ }
254
+
255
+ async acceptTask(teamSlug: string, dispatchId: string): Promise<{ taskId: string }> {
256
+ const pending = this.pendingRequests.get(dispatchId);
257
+ if (!pending) {
258
+ throw new Error(`No pending request found for dispatchId: ${dispatchId}`);
259
+ }
260
+
261
+ const { payload, msgId, groupName, localTaskId } = pending;
262
+
263
+ const remoteTaskId = localTaskId ?? payload.dispatchId;
264
+
265
+ // Send accept response
266
+ const response: TaskHandshakeResponse = {
267
+ dispatchId: payload.dispatchId,
268
+ type: 'task_accept',
269
+ fromTeam: teamSlug,
270
+ toTeam: payload.originTeam,
271
+ remoteTaskId,
272
+ acceptedAt: new Date().toISOString(),
273
+ };
274
+
275
+ const isLocalOrigin = await this.isLocalTeam(payload.originTeam);
276
+ if (isLocalOrigin) {
277
+ await this.handleLocalResponse(response);
278
+ } else if (this.redis) {
279
+ await this.redis
280
+ .xadd(`task:response:${payload.originTeam}`, '*', 'payload', JSON.stringify(response))
281
+ .catch((err: Error) => {
282
+ console.error('[TaskDispatchService] accept xadd failed:', err.message);
283
+ });
284
+ }
285
+
286
+ if (this.redis) {
287
+ await this.redis.xack(`task:dispatch:${teamSlug}`, groupName, msgId).catch(() => {});
288
+ }
289
+
290
+ this.pendingRequests.delete(dispatchId);
291
+
292
+ // Update collab board
293
+ const acceptedAt = new Date().toISOString();
294
+ this.collabBoard.transition({
295
+ dispatchId: payload.dispatchId,
296
+ expected: 'pending_accept',
297
+ next: 'accepted',
298
+ actor: { type: 'team', id: teamSlug },
299
+ eventType: 'task_accepted',
300
+ payload: { remoteTaskId },
301
+ extra: { acceptedAt },
302
+ });
303
+ this.emitCollabChange(payload.dispatchId, 'accepted', payload.originTeam, payload.targetTeam);
304
+
305
+ // Fixed flow: notify receiving agent to start executing
306
+ try {
307
+ await this.workspace.appendMessage(teamSlug, {
308
+ from: 'system',
309
+ to: 'team',
310
+ role: 'agent',
311
+ content: `[跨团队任务已确认] "${payload.task.subject}" — 来自 ${payload.originTeam} 的任务已被人工确认接单,请开始执行。任务描述:${payload.task.description ?? '无'}`,
312
+ meta: {
313
+ source: 'cross_team_accepted',
314
+ dispatchId: payload.dispatchId,
315
+ originTeam: payload.originTeam,
316
+ taskId: remoteTaskId,
317
+ },
318
+ });
319
+ } catch {}
320
+
321
+ // Fixed flow: notify originating agent that task was accepted
322
+ try {
323
+ await this.workspace.appendMessage(payload.originTeam, {
324
+ from: 'system',
325
+ to: 'team',
326
+ role: 'agent',
327
+ content: `[跨团队任务已接单] "${payload.task.subject}" — ${teamSlug} 已确认接单,正在执行中。`,
328
+ meta: {
329
+ source: 'cross_team_accepted_notify',
330
+ dispatchId: payload.dispatchId,
331
+ targetTeam: teamSlug,
332
+ },
333
+ });
334
+ } catch {}
335
+
336
+ // Feishu notification
337
+ this.sendFeishuNotification(
338
+ `跨团队任务已接单:${payload.originTeam} → ${teamSlug}\n${payload.task.subject}\n状态:执行中`
339
+ );
340
+
341
+ return { taskId: remoteTaskId };
342
+ }
343
+
344
+ async rejectTask(teamSlug: string, dispatchId: string, reason?: string): Promise<void> {
345
+ const pending = this.pendingRequests.get(dispatchId);
346
+ if (!pending) {
347
+ throw new Error(`No pending request found for dispatchId: ${dispatchId}`);
348
+ }
349
+
350
+ const { payload, msgId, groupName } = pending;
351
+
352
+ const response: TaskHandshakeResponse = {
353
+ dispatchId: payload.dispatchId,
354
+ type: 'task_reject',
355
+ fromTeam: teamSlug,
356
+ toTeam: payload.originTeam,
357
+ reason,
358
+ rejectedAt: new Date().toISOString(),
359
+ };
360
+
361
+ const isLocalOrigin = await this.isLocalTeam(payload.originTeam);
362
+ if (isLocalOrigin) {
363
+ await this.handleLocalResponse(response);
364
+ } else if (this.redis) {
365
+ await this.redis
366
+ .xadd(`task:response:${payload.originTeam}`, '*', 'payload', JSON.stringify(response))
367
+ .catch((err: Error) => {
368
+ console.error('[TaskDispatchService] reject xadd failed:', err.message);
369
+ });
370
+ }
371
+
372
+ if (this.redis) {
373
+ await this.redis.xack(`task:dispatch:${teamSlug}`, groupName, msgId).catch(() => {});
374
+ }
375
+
376
+ this.pendingRequests.delete(dispatchId);
377
+
378
+ // Update collab board
379
+ this.collabBoard.transition({
380
+ dispatchId: payload.dispatchId,
381
+ expected: 'pending_accept',
382
+ next: 'rejected',
383
+ actor: { type: 'team', id: teamSlug },
384
+ eventType: 'task_rejected',
385
+ payload: { reason },
386
+ extra: { reason, rejectedAt: response.rejectedAt },
387
+ });
388
+ this.emitCollabChange(payload.dispatchId, 'rejected', payload.originTeam, payload.targetTeam);
389
+
390
+ // Fixed flow: notify originating agent that task was rejected
391
+ try {
392
+ await this.workspace.appendMessage(payload.originTeam, {
393
+ from: 'system',
394
+ to: 'team',
395
+ role: 'agent',
396
+ content: `[跨团队任务被拒绝] "${payload.task.subject}" — ${teamSlug} 拒绝了此任务。原因:${reason ?? '未说明'}`,
397
+ meta: {
398
+ source: 'cross_team_rejected',
399
+ dispatchId: payload.dispatchId,
400
+ targetTeam: teamSlug,
401
+ rejectReason: reason,
402
+ },
403
+ });
404
+ } catch {}
405
+
406
+ // Feishu notification
407
+ this.sendFeishuNotification(
408
+ `跨团队任务被拒绝:${payload.originTeam} → ${teamSlug}\n${payload.task.subject}\n原因:${reason ?? '未说明'}`
409
+ );
410
+ }
411
+
412
+ // ── Deliver / Approve / Revision ────────────────────────────────
413
+
414
+ async deliverTask(
415
+ teamSlug: string,
416
+ dispatchId: string,
417
+ result: string
418
+ ): Promise<{ ok: boolean }> {
419
+ const collabTask = this.collabBoard.getTask(dispatchId);
420
+ if (!collabTask) {
421
+ throw new Error(`No collab task found for dispatchId: ${dispatchId}`);
422
+ }
423
+
424
+ const deliveredAt = new Date().toISOString();
425
+
426
+ // Send deliver response to origin team
427
+ const response: TaskHandshakeResponse = {
428
+ dispatchId,
429
+ type: 'task_deliver',
430
+ fromTeam: teamSlug,
431
+ toTeam: collabTask.fromTeam,
432
+ result,
433
+ deliveredAt,
150
434
  };
435
+
436
+ const isLocalOrigin = await this.isLocalTeam(collabTask.fromTeam);
437
+ if (isLocalOrigin) {
438
+ await this.handleLocalResponse(response);
439
+ } else if (this.redis) {
440
+ await this.redis
441
+ .xadd(`task:response:${collabTask.fromTeam}`, '*', 'payload', JSON.stringify(response))
442
+ .catch((err: Error) => {
443
+ console.error('[TaskDispatchService] deliver xadd failed:', err.message);
444
+ });
445
+ }
446
+
447
+ // Update local collab board
448
+ const deliveredTask = this.collabBoard.transition({
449
+ dispatchId,
450
+ expected: ['accepted', 'revision'],
451
+ next: 'delivered',
452
+ actor: { type: 'team', id: teamSlug },
453
+ eventType: 'task_delivered',
454
+ payload: { summary: result.slice(0, 1000) },
455
+ extra: { result, deliveredAt },
456
+ });
457
+ this.emitCollabChange(dispatchId, 'delivered', deliveredTask.fromTeam, deliveredTask.toTeam);
458
+
459
+ // Notify origin agent: task is ready for review
460
+ try {
461
+ await this.workspace.appendMessage(collabTask.fromTeam, {
462
+ from: 'system',
463
+ to: 'team',
464
+ role: 'agent',
465
+ content: `[跨团队任务待审核] "${collabTask.subject}" — ${teamSlug} 已完成任务并提交交付结果,请审核。结果:${result}`,
466
+ meta: {
467
+ source: 'cross_team_delivered',
468
+ dispatchId,
469
+ targetTeam: teamSlug,
470
+ result,
471
+ },
472
+ });
473
+ } catch {}
474
+
475
+ this.sendFeishuNotification(
476
+ `跨团队任务待审核:${collabTask.fromTeam} ← ${teamSlug}\n${collabTask.subject}\n状态:待审核`
477
+ );
478
+
479
+ return { ok: true };
480
+ }
481
+
482
+ async approveTask(teamSlug: string, dispatchId: string): Promise<{ ok: boolean }> {
483
+ const collabTask = this.collabBoard.getTask(dispatchId);
484
+ if (!collabTask) {
485
+ throw new Error(`No collab task found for dispatchId: ${dispatchId}`);
486
+ }
487
+
488
+ const approvedAt = new Date().toISOString();
489
+
490
+ // Send approve response to target team
491
+ const response: TaskHandshakeResponse = {
492
+ dispatchId,
493
+ type: 'task_approve',
494
+ fromTeam: teamSlug,
495
+ toTeam: collabTask.toTeam,
496
+ approvedAt,
497
+ };
498
+
499
+ const isLocalTarget = await this.isLocalTeam(collabTask.toTeam);
500
+ if (isLocalTarget) {
501
+ await this.handleLocalResponse(response);
502
+ } else if (this.redis) {
503
+ await this.redis
504
+ .xadd(`task:response:${collabTask.toTeam}`, '*', 'payload', JSON.stringify(response))
505
+ .catch((err: Error) => {
506
+ console.error('[TaskDispatchService] approve xadd failed:', err.message);
507
+ });
508
+ }
509
+
510
+ // Update collab board
511
+ const approvedTask = this.collabBoard.transition({
512
+ dispatchId,
513
+ expected: 'delivered',
514
+ next: 'approved',
515
+ actor: { type: 'team', id: teamSlug },
516
+ eventType: 'task_approved',
517
+ payload: { approvedAt },
518
+ extra: { approvedAt },
519
+ });
520
+ this.emitCollabChange(dispatchId, 'approved', approvedTask.fromTeam, approvedTask.toTeam);
521
+
522
+ // Notify origin agent: task is fully complete, you can continue
523
+ try {
524
+ await this.workspace.appendMessage(collabTask.fromTeam, {
525
+ from: 'system',
526
+ to: 'team',
527
+ role: 'agent',
528
+ content: `[跨团队任务已完成] "${collabTask.subject}" — ${collabTask.toTeam} 的交付已通过审核,此跨团队任务结束。`,
529
+ meta: {
530
+ source: 'cross_team_approved',
531
+ dispatchId,
532
+ targetTeam: collabTask.toTeam,
533
+ },
534
+ });
535
+ } catch {}
536
+
537
+ // Notify target agent: approved
538
+ try {
539
+ await this.workspace.appendMessage(collabTask.toTeam, {
540
+ from: 'system',
541
+ to: 'team',
542
+ role: 'agent',
543
+ content: `[跨团队任务审核通过] "${collabTask.subject}" — ${teamSlug} 已通过审核,任务完成。`,
544
+ meta: {
545
+ source: 'cross_team_approved_target',
546
+ dispatchId,
547
+ originTeam: teamSlug,
548
+ },
549
+ });
550
+ } catch {}
551
+
552
+ this.sendFeishuNotification(
553
+ `跨团队任务完成:${collabTask.fromTeam} ← ${collabTask.toTeam}\n${collabTask.subject}\n状态:已完成`
554
+ );
555
+
556
+ return { ok: true };
557
+ }
558
+
559
+ async rejectResult(
560
+ teamSlug: string,
561
+ dispatchId: string,
562
+ feedback: string
563
+ ): Promise<{ ok: boolean }> {
564
+ const collabTask = this.collabBoard.getTask(dispatchId);
565
+ if (!collabTask) {
566
+ throw new Error(`No collab task found for dispatchId: ${dispatchId}`);
567
+ }
568
+
569
+ const newRevisionCount = collabTask.revisionCount + 1;
570
+
571
+ // Send revision response to target team
572
+ const response: TaskHandshakeResponse = {
573
+ dispatchId,
574
+ type: 'task_revision',
575
+ fromTeam: teamSlug,
576
+ toTeam: collabTask.toTeam,
577
+ feedback,
578
+ };
579
+
580
+ const isLocalTarget = await this.isLocalTeam(collabTask.toTeam);
581
+ if (isLocalTarget) {
582
+ await this.handleLocalResponse(response);
583
+ } else if (this.redis) {
584
+ await this.redis
585
+ .xadd(`task:response:${collabTask.toTeam}`, '*', 'payload', JSON.stringify(response))
586
+ .catch((err: Error) => {
587
+ console.error('[TaskDispatchService] revision xadd failed:', err.message);
588
+ });
589
+ }
590
+
591
+ // Update collab board
592
+ const revisionTask = this.collabBoard.transition({
593
+ dispatchId,
594
+ expected: 'delivered',
595
+ next: 'revision',
596
+ actor: { type: 'team', id: teamSlug },
597
+ eventType: 'revision_requested',
598
+ payload: {
599
+ feedback,
600
+ previousResult: collabTask.result,
601
+ revisionCount: newRevisionCount,
602
+ },
603
+ extra: {
604
+ feedback,
605
+ revisionCount: newRevisionCount,
606
+ },
607
+ });
608
+ this.emitCollabChange(dispatchId, 'revision', revisionTask.fromTeam, revisionTask.toTeam);
609
+
610
+ return { ok: true };
611
+ }
612
+
613
+ /** Get the collaboration board. */
614
+ getCollabBoard() {
615
+ return this.collabBoard.getBoard();
616
+ }
617
+
618
+ /** Get a single collab task. */
619
+ getCollabTask(dispatchId: string) {
620
+ return this.collabBoard.getTask(dispatchId);
621
+ }
622
+
623
+ /** Get event log for a collab task. */
624
+ getCollabTaskEvents(dispatchId: string) {
625
+ return this.collabBoard.getEvents(dispatchId);
626
+ }
627
+
628
+ listPendingRequests(teamSlug: string): TaskDispatchPayload[] {
629
+ const results: TaskDispatchPayload[] = [];
630
+ for (const [, req] of this.pendingRequests) {
631
+ if (req.teamSlug === teamSlug) {
632
+ results.push(req.payload);
633
+ }
634
+ }
635
+ return results;
151
636
  }
152
637
 
153
638
  async onTaskCompleted(teamSlug: string, taskId: string): Promise<void> {
@@ -165,12 +650,10 @@ export class TaskDispatchService {
165
650
  result: task.result ?? undefined,
166
651
  };
167
652
 
168
- // Update local dispatchMeta
169
653
  await this.workspace.patchTask(teamSlug, taskId, {
170
654
  dispatchMeta: { ...meta, status: 'completed', completedAt: update.timestamp },
171
655
  } as any);
172
656
 
173
- // Notify origin team
174
657
  if (this.redis) {
175
658
  const channel = `task:status:${meta.originTeam}`;
176
659
  await this.redis.publish(channel, JSON.stringify(update)).catch((err: Error) => {
@@ -179,7 +662,7 @@ export class TaskDispatchService {
179
662
  }
180
663
  }
181
664
 
182
- // ── Local dispatch ────────────────────────────────────────────
665
+ // ── Local team check ─────────────────────────────────────────
183
666
 
184
667
  private async isLocalTeam(teamSlug: string): Promise<boolean> {
185
668
  try {
@@ -190,21 +673,84 @@ export class TaskDispatchService {
190
673
  }
191
674
  }
192
675
 
193
- private async handleLocalDispatch(
676
+ // ── Unified Redis dispatch ───────────────────────────────────────
677
+
678
+ private async handleRedisDispatch(
194
679
  dispatchMeta: DispatchMeta,
195
- task: { subject: string; description?: string; prompt?: string }
680
+ task: { subject: string; description?: string; prompt?: string },
681
+ needsHumanReview?: boolean
196
682
  ): Promise<void> {
197
- const created = await this.workspace.createTask(dispatchMeta.targetTeam, {
198
- title: task.subject,
199
- description: task.description,
683
+ const payload: TaskDispatchPayload = {
684
+ dispatchId: dispatchMeta.dispatchId,
685
+ originTeam: dispatchMeta.originTeam,
686
+ targetTeam: dispatchMeta.targetTeam,
687
+ task: { subject: task.subject, description: task.description, prompt: task.prompt },
688
+ dispatchedAt: dispatchMeta.dispatchedAt,
689
+ deadline: dispatchMeta.deadline,
690
+ needsHumanReview,
691
+ };
692
+
693
+ // Store in memory for accept/reject
694
+ this.pendingRequests.set(dispatchMeta.dispatchId, {
695
+ payload,
696
+ msgId: `redis-${Date.now()}`,
697
+ groupName: 'dispatch-group',
698
+ teamSlug: dispatchMeta.targetTeam,
200
699
  });
201
- // Attach dispatchMeta after creation
202
- await this.workspace.patchTask(dispatchMeta.targetTeam, created.id, {
203
- dispatchMeta,
204
- } as any);
700
+
701
+ // Publish to Redis stream
702
+ const streamKey = `task:dispatch:${dispatchMeta.targetTeam}`;
703
+ await this.redis!.xadd(streamKey, '*', 'payload', JSON.stringify(payload));
704
+
705
+ // If local team, also write to inbox
706
+ const isLocal = await this.isLocalTeam(dispatchMeta.targetTeam);
707
+ if (isLocal) {
708
+ try {
709
+ await this.workspace.appendMessage(dispatchMeta.targetTeam, {
710
+ from: dispatchMeta.originTeam,
711
+ to: 'team',
712
+ role: 'agent',
713
+ content: `[跨团队任务] ${task.subject}${task.description ? '\n' + task.description : ''}`,
714
+ meta: {
715
+ source: 'cross_team_dispatch',
716
+ dispatchId: dispatchMeta.dispatchId,
717
+ originTeam: dispatchMeta.originTeam,
718
+ needsHumanReview,
719
+ },
720
+ });
721
+ } catch (err) {
722
+ console.error('[TaskDispatchService] inbox write failed:', (err as Error).message);
723
+ }
724
+ }
725
+
726
+ this.sendFeishuNotification(
727
+ `跨团队任务派发:${dispatchMeta.originTeam} → ${dispatchMeta.targetTeam}\n${task.subject}`
728
+ );
205
729
  }
206
730
 
207
- // ── Remote dispatch (Redis) ───────────────────────────────────
731
+ // ── Feishu notification helper ──────────────────────────────────
732
+
733
+ private sendFeishuNotification(text: string): void {
734
+ setTimeout(() => {
735
+ try {
736
+ const { execSync } = require('node:child_process');
737
+ execSync(
738
+ `feishu-cli msg send --receive-id-type chat_id --receive-id oc_e7d4204895f8f9d763d9f0e42ead1e5e --text ${JSON.stringify(text)}`,
739
+ { timeout: 5000, stdio: 'pipe' }
740
+ );
741
+ } catch {
742
+ // best effort
743
+ }
744
+ }, 0);
745
+ }
746
+
747
+ // ── Local response (same machine) ─────────────────────────────
748
+
749
+ private async handleLocalResponse(response: TaskHandshakeResponse): Promise<void> {
750
+ await this.applyResponse(response);
751
+ }
752
+
753
+ // ── Redis connection ──────────────────────────────────────────
208
754
 
209
755
  private async connectRedis(): Promise<void> {
210
756
  if (!this.config?.redis) return;
@@ -222,8 +768,10 @@ export class TaskDispatchService {
222
768
 
223
769
  await this.redis.ping();
224
770
 
771
+ this.collabBoard.setRedis(this.redis);
225
772
  this.startHeartbeat();
226
773
  this.startConsumers();
774
+ this.startResponseConsumers();
227
775
  this.subscribeStatus();
228
776
  } catch (err) {
229
777
  console.error('[TaskDispatchService] Redis connect failed:', err);
@@ -232,27 +780,7 @@ export class TaskDispatchService {
232
780
  }
233
781
  }
234
782
 
235
- private async handleRemoteDispatch(
236
- dispatchMeta: DispatchMeta,
237
- task: { subject: string; description?: string; prompt?: string }
238
- ): Promise<void> {
239
- const payload: TaskDispatchPayload = {
240
- dispatchId: dispatchMeta.dispatchId,
241
- originTeam: dispatchMeta.originTeam,
242
- targetTeam: dispatchMeta.targetTeam,
243
- task: {
244
- subject: task.subject,
245
- description: task.description,
246
- prompt: task.prompt,
247
- },
248
- dispatchedAt: dispatchMeta.dispatchedAt,
249
- };
250
-
251
- const streamKey = `task:dispatch:${dispatchMeta.targetTeam}`;
252
- await this.redis!.xadd(streamKey, '*', 'payload', JSON.stringify(payload));
253
- }
254
-
255
- // ── Heartbeat ─────────────────────────────────────────────────
783
+ // ── Heartbeat + agent info ────────────────────────────────────
256
784
 
257
785
  private startHeartbeat(): void {
258
786
  this.stopHeartbeat();
@@ -262,7 +790,21 @@ export class TaskDispatchService {
262
790
  const localTeams = await this.workspace.listTeams();
263
791
  for (const team of localTeams) {
264
792
  await this.redis.zadd('task:teams', now, team.slug).catch(() => {});
793
+ await this.redis
794
+ .hset(`task:team:info:${team.slug}`, {
795
+ slug: team.slug,
796
+ displayName: team.displayName ?? team.slug,
797
+ harness: team.harness,
798
+ description: team.description ?? '',
799
+ capabilities: JSON.stringify(this.inferCapabilities(team)),
800
+ collaboration: String(team.collaboration !== false),
801
+ updatedAt: new Date().toISOString(),
802
+ })
803
+ .catch(() => {});
265
804
  }
805
+
806
+ // Check deadline timeouts
807
+ await this.checkDeadlines(localTeams);
266
808
  };
267
809
  beat();
268
810
  this.heartbeatTimer = setInterval(beat, 30_000);
@@ -275,21 +817,47 @@ export class TaskDispatchService {
275
817
  }
276
818
  }
277
819
 
278
- // ── Consumers (XREADGROUP) ────────────────────────────────────
820
+ private async checkDeadlines(localTeams: TeamManifest[]): Promise<void> {
821
+ for (const team of localTeams) {
822
+ try {
823
+ const tasks = await this.workspace.readTasks(team.slug);
824
+ for (const task of tasks) {
825
+ if (
826
+ task.dispatchMeta?.status === 'pending_accept' &&
827
+ task.dispatchMeta.deadline &&
828
+ new Date(task.dispatchMeta.deadline).getTime() < Date.now()
829
+ ) {
830
+ await this.workspace.patchTask(team.slug, task.id, {
831
+ dispatchMeta: {
832
+ ...task.dispatchMeta,
833
+ status: 'failed',
834
+ rejectionReason: 'handshake timeout',
835
+ },
836
+ } as any);
837
+ }
838
+ }
839
+ } catch {
840
+ /* skip broken teams */
841
+ }
842
+ }
843
+ }
844
+
845
+ // ── Dispatch consumers (XREADGROUP) ───────────────────────────
279
846
 
280
847
  private startConsumers(): void {
281
848
  if (!this.redis || !this.redisSub) return;
282
849
 
283
850
  const startForTeam = async (teamSlug: string) => {
851
+ if (this.consumerTeamSlugs.has(teamSlug)) return;
852
+ this.consumerTeamSlugs.add(teamSlug);
284
853
  const streamKey = `task:dispatch:${teamSlug}`;
285
854
  const groupName = `hermit-${teamSlug}`;
286
855
  const consumerId = `consumer-${process.pid}`;
287
856
 
288
- // Create consumer group (MKSTREAM creates stream if missing)
289
857
  try {
290
858
  await this.redis!.xgroup('CREATE', streamKey, groupName, '0', 'MKSTREAM');
291
859
  } catch {
292
- // Group already exists — ignore
860
+ // Group already exists
293
861
  }
294
862
 
295
863
  const poll = async () => {
@@ -322,18 +890,19 @@ export class TaskDispatchService {
322
890
  }
323
891
  };
324
892
 
325
- // Initial poll, then interval
326
893
  poll();
327
894
  const timer = setInterval(poll, 5000);
328
895
  this.consumerTimers.push(timer);
329
896
  };
330
897
 
331
- // Start consumer for each local team
332
- this.workspace.listTeams().then((teams) => {
333
- for (const team of teams) {
334
- startForTeam(team.slug);
335
- }
336
- });
898
+ const syncConsumers = () =>
899
+ void this.workspace.listTeams().then((teams) => {
900
+ for (const team of teams) {
901
+ void startForTeam(team.slug);
902
+ }
903
+ });
904
+ syncConsumers();
905
+ this.consumerTimers.push(setInterval(syncConsumers, 10_000));
337
906
  }
338
907
 
339
908
  private async handleIncomingDispatch(
@@ -343,44 +912,87 @@ export class TaskDispatchService {
343
912
  groupName: string
344
913
  ): Promise<void> {
345
914
  try {
346
- // fields is [key, value, key, value, ...]
347
915
  const payloadStr = fields[1]?.toString();
348
916
  if (!payloadStr) return;
349
917
 
350
918
  const payload: TaskDispatchPayload = JSON.parse(payloadStr);
919
+ const alreadyPending = this.pendingRequests.has(payload.dispatchId);
351
920
 
352
- // Write task to board.json
353
- const created = await this.workspace.createTask(teamSlug, {
354
- title: payload.task.subject,
921
+ const fromTeamManifest = await this.safeReadManifest(payload.originTeam);
922
+ const toTeamManifest = await this.safeReadManifest(teamSlug);
923
+ const createdAt = payload.dispatchedAt || new Date().toISOString();
924
+ this.collabBoard.addTask({
925
+ id: payload.dispatchId,
926
+ dispatchId: payload.dispatchId,
927
+ subject: payload.task.subject,
355
928
  description: payload.task.description,
929
+ fromTeam: payload.originTeam,
930
+ fromTeamDisplay: fromTeamManifest?.displayName ?? payload.originTeam,
931
+ toTeam: teamSlug,
932
+ toTeamDisplay: toTeamManifest?.displayName ?? teamSlug,
933
+ status: 'pending_accept',
934
+ deadline: payload.deadline,
935
+ needsHumanReview: payload.needsHumanReview ?? false,
936
+ revisionCount: 0,
937
+ createdAt,
938
+ updatedAt: createdAt,
356
939
  });
940
+ this.emitCollabChange(payload.dispatchId, 'pending_accept', payload.originTeam, teamSlug);
357
941
 
358
- const dispatchMeta: DispatchMeta = {
359
- dispatchId: payload.dispatchId,
360
- originTeam: payload.originTeam,
361
- targetTeam: payload.targetTeam,
362
- status: 'received',
363
- dispatchedAt: payload.dispatchedAt,
364
- receivedAt: new Date().toISOString(),
365
- remoteTaskId: created.id,
366
- };
942
+ const existingTasks = await this.workspace.readTasks(teamSlug).catch(() => []);
943
+ const existingTask = existingTasks.find(
944
+ (task) => task.dispatchMeta?.dispatchId === payload.dispatchId
945
+ );
946
+ const localTask =
947
+ existingTask ??
948
+ (await this.workspace.createTask(teamSlug, {
949
+ title: payload.task.subject,
950
+ description: payload.task.description ?? payload.task.prompt ?? '',
951
+ status: 'todo',
952
+ dispatchMeta: {
953
+ dispatchId: payload.dispatchId,
954
+ originTeam: payload.originTeam,
955
+ targetTeam: teamSlug,
956
+ status: 'pending_accept',
957
+ dispatchedAt: payload.dispatchedAt,
958
+ receivedAt: new Date().toISOString(),
959
+ deadline: payload.deadline,
960
+ },
961
+ }));
367
962
 
368
- await this.workspace.patchTask(teamSlug, created.id, {
369
- dispatchMeta,
370
- } as any);
963
+ // Store in pending requests — wait for a human to accept/reject the agent-created dispatch.
964
+ this.pendingRequests.set(payload.dispatchId, {
965
+ payload,
966
+ msgId,
967
+ groupName,
968
+ teamSlug,
969
+ localTaskId: localTask.id,
970
+ });
371
971
 
372
- // Send ack
373
- if (this.redis) {
374
- const ackKey = `task:ack:${payload.dispatchId}`;
375
- const ackPayload = JSON.stringify({
376
- dispatchId: payload.dispatchId,
377
- status: 'received',
378
- remoteTaskId: created.id,
379
- timestamp: new Date().toISOString(),
380
- });
381
- await this.redis.xadd(ackKey, '*', 'ack', ackPayload);
382
- await this.redis.xack(`task:dispatch:${teamSlug}`, groupName, msgId);
972
+ if (!alreadyPending) {
973
+ await this.workspace
974
+ .appendMessage(teamSlug, {
975
+ from: payload.originTeam,
976
+ to: 'team',
977
+ role: 'agent',
978
+ content: `[跨团队任务] ${payload.task.subject}${
979
+ payload.task.description ? '\n' + payload.task.description : ''
980
+ }`,
981
+ meta: {
982
+ source: 'cross_team_dispatch',
983
+ dispatchId: payload.dispatchId,
984
+ originTeam: payload.originTeam,
985
+ needsHumanReview: payload.needsHumanReview,
986
+ },
987
+ })
988
+ .catch((err: Error) => {
989
+ console.error('[TaskDispatchService] inbox write failed:', err.message);
990
+ });
383
991
  }
992
+
993
+ console.log(
994
+ `[TaskDispatchService] received dispatch request: ${payload.dispatchId} from ${payload.originTeam} → ${teamSlug}`
995
+ );
384
996
  } catch (err) {
385
997
  console.error('[TaskDispatchService] handleIncomingDispatch error:', err);
386
998
  }
@@ -389,9 +1001,155 @@ export class TaskDispatchService {
389
1001
  private stopConsumers(): void {
390
1002
  for (const t of this.consumerTimers) clearInterval(t);
391
1003
  this.consumerTimers = [];
1004
+ this.consumerTeamSlugs.clear();
1005
+ }
1006
+
1007
+ // ── Response consumers (XREADGROUP) ───────────────────────────
1008
+
1009
+ private startResponseConsumers(): void {
1010
+ if (!this.redis || !this.redisSub) return;
1011
+
1012
+ const startForTeam = async (teamSlug: string) => {
1013
+ if (this.responseConsumerTeamSlugs.has(teamSlug)) return;
1014
+ this.responseConsumerTeamSlugs.add(teamSlug);
1015
+ const streamKey = `task:response:${teamSlug}`;
1016
+ const groupName = `hermit-response-${teamSlug}`;
1017
+ const consumerId = `response-consumer-${process.pid}`;
1018
+
1019
+ try {
1020
+ await this.redis!.xgroup('CREATE', streamKey, groupName, '0', 'MKSTREAM');
1021
+ } catch {
1022
+ // Group already exists
1023
+ }
1024
+
1025
+ const poll = async () => {
1026
+ if (this.disposed || !this.redisSub) return;
1027
+ try {
1028
+ const raw: unknown = await (this.redisSub as any).xreadgroup(
1029
+ 'GROUP',
1030
+ groupName,
1031
+ consumerId,
1032
+ 'BLOCK',
1033
+ 5000,
1034
+ 'COUNT',
1035
+ 1,
1036
+ 'STREAMS',
1037
+ streamKey,
1038
+ '>'
1039
+ );
1040
+ const results = raw as [string, [string, (string | Buffer)[]][]][] | null;
1041
+
1042
+ if (!results || results.length === 0) return;
1043
+
1044
+ for (const [, messages] of results) {
1045
+ if (!Array.isArray(messages)) continue;
1046
+ for (const [msgId, fields] of messages) {
1047
+ await this.handleIncomingResponse(teamSlug, msgId, fields, groupName);
1048
+ }
1049
+ }
1050
+ } catch {
1051
+ // Read error — will retry next poll
1052
+ }
1053
+ };
1054
+
1055
+ poll();
1056
+ const timer = setInterval(poll, 5000);
1057
+ this.responseConsumerTimers.push(timer);
1058
+ };
1059
+
1060
+ const syncConsumers = () =>
1061
+ void this.workspace.listTeams().then((teams) => {
1062
+ for (const team of teams) {
1063
+ void startForTeam(team.slug);
1064
+ }
1065
+ });
1066
+ syncConsumers();
1067
+ this.responseConsumerTimers.push(setInterval(syncConsumers, 10_000));
392
1068
  }
393
1069
 
394
- // ── Status subscribe ──────────────────────────────────────────
1070
+ private async handleIncomingResponse(
1071
+ _teamSlug: string,
1072
+ msgId: string,
1073
+ fields: (string | Buffer)[],
1074
+ groupName: string
1075
+ ): Promise<void> {
1076
+ try {
1077
+ const payloadStr = fields[1]?.toString();
1078
+ if (!payloadStr) return;
1079
+
1080
+ const response: TaskHandshakeResponse = JSON.parse(payloadStr);
1081
+ await this.applyResponse(response);
1082
+
1083
+ // ACK
1084
+ if (this.redis) {
1085
+ await this.redis.xack(`task:response:${_teamSlug}`, groupName, msgId).catch(() => {});
1086
+ }
1087
+ } catch (err) {
1088
+ console.error('[TaskDispatchService] handleIncomingResponse error:', err);
1089
+ }
1090
+ }
1091
+
1092
+ private async applyResponse(response: TaskHandshakeResponse): Promise<void> {
1093
+ const originTeam = response.toTeam;
1094
+ const tasks = await this.workspace.readTasks(originTeam);
1095
+ const shadowTask = tasks.find((t) => t.dispatchMeta?.dispatchId === response.dispatchId);
1096
+ if (!shadowTask) return;
1097
+
1098
+ const meta = { ...shadowTask.dispatchMeta! };
1099
+
1100
+ if (response.type === 'task_accept') {
1101
+ meta.status = 'accepted';
1102
+ meta.acceptedAt = response.acceptedAt;
1103
+ meta.remoteTaskId = response.remoteTaskId;
1104
+ // Collab board update already done in acceptTask
1105
+ } else if (response.type === 'task_reject') {
1106
+ meta.status = 'rejected';
1107
+ meta.rejectedAt = response.rejectedAt;
1108
+ meta.rejectionReason = response.reason;
1109
+ // Collab board update already done in rejectTask
1110
+ } else if (response.type === 'task_deliver') {
1111
+ meta.status = 'completed';
1112
+ meta.completedAt = response.deliveredAt;
1113
+ await this.workspace.patchTask(originTeam, shadowTask.id, {
1114
+ dispatchMeta: meta,
1115
+ } as any);
1116
+
1117
+ // Auto-approve if no human review needed
1118
+ const collabTask = this.collabBoard.getTask(response.dispatchId);
1119
+ if (collabTask && !collabTask.needsHumanReview && collabTask.status === 'delivered') {
1120
+ const approvedAt = new Date().toISOString();
1121
+ this.collabBoard.transition({
1122
+ dispatchId: response.dispatchId,
1123
+ expected: 'delivered',
1124
+ next: 'approved',
1125
+ actor: { type: 'system', id: 'auto-approve' },
1126
+ eventType: 'task_approved',
1127
+ payload: { auto: true },
1128
+ extra: { approvedAt },
1129
+ });
1130
+ this.emitCollabChange(response.dispatchId, 'approved', response.fromTeam, response.toTeam);
1131
+ }
1132
+ return;
1133
+ } else if (response.type === 'task_approve') {
1134
+ // Received by target team — already handled in approveTask
1135
+ return;
1136
+ } else if (response.type === 'task_revision') {
1137
+ // Received by target team — already handled in rejectResult
1138
+ return;
1139
+ }
1140
+
1141
+ await this.workspace.patchTask(originTeam, shadowTask.id, {
1142
+ dispatchMeta: meta,
1143
+ } as any);
1144
+ }
1145
+
1146
+ private stopResponseConsumers(): void {
1147
+ for (const t of this.responseConsumerTimers) clearInterval(t);
1148
+ this.responseConsumerTimers = [];
1149
+ this.responseConsumerTeamSlugs.clear();
1150
+ }
1151
+
1152
+ // ── Status subscribe (completion notifications) ──────────────
395
1153
 
396
1154
  private subscribeStatus(): void {
397
1155
  if (!this.redisSub) return;
@@ -407,14 +1165,11 @@ export class TaskDispatchService {
407
1165
  });
408
1166
 
409
1167
  this.redisSub.on('message', (channel: string, message: string) => {
410
- // channel format: task:status:{teamSlug}
411
1168
  if (!channel.startsWith('task:status:')) return;
412
1169
 
413
1170
  try {
414
1171
  const update: TaskStatusUpdate = JSON.parse(message);
415
1172
  const teamSlug = channel.replace('task:status:', '');
416
-
417
- // Find shadow task (dispatched from this team) and update status
418
1173
  this.handleStatusSync(teamSlug, update);
419
1174
  } catch {
420
1175
  // Ignore malformed messages
@@ -437,4 +1192,34 @@ export class TaskDispatchService {
437
1192
  },
438
1193
  } as any);
439
1194
  }
1195
+
1196
+ // ── Capability inference ──────────────────────────────────────
1197
+
1198
+ private inferCapabilities(team: TeamManifest): AgentCapability[] {
1199
+ const caps: AgentCapability[] = [];
1200
+ if (team.harness) {
1201
+ caps.push({ skill: team.harness, description: `${team.harness} agent` });
1202
+ }
1203
+ if (team.description) {
1204
+ caps.push({ skill: 'general', description: team.description });
1205
+ }
1206
+ return caps;
1207
+ }
1208
+
1209
+ private async safeReadManifest(teamSlug: string): Promise<TeamManifest | null> {
1210
+ try {
1211
+ return await this.workspace.readTeamManifest(teamSlug);
1212
+ } catch {
1213
+ return null;
1214
+ }
1215
+ }
1216
+
1217
+ private emitCollabChange(
1218
+ dispatchId: string,
1219
+ status: string,
1220
+ fromTeam: string,
1221
+ toTeam: string
1222
+ ): void {
1223
+ this.onCollabChange?.(dispatchId, status, fromTeam, toTeam);
1224
+ }
440
1225
  }