|
@@ -1,36 +1,53 @@
|
|
|
-// 工作组(docs/design/13-bot-teams.md):组成规则、生命周期、消息与唤醒、机器人执行结果回帖、审批
|
|
|
|
|
|
|
+// 工作组(docs/design/13-bot-teams.md):组成规则、生命周期、消息与唤醒、机器人执行结果回帖、审批,
|
|
|
|
|
+// 以及组长的任务与子任务、限额、替身组员和逐步执行(二期)。
|
|
|
// 每个"机器人 × 工作组"一个引擎会话(来源 team:<id>),工作目录都是工作组的共享目录;同一机器人按顺序执行,
|
|
// 每个"机器人 × 工作组"一个引擎会话(来源 team:<id>),工作目录都是工作组的共享目录;同一机器人按顺序执行,
|
|
|
-// 执行期间到达的新消息在下一轮一起交给它
|
|
|
|
|
-import { and, desc, eq } from 'drizzle-orm'
|
|
|
|
|
|
|
+// 执行期间到达的新消息在下一轮一起交给它。组员有排队的子任务时先做子任务
|
|
|
|
|
+import { and, desc, eq, inArray } from 'drizzle-orm'
|
|
|
import { z } from 'zod'
|
|
import { z } from 'zod'
|
|
|
import type {
|
|
import type {
|
|
|
- AgentEvent, BotDto, CreateTeamBody, InteractionResponse, SendTeamMessageBody, TeamBotDto, TeamBotStatus, TeamDetailDto, TeamDto,
|
|
|
|
|
- TeamEventOf, TeamFileRef, TeamInteractionDto, TeamMessage, TeamStatus,
|
|
|
|
|
|
|
+ AgentEvent, BotDto, CreateTeamBody, InteractionResponse, PatchTeamBody, SendTeamMessageBody, StubTaskBody, TeamBotDto, TeamBotStatus,
|
|
|
|
|
+ TeamCard, TeamDebug, TeamDetailDto, TeamDto, TeamEventOf, TeamFileRef, TeamInteractionDto, TeamJobsDto, TeamLimitKey, TeamLimits,
|
|
|
|
|
+ TeamMessage, TeamStatus, TeamStepDto, TeamUsageDto, TeamUsageRow,
|
|
|
} from '@agent-server/shared'
|
|
} from '@agent-server/shared'
|
|
|
import type { Readable } from 'node:stream'
|
|
import type { Readable } from 'node:stream'
|
|
|
import path from 'node:path'
|
|
import path from 'node:path'
|
|
|
import type { DB } from '../db'
|
|
import type { DB } from '../db'
|
|
|
-import { sessions, teamBotSessions, teamBots, teams } from '../db/schema'
|
|
|
|
|
|
|
+import { runs, sessions, teamBotSessions, teamBots, teams } from '../db/schema'
|
|
|
import { AppError, badRequest, conflict, notFound } from '../lib/errors'
|
|
import { AppError, badRequest, conflict, notFound } from '../lib/errors'
|
|
|
import { newId, nowIso } from '../lib/ids'
|
|
import { newId, nowIso } from '../lib/ids'
|
|
|
|
|
+import type { GatewayRunContext } from '../mcp-gateway/tokens'
|
|
|
import { parse, type ProfileService } from '../services/catalog'
|
|
import { parse, type ProfileService } from '../services/catalog'
|
|
|
import type { EventStore } from '../services/event-store'
|
|
import type { EventStore } from '../services/event-store'
|
|
|
import type { FileService } from '../services/files'
|
|
import type { FileService } from '../services/files'
|
|
|
import type { InteractionService } from '../services/interactions'
|
|
import type { InteractionService } from '../services/interactions'
|
|
|
import type { RunAttachment, RunManager } from '../services/runs'
|
|
import type { RunAttachment, RunManager } from '../services/runs'
|
|
|
import { isTeamSource, TEAM_SOURCE_PREFIX, type SessionRow, type SessionService } from '../services/sessions'
|
|
import { isTeamSource, TEAM_SOURCE_PREFIX, type SessionRow, type SessionService } from '../services/sessions'
|
|
|
-import type { SettingsService } from '../services/settings'
|
|
|
|
|
|
|
+import { teamLimitsSchema, type SettingsService } from '../services/settings'
|
|
|
import type { BotKeyService, BotService } from './bots'
|
|
import type { BotKeyService, BotService } from './bots'
|
|
|
|
|
+import { cardText, type CardService } from './cards'
|
|
|
import { TeamEventStore } from './events'
|
|
import { TeamEventStore } from './events'
|
|
|
-import { teamInput, teamPrompt } from './prompt'
|
|
|
|
|
|
|
+import { JobStore, LIMIT_LABELS, OPEN_TASK, type JobRow, type TaskRow } from './jobs'
|
|
|
|
|
+import { taskInput, teamInput, teamPrompt } from './prompt'
|
|
|
|
|
+import { collabTools, LEAD_TOOLS, WORKER_TOOLS, type CollabArgs, type CollabToolName } from './tools'
|
|
|
|
|
|
|
|
export type TeamRow = typeof teams.$inferSelect
|
|
export type TeamRow = typeof teams.$inferSelect
|
|
|
|
|
|
|
|
|
|
+const limitsPatch = teamLimitsSchema.partial()
|
|
|
|
|
+const debugPatch = z.object({ stepMode: z.boolean(), stubBotIds: z.array(z.string()).max(50) }).partial()
|
|
|
|
|
+
|
|
|
const createSchema: z.ZodType<CreateTeamBody> = z.object({
|
|
const createSchema: z.ZodType<CreateTeamBody> = z.object({
|
|
|
title: z.string().trim().max(100).optional(),
|
|
title: z.string().trim().max(100).optional(),
|
|
|
human: z.object({ id: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(60) }),
|
|
human: z.object({ id: z.string().trim().min(1).max(100), name: z.string().trim().min(1).max(60) }),
|
|
|
leadBotId: z.string().optional(),
|
|
leadBotId: z.string().optional(),
|
|
|
workerBotIds: z.array(z.string()).max(20).optional(),
|
|
workerBotIds: z.array(z.string()).max(20).optional(),
|
|
|
|
|
+ limits: limitsPatch.optional(),
|
|
|
|
|
+ debug: debugPatch.optional(),
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const patchSchema: z.ZodType<PatchTeamBody> = z.object({
|
|
|
|
|
+ title: z.string().trim().min(1).max(100).optional(),
|
|
|
|
|
+ limits: limitsPatch.optional(),
|
|
|
|
|
+ debug: debugPatch.optional(),
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const messageSchema: z.ZodType<SendTeamMessageBody> = z.object({
|
|
const messageSchema: z.ZodType<SendTeamMessageBody> = z.object({
|
|
@@ -39,6 +56,12 @@ const messageSchema: z.ZodType<SendTeamMessageBody> = z.object({
|
|
|
fileIds: z.array(z.string()).max(50).optional(),
|
|
fileIds: z.array(z.string()).max(50).optional(),
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+const stubSchema: z.ZodType<StubTaskBody> = z.object({
|
|
|
|
|
+ outcome: z.enum(['completed', 'failed']),
|
|
|
|
|
+ result: z.string().trim().min(1).max(50_000),
|
|
|
|
|
+ fileIds: z.array(z.string()).max(50).optional(),
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
interface BotRuntime {
|
|
interface BotRuntime {
|
|
|
status: TeamBotStatus
|
|
status: TeamBotStatus
|
|
|
runId: string | null
|
|
runId: string | null
|
|
@@ -49,15 +72,29 @@ interface BotRuntime {
|
|
|
/** 本轮最后一段回复与改动过的文件(用于回帖) */
|
|
/** 本轮最后一段回复与改动过的文件(用于回帖) */
|
|
|
lastText: string
|
|
lastText: string
|
|
|
changedPaths: Set<string>
|
|
changedPaths: Set<string>
|
|
|
|
|
+ /** 本轮执行的子任务,以及组员调用 task_complete / task_fail 报告的结果 */
|
|
|
|
|
+ taskId: string | null
|
|
|
|
|
+ taskOutcome: { status: 'completed' | 'failed'; result: string; paths: string[] } | null
|
|
|
|
|
+ /** 组长本轮发出了任务卡片(开始、完成等),没有最终回复时不再提示"本轮没有回复" */
|
|
|
|
|
+ posted: boolean
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** 机器人下一步要做的事 */
|
|
|
|
|
+type Work =
|
|
|
|
|
+ | { kind: 'task'; task: TaskRow }
|
|
|
|
|
+ | { kind: 'messages'; relevant: TeamEventOf<'message.created'>[]; readUpTo: number }
|
|
|
|
|
+
|
|
|
interface Logger { warn(o: object, m?: string): void; error(o: object, m?: string): void }
|
|
interface Logger { warn(o: object, m?: string): void; error(o: object, m?: string): void }
|
|
|
|
|
|
|
|
export class TeamService {
|
|
export class TeamService {
|
|
|
readonly events: TeamEventStore
|
|
readonly events: TeamEventStore
|
|
|
|
|
+ readonly jobs: JobStore
|
|
|
private runtime = new Map<string, BotRuntime>()
|
|
private runtime = new Map<string, BotRuntime>()
|
|
|
/** 会话 → 工作组与机器人;null 表示不是工作组的当前会话 */
|
|
/** 会话 → 工作组与机器人;null 表示不是工作组的当前会话 */
|
|
|
private sessionIndex = new Map<string, { teamId: string; botId: string } | null>()
|
|
private sessionIndex = new Map<string, { teamId: string; botId: string } | null>()
|
|
|
|
|
+ /** 逐步执行模式下等待"继续"的步骤(不落库,服务重启后由下一次唤醒重新生成) */
|
|
|
|
|
+ private steps = new Map<string, TeamStepDto[]>()
|
|
|
|
|
+ private scans = new Map<string, Promise<void>>()
|
|
|
|
|
|
|
|
constructor(
|
|
constructor(
|
|
|
private deps: {
|
|
private deps: {
|
|
@@ -72,14 +109,37 @@ export class TeamService {
|
|
|
interactions: InteractionService
|
|
interactions: InteractionService
|
|
|
bots: BotService
|
|
bots: BotService
|
|
|
botKeys: BotKeyService
|
|
botKeys: BotKeyService
|
|
|
|
|
+ cards: CardService
|
|
|
/** 删除会话时清理网关的会话级状态 */
|
|
/** 删除会话时清理网关的会话级状态 */
|
|
|
forgetSession: (sessionId: string) => void
|
|
forgetSession: (sessionId: string) => void
|
|
|
},
|
|
},
|
|
|
) {
|
|
) {
|
|
|
this.events = new TeamEventStore(deps.db)
|
|
this.events = new TeamEventStore(deps.db)
|
|
|
|
|
+ this.jobs = new JobStore(deps.db)
|
|
|
deps.events.subscribe('*', e => this.onSessionEvent(e))
|
|
deps.events.subscribe('*', e => this.onSessionEvent(e))
|
|
|
// 用量记到工作组的来源(Bot Key 或 admin)上
|
|
// 用量记到工作组的来源(Bot Key 或 admin)上
|
|
|
deps.runs.usageSourceOf = s => (isTeamSource(s.source) ? this.findRow(s.source.slice(TEAM_SOURCE_PREFIX.length))?.source ?? s.source : s.source)
|
|
deps.runs.usageSourceOf = s => (isTeamSource(s.source) ? this.findRow(s.source.slice(TEAM_SOURCE_PREFIX.length))?.source ?? s.source : s.source)
|
|
|
|
|
+ // 协作工具:按会话所属机器人的角色提供
|
|
|
|
|
+ const leadTools = collabTools(LEAD_TOOLS, (name, args, ctx) => this.collab(name, args, ctx))
|
|
|
|
|
+ const workerTools = collabTools(WORKER_TOOLS, (name, args, ctx) => this.collab(name, args, ctx))
|
|
|
|
|
+ deps.runs.gatewayToolsOf = s => {
|
|
|
|
|
+ const ref = isTeamSource(s.source) ? this.lookup(s.id) : null
|
|
|
|
|
+ if (!ref) return []
|
|
|
|
|
+ return this.memberRows(ref.teamId).find(m => m.botId === ref.botId)?.role === 'lead' ? leadTools : workerTools
|
|
|
|
|
+ }
|
|
|
|
|
+ // 服务重启时仍在执行的子任务按失败处理;结果消息发给组长,但不在启动时唤醒它,等下一次有消息时一并处理
|
|
|
|
|
+ for (const t of this.jobs.failInterrupted()) {
|
|
|
|
|
+ const task = this.jobs.task(t.id)
|
|
|
|
|
+ if (!this.findRow(task.teamId)) continue
|
|
|
|
|
+ this.emitTask(task)
|
|
|
|
|
+ const lead = this.leadOf(task.teamId)
|
|
|
|
|
+ const bot = this.findBot(task.botId)
|
|
|
|
|
+ this.events.append(task.teamId, 'message.created', {
|
|
|
|
|
+ id: newId('tmsg'), sender: { kind: 'bot', botId: task.botId, name: bot?.name ?? '(已删除的机器人)', avatar: bot?.avatar ?? '🤖' },
|
|
|
|
|
+ text: `失败:${task.result}`, mentions: [], files: [], targets: lead ? [lead] : [],
|
|
|
|
|
+ card: { kind: 'task', taskId: task.id, no: task.no, event: 'failed' },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- 工作组 ----
|
|
// ---- 工作组 ----
|
|
@@ -91,13 +151,15 @@ export class TeamService {
|
|
|
if (data.leadBotId) members.push({ bot: this.usableBot(source, data.leadBotId, 'lead'), role: 'lead' })
|
|
if (data.leadBotId) members.push({ bot: this.usableBot(source, data.leadBotId, 'lead'), role: 'lead' })
|
|
|
for (const id of workers) members.push({ bot: this.usableBot(source, id, 'worker'), role: 'worker' })
|
|
for (const id of workers) members.push({ bot: this.usableBot(source, id, 'worker'), role: 'worker' })
|
|
|
if (!members.length) throw badRequest('工作组至少需要一个机器人(组长或组员)')
|
|
if (!members.length) throw badRequest('工作组至少需要一个机器人(组长或组员)')
|
|
|
|
|
+ const limits = data.limits ? this.lowerLimits(this.deps.settings.get().teamLimits, data.limits) : null
|
|
|
|
|
+ const debug = data.debug ? this.checkDebug(source, { stepMode: false, stubBotIds: [] }, data.debug) : undefined
|
|
|
|
|
|
|
|
const id = newId('team')
|
|
const id = newId('team')
|
|
|
const workspaceDir = path.join(this.deps.settings.get().paths.workspaceRoot, id)
|
|
const workspaceDir = path.join(this.deps.settings.get().paths.workspaceRoot, id)
|
|
|
await this.deps.files.createWorkspace(workspaceDir)
|
|
await this.deps.files.createWorkspace(workspaceDir)
|
|
|
const title = data.title || members.map(m => m.bot.name).join('、')
|
|
const title = data.title || members.map(m => m.bot.name).join('、')
|
|
|
this.deps.db.transaction(tx => {
|
|
this.deps.db.transaction(tx => {
|
|
|
- tx.insert(teams).values({ id, source, title, human: data.human, workspaceDir }).run()
|
|
|
|
|
|
|
+ tx.insert(teams).values({ id, source, title, human: data.human, workspaceDir, limits, ...(debug ? { debug } : {}) }).run()
|
|
|
for (const m of members) tx.insert(teamBots).values({ teamId: id, botId: m.bot.id, role: m.role }).run()
|
|
for (const m of members) tx.insert(teamBots).values({ teamId: id, botId: m.bot.id, role: m.role }).run()
|
|
|
})
|
|
})
|
|
|
const lead = members.find(m => m.role === 'lead')
|
|
const lead = members.find(m => m.role === 'lead')
|
|
@@ -139,31 +201,33 @@ export class TeamService {
|
|
|
})
|
|
})
|
|
|
// 组长排在最前面
|
|
// 组长排在最前面
|
|
|
bots.sort((a, b) => (a.role === b.role ? a.joinedAt.localeCompare(b.joinedAt) : a.role === 'lead' ? -1 : 1))
|
|
bots.sort((a, b) => (a.role === b.role ? a.joinedAt.localeCompare(b.joinedAt) : a.role === 'lead' ? -1 : 1))
|
|
|
- return { id: r.id, title: r.title, human: r.human, status: r.status, source: r.source, bots, lastSeq: r.lastSeq, createdAt: r.createdAt, updatedAt: r.updatedAt }
|
|
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: r.id, title: r.title, human: r.human, status: r.status, source: r.source, bots, limits: this.limitsOf(r), debug: r.debug,
|
|
|
|
|
+ activeJobId: this.jobs.active(r.id)?.id ?? null, lastSeq: r.lastSeq, createdAt: r.createdAt, updatedAt: r.updatedAt,
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
detail(r: TeamRow): TeamDetailDto {
|
|
detail(r: TeamRow): TeamDetailDto {
|
|
|
- return { ...this.toDto(r), pendingInteractions: this.pendingInteractions(r.id) }
|
|
|
|
|
|
|
+ return { ...this.toDto(r), pendingInteractions: this.pendingInteractions(r.id), pendingSteps: this.steps.get(r.id) ?? [] }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
patch(id: string, source: string, body: unknown): TeamRow {
|
|
patch(id: string, source: string, body: unknown): TeamRow {
|
|
|
- this.getRow(id, source)
|
|
|
|
|
- const { title } = parse(z.object({ title: z.string().trim().min(1).max(100) }), body)
|
|
|
|
|
- this.deps.db.update(teams).set({ title, updatedAt: nowIso() }).where(eq(teams.id, id)).run()
|
|
|
|
|
|
|
+ const team = this.getRow(id, source)
|
|
|
|
|
+ const data = parse(patchSchema, body)
|
|
|
|
|
+ const set: Partial<TeamRow> = { updatedAt: nowIso() }
|
|
|
|
|
+ if (data.title) set.title = data.title
|
|
|
|
|
+ if (data.limits) set.limits = this.lowerLimits(this.deps.settings.get().teamLimits, data.limits)
|
|
|
|
|
+ const debug = data.debug ? this.checkDebug(source, team.debug, data.debug) : null
|
|
|
|
|
+ if (debug) set.debug = debug
|
|
|
|
|
+ this.deps.db.update(teams).set(set).where(eq(teams.id, id)).run()
|
|
|
|
|
+ if (debug) this.applyDebug(team, debug)
|
|
|
this.changed(id)
|
|
this.changed(id)
|
|
|
return this.getRow(id)
|
|
return this.getRow(id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
addBot(id: string, source: string, botId: string): TeamRow {
|
|
addBot(id: string, source: string, botId: string): TeamRow {
|
|
|
const team = this.getRow(id, source)
|
|
const team = this.getRow(id, source)
|
|
|
- if (team.status === 'closed') throw conflict('team_closed', '工作组已关闭')
|
|
|
|
|
- const members = this.memberRows(id)
|
|
|
|
|
- if (members.some(m => m.botId === botId)) throw conflict('already_member', '这个机器人已经在工作组中')
|
|
|
|
|
- const bot = this.usableBot(team.source, botId)
|
|
|
|
|
- if (bot.kind === 'lead' && members.some(m => m.role === 'lead')) throw badRequest('工作组已经有组长,最多只能有一个')
|
|
|
|
|
- this.deps.db.insert(teamBots).values({ teamId: id, botId, role: bot.kind }).run()
|
|
|
|
|
- this.system(id, `${bot.name}(${bot.kind === 'lead' ? '组长' : '组员'})加入了工作组`)
|
|
|
|
|
- this.changed(id)
|
|
|
|
|
|
|
+ this.join(team, this.usableBot(team.source, botId))
|
|
|
return this.getRow(id)
|
|
return this.getRow(id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -172,15 +236,19 @@ export class TeamService {
|
|
|
const members = this.memberRows(id)
|
|
const members = this.memberRows(id)
|
|
|
if (!members.some(m => m.botId === botId)) throw notFound('工作组中的机器人')
|
|
if (!members.some(m => m.botId === botId)) throw notFound('工作组中的机器人')
|
|
|
if (members.length <= 1) throw badRequest('工作组至少需要一个机器人,不能移除最后一个')
|
|
if (members.length <= 1) throw badRequest('工作组至少需要一个机器人,不能移除最后一个')
|
|
|
- this.interruptBot(id, botId)
|
|
|
|
|
|
|
+ this.dropStep(id, botId)
|
|
|
this.deps.db.delete(teamBots).where(and(eq(teamBots.teamId, id), eq(teamBots.botId, botId))).run()
|
|
this.deps.db.delete(teamBots).where(and(eq(teamBots.teamId, id), eq(teamBots.botId, botId))).run()
|
|
|
- this.runtime.delete(key(id, botId))
|
|
|
|
|
|
|
+ this.interruptBot(id, botId)
|
|
|
this.system(id, `${this.botName(botId)} 离开了工作组`)
|
|
this.system(id, `${this.botName(botId)} 离开了工作组`)
|
|
|
|
|
+ // 它还没有结束的子任务按失败处理,并通知组长
|
|
|
|
|
+ for (const t of this.jobs.tasks(id, { botId, status: OPEN_TASK })) {
|
|
|
|
|
+ if (t.status !== 'running') this.endTask(t, 'failed', '组员已被移出工作组', [])
|
|
|
|
|
+ }
|
|
|
this.changed(id)
|
|
this.changed(id)
|
|
|
return this.getRow(id)
|
|
return this.getRow(id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 暂停:中断正在执行的工作,新消息只记录不唤醒;恢复后处理暂停期间收到的消息 */
|
|
|
|
|
|
|
+ /** 暂停:中断正在执行的工作,新消息只记录不唤醒;恢复后处理暂停期间收到的消息和排队的子任务 */
|
|
|
setStatus(id: string, source: string, action: 'pause' | 'resume' | 'close' | 'reopen'): TeamRow {
|
|
setStatus(id: string, source: string, action: 'pause' | 'resume' | 'close' | 'reopen'): TeamRow {
|
|
|
const team = this.getRow(id, source)
|
|
const team = this.getRow(id, source)
|
|
|
const next: Record<typeof action, { from: TeamStatus[]; to: TeamStatus; text: string }> = {
|
|
const next: Record<typeof action, { from: TeamStatus[]; to: TeamStatus; text: string }> = {
|
|
@@ -192,10 +260,17 @@ export class TeamService {
|
|
|
const t = next[action]
|
|
const t = next[action]
|
|
|
if (!t.from.includes(team.status)) throw conflict('invalid_status', `工作组当前状态(${team.status})不能执行这个操作`)
|
|
if (!t.from.includes(team.status)) throw conflict('invalid_status', `工作组当前状态(${team.status})不能执行这个操作`)
|
|
|
this.deps.db.update(teams).set({ status: t.to, updatedAt: nowIso() }).where(eq(teams.id, id)).run()
|
|
this.deps.db.update(teams).set({ status: t.to, updatedAt: nowIso() }).where(eq(teams.id, id)).run()
|
|
|
- if (t.to !== 'active') for (const m of this.memberRows(id)) this.interruptBot(id, m.botId)
|
|
|
|
|
|
|
+ if (t.to === 'closed') {
|
|
|
|
|
+ const job = this.jobs.active(id)
|
|
|
|
|
+ if (job) this.endJob(job, 'cancelled', '工作组已关闭')
|
|
|
|
|
+ }
|
|
|
|
|
+ if (t.to !== 'active') {
|
|
|
|
|
+ this.clearSteps(id)
|
|
|
|
|
+ for (const m of this.memberRows(id)) this.interruptBot(id, m.botId)
|
|
|
|
|
+ }
|
|
|
this.system(id, t.text)
|
|
this.system(id, t.text)
|
|
|
this.changed(id)
|
|
this.changed(id)
|
|
|
- if (t.to === 'active') for (const m of this.memberRows(id)) this.wake(id, m.botId)
|
|
|
|
|
|
|
+ if (t.to === 'active') this.wakeAll(id)
|
|
|
return this.getRow(id)
|
|
return this.getRow(id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -221,7 +296,9 @@ export class TeamService {
|
|
|
tx.delete(teamBots).where(eq(teamBots.teamId, id)).run()
|
|
tx.delete(teamBots).where(eq(teamBots.teamId, id)).run()
|
|
|
tx.delete(teams).where(eq(teams.id, id)).run()
|
|
tx.delete(teams).where(eq(teams.id, id)).run()
|
|
|
})
|
|
})
|
|
|
|
|
+ this.jobs.removeForTeam(id)
|
|
|
this.events.remove(id)
|
|
this.events.remove(id)
|
|
|
|
|
+ this.steps.delete(id)
|
|
|
this.deps.files.deleteForSession(id)
|
|
this.deps.files.deleteForSession(id)
|
|
|
for (const k of [...this.runtime.keys()]) if (k.startsWith(`${id}/`)) this.runtime.delete(k)
|
|
for (const k of [...this.runtime.keys()]) if (k.startsWith(`${id}/`)) this.runtime.delete(k)
|
|
|
await this.deps.files.removeWorkspace(team.workspaceDir, this.deps.settings.get().paths.workspaceRoot)
|
|
await this.deps.files.removeWorkspace(team.workspaceDir, this.deps.settings.get().paths.workspaceRoot)
|
|
@@ -245,12 +322,7 @@ export class TeamService {
|
|
|
else throw new AppError(400, 'mention_required', '工作组有多个机器人且没有组长,请 @ 要处理这条消息的机器人')
|
|
else throw new AppError(400, 'mention_required', '工作组有多个机器人且没有组长,请 @ 要处理这条消息的机器人')
|
|
|
}
|
|
}
|
|
|
const files = (data.fileIds ?? []).map(fid => this.fileRef(id, fid))
|
|
const files = (data.fileIds ?? []).map(fid => this.fileRef(id, fid))
|
|
|
- const message: TeamMessage = {
|
|
|
|
|
- id: newId('tmsg'), sender: { kind: 'human', id: team.human.id, name: team.human.name }, text: data.text, mentions, files, targets,
|
|
|
|
|
- }
|
|
|
|
|
- this.events.append(id, 'message.created', message)
|
|
|
|
|
- if (team.status === 'active') for (const t of targets) this.wake(id, t)
|
|
|
|
|
- return message
|
|
|
|
|
|
|
+ return this.post(id, { sender: { kind: 'human', id: team.human.id, name: team.human.name }, text: data.text, mentions, files, targets })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- 文件 ----
|
|
// ---- 文件 ----
|
|
@@ -294,10 +366,198 @@ export class TeamService {
|
|
|
.flatMap(l => this.deps.interactions.pendingFor(l.sessionId).map(request => ({ botId: l.botId, sessionId: l.sessionId, request })))
|
|
.flatMap(l => this.deps.interactions.pendingFor(l.sessionId).map(request => ({ botId: l.botId, sessionId: l.sessionId, request })))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ---- 任务、子任务与用量 ----
|
|
|
|
|
+
|
|
|
|
|
+ jobList(id: string, source: string): TeamJobsDto {
|
|
|
|
|
+ this.getRow(id, source)
|
|
|
|
|
+ return { jobs: this.jobs.jobs(id).map(j => this.jobs.jobDto(j)), tasks: this.jobs.tasks(id).map(t => this.jobs.taskDto(t)) }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 成员取消任务:还没有结束的子任务一并取消,组长下一轮会看到 */
|
|
|
|
|
+ cancelJob(id: string, source: string, jobId: string) {
|
|
|
|
|
+ this.getRow(id, source)
|
|
|
|
|
+ const job = this.jobs.job(jobId, id)
|
|
|
|
|
+ if (job.status !== 'running' && job.status !== 'blocked') throw conflict('job_ended', '任务已经结束')
|
|
|
|
|
+ this.endJob(job, 'cancelled', `${this.getRow(id).human.name} 取消了任务`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 超出限额后继续:这一项限额放宽一倍 */
|
|
|
|
|
+ continueJob(id: string, source: string, jobId: string) {
|
|
|
|
|
+ this.getRow(id, source)
|
|
|
|
|
+ const job = this.jobs.job(jobId, id)
|
|
|
|
|
+ if (job.status !== 'blocked' || !job.blockedBy) throw conflict('job_not_blocked', '任务没有因为限额暂停')
|
|
|
|
|
+ const key = job.blockedBy
|
|
|
|
|
+ const current = job.limits[key]
|
|
|
|
|
+ const raised = current === null ? null : key === 'maxCostUsdPerJob' ? Math.round(current * 200) / 100 : current * 2
|
|
|
|
|
+ const next = this.jobs.updateJob(job.id, { status: 'running', blockedBy: null, limits: { ...job.limits, [key]: raised } })
|
|
|
|
|
+ this.emitJob(next)
|
|
|
|
|
+ const lead = this.leadOf(id)
|
|
|
|
|
+ this.post(id, {
|
|
|
|
|
+ sender: { kind: 'system' }, text: `成员选择继续任务「${job.title}」,${LIMIT_LABELS[key]}上限放宽到 ${raised}`,
|
|
|
|
|
+ mentions: [], files: [], targets: lead ? [lead] : [],
|
|
|
|
|
+ })
|
|
|
|
|
+ this.dispatchTasks(id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 替身组员的子任务:由管理员填写结果 */
|
|
|
|
|
+ async stubResult(id: string, source: string, taskId: string, body: StubTaskBody) {
|
|
|
|
|
+ const team = this.getRow(id, source)
|
|
|
|
|
+ if (source !== 'admin') throw new AppError(403, 'forbidden', '只有后台可以填写替身结果')
|
|
|
|
|
+ const data = parse(stubSchema, body)
|
|
|
|
|
+ const task = this.jobs.task(taskId, id)
|
|
|
|
|
+ if (task.status !== 'stub') throw conflict('task_not_stub', '这个子任务不在等待替身结果')
|
|
|
|
|
+ await this.scanFiles(team)
|
|
|
|
|
+ const files = (data.fileIds ?? []).map(fid => this.fileRef(id, fid))
|
|
|
|
|
+ this.endTask(task, data.outcome, data.result, files, { stub: true })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 逐步执行:放行一步 */
|
|
|
|
|
+ continueStep(id: string, source: string, stepId: string) {
|
|
|
|
|
+ this.getRow(id, source)
|
|
|
|
|
+ if (source !== 'admin') throw new AppError(403, 'forbidden', '只有后台可以使用逐步执行')
|
|
|
|
|
+ const step = (this.steps.get(id) ?? []).find(s => s.id === stepId)
|
|
|
|
|
+ if (!step) throw notFound('步骤')
|
|
|
|
|
+ this.dropStep(id, step.botId)
|
|
|
|
|
+ this.wake(id, step.botId, { approved: true })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ usage(id: string, source: string): TeamUsageDto {
|
|
|
|
|
+ this.getRow(id, source)
|
|
|
|
|
+ const links = this.deps.db.select().from(teamBotSessions).where(eq(teamBotSessions.teamId, id)).all()
|
|
|
|
|
+ const botOf = new Map(links.map(l => [l.sessionId, l.botId]))
|
|
|
|
|
+ const sessionIds = this.teamSessionIds(id)
|
|
|
|
|
+ const rows = sessionIds.length ? this.deps.db.select().from(runs).where(inArray(runs.sessionId, sessionIds)).all() : []
|
|
|
|
|
+ const byRun = new Map(rows.map(r => [r.id, r]))
|
|
|
|
|
+ const sum = (list: typeof rows): TeamUsageRow => ({
|
|
|
|
|
+ runs: list.length,
|
|
|
|
|
+ inputTokens: list.reduce((s, r) => s + (r.inputTokens ?? 0), 0),
|
|
|
|
|
+ outputTokens: list.reduce((s, r) => s + (r.outputTokens ?? 0), 0),
|
|
|
|
|
+ costUsd: list.reduce((s, r) => s + (r.costUsd ?? 0), 0),
|
|
|
|
|
+ })
|
|
|
|
|
+ const pick = (ids: (string | null)[]) => ids.flatMap(i => (i && byRun.has(i) ? [byRun.get(i)!] : []))
|
|
|
|
|
+ const botIds = [...new Set(rows.map(r => botOf.get(r.sessionId) ?? 'unknown'))]
|
|
|
|
|
+ return {
|
|
|
|
|
+ total: sum(rows),
|
|
|
|
|
+ bots: botIds.map(botId => ({ botId, ...sum(rows.filter(r => (botOf.get(r.sessionId) ?? 'unknown') === botId)) })),
|
|
|
|
|
+ jobs: this.jobs.jobs(id).map(j => ({ jobId: j.id, ...sum(pick(j.runIds)) })),
|
|
|
|
|
+ tasks: this.jobs.tasks(id).map(t => ({ taskId: t.id, ...sum(pick([t.runId])) })),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 协作工具(MCP Gateway 调用,见 tools.ts)----
|
|
|
|
|
+
|
|
|
|
|
+ private async collab<N extends CollabToolName>(name: N, rawArgs: CollabArgs<N>, ctx: GatewayRunContext): Promise<string> {
|
|
|
|
|
+ const ref = this.lookup(ctx.sessionId)
|
|
|
|
|
+ const team = ref && this.findRow(ref.teamId)
|
|
|
|
|
+ if (!ref || !team) throw new Error('这个会话不属于任何工作组')
|
|
|
|
|
+ const role = this.memberRows(team.id).find(m => m.botId === ref.botId)?.role
|
|
|
|
|
+ if (!role) throw new Error('你已不在这个工作组中')
|
|
|
|
|
+ const rt = this.rt(team.id, ref.botId)
|
|
|
|
|
+ const args = rawArgs as Record<string, unknown>
|
|
|
|
|
+
|
|
|
|
|
+ switch (name) {
|
|
|
|
|
+ case 'team_members': {
|
|
|
|
|
+ const cards = await this.deps.cards.cards(this.memberBots(team.id).filter(b => this.roleOf(team.id, b.id) === 'worker'))
|
|
|
|
|
+ return cards.length ? cards.map(cardText).join('\n\n') : '当前没有组员。'
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'team_bot_catalog': {
|
|
|
|
|
+ const cards = await this.deps.cards.cards(this.catalog(team))
|
|
|
|
|
+ return cards.length ? cards.map(cardText).join('\n\n') : '没有可以申请加入的机器人。'
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'team_request_bot': {
|
|
|
|
|
+ const bot = this.catalog(team).find(b => b.id === args.bot_id)
|
|
|
|
|
+ if (!bot) throw new Error('这个机器人不能加入本工作组(不存在、已在组中或不在可用范围内),请先调用 team_bot_catalog')
|
|
|
|
|
+ const outcome = await ctx.interact({
|
|
|
|
|
+ kind: 'approval', tool: 'team_request_bot', title: `组长申请让「${bot.name}」加入工作组`,
|
|
|
|
|
+ input: { bot: bot.name, reason: args.reason }, detail: { reason: String(args.reason ?? '') }, options: ['allow_once', 'deny'],
|
|
|
|
|
+ })
|
|
|
|
|
+ if (outcome.resolution !== 'allowed') return `成员没有批准${outcome.resolution === 'denied' && outcome.reason ? `:${outcome.reason}` : ''}。`
|
|
|
|
|
+ const fresh = this.getRow(team.id)
|
|
|
|
|
+ this.join(fresh, this.usableBot(fresh.source, bot.id, 'worker'), '组长申请,成员已批准')
|
|
|
|
|
+ return `${bot.name} 已加入工作组。它的能力卡片:\n\n${cardText(await this.deps.cards.card(bot))}`
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'team_history': {
|
|
|
|
|
+ const limit = Number(args.limit ?? 30)
|
|
|
|
|
+ const before = args.before === undefined ? Number.MAX_SAFE_INTEGER : Number(args.before)
|
|
|
|
|
+ const all = this.events.list(team.id, 0, 100_000, ['message.created']) as TeamEventOf<'message.created'>[]
|
|
|
|
|
+ const page = all.filter(e => e.seq < before).slice(-limit)
|
|
|
|
|
+ if (!page.length) return '没有更早的消息。'
|
|
|
|
|
+ return `${page.map(e => `#${e.seq} ${teamInput([e], ref.botId).replace(/^【工作组新消息】\n/, '')}`).join('\n\n')}\n\n(更早的消息:before=${page[0]!.seq})`
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'job_start': {
|
|
|
|
|
+ const active = this.jobs.active(team.id)
|
|
|
|
|
+ if (active) throw new Error(`已有进行中的任务「${active.title}」,请先完成(job_complete)或结束(job_fail)它`)
|
|
|
|
|
+ const job = this.jobs.createJob(team.id, String(args.title), String(args.plan ?? ''), this.limitsOf(team), ctx.runId)
|
|
|
|
|
+ this.emitJob(job)
|
|
|
|
|
+ this.botPost(team.id, ref.botId, { text: `开始任务「${job.title}」${job.plan ? `\n\n${job.plan}` : ''}`, card: { kind: 'job', jobId: job.id, event: 'started' } })
|
|
|
|
|
+ rt.posted = true
|
|
|
|
|
+ this.changed(team.id)
|
|
|
|
|
+ return `任务 #${job.no} 已开始。用 task_create 分派子任务。`
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'job_complete':
|
|
|
|
|
+ case 'job_fail': {
|
|
|
|
|
+ const job = this.jobs.active(team.id)
|
|
|
|
|
+ if (!job) throw new Error('当前没有进行中的任务')
|
|
|
|
|
+ const done = name === 'job_complete'
|
|
|
|
|
+ const files = done ? await this.resolvePaths(team, (args.files as string[] | undefined) ?? []) : []
|
|
|
|
|
+ this.endJob(job, done ? 'completed' : 'failed', String(done ? args.summary : args.reason), files, ref.botId)
|
|
|
|
|
+ rt.posted = true
|
|
|
|
|
+ return done ? '任务已完成并交付给成员。' : '已告知成员任务失败。'
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'task_create': {
|
|
|
|
|
+ const job = this.requireRunningJob(team.id)
|
|
|
|
|
+ const botId = String(args.bot_id)
|
|
|
|
|
+ if (this.roleOf(team.id, botId) !== 'worker') throw new Error('bot_id 不是本工作组的组员,请查看 team_members')
|
|
|
|
|
+ const dependsOn = [...new Set((args.depends_on as number[] | undefined) ?? [])]
|
|
|
|
|
+ for (const no of dependsOn) {
|
|
|
|
|
+ if (this.jobs.taskByNo(team.id, no)?.jobId !== job.id) throw new Error(`依赖的子任务 #${no} 不属于当前任务`)
|
|
|
|
|
+ }
|
|
|
|
|
+ const count = this.jobs.tasks(team.id, { jobId: job.id }).length
|
|
|
|
|
+ const max = job.limits.maxTasksPerJob
|
|
|
|
|
+ if (max !== null && count >= max) {
|
|
|
|
|
+ this.blockJob(job, 'maxTasksPerJob')
|
|
|
|
|
+ throw new Error(`已达到子任务数上限(${max}),任务已暂停,等待成员决定继续或取消。请结束本轮回复。`)
|
|
|
|
|
+ }
|
|
|
|
|
+ const task = this.jobs.createTask(job, { botId, title: String(args.title), instructions: String(args.instructions), dependsOn })
|
|
|
|
|
+ this.emitTask(task)
|
|
|
|
|
+ this.botPost(team.id, ref.botId, {
|
|
|
|
|
+ text: `#${task.no} → ${this.botName(botId)}:${task.title}${dependsOn.length ? `(等待 ${dependsOn.map(n => `#${n}`).join('、')} 完成)` : ''}\n\n${task.instructions}`,
|
|
|
|
|
+ card: { kind: 'task', taskId: task.id, no: task.no, event: 'assigned' },
|
|
|
|
|
+ })
|
|
|
|
|
+ rt.posted = true
|
|
|
|
|
+ this.dispatchTasks(team.id)
|
|
|
|
|
+ return `子任务 #${task.no} 已分派给 ${this.botName(botId)}。`
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'task_cancel': {
|
|
|
|
|
+ const task = this.jobs.taskByNo(team.id, Number(args.task_no))
|
|
|
|
|
+ if (!task || !OPEN_TASK.includes(task.status)) throw new Error('子任务不存在或已经结束')
|
|
|
|
|
+ this.cancelTask(task)
|
|
|
|
|
+ return `子任务 #${task.no} 已取消。`
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'task_list': {
|
|
|
|
|
+ const job = this.jobs.active(team.id)
|
|
|
|
|
+ if (!job) return '当前没有进行中的任务。'
|
|
|
|
|
+ const list = this.jobs.tasks(team.id, { jobId: job.id })
|
|
|
|
|
+ if (!list.length) return `任务「${job.title}」还没有子任务。`
|
|
|
|
|
+ return list.map(t => `#${t.no} ${this.botName(t.botId)} · ${t.title} · ${TASK_STATUS[t.status]}${t.dependsOn.length ? ` · 依赖 ${t.dependsOn.map(n => `#${n}`).join('、')}` : ''}${t.result ? `\n 结果:${t.result.slice(0, 500)}` : ''}`).join('\n')
|
|
|
|
|
+ }
|
|
|
|
|
+ case 'task_complete':
|
|
|
|
|
+ case 'task_fail': {
|
|
|
|
|
+ const task = rt.taskId ? this.jobs.task(rt.taskId) : null
|
|
|
|
|
+ if (!task || task.status !== 'running') throw new Error('你当前没有在执行子任务(成员直接 @ 你的工作,直接回复即可)')
|
|
|
|
|
+ rt.taskOutcome = name === 'task_complete'
|
|
|
|
|
+ ? { status: 'completed', result: String(args.result), paths: (args.files as string[] | undefined) ?? [] }
|
|
|
|
|
+ : { status: 'failed', result: String(args.reason), paths: [] }
|
|
|
|
|
+ return '已记录,本轮结束后交给组长。'
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error(`未知的工具:${name}`)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ---- 唤醒与执行 ----
|
|
// ---- 唤醒与执行 ----
|
|
|
|
|
|
|
|
- /** 唤醒机器人:空闲时立即开始一轮;正在执行时记下,本轮结束后再处理 */
|
|
|
|
|
- private wake(teamId: string, botId: string) {
|
|
|
|
|
|
|
+ /** 唤醒机器人:空闲且有事可做时开始一轮;正在执行时记下,本轮结束后再处理 */
|
|
|
|
|
+ private wake(teamId: string, botId: string, opts: { approved?: boolean } = {}) {
|
|
|
const team = this.findRow(teamId)
|
|
const team = this.findRow(teamId)
|
|
|
if (!team || team.status !== 'active') return
|
|
if (!team || team.status !== 'active') return
|
|
|
const rt = this.rt(teamId, botId)
|
|
const rt = this.rt(teamId, botId)
|
|
@@ -305,23 +565,74 @@ export class TeamService {
|
|
|
rt.pending = true
|
|
rt.pending = true
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ const work = this.nextWork(team, botId)
|
|
|
|
|
+ if (!work) {
|
|
|
|
|
+ rt.pending = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ // 工作组同时执行的 Run 数:超出时等其他机器人结束后再唤醒
|
|
|
|
|
+ if (this.busyCount(teamId) >= this.limitsOf(team).maxConcurrentRuns) {
|
|
|
|
|
+ rt.pending = true
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.roleOf(teamId, botId) === 'lead') {
|
|
|
|
|
+ const job = this.jobs.active(teamId)
|
|
|
|
|
+ // 任务因限额暂停时组长不工作,等成员选择继续或取消
|
|
|
|
|
+ if (job?.status === 'blocked') {
|
|
|
|
|
+ rt.pending = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const over = job && this.overLimit(job, true)
|
|
|
|
|
+ if (job && over) {
|
|
|
|
|
+ this.blockJob(job, over)
|
|
|
|
|
+ rt.pending = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (team.debug.stepMode && !opts.approved) {
|
|
|
|
|
+ this.holdStep(team, botId, work)
|
|
|
|
|
+ rt.pending = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
rt.pending = false
|
|
rt.pending = false
|
|
|
rt.starting = true
|
|
rt.starting = true
|
|
|
void this.startRun(team, botId)
|
|
void this.startRun(team, botId)
|
|
|
.catch(err => {
|
|
.catch(err => {
|
|
|
this.deps.log.warn({ err, teamId, botId }, 'team bot run failed to start')
|
|
this.deps.log.warn({ err, teamId, botId }, 'team bot run failed to start')
|
|
|
- this.system(teamId, `${this.botName(botId)} 无法开始工作:${(err as Error).message}`, 'error')
|
|
|
|
|
|
|
+ // 要做的是子任务时,子任务按失败处理并通知组长
|
|
|
|
|
+ const task = rt.taskId ? this.jobs.task(rt.taskId) : this.jobs.tasks(teamId, { botId, status: ['queued'] })[0]
|
|
|
|
|
+ rt.taskId = null
|
|
|
|
|
+ if (task && (task.status === 'queued' || task.status === 'running')) {
|
|
|
|
|
+ this.endTask(task, 'failed', `无法开始:${(err as Error).message}`, [])
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.system(teamId, `${this.botName(botId)} 无法开始工作:${(err as Error).message}`, 'error')
|
|
|
|
|
+ }
|
|
|
})
|
|
})
|
|
|
.finally(() => {
|
|
.finally(() => {
|
|
|
rt.starting = false
|
|
rt.starting = false
|
|
|
- // 没有开始新一轮(没有发给它的新消息或启动失败)但准备期间又来了消息:再检查一次
|
|
|
|
|
- if (rt.pending && rt.status === 'idle') this.wake(teamId, botId)
|
|
|
|
|
|
|
+ // 没有开始新一轮(没有新工作或启动失败)但准备期间又来了消息:再检查一次
|
|
|
|
|
+ if (rt.status === 'idle') {
|
|
|
|
|
+ if (rt.pending) this.wake(teamId, botId)
|
|
|
|
|
+ this.wakeWaiting(teamId)
|
|
|
|
|
+ }
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 机器人的下一项工作:组员先做排队的子任务,再处理发给它的消息 */
|
|
|
|
|
+ private nextWork(team: TeamRow, botId: string): Work | null {
|
|
|
|
|
+ const role = this.roleOf(team.id, botId)
|
|
|
|
|
+ if (!role) return null
|
|
|
|
|
+ if (role === 'worker') {
|
|
|
|
|
+ const task = this.jobs.tasks(team.id, { botId, status: ['queued'] })[0]
|
|
|
|
|
+ if (task) return { kind: 'task', task }
|
|
|
|
|
+ }
|
|
|
|
|
+ const unread = this.unreadFor(team.id, botId, role)
|
|
|
|
|
+ return unread ? { kind: 'messages', ...unread } : null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/** 自上次以来的新消息;只有发给它的消息才会让它开始一轮。组长能看到组里的全部新消息,组员只看到发给它的 */
|
|
/** 自上次以来的新消息;只有发给它的消息才会让它开始一轮。组长能看到组里的全部新消息,组员只看到发给它的 */
|
|
|
private unreadFor(teamId: string, botId: string, role: BotDto['kind']) {
|
|
private unreadFor(teamId: string, botId: string, role: BotDto['kind']) {
|
|
|
- const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
|
|
|
|
|
|
|
+ const link = this.link(teamId, botId)
|
|
|
const unread = this.events.list(teamId, link?.lastReadSeq ?? 0, 5000, ['message.created']) as TeamEventOf<'message.created'>[]
|
|
const unread = this.events.list(teamId, link?.lastReadSeq ?? 0, 5000, ['message.created']) as TeamEventOf<'message.created'>[]
|
|
|
if (!unread.some(e => e.payload.targets.includes(botId))) return null
|
|
if (!unread.some(e => e.payload.targets.includes(botId))) return null
|
|
|
const relevant = unread.filter(e => !(e.payload.sender.kind === 'bot' && e.payload.sender.botId === botId)
|
|
const relevant = unread.filter(e => !(e.payload.sender.kind === 'bot' && e.payload.sender.botId === botId)
|
|
@@ -331,8 +642,8 @@ export class TeamService {
|
|
|
|
|
|
|
|
private async startRun(team: TeamRow, botId: string) {
|
|
private async startRun(team: TeamRow, botId: string) {
|
|
|
const member = this.memberRows(team.id).find(m => m.botId === botId)
|
|
const member = this.memberRows(team.id).find(m => m.botId === botId)
|
|
|
- if (!member || !this.unreadFor(team.id, botId, member.role)) return
|
|
|
|
|
- const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).get()
|
|
|
|
|
|
|
+ if (!member || !this.nextWork(team, botId)) return
|
|
|
|
|
+ const link = this.link(team.id, botId)
|
|
|
const lastRead = link?.lastReadSeq ?? 0
|
|
const lastRead = link?.lastReadSeq ?? 0
|
|
|
|
|
|
|
|
const bot = this.deps.bots.get(botId)
|
|
const bot = this.deps.bots.get(botId)
|
|
@@ -340,7 +651,9 @@ export class TeamService {
|
|
|
const allowed = this.deps.botKeys.allowedBots(team.source)
|
|
const allowed = this.deps.botKeys.allowedBots(team.source)
|
|
|
if (allowed && !allowed.has(botId)) throw new Error('这个机器人已不再对当前 Bot Key 开放')
|
|
if (allowed && !allowed.has(botId)) throw new Error('这个机器人已不再对当前 Bot Key 开放')
|
|
|
const profile = this.deps.profiles.get(bot.profileId)
|
|
const profile = this.deps.profiles.get(bot.profileId)
|
|
|
- const prompt = teamPrompt({ title: team.title, human: team.human, self: bot, bots: this.memberBots(team.id) })
|
|
|
|
|
|
|
+ const members = this.memberBots(team.id)
|
|
|
|
|
+ const cards = member.role === 'lead' ? await this.deps.cards.cards(members.filter(b => this.roleOf(team.id, b.id) === 'worker')) : undefined
|
|
|
|
|
+ const prompt = teamPrompt({ title: team.title, human: team.human, self: bot, bots: members, cards })
|
|
|
const title = `${team.title} · ${bot.name}`
|
|
const title = `${team.title} · ${bot.name}`
|
|
|
|
|
|
|
|
let session: SessionRow | null = null
|
|
let session: SessionRow | null = null
|
|
@@ -371,10 +684,23 @@ export class TeamService {
|
|
|
// 共享目录中其他机器人产生的文件先登记到这个会话的文件索引,本轮结束时只把本轮改动的文件算作它的产出
|
|
// 共享目录中其他机器人产生的文件先登记到这个会话的文件索引,本轮结束时只把本轮改动的文件算作它的产出
|
|
|
await this.deps.files.scan(session.id, team.workspaceDir)
|
|
await this.deps.files.scan(session.id, team.workspaceDir)
|
|
|
const fresh = this.findRow(team.id)
|
|
const fresh = this.findRow(team.id)
|
|
|
- const input = this.unreadFor(team.id, botId, member.role)
|
|
|
|
|
- if (!fresh || fresh.status !== 'active' || !input) return
|
|
|
|
|
- const { relevant, readUpTo } = input
|
|
|
|
|
|
|
+ const work = fresh && fresh.status === 'active' ? this.nextWork(fresh, botId) : null
|
|
|
|
|
+ if (!work) return
|
|
|
|
|
+ const rt = this.rt(team.id, botId)
|
|
|
|
|
|
|
|
|
|
+ if (work.kind === 'task') {
|
|
|
|
|
+ const { task } = work
|
|
|
|
|
+ const job = this.jobs.job(task.jobId)
|
|
|
|
|
+ const deps = task.dependsOn.flatMap(no => this.jobs.taskByNo(team.id, no) ?? [])
|
|
|
|
|
+ rt.taskId = task.id
|
|
|
|
|
+ rt.taskOutcome = null
|
|
|
|
|
+ const run = this.deps.runs.start(session, taskInput(task, job, this.botName(this.leadOf(team.id) ?? ''), deps, id => this.botName(id)), [])
|
|
|
|
|
+ this.emitTask(this.jobs.updateTask(task.id, { status: 'running', runId: run.id, sessionId: session.id, startedAt: nowIso() }))
|
|
|
|
|
+ this.jobs.addRun(job.id, run.id, false)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { relevant, readUpTo } = work
|
|
|
const attachments: RunAttachment[] = []
|
|
const attachments: RunAttachment[] = []
|
|
|
for (const e of relevant) {
|
|
for (const e of relevant) {
|
|
|
for (const f of e.payload.files) {
|
|
for (const f of e.payload.files) {
|
|
@@ -386,9 +712,14 @@ export class TeamService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- this.deps.runs.start(session, teamInput(relevant, botId), attachments)
|
|
|
|
|
|
|
+ const run = this.deps.runs.start(session, teamInput(relevant, botId), attachments)
|
|
|
this.deps.db.update(teamBotSessions).set({ lastReadSeq: readUpTo })
|
|
this.deps.db.update(teamBotSessions).set({ lastReadSeq: readUpTo })
|
|
|
.where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).run()
|
|
.where(and(eq(teamBotSessions.teamId, team.id), eq(teamBotSessions.botId, botId))).run()
|
|
|
|
|
+ const job = member.role === 'lead' ? this.jobs.active(team.id) : undefined
|
|
|
|
|
+ if (job) {
|
|
|
|
|
+ this.jobs.addRun(job.id, run.id, true)
|
|
|
|
|
+ this.emitJob(this.jobs.job(job.id))
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 把机器人会话中的事件转成工作组的状态、交互与回帖 */
|
|
/** 把机器人会话中的事件转成工作组的状态、交互与回帖 */
|
|
@@ -398,7 +729,7 @@ export class TeamService {
|
|
|
const rt = this.rt(ref.teamId, ref.botId)
|
|
const rt = this.rt(ref.teamId, ref.botId)
|
|
|
switch (e.type) {
|
|
switch (e.type) {
|
|
|
case 'run.queued':
|
|
case 'run.queued':
|
|
|
- Object.assign(rt, { lastText: '', changedPaths: new Set<string>() })
|
|
|
|
|
|
|
+ Object.assign(rt, { lastText: '', changedPaths: new Set<string>(), posted: false })
|
|
|
this.setBotStatus(ref, e.sessionId, 'queued', e.runId)
|
|
this.setBotStatus(ref, e.sessionId, 'queued', e.runId)
|
|
|
break
|
|
break
|
|
|
case 'run.started':
|
|
case 'run.started':
|
|
@@ -435,14 +766,12 @@ export class TeamService {
|
|
|
const outputs = this.deps.files.list(team.id).filter(f => rt.changedPaths.has(f.path) && f.path.startsWith('outputs/'))
|
|
const outputs = this.deps.files.list(team.id).filter(f => rt.changedPaths.has(f.path) && f.path.startsWith('outputs/'))
|
|
|
const files: TeamFileRef[] = outputs.map(f => ({ fileId: f.id, path: f.path, mime: f.mime, size: f.size }))
|
|
const files: TeamFileRef[] = outputs.map(f => ({ fileId: f.id, path: f.path, mime: f.mime, size: f.size }))
|
|
|
|
|
|
|
|
- if (e.type === 'run.completed') {
|
|
|
|
|
|
|
+ if (rt.taskId) {
|
|
|
|
|
+ await this.finishTask(team, rt, e, files)
|
|
|
|
|
+ } else if (e.type === 'run.completed') {
|
|
|
if (rt.lastText || files.length) {
|
|
if (rt.lastText || files.length) {
|
|
|
- const bot = this.findBot(ref.botId)
|
|
|
|
|
- this.events.append(team.id, 'message.created', {
|
|
|
|
|
- id: newId('tmsg'), sender: { kind: 'bot', botId: ref.botId, name, avatar: bot?.avatar ?? '🤖' },
|
|
|
|
|
- text: rt.lastText, mentions: [], files, targets: [], runId: e.runId ?? undefined, sessionId: e.sessionId,
|
|
|
|
|
- })
|
|
|
|
|
- } else {
|
|
|
|
|
|
|
+ this.botPost(team.id, ref.botId, { text: rt.lastText, files, runId: e.runId ?? undefined, sessionId: e.sessionId })
|
|
|
|
|
+ } else if (!rt.posted) {
|
|
|
this.system(team.id, `${name} 本轮没有回复`, 'warning', e)
|
|
this.system(team.id, `${name} 本轮没有回复`, 'warning', e)
|
|
|
}
|
|
}
|
|
|
} else if (e.type === 'run.failed') {
|
|
} else if (e.type === 'run.failed') {
|
|
@@ -450,12 +779,245 @@ export class TeamService {
|
|
|
} else if (e.type === 'run.interrupted') {
|
|
} else if (e.type === 'run.interrupted') {
|
|
|
this.system(team.id, `${name} 的本轮工作已中断${e.payload.reason === 'restart' ? '(服务重启)' : ''}`, 'warning', e)
|
|
this.system(team.id, `${name} 的本轮工作已中断${e.payload.reason === 'restart' ? '(服务重启)' : ''}`, 'warning', e)
|
|
|
}
|
|
}
|
|
|
|
|
+ Object.assign(rt, { taskId: null, taskOutcome: null, posted: false })
|
|
|
this.setBotStatus(ref, e.sessionId, 'idle', null)
|
|
this.setBotStatus(ref, e.sessionId, 'idle', null)
|
|
|
- if (rt.pending) this.wake(ref.teamId, ref.botId)
|
|
|
|
|
|
|
+ this.wake(ref.teamId, ref.botId)
|
|
|
|
|
+ this.wakeWaiting(ref.teamId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 子任务的一轮结束:按 task_complete / task_fail 的报告或本轮结果结束子任务 */
|
|
|
|
|
+ private async finishTask(team: TeamRow, rt: BotRuntime, e: AgentEvent, changed: TeamFileRef[]) {
|
|
|
|
|
+ const task = this.jobs.task(rt.taskId!)
|
|
|
|
|
+ // 已被取消(组长取消、成员取消任务):不再汇报
|
|
|
|
|
+ if (task.status !== 'running') return
|
|
|
|
|
+ // 工作组暂停导致的中断:恢复后重新执行
|
|
|
|
|
+ if (e.type === 'run.interrupted' && team.status === 'paused') {
|
|
|
|
|
+ this.emitTask(this.jobs.updateTask(task.id, { status: 'queued', runId: null, startedAt: null }))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const meta = { runId: e.runId ?? undefined, sessionId: e.sessionId }
|
|
|
|
|
+ if (e.type !== 'run.completed') {
|
|
|
|
|
+ const reason = e.type === 'run.failed' ? `执行失败:${e.payload.message}` : '本轮工作被中断'
|
|
|
|
|
+ this.endTask(task, 'failed', reason, changed, meta)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const outcome = rt.taskOutcome
|
|
|
|
|
+ const reported = outcome ? await this.resolvePaths(team, outcome.paths) : []
|
|
|
|
|
+ const files = [...changed, ...reported.filter(f => !changed.some(c => c.path === f.path))]
|
|
|
|
|
+ this.endTask(task, outcome?.status ?? 'completed', outcome?.result ?? (rt.lastText || '(没有填写结果)'), files, meta)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 任务流程 ----
|
|
|
|
|
+
|
|
|
|
|
+ /** 结束子任务:以组员的名义把结果发到工作组,唤醒组长,再看有没有可以开始的子任务 */
|
|
|
|
|
+ private endTask(task: TaskRow, status: 'completed' | 'failed', result: string, files: TeamFileRef[], meta: { runId?: string; sessionId?: string; stub?: boolean } = {}) {
|
|
|
|
|
+ const next = this.jobs.updateTask(task.id, { status, result, files, endedAt: nowIso() })
|
|
|
|
|
+ this.emitTask(next)
|
|
|
|
|
+ const job = this.jobs.job(task.jobId)
|
|
|
|
|
+ const lead = this.leadOf(task.teamId)
|
|
|
|
|
+ const open = job.status === 'running' || job.status === 'blocked'
|
|
|
|
|
+ this.botPost(task.teamId, task.botId, {
|
|
|
|
|
+ text: status === 'failed' ? `失败:${result}` : result, files,
|
|
|
|
|
+ card: { kind: 'task', taskId: task.id, no: task.no, event: status, ...(meta.stub ? { stub: true } : {}) },
|
|
|
|
|
+ targets: open && lead ? [lead] : [], runId: meta.runId, sessionId: meta.sessionId,
|
|
|
|
|
+ })
|
|
|
|
|
+ if (open) this.dispatchTasks(task.teamId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private cancelTask(task: TaskRow) {
|
|
|
|
|
+ this.emitTask(this.jobs.updateTask(task.id, { status: 'cancelled', endedAt: nowIso() }))
|
|
|
|
|
+ if (task.status === 'running') {
|
|
|
|
|
+ const rt = this.rt(task.teamId, task.botId)
|
|
|
|
|
+ if (rt.taskId === task.id) this.interruptBot(task.teamId, task.botId)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.botPost(task.teamId, task.botId, { text: `子任务 #${task.no} 已取消`, card: { kind: 'task', taskId: task.id, no: task.no, event: 'cancelled' } })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 依赖都已完成的子任务:替身等管理员填写,其余排队并唤醒组员 */
|
|
|
|
|
+ private dispatchTasks(teamId: string) {
|
|
|
|
|
+ const team = this.findRow(teamId)
|
|
|
|
|
+ const job = this.jobs.active(teamId)
|
|
|
|
|
+ if (!team || !job) return
|
|
|
|
|
+ const stubs = new Set(team.source === 'admin' ? team.debug.stubBotIds : [])
|
|
|
|
|
+ for (const t of this.jobs.tasks(teamId, { jobId: job.id, status: ['waiting'] })) {
|
|
|
|
|
+ const ready = t.dependsOn.every(no => this.jobs.taskByNo(teamId, no)?.status === 'completed')
|
|
|
|
|
+ if (!ready) continue
|
|
|
|
|
+ if (stubs.has(t.botId)) {
|
|
|
|
|
+ this.emitTask(this.jobs.updateTask(t.id, { status: 'stub', startedAt: nowIso() }))
|
|
|
|
|
+ this.post(teamId, {
|
|
|
|
|
+ sender: { kind: 'system' }, text: `${this.botName(t.botId)} 是替身:请填写子任务 #${t.no}「${t.title}」的结果`,
|
|
|
|
|
+ mentions: [], files: [], targets: [], card: { kind: 'stub', taskId: t.id, no: t.no },
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.emitTask(this.jobs.updateTask(t.id, { status: 'queued' }))
|
|
|
|
|
+ this.wake(teamId, t.botId)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private endJob(job: JobRow, status: 'completed' | 'failed' | 'cancelled', summary: string, files: TeamFileRef[] = [], leadId?: string) {
|
|
|
|
|
+ for (const t of this.jobs.tasks(job.teamId, { jobId: job.id, status: OPEN_TASK })) this.cancelTask(t)
|
|
|
|
|
+ const next = this.jobs.updateJob(job.id, this.jobs.end(status, { summary, files }))
|
|
|
|
|
+ this.emitJob(next)
|
|
|
|
|
+ const card: TeamCard = { kind: 'job', jobId: job.id, event: status }
|
|
|
|
|
+ const head = { completed: '已完成', failed: '失败', cancelled: '已取消' }[status]
|
|
|
|
|
+ const text = `任务「${job.title}」${head}\n\n${summary}`
|
|
|
|
|
+ if (leadId) this.botPost(job.teamId, leadId, { text, files, card })
|
|
|
|
|
+ else this.post(job.teamId, { sender: { kind: 'system' }, text, mentions: [], files, targets: [], card, level: status === 'completed' ? 'info' : 'warning' })
|
|
|
|
|
+ this.changed(job.teamId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private requireRunningJob(teamId: string): JobRow {
|
|
|
|
|
+ const job = this.jobs.active(teamId)
|
|
|
|
|
+ if (!job) throw new Error('当前没有进行中的任务,请先调用 job_start')
|
|
|
|
|
+ if (job.status === 'blocked') throw new Error('任务因超出限额已暂停,等待成员决定。请结束本轮回复')
|
|
|
|
|
+ return job
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 超出的限额(子任务数在分派时检查) */
|
|
|
|
|
+ private overLimit(job: JobRow, leadRun: boolean): TeamLimitKey | null {
|
|
|
|
|
+ const l = job.limits
|
|
|
|
|
+ if (leadRun && l.maxLeadRunsPerJob !== null && job.leadRuns >= l.maxLeadRunsPerJob) return 'maxLeadRunsPerJob'
|
|
|
|
|
+ if (l.maxMinutesPerJob !== null && Date.now() - Date.parse(job.createdAt) > l.maxMinutesPerJob * 60_000) return 'maxMinutesPerJob'
|
|
|
|
|
+ if (l.maxCostUsdPerJob !== null && this.jobs.costOf(job.runIds) >= l.maxCostUsdPerJob) return 'maxCostUsdPerJob'
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private blockJob(job: JobRow, limit: TeamLimitKey) {
|
|
|
|
|
+ const next = this.jobs.updateJob(job.id, { status: 'blocked', blockedBy: limit })
|
|
|
|
|
+ this.emitJob(next)
|
|
|
|
|
+ this.post(job.teamId, {
|
|
|
|
|
+ sender: { kind: 'system' }, level: 'warning', mentions: [], files: [], targets: [], card: { kind: 'limit', jobId: job.id, limit },
|
|
|
|
|
+ text: `任务「${job.title}」超出${LIMIT_LABELS[limit]}上限(${job.limits[limit]}),已暂停。请选择继续(这一项放宽一倍)或取消任务。`,
|
|
|
|
|
+ })
|
|
|
|
|
+ this.changed(job.teamId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 逐步执行 ----
|
|
|
|
|
+
|
|
|
|
|
+ private holdStep(team: TeamRow, botId: string, work: Work) {
|
|
|
|
|
+ const list = this.steps.get(team.id) ?? []
|
|
|
|
|
+ if (list.some(s => s.botId === botId)) return
|
|
|
|
|
+ const reason = work.kind === 'task' ? `子任务 #${work.task.no}:${work.task.title}` : `处理 ${work.relevant.length} 条新消息`
|
|
|
|
|
+ const step: TeamStepDto = { id: newId('step'), botId, reason, createdAt: nowIso() }
|
|
|
|
|
+ this.steps.set(team.id, [...list, step])
|
|
|
|
|
+ this.events.append(team.id, 'step.updated', { steps: this.steps.get(team.id)! })
|
|
|
|
|
+ this.post(team.id, {
|
|
|
|
|
+ sender: { kind: 'system' }, text: `下一步:唤醒 ${this.botName(botId)}(${reason})`, mentions: [], files: [], targets: [],
|
|
|
|
|
+ card: { kind: 'step', stepId: step.id },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private dropStep(teamId: string, botId: string) {
|
|
|
|
|
+ const list = this.steps.get(teamId) ?? []
|
|
|
|
|
+ if (!list.some(s => s.botId === botId)) return
|
|
|
|
|
+ this.steps.set(teamId, list.filter(s => s.botId !== botId))
|
|
|
|
|
+ this.events.append(teamId, 'step.updated', { steps: this.steps.get(teamId)! })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private clearSteps(teamId: string) {
|
|
|
|
|
+ if (!this.steps.get(teamId)?.length) return
|
|
|
|
|
+ this.steps.set(teamId, [])
|
|
|
|
|
+ this.events.append(teamId, 'step.updated', { steps: [] })
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ---- 内部工具 ----
|
|
// ---- 内部工具 ----
|
|
|
|
|
|
|
|
|
|
+ /** 发一条消息并唤醒它的目标 */
|
|
|
|
|
+ private post(teamId: string, m: Omit<TeamMessage, 'id'>): TeamMessage {
|
|
|
|
|
+ const message: TeamMessage = { id: newId('tmsg'), ...m }
|
|
|
|
|
+ this.events.append(teamId, 'message.created', message)
|
|
|
|
|
+ if (this.findRow(teamId)?.status === 'active') for (const t of message.targets) this.wake(teamId, t)
|
|
|
|
|
+ return message
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private botPost(teamId: string, botId: string, m: Partial<TeamMessage> & { text: string }) {
|
|
|
|
|
+ const bot = this.findBot(botId)
|
|
|
|
|
+ return this.post(teamId, {
|
|
|
|
|
+ mentions: [], files: [], targets: [], ...m,
|
|
|
|
|
+ sender: { kind: 'bot', botId, name: bot?.name ?? '(已删除的机器人)', avatar: bot?.avatar ?? '🤖' },
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 机器人加入工作组(成员直接添加,或组长申请经批准) */
|
|
|
|
|
+ private join(team: TeamRow, bot: BotDto, note?: string) {
|
|
|
|
|
+ if (team.status === 'closed') throw conflict('team_closed', '工作组已关闭')
|
|
|
|
|
+ const members = this.memberRows(team.id)
|
|
|
|
|
+ if (members.some(m => m.botId === bot.id)) throw conflict('already_member', '这个机器人已经在工作组中')
|
|
|
|
|
+ if (bot.kind === 'lead' && members.some(m => m.role === 'lead')) throw badRequest('工作组已经有组长,最多只能有一个')
|
|
|
|
|
+ this.deps.db.insert(teamBots).values({ teamId: team.id, botId: bot.id, role: bot.kind }).run()
|
|
|
|
|
+ this.system(team.id, `${bot.name}(${bot.kind === 'lead' ? '组长' : '组员'})加入了工作组${note ? `(${note})` : ''}`)
|
|
|
|
|
+ this.changed(team.id)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 组长可以申请加入的机器人:来源可用、已启用、不在本组的组员 */
|
|
|
|
|
+ private catalog(team: TeamRow): BotDto[] {
|
|
|
|
|
+ const allowed = this.deps.botKeys.allowedBots(team.source)
|
|
|
|
|
+ const members = new Set(this.memberRows(team.id).map(m => m.botId))
|
|
|
|
|
+ return this.deps.bots.list().filter(b => b.kind === 'worker' && b.enabled && !members.has(b.id) && (!allowed || allowed.has(b.id)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private limitsOf(r: TeamRow): TeamLimits {
|
|
|
|
|
+ return { ...this.deps.settings.get().teamLimits, ...r.limits }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 工作组的限额只能比系统设置低 */
|
|
|
|
|
+ private lowerLimits(base: TeamLimits, patch: Partial<TeamLimits>): Partial<TeamLimits> {
|
|
|
|
|
+ for (const [k, v] of Object.entries(patch) as [keyof TeamLimits, number | null][]) {
|
|
|
|
|
+ const cap = base[k]
|
|
|
|
|
+ if (cap !== null && (v === null || v > cap)) throw badRequest(`限额 ${k} 不能超过系统设置(${cap})`)
|
|
|
|
|
+ }
|
|
|
|
|
+ return patch
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private checkDebug(source: string, current: TeamDebug, patch: Partial<TeamDebug>): TeamDebug {
|
|
|
|
|
+ if (source !== 'admin') throw new AppError(403, 'forbidden', '只有后台(Bot Playground)可以设置调试选项')
|
|
|
|
|
+ return { ...current, ...patch }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 调试设置变化:替身开关影响等待中的子任务,关闭逐步执行时放行所有步骤 */
|
|
|
|
|
+ private applyDebug(team: TeamRow, debug: TeamDebug) {
|
|
|
|
|
+ const stubs = new Set(debug.stubBotIds)
|
|
|
|
|
+ for (const t of this.jobs.tasks(team.id, { status: ['stub'] })) {
|
|
|
|
|
+ if (!stubs.has(t.botId)) {
|
|
|
|
|
+ this.emitTask(this.jobs.updateTask(t.id, { status: 'queued', startedAt: null }))
|
|
|
|
|
+ this.wake(team.id, t.botId)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const t of this.jobs.tasks(team.id, { status: ['queued'] })) {
|
|
|
|
|
+ if (stubs.has(t.botId)) this.emitTask(this.jobs.updateTask(t.id, { status: 'waiting' }))
|
|
|
|
|
+ }
|
|
|
|
|
+ this.dispatchTasks(team.id)
|
|
|
|
|
+ if (!debug.stepMode) {
|
|
|
|
|
+ const held = this.steps.get(team.id) ?? []
|
|
|
|
|
+ this.clearSteps(team.id)
|
|
|
|
|
+ for (const s of held) this.wake(team.id, s.botId, { approved: true })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 交付文件的路径 → 文件引用(只接受共享目录中已登记的文件) */
|
|
|
|
|
+ private async resolvePaths(team: TeamRow, paths: string[]): Promise<TeamFileRef[]> {
|
|
|
|
|
+ if (!paths.length) return []
|
|
|
|
|
+ await this.scanFiles(team)
|
|
|
|
|
+ const all = this.deps.files.list(team.id)
|
|
|
|
|
+ const out: TeamFileRef[] = []
|
|
|
|
|
+ for (const p of paths) {
|
|
|
|
|
+ const rel = path.normalize(p.replace(/^\.?\/+/, '')).replace(/^(\.\.(\/|$))+/, '')
|
|
|
|
|
+ const f = all.find(x => x.path === rel || path.join(team.workspaceDir, x.path) === p)
|
|
|
|
|
+ if (!f) throw new Error(`共享目录中没有这个文件:${p}`)
|
|
|
|
|
+ out.push({ fileId: f.id, path: f.path, mime: f.mime, size: f.size })
|
|
|
|
|
+ }
|
|
|
|
|
+ return out
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private emitJob(j: JobRow) {
|
|
|
|
|
+ this.events.append(j.teamId, 'job.updated', this.jobs.jobDto(j))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private emitTask(t: TaskRow) {
|
|
|
|
|
+ this.events.append(t.teamId, 'task.updated', this.jobs.taskDto(t))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private setBotStatus(ref: { teamId: string; botId: string }, sessionId: string, status: TeamBotStatus, runId: string | null) {
|
|
private setBotStatus(ref: { teamId: string; botId: string }, sessionId: string, status: TeamBotStatus, runId: string | null) {
|
|
|
const rt = this.rt(ref.teamId, ref.botId)
|
|
const rt = this.rt(ref.teamId, ref.botId)
|
|
|
rt.status = status
|
|
rt.status = status
|
|
@@ -464,15 +1026,34 @@ export class TeamService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private interruptBot(teamId: string, botId: string) {
|
|
private interruptBot(teamId: string, botId: string) {
|
|
|
- const link = this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
|
|
|
|
|
|
|
+ const link = this.link(teamId, botId)
|
|
|
const active = link && this.deps.runs.activeForSession(link.sessionId)
|
|
const active = link && this.deps.runs.activeForSession(link.sessionId)
|
|
|
if (active) this.deps.runs.interrupt(active.runId)
|
|
if (active) this.deps.runs.interrupt(active.runId)
|
|
|
this.rt(teamId, botId).pending = false
|
|
this.rt(teamId, botId).pending = false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 正在执行或准备执行的机器人数 */
|
|
|
|
|
+ private busyCount(teamId: string) {
|
|
|
|
|
+ let n = 0
|
|
|
|
|
+ for (const [k, r] of this.runtime) if (k.startsWith(`${teamId}/`) && (r.status !== 'idle' || r.starting)) n++
|
|
|
|
|
+ return n
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 因并发上限而等待的机器人 */
|
|
|
|
|
+ private wakeWaiting(teamId: string) {
|
|
|
|
|
+ for (const m of this.memberRows(teamId)) {
|
|
|
|
|
+ const r = this.rt(teamId, m.botId)
|
|
|
|
|
+ if (r.pending && r.status === 'idle' && !r.starting) this.wake(teamId, m.botId)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private wakeAll(teamId: string) {
|
|
|
|
|
+ for (const m of this.memberRows(teamId)) this.wake(teamId, m.botId)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private system(teamId: string, text: string, level: TeamMessage['level'] = 'info', e?: AgentEvent) {
|
|
private system(teamId: string, text: string, level: TeamMessage['level'] = 'info', e?: AgentEvent) {
|
|
|
- this.events.append(teamId, 'message.created', {
|
|
|
|
|
- id: newId('tmsg'), sender: { kind: 'system' }, text, mentions: [], files: [], targets: [], level,
|
|
|
|
|
|
|
+ this.post(teamId, {
|
|
|
|
|
+ sender: { kind: 'system' }, text, mentions: [], files: [], targets: [], level,
|
|
|
...(e ? { runId: e.runId ?? undefined, sessionId: e.sessionId } : {}),
|
|
...(e ? { runId: e.runId ?? undefined, sessionId: e.sessionId } : {}),
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
@@ -482,8 +1063,17 @@ export class TeamService {
|
|
|
if (row) this.events.append(teamId, 'team.updated', this.toDto(row))
|
|
if (row) this.events.append(teamId, 'team.updated', this.toDto(row))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private async scanFiles(team: TeamRow) {
|
|
|
|
|
- for (const c of await this.deps.files.scan(team.id, team.workspaceDir)) this.events.append(team.id, 'file.changed', c)
|
|
|
|
|
|
|
+ /** 同一工作组的扫描依次进行:并发扫描会把同一个新文件登记两次 */
|
|
|
|
|
+ private scanFiles(team: TeamRow): Promise<void> {
|
|
|
|
|
+ const prev = this.scans.get(team.id) ?? Promise.resolve()
|
|
|
|
|
+ const next = prev.catch(() => {}).then(async () => {
|
|
|
|
|
+ for (const c of await this.deps.files.scan(team.id, team.workspaceDir)) this.events.append(team.id, 'file.changed', c)
|
|
|
|
|
+ })
|
|
|
|
|
+ this.scans.set(team.id, next)
|
|
|
|
|
+ void next.finally(() => {
|
|
|
|
|
+ if (this.scans.get(team.id) === next) this.scans.delete(team.id)
|
|
|
|
|
+ }).catch(() => {})
|
|
|
|
|
+ return next
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private fileRef(teamId: string, fileId: string): TeamFileRef {
|
|
private fileRef(teamId: string, fileId: string): TeamFileRef {
|
|
@@ -506,10 +1096,22 @@ export class TeamService {
|
|
|
return this.deps.db.select({ id: sessions.id }).from(sessions).where(eq(sessions.source, `${TEAM_SOURCE_PREFIX}${teamId}`)).all().map(r => r.id)
|
|
return this.deps.db.select({ id: sessions.id }).from(sessions).where(eq(sessions.source, `${TEAM_SOURCE_PREFIX}${teamId}`)).all().map(r => r.id)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private link(teamId: string, botId: string) {
|
|
|
|
|
+ return this.deps.db.select().from(teamBotSessions).where(and(eq(teamBotSessions.teamId, teamId), eq(teamBotSessions.botId, botId))).get()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private memberRows(teamId: string) {
|
|
private memberRows(teamId: string) {
|
|
|
return this.deps.db.select().from(teamBots).where(eq(teamBots.teamId, teamId)).all()
|
|
return this.deps.db.select().from(teamBots).where(eq(teamBots.teamId, teamId)).all()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private roleOf(teamId: string, botId: string): BotDto['kind'] | undefined {
|
|
|
|
|
+ return this.memberRows(teamId).find(m => m.botId === botId)?.role
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private leadOf(teamId: string): string | undefined {
|
|
|
|
|
+ return this.memberRows(teamId).find(m => m.role === 'lead')?.botId
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private memberBots(teamId: string): BotDto[] {
|
|
private memberBots(teamId: string): BotDto[] {
|
|
|
const ids = this.memberRows(teamId).map(m => m.botId)
|
|
const ids = this.memberRows(teamId).map(m => m.botId)
|
|
|
return ids.length ? this.deps.bots.list().filter(b => ids.includes(b.id)) : []
|
|
return ids.length ? this.deps.bots.list().filter(b => ids.includes(b.id)) : []
|
|
@@ -534,7 +1136,7 @@ export class TeamService {
|
|
|
private rt(teamId: string, botId: string): BotRuntime {
|
|
private rt(teamId: string, botId: string): BotRuntime {
|
|
|
let r = this.runtime.get(key(teamId, botId))
|
|
let r = this.runtime.get(key(teamId, botId))
|
|
|
if (!r) {
|
|
if (!r) {
|
|
|
- r = { status: 'idle', runId: null, pending: false, starting: false, lastText: '', changedPaths: new Set() }
|
|
|
|
|
|
|
+ r = { status: 'idle', runId: null, pending: false, starting: false, lastText: '', changedPaths: new Set(), taskId: null, taskOutcome: null, posted: false }
|
|
|
this.runtime.set(key(teamId, botId), r)
|
|
this.runtime.set(key(teamId, botId), r)
|
|
|
}
|
|
}
|
|
|
return r
|
|
return r
|
|
@@ -550,3 +1152,5 @@ export class TeamService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const key = (teamId: string, botId: string) => `${teamId}/${botId}`
|
|
const key = (teamId: string, botId: string) => `${teamId}/${botId}`
|
|
|
|
|
+
|
|
|
|
|
+const TASK_STATUS = { waiting: '等待依赖', queued: '排队中', running: '执行中', stub: '等待替身结果', completed: '已完成', failed: '失败', cancelled: '已取消' } as const
|