dsh-taskboard 0.6.1 → 0.6.3

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.
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @module dsh-taskboard/client/controller
9
9
  */
10
- import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, PromptCompletionsResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
10
+ import type { ChangeEvent, DiagnosticsResponse, DiffResponse, ImportCommitResponse, ImportPreviewResponse, MergeRepoResult, PromptCompletionsResponse, TaskTemplate, TaskTemplateSpec, UpdateTaskBody, WorkspaceView } from '../shared/api.ts'
11
11
  import type { ChecklistItem, TaskLedger, TaskRecord, Urgency } from '../shared/protocol.ts'
12
12
  import { emptyLedger } from '../shared/protocol.ts'
13
13
  import type { TaskboardClient } from './api.ts'
@@ -277,6 +277,12 @@ export class BoardController {
277
277
  return this.state.workspaces.find(w => w.id === workspaceId)?.gitAvailable === true
278
278
  }
279
279
 
280
+ /** How many repos a task mirror of this workspace would cover (0.6.3 mirror badge). */
281
+ repoCount(workspaceId: string | undefined): number {
282
+ if (workspaceId === undefined) return 1
283
+ return this.state.workspaces.find(w => w.id === workspaceId)?.repoCount ?? 1
284
+ }
285
+
280
286
  /**
281
287
  * Install the session-jump bridge (built from the runtime sessions service
282
288
  * by the client entry). Without it openSession reports 'unavailable'.
@@ -473,8 +479,8 @@ export class BoardController {
473
479
  }
474
480
  }
475
481
 
476
- /** Diff view (0.4.0): one execution's commit or changed path; errors surface via throw. */
477
- async fetchDiff(taskId: string, query: { execution: string; commit?: string; path?: string }): Promise<DiffResponse | undefined> {
482
+ /** Diff view (0.4.0): one execution's commit or changed path; `repo` picks a mirror repo (0.6.3). */
483
+ async fetchDiff(taskId: string, query: { execution: string; commit?: string; path?: string; repo?: string }): Promise<DiffResponse | undefined> {
478
484
  try {
479
485
  return await this.client.diff(taskId, query)
480
486
  } catch (error) {
@@ -520,13 +526,16 @@ export class BoardController {
520
526
 
521
527
  /**
522
528
  * ⇥ 合并 (detail page): merge the task branch into the main worktree.
523
- * @returns the outcome; `noop` means the branch had no new commits (nothing merged).
529
+ * @returns the outcome; `noop` means the branch had no new commits (nothing
530
+ * merged); multi-repo tasks (0.6.3) additionally carry per-repo `results`.
524
531
  */
525
- async mergeBranch(id: string): Promise<{ ok: true; noop?: boolean } | { ok: false; error: string }> {
532
+ async mergeBranch(id: string): Promise<{ ok: true; noop?: boolean; results?: MergeRepoResult[] } | { ok: false; error: string }> {
526
533
  try {
527
534
  const value = await this.client.mergeBranch(id)
528
535
  await this.refresh()
529
- return value.noop === true ? { ok: true, noop: true } : { ok: true }
536
+ return value.noop === true
537
+ ? { ok: true, noop: true }
538
+ : value.results !== undefined ? { ok: true, results: value.results } : { ok: true }
530
539
  } catch (error) {
531
540
  return { ok: false, error: error instanceof Error ? error.message : String(error) }
532
541
  }
@@ -166,6 +166,9 @@ export const en: TaskboardDict = {
166
166
  'iso.merge.button': '⇥ Merge into main worktree',
167
167
  'iso.merge.failed': 'Merge failed: {error}',
168
168
  'iso.merge.noop': 'The branch has no commits ahead of the main worktree — nothing to merge (send it back to continue running, or clean it up)',
169
+ 'iso.merge.done': 'Merged per repo (--no-ff): {summary}',
170
+ 'iso.merge.partial': 'Some repos failed to merge: {summary}\n{error}',
171
+ 'iso.repo.root': 'Root repo',
169
172
  'iso.remove.wt': '🗑 Remove worktree',
170
173
  'iso.remove.wtTitle': 'git worktree remove (refused when uncommitted changes exist)',
171
174
  'iso.remove.wtb': '🗑 Remove worktree + branch',
@@ -290,6 +293,7 @@ export const en: TaskboardDict = {
290
293
  'form.iso.noneHintNonGit': 'Not a git repository; will run in the project directory',
291
294
  'form.iso.noneHint': 'No git; works directly in the project directory',
292
295
  'form.iso.nonGitNote': 'This project is not a git repository; the task will run in its directory (still created with the default configuration; the runtime degrades automatically)',
296
+ 'form.iso.mirrorNote': 'Multi-repo workspace: worktree isolation automatically mirrors all {n} repos — one task branch each, merged per repo at acceptance',
293
297
  'form.field.checklist': 'Acceptance checklist (DoD)',
294
298
  'form.field.checklistOptional': 'Acceptance checklist (DoD, optional)',
295
299
  'form.check.itemPlaceholder': 'Checklist item {n} (definition of done)',
@@ -361,7 +365,7 @@ export const en: TaskboardDict = {
361
365
  'set.subtitle': 'Global defaults for new tasks and session sync',
362
366
  'set.iso.heading': 'Default execution isolation',
363
367
  'set.iso.noneHint': 'No git; works directly in the project directory (factory default)',
364
- 'set.iso.worktreeHint': 'Each execution runs on its own worktree branch (task/title+ID), isolated from the others',
368
+ 'set.iso.worktreeHint': 'Each execution runs on its own worktree branch (task/title+ID), isolated from the others; multi-repo workspaces are mirrored whole (one branch per repo)',
365
369
  'set.iso.current': 'Currently saved default: {current}. Affects only tasks created hereafter; existing tasks keep their creation-time choice, and non-git projects still degrade to the project directory at run time.',
366
370
  'set.sync.heading': 'Auto-sync workspace sessions',
367
371
  'set.sync.off.name': '🚫 Sync off',
@@ -168,6 +168,9 @@ export const zh = {
168
168
  'iso.merge.button': '⇥ 合并到主工作区',
169
169
  'iso.merge.failed': '合并失败:{error}',
170
170
  'iso.merge.noop': '该分支没有领先主工作区的新提交,无需合并(可退回续跑或直接清理)',
171
+ 'iso.merge.done': '已按仓库合并(--no-ff):{summary}',
172
+ 'iso.merge.partial': '部分仓库合并失败:{summary}\n{error}',
173
+ 'iso.repo.root': '根仓库',
171
174
  'iso.remove.wt': '🗑 删除 worktree',
172
175
  'iso.remove.wtTitle': 'git worktree remove(有未提交修改时拒绝)',
173
176
  'iso.remove.wtb': '🗑 删 worktree + 分支',
@@ -292,6 +295,7 @@ export const zh = {
292
295
  'form.iso.noneHintNonGit': '当前项目非 git 仓库,将在原目录执行',
293
296
  'form.iso.noneHint': '不使用 git,直接在项目目录工作',
294
297
  'form.iso.nonGitNote': '当前项目非 git 仓库,将在原目录执行(任务仍按默认配置创建,运行时自动降级)',
298
+ 'form.iso.mirrorNote': '多仓库工作区:Worktree 隔离将自动整区镜像 {n} 个仓库——每仓库独立任务分支,验收按仓库合并',
295
299
  'form.field.checklist': '验收清单(DoD)',
296
300
  'form.field.checklistOptional': '验收清单(DoD,可选)',
297
301
  'form.check.itemPlaceholder': '验收项 {n}(完成标准)',
@@ -363,7 +367,7 @@ export const zh = {
363
367
  'set.subtitle': '新建任务与会话同步的全局默认值',
364
368
  'set.iso.heading': '默认执行隔离',
365
369
  'set.iso.noneHint': '不使用 git,直接在项目目录工作(出厂默认)',
366
- 'set.iso.worktreeHint': '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染',
370
+ 'set.iso.worktreeHint': '每次执行在独立 worktree 分支上进行(task/标题+ID),互不污染;多仓库工作区自动整区镜像(每仓库独立分支)',
367
371
  'set.iso.current': '当前保存的默认:{current}。仅影响之后新建的任务;已有任务保持创建时的选择,非 git 项目运行时仍自动降级原目录。',
368
372
  'set.sync.heading': '自动同步工作区会话',
369
373
  'set.sync.off.name': '🚫 关闭同步',
@@ -622,6 +622,13 @@ button.dsh-atb-chip2.dsh-atb-chip-btn:hover {
622
622
  .dsh-atb-isolation-note { display: block; margin-top: 6px; font-size: 11px; color: var(--dsw-alias-label-tertiary, gray); }
623
623
  .dsh-atb-mode-picker[data-disabled="true"] .dsh-atb-mode-opt { cursor: not-allowed; opacity: .55; }
624
624
  .dsh-atb-iso-none { font-size: 12.5px; color: var(--dsw-alias-label-secondary, inherit); }
625
+ .dsh-atb-iso-repo[data-multi="true"] { padding: 6px 0 2px; border-top: 1px dashed var(--dsw-alias-border-secondary, #ddd2); }
626
+ .dsh-atb-iso-repo[data-multi="true"]:first-of-type { border-top: none; padding-top: 0; }
627
+ .dsh-atb-iso-repohead { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 6px; align-items: baseline; }
628
+ .dsh-atb-iso-repohead code {
629
+ font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; font-weight: 600;
630
+ color: var(--dsw-alias-state-business-primary, #3e63dd);
631
+ }
625
632
  .dsh-atb-iso-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; margin-bottom: 8px; }
626
633
  .dsh-atb-iso-fact { font-size: 11.5px; color: var(--dsw-alias-label-secondary, inherit); }
627
634
  .dsh-atb-iso-fact b { font-weight: 600; color: var(--dsw-alias-state-business-primary, #3e63dd); }
@@ -18,12 +18,15 @@ import {
18
18
  newExecutionId,
19
19
  normalizeBody,
20
20
  type ExecutionRecord,
21
+ type ExecutionRepoEvidence,
21
22
  type IsolationMode,
22
23
  type PermissionMode,
23
24
  type TaskModel,
24
25
  type TaskRecord,
25
26
  } from '../shared/protocol.ts'
26
- import { sanitizeBranchName, worktreePathOf, type GitFace, type SettlementFacts } from './git.ts'
27
+ import { sanitizeBranchName, type GitFace, type SettlementFacts } from './git.ts'
28
+ import { isLegacySingle, prepareMirror, type PreparedMirror, type PreparedMirrorRepo } from './isolation.ts'
29
+ import { createRepoScanner, type RepoScanner } from './repos.ts'
27
30
  import { MessageId } from './sdk.ts'
28
31
  import type { TaskStore } from './store.ts'
29
32
 
@@ -94,6 +97,11 @@ export interface ExecutionDeps {
94
97
  * task degrades to the original directory with an isolationNote.
95
98
  */
96
99
  git?: GitFace
100
+ /**
101
+ * Nested-repo scanner for multi-repo mirrors (0.6.3). Absent → a default
102
+ * real-filesystem scanner is built on first use.
103
+ */
104
+ scanner?: RepoScanner
97
105
  /**
98
106
  * Resolve the preset composition for an execution session (0.3.3): hands
99
107
  * the session its tool set. Absent → sessions run on the bare host
@@ -131,15 +139,6 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
131
139
  return { message }
132
140
  }
133
141
 
134
- /** Prepared worktree facts threaded through a live run (settlement evidence). */
135
- export interface PreparedWorktree {
136
- branch: string
137
- worktreePath: string
138
- baseCommit: string
139
- /** True when an existing live worktree was kept as-is (续跑). */
140
- reused?: boolean
141
- }
142
-
143
142
  /** Per-run options. */
144
143
  export interface RunOptions {
145
144
  /**
@@ -153,8 +152,8 @@ export interface RunOptions {
153
152
  /** One live execution tracked for settlement and cancellation. */
154
153
  interface RunEntry {
155
154
  sessionId: string
156
- /** Worktree prepared for this run (evidence collection at ANY settlement). */
157
- prepared?: PreparedWorktree
155
+ /** Task mirror prepared for this run (evidence collection at ANY settlement). */
156
+ prepared?: PreparedMirror
158
157
  settle: () => void
159
158
  dispose: () => Promise<void>
160
159
  }
@@ -193,28 +192,74 @@ export class ExecutionService {
193
192
  }
194
193
 
195
194
  /**
196
- * Best-effort evidence collection for a prepared run (fail-soft: undefined
197
- * on any git problem — settlement NEVER blocks on git).
195
+ * Best-effort evidence collection for a prepared mirror: a repo whose git
196
+ * collect fails is SKIPPED (missing pieces stay unset — settlement NEVER
197
+ * blocks on git); all-fail resolves undefined.
198
198
  */
199
- private async collectEvidence(prepared: PreparedWorktree | undefined): Promise<SettlementFacts | undefined> {
200
- if (prepared === undefined || this.deps.git === undefined) return undefined
201
- try {
202
- return await this.deps.git.collect(prepared.worktreePath, prepared.baseCommit)
203
- } catch {
204
- return undefined
199
+ private async collectEvidence(prepared: PreparedMirror | undefined): Promise<Array<{ repo: PreparedMirrorRepo; facts: SettlementFacts }> | undefined> {
200
+ if (prepared === undefined || this.deps.git === undefined || prepared.repos.length === 0) return undefined
201
+ const out: Array<{ repo: PreparedMirrorRepo; facts: SettlementFacts }> = []
202
+ // The root worktree's status lists its nested child worktrees as untracked
203
+ // noise — exclude them so a fully committed mirror doesn't report fake
204
+ // dirty evidence (0.6.3 review fix).
205
+ const nestedRels = prepared.repos.filter(r => r.repo !== '').map(r => r.repo)
206
+ for (const repo of prepared.repos) {
207
+ try {
208
+ const facts = await this.deps.git.collect(
209
+ repo.worktreePath,
210
+ repo.baseCommit,
211
+ repo.repo === '' && nestedRels.length > 0 ? nestedRels : undefined,
212
+ )
213
+ out.push({ repo, facts })
214
+ } catch {
215
+ /* fail-soft: this repo contributes no evidence */
216
+ }
217
+ }
218
+ return out.length > 0 ? out : undefined
219
+ }
220
+
221
+ /** Map one repo's settlement facts onto evidence record fields. */
222
+ private factsFields(facts: SettlementFacts): Omit<ExecutionRepoEvidence, 'repo' | 'branch' | 'worktreePath' | 'baseCommit'> {
223
+ return {
224
+ ...(facts.headCommit !== undefined ? { headCommit: facts.headCommit } : {}),
225
+ commits: facts.commits,
226
+ commitsTotal: facts.commitsTotal,
227
+ dirtyFiles: facts.dirtyFiles,
228
+ dirtyFilesTotal: facts.dirtyFilesTotal,
229
+ changedFiles: facts.changedFiles,
230
+ ...(facts.diffStat !== undefined ? { diffStat: facts.diffStat } : {}),
205
231
  }
206
232
  }
207
233
 
208
- /** Copy collected facts onto an execution record (in place). */
209
- private applyFacts(execution: ExecutionRecord, facts: SettlementFacts | undefined): void {
210
- if (facts === undefined) return
211
- if (facts.headCommit !== undefined) execution.headCommit = facts.headCommit
212
- execution.commits = facts.commits
213
- execution.commitsTotal = facts.commitsTotal
214
- execution.dirtyFiles = facts.dirtyFiles
215
- execution.dirtyFilesTotal = facts.dirtyFilesTotal
216
- execution.changedFiles = facts.changedFiles
217
- if (facts.diffStat !== undefined) execution.diffStat = facts.diffStat
234
+ /**
235
+ * Copy collected facts onto an execution record (in place). The legacy
236
+ * flat fields always carry the FIRST repo (the workspace root when it has
237
+ * one) so single-repo records stay byte-identical to the pre-mirror shape;
238
+ * non-legacy mirrors additionally fill the per-repo `repos` evidence.
239
+ */
240
+ private applyFacts(
241
+ execution: ExecutionRecord,
242
+ prepared: PreparedMirror | undefined,
243
+ evidence: Array<{ repo: PreparedMirrorRepo; facts: SettlementFacts }> | undefined,
244
+ ): void {
245
+ if (evidence === undefined || evidence.length === 0) return
246
+ const first = evidence[0]!.facts
247
+ if (first.headCommit !== undefined) execution.headCommit = first.headCommit
248
+ execution.commits = first.commits
249
+ execution.commitsTotal = first.commitsTotal
250
+ execution.dirtyFiles = first.dirtyFiles
251
+ execution.dirtyFilesTotal = first.dirtyFilesTotal
252
+ execution.changedFiles = first.changedFiles
253
+ if (first.diffStat !== undefined) execution.diffStat = first.diffStat
254
+ if (prepared !== undefined && !isLegacySingle(prepared)) {
255
+ execution.repos = evidence.map(({ repo, facts }) => ({
256
+ repo: repo.repo,
257
+ branch: repo.branch,
258
+ worktreePath: repo.worktreePath,
259
+ baseCommit: repo.baseCommit,
260
+ ...this.factsFields(facts),
261
+ }))
262
+ }
218
263
  }
219
264
 
220
265
  /**
@@ -228,7 +273,7 @@ export class ExecutionService {
228
273
  // The failed session may already have committed work — collect the
229
274
  // evidence (best effort) BEFORE marking the execution failed (0.3.1).
230
275
  const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)
231
- return this.collectEvidence(entry?.prepared).then(facts =>
276
+ return this.collectEvidence(entry?.prepared).then(evidence =>
232
277
  this.deps.store.mutate('execution-recorded', (ledger) => {
233
278
  for (const task of ledger.tasks) {
234
279
  for (const execution of task.executions) {
@@ -236,7 +281,7 @@ export class ExecutionService {
236
281
  execution.outcome = 'failed'
237
282
  execution.error = message.slice(0, 500)
238
283
  execution.endedAt = this.deps.now()
239
- this.applyFacts(execution, facts)
284
+ this.applyFacts(execution, entry?.prepared, evidence)
240
285
  // The failed session will not finish the work: hand the task back
241
286
  // instead of leaving it stuck in in_progress forever — and leave a
242
287
  // system comment so the GUI shows why.
@@ -315,7 +360,6 @@ export class ExecutionService {
315
360
  // the original directory fail-soft on any git problem.
316
361
  const isolation: IsolationMode = effectiveIsolation(task)
317
362
  const branch = task.branch ?? sanitizeBranchName(task.title, task.id)
318
- const worktreePath = worktreePathOf(workspace.path, task.id)
319
363
 
320
364
  // 1. Open the execution record, flip the card to in_progress, and record
321
365
  // the executing session as the claim holder — atomically.
@@ -356,23 +400,39 @@ export class ExecutionService {
356
400
 
357
401
  // 1b. Worktree preparation (fail-soft): any failure degrades this run to
358
402
  // the original directory with an isolationNote — the ledger and the
359
- // execution pipeline itself never fail over git.
403
+ // execution pipeline itself never fail over git. 0.6.3: preparation
404
+ // builds a whole-workspace MIRROR (root repo + nested repos); a plain
405
+ // single-repo workspace keeps the legacy record shape everywhere.
360
406
  let isolationNote: string | undefined
361
- let prepared: PreparedWorktree | undefined
407
+ let prepared: PreparedMirror | undefined
362
408
  if (isolation === 'worktree') {
363
- const outcome = await this.prepareIsolation(task, workspace.path, worktreePath, branch, options?.reuseWorktree === true)
364
- if (outcome.prepared !== undefined) {
365
- prepared = outcome.prepared
366
- // Persist the isolation facts of the run (branch is already on the
367
- // record from the gate mutation).
368
- await this.patchExecution(executionId, {
369
- worktreePath: outcome.prepared.worktreePath,
370
- baseCommit: outcome.prepared.baseCommit,
371
- })
372
- } else {
373
- isolationNote = outcome.note
374
- // Degraded run: clear the optimistic worktree markers.
409
+ if (this.deps.git === undefined) {
410
+ isolationNote = 'git 集成不可用,已在原目录执行'
375
411
  await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
412
+ } else {
413
+ const outcome = await prepareMirror(
414
+ { git: this.deps.git, scanner: this.deps.scanner ?? createRepoScanner() },
415
+ { workspacePath: workspace.path, taskId: task.id, branch, reuse: options?.reuseWorktree === true },
416
+ )
417
+ if ('mirror' in outcome) {
418
+ prepared = outcome.mirror
419
+ await this.pinBranches(task, prepared)
420
+ // Persist the isolation facts of the run (branch is already on the
421
+ // record from the gate mutation). The root repo keeps the legacy
422
+ // flat fields; non-legacy mirrors also record per-repo entries.
423
+ const root = prepared.repos[0]
424
+ await this.patchExecution(executionId, {
425
+ worktreePath: root?.worktreePath,
426
+ baseCommit: root?.baseCommit,
427
+ ...(!isLegacySingle(prepared)
428
+ ? { repos: prepared.repos.map(r => ({ repo: r.repo, branch: r.branch, worktreePath: r.worktreePath, baseCommit: r.baseCommit })) }
429
+ : {}),
430
+ })
431
+ } else {
432
+ isolationNote = outcome.note
433
+ // Degraded run: clear the optimistic worktree markers.
434
+ await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
435
+ }
376
436
  }
377
437
  }
378
438
 
@@ -397,9 +457,7 @@ export class ExecutionService {
397
457
  await this.patchExecution(executionId, { outcome: 'failed', error: `preset 组合失败:${message.slice(0, 400)}`, endedAt: this.deps.now() })
398
458
  await this.revertProgress(taskId)
399
459
  // S1: a run that never started must not leave its worktree behind.
400
- if (prepared !== undefined && this.deps.git !== undefined) {
401
- try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort (dirty worktrees are kept) */ }
402
- }
460
+ await this.cleanupMirror(prepared, workspace.path)
403
461
  return { ok: false, error: `preset composition failed: ${message}` }
404
462
  }
405
463
  let handle: Awaited<ReturnType<AgentsFace['create']>>
@@ -425,9 +483,7 @@ export class ExecutionService {
425
483
  await this.patchExecution(executionId, { outcome: 'failed', error: message.slice(0, 500), endedAt: this.deps.now() })
426
484
  await this.revertProgress(taskId)
427
485
  // S1: a run that never started must not leave its worktree behind.
428
- if (prepared !== undefined && this.deps.git !== undefined) {
429
- try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort (dirty worktrees are kept) */ }
430
- }
486
+ await this.cleanupMirror(prepared, workspace.path)
431
487
  return { ok: false, error: message }
432
488
  }
433
489
 
@@ -443,9 +499,7 @@ export class ExecutionService {
443
499
  if (!stillRunning) {
444
500
  await handle.dispose().catch(() => { /* best effort */ })
445
501
  // S1: do not leave the startup artifacts behind a cancelled run either.
446
- if (prepared !== undefined && this.deps.git !== undefined) {
447
- try { await this.deps.git.removeWorktree(workspace.path, prepared.worktreePath) } catch { /* best effort */ }
448
- }
502
+ await this.cleanupMirror(prepared, workspace.path)
449
503
  return { ok: false, error: 'cancelled during startup' }
450
504
  }
451
505
 
@@ -522,9 +576,9 @@ export class ExecutionService {
522
576
  private async settleExecution(
523
577
  executionId: string,
524
578
  sessionId: string,
525
- prepared: PreparedWorktree | undefined,
579
+ prepared: PreparedMirror | undefined,
526
580
  ): Promise<void> {
527
- const facts = await this.collectEvidence(prepared)
581
+ const evidence = await this.collectEvidence(prepared)
528
582
  await this.deps.store.mutate('execution-recorded', (ledger) => {
529
583
  for (const t of ledger.tasks) {
530
584
  const execution = t.executions.find(e => e.id === executionId)
@@ -532,7 +586,7 @@ export class ExecutionService {
532
586
  const now = this.deps.now()
533
587
  execution.outcome = 'succeeded'
534
588
  execution.endedAt = now
535
- this.applyFacts(execution, facts)
589
+ this.applyFacts(execution, prepared, evidence)
536
590
  if (t.status === 'in_progress' && t.claimedBy === sessionId) {
537
591
  delete t.claimedBy
538
592
  delete t.claimedAt
@@ -559,57 +613,60 @@ export class ExecutionService {
559
613
  }
560
614
 
561
615
  /**
562
- * Prepare the dedicated worktree for a run (fail-soft): detect git, then
563
- * create/reset the fixed task branch fresh baseline, or keep a live
564
- * worktree as-is for 续跑. On success the branch name is pinned onto the
565
- * task once (renames never change it); on any failure the run degrades
566
- * with a human-readable note.
616
+ * Pin branch names at FIRST successful creation (§9: 改名不改分支) the
617
+ * workspace root repo onto the legacy `branch` field, every nested repo
618
+ * into the `branches` map. Re-checked inside the mutation (the task may
619
+ * have moved between preparation and commit).
567
620
  */
568
- private async prepareIsolation(
569
- task: TaskRecord,
570
- workspacePath: string,
571
- worktreePath: string,
572
- branch: string,
573
- reuse: boolean,
574
- ): Promise<{ prepared?: PreparedWorktree; note?: string }> {
575
- const git = this.deps.git
576
- if (git === undefined) return { note: 'git 集成不可用,已在原目录执行' }
577
- let inside = false
578
- try {
579
- inside = await git.detect(workspacePath)
580
- } catch { /* fail-soft */ }
581
- if (!inside) {
582
- // Distinguish 未装 git from 非 git 仓库 (0.3.1): probe the binary so the
583
- // degradation note names the real cause.
584
- let hasBinary = true
585
- try {
586
- hasBinary = await git.binaryAvailable()
587
- } catch { /* fail-soft → treat as repo-side */ }
588
- return { note: hasBinary ? '当前项目不是 git 仓库,已在原目录执行' : 'git 不可用(未安装或不在 PATH),已在原目录执行' }
621
+ private async pinBranches(task: TaskRecord, mirror: PreparedMirror): Promise<void> {
622
+ const wanted: Array<{ repo: string; branch: string }> = []
623
+ for (const repo of mirror.repos) {
624
+ if (repo.repo === '') {
625
+ if (task.branch === undefined) wanted.push({ repo: '', branch: repo.branch })
626
+ } else if (task.branches?.[repo.repo] === undefined) {
627
+ wanted.push({ repo: repo.repo, branch: repo.branch })
628
+ }
589
629
  }
590
- let info
591
- try {
592
- info = await git.prepareWorktree(workspacePath, worktreePath, branch, reuse ? 'reuse' : 'fresh')
593
- } catch { /* fail-soft */ }
594
- if (info === undefined) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }
595
- // Pin the branch name at first SUCCESSFUL creation (§9: 改名不改分支).
596
- if (task.branch === undefined) {
597
- await this.deps.store.mutate('task-updated', (ledger) => {
598
- const target = ledger.tasks.find(t => t.id === task.id)
599
- if (target !== undefined && target.branch === undefined) {
600
- target.branch = branch
601
- return [target]
630
+ if (wanted.length === 0) return
631
+ await this.deps.store.mutate('task-updated', (ledger) => {
632
+ const target = ledger.tasks.find(t => t.id === task.id)
633
+ if (target === undefined) return undefined
634
+ let touched = false
635
+ for (const w of wanted) {
636
+ if (w.repo === '') {
637
+ if (target.branch === undefined) {
638
+ target.branch = w.branch
639
+ touched = true
640
+ }
641
+ } else if (target.branches?.[w.repo] === undefined) {
642
+ target.branches = { ...target.branches, [w.repo]: w.branch }
643
+ touched = true
602
644
  }
603
- return undefined
604
- })
605
- }
606
- return {
607
- prepared: {
608
- branch: info.branch,
609
- worktreePath: info.path,
610
- baseCommit: info.baseCommit,
611
- ...(info.reused === true ? { reused: true } : {}),
612
- },
645
+ }
646
+ return touched ? [target] : undefined
647
+ })
648
+ }
649
+
650
+ /**
651
+ * Best-effort mirror teardown after a failed start (S1): each repo's
652
+ * worktree is removed through its OWN repo root; dirty worktrees are kept
653
+ * never a data-loss primitive.
654
+ */
655
+ private async cleanupMirror(mirror: PreparedMirror | undefined, workspacePath: string): Promise<void> {
656
+ if (mirror === undefined || this.deps.git === undefined) return
657
+ // Children first (removeMirror's rule): the root worktree's status shows
658
+ // its still-present child worktrees as untracked, so removing it first
659
+ // hits a false dirty-worktree refusal and leaves residue behind. The root
660
+ // gets the noise exemption but NO force: a reused worktree's real agent
661
+ // dirt must keep it alive. (Structural-noise residue stays recoverable
662
+ // through the routes' aggregated mirror removal.)
663
+ const nestedRels = mirror.repos.filter(r => r.repo !== '').map(r => r.repo)
664
+ for (const repo of [...mirror.repos].reverse()) {
665
+ const root = repo.repo === '' ? workspacePath : workspacePath + '/' + repo.repo
666
+ try {
667
+ await this.deps.git.removeWorktree(root, repo.worktreePath,
668
+ repo.repo === '' && nestedRels.length > 0 ? { exempt: nestedRels } : undefined)
669
+ } catch { /* best effort (dirty worktrees are kept) */ }
613
670
  }
614
671
  }
615
672
 
@@ -641,7 +698,7 @@ export class ExecutionService {
641
698
 
642
699
  // The cancelled session may already have committed work — keep the
643
700
  // evidence (best effort) so the user can inspect or 续跑 (0.3.1).
644
- const facts = await this.collectEvidence(entry?.prepared)
701
+ const evidence = await this.collectEvidence(entry?.prepared)
645
702
  let settled = false
646
703
  await this.deps.store.mutate('execution-recorded', (ledger) => {
647
704
  const target = ledger.tasks.find(t => t.id === taskId)
@@ -651,7 +708,7 @@ export class ExecutionService {
651
708
  settled = true
652
709
  execution.outcome = 'cancelled'
653
710
  execution.endedAt = this.deps.now()
654
- this.applyFacts(execution, facts)
711
+ this.applyFacts(execution, entry?.prepared, evidence)
655
712
  if (target.status === 'in_progress') {
656
713
  target.status = 'todo'
657
714
  target.updatedAt = this.deps.now()
@@ -708,10 +765,10 @@ export class ExecutionService {
708
765
  * the user reviews at merge time); 续跑 and degraded runs each add their
709
766
  * own steering line (0.3.1).
710
767
  * @param task - the task.
711
- * @param prepared - worktree facts when this run is isolated.
768
+ * @param prepared - the task mirror when this run is isolated.
712
769
  * @param degradeNote - why a worktree task degraded to the main directory.
713
770
  */
714
- private pluginFraming(task: TaskRecord, prepared?: PreparedWorktree, degradeNote?: string): string {
771
+ private pluginFraming(task: TaskRecord, prepared?: PreparedMirror, degradeNote?: string): string {
715
772
  let text = `【任务看板】${task.title}(ID: ${task.id})\n`
716
773
  + `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
717
774
  + `完成后按序交接:\n`
@@ -728,10 +785,16 @@ export class ExecutionService {
728
785
  text += `\n本任务有验收清单(DoD,${done}/${task.checklist.length} 已完成)——按清单干活:\n${items}\n完成一项就用 taskboard_checklist(action=check,附 note 证据)勾选;未完成项会在验收时高亮,全部完成再移待验收。需要补充验收项也可用 action=add 追加。`
729
786
  }
730
787
  if (prepared !== undefined) {
731
- if (prepared.reused === true) {
732
- text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\n${prepared.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`
788
+ if (isLegacySingle(prepared)) {
789
+ // Byte-identical legacy single-repo steering (0.3.0–0.6.2 wording).
790
+ const only = prepared.repos[0]!
791
+ if (only.reused === true) {
792
+ text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${only.branch} 的 worktree——\n${only.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`
793
+ } else {
794
+ text += `\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${only.branch} 的全新 worktree——\n${only.worktreePath}\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`
795
+ }
733
796
  } else {
734
- text += `\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${prepared.branch} 的全新 worktree——\n${prepared.worktreePath}\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`
797
+ text += this.mirrorFraming(prepared)
735
798
  }
736
799
  } else if (degradeNote !== undefined) {
737
800
  text += `\n⚠ 本次执行未能建立隔离,正在主项目目录中工作(原因:${degradeNote})。该目录可能有他人未提交的改动:动手前先 git status 检查现状,改动尽量集中,结束时在评论中说明动了哪些文件;避免把未经验证的改动直接提交到主分支。`
@@ -739,6 +802,27 @@ export class ExecutionService {
739
802
  return text
740
803
  }
741
804
 
805
+ /**
806
+ * The multi-repo mirror section of the framing line (0.6.3): per-repo
807
+ * checkout list, the (possibly partial) coverage boundary, per-repo commit
808
+ * discipline, and the 禁改 list for repos that failed to mirror.
809
+ */
810
+ private mirrorFraming(mirror: PreparedMirror): string {
811
+ const mode = mirror.allReused ? '续跑' : '全新'
812
+ const lines = mirror.repos
813
+ .map(r => `- ${r.repo === '' ? '根仓库' : r.repo} → ${r.worktreePath}(分支 ${r.branch}${r.reused === true ? ',续跑' : ''})`)
814
+ .join('\n')
815
+ let text = `\n本任务启用了 Git Worktree 隔离(多仓库镜像模式,本次${mode}):整个工作区已镜像到任务目录——\n${mirror.root}\n各仓库检出位置与任务分支(每仓库各一个同名任务分支):\n${lines}\n(全新检出的镜像不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述任务目录内对应仓库的镜像里——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动镜像之外的任何文件;改动发生在哪个仓库,就把完成的工作提交(git commit)到那个仓库的任务分支,验收将按仓库合并各分支的提交记录。`
816
+ if (mirror.skipped.length > 0) {
817
+ const skipped = mirror.skipped.map(s => `- ${s.repo}(原因:${s.reason})`).join('\n')
818
+ text += `\n⚠ 以下仓库未能建立镜像:\n${skipped}\n本次执行严禁改动这些仓库的主目录。`
819
+ }
820
+ if (mirror.allReused) {
821
+ text += `\n本次为续跑:各仓库上一次执行的改动与提交都保留在镜像原处——动手前先在各仓库镜像里查看已有改动(git status / git log),避免重复劳动。`
822
+ }
823
+ return text
824
+ }
825
+
742
826
  /**
743
827
  * The card body as a normal user bubble: the effective prompt (title+
744
828
  * description, with the explicit prompt appended when set) with template
@@ -774,4 +858,4 @@ export class ExecutionService {
774
858
  return undefined
775
859
  })
776
860
  }
777
- }
861
+ }