dsh-taskboard 0.2.2 → 0.3.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 +44 -4
- package/lib/client.js +635 -23
- package/lib/host/execution.js +194 -54
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +234 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/routes.js +252 -4
- package/lib/host/routes.js.map +1 -1
- package/lib/host/tools.js +16 -2
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +17 -2
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +10 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +74 -74
- package/src/client/api.ts +19 -3
- package/src/client/board/TaskBoard.tsx +73 -0
- package/src/client/board/TaskDetail.tsx +173 -1
- package/src/client/board/TaskFormModal.tsx +100 -1
- package/src/client/controller.ts +89 -6
- package/src/client/index.ts +18 -1
- package/src/client/styles.ts +45 -0
- package/src/host/execution.ts +291 -65
- package/src/host/git.ts +293 -0
- package/src/host/routes.ts +268 -5
- package/src/host/tools.ts +17 -0
- package/src/index.ts +24 -1
- package/src/shared/api.ts +35 -3
- package/src/shared/protocol.ts +64 -0
- package/src/shared/version.ts +1 -1
package/src/host/execution.ts
CHANGED
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
* @module dsh-taskboard/host/execution
|
|
12
12
|
*/
|
|
13
13
|
import {
|
|
14
|
+
effectiveIsolation,
|
|
14
15
|
effectivePrompt,
|
|
15
16
|
newCommentId,
|
|
16
17
|
newExecutionId,
|
|
17
18
|
normalizeBody,
|
|
18
19
|
type ExecutionRecord,
|
|
20
|
+
type IsolationMode,
|
|
19
21
|
type TaskRecord,
|
|
20
22
|
} from '../shared/protocol.ts'
|
|
23
|
+
import { sanitizeBranchName, worktreePathOf, type GitFace, type SettlementFacts } from './git.ts'
|
|
21
24
|
import { MessageId } from './sdk.ts'
|
|
22
25
|
import type { TaskStore } from './store.ts'
|
|
23
26
|
|
|
@@ -28,8 +31,10 @@ export const DEFAULT_MAX_CONCURRENT = 3
|
|
|
28
31
|
export interface AgentsFace {
|
|
29
32
|
create(options: {
|
|
30
33
|
sessionId: string
|
|
31
|
-
meta?: { cwd?: string }
|
|
34
|
+
meta?: { cwd?: string; agentPreset?: string }
|
|
32
35
|
agentOptions?: { provider?: string; model?: string }
|
|
36
|
+
/** Preset composition callback: mounts tools/persona into the agent's scoped context. */
|
|
37
|
+
setup?: (agentCtx: unknown) => Promise<void> | void
|
|
33
38
|
}): Promise<{
|
|
34
39
|
agent: {
|
|
35
40
|
id: string
|
|
@@ -41,6 +46,18 @@ export interface AgentsFace {
|
|
|
41
46
|
}>
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The preset composition an execution session is built from — the shape
|
|
51
|
+
* apiproxy's ensureSession produces: resolve → record on the session header,
|
|
52
|
+
* mount → inside agents.create's setup callback.
|
|
53
|
+
*/
|
|
54
|
+
export interface AgentComposition {
|
|
55
|
+
/** The resolved preset id recorded on the session header. */
|
|
56
|
+
agentPreset: string
|
|
57
|
+
/** Mounts the preset's plugins (tools, persona) into the agent's scope. */
|
|
58
|
+
setup: (agentCtx: unknown) => Promise<void> | void
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
/** Narrow workspaces face for execution. */
|
|
45
62
|
export interface ExecutionWorkspaceFace {
|
|
46
63
|
get(id: string): { id: string; path: string } | undefined
|
|
@@ -69,6 +86,19 @@ export interface ExecutionDeps {
|
|
|
69
86
|
renameSession?: (sessionId: string, title: string) => void
|
|
70
87
|
/** Max concurrently running executions across all tasks (default 3). */
|
|
71
88
|
maxConcurrent?: number
|
|
89
|
+
/**
|
|
90
|
+
* Git face for worktree isolation (0.3.0). Absent → every worktree-mode
|
|
91
|
+
* task degrades to the original directory with an isolationNote.
|
|
92
|
+
*/
|
|
93
|
+
git?: GitFace
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the preset composition for an execution session (0.3.3): hands
|
|
96
|
+
* the session its tool set. Absent → sessions run on the bare host
|
|
97
|
+
* composition (pre-preset behavior). A rejection fails the run through
|
|
98
|
+
* the existing failure path — a broken preset never yields a half-composed
|
|
99
|
+
* session (same rollback semantics as apiproxy).
|
|
100
|
+
*/
|
|
101
|
+
composeAgent?: (presetId?: string) => Promise<AgentComposition | undefined>
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
/** Outcome of a run request (immediate; the run settles asynchronously). */
|
|
@@ -96,9 +126,30 @@ function isErrorTurnEnd(data: unknown): { message: string } | undefined {
|
|
|
96
126
|
return { message }
|
|
97
127
|
}
|
|
98
128
|
|
|
129
|
+
/** Prepared worktree facts threaded through a live run (settlement evidence). */
|
|
130
|
+
export interface PreparedWorktree {
|
|
131
|
+
branch: string
|
|
132
|
+
worktreePath: string
|
|
133
|
+
baseCommit: string
|
|
134
|
+
/** True when an existing live worktree was kept as-is (续跑). */
|
|
135
|
+
reused?: boolean
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Per-run options. */
|
|
139
|
+
export interface RunOptions {
|
|
140
|
+
/**
|
|
141
|
+
* 续跑: keep a live worktree/branch exactly as-is (the previous agent's
|
|
142
|
+
* commits and uncommitted changes survive) instead of resetting to the
|
|
143
|
+
* main HEAD. Falls back to a fresh preparation when none is alive.
|
|
144
|
+
*/
|
|
145
|
+
reuseWorktree?: boolean
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
/** One live execution tracked for settlement and cancellation. */
|
|
100
149
|
interface RunEntry {
|
|
101
150
|
sessionId: string
|
|
151
|
+
/** Worktree prepared for this run (evidence collection at ANY settlement). */
|
|
152
|
+
prepared?: PreparedWorktree
|
|
102
153
|
settle: () => void
|
|
103
154
|
dispose: () => Promise<void>
|
|
104
155
|
}
|
|
@@ -119,35 +170,66 @@ export class ExecutionService {
|
|
|
119
170
|
})
|
|
120
171
|
}
|
|
121
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Best-effort evidence collection for a prepared run (fail-soft: undefined
|
|
175
|
+
* on any git problem — settlement NEVER blocks on git).
|
|
176
|
+
*/
|
|
177
|
+
private async collectEvidence(prepared: PreparedWorktree | undefined): Promise<SettlementFacts | undefined> {
|
|
178
|
+
if (prepared === undefined || this.deps.git === undefined) return undefined
|
|
179
|
+
try {
|
|
180
|
+
return await this.deps.git.collect(prepared.worktreePath, prepared.baseCommit)
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Copy collected facts onto an execution record (in place). */
|
|
187
|
+
private applyFacts(execution: ExecutionRecord, facts: SettlementFacts | undefined): void {
|
|
188
|
+
if (facts === undefined) return
|
|
189
|
+
if (facts.headCommit !== undefined) execution.headCommit = facts.headCommit
|
|
190
|
+
execution.commits = facts.commits
|
|
191
|
+
execution.commitsTotal = facts.commitsTotal
|
|
192
|
+
execution.dirtyFiles = facts.dirtyFiles
|
|
193
|
+
execution.dirtyFilesTotal = facts.dirtyFilesTotal
|
|
194
|
+
execution.changedFiles = facts.changedFiles
|
|
195
|
+
if (facts.diffStat !== undefined) execution.diffStat = facts.diffStat
|
|
196
|
+
}
|
|
197
|
+
|
|
122
198
|
/** Record a turn failure against the running execution of that session and give the task back. */
|
|
123
199
|
private noteFailure(sessionId: string, message: string): void {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
task
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
task.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
200
|
+
// The failed session may already have committed work — collect the
|
|
201
|
+
// evidence (best effort) BEFORE marking the execution failed (0.3.1).
|
|
202
|
+
const entry = [...this.runs.values()].find(e => e.sessionId === sessionId)
|
|
203
|
+
void this.collectEvidence(entry?.prepared).then(facts => {
|
|
204
|
+
void this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
205
|
+
for (const task of ledger.tasks) {
|
|
206
|
+
for (const execution of task.executions) {
|
|
207
|
+
if (execution.sessionId === sessionId && execution.outcome === 'running') {
|
|
208
|
+
execution.outcome = 'failed'
|
|
209
|
+
execution.error = message.slice(0, 500)
|
|
210
|
+
execution.endedAt = this.deps.now()
|
|
211
|
+
this.applyFacts(execution, facts)
|
|
212
|
+
// The failed session will not finish the work: hand the task back
|
|
213
|
+
// instead of leaving it stuck in in_progress forever — and leave a
|
|
214
|
+
// system comment so the GUI shows why.
|
|
215
|
+
if (task.status === 'in_progress' && task.claimedBy === sessionId) {
|
|
216
|
+
task.status = 'todo'
|
|
217
|
+
task.updatedAt = this.deps.now()
|
|
218
|
+
delete task.claimedBy
|
|
219
|
+
delete task.claimedAt
|
|
220
|
+
task.comments.push({
|
|
221
|
+
id: newCommentId(),
|
|
222
|
+
body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
|
|
223
|
+
version: 1,
|
|
224
|
+
createdAt: this.deps.now(),
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
return [task]
|
|
145
228
|
}
|
|
146
|
-
return [task]
|
|
147
229
|
}
|
|
148
230
|
}
|
|
149
|
-
|
|
150
|
-
|
|
231
|
+
return undefined
|
|
232
|
+
})
|
|
151
233
|
})
|
|
152
234
|
}
|
|
153
235
|
|
|
@@ -174,9 +256,10 @@ export class ExecutionService {
|
|
|
174
256
|
* is opened per task.
|
|
175
257
|
* @param taskId - the task to run.
|
|
176
258
|
* @param trigger - what started it.
|
|
259
|
+
* @param options - per-run options (`reuseWorktree` = 续跑).
|
|
177
260
|
* @returns the immediate result; settlement lands in the ledger.
|
|
178
261
|
*/
|
|
179
|
-
async run(taskId: string, trigger: ExecutionRecord['trigger']): Promise<RunRequestResult> {
|
|
262
|
+
async run(taskId: string, trigger: ExecutionRecord['trigger'], options?: RunOptions): Promise<RunRequestResult> {
|
|
180
263
|
const max = this.deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
|
|
181
264
|
if (this.runs.size >= max) {
|
|
182
265
|
return { ok: false, error: `execution concurrency limit reached (${this.runs.size}/${max} running)` }
|
|
@@ -193,6 +276,13 @@ export class ExecutionService {
|
|
|
193
276
|
const executionId = newExecutionId()
|
|
194
277
|
const sessionId = this.deps.mintSessionId?.() ?? `session-taskboard-${crypto.randomUUID()}`
|
|
195
278
|
|
|
279
|
+
// 0. Resolve code isolation (plan §3.2): explicit 'none' → zero git calls;
|
|
280
|
+
// 'worktree' (also the omitted default) → prepare below, degrading to
|
|
281
|
+
// the original directory fail-soft on any git problem.
|
|
282
|
+
const isolation: IsolationMode = effectiveIsolation(task)
|
|
283
|
+
const branch = task.branch ?? sanitizeBranchName(task.title, task.id)
|
|
284
|
+
const worktreePath = worktreePathOf(workspace.path, task.id)
|
|
285
|
+
|
|
196
286
|
// 1. Open the execution record, flip the card to in_progress, and record
|
|
197
287
|
// the executing session as the claim holder — atomically.
|
|
198
288
|
let gate: string | undefined
|
|
@@ -211,6 +301,7 @@ export class ExecutionService {
|
|
|
211
301
|
trigger,
|
|
212
302
|
startedAt: this.deps.now(),
|
|
213
303
|
outcome: 'running',
|
|
304
|
+
...(isolation === 'none' ? { isolation: 'none' as const } : { isolation: 'worktree' as const, branch }),
|
|
214
305
|
})
|
|
215
306
|
target.status = 'in_progress'
|
|
216
307
|
target.updatedAt = this.deps.now()
|
|
@@ -221,16 +312,61 @@ export class ExecutionService {
|
|
|
221
312
|
})
|
|
222
313
|
if (gate !== undefined) return { ok: false, error: gate }
|
|
223
314
|
|
|
315
|
+
// 1b. Worktree preparation (fail-soft): any failure degrades this run to
|
|
316
|
+
// the original directory with an isolationNote — the ledger and the
|
|
317
|
+
// execution pipeline itself never fail over git.
|
|
318
|
+
let isolationNote: string | undefined
|
|
319
|
+
let prepared: PreparedWorktree | undefined
|
|
320
|
+
if (isolation === 'worktree') {
|
|
321
|
+
const outcome = await this.prepareIsolation(task, workspace.path, worktreePath, branch, options?.reuseWorktree === true)
|
|
322
|
+
if (outcome.prepared !== undefined) {
|
|
323
|
+
prepared = outcome.prepared
|
|
324
|
+
// Persist the isolation facts of the run (branch is already on the
|
|
325
|
+
// record from the gate mutation).
|
|
326
|
+
await this.patchExecution(executionId, {
|
|
327
|
+
worktreePath: outcome.prepared.worktreePath,
|
|
328
|
+
baseCommit: outcome.prepared.baseCommit,
|
|
329
|
+
})
|
|
330
|
+
} else {
|
|
331
|
+
isolationNote = outcome.note
|
|
332
|
+
// Degraded run: clear the optimistic worktree markers.
|
|
333
|
+
await this.patchExecution(executionId, { isolation: 'none', isolationNote, branch: undefined, worktreePath: undefined, baseCommit: undefined })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
224
337
|
// 2. Create the fresh agent+session inside the task's project, carrying
|
|
225
338
|
// the pinned model — or the deployment default when unpinned (the
|
|
226
339
|
// persona template renders {{model}}, so the session always needs one).
|
|
340
|
+
// The session cwd is ALWAYS the project root: DSH's session model
|
|
341
|
+
// requires cwd === the workspace path EXACTLY (attachSession validates
|
|
342
|
+
// it, the sidebar groups by it, and the file sandbox takes it as the
|
|
343
|
+
// workspace-write boundary) — a subdirectory cwd (the worktree) breaks
|
|
344
|
+
// all three. The worktree is instead handed to the agent explicitly in
|
|
345
|
+
// the framing line below.
|
|
346
|
+
// Preset composition (0.3.3): resolve BEFORE creation so the header
|
|
347
|
+
// snapshots `agentPreset` and the setup callback mounts the preset's
|
|
348
|
+
// tools/persona into the agent's scope. undefined composeAgent (or an
|
|
349
|
+
// absent preset roster) keeps the bare host composition.
|
|
350
|
+
let composition: AgentComposition | undefined
|
|
351
|
+
try {
|
|
352
|
+
composition = this.deps.composeAgent === undefined ? undefined : await this.deps.composeAgent(task.presetId)
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
355
|
+
await this.patchExecution(executionId, { outcome: 'failed', error: `preset 组合失败:${message.slice(0, 400)}`, endedAt: this.deps.now() })
|
|
356
|
+
await this.revertProgress(taskId)
|
|
357
|
+
return { ok: false, error: `preset composition failed: ${message}` }
|
|
358
|
+
}
|
|
227
359
|
let handle: Awaited<ReturnType<AgentsFace['create']>>
|
|
228
360
|
try {
|
|
229
361
|
const model = task.model ?? this.deps.defaultModel?.()
|
|
230
362
|
handle = await this.deps.agents.create({
|
|
231
363
|
sessionId,
|
|
232
|
-
meta: {
|
|
364
|
+
meta: {
|
|
365
|
+
cwd: workspace.path,
|
|
366
|
+
...(composition !== undefined ? { agentPreset: composition.agentPreset } : {}),
|
|
367
|
+
},
|
|
233
368
|
...(model !== undefined ? { agentOptions: { provider: model.provider, model: model.model } } : {}),
|
|
369
|
+
...(composition !== undefined ? { setup: composition.setup } : {}),
|
|
234
370
|
})
|
|
235
371
|
} catch (error) {
|
|
236
372
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -263,7 +399,7 @@ export class ExecutionService {
|
|
|
263
399
|
handle.agent.inject({
|
|
264
400
|
id: this.deps.mintMessageId?.() ?? MessageId(`msg-taskboard-${crypto.randomUUID()}`),
|
|
265
401
|
role: 'user' as const,
|
|
266
|
-
content: [{ type: 'text' as const, text: this.pluginFraming(task) }],
|
|
402
|
+
content: [{ type: 'text' as const, text: this.pluginFraming(task, prepared, isolationNote) }],
|
|
267
403
|
source: { kind: 'plugin' as const, plugin: 'dsh-taskboard' },
|
|
268
404
|
})
|
|
269
405
|
handle.agent.followup({
|
|
@@ -274,43 +410,14 @@ export class ExecutionService {
|
|
|
274
410
|
})
|
|
275
411
|
|
|
276
412
|
// 6. Settlement watcher: mark succeeded, release the executing session's
|
|
277
|
-
// hold,
|
|
278
|
-
//
|
|
279
|
-
//
|
|
413
|
+
// hold, collect the worktree evidence (commits / dirty / diff), and —
|
|
414
|
+
// when the session did NOT follow the handoff protocol — auto-move the
|
|
415
|
+
// card to in_review with a system comment.
|
|
280
416
|
const settle = (): void => {
|
|
281
417
|
this.runs.delete(executionId)
|
|
282
|
-
void this.
|
|
283
|
-
for (const t of ledger.tasks) {
|
|
284
|
-
const execution = t.executions.find(e => e.id === executionId)
|
|
285
|
-
if (execution !== undefined && execution.outcome === 'running') {
|
|
286
|
-
const now = this.deps.now()
|
|
287
|
-
execution.outcome = 'succeeded'
|
|
288
|
-
execution.endedAt = now
|
|
289
|
-
if (t.status === 'in_progress' && t.claimedBy === sessionId) {
|
|
290
|
-
delete t.claimedBy
|
|
291
|
-
delete t.claimedAt
|
|
292
|
-
}
|
|
293
|
-
if (t.status === 'in_progress') {
|
|
294
|
-
const commented = t.comments.some(c => c.threadId === sessionId)
|
|
295
|
-
t.comments.push({
|
|
296
|
-
id: newCommentId(),
|
|
297
|
-
body: normalizeBody(commented
|
|
298
|
-
? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
|
|
299
|
-
: '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
|
|
300
|
-
version: 1,
|
|
301
|
-
createdAt: now,
|
|
302
|
-
})
|
|
303
|
-
t.status = 'in_review'
|
|
304
|
-
t.updatedAt = now
|
|
305
|
-
t.updatedBy = { kind: 'user' }
|
|
306
|
-
}
|
|
307
|
-
return [t]
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return undefined
|
|
311
|
-
})
|
|
418
|
+
void this.settleExecution(executionId, sessionId, prepared)
|
|
312
419
|
}
|
|
313
|
-
this.runs.set(executionId, { sessionId, settle, dispose: () => handle.dispose() })
|
|
420
|
+
this.runs.set(executionId, { sessionId, ...(prepared !== undefined ? { prepared } : {}), settle, dispose: () => handle.dispose() })
|
|
314
421
|
void handle.agent.whenIdle().then(settle, () => {
|
|
315
422
|
this.noteFailure(sessionId, 'agent did not reach quiescence')
|
|
316
423
|
settle()
|
|
@@ -319,6 +426,105 @@ export class ExecutionService {
|
|
|
319
426
|
return { ok: true, executionId, sessionId }
|
|
320
427
|
}
|
|
321
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Settle one execution: collect worktree facts first (fail-soft — git
|
|
431
|
+
* problems never block settlement), then commit outcome + release + the
|
|
432
|
+
* protocol-auto-review move in ONE ledger mutation.
|
|
433
|
+
*/
|
|
434
|
+
private async settleExecution(
|
|
435
|
+
executionId: string,
|
|
436
|
+
sessionId: string,
|
|
437
|
+
prepared: PreparedWorktree | undefined,
|
|
438
|
+
): Promise<void> {
|
|
439
|
+
const facts = await this.collectEvidence(prepared)
|
|
440
|
+
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
441
|
+
for (const t of ledger.tasks) {
|
|
442
|
+
const execution = t.executions.find(e => e.id === executionId)
|
|
443
|
+
if (execution !== undefined && execution.outcome === 'running') {
|
|
444
|
+
const now = this.deps.now()
|
|
445
|
+
execution.outcome = 'succeeded'
|
|
446
|
+
execution.endedAt = now
|
|
447
|
+
this.applyFacts(execution, facts)
|
|
448
|
+
if (t.status === 'in_progress' && t.claimedBy === sessionId) {
|
|
449
|
+
delete t.claimedBy
|
|
450
|
+
delete t.claimedAt
|
|
451
|
+
}
|
|
452
|
+
if (t.status === 'in_progress') {
|
|
453
|
+
const commented = t.comments.some(c => c.threadId === sessionId)
|
|
454
|
+
t.comments.push({
|
|
455
|
+
id: newCommentId(),
|
|
456
|
+
body: normalizeBody(commented
|
|
457
|
+
? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
|
|
458
|
+
: '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
|
|
459
|
+
version: 1,
|
|
460
|
+
createdAt: now,
|
|
461
|
+
})
|
|
462
|
+
t.status = 'in_review'
|
|
463
|
+
t.updatedAt = now
|
|
464
|
+
t.updatedBy = { kind: 'user' }
|
|
465
|
+
}
|
|
466
|
+
return [t]
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return undefined
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Prepare the dedicated worktree for a run (fail-soft): detect git, then
|
|
475
|
+
* create/reset the fixed task branch — fresh baseline, or keep a live
|
|
476
|
+
* worktree as-is for 续跑. On success the branch name is pinned onto the
|
|
477
|
+
* task once (renames never change it); on any failure the run degrades
|
|
478
|
+
* with a human-readable note.
|
|
479
|
+
*/
|
|
480
|
+
private async prepareIsolation(
|
|
481
|
+
task: TaskRecord,
|
|
482
|
+
workspacePath: string,
|
|
483
|
+
worktreePath: string,
|
|
484
|
+
branch: string,
|
|
485
|
+
reuse: boolean,
|
|
486
|
+
): Promise<{ prepared?: PreparedWorktree; note?: string }> {
|
|
487
|
+
const git = this.deps.git
|
|
488
|
+
if (git === undefined) return { note: 'git 集成不可用,已在原目录执行' }
|
|
489
|
+
let inside = false
|
|
490
|
+
try {
|
|
491
|
+
inside = await git.detect(workspacePath)
|
|
492
|
+
} catch { /* fail-soft */ }
|
|
493
|
+
if (!inside) {
|
|
494
|
+
// Distinguish 未装 git from 非 git 仓库 (0.3.1): probe the binary so the
|
|
495
|
+
// degradation note names the real cause.
|
|
496
|
+
let hasBinary = true
|
|
497
|
+
try {
|
|
498
|
+
hasBinary = await git.binaryAvailable()
|
|
499
|
+
} catch { /* fail-soft → treat as repo-side */ }
|
|
500
|
+
return { note: hasBinary ? '当前项目不是 git 仓库,已在原目录执行' : 'git 不可用(未安装或不在 PATH),已在原目录执行' }
|
|
501
|
+
}
|
|
502
|
+
let info
|
|
503
|
+
try {
|
|
504
|
+
info = await git.prepareWorktree(workspacePath, worktreePath, branch, reuse ? 'reuse' : 'fresh')
|
|
505
|
+
} catch { /* fail-soft */ }
|
|
506
|
+
if (info === undefined) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }
|
|
507
|
+
// Pin the branch name at first SUCCESSFUL creation (§9: 改名不改分支).
|
|
508
|
+
if (task.branch === undefined) {
|
|
509
|
+
await this.deps.store.mutate('task-updated', (ledger) => {
|
|
510
|
+
const target = ledger.tasks.find(t => t.id === task.id)
|
|
511
|
+
if (target !== undefined && target.branch === undefined) {
|
|
512
|
+
target.branch = branch
|
|
513
|
+
return [target]
|
|
514
|
+
}
|
|
515
|
+
return undefined
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
prepared: {
|
|
520
|
+
branch: info.branch,
|
|
521
|
+
worktreePath: info.path,
|
|
522
|
+
baseCommit: info.baseCommit,
|
|
523
|
+
...(info.reused === true ? { reused: true } : {}),
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
322
528
|
/** How many executions are currently running (for the concurrency cap). */
|
|
323
529
|
inFlight(): number {
|
|
324
530
|
return this.runs.size
|
|
@@ -345,6 +551,9 @@ export class ExecutionService {
|
|
|
345
551
|
await entry?.dispose()
|
|
346
552
|
} catch { /* already gone */ }
|
|
347
553
|
|
|
554
|
+
// The cancelled session may already have committed work — keep the
|
|
555
|
+
// evidence (best effort) so the user can inspect or 续跑 (0.3.1).
|
|
556
|
+
const facts = await this.collectEvidence(entry?.prepared)
|
|
348
557
|
await this.deps.store.mutate('execution-recorded', (ledger) => {
|
|
349
558
|
const target = ledger.tasks.find(t => t.id === taskId)
|
|
350
559
|
if (target === undefined) return undefined
|
|
@@ -352,6 +561,7 @@ export class ExecutionService {
|
|
|
352
561
|
if (execution === undefined || execution.outcome !== 'running') return undefined
|
|
353
562
|
execution.outcome = 'cancelled'
|
|
354
563
|
execution.endedAt = this.deps.now()
|
|
564
|
+
this.applyFacts(execution, facts)
|
|
355
565
|
if (target.status === 'in_progress') {
|
|
356
566
|
target.status = 'todo'
|
|
357
567
|
target.updatedAt = this.deps.now()
|
|
@@ -399,16 +609,32 @@ export class ExecutionService {
|
|
|
399
609
|
* The plugin framing line (rendered as a plugin context row): task head,
|
|
400
610
|
* already-claimed state, and the handoff protocol — everything the session
|
|
401
611
|
* must know about the board. The task id appears exactly once (here); the
|
|
402
|
-
* protocol steps below refer to it as 本任务.
|
|
612
|
+
* protocol steps below refer to it as 本任务. Isolated runs add one line
|
|
613
|
+
* steering the session onto its dedicated branch (commits are the evidence
|
|
614
|
+
* the user reviews at merge time); 续跑 and degraded runs each add their
|
|
615
|
+
* own steering line (0.3.1).
|
|
616
|
+
* @param task - the task.
|
|
617
|
+
* @param prepared - worktree facts when this run is isolated.
|
|
618
|
+
* @param degradeNote - why a worktree task degraded to the main directory.
|
|
403
619
|
*/
|
|
404
|
-
private pluginFraming(task: TaskRecord): string {
|
|
405
|
-
|
|
620
|
+
private pluginFraming(task: TaskRecord, prepared?: PreparedWorktree, degradeNote?: string): string {
|
|
621
|
+
let text = `【任务看板】${task.title}(ID: ${task.id})\n`
|
|
406
622
|
+ `本会话由任务看板执行服务启动,任务已置为进行中——无需认领;「已完成」仅限用户在界面操作(代码已限制,移了会被拒)。\n`
|
|
407
623
|
+ `完成后按序交接:\n`
|
|
408
624
|
+ `1. taskboard_get 读取本任务,取得最新 version\n`
|
|
409
625
|
+ `2. taskboard_comment_add 留评论:做了什么改动 / 如何验证 / 剩余风险\n`
|
|
410
626
|
+ `3. taskboard_move 将本任务移至待验收 in_review(带 ifVersion)\n`
|
|
411
627
|
+ `若无法完成:留评论说明原因,将任务移回待办 todo。`
|
|
628
|
+
if (prepared !== undefined) {
|
|
629
|
+
if (prepared.reused === true) {
|
|
630
|
+
text += `\n本任务启用了 Git Worktree 隔离,且本次为续跑:任务工作目录是独立分支 ${prepared.branch} 的 worktree——\n${prepared.worktreePath}\n上一次执行的改动与提交都保留在原处——请先查看已有改动(git status / git log)再继续,避免重复劳动,并把新完成的工作提交到该分支。`
|
|
631
|
+
} else {
|
|
632
|
+
text += `\n本任务启用了 Git Worktree 隔离:任务工作目录是独立分支 ${prepared.branch} 的全新 worktree——\n${prepared.worktreePath}\n(全新检出,不含 node_modules/构建产物,构建或测试前可能需要先安装依赖)。\n⚠ 边界纪律:你的会话根目录是整个项目,但本任务的全部改动必须只发生在上述 worktree 目录内——命令用 workdir 指向它、文件读写用它的绝对路径;不要改动主工作区的任何其它文件;把完成的工作提交(git commit)到该分支,验收将基于该分支的提交记录合并。`
|
|
633
|
+
}
|
|
634
|
+
} else if (degradeNote !== undefined) {
|
|
635
|
+
text += `\n⚠ 本次执行未能建立隔离,正在主项目目录中工作(原因:${degradeNote})。该目录可能有他人未提交的改动:动手前先 git status 检查现状,改动尽量集中,结束时在评论中说明动了哪些文件;避免把未经验证的改动直接提交到主分支。`
|
|
636
|
+
}
|
|
637
|
+
return text
|
|
412
638
|
}
|
|
413
639
|
|
|
414
640
|
/**
|