dsh-taskboard 0.3.3 → 0.4.0
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.
- package/README.md +22 -6
- package/lib/client.js +1192 -39
- package/lib/host/execution.js +6 -1
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +95 -2
- package/lib/host/git.js.map +1 -1
- package/lib/host/protocol-text.js +5 -3
- package/lib/host/protocol-text.js.map +1 -1
- package/lib/host/routes.js +184 -2
- package/lib/host/routes.js.map +1 -1
- package/lib/host/store.js +12 -0
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +166 -0
- package/lib/host/templates.js.map +1 -0
- package/lib/host/tools.js +202 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +7 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +277 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/src/client/api.ts +28 -0
- package/src/client/board/ImportModal.tsx +182 -0
- package/src/client/board/TaskBoard.tsx +45 -3
- package/src/client/board/TaskCard.tsx +9 -0
- package/src/client/board/TaskDetail.tsx +192 -8
- package/src/client/board/TaskFormModal.tsx +100 -18
- package/src/client/board/TemplateManager.tsx +121 -0
- package/src/client/controller.ts +152 -8
- package/src/client/styles.ts +153 -0
- package/src/host/execution.ts +10 -2
- package/src/host/git.ts +77 -0
- package/src/host/protocol-text.ts +5 -3
- package/src/host/routes.ts +215 -0
- package/src/host/store.ts +13 -0
- package/src/host/templates.ts +143 -0
- package/src/host/tools.ts +198 -2
- package/src/index.ts +6 -0
- package/src/shared/api.ts +54 -0
- package/src/shared/protocol.ts +344 -0
- package/src/shared/version.ts +1 -1
package/lib/host/execution.js
CHANGED
|
@@ -433,7 +433,12 @@ var ExecutionService = class {
|
|
|
433
433
|
* @param degradeNote - why a worktree task degraded to the main directory.
|
|
434
434
|
*/
|
|
435
435
|
pluginFraming(task, prepared, degradeNote) {
|
|
436
|
-
let text = `【任务看板】${task.title}(ID: ${task.id})\n本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n完成后按序交接:\n1. taskboard_get 读取本任务,取得最新 version\n2. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\
|
|
436
|
+
let text = `【任务看板】${task.title}(ID: ${task.id})\n本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n完成后按序交接:\n1. taskboard_get 读取本任务,取得最新 version\n2. taskboard_execution_report 提交结构化执行报告(做了什么/改了哪些文件/如何验证/剩余风险;提交与评论不冲突,都会展示给验收人)\n3. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\n4. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\n若无法完成:留评论说明原因,将任务移回待办 todo。`;
|
|
437
|
+
if (task.checklist !== void 0 && task.checklist.length > 0) {
|
|
438
|
+
const items = task.checklist.map((item, index) => `${item.checked ? "☑" : "☐"} ${index + 1}. ${item.text}${item.note !== void 0 ? `(证据: ${item.note})` : ""}`).join("\n");
|
|
439
|
+
const done = task.checklist.filter((i) => i.checked).length;
|
|
440
|
+
text += `\n本任务有验收清单(DoD,${done}/${task.checklist.length} 已完成)——按清单干活:\n${items}\n完成一项就用 taskboard_checklist(action=check,附 note 证据)勾选;未完成项会在验收时高亮,全部完成再移待验收。需要补充验收项也可用 action=add 追加。`;
|
|
441
|
+
}
|
|
437
442
|
if (prepared !== void 0) if (prepared.reused === true) text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\n${prepared.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`;
|
|
438
443
|
else text += `\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${prepared.branch} 的全新 worktree——\n${prepared.worktreePath}\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`;
|
|
439
444
|
else if (degradeNote !== void 0) text += `\n⚠ 本次执行未能建立隔离,正在主项目目录中工作(原因:${degradeNote})。该目录可能有他人未提交的改动:动手前先 git status 检查现状,改动尽量集中,结束时在评论中说明动了哪些文件;避免把未经验证的改动直接提交到主分支。`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"execution.js","names":[],"sources":["../../src/host/execution.ts"],"sourcesContent":["/**\n * Host execution service: runs a task through dsh's REAL session machinery —\n * a fresh agent+session inside the task's project workspace (creation carries\n * the pinned model when the task has one), the session is attached to the\n * workspace so it appears in the GUI's project session list, the effective\n * prompt is submitted as an ordinary user message, and the turn settlement\n * (turn/end reason) is folded back into the task's execution record.\n *\n * Every execution is a NEW session: clean context, no reuse of previous runs.\n *\n * @module dsh-taskboard/host/execution\n */\nimport {\n effectiveIsolation,\n effectivePrompt,\n newCommentId,\n newExecutionId,\n normalizeBody,\n type ExecutionRecord,\n type IsolationMode,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport { sanitizeBranchName, worktreePathOf, type GitFace, type SettlementFacts } from './git.ts'\nimport { MessageId } from './sdk.ts'\nimport type { TaskStore } from './store.ts'\n\n/** Default cap on concurrently running executions (env-overridable). */\nexport const DEFAULT_MAX_CONCURRENT = 3\n\n/** Narrow agents face (the registry's create, structurally). */\nexport interface AgentsFace {\n create(options: {\n sessionId: string\n meta?: { cwd?: string; agentPreset?: string }\n agentOptions?: { provider?: string; model?: string }\n /** Preset composition callback: mounts tools/persona into the agent's scoped context. */\n setup?: (agentCtx: unknown) => Promise<void> | void\n }): Promise<{\n agent: {\n id: string\n followup(message: unknown): void\n inject(message: unknown): void\n whenIdle(): Promise<void>\n }\n dispose(): Promise<void>\n }>\n}\n\n/**\n * The preset composition an execution session is built from — the shape\n * apiproxy's ensureSession produces: resolve → record on the session header,\n * mount → inside agents.create's setup callback.\n */\nexport interface AgentComposition {\n /** The resolved preset id recorded on the session header. */\n agentPreset: string\n /** Mounts the preset's plugins (tools, persona) into the agent's scope. */\n setup: (agentCtx: unknown) => Promise<void> | void\n}\n\n/** Narrow workspaces face for execution. */\nexport interface ExecutionWorkspaceFace {\n get(id: string): { id: string; path: string } | undefined\n attach(workspaceId: string, sessionId: string): Promise<void>\n}\n\n/** Narrow event-bus face for settlement listening. */\nexport interface EventsFace {\n onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }) => void): () => void\n}\n\n/** Everything the execution service needs. */\nexport interface ExecutionDeps {\n store: TaskStore\n agents: AgentsFace\n workspaces: ExecutionWorkspaceFace\n events: EventsFace\n now: () => number\n /** The deployment default model (fills sessions of unpinned tasks). */\n defaultModel?: () => { provider: string; model: string } | undefined\n /** Mint session ids (injectable for tests). */\n mintSessionId?: () => string\n /** Mint message ids (injectable for tests). */\n mintMessageId?: () => string\n /** Best-effort session rename (pins the session list title to the task title). */\n renameSession?: (sessionId: string, title: string) => void\n /** Max concurrently running executions across all tasks (default 3). */\n maxConcurrent?: number\n /**\n * Git face for worktree isolation (0.3.0). Absent → every worktree-mode\n * task degrades to the original directory with an isolationNote.\n */\n git?: GitFace\n /**\n * Resolve the preset composition for an execution session (0.3.3): hands\n * the session its tool set. Absent → sessions run on the bare host\n * composition (pre-preset behavior). A rejection fails the run through\n * the existing failure path — a broken preset never yields a half-composed\n * session (same rollback semantics as apiproxy).\n */\n composeAgent?: (presetId?: string) => Promise<AgentComposition | undefined>\n}\n\n/** Outcome of a run request (immediate; the run settles asynchronously). */\nexport type RunRequestResult =\n | { ok: true; executionId: string; sessionId: string }\n | { ok: false; error: string }\n\n/** Outcome of a cancel request. */\nexport type CancelRequestResult =\n | { ok: true; executionId: string }\n | { ok: false; error: string }\n\n/** Whether a turn/end payload closed with an error reason. */\nfunction isErrorTurnEnd(data: unknown): { message: string } | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n const reason = (data as { reason?: unknown }).reason\n if (typeof reason !== 'object' || reason === null) return undefined\n const kind = (reason as { kind?: unknown }).kind\n if (kind !== 'error') return undefined\n const error = (reason as { error?: { message?: unknown } }).error\n const detail = JSON.stringify(error) ?? ''\n const message = typeof error?.message === 'string' ? error.message : 'turn failed'\n console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))\n void detail\n return { message }\n}\n\n/** Prepared worktree facts threaded through a live run (settlement evidence). */\nexport interface PreparedWorktree {\n branch: string\n worktreePath: string\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Per-run options. */\nexport interface RunOptions {\n /**\n * 续跑: keep a live worktree/branch exactly as-is (the previous agent's\n * commits and uncommitted changes survive) instead of resetting to the\n * main HEAD. Falls back to a fresh preparation when none is alive.\n */\n reuseWorktree?: boolean\n}\n\n/** One live execution tracked for settlement and cancellation. */\ninterface RunEntry {\n sessionId: string\n /** Worktree prepared for this run (evidence collection at ANY settlement). */\n prepared?: PreparedWorktree\n settle: () => void\n dispose: () => Promise<void>\n}\n\n/**\n * The execution service.\n */\nexport class ExecutionService {\n /** Live executions by execution id (settles and cancels remove entries). */\n private readonly runs = new Map<string, RunEntry>()\n\n /** @param deps - store + agents + workspaces + events + clock. */\n constructor(private readonly deps: ExecutionDeps) {\n deps.events.onSessionEvent((sessionId, event) => {\n if (event.type !== 'turn/end') return\n const failure = isErrorTurnEnd(event.data)\n if (failure !== undefined) this.noteFailure(sessionId, failure.message)\n })\n }\n\n /**\n * Best-effort evidence collection for a prepared run (fail-soft: undefined\n * on any git problem — settlement NEVER blocks on git).\n */\n private async collectEvidence(prepared: PreparedWorktree | undefined): Promise<SettlementFacts | undefined> {\n if (prepared === undefined || this.deps.git === undefined) return undefined\n try {\n return await this.deps.git.collect(prepared.worktreePath, prepared.baseCommit)\n } catch {\n return undefined\n }\n }\n\n /** Copy collected facts onto an execution record (in place). */\n private applyFacts(execution: ExecutionRecord, facts: SettlementFacts | undefined): void {\n if (facts === undefined) return\n if (facts.headCommit !== undefined) execution.headCommit = facts.headCommit\n execution.commits = facts.commits\n execution.commitsTotal = facts.commitsTotal\n execution.dirtyFiles = facts.dirtyFiles\n execution.dirtyFilesTotal = facts.dirtyFilesTotal\n execution.changedFiles = facts.changedFiles\n if (facts.diffStat !== undefined) execution.diffStat = facts.diffStat\n }\n\n /** Record a turn failure against the running execution of that session and give the task back. */\n private noteFailure(sessionId: string, message: string): void {\n // The failed session may already have committed work — collect the\n // evidence (best effort) BEFORE marking the execution failed (0.3.1).\n const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)\n void this.collectEvidence(entry?.prepared).then(facts => {\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n for (const execution of task.executions) {\n if (execution.sessionId === sessionId && execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = message.slice(0, 500)\n execution.endedAt = this.deps.now()\n this.applyFacts(execution, facts)\n // The failed session will not finish the work: hand the task back\n // instead of leaving it stuck in in_progress forever — and leave a\n // system comment so the GUI shows why.\n if (task.status === 'in_progress' && task.claimedBy === sessionId) {\n task.status = 'todo'\n task.updatedAt = this.deps.now()\n delete task.claimedBy\n delete task.claimedAt\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),\n version: 1,\n createdAt: this.deps.now(),\n })\n }\n return [task]\n }\n }\n }\n return undefined\n })\n })\n }\n\n /** Patch one task's execution record in the ledger. */\n private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n const execution = task.executions.find(e => e.id === executionId)\n if (execution !== undefined) {\n Object.assign(execution, patch)\n return [task]\n }\n }\n return undefined\n })\n }\n\n /**\n * Run one task now (manual button or scheduler tick).\n *\n * The in-progress gate and the execution-open write happen inside ONE\n * serial-queue mutation, so two overlapping run() calls (double click,\n * overlapping scheduler ticks) can never both pass — exactly one session\n * is opened per task.\n * @param taskId - the task to run.\n * @param trigger - what started it.\n * @param options - per-run options (`reuseWorktree` = 续跑).\n * @returns the immediate result; settlement lands in the ledger.\n */\n async run(taskId: string, trigger: ExecutionRecord['trigger'], options?: RunOptions): Promise<RunRequestResult> {\n const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT\n if (this.runs.size >= max) {\n return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }\n }\n const task = this.deps.store.get(taskId)\n if (task === undefined || task.trashedAt !== undefined) {\n return { ok: false, error: `no task ${taskId}` }\n }\n const workspace = this.deps.workspaces.get(task.workspaceId)\n if (workspace === undefined) {\n return { ok: false, error: `unknown workspace ${task.workspaceId}` }\n }\n\n const executionId = newExecutionId()\n const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`\n\n // 0. Resolve code isolation (plan §3.2): explicit 'none' → zero git calls;\n // 'worktree' (also the omitted default) → prepare below, degrading to\n // the original directory fail-soft on any git problem.\n const isolation: IsolationMode = effectiveIsolation(task)\n const branch = task.branch ?? sanitizeBranchName(task.title, task.id)\n const worktreePath = worktreePathOf(workspace.path, task.id)\n\n // 1. Open the execution record, flip the card to in_progress, and record\n // the executing session as the claim holder — atomically.\n let gate: string | undefined\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined || target.trashedAt !== undefined) {\n gate = `no task ${taskId}`\n return undefined\n }\n if (target.status === 'in_progress') {\n gate = 'task is already in progress'\n return undefined\n }\n target.executions.push({\n id: executionId,\n trigger,\n startedAt: this.deps.now(),\n outcome: 'running',\n ...(isolation === 'none' ? { isolation: 'none' as const } : { isolation: 'worktree' as const, branch }),\n })\n target.status = 'in_progress'\n target.updatedAt = this.deps.now()\n target.updatedBy = { kind: 'user' }\n target.claimedBy = sessionId\n target.claimedAt = this.deps.now()\n return [target]\n })\n if (gate !== undefined) return { ok: false, error: gate }\n\n // 1b. Worktree preparation (fail-soft): any failure degrades this run to\n // the original directory with an isolationNote — the ledger and the\n // execution pipeline itself never fail over git.\n let isolationNote: string | undefined\n let prepared: PreparedWorktree | undefined\n if (isolation === 'worktree') {\n const outcome = await this.prepareIsolation(task, workspace.path, worktreePath, branch, options?.reuseWorktree === true)\n if (outcome.prepared !== undefined) {\n prepared = outcome.prepared\n // Persist the isolation facts of the run (branch is already on the\n // record from the gate mutation).\n await this.patchExecution(executionId, {\n worktreePath: outcome.prepared.worktreePath,\n baseCommit: outcome.prepared.baseCommit,\n })\n } else {\n isolationNote = outcome.note\n // Degraded run: clear the optimistic worktree markers.\n await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })\n }\n }\n\n // 2. Create the fresh agent+session inside the task's project, carrying\n // the pinned model — or the deployment default when unpinned (the\n // persona template renders {{model}}, so the session always needs one).\n // The session cwd is ALWAYS the project root: DSH's session model\n // requires cwd === the workspace path EXACTLY (attachSession validates\n // it, the sidebar groups by it, and the file sandbox takes it as the\n // workspace-write boundary) — a subdirectory cwd (the worktree) breaks\n // all three. The worktree is instead handed to the agent explicitly in\n // the framing line below.\n // Preset composition (0.3.3): resolve BEFORE creation so the header\n // snapshots `agentPreset` and the setup callback mounts the preset's\n // tools/persona into the agent's scope. undefined composeAgent (or an\n // absent preset roster) keeps the bare host composition.\n let composition: AgentComposition | undefined\n try {\n composition = this.deps.composeAgent === undefined ? undefined : await this.deps.composeAgent(task.presetId)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: `preset 组合失败:${message.slice(0, 400)}`, endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: `preset composition failed: ${message}` }\n }\n let handle: Awaited<ReturnType<AgentsFace['create']>>\n try {\n const model = task.model ?? this.deps.defaultModel?.()\n handle = await this.deps.agents.create({\n sessionId,\n meta: {\n cwd: workspace.path,\n ...(composition !== undefined ? { agentPreset: composition.agentPreset } : {}),\n },\n ...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),\n ...(composition !== undefined ? { setup: composition.setup } : {}),\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: message }\n }\n\n // 3. Attach the session to the workspace (GUI project session list).\n await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })\n\n // 3b. Best-effort rename: pin the session title to the task title so the\n // session list shows the task name (a user-sourced title also stops\n // automatic first-prompt retitling).\n try {\n this.deps.renameSession?.(sessionId, task.title)\n } catch { /* cosmetic */ }\n\n // 4. Record the session id (execution is really started now).\n await this.patchExecution(executionId, { sessionId })\n\n // 5. Submit the opening pair and settle on quiescence (turn/end errors\n // were already folded by the listener). Two messages, ONE turn:\n // - inject() queues the plugin framing line (next-step, no wake); it\n // renders as a plugin context row in the conversation.\n // - followup() queues the card body as a normal user message\n // (next-turn, wakes the driver). At claim time the loop drains ALL\n // next-step messages plus the one next-turn message into a single\n // turn — framing first, then the user bubble.\n handle.agent.inject({\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.pluginFraming(task, prepared, isolationNote) }],\n source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },\n })\n handle.agent.followup({\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.userBody(task) }],\n source: { kind: 'user' as const },\n })\n\n // 6. Settlement watcher: mark succeeded, release the executing session's\n // hold, collect the worktree evidence (commits / dirty / diff), and —\n // when the session did NOT follow the handoff protocol — auto-move the\n // card to in_review with a system comment.\n const settle = (): void => {\n this.runs.delete(executionId)\n void this.settleExecution(executionId, sessionId, prepared)\n }\n this.runs.set(executionId, { sessionId, ...(prepared !== undefined ? { prepared } : {}), settle, dispose: () => handle.dispose() })\n void handle.agent.whenIdle().then(settle, () => {\n this.noteFailure(sessionId, 'agent did not reach quiescence')\n settle()\n })\n\n return { ok: true, executionId, sessionId }\n }\n\n /**\n * Settle one execution: collect worktree facts first (fail-soft — git\n * problems never block settlement), then commit outcome + release + the\n * protocol-auto-review move in ONE ledger mutation.\n */\n private async settleExecution(\n executionId: string,\n sessionId: string,\n prepared: PreparedWorktree | undefined,\n ): Promise<void> {\n const facts = await this.collectEvidence(prepared)\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const t of ledger.tasks) {\n const execution = t.executions.find(e => e.id === executionId)\n if (execution !== undefined && execution.outcome === 'running') {\n const now = this.deps.now()\n execution.outcome = 'succeeded'\n execution.endedAt = now\n this.applyFacts(execution, facts)\n if (t.status === 'in_progress' && t.claimedBy === sessionId) {\n delete t.claimedBy\n delete t.claimedAt\n }\n if (t.status === 'in_progress') {\n const commented = t.comments.some(c => c.threadId === sessionId)\n t.comments.push({\n id: newCommentId(),\n body: normalizeBody(commented\n ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'\n : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),\n version: 1,\n createdAt: now,\n })\n t.status = 'in_review'\n t.updatedAt = now\n t.updatedBy = { kind: 'user' }\n }\n return [t]\n }\n }\n return undefined\n })\n }\n\n /**\n * Prepare the dedicated worktree for a run (fail-soft): detect git, then\n * create/reset the fixed task branch — fresh baseline, or keep a live\n * worktree as-is for 续跑. On success the branch name is pinned onto the\n * task once (renames never change it); on any failure the run degrades\n * with a human-readable note.\n */\n private async prepareIsolation(\n task: TaskRecord,\n workspacePath: string,\n worktreePath: string,\n branch: string,\n reuse: boolean,\n ): Promise<{ prepared?: PreparedWorktree; note?: string }> {\n const git = this.deps.git\n if (git === undefined) return { note: 'git 集成不可用,已在原目录执行' }\n let inside = false\n try {\n inside = await git.detect(workspacePath)\n } catch { /* fail-soft */ }\n if (!inside) {\n // Distinguish 未装 git from 非 git 仓库 (0.3.1): probe the binary so the\n // degradation note names the real cause.\n let hasBinary = true\n try {\n hasBinary = await git.binaryAvailable()\n } catch { /* fail-soft → treat as repo-side */ }\n return { note: hasBinary ? '当前项目不是 git 仓库,已在原目录执行' : 'git 不可用(未安装或不在 PATH),已在原目录执行' }\n }\n let info\n try {\n info = await git.prepareWorktree(workspacePath, worktreePath, branch, reuse ? 'reuse' : 'fresh')\n } catch { /* fail-soft */ }\n if (info === undefined) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }\n // Pin the branch name at first SUCCESSFUL creation (§9: 改名不改分支).\n if (task.branch === undefined) {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const target = ledger.tasks.find(t => t.id === task.id)\n if (target !== undefined && target.branch === undefined) {\n target.branch = branch\n return [target]\n }\n return undefined\n })\n }\n return {\n prepared: {\n branch: info.branch,\n worktreePath: info.path,\n baseCommit: info.baseCommit,\n ...(info.reused === true ? { reused: true } : {}),\n },\n }\n }\n\n /** How many executions are currently running (for the concurrency cap). */\n inFlight(): number {\n return this.runs.size\n }\n\n /**\n * Cancel the running execution of a task (user action): stop the agent\n * session, mark the execution cancelled, and hand the task back to todo.\n * @param taskId - the task whose execution should be stopped.\n * @returns the immediate result.\n */\n async cancel(taskId: string): Promise<CancelRequestResult> {\n const task = this.deps.store.get(taskId)\n if (task === undefined) return { ok: false, error: `no task ${taskId}` }\n const running = [...task.executions].reverse().find(e => e.outcome === 'running')\n if (running === undefined) return { ok: false, error: 'no running execution' }\n\n const entry = this.runs.get(running.id)\n this.runs.delete(running.id)\n // Stop the agent first (best effort): dispose stops the loop, unregisters\n // the agent, and removes its session. A late whenIdle settlement no-ops —\n // the record is no longer 'running'.\n try {\n await entry?.dispose()\n } catch { /* already gone */ }\n\n // The cancelled session may already have committed work — keep the\n // evidence (best effort) so the user can inspect or 续跑 (0.3.1).\n const facts = await this.collectEvidence(entry?.prepared)\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined) return undefined\n const execution = target.executions.find(e => e.id === running.id)\n if (execution === undefined || execution.outcome !== 'running') return undefined\n execution.outcome = 'cancelled'\n execution.endedAt = this.deps.now()\n this.applyFacts(execution, facts)\n if (target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n delete target.claimedBy\n delete target.claimedAt\n }\n return [target]\n })\n return { ok: true, executionId: running.id }\n }\n\n /**\n * Startup reconciliation after a host restart: executions left `running`\n * by the previous process can never settle here (their settlement watchers\n * died with it), so mark them failed and hand their tasks back to todo.\n */\n async reconcile(): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const now = this.deps.now()\n const touched: TaskRecord[] = []\n for (const task of ledger.tasks) {\n let dirty = false\n for (const execution of task.executions) {\n if (execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = 'interrupted by host restart'\n execution.endedAt = now\n dirty = true\n }\n }\n if (!dirty) continue\n if (task.status === 'in_progress') {\n task.status = 'todo'\n task.updatedAt = now\n delete task.claimedBy\n delete task.claimedAt\n }\n touched.push(task)\n }\n return touched.length > 0 ? touched : undefined\n })\n }\n\n /**\n * The plugin framing line (rendered as a plugin context row): task head,\n * already-claimed state, and the handoff protocol — everything the session\n * must know about the board. The task id appears exactly once (here); the\n * protocol steps below refer to it as 本任务. Isolated runs add one line\n * steering the session onto its dedicated branch (commits are the evidence\n * the user reviews at merge time); 续跑 and degraded runs each add their\n * own steering line (0.3.1).\n * @param task - the task.\n * @param prepared - worktree facts when this run is isolated.\n * @param degradeNote - why a worktree task degraded to the main directory.\n */\n private pluginFraming(task: TaskRecord, prepared?: PreparedWorktree, degradeNote?: string): string {\n let text = `【任务看板】${task.title}(ID: ${task.id})\\n`\n + `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\\n`\n + `完成后按序交接:\\n`\n + `1. taskboard_get 读取本任务,取得最新 version\\n`\n + `2. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\\n`\n + `3. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\\n`\n + `若无法完成:留评论说明原因,将任务移回待办 todo。`\n if (prepared !== undefined) {\n if (prepared.reused === true) {\n text += `\\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\\n${prepared.worktreePath}\\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`\n } else {\n text += `\\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${prepared.branch} 的全新 worktree——\\n${prepared.worktreePath}\\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`\n }\n } else if (degradeNote !== undefined) {\n text += `\\n⚠ 本次执行未能建立隔离,正在主项目目录中工作(原因:${degradeNote})。该目录可能有他人未提交的改动:动手前先 git status 检查现状,改动尽量集中,结束时在评论中说明动了哪些文件;避免把未经验证的改动直接提交到主分支。`\n }\n return text\n }\n\n /**\n * The card body as a normal user bubble: the effective prompt (explicit\n * prompt, else title+description) with template variables resolved from\n * the task's own history at submit time (valuable for recurring patrols):\n * `{{lastExecution}}` → the previous execution's trigger/outcome/error;\n * `{{lastComments}}` → the last three comments (who + body).\n */\n private userBody(task: TaskRecord): string {\n const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')\n const lastExecText = lastExec === undefined\n ? '(无)'\n : `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`\n const lastCommentsText = task.comments.slice(-3)\n .map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)\n .join('\\n') || '(无)'\n return effectivePrompt(task)\n .replace(/\\{\\{lastExecution\\}\\}/g, lastExecText)\n .replace(/\\{\\{lastComments\\}\\}/g, lastCommentsText)\n }\n\n /** Move a task back out of in_progress (and release its hold) after a failed start. */\n private async revertProgress(taskId: string): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target !== undefined && target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n delete target.claimedBy\n delete target.claimedAt\n return [target]\n }\n return undefined\n })\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"execution.js","names":[],"sources":["../../src/host/execution.ts"],"sourcesContent":["/**\n * Host execution service: runs a task through dsh's REAL session machinery —\n * a fresh agent+session inside the task's project workspace (creation carries\n * the pinned model when the task has one), the session is attached to the\n * workspace so it appears in the GUI's project session list, the effective\n * prompt is submitted as an ordinary user message, and the turn settlement\n * (turn/end reason) is folded back into the task's execution record.\n *\n * Every execution is a NEW session: clean context, no reuse of previous runs.\n *\n * @module dsh-taskboard/host/execution\n */\nimport {\n effectiveIsolation,\n effectivePrompt,\n newCommentId,\n newExecutionId,\n normalizeBody,\n type ExecutionRecord,\n type IsolationMode,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport { sanitizeBranchName, worktreePathOf, type GitFace, type SettlementFacts } from './git.ts'\nimport { MessageId } from './sdk.ts'\nimport type { TaskStore } from './store.ts'\n\n/** Default cap on concurrently running executions (env-overridable). */\nexport const DEFAULT_MAX_CONCURRENT = 3\n\n/** Narrow agents face (the registry's create, structurally). */\nexport interface AgentsFace {\n create(options: {\n sessionId: string\n meta?: { cwd?: string; agentPreset?: string }\n agentOptions?: { provider?: string; model?: string }\n /** Preset composition callback: mounts tools/persona into the agent's scoped context. */\n setup?: (agentCtx: unknown) => Promise<void> | void\n }): Promise<{\n agent: {\n id: string\n followup(message: unknown): void\n inject(message: unknown): void\n whenIdle(): Promise<void>\n }\n dispose(): Promise<void>\n }>\n}\n\n/**\n * The preset composition an execution session is built from — the shape\n * apiproxy's ensureSession produces: resolve → record on the session header,\n * mount → inside agents.create's setup callback.\n */\nexport interface AgentComposition {\n /** The resolved preset id recorded on the session header. */\n agentPreset: string\n /** Mounts the preset's plugins (tools, persona) into the agent's scope. */\n setup: (agentCtx: unknown) => Promise<void> | void\n}\n\n/** Narrow workspaces face for execution. */\nexport interface ExecutionWorkspaceFace {\n get(id: string): { id: string; path: string } | undefined\n attach(workspaceId: string, sessionId: string): Promise<void>\n}\n\n/** Narrow event-bus face for settlement listening. */\nexport interface EventsFace {\n onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }) => void): () => void\n}\n\n/** Everything the execution service needs. */\nexport interface ExecutionDeps {\n store: TaskStore\n agents: AgentsFace\n workspaces: ExecutionWorkspaceFace\n events: EventsFace\n now: () => number\n /** The deployment default model (fills sessions of unpinned tasks). */\n defaultModel?: () => { provider: string; model: string } | undefined\n /** Mint session ids (injectable for tests). */\n mintSessionId?: () => string\n /** Mint message ids (injectable for tests). */\n mintMessageId?: () => string\n /** Best-effort session rename (pins the session list title to the task title). */\n renameSession?: (sessionId: string, title: string) => void\n /** Max concurrently running executions across all tasks (default 3). */\n maxConcurrent?: number\n /**\n * Git face for worktree isolation (0.3.0). Absent → every worktree-mode\n * task degrades to the original directory with an isolationNote.\n */\n git?: GitFace\n /**\n * Resolve the preset composition for an execution session (0.3.3): hands\n * the session its tool set. Absent → sessions run on the bare host\n * composition (pre-preset behavior). A rejection fails the run through\n * the existing failure path — a broken preset never yields a half-composed\n * session (same rollback semantics as apiproxy).\n */\n composeAgent?: (presetId?: string) => Promise<AgentComposition | undefined>\n}\n\n/** Outcome of a run request (immediate; the run settles asynchronously). */\nexport type RunRequestResult =\n | { ok: true; executionId: string; sessionId: string }\n | { ok: false; error: string }\n\n/** Outcome of a cancel request. */\nexport type CancelRequestResult =\n | { ok: true; executionId: string }\n | { ok: false; error: string }\n\n/** Whether a turn/end payload closed with an error reason. */\nfunction isErrorTurnEnd(data: unknown): { message: string } | undefined {\n if (typeof data !== 'object' || data === null) return undefined\n const reason = (data as { reason?: unknown }).reason\n if (typeof reason !== 'object' || reason === null) return undefined\n const kind = (reason as { kind?: unknown }).kind\n if (kind !== 'error') return undefined\n const error = (reason as { error?: { message?: unknown } }).error\n const detail = JSON.stringify(error) ?? ''\n const message = typeof error?.message === 'string' ? error.message : 'turn failed'\n console.error('[dsh-taskboard] turn error detail:', detail.slice(0, 2000))\n void detail\n return { message }\n}\n\n/** Prepared worktree facts threaded through a live run (settlement evidence). */\nexport interface PreparedWorktree {\n branch: string\n worktreePath: string\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Per-run options. */\nexport interface RunOptions {\n /**\n * 续跑: keep a live worktree/branch exactly as-is (the previous agent's\n * commits and uncommitted changes survive) instead of resetting to the\n * main HEAD. Falls back to a fresh preparation when none is alive.\n */\n reuseWorktree?: boolean\n}\n\n/** One live execution tracked for settlement and cancellation. */\ninterface RunEntry {\n sessionId: string\n /** Worktree prepared for this run (evidence collection at ANY settlement). */\n prepared?: PreparedWorktree\n settle: () => void\n dispose: () => Promise<void>\n}\n\n/**\n * The execution service.\n */\nexport class ExecutionService {\n /** Live executions by execution id (settles and cancels remove entries). */\n private readonly runs = new Map<string, RunEntry>()\n\n /** @param deps - store + agents + workspaces + events + clock. */\n constructor(private readonly deps: ExecutionDeps) {\n deps.events.onSessionEvent((sessionId, event) => {\n if (event.type !== 'turn/end') return\n const failure = isErrorTurnEnd(event.data)\n if (failure !== undefined) this.noteFailure(sessionId, failure.message)\n })\n }\n\n /**\n * Best-effort evidence collection for a prepared run (fail-soft: undefined\n * on any git problem — settlement NEVER blocks on git).\n */\n private async collectEvidence(prepared: PreparedWorktree | undefined): Promise<SettlementFacts | undefined> {\n if (prepared === undefined || this.deps.git === undefined) return undefined\n try {\n return await this.deps.git.collect(prepared.worktreePath, prepared.baseCommit)\n } catch {\n return undefined\n }\n }\n\n /** Copy collected facts onto an execution record (in place). */\n private applyFacts(execution: ExecutionRecord, facts: SettlementFacts | undefined): void {\n if (facts === undefined) return\n if (facts.headCommit !== undefined) execution.headCommit = facts.headCommit\n execution.commits = facts.commits\n execution.commitsTotal = facts.commitsTotal\n execution.dirtyFiles = facts.dirtyFiles\n execution.dirtyFilesTotal = facts.dirtyFilesTotal\n execution.changedFiles = facts.changedFiles\n if (facts.diffStat !== undefined) execution.diffStat = facts.diffStat\n }\n\n /** Record a turn failure against the running execution of that session and give the task back. */\n private noteFailure(sessionId: string, message: string): void {\n // The failed session may already have committed work — collect the\n // evidence (best effort) BEFORE marking the execution failed (0.3.1).\n const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)\n void this.collectEvidence(entry?.prepared).then(facts => {\n void this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n for (const execution of task.executions) {\n if (execution.sessionId === sessionId && execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = message.slice(0, 500)\n execution.endedAt = this.deps.now()\n this.applyFacts(execution, facts)\n // The failed session will not finish the work: hand the task back\n // instead of leaving it stuck in in_progress forever — and leave a\n // system comment so the GUI shows why.\n if (task.status === 'in_progress' && task.claimedBy === sessionId) {\n task.status = 'todo'\n task.updatedAt = this.deps.now()\n delete task.claimedBy\n delete task.claimedAt\n task.comments.push({\n id: newCommentId(),\n body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),\n version: 1,\n createdAt: this.deps.now(),\n })\n }\n return [task]\n }\n }\n }\n return undefined\n })\n })\n }\n\n /** Patch one task's execution record in the ledger. */\n private async patchExecution(executionId: string, patch: Partial<ExecutionRecord>): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const task of ledger.tasks) {\n const execution = task.executions.find(e => e.id === executionId)\n if (execution !== undefined) {\n Object.assign(execution, patch)\n return [task]\n }\n }\n return undefined\n })\n }\n\n /**\n * Run one task now (manual button or scheduler tick).\n *\n * The in-progress gate and the execution-open write happen inside ONE\n * serial-queue mutation, so two overlapping run() calls (double click,\n * overlapping scheduler ticks) can never both pass — exactly one session\n * is opened per task.\n * @param taskId - the task to run.\n * @param trigger - what started it.\n * @param options - per-run options (`reuseWorktree` = 续跑).\n * @returns the immediate result; settlement lands in the ledger.\n */\n async run(taskId: string, trigger: ExecutionRecord['trigger'], options?: RunOptions): Promise<RunRequestResult> {\n const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT\n if (this.runs.size >= max) {\n return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }\n }\n const task = this.deps.store.get(taskId)\n if (task === undefined || task.trashedAt !== undefined) {\n return { ok: false, error: `no task ${taskId}` }\n }\n const workspace = this.deps.workspaces.get(task.workspaceId)\n if (workspace === undefined) {\n return { ok: false, error: `unknown workspace ${task.workspaceId}` }\n }\n\n const executionId = newExecutionId()\n const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`\n\n // 0. Resolve code isolation (plan §3.2): explicit 'none' → zero git calls;\n // 'worktree' (also the omitted default) → prepare below, degrading to\n // the original directory fail-soft on any git problem.\n const isolation: IsolationMode = effectiveIsolation(task)\n const branch = task.branch ?? sanitizeBranchName(task.title, task.id)\n const worktreePath = worktreePathOf(workspace.path, task.id)\n\n // 1. Open the execution record, flip the card to in_progress, and record\n // the executing session as the claim holder — atomically.\n let gate: string | undefined\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined || target.trashedAt !== undefined) {\n gate = `no task ${taskId}`\n return undefined\n }\n if (target.status === 'in_progress') {\n gate = 'task is already in progress'\n return undefined\n }\n target.executions.push({\n id: executionId,\n trigger,\n startedAt: this.deps.now(),\n outcome: 'running',\n ...(isolation === 'none' ? { isolation: 'none' as const } : { isolation: 'worktree' as const, branch }),\n })\n target.status = 'in_progress'\n target.updatedAt = this.deps.now()\n target.updatedBy = { kind: 'user' }\n target.claimedBy = sessionId\n target.claimedAt = this.deps.now()\n return [target]\n })\n if (gate !== undefined) return { ok: false, error: gate }\n\n // 1b. Worktree preparation (fail-soft): any failure degrades this run to\n // the original directory with an isolationNote — the ledger and the\n // execution pipeline itself never fail over git.\n let isolationNote: string | undefined\n let prepared: PreparedWorktree | undefined\n if (isolation === 'worktree') {\n const outcome = await this.prepareIsolation(task, workspace.path, worktreePath, branch, options?.reuseWorktree === true)\n if (outcome.prepared !== undefined) {\n prepared = outcome.prepared\n // Persist the isolation facts of the run (branch is already on the\n // record from the gate mutation).\n await this.patchExecution(executionId, {\n worktreePath: outcome.prepared.worktreePath,\n baseCommit: outcome.prepared.baseCommit,\n })\n } else {\n isolationNote = outcome.note\n // Degraded run: clear the optimistic worktree markers.\n await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })\n }\n }\n\n // 2. Create the fresh agent+session inside the task's project, carrying\n // the pinned model — or the deployment default when unpinned (the\n // persona template renders {{model}}, so the session always needs one).\n // The session cwd is ALWAYS the project root: DSH's session model\n // requires cwd === the workspace path EXACTLY (attachSession validates\n // it, the sidebar groups by it, and the file sandbox takes it as the\n // workspace-write boundary) — a subdirectory cwd (the worktree) breaks\n // all three. The worktree is instead handed to the agent explicitly in\n // the framing line below.\n // Preset composition (0.3.3): resolve BEFORE creation so the header\n // snapshots `agentPreset` and the setup callback mounts the preset's\n // tools/persona into the agent's scope. undefined composeAgent (or an\n // absent preset roster) keeps the bare host composition.\n let composition: AgentComposition | undefined\n try {\n composition = this.deps.composeAgent === undefined ? undefined : await this.deps.composeAgent(task.presetId)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: `preset 组合失败:${message.slice(0, 400)}`, endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: `preset composition failed: ${message}` }\n }\n let handle: Awaited<ReturnType<AgentsFace['create']>>\n try {\n const model = task.model ?? this.deps.defaultModel?.()\n handle = await this.deps.agents.create({\n sessionId,\n meta: {\n cwd: workspace.path,\n ...(composition !== undefined ? { agentPreset: composition.agentPreset } : {}),\n },\n ...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),\n ...(composition !== undefined ? { setup: composition.setup } : {}),\n })\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })\n await this.revertProgress(taskId)\n return { ok: false, error: message }\n }\n\n // 3. Attach the session to the workspace (GUI project session list).\n await this.deps.workspaces.attach(task.workspaceId, sessionId).catch(() => { /* cosmetic */ })\n\n // 3b. Best-effort rename: pin the session title to the task title so the\n // session list shows the task name (a user-sourced title also stops\n // automatic first-prompt retitling).\n try {\n this.deps.renameSession?.(sessionId, task.title)\n } catch { /* cosmetic */ }\n\n // 4. Record the session id (execution is really started now).\n await this.patchExecution(executionId, { sessionId })\n\n // 5. Submit the opening pair and settle on quiescence (turn/end errors\n // were already folded by the listener). Two messages, ONE turn:\n // - inject() queues the plugin framing line (next-step, no wake); it\n // renders as a plugin context row in the conversation.\n // - followup() queues the card body as a normal user message\n // (next-turn, wakes the driver). At claim time the loop drains ALL\n // next-step messages plus the one next-turn message into a single\n // turn — framing first, then the user bubble.\n handle.agent.inject({\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.pluginFraming(task, prepared, isolationNote) }],\n source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },\n })\n handle.agent.followup({\n id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),\n role: 'user' as const,\n content: [{ type: 'text' as const, text: this.userBody(task) }],\n source: { kind: 'user' as const },\n })\n\n // 6. Settlement watcher: mark succeeded, release the executing session's\n // hold, collect the worktree evidence (commits / dirty / diff), and —\n // when the session did NOT follow the handoff protocol — auto-move the\n // card to in_review with a system comment.\n const settle = (): void => {\n this.runs.delete(executionId)\n void this.settleExecution(executionId, sessionId, prepared)\n }\n this.runs.set(executionId, { sessionId, ...(prepared !== undefined ? { prepared } : {}), settle, dispose: () => handle.dispose() })\n void handle.agent.whenIdle().then(settle, () => {\n this.noteFailure(sessionId, 'agent did not reach quiescence')\n settle()\n })\n\n return { ok: true, executionId, sessionId }\n }\n\n /**\n * Settle one execution: collect worktree facts first (fail-soft — git\n * problems never block settlement), then commit outcome + release + the\n * protocol-auto-review move in ONE ledger mutation.\n */\n private async settleExecution(\n executionId: string,\n sessionId: string,\n prepared: PreparedWorktree | undefined,\n ): Promise<void> {\n const facts = await this.collectEvidence(prepared)\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n for (const t of ledger.tasks) {\n const execution = t.executions.find(e => e.id === executionId)\n if (execution !== undefined && execution.outcome === 'running') {\n const now = this.deps.now()\n execution.outcome = 'succeeded'\n execution.endedAt = now\n this.applyFacts(execution, facts)\n if (t.status === 'in_progress' && t.claimedBy === sessionId) {\n delete t.claimedBy\n delete t.claimedAt\n }\n if (t.status === 'in_progress') {\n const commented = t.comments.some(c => c.threadId === sessionId)\n t.comments.push({\n id: newCommentId(),\n body: normalizeBody(commented\n ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'\n : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),\n version: 1,\n createdAt: now,\n })\n t.status = 'in_review'\n t.updatedAt = now\n t.updatedBy = { kind: 'user' }\n }\n return [t]\n }\n }\n return undefined\n })\n }\n\n /**\n * Prepare the dedicated worktree for a run (fail-soft): detect git, then\n * create/reset the fixed task branch — fresh baseline, or keep a live\n * worktree as-is for 续跑. On success the branch name is pinned onto the\n * task once (renames never change it); on any failure the run degrades\n * with a human-readable note.\n */\n private async prepareIsolation(\n task: TaskRecord,\n workspacePath: string,\n worktreePath: string,\n branch: string,\n reuse: boolean,\n ): Promise<{ prepared?: PreparedWorktree; note?: string }> {\n const git = this.deps.git\n if (git === undefined) return { note: 'git 集成不可用,已在原目录执行' }\n let inside = false\n try {\n inside = await git.detect(workspacePath)\n } catch { /* fail-soft */ }\n if (!inside) {\n // Distinguish 未装 git from 非 git 仓库 (0.3.1): probe the binary so the\n // degradation note names the real cause.\n let hasBinary = true\n try {\n hasBinary = await git.binaryAvailable()\n } catch { /* fail-soft → treat as repo-side */ }\n return { note: hasBinary ? '当前项目不是 git 仓库,已在原目录执行' : 'git 不可用(未安装或不在 PATH),已在原目录执行' }\n }\n let info\n try {\n info = await git.prepareWorktree(workspacePath, worktreePath, branch, reuse ? 'reuse' : 'fresh')\n } catch { /* fail-soft */ }\n if (info === undefined) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }\n // Pin the branch name at first SUCCESSFUL creation (§9: 改名不改分支).\n if (task.branch === undefined) {\n await this.deps.store.mutate('task-updated', (ledger) => {\n const target = ledger.tasks.find(t => t.id === task.id)\n if (target !== undefined && target.branch === undefined) {\n target.branch = branch\n return [target]\n }\n return undefined\n })\n }\n return {\n prepared: {\n branch: info.branch,\n worktreePath: info.path,\n baseCommit: info.baseCommit,\n ...(info.reused === true ? { reused: true } : {}),\n },\n }\n }\n\n /** How many executions are currently running (for the concurrency cap). */\n inFlight(): number {\n return this.runs.size\n }\n\n /**\n * Cancel the running execution of a task (user action): stop the agent\n * session, mark the execution cancelled, and hand the task back to todo.\n * @param taskId - the task whose execution should be stopped.\n * @returns the immediate result.\n */\n async cancel(taskId: string): Promise<CancelRequestResult> {\n const task = this.deps.store.get(taskId)\n if (task === undefined) return { ok: false, error: `no task ${taskId}` }\n const running = [...task.executions].reverse().find(e => e.outcome === 'running')\n if (running === undefined) return { ok: false, error: 'no running execution' }\n\n const entry = this.runs.get(running.id)\n this.runs.delete(running.id)\n // Stop the agent first (best effort): dispose stops the loop, unregisters\n // the agent, and removes its session. A late whenIdle settlement no-ops —\n // the record is no longer 'running'.\n try {\n await entry?.dispose()\n } catch { /* already gone */ }\n\n // The cancelled session may already have committed work — keep the\n // evidence (best effort) so the user can inspect or 续跑 (0.3.1).\n const facts = await this.collectEvidence(entry?.prepared)\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target === undefined) return undefined\n const execution = target.executions.find(e => e.id === running.id)\n if (execution === undefined || execution.outcome !== 'running') return undefined\n execution.outcome = 'cancelled'\n execution.endedAt = this.deps.now()\n this.applyFacts(execution, facts)\n if (target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n delete target.claimedBy\n delete target.claimedAt\n }\n return [target]\n })\n return { ok: true, executionId: running.id }\n }\n\n /**\n * Startup reconciliation after a host restart: executions left `running`\n * by the previous process can never settle here (their settlement watchers\n * died with it), so mark them failed and hand their tasks back to todo.\n */\n async reconcile(): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const now = this.deps.now()\n const touched: TaskRecord[] = []\n for (const task of ledger.tasks) {\n let dirty = false\n for (const execution of task.executions) {\n if (execution.outcome === 'running') {\n execution.outcome = 'failed'\n execution.error = 'interrupted by host restart'\n execution.endedAt = now\n dirty = true\n }\n }\n if (!dirty) continue\n if (task.status === 'in_progress') {\n task.status = 'todo'\n task.updatedAt = now\n delete task.claimedBy\n delete task.claimedAt\n }\n touched.push(task)\n }\n return touched.length > 0 ? touched : undefined\n })\n }\n\n /**\n * The plugin framing line (rendered as a plugin context row): task head,\n * already-claimed state, and the handoff protocol — everything the session\n * must know about the board. The task id appears exactly once (here); the\n * protocol steps below refer to it as 本任务. Isolated runs add one line\n * steering the session onto its dedicated branch (commits are the evidence\n * the user reviews at merge time); 续跑 and degraded runs each add their\n * own steering line (0.3.1).\n * @param task - the task.\n * @param prepared - worktree facts when this run is isolated.\n * @param degradeNote - why a worktree task degraded to the main directory.\n */\n private pluginFraming(task: TaskRecord, prepared?: PreparedWorktree, degradeNote?: string): string {\n let text = `【任务看板】${task.title}(ID: ${task.id})\\n`\n + `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\\n`\n + `完成后按序交接:\\n`\n + `1. taskboard_get 读取本任务,取得最新 version\\n`\n + `2. taskboard_execution_report 提交结构化执行报告(做了什么/改了哪些文件/如何验证/剩余风险;提交与评论不冲突,都会展示给验收人)\\n`\n + `3. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\\n`\n + `4. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\\n`\n + `若无法完成:留评论说明原因,将任务移回待办 todo。`\n if (task.checklist !== undefined && task.checklist.length > 0) {\n const items = task.checklist\n .map((item, index) => `${item.checked ? '☑' : '☐'} ${index + 1}. ${item.text}${item.note !== undefined ? `(证据: ${item.note})` : ''}`)\n .join('\\n')\n const done = task.checklist.filter(i => i.checked).length\n text += `\\n本任务有验收清单(DoD,${done}/${task.checklist.length} 已完成)——按清单干活:\\n${items}\\n完成一项就用 taskboard_checklist(action=check,附 note 证据)勾选;未完成项会在验收时高亮,全部完成再移待验收。需要补充验收项也可用 action=add 追加。`\n }\n if (prepared !== undefined) {\n if (prepared.reused === true) {\n text += `\\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\\n${prepared.worktreePath}\\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`\n } else {\n text += `\\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${prepared.branch} 的全新 worktree——\\n${prepared.worktreePath}\\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`\n }\n } else if (degradeNote !== undefined) {\n text += `\\n⚠ 本次执行未能建立隔离,正在主项目目录中工作(原因:${degradeNote})。该目录可能有他人未提交的改动:动手前先 git status 检查现状,改动尽量集中,结束时在评论中说明动了哪些文件;避免把未经验证的改动直接提交到主分支。`\n }\n return text\n }\n\n /**\n * The card body as a normal user bubble: the effective prompt (explicit\n * prompt, else title+description) with template variables resolved from\n * the task's own history at submit time (valuable for recurring patrols):\n * `{{lastExecution}}` → the previous execution's trigger/outcome/error;\n * `{{lastComments}}` → the last three comments (who + body).\n */\n private userBody(task: TaskRecord): string {\n const lastExec = [...task.executions].reverse().find(e => e.outcome !== 'running')\n const lastExecText = lastExec === undefined\n ? '(无)'\n : `${lastExec.trigger} · ${lastExec.outcome}${lastExec.error !== undefined ? ` · ${lastExec.error.slice(0, 200)}` : ''} · ${new Date(lastExec.startedAt ?? 0).toISOString()}`\n const lastCommentsText = task.comments.slice(-3)\n .map(c => `[${c.threadId !== undefined ? 'agent' : 'user'}] ${c.body}`)\n .join('\\n') || '(无)'\n return effectivePrompt(task)\n .replace(/\\{\\{lastExecution\\}\\}/g, lastExecText)\n .replace(/\\{\\{lastComments\\}\\}/g, lastCommentsText)\n }\n\n /** Move a task back out of in_progress (and release its hold) after a failed start. */\n private async revertProgress(taskId: string): Promise<void> {\n await this.deps.store.mutate('execution-recorded', (ledger) => {\n const target = ledger.tasks.find(t => t.id === taskId)\n if (target !== undefined && target.status === 'in_progress') {\n target.status = 'todo'\n target.updatedAt = this.deps.now()\n delete target.claimedBy\n delete target.claimedAt\n return [target]\n }\n return undefined\n })\n }\n}\n"],"mappings":"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"}
|
package/lib/host/git.js
CHANGED
|
@@ -5,6 +5,32 @@ const QUICK_TIMEOUT_MS = 2e3;
|
|
|
5
5
|
const HEAVY_TIMEOUT_MS = 15e3;
|
|
6
6
|
/** Directory under a workspace where task worktrees live. */
|
|
7
7
|
const WORKTREE_DIR = ".dsh-worktrees";
|
|
8
|
+
/** Diff viewer caps (0.4.0): raw text kept per view. */
|
|
9
|
+
const MAX_DIFF_BYTES = 128 * 1024;
|
|
10
|
+
/** Diff viewer caps: lines kept per view. */
|
|
11
|
+
const MAX_DIFF_LINES = 2e3;
|
|
12
|
+
/** Cap one diff payload by bytes and lines (in order, marking truncation). */
|
|
13
|
+
function capDiff(out) {
|
|
14
|
+
let text = out;
|
|
15
|
+
let truncated = false;
|
|
16
|
+
if (text.length > 131072) {
|
|
17
|
+
text = text.slice(0, MAX_DIFF_BYTES);
|
|
18
|
+
truncated = true;
|
|
19
|
+
}
|
|
20
|
+
const lines = text.split("\n");
|
|
21
|
+
if (lines.length > 2e3) {
|
|
22
|
+
text = lines.slice(0, MAX_DIFF_LINES).join("\n");
|
|
23
|
+
truncated = true;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
text,
|
|
27
|
+
truncated
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** A plausible git object hash (defense against option injection). */
|
|
31
|
+
function isHash(hash) {
|
|
32
|
+
return /^[0-9a-f]{4,64}$/i.test(hash);
|
|
33
|
+
}
|
|
8
34
|
/**
|
|
9
35
|
* Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).
|
|
10
36
|
*
|
|
@@ -225,10 +251,77 @@ function createGitFace(exec = realExec) {
|
|
|
225
251
|
branch
|
|
226
252
|
], root);
|
|
227
253
|
if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`);
|
|
228
|
-
})
|
|
254
|
+
}),
|
|
255
|
+
async showCommit(cwd, hash) {
|
|
256
|
+
if (!isHash(hash)) return void 0;
|
|
257
|
+
const r = await quick([
|
|
258
|
+
"show",
|
|
259
|
+
"--no-color",
|
|
260
|
+
"--format=medium",
|
|
261
|
+
hash
|
|
262
|
+
], cwd);
|
|
263
|
+
if (!r.ok || r.stdout.trim().length === 0) return void 0;
|
|
264
|
+
return capDiff(r.stdout);
|
|
265
|
+
},
|
|
266
|
+
async showPathDiff(cwd, path, baseCommit) {
|
|
267
|
+
const target = path.trim();
|
|
268
|
+
if (target.length === 0) return void 0;
|
|
269
|
+
if (baseCommit !== void 0 && isHash(baseCommit)) {
|
|
270
|
+
const r = await quick([
|
|
271
|
+
"diff",
|
|
272
|
+
"--no-color",
|
|
273
|
+
`${baseCommit}..HEAD`,
|
|
274
|
+
"--",
|
|
275
|
+
target
|
|
276
|
+
], cwd);
|
|
277
|
+
if (!r.ok) return void 0;
|
|
278
|
+
if (r.stdout.trim().length === 0) return {
|
|
279
|
+
text: "(该文件无差异)",
|
|
280
|
+
truncated: false
|
|
281
|
+
};
|
|
282
|
+
return capDiff(r.stdout);
|
|
283
|
+
}
|
|
284
|
+
const r = await quick([
|
|
285
|
+
"diff",
|
|
286
|
+
"--no-color",
|
|
287
|
+
"HEAD",
|
|
288
|
+
"--",
|
|
289
|
+
target
|
|
290
|
+
], cwd);
|
|
291
|
+
if (r.ok && r.stdout.trim().length > 0) return capDiff(r.stdout);
|
|
292
|
+
const st = await quick([
|
|
293
|
+
"status",
|
|
294
|
+
"--porcelain",
|
|
295
|
+
"--",
|
|
296
|
+
target
|
|
297
|
+
], cwd);
|
|
298
|
+
if (r.ok && st.ok && st.stdout.trim().startsWith("??")) {
|
|
299
|
+
const ni = await exec([
|
|
300
|
+
"diff",
|
|
301
|
+
"--no-color",
|
|
302
|
+
"--no-index",
|
|
303
|
+
"--",
|
|
304
|
+
"/dev/null",
|
|
305
|
+
target
|
|
306
|
+
], {
|
|
307
|
+
cwd,
|
|
308
|
+
timeout: QUICK_TIMEOUT_MS
|
|
309
|
+
});
|
|
310
|
+
if (ni.stdout.includes("diff --git")) return capDiff(ni.stdout);
|
|
311
|
+
return {
|
|
312
|
+
text: `(未跟踪新文件:${target})`,
|
|
313
|
+
truncated: false
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (!r.ok) return void 0;
|
|
317
|
+
return {
|
|
318
|
+
text: "(该文件无差异)",
|
|
319
|
+
truncated: false
|
|
320
|
+
};
|
|
321
|
+
}
|
|
229
322
|
};
|
|
230
323
|
}
|
|
231
324
|
//#endregion
|
|
232
|
-
export { WORKTREE_DIR, createGitFace, sanitizeBranchName, worktreePathOf };
|
|
325
|
+
export { MAX_DIFF_BYTES, MAX_DIFF_LINES, WORKTREE_DIR, createGitFace, sanitizeBranchName, worktreePathOf };
|
|
233
326
|
|
|
234
327
|
//# sourceMappingURL=git.js.map
|
package/lib/host/git.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git.js","names":[],"sources":["../../src/host/git.ts"],"sourcesContent":["/**\n * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.\n * 0.3.1: per-repo serialization of structural operations, binary probing,\n * no-op merge detection, worktree REUSE mode, and evidence size caps.\n *\n * Design invariants (plan §3.4/§3.5):\n * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /\n * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks\n * into the plugin.\n * - FAIL-SOFT: every call has a timeout and resolves to a benign result\n * (false / undefined / empty facts) on ANY git failure — a missing git,\n * a locked worktree, or a damaged repo degrades execution to the original\n * directory and NEVER fails the ledger or the run pipeline. Only the\n * explicit user actions (merge / remove / deleteBranch) throw, with a\n * readable message the GUI surfaces as-is.\n * - SERIALIZED structural ops: concurrent isolated executions on the SAME\n * repository would race on git's index/worktree locks, so every structural\n * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs\n * inside a per-root in-process mutex. Read-only collects stay concurrent.\n * - INJECTABLE runner: the exec layer is a single function so unit tests\n * script every path without a real git.\n *\n * @module dsh-taskboard/host/git\n */\nimport type { CommitInfo } from '../shared/protocol.ts'\n\n/** Timeout for quick read-only queries (rev-parse / status / log / diff). */\nconst QUICK_TIMEOUT_MS = 2_000\n\n/** Timeout for structural operations (worktree add/remove, merge, branch). */\nconst HEAVY_TIMEOUT_MS = 15_000\n\n/** Directory under a workspace where task worktrees live. */\nexport const WORKTREE_DIR = '.dsh-worktrees'\n\n/** Evidence caps: commits kept per execution record (newest first). */\nexport const MAX_COMMIT_EVIDENCE = 50\n\n/** Evidence caps: uncommitted-change lines kept per execution record. */\nexport const MAX_DIRTY_EVIDENCE = 100\n\n/** Result of one underlying exec: `ok` is exit-0, output never null. */\nexport interface ExecResult { ok: boolean; stdout: string; stderr: string }\n\n/** The injectable exec layer: run `git <args>` under a cwd with a timeout. */\nexport type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>\n\n/** Facts needed to open an isolated execution. */\nexport interface WorktreeInfo {\n /** Absolute worktree path (the session's cwd). */\n path: string\n /** The task branch checked out there. */\n branch: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Settlement facts collected from a worktree (partial on best-effort basis). */\nexport interface SettlementFacts {\n headCommit?: string\n commits: CommitInfo[]\n /** Total commits before capping (equals commits.length when under the cap). */\n commitsTotal: number\n dirtyFiles: string[]\n /** Total uncommitted lines before capping. */\n dirtyFilesTotal: number\n diffStat?: string\n changedFiles: number\n}\n\n/** The narrow git face the rest of the plugin depends on. */\nexport interface GitFace {\n /** Whether `root` sits inside a usable git work tree (fail-soft → false). */\n detect(root: string): Promise<boolean>\n /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */\n binaryAvailable(): Promise<boolean>\n /**\n * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to\n * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live\n * worktree exactly as-is (续跑 — agent's commits and uncommitted changes\n * survive) and falls back to a fresh creation when none is alive. Resolves\n * undefined on any failure — callers degrade to the original directory.\n */\n prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>\n /** Collect settlement facts (never throws; missing pieces stay unset). */\n collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>\n /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */\n merge(root: string, branch: string): Promise<void>\n /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */\n isAncestor(root: string, branch: string): Promise<boolean>\n /** Remove a worktree; THROWS when it still has uncommitted changes. */\n removeWorktree(root: string, worktreePath: string): Promise<void>\n /** Delete a branch; THROWS (e.g. still checked out in a worktree). */\n deleteBranch(root: string, branch: string): Promise<void>\n}\n\n/**\n * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).\n *\n * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters\n * (`~ ^ : ? * [ \\ / @ { }` and friends) are stripped; `..` collapses; the\n * segment is trimmed of leading/trailing `.-` and truncated to ~20 code\n * points; an empty result falls back to the bare `task/<taskId>`.\n * @param title - the task title (already normalized 1..200 chars).\n * @param taskId - the task id (stable suffix).\n * @returns the branch name.\n */\nexport function sanitizeBranchName(title: string, taskId: string): string {\n const segment = title.trim()\n .replace(/\\s+/g, '-')\n .replace(/[/\\\\~^:?*[\\]@{}\"'<>|#%&;$!`'=,;()]+/g, '')\n .replace(/\\.\\.+/g, '.')\n .replace(/^[-.\\s]+|[-.\\s]+$/g, '')\n const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')\n return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`\n}\n\n/** The canonical worktree path of a task inside its workspace (forward slashes). */\nexport function worktreePathOf(workspacePath: string, taskId: string): string {\n const root = workspacePath.replace(/[\\\\/]+$/, '').replaceAll('\\\\', '/')\n return `${root}/${WORKTREE_DIR}/${taskId}`\n}\n\n/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */\nconst realExec: ExecFn = (args, options) => new Promise(resolve => {\n void (async () => {\n const { execFile } = await import('node:child_process')\n execFile('git', args, {\n cwd: options.cwd,\n timeout: options.timeout ?? QUICK_TIMEOUT_MS,\n windowsHide: true,\n maxBuffer: 4 * 1024 * 1024,\n encoding: 'utf8',\n }, (error, stdout, stderr) => {\n resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })\n })\n })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))\n})\n\n/**\n * Build a {@link GitFace} over an injectable exec layer.\n * @param exec - the exec function (real `git` when omitted).\n */\nexport function createGitFace(exec: ExecFn = realExec): GitFace {\n const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })\n const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })\n\n // Per-root mutex (0.3.1): structural git ops on the SAME repository run one\n // at a time — concurrent isolated executions must not race on git's locks.\n const locks = new Map<string, Promise<unknown>>()\n const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {\n const prev = locks.get(root) ?? Promise.resolve()\n const next = prev.then(fn, fn)\n locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))\n return next\n }\n\n return {\n async detect(root) {\n const r = await quick(['rev-parse', '--is-inside-work-tree'], root)\n return r.ok && r.stdout.trim() === 'true'\n },\n\n async binaryAvailable() {\n const r = await quick(['--version'])\n return r.ok && r.stdout.startsWith('git version')\n },\n\n prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {\n // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's\n // commits and uncommitted changes survive; the baseline becomes the\n // worktree's own HEAD so evidence covers only the new run.\n if (mode === 'reuse') {\n const wtHead = await quick(['rev-parse', 'HEAD'], path)\n if (wtHead.ok && wtHead.stdout.trim().length > 0) {\n return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }\n }\n // No live worktree → fall through to a fresh preparation.\n }\n\n // Baseline: the main worktree's current HEAD (also validates the repo).\n const head = await quick(['rev-parse', 'HEAD'], root)\n if (!head.ok) return undefined\n const baseCommit = head.stdout.trim()\n\n const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)\n if (exists.ok) {\n // Reuse the fixed branch name, but guarantee a FRESH baseline: drop\n // any stale worktree at the path, move the branch to the current\n // HEAD, then check the branch out again (每次全新,复用仅作选项保留).\n await heavy(['worktree', 'remove', '--force', path], root)\n await heavy(['worktree', 'prune'], root)\n const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)\n if (!moved.ok) return undefined\n const added = await heavy(['worktree', 'add', path, branch], root)\n if (!added.ok) return undefined\n } else {\n const added = await heavy(['worktree', 'add', '-b', branch, path], root)\n if (!added.ok) return undefined\n }\n return { path, branch, baseCommit }\n }),\n\n async collect(worktreePath, baseCommit) {\n const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }\n const range = `${baseCommit}..HEAD`\n\n const head = await quick(['rev-parse', 'HEAD'], worktreePath)\n if (head.ok) facts.headCommit = head.stdout.trim()\n\n const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)\n if (log.ok) {\n const commits = log.stdout.split('\\n')\n .map(line => line.trim())\n .filter(line => line.length > 0)\n .map(line => {\n const space = line.indexOf(' ')\n return space === -1\n ? { hash: line, subject: '' }\n : { hash: line.slice(0, space), subject: line.slice(space + 1) }\n })\n // Evidence caps (0.3.1): the ledger is rewritten whole on every\n // mutation — cap what a huge branch/status dump can add to it.\n facts.commitsTotal = commits.length\n facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)\n }\n\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok) {\n const dirty = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n facts.dirtyFilesTotal = dirty.length\n facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)\n }\n\n const shortstat = await quick(['diff', '--shortstat', range], worktreePath)\n if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()\n\n const names = await quick(['diff', '--name-only', range], worktreePath)\n if (names.ok) facts.changedFiles = names.stdout.split('\\n').filter(l => l.trim().length > 0).length\n\n return facts\n },\n\n merge: (root, branch) => withRootLock(root, async () => {\n // Main-clean check. The plugin's own worktree directory\n // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —\n // otherwise merging would be impossible without gitignoring it first.\n const status = await quick(['status', '--porcelain'], root)\n if (status.ok) {\n const dirtyLines = status.stdout.split('\\n')\n .map(l => l.trim())\n .filter(l => {\n if (l.length === 0) return false\n const path = l.slice(3)\n return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)\n })\n if (dirtyLines.length > 0) {\n throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`)\n }\n }\n const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)\n if (!merged.ok) {\n // Roll the half-finished merge back so the main worktree stays usable;\n // report the ORIGINAL failure verbatim (不自动解决冲突).\n await heavy(['merge', '--abort'], root)\n throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)\n }\n }),\n\n async isAncestor(root, branch) {\n // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.\n const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)\n return r.ok\n },\n\n removeWorktree: (root, worktreePath) => withRootLock(root, async () => {\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok && status.stdout.trim().length > 0) {\n const lines = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n throw new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\\n${lines.slice(0, 10).join('\\n')}`)\n }\n const removed = await heavy(['worktree', 'remove', worktreePath], root)\n if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)\n }),\n\n deleteBranch: (root, branch) => withRootLock(root, async () => {\n const deleted = await heavy(['branch', '-D', branch], root)\n if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)\n }),\n }\n}\n"],"mappings":";;AA2BA,MAAM,mBAAmB;;AAGzB,MAAM,mBAAmB;;AAGzB,MAAa,eAAe;;;;;;;;;;;;AA4E5B,SAAgB,mBAAmB,OAAe,QAAwB;CACxE,MAAM,UAAU,MAAM,KAAK,CAAC,CACzB,QAAQ,QAAQ,GAAG,CAAC,CACpB,QAAQ,wCAAwC,EAAE,CAAC,CACnD,QAAQ,UAAU,GAAG,CAAC,CACtB,QAAQ,sBAAsB,EAAE;CACnC,MAAM,OAAO,MAAM,KAAK,OAAO,CAAC,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,QAAQ,kBAAkB,EAAE;CACnF,OAAO,KAAK,WAAW,IAAI,QAAQ,WAAW,QAAQ,KAAK,GAAG;AAChE;;AAGA,SAAgB,eAAe,eAAuB,QAAwB;CAE5E,OAAO,GADM,cAAc,QAAQ,WAAW,EAAE,CAAC,CAAC,WAAW,MAAM,GACtD,EAAE,GAAG,aAAa,GAAG;AACpC;;AAGA,MAAM,YAAoB,MAAM,YAAY,IAAI,SAAQ,YAAW;CACjE,CAAM,YAAY;EAChB,MAAM,EAAE,aAAa,MAAM,OAAO;EAClC,SAAS,OAAO,MAAM;GACpB,KAAK,QAAQ;GACb,SAAS,QAAQ,WAAW;GAC5B,aAAa;GACb,WAAW,IAAI,OAAO;GACtB,UAAU;EACZ,IAAI,OAAO,QAAQ,WAAW;GAC5B,QAAQ;IAAE,IAAI,UAAU;IAAM,QAAQ,OAAO,UAAU,EAAE;IAAG,QAAQ,OAAO,UAAU,EAAE;GAAE,CAAC;EAC5F,CAAC;CACH,EAAA,CAAG,CAAC,CAAC,YAAY,QAAQ;EAAE,IAAI;EAAO,QAAQ;EAAI,QAAQ;CAAmB,CAAC,CAAC;AACjF,CAAC;;;;;AAMD,SAAgB,cAAc,OAAe,UAAmB;CAC9D,MAAM,SAAS,MAAgB,QAAsC,KAAK,MAAM;EAAE;EAAK,SAAS;CAAiB,CAAC;CAClH,MAAM,SAAS,MAAgB,QAAsC,KAAK,MAAM;EAAE;EAAK,SAAS;CAAiB,CAAC;CAIlH,MAAM,wBAAQ,IAAI,IAA8B;CAChD,MAAM,gBAAmB,MAAc,OAAqC;EAE1E,MAAM,QADO,MAAM,IAAI,IAAI,KAAK,QAAQ,QAAQ,EAAA,CAC9B,KAAK,IAAI,EAAE;EAC7B,MAAM,IAAI,MAAM,KAAK,YAAY,CAAyC,CAAC,CAAC;EAC5E,OAAO;CACT;CAEA,OAAO;EACL,MAAM,OAAO,MAAM;GACjB,MAAM,IAAI,MAAM,MAAM,CAAC,aAAa,uBAAuB,GAAG,IAAI;GAClE,OAAO,EAAE,MAAM,EAAE,OAAO,KAAK,MAAM;EACrC;EAEA,MAAM,kBAAkB;GACtB,MAAM,IAAI,MAAM,MAAM,CAAC,WAAW,CAAC;GACnC,OAAO,EAAE,MAAM,EAAE,OAAO,WAAW,aAAa;EAClD;EAEA,kBAAkB,MAAM,MAAM,QAAQ,OAAO,YAAY,aAAa,MAAM,YAAY;GAItF,IAAI,SAAS,SAAS;IACpB,MAAM,SAAS,MAAM,MAAM,CAAC,aAAa,MAAM,GAAG,IAAI;IACtD,IAAI,OAAO,MAAM,OAAO,OAAO,KAAK,CAAC,CAAC,SAAS,GAC7C,OAAO;KAAE;KAAM;KAAQ,YAAY,OAAO,OAAO,KAAK;KAAG,QAAQ;IAAK;GAG1E;GAGA,MAAM,OAAO,MAAM,MAAM,CAAC,aAAa,MAAM,GAAG,IAAI;GACpD,IAAI,CAAC,KAAK,IAAI,OAAO,KAAA;GACrB,MAAM,aAAa,KAAK,OAAO,KAAK;GAGpC,KAAI,MADiB,MAAM;IAAC;IAAY;IAAY,cAAc;GAAQ,GAAG,IAAI,EAAA,CACtE,IAAI;IAIb,MAAM,MAAM;KAAC;KAAY;KAAU;KAAW;IAAI,GAAG,IAAI;IACzD,MAAM,MAAM,CAAC,YAAY,OAAO,GAAG,IAAI;IAEvC,IAAI,EAAC,MADe,MAAM;KAAC;KAAU;KAAM;KAAQ;IAAM,GAAG,IAAI,EAAA,CACrD,IAAI,OAAO,KAAA;IAEtB,IAAI,EAAC,MADe,MAAM;KAAC;KAAY;KAAO;KAAM;IAAM,GAAG,IAAI,EAAA,CACtD,IAAI,OAAO,KAAA;GACxB,OAEE,IAAI,EAAC,MADe,MAAM;IAAC;IAAY;IAAO;IAAM;IAAQ;GAAI,GAAG,IAAI,EAAA,CAC5D,IAAI,OAAO,KAAA;GAExB,OAAO;IAAE;IAAM;IAAQ;GAAW;EACpC,CAAC;EAED,MAAM,QAAQ,cAAc,YAAY;GACtC,MAAM,QAAyB;IAAE,SAAS,CAAC;IAAG,cAAc;IAAG,YAAY,CAAC;IAAG,iBAAiB;IAAG,cAAc;GAAE;GACnH,MAAM,QAAQ,GAAG,WAAW;GAE5B,MAAM,OAAO,MAAM,MAAM,CAAC,aAAa,MAAM,GAAG,YAAY;GAC5D,IAAI,KAAK,IAAI,MAAM,aAAa,KAAK,OAAO,KAAK;GAEjD,MAAM,MAAM,MAAM,MAAM;IAAC;IAAO;IAAyB;GAAK,GAAG,YAAY;GAC7E,IAAI,IAAI,IAAI;IACV,MAAM,UAAU,IAAI,OAAO,MAAM,IAAI,CAAC,CACnC,KAAI,SAAQ,KAAK,KAAK,CAAC,CAAC,CACxB,QAAO,SAAQ,KAAK,SAAS,CAAC,CAAC,CAC/B,KAAI,SAAQ;KACX,MAAM,QAAQ,KAAK,QAAQ,GAAG;KAC9B,OAAO,UAAU,KACb;MAAE,MAAM;MAAM,SAAS;KAAG,IAC1B;MAAE,MAAM,KAAK,MAAM,GAAG,KAAK;MAAG,SAAS,KAAK,MAAM,QAAQ,CAAC;KAAE;IACnE,CAAC;IAGH,MAAM,eAAe,QAAQ;IAC7B,MAAM,UAAU,QAAQ,MAAM,GAAA,EAAsB;GACtD;GAEA,MAAM,SAAS,MAAM,MAAM,CAAC,UAAU,aAAa,GAAG,YAAY;GAClE,IAAI,OAAO,IAAI;IACb,MAAM,QAAQ,OAAO,OAAO,MAAM,IAAI,CAAC,CAAC,KAAI,MAAK,EAAE,KAAK,CAAC,CAAC,CAAC,QAAO,MAAK,EAAE,SAAS,CAAC;IACnF,MAAM,kBAAkB,MAAM;IAC9B,MAAM,aAAa,MAAM,MAAM,GAAA,GAAqB;GACtD;GAEA,MAAM,YAAY,MAAM,MAAM;IAAC;IAAQ;IAAe;GAAK,GAAG,YAAY;GAC1E,IAAI,UAAU,MAAM,UAAU,OAAO,KAAK,CAAC,CAAC,SAAS,GAAG,MAAM,WAAW,UAAU,OAAO,KAAK;GAE/F,MAAM,QAAQ,MAAM,MAAM;IAAC;IAAQ;IAAe;GAAK,GAAG,YAAY;GACtE,IAAI,MAAM,IAAI,MAAM,eAAe,MAAM,OAAO,MAAM,IAAI,CAAC,CAAC,QAAO,MAAK,EAAE,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;GAE7F,OAAO;EACT;EAEA,QAAQ,MAAM,WAAW,aAAa,MAAM,YAAY;GAItD,MAAM,SAAS,MAAM,MAAM,CAAC,UAAU,aAAa,GAAG,IAAI;GAC1D,IAAI,OAAO,IAAI;IACb,MAAM,aAAa,OAAO,OAAO,MAAM,IAAI,CAAC,CACzC,KAAI,MAAK,EAAE,KAAK,CAAC,CAAC,CAClB,QAAO,MAAK;KACX,IAAI,EAAE,WAAW,GAAG,OAAO;KAC3B,MAAM,OAAO,EAAE,MAAM,CAAC;KACtB,OAAO,SAAA,oBAAyB,CAAC,KAAK,WAAW,iBAAkB;IACrE,CAAC;IACH,IAAI,WAAW,SAAS,GACtB,MAAM,IAAI,MAAM,SAAS,WAAW,OAAO,oBAAoB;GAEnE;GACA,MAAM,SAAS,MAAM,MAAM;IAAC;IAAS;IAAW;IAAa;GAAM,GAAG,IAAI;GAC1E,IAAI,CAAC,OAAO,IAAI;IAGd,MAAM,MAAM,CAAC,SAAS,SAAS,GAAG,IAAI;IACtC,MAAM,IAAI,MAAM,QAAQ,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,GAAG,GAAG;GAC9D;EACF,CAAC;EAED,MAAM,WAAW,MAAM,QAAQ;GAG7B,QAAO,MADS,MAAM;IAAC;IAAc;IAAiB;IAAQ;GAAM,GAAG,IAAI,EAAA,CAClE;EACX;EAEA,iBAAiB,MAAM,iBAAiB,aAAa,MAAM,YAAY;GACrE,MAAM,SAAS,MAAM,MAAM,CAAC,UAAU,aAAa,GAAG,YAAY;GAClE,IAAI,OAAO,MAAM,OAAO,OAAO,KAAK,CAAC,CAAC,SAAS,GAAG;IAChD,MAAM,QAAQ,OAAO,OAAO,MAAM,IAAI,CAAC,CAAC,KAAI,MAAK,EAAE,KAAK,CAAC,CAAC,CAAC,QAAO,MAAK,EAAE,SAAS,CAAC;IACnF,MAAM,IAAI,MAAM,cAAc,MAAM,OAAO,iBAAiB,MAAM,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK,IAAI,GAAG;GAC7F;GACA,MAAM,UAAU,MAAM,MAAM;IAAC;IAAY;IAAU;GAAY,GAAG,IAAI;GACtE,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,MAAM,mBAAmB,QAAQ,OAAO,KAAK,KAAK,QAAQ,OAAO,KAAK,EAAA,CAAG,MAAM,GAAG,GAAG,GAAG;EACrH,CAAC;EAED,eAAe,MAAM,WAAW,aAAa,MAAM,YAAY;GAC7D,MAAM,UAAU,MAAM,MAAM;IAAC;IAAU;IAAM;GAAM,GAAG,IAAI;GAC1D,IAAI,CAAC,QAAQ,IAAI,MAAM,IAAI,MAAM,UAAU,QAAQ,OAAO,KAAK,CAAC,CAAC,MAAM,GAAG,GAAG,GAAG;EAClF,CAAC;CACH;AACF"}
|
|
1
|
+
{"version":3,"file":"git.js","names":[],"sources":["../../src/host/git.ts"],"sourcesContent":["/**\n * Host git face (0.3.0): the ONLY place dsh-taskboard shells out to git.\n * 0.3.1: per-repo serialization of structural operations, binary probing,\n * no-op merge detection, worktree REUSE mode, and evidence size caps.\n *\n * Design invariants (plan §3.4/§3.5):\n * - NARROW interface: detect / binaryAvailable / prepareWorktree / collect /\n * merge / isAncestor / removeWorktree / deleteBranch — nothing else leaks\n * into the plugin.\n * - FAIL-SOFT: every call has a timeout and resolves to a benign result\n * (false / undefined / empty facts) on ANY git failure — a missing git,\n * a locked worktree, or a damaged repo degrades execution to the original\n * directory and NEVER fails the ledger or the run pipeline. Only the\n * explicit user actions (merge / remove / deleteBranch) throw, with a\n * readable message the GUI surfaces as-is.\n * - SERIALIZED structural ops: concurrent isolated executions on the SAME\n * repository would race on git's index/worktree locks, so every structural\n * operation (prepareWorktree / merge / removeWorktree / deleteBranch) runs\n * inside a per-root in-process mutex. Read-only collects stay concurrent.\n * - INJECTABLE runner: the exec layer is a single function so unit tests\n * script every path without a real git.\n *\n * @module dsh-taskboard/host/git\n */\nimport type { CommitInfo } from '../shared/protocol.ts'\n\n/** Timeout for quick read-only queries (rev-parse / status / log / diff). */\nconst QUICK_TIMEOUT_MS = 2_000\n\n/** Timeout for structural operations (worktree add/remove, merge, branch). */\nconst HEAVY_TIMEOUT_MS = 15_000\n\n/** Directory under a workspace where task worktrees live. */\nexport const WORKTREE_DIR = '.dsh-worktrees'\n\n/** Evidence caps: commits kept per execution record (newest first). */\nexport const MAX_COMMIT_EVIDENCE = 50\n\n/** Evidence caps: uncommitted-change lines kept per execution record. */\nexport const MAX_DIRTY_EVIDENCE = 100\n\n/** Diff viewer caps (0.4.0): raw text kept per view. */\nexport const MAX_DIFF_BYTES = 128 * 1024\n\n/** Diff viewer caps: lines kept per view. */\nexport const MAX_DIFF_LINES = 2_000\n\n/** One capped, read-only diff view (diff viewer, 0.4.0). */\nexport interface DiffResult {\n text: string\n truncated: boolean\n}\n\n/** Cap one diff payload by bytes and lines (in order, marking truncation). */\nfunction capDiff(out: string): DiffResult {\n let text = out\n let truncated = false\n if (text.length > MAX_DIFF_BYTES) {\n text = text.slice(0, MAX_DIFF_BYTES)\n truncated = true\n }\n const lines = text.split('\\n')\n if (lines.length > MAX_DIFF_LINES) {\n text = lines.slice(0, MAX_DIFF_LINES).join('\\n')\n truncated = true\n }\n return { text, truncated }\n}\n\n/** A plausible git object hash (defense against option injection). */\nfunction isHash(hash: string): boolean {\n return /^[0-9a-f]{4,64}$/i.test(hash)\n}\n\n/** Result of one underlying exec: `ok` is exit-0, output never null. */\nexport interface ExecResult { ok: boolean; stdout: string; stderr: string }\n\n/** The injectable exec layer: run `git <args>` under a cwd with a timeout. */\nexport type ExecFn = (args: string[], options: { cwd?: string; timeout?: number }) => Promise<ExecResult>\n\n/** Facts needed to open an isolated execution. */\nexport interface WorktreeInfo {\n /** Absolute worktree path (the session's cwd). */\n path: string\n /** The task branch checked out there. */\n branch: string\n /** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */\n baseCommit: string\n /** True when an existing live worktree was kept as-is (续跑). */\n reused?: boolean\n}\n\n/** Settlement facts collected from a worktree (partial on best-effort basis). */\nexport interface SettlementFacts {\n headCommit?: string\n commits: CommitInfo[]\n /** Total commits before capping (equals commits.length when under the cap). */\n commitsTotal: number\n dirtyFiles: string[]\n /** Total uncommitted lines before capping. */\n dirtyFilesTotal: number\n diffStat?: string\n changedFiles: number\n}\n\n/** The narrow git face the rest of the plugin depends on. */\nexport interface GitFace {\n /** Whether `root` sits inside a usable git work tree (fail-soft → false). */\n detect(root: string): Promise<boolean>\n /** Whether a usable git binary answers at all (distinguishes 未装 git vs 非 git 仓库). */\n binaryAvailable(): Promise<boolean>\n /**\n * Ensure a worktree at `path` on `branch`. Default mode `'fresh'` resets to\n * the main worktree's current HEAD (每次全新); mode `'reuse'` keeps a live\n * worktree exactly as-is (续跑 — agent's commits and uncommitted changes\n * survive) and falls back to a fresh creation when none is alive. Resolves\n * undefined on any failure — callers degrade to the original directory.\n */\n prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>\n /** Collect settlement facts (never throws; missing pieces stay unset). */\n collect(worktreePath: string, baseCommit: string): Promise<SettlementFacts>\n /** Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable reason. */\n merge(root: string, branch: string): Promise<void>\n /** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */\n isAncestor(root: string, branch: string): Promise<boolean>\n /** Remove a worktree; THROWS when it still has uncommitted changes. */\n removeWorktree(root: string, worktreePath: string): Promise<void>\n /** Delete a branch; THROWS (e.g. still checked out in a worktree). */\n deleteBranch(root: string, branch: string): Promise<void>\n /**\n * Show one commit (message + patch) — diff viewer (0.4.0). Fail-soft:\n * undefined on any git failure; the payload is capped.\n */\n showCommit(cwd: string, hash: string): Promise<DiffResult | undefined>\n /**\n * Show the diff of one path — diff viewer (0.4.0). Without `baseCommit`:\n * the working-tree view (staged + unstaged vs HEAD, e.g. uncommitted\n * changes in a live worktree); with `baseCommit`: the range\n * base..HEAD restricted to the path. Fail-soft: undefined on failure.\n */\n showPathDiff(cwd: string, path: string, baseCommit?: string): Promise<DiffResult | undefined>\n}\n\n/**\n * Build the task branch name `task/<标题>+<taskId>` (plan §9 拍板).\n *\n * Title sanitizing: whitespace runs collapse to `-`; git-illegal characters\n * (`~ ^ : ? * [ \\ / @ { }` and friends) are stripped; `..` collapses; the\n * segment is trimmed of leading/trailing `.-` and truncated to ~20 code\n * points; an empty result falls back to the bare `task/<taskId>`.\n * @param title - the task title (already normalized 1..200 chars).\n * @param taskId - the task id (stable suffix).\n * @returns the branch name.\n */\nexport function sanitizeBranchName(title: string, taskId: string): string {\n const segment = title.trim()\n .replace(/\\s+/g, '-')\n .replace(/[/\\\\~^:?*[\\]@{}\"'<>|#%&;$!`'=,;()]+/g, '')\n .replace(/\\.\\.+/g, '.')\n .replace(/^[-.\\s]+|[-.\\s]+$/g, '')\n const head = Array.from(segment).slice(0, 20).join('').replace(/^[-.]+|[-.]+$/g, '')\n return head.length === 0 ? `task/${taskId}` : `task/${head}+${taskId}`\n}\n\n/** The canonical worktree path of a task inside its workspace (forward slashes). */\nexport function worktreePathOf(workspacePath: string, taskId: string): string {\n const root = workspacePath.replace(/[\\\\/]+$/, '').replaceAll('\\\\', '/')\n return `${root}/${WORKTREE_DIR}/${taskId}`\n}\n\n/** Real exec layer over child_process.execFile (windowsHide, timeout, maxBuffer). */\nconst realExec: ExecFn = (args, options) => new Promise(resolve => {\n void (async () => {\n const { execFile } = await import('node:child_process')\n execFile('git', args, {\n cwd: options.cwd,\n timeout: options.timeout ?? QUICK_TIMEOUT_MS,\n windowsHide: true,\n maxBuffer: 4 * 1024 * 1024,\n encoding: 'utf8',\n }, (error, stdout, stderr) => {\n resolve({ ok: error === null, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') })\n })\n })().catch(() => resolve({ ok: false, stdout: '', stderr: 'exec unavailable' }))\n})\n\n/**\n * Build a {@link GitFace} over an injectable exec layer.\n * @param exec - the exec function (real `git` when omitted).\n */\nexport function createGitFace(exec: ExecFn = realExec): GitFace {\n const quick = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: QUICK_TIMEOUT_MS })\n const heavy = (args: string[], cwd?: string): Promise<ExecResult> => exec(args, { cwd, timeout: HEAVY_TIMEOUT_MS })\n\n // Per-root mutex (0.3.1): structural git ops on the SAME repository run one\n // at a time — concurrent isolated executions must not race on git's locks.\n const locks = new Map<string, Promise<unknown>>()\n const withRootLock = <T>(root: string, fn: () => Promise<T>): Promise<T> => {\n const prev = locks.get(root) ?? Promise.resolve()\n const next = prev.then(fn, fn)\n locks.set(root, next.catch(() => { /* the chain never blocks later ops */ }))\n return next\n }\n\n return {\n async detect(root) {\n const r = await quick(['rev-parse', '--is-inside-work-tree'], root)\n return r.ok && r.stdout.trim() === 'true'\n },\n\n async binaryAvailable() {\n const r = await quick(['--version'])\n return r.ok && r.stdout.startsWith('git version')\n },\n\n prepareWorktree: (root, path, branch, mode = 'fresh') => withRootLock(root, async () => {\n // 续跑: a live worktree at the path is kept EXACTLY as-is — the agent's\n // commits and uncommitted changes survive; the baseline becomes the\n // worktree's own HEAD so evidence covers only the new run.\n if (mode === 'reuse') {\n const wtHead = await quick(['rev-parse', 'HEAD'], path)\n if (wtHead.ok && wtHead.stdout.trim().length > 0) {\n return { path, branch, baseCommit: wtHead.stdout.trim(), reused: true }\n }\n // No live worktree → fall through to a fresh preparation.\n }\n\n // Baseline: the main worktree's current HEAD (also validates the repo).\n const head = await quick(['rev-parse', 'HEAD'], root)\n if (!head.ok) return undefined\n const baseCommit = head.stdout.trim()\n\n const exists = await quick(['show-ref', '--verify', `refs/heads/${branch}`], root)\n if (exists.ok) {\n // Reuse the fixed branch name, but guarantee a FRESH baseline: drop\n // any stale worktree at the path, move the branch to the current\n // HEAD, then check the branch out again (每次全新,复用仅作选项保留).\n await heavy(['worktree', 'remove', '--force', path], root)\n await heavy(['worktree', 'prune'], root)\n const moved = await heavy(['branch', '-f', branch, 'HEAD'], root)\n if (!moved.ok) return undefined\n const added = await heavy(['worktree', 'add', path, branch], root)\n if (!added.ok) return undefined\n } else {\n const added = await heavy(['worktree', 'add', '-b', branch, path], root)\n if (!added.ok) return undefined\n }\n return { path, branch, baseCommit }\n }),\n\n async collect(worktreePath, baseCommit) {\n const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }\n const range = `${baseCommit}..HEAD`\n\n const head = await quick(['rev-parse', 'HEAD'], worktreePath)\n if (head.ok) facts.headCommit = head.stdout.trim()\n\n const log = await quick(['log', '--pretty=format:%h %s', range], worktreePath)\n if (log.ok) {\n const commits = log.stdout.split('\\n')\n .map(line => line.trim())\n .filter(line => line.length > 0)\n .map(line => {\n const space = line.indexOf(' ')\n return space === -1\n ? { hash: line, subject: '' }\n : { hash: line.slice(0, space), subject: line.slice(space + 1) }\n })\n // Evidence caps (0.3.1): the ledger is rewritten whole on every\n // mutation — cap what a huge branch/status dump can add to it.\n facts.commitsTotal = commits.length\n facts.commits = commits.slice(0, MAX_COMMIT_EVIDENCE)\n }\n\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok) {\n const dirty = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n facts.dirtyFilesTotal = dirty.length\n facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)\n }\n\n const shortstat = await quick(['diff', '--shortstat', range], worktreePath)\n if (shortstat.ok && shortstat.stdout.trim().length > 0) facts.diffStat = shortstat.stdout.trim()\n\n const names = await quick(['diff', '--name-only', range], worktreePath)\n if (names.ok) facts.changedFiles = names.stdout.split('\\n').filter(l => l.trim().length > 0).length\n\n return facts\n },\n\n merge: (root, branch) => withRootLock(root, async () => {\n // Main-clean check. The plugin's own worktree directory\n // (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —\n // otherwise merging would be impossible without gitignoring it first.\n const status = await quick(['status', '--porcelain'], root)\n if (status.ok) {\n const dirtyLines = status.stdout.split('\\n')\n .map(l => l.trim())\n .filter(l => {\n if (l.length === 0) return false\n const path = l.slice(3)\n return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)\n })\n if (dirtyLines.length > 0) {\n throw new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`)\n }\n }\n const merged = await heavy(['merge', '--no-ff', '--no-edit', branch], root)\n if (!merged.ok) {\n // Roll the half-finished merge back so the main worktree stays usable;\n // report the ORIGINAL failure verbatim (不自动解决冲突).\n await heavy(['merge', '--abort'], root)\n throw new Error(`合并失败:${merged.stderr.trim().slice(0, 300)}`)\n }\n }),\n\n async isAncestor(root, branch) {\n // exit 0 = branch is an ancestor of (or equal to) HEAD → merge no-op.\n const r = await quick(['merge-base', '--is-ancestor', branch, 'HEAD'], root)\n return r.ok\n },\n\n removeWorktree: (root, worktreePath) => withRootLock(root, async () => {\n const status = await quick(['status', '--porcelain'], worktreePath)\n if (status.ok && status.stdout.trim().length > 0) {\n const lines = status.stdout.split('\\n').map(l => l.trim()).filter(l => l.length > 0)\n throw new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\\n${lines.slice(0, 10).join('\\n')}`)\n }\n const removed = await heavy(['worktree', 'remove', worktreePath], root)\n if (!removed.ok) throw new Error(`删除 worktree 失败:${(removed.stderr.trim() || removed.stdout.trim()).slice(0, 300)}`)\n }),\n\n deleteBranch: (root, branch) => withRootLock(root, async () => {\n const deleted = await heavy(['branch', '-D', branch], root)\n if (!deleted.ok) throw new Error(`删除分支失败:${deleted.stderr.trim().slice(0, 300)}`)\n }),\n\n async showCommit(cwd, hash) {\n if (!isHash(hash)) return undefined\n const r = await quick(['show', '--no-color', '--format=medium', hash], cwd)\n if (!r.ok || r.stdout.trim().length === 0) return undefined\n return capDiff(r.stdout)\n },\n\n async showPathDiff(cwd, path, baseCommit) {\n const target = path.trim()\n if (target.length === 0) return undefined\n if (baseCommit !== undefined && isHash(baseCommit)) {\n const r = await quick(['diff', '--no-color', `${baseCommit}..HEAD`, '--', target], cwd)\n if (!r.ok) return undefined\n if (r.stdout.trim().length === 0) return { text: '(该文件无差异)', truncated: false }\n return capDiff(r.stdout)\n }\n // Working-tree view: staged + unstaged vs HEAD.\n const r = await quick(['diff', '--no-color', 'HEAD', '--', target], cwd)\n if (r.ok && r.stdout.trim().length > 0) return capDiff(r.stdout)\n // Untracked files never appear in `git diff` — detect one and synthesize\n // its new-file patch via --no-index (which exits 1 on differences, so\n // its stdout is trusted whenever it carries a diff header).\n const st = await quick(['status', '--porcelain', '--', target], cwd)\n if (r.ok && st.ok && st.stdout.trim().startsWith('??')) {\n const ni = await exec(['diff', '--no-color', '--no-index', '--', '/dev/null', target], { cwd, timeout: QUICK_TIMEOUT_MS })\n if (ni.stdout.includes('diff --git')) return capDiff(ni.stdout)\n return { text: `(未跟踪新文件:${target})`, truncated: false }\n }\n if (!r.ok) return undefined\n return { text: '(该文件无差异)', truncated: false }\n },\n }\n}\n"],"mappings":"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"}
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
const TASKBOARD_PROTOCOL = [
|
|
15
15
|
"本机已安装 dsh-taskboard 插件(DSH 任务看板):任务挂在项目(DSH workspace)上,",
|
|
16
16
|
"用 taskboard_* 工具读写;人在 Web GUI 看板上实时看到同样数据。能力:查板(list/get)、",
|
|
17
|
-
"建卡(create)、改卡(update)、移卡(move)、评论(comment_add/comments)、删除(delete=仅标记)
|
|
17
|
+
"建卡(create)、改卡(update)、移卡(move)、评论(comment_add/comments)、删除(delete=仅标记)、",
|
|
18
|
+
"验收清单(checklist)、执行报告(execution_report)。",
|
|
18
19
|
"任务带紧急度(urgent红/normal紫/relaxed蓝)、执行方式(claim认领/scheduled定时)与可选指定模型。",
|
|
19
20
|
"工作纪律:",
|
|
20
21
|
"1. 开工先查板:开始工作前先 taskboard_list(按本项目过滤、status=todo),有可认领任务时按纪律认领。",
|
|
@@ -22,8 +23,9 @@ const TASKBOARD_PROTOCOL = [
|
|
|
22
23
|
"3. 先认领再干活:把 todo→in_progress(带 ifVersion)成功后,才开始读代码/分析实现;",
|
|
23
24
|
" 认领失败(版本冲突/项目边界不符/已被他人持有)就停止并报告,绝不循环重试或接管他人任务。",
|
|
24
25
|
"4. 版本冲突只重试一次:ifVersion 冲突时重新读卡,仅当状态仍可认领且需求未变时用新版本号重试一次,再失败即停止报告。",
|
|
25
|
-
"5.
|
|
26
|
-
"
|
|
26
|
+
"5. 验收交接:实现并自验后,taskboard_execution_report 提交结构化报告(摘要/改动文件/自验/风险),",
|
|
27
|
+
" 评论记录补充细节,再把 in_progress→in_review;任务带验收清单时用 taskboard_checklist 逐项勾选(附证据)。",
|
|
28
|
+
"6. 完成须用户确认:你永远不能把任务移到 done——那是用户的确认动作;清单全勾也不等于完成;blocked=无法继续,canceled=不再继续。",
|
|
27
29
|
"7. backlog=未授权:backlog 任务不算批准执行,被指派也不是授权,除非用户明确要求。",
|
|
28
30
|
"8. 模型与定时只读:任务的 model 与 execution 配置归创建者/用户所有,update 工具不允许你修改这两个字段。",
|
|
29
31
|
"项目边界:只有属于任务所在项目的会话才能认领(todo→in_progress)或执行它。",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol-text.js","names":[],"sources":["../../src/host/protocol-text.ts"],"sourcesContent":["/**\n * The agent workflow protocol text — the single source the system-prompt\n * section serves. This is a behavioral contract, not a feature ad: claiming\n * discipline, optimistic-version retry rules, review handoff, and the\n * user-only completion gate.\n *\n * The regression test (tests/protocol.spec.ts) locks the discipline\n * sentences, so editing the text without revisiting the test fails loud.\n *\n * @module dsh-taskboard/host/protocol-text\n */\n\n/** The protocol section served to every agent (Chinese UI deployment). */\nexport const TASKBOARD_PROTOCOL = [\n '本机已安装 dsh-taskboard 插件(DSH 任务看板):任务挂在项目(DSH workspace)上,',\n '用 taskboard_* 工具读写;人在 Web GUI 看板上实时看到同样数据。能力:查板(list/get)、',\n '建卡(create)、改卡(update)、移卡(move)、评论(comment_add/comments)、删除(delete=仅标记)。',\n '任务带紧急度(urgent红/normal紫/relaxed蓝)、执行方式(claim认领/scheduled定时)与可选指定模型。',\n '工作纪律:',\n '1. 开工先查板:开始工作前先 taskboard_list(按本项目过滤、status=todo),有可认领任务时按纪律认领。',\n '2. 先读后动:动卡前先 taskboard_get 并读评论;评论视为最新需求,若要求等待/暂缓,停下汇报,不改状态。',\n '3. 先认领再干活:把 todo→in_progress(带 ifVersion)成功后,才开始读代码/分析实现;',\n ' 认领失败(版本冲突/项目边界不符/已被他人持有)就停止并报告,绝不循环重试或接管他人任务。',\n '4. 版本冲突只重试一次:ifVersion 冲突时重新读卡,仅当状态仍可认领且需求未变时用新版本号重试一次,再失败即停止报告。',\n '5.
|
|
1
|
+
{"version":3,"file":"protocol-text.js","names":[],"sources":["../../src/host/protocol-text.ts"],"sourcesContent":["/**\n * The agent workflow protocol text — the single source the system-prompt\n * section serves. This is a behavioral contract, not a feature ad: claiming\n * discipline, optimistic-version retry rules, review handoff, and the\n * user-only completion gate.\n *\n * The regression test (tests/protocol.spec.ts) locks the discipline\n * sentences, so editing the text without revisiting the test fails loud.\n *\n * @module dsh-taskboard/host/protocol-text\n */\n\n/** The protocol section served to every agent (Chinese UI deployment). */\nexport const TASKBOARD_PROTOCOL = [\n '本机已安装 dsh-taskboard 插件(DSH 任务看板):任务挂在项目(DSH workspace)上,',\n '用 taskboard_* 工具读写;人在 Web GUI 看板上实时看到同样数据。能力:查板(list/get)、',\n '建卡(create)、改卡(update)、移卡(move)、评论(comment_add/comments)、删除(delete=仅标记)、',\n '验收清单(checklist)、执行报告(execution_report)。',\n '任务带紧急度(urgent红/normal紫/relaxed蓝)、执行方式(claim认领/scheduled定时)与可选指定模型。',\n '工作纪律:',\n '1. 开工先查板:开始工作前先 taskboard_list(按本项目过滤、status=todo),有可认领任务时按纪律认领。',\n '2. 先读后动:动卡前先 taskboard_get 并读评论;评论视为最新需求,若要求等待/暂缓,停下汇报,不改状态。',\n '3. 先认领再干活:把 todo→in_progress(带 ifVersion)成功后,才开始读代码/分析实现;',\n ' 认领失败(版本冲突/项目边界不符/已被他人持有)就停止并报告,绝不循环重试或接管他人任务。',\n '4. 版本冲突只重试一次:ifVersion 冲突时重新读卡,仅当状态仍可认领且需求未变时用新版本号重试一次,再失败即停止报告。',\n '5. 验收交接:实现并自验后,taskboard_execution_report 提交结构化报告(摘要/改动文件/自验/风险),',\n ' 评论记录补充细节,再把 in_progress→in_review;任务带验收清单时用 taskboard_checklist 逐项勾选(附证据)。',\n '6. 完成须用户确认:你永远不能把任务移到 done——那是用户的确认动作;清单全勾也不等于完成;blocked=无法继续,canceled=不再继续。',\n '7. backlog=未授权:backlog 任务不算批准执行,被指派也不是授权,除非用户明确要求。',\n '8. 模型与定时只读:任务的 model 与 execution 配置归创建者/用户所有,update 工具不允许你修改这两个字段。',\n '项目边界:只有属于任务所在项目的会话才能认领(todo→in_progress)或执行它。',\n '用户提到「任务看板/看板/认领任务」时即指本插件,请据此协作。',\n].join('\\n')\n\n/** Section order inside the tool-guidance band (100–199). */\nexport const PROTOCOL_SECTION_ORDER = 180\n\n/** Registered section name. */\nexport const PROTOCOL_SECTION_NAME = 'plugin:dsh-taskboard'\n"],"mappings":";;;;;;;;;;;;;AAaA,MAAa,qBAAqB;CAChC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF,CAAC,CAAC,KAAK,IAAI;;AAMX,MAAa,wBAAwB"}
|