dsh-taskboard 0.6.2 → 0.6.4
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 +18 -3
- package/lib/client.js +13 -4
- 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 +205 -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/runtime.ts +71 -5
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.ts +5 -2
- 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/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
|
+
}
|
package/src/host/git.ts
CHANGED
|
@@ -34,6 +34,30 @@ const HEAVY_TIMEOUT_MS = 15_000
|
|
|
34
34
|
/** Directory under a workspace where task worktrees live. */
|
|
35
35
|
export const WORKTREE_DIR = '.dsh-worktrees'
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The path segment of one `status --porcelain` line. Shape-aware: the RAW
|
|
39
|
+
* line is `XY path` (path at index 3) while the plugin's trimmed evidence
|
|
40
|
+
* lines collapse a leading-space status to `X path` (path at index 2) —
|
|
41
|
+
* slicing a fixed 3 misparses exactly the gitlink/unstaged shapes a
|
|
42
|
+
* multi-repo mirror produces (` M sub-repo`), 0.6.3 review fix.
|
|
43
|
+
*/
|
|
44
|
+
export function statusLinePath(line: string): string {
|
|
45
|
+
if (line.length >= 3 && line[2] === ' ') return line.slice(3)
|
|
46
|
+
if (line.length >= 2 && line[1] === ' ') return line.slice(2)
|
|
47
|
+
return line
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a `status --porcelain` line targets one of `rels` (a repo-relative
|
|
52
|
+
* path) or anything under it. Porcelain prints untracked directories with a
|
|
53
|
+
* trailing slash (`?? sub/`), so all three shapes match. Empty rels never
|
|
54
|
+
* match (0.6.3 review fix).
|
|
55
|
+
*/
|
|
56
|
+
export function statusLineUnder(line: string, rels: readonly string[]): boolean {
|
|
57
|
+
const p = statusLinePath(line)
|
|
58
|
+
return rels.some(rel => rel.length > 0 && (p === rel || p === rel + '/' || p.startsWith(rel + '/')))
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
/** Evidence caps: commits kept per execution record (newest first). */
|
|
38
62
|
export const MAX_COMMIT_EVIDENCE = 50
|
|
39
63
|
|
|
@@ -118,18 +142,40 @@ export interface GitFace {
|
|
|
118
142
|
* undefined on any failure — callers degrade to the original directory.
|
|
119
143
|
*/
|
|
120
144
|
prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Collect settlement facts (never throws; missing pieces stay unset).
|
|
147
|
+
* `excludeRelPaths` drops `status --porcelain` lines targeting those
|
|
148
|
+
* repo-relative paths (or anything under them) — the mirror's NESTED repo
|
|
149
|
+
* worktrees read as untracked noise in the root worktree's status and are
|
|
150
|
+
* not this repo's uncommitted changes (0.6.3 review fix).
|
|
151
|
+
*/
|
|
152
|
+
collect(worktreePath: string, baseCommit: string, excludeRelPaths?: readonly string[]): Promise<SettlementFacts>
|
|
153
|
+
/**
|
|
154
|
+
* Uncommitted-change lines of a working tree ([] = clean; undefined on git
|
|
155
|
+
* failure). Mirror removal pre-checks EVERY repo worktree before deleting
|
|
156
|
+
* any, so one dirty repo refuses the whole mirror (0.6.3).
|
|
157
|
+
*/
|
|
158
|
+
dirtyLines(cwd: string): Promise<string[] | undefined>
|
|
159
|
+
/**
|
|
160
|
+
* Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable
|
|
161
|
+
* reason. `exemptRelPaths` extends the main-clean exemption beyond
|
|
162
|
+
* `.dsh-worktrees`: uncommitted-shape noise under those repo-relative paths
|
|
163
|
+
* (parallel repos are self-governing — their content AND their gitlink
|
|
164
|
+
* entries don't gate the root repo's merge, 0.6.3 review fix).
|
|
165
|
+
*/
|
|
166
|
+
merge(root: string, branch: string, exemptRelPaths?: readonly string[]): Promise<void>
|
|
125
167
|
/** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */
|
|
126
168
|
isAncestor(root: string, branch: string): Promise<boolean>
|
|
127
169
|
/**
|
|
128
170
|
* Remove a worktree. Resolves 'removed' on success, 'unregistered' when git
|
|
129
171
|
* no longer knows the path (an orphaned directory). THROWS when it still
|
|
130
172
|
* has uncommitted changes, or on any other git failure (readable reason).
|
|
173
|
+
* `opts.exempt` drops mirror-structural noise from the dirty check and
|
|
174
|
+
* `opts.force` lets `git worktree remove` accept it — for callers that have
|
|
175
|
+
* ALREADY aggregated the real-dirty check themselves (the mirror removal:
|
|
176
|
+
* noise-exempt clean → force is safe; real dirt never gets here).
|
|
131
177
|
*/
|
|
132
|
-
removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>
|
|
178
|
+
removeWorktree(root: string, worktreePath: string, opts?: { exempt?: readonly string[]; force?: boolean }): Promise<'removed' | 'unregistered'>
|
|
133
179
|
/** Delete a branch; THROWS (e.g. still checked out in a worktree). */
|
|
134
180
|
deleteBranch(root: string, branch: string): Promise<void>
|
|
135
181
|
/**
|
|
@@ -266,7 +312,7 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
266
312
|
return { path, branch, baseCommit }
|
|
267
313
|
}),
|
|
268
314
|
|
|
269
|
-
async collect(worktreePath, baseCommit) {
|
|
315
|
+
async collect(worktreePath, baseCommit, excludeRelPaths) {
|
|
270
316
|
const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }
|
|
271
317
|
const range = `${baseCommit}..HEAD`
|
|
272
318
|
|
|
@@ -292,7 +338,14 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
292
338
|
|
|
293
339
|
const status = await quick(['status', '--porcelain'], worktreePath)
|
|
294
340
|
if (status.ok) {
|
|
295
|
-
|
|
341
|
+
// excludeRelPaths drops the mirror's nested repo worktrees — they
|
|
342
|
+
// read as untracked noise / gitlink drift here, not as this repo's
|
|
343
|
+
// uncommitted changes. Excluded on the RAW line (path extraction is
|
|
344
|
+
// shape-aware); the stored evidence keeps its trimmed 0.3.x shape.
|
|
345
|
+
const dirty = status.stdout.split('\n')
|
|
346
|
+
.filter(l => l.trim().length > 0)
|
|
347
|
+
.filter(l => !statusLineUnder(l, excludeRelPaths ?? []))
|
|
348
|
+
.map(l => l.trim())
|
|
296
349
|
facts.dirtyFilesTotal = dirty.length
|
|
297
350
|
facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)
|
|
298
351
|
}
|
|
@@ -306,19 +359,22 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
306
359
|
return facts
|
|
307
360
|
},
|
|
308
361
|
|
|
309
|
-
|
|
362
|
+
async dirtyLines(cwd) {
|
|
363
|
+
const status = await quick(['status', '--porcelain'], cwd)
|
|
364
|
+
if (!status.ok) return undefined
|
|
365
|
+
return status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
merge: (root, branch, exemptRelPaths) => withRootLock(root, async () => {
|
|
310
369
|
// Main-clean check. The plugin's own worktree directory
|
|
311
370
|
// (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —
|
|
312
371
|
// otherwise merging would be impossible without gitignoring it first.
|
|
313
372
|
const status = await quick(['status', '--porcelain'], root)
|
|
314
373
|
if (status.ok) {
|
|
315
374
|
const dirtyLines = status.stdout.split('\n')
|
|
375
|
+
.filter(l => l.trim().length > 0)
|
|
376
|
+
.filter(l => !statusLineUnder(l, [WORKTREE_DIR, ...(exemptRelPaths ?? [])]))
|
|
316
377
|
.map(l => l.trim())
|
|
317
|
-
.filter(l => {
|
|
318
|
-
if (l.length === 0) return false
|
|
319
|
-
const path = l.slice(3)
|
|
320
|
-
return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)
|
|
321
|
-
})
|
|
322
378
|
if (dirtyLines.length > 0) {
|
|
323
379
|
// Machine-readable tag: callers classify without parsing zh-CN text.
|
|
324
380
|
throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })
|
|
@@ -339,14 +395,24 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
339
395
|
return r.ok
|
|
340
396
|
},
|
|
341
397
|
|
|
342
|
-
removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
|
|
398
|
+
removeWorktree: (root, worktreePath, opts) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
|
|
343
399
|
const status = await quick(['status', '--porcelain'], worktreePath)
|
|
344
400
|
if (status.ok && status.stdout.trim().length > 0) {
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
401
|
+
// opts.exempt drops mirror-structural noise (nested child worktrees /
|
|
402
|
+
// gitlink drift) — the caller's aggregated check already refused real
|
|
403
|
+
// dirt before reaching here.
|
|
404
|
+
const lines = status.stdout.split('\n')
|
|
405
|
+
.filter(l => l.trim().length > 0)
|
|
406
|
+
.filter(l => !statusLineUnder(l, opts?.exempt ?? []))
|
|
407
|
+
.map(l => l.trim())
|
|
408
|
+
if (lines.length > 0) {
|
|
409
|
+
// Machine-readable tag: purge flows classify without parsing zh-CN text.
|
|
410
|
+
throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
|
|
411
|
+
}
|
|
348
412
|
}
|
|
349
|
-
const removed = await heavy(
|
|
413
|
+
const removed = await heavy(opts?.force === true
|
|
414
|
+
? ['worktree', 'remove', '--force', worktreePath]
|
|
415
|
+
: ['worktree', 'remove', worktreePath], root)
|
|
350
416
|
if (removed.ok) return 'removed'
|
|
351
417
|
// S3: classify the failure WITHOUT parsing git's (localizable) stderr —
|
|
352
418
|
// a path absent from `worktree list` is an unregistered leftover, not
|