dsh-taskboard 0.6.2 → 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.
- package/README.md +14 -3
- package/lib/client.js +12 -3
- package/lib/host/execution.js +160 -78
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +41 -13
- package/lib/host/git.js.map +1 -1
- package/lib/host/isolation.js +192 -0
- package/lib/host/isolation.js.map +1 -0
- package/lib/host/repos.js +91 -0
- package/lib/host/repos.js.map +1 -0
- package/lib/host/routes.js +203 -60
- package/lib/host/routes.js.map +1 -1
- package/lib/host/tools.js +1 -1
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +62 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +5 -2
- package/src/client/api.ts +2 -1
- package/src/client/board/TaskDetail.tsx +100 -32
- package/src/client/board/TaskFormModal.tsx +7 -0
- package/src/client/controller.ts +15 -6
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/styles.ts +7 -0
- package/src/host/execution.ts +199 -115
- package/src/host/git.ts +84 -18
- package/src/host/isolation.ts +268 -0
- package/src/host/repos.ts +146 -0
- package/src/host/routes.ts +211 -73
- package/src/host/tools.ts +2 -2
- package/src/index.ts +6 -1
- package/src/shared/api.ts +29 -5
- package/src/shared/protocol.ts +124 -0
- package/src/shared/version.ts +1 -1
package/src/client/controller.ts
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
|
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
|
}
|
package/src/client/i18n/en.ts
CHANGED
|
@@ -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',
|
package/src/client/i18n/zh.ts
CHANGED
|
@@ -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': '🚫 关闭同步',
|
package/src/client/styles.ts
CHANGED
|
@@ -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); }
|
package/src/host/execution.ts
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
157
|
-
prepared?:
|
|
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
|
|
197
|
-
*
|
|
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:
|
|
200
|
-
if (prepared === undefined || this.deps.git === undefined) return undefined
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
/**
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
execution
|
|
216
|
-
|
|
217
|
-
|
|
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(
|
|
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,
|
|
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:
|
|
407
|
+
let prepared: PreparedMirror | undefined
|
|
362
408
|
if (isolation === 'worktree') {
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
579
|
+
prepared: PreparedMirror | undefined,
|
|
526
580
|
): Promise<void> {
|
|
527
|
-
const
|
|
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,
|
|
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
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
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
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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
|
|
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,
|
|
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 -
|
|
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?:
|
|
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
|
|
732
|
-
|
|
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 +=
|
|
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
|
+
}
|