@goodandready/dsh-kanban 0.1.29 → 0.1.31
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/docs/design/DESIGN.md +6 -0
- package/docs/plans/2026-09-08-session-worktree-stability.md +33 -0
- package/lib/client.js +29 -0
- package/lib/index.js +34 -0
- package/lib/launcher.js +32 -8
- package/lib/lifecycle.js +5 -1
- package/lib/worktree.js +34 -3
- package/package.json +1 -1
package/docs/design/DESIGN.md
CHANGED
|
@@ -59,3 +59,9 @@
|
|
|
59
59
|
- Structured Report: фиксация отчёта выполнения (summary, changedFiles, checksRun, artifacts, risks) в карточке задачи.
|
|
60
60
|
- Двусторонний цикл приёмки: явные действия человека Accept (в done) и Reject (возврат в in-progress с обязательной причиной rejectReason, инжектируемой в следующую сессию агента).
|
|
61
61
|
- Ветка комментариев к задаче с поддержкой ролей user и agent для сквозного диалога прямо в Task Drawer.
|
|
62
|
+
|
|
63
|
+
- 2026-09-08 — Стабильность сессий и воркдеревьев (#227, #226, #213, #215):
|
|
64
|
+
- Привязка проекта сессии: при создании агента с воркдеревом поле meta.cwd сохраняет канонический projectRoot (repoDir), предотвращая выпадение сессий в категорию «Ungrouped» в сайдбаре DSH; путь воркдерева передается в meta.worktreePath и инструкциях агента (#227).
|
|
65
|
+
- Режим «↻ Продолжить» (Resume Execution): эндпоинт POST /dsh-kanban/tasks/:id/resume и кнопка в TaskDrawer; сохраняет существующее воркдерево, ветку и незакоммиченные файлы, передавая агенту преамбулу продолжения и актуальный PROGRESSDUMP (#226).
|
|
66
|
+
- Фильтрация structural noise в проверках dirtyFiles: в checkWorktreeDirty добавлен флаг --ignore-submodules=all, исключающий ложные блокировки удаления воркдеревьев из-за субмодулей и дрейфа gitlinks (#213).
|
|
67
|
+
- Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# План: Вектор 1 — Стабильность сессий и воркдеревьев (#227, #226, #213, #215)
|
|
2
|
+
|
|
3
|
+
## 1. Контекст и цели
|
|
4
|
+
Повышение отказоустойчивости жизненного цикла сессий задач в DSH Kanban:
|
|
5
|
+
- Сохранение привязки сессии к проекту в левом сайдбаре DSH (meta.cwd = projectRoot) при изоляции в воркдереве (#227).
|
|
6
|
+
- Возможность продолжить работу над задачей («↻ Продолжить / Resume») в существующем воркдереве без деструктивного сброса ветки (#226).
|
|
7
|
+
- Игнорирование структурного шума субмодулей и gitlink drift в проверках чистоты воркдерева checkWorktreeDirty (#213).
|
|
8
|
+
- Исключение дочерних сессий субагентов (invoke_subagent) из списка живых сессий и трекинга карточек (#215).
|
|
9
|
+
|
|
10
|
+
## 2. Архитектурные изменения
|
|
11
|
+
- **lib/worktree.js**:
|
|
12
|
+
- checkWorktreeDirty: вызов gitRunner(['status', '--porcelain', '--ignore-submodules=all'], worktreePath) для устранения ложного dirty-статуса от субмодулей.
|
|
13
|
+
- **lib/launcher.js**:
|
|
14
|
+
- obtainAgent: meta.cwd фиксируется как projectRoot, meta.worktreePath = worktreePath.
|
|
15
|
+
-
|
|
16
|
+
- liveSessions: фильтрация сессий с origin === 'subagent' или parentId !== undefined.
|
|
17
|
+
- **lib/lifecycle.js**:
|
|
18
|
+
- handleSessionEvent: игнорирование событий от сессий-субагентов.
|
|
19
|
+
- **lib/routes.js**:
|
|
20
|
+
- Эндпоинт POST /tasks/:id/resume.
|
|
21
|
+
- **lib/index.js**:
|
|
22
|
+
- Регистрация маршрута /dsh-kanban/tasks/:id/resume.
|
|
23
|
+
- **lib/client.js**:
|
|
24
|
+
- Кнопка «↻ Resume» в TaskDrawer.
|
|
25
|
+
|
|
26
|
+
## 3. Верификация
|
|
27
|
+
- Юнит-тесты: est/worktree-dirty.test.mjs, est/launcher-cwd.test.mjs, est/resume.test.mjs, est/subagent-filter.test.mjs.
|
|
28
|
+
- Полный прогон
|
|
29
|
+
pm test и
|
|
30
|
+
pm run compat.
|
|
31
|
+
- Проверка пакета
|
|
32
|
+
pm pack --dry-run --json.
|
|
33
|
+
- Тест на MiniPC test runner через dsh-test-plugin.
|
package/lib/client.js
CHANGED
|
@@ -1095,6 +1095,7 @@ window.__ModuleLoader__.load({
|
|
|
1095
1095
|
'panel.workDirHint': 'The project root is not set in settings, so this folder is used.',
|
|
1096
1096
|
'panel.worktree': 'Worktree: {path}',
|
|
1097
1097
|
'panel.cleanupWorktree': 'Clean Worktree',
|
|
1098
|
+
'action.resume': '↻ Resume',
|
|
1098
1099
|
'panel.worktreeDirty': 'Uncommitted changes in worktree: {files}',
|
|
1099
1100
|
'panel.priority': 'Priority',
|
|
1100
1101
|
'panel.priorityNone': 'No priority',
|
|
@@ -1416,6 +1417,7 @@ window.__ModuleLoader__.load({
|
|
|
1416
1417
|
'panel.workDirHint': '设置中未指定项目根目录,因此使用此目录。',
|
|
1417
1418
|
'panel.worktree': '工作区:{path}',
|
|
1418
1419
|
'panel.cleanupWorktree': '清理工作区',
|
|
1420
|
+
'action.resume': '↻ 继续执行',
|
|
1419
1421
|
'panel.worktreeDirty': '工作区中存在未提交的更改:{files}',
|
|
1420
1422
|
'panel.priority': '优先级',
|
|
1421
1423
|
'panel.priorityNone': '无优先级',
|
|
@@ -3221,6 +3223,33 @@ window.__ModuleLoader__.load({
|
|
|
3221
3223
|
}, t('panel.cleanupWorktree'))
|
|
3222
3224
|
: null,
|
|
3223
3225
|
),
|
|
3226
|
+
(openTask.worktree || openTask.rejectReason || (Array.isArray(openTask.runs) && openTask.runs.length > 0)) ? React.createElement('button', {
|
|
3227
|
+
type: 'button',
|
|
3228
|
+
className: 'dkb-secondary',
|
|
3229
|
+
style: { marginRight: '8px', border: '1px solid var(--dsw-alias-border-color-primary, #444)' },
|
|
3230
|
+
disabled: starting || model === '',
|
|
3231
|
+
onClick: async () => {
|
|
3232
|
+
setStarting(true)
|
|
3233
|
+
try {
|
|
3234
|
+
rememberLaunch(board, { provider, model, agentPreset, permission })
|
|
3235
|
+
const out = await api('/task/' + encodeURIComponent(openTask.id) + '/resume', {
|
|
3236
|
+
method: 'POST',
|
|
3237
|
+
body: JSON.stringify({ provider, model, text: draftText, agentPreset, permission }),
|
|
3238
|
+
})
|
|
3239
|
+
setOpenTask(null)
|
|
3240
|
+
await load(board)
|
|
3241
|
+
if (!openSession(props.ctx, out && out.sessionId)) {
|
|
3242
|
+
setError(t('error.sessionNotOpened', { id: (out && out.sessionId) || '' }))
|
|
3243
|
+
return
|
|
3244
|
+
}
|
|
3245
|
+
if (props.onClose) props.onClose()
|
|
3246
|
+
} catch (e) {
|
|
3247
|
+
setError(t(e.key || 'error.unknown'))
|
|
3248
|
+
} finally {
|
|
3249
|
+
setStarting(false)
|
|
3250
|
+
}
|
|
3251
|
+
},
|
|
3252
|
+
}, t('action.resume')) : null,
|
|
3224
3253
|
React.createElement('button', {
|
|
3225
3254
|
type: 'button', className: 'dkb-save', disabled: starting || model === '',
|
|
3226
3255
|
onClick: async () => {
|
package/lib/index.js
CHANGED
|
@@ -268,6 +268,7 @@ export function apply(ctx, config) {
|
|
|
268
268
|
handleSessionEvent({
|
|
269
269
|
store,
|
|
270
270
|
sessionId: String(session?.id ?? ''),
|
|
271
|
+
session,
|
|
271
272
|
type: event?.type,
|
|
272
273
|
text,
|
|
273
274
|
error: event?.error,
|
|
@@ -927,6 +928,39 @@ export function apply(ctx, config) {
|
|
|
927
928
|
reply(res, { task: updated })
|
|
928
929
|
return
|
|
929
930
|
}
|
|
931
|
+
if (req.method === 'POST' && action === 'resume') {
|
|
932
|
+
const task = store.getTask(id)
|
|
933
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
934
|
+
const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
|
|
935
|
+
const picked = resolveModel({
|
|
936
|
+
requested: { model: input?.model || task.model, provider: input?.provider || task.provider },
|
|
937
|
+
fallback,
|
|
938
|
+
})
|
|
939
|
+
if (picked.error) { reply(res, picked); return }
|
|
940
|
+
const out = await runTask({
|
|
941
|
+
agents: ctx.agents,
|
|
942
|
+
store,
|
|
943
|
+
task,
|
|
944
|
+
config: getConfig(),
|
|
945
|
+
provider: picked.provider,
|
|
946
|
+
model: picked.model,
|
|
947
|
+
mintSessionId,
|
|
948
|
+
createMessage,
|
|
949
|
+
cwdOf,
|
|
950
|
+
logger: ctx.logger,
|
|
951
|
+
text: input?.text,
|
|
952
|
+
agentPreset: input?.agentPreset || task.agentPreset,
|
|
953
|
+
permission: input?.permission || task.permission,
|
|
954
|
+
permissions: ctx.permissions,
|
|
955
|
+
claimAs: req.headers['x-dsh-user'],
|
|
956
|
+
gitRunner,
|
|
957
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
958
|
+
resume: true,
|
|
959
|
+
})
|
|
960
|
+
syncPower()
|
|
961
|
+
reply(res, out)
|
|
962
|
+
return
|
|
963
|
+
}
|
|
930
964
|
if (req.method === 'POST' && action === 'confirm-permission') {
|
|
931
965
|
reply(res, confirmPermission({ store, id }))
|
|
932
966
|
return
|
package/lib/launcher.js
CHANGED
|
@@ -171,6 +171,7 @@ export async function obtainAgent({
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
let cwd = resolveCwd(task, config, cwdOf)
|
|
174
|
+
const projectRoot = cwd
|
|
174
175
|
let worktreePath = task?.worktree || undefined
|
|
175
176
|
let branchName = task?.branch || undefined
|
|
176
177
|
let workspaceId = task?.workspaceId || undefined
|
|
@@ -189,7 +190,6 @@ export async function obtainAgent({
|
|
|
189
190
|
worktreePath = created.worktreePath
|
|
190
191
|
branchName = created.branchName
|
|
191
192
|
worktreeCreated = created.created === true
|
|
192
|
-
cwd = created.worktreePath
|
|
193
193
|
|
|
194
194
|
if (workspaceRegistry) {
|
|
195
195
|
workspaceId = await registerDshWorkspace({
|
|
@@ -202,11 +202,12 @@ export async function obtainAgent({
|
|
|
202
202
|
} catch (err) {
|
|
203
203
|
logger?.warn?.(`dsh-kanban: не удалось создать worktree для ${task.id}: ${err.message}`)
|
|
204
204
|
}
|
|
205
|
-
} else if (worktreePath) {
|
|
206
|
-
cwd = worktreePath
|
|
207
205
|
}
|
|
208
206
|
|
|
209
|
-
|
|
207
|
+
// meta.cwd сохраняет projectRoot, чтобы DSH группировал сессию внутри проекта (#227)
|
|
208
|
+
const meta = { cwd: projectRoot, projectRoot }
|
|
209
|
+
if (worktreePath) meta.worktreePath = worktreePath
|
|
210
|
+
if (branchName) meta.branch = branchName
|
|
210
211
|
if (typeof agentPreset === 'string' && agentPreset !== '') meta.agentPreset = agentPreset
|
|
211
212
|
if (workspaceId) meta.workspaceId = workspaceId
|
|
212
213
|
|
|
@@ -293,7 +294,7 @@ export async function runTask({
|
|
|
293
294
|
agents, store, task, config, provider, model,
|
|
294
295
|
mintSessionId, createMessage, cwdOf, logger, text,
|
|
295
296
|
agentPreset, permission, permissions, claimAs,
|
|
296
|
-
gitRunner, workspaceRegistry,
|
|
297
|
+
gitRunner, workspaceRegistry, resume = false,
|
|
297
298
|
}) {
|
|
298
299
|
if (config?.permissionGateEnabled) {
|
|
299
300
|
const perm = permission || task.permission || 'read-only'
|
|
@@ -328,8 +329,8 @@ export async function runTask({
|
|
|
328
329
|
taken.agent.followup(createMessage({
|
|
329
330
|
// Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
|
|
330
331
|
// (`content.some(...)`), и строка роняет ход целиком.
|
|
331
|
-
content: [{ type: 'text', text: messageFor(task, config, text) }],
|
|
332
|
-
source: { kind: 'plugin', plugin: 'dsh-kanban', form: 'task-start' },
|
|
332
|
+
content: [{ type: 'text', text: messageFor(task, config, text, { resume, mode: taken.mode }) }],
|
|
333
|
+
source: { kind: 'plugin', plugin: 'dsh-kanban', form: resume ? 'task-resume' : 'task-start' },
|
|
333
334
|
}))
|
|
334
335
|
|
|
335
336
|
const before = store.getTask(task.id)
|
|
@@ -347,6 +348,16 @@ export async function runTask({
|
|
|
347
348
|
// остаётся чужой.
|
|
348
349
|
claimOnStart({ store, task: before, login: claimAs })
|
|
349
350
|
|
|
351
|
+
if (resume) {
|
|
352
|
+
store.addTransition({
|
|
353
|
+
taskId: task.id,
|
|
354
|
+
fromCol: before.column,
|
|
355
|
+
toCol: before.column,
|
|
356
|
+
source: 'session',
|
|
357
|
+
detail: `продолжение работы над задачей (сессия ${taken.sessionId})`,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
350
361
|
// Изоляция сессии в отдельном воркдереве фиксируется в журнале.
|
|
351
362
|
if (taken.worktreeCreated) {
|
|
352
363
|
store.addTransition({
|
|
@@ -386,9 +397,15 @@ export async function runTask({
|
|
|
386
397
|
}
|
|
387
398
|
|
|
388
399
|
/** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
|
|
389
|
-
function messageFor(task, config, text) {
|
|
400
|
+
export function messageFor(task, config, text, options = {}) {
|
|
390
401
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
391
402
|
let base = own !== '' ? own : buildStartMessage(task, config)
|
|
403
|
+
let resumePreamble = ''
|
|
404
|
+
if (options.resume || options.mode === 'resumed') {
|
|
405
|
+
const wt = task?.worktree ? ` в существующем воркдереве ${task.worktree}` : ''
|
|
406
|
+
const br = task?.branch ? ` на ветке ${task.branch}` : ''
|
|
407
|
+
resumePreamble = `### ↻ Режим продолжения работы над задачей\nРабота продолжается${wt}${br}. Сохраняй существующие изменения и продолжай выполнение с текущего состояния.\n\n`
|
|
408
|
+
}
|
|
392
409
|
let qualityNotes = ''
|
|
393
410
|
if (task?.rejectReason) {
|
|
394
411
|
qualityNotes += `### ⚠️ Внимание: Задача возвращена на доработку\nЗамечания к приёмке (причина возврата):\n${task.rejectReason}\n\n`
|
|
@@ -404,6 +421,9 @@ function messageFor(task, config, text) {
|
|
|
404
421
|
if (qualityNotes) {
|
|
405
422
|
base = qualityNotes + base
|
|
406
423
|
}
|
|
424
|
+
if (resumePreamble) {
|
|
425
|
+
base = resumePreamble + base
|
|
426
|
+
}
|
|
407
427
|
if (config?.progressDumpEnabled && task.progressDump) {
|
|
408
428
|
let parsed
|
|
409
429
|
try {
|
|
@@ -590,6 +610,10 @@ export function liveSessions({ store, agents }) {
|
|
|
590
610
|
let live
|
|
591
611
|
try { live = agents.get(id) } catch { live = undefined }
|
|
592
612
|
if (live === undefined) continue
|
|
613
|
+
// Исключаем дочерние сессии субагентов (#215)
|
|
614
|
+
if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
|
|
615
|
+
continue
|
|
616
|
+
}
|
|
593
617
|
if (!byId.has(id)) byId.set(id, { sessionId: id, status: live.status, tasks: [] })
|
|
594
618
|
byId.get(id).tasks.push({
|
|
595
619
|
id: task.id, title: task.title, repo: task.repo, issueNumber: task.issueNumber,
|
package/lib/lifecycle.js
CHANGED
|
@@ -20,7 +20,11 @@ import { parseProgressDump, formatProgressDump } from './progress-dump.js'
|
|
|
20
20
|
*
|
|
21
21
|
* @returns {{taskId: string, waiting?: boolean}|undefined} что изменилось
|
|
22
22
|
*/
|
|
23
|
-
export function handleSessionEvent({ store, sessionId, type, text, error }) {
|
|
23
|
+
export function handleSessionEvent({ store, sessionId, session, type, text, error }) {
|
|
24
|
+
// Исключаем события дочерних сессий субагентов (#215)
|
|
25
|
+
if (session && (session.origin === 'subagent' || session.parentId !== undefined || session.meta?.origin === 'subagent' || session.isSubagent)) {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
24
28
|
const task = store.findTaskBySession(sessionId)
|
|
25
29
|
if (task === undefined) return undefined
|
|
26
30
|
|
package/lib/worktree.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { execFile } from 'node:child_process'
|
|
15
15
|
import { promisify } from 'node:util'
|
|
16
|
-
import { existsSync } from 'node:fs'
|
|
16
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
17
17
|
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
18
18
|
|
|
19
19
|
const execFileAsync = promisify(execFile)
|
|
@@ -202,13 +202,44 @@ export async function createTaskWorktree({
|
|
|
202
202
|
/**
|
|
203
203
|
* Проверить наличие незакоммиченных изменений в воркдереве.
|
|
204
204
|
*/
|
|
205
|
+
function isSubmoduleDrift(worktreePath, line) {
|
|
206
|
+
try {
|
|
207
|
+
const filePath = line.slice(3).trim()
|
|
208
|
+
if (!filePath) return false
|
|
209
|
+
const fullPath = resolve(worktreePath, filePath)
|
|
210
|
+
const gitFile = join(fullPath, '.git')
|
|
211
|
+
if (existsSync(gitFile)) {
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
const gitmodulesPath = join(worktreePath, '.gitmodules')
|
|
215
|
+
if (existsSync(gitmodulesPath)) {
|
|
216
|
+
const content = readFileSync(gitmodulesPath, 'utf8')
|
|
217
|
+
const paths = [...content.matchAll(/path\s*=\s*([^\r\n]+)/g)].map((m) => m[1].trim())
|
|
218
|
+
if (paths.includes(filePath) || paths.some((p) => filePath.startsWith(p + '/') || filePath === p)) {
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// Игнорируем ошибки доступа к файлам при проверке
|
|
224
|
+
}
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Проверить наличие незакоммиченных изменений в воркдереве.
|
|
230
|
+
* Исключает структурный шум субмодулей и дрейф gitlinks (#213).
|
|
231
|
+
*/
|
|
205
232
|
export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
|
|
206
233
|
if (!worktreePath || !existsSync(worktreePath)) {
|
|
207
234
|
return { dirty: false, files: [] }
|
|
208
235
|
}
|
|
209
236
|
try {
|
|
210
|
-
const res = await gitRunner(['status', '--porcelain'], worktreePath)
|
|
211
|
-
const files = res.stdout
|
|
237
|
+
const res = await gitRunner(['status', '--porcelain', '--ignore-submodules=all'], worktreePath)
|
|
238
|
+
const files = res.stdout
|
|
239
|
+
.split('\n')
|
|
240
|
+
.map((l) => l.trim())
|
|
241
|
+
.filter((l) => l.length > 0)
|
|
242
|
+
.filter((line) => !isSubmoduleDrift(worktreePath, line))
|
|
212
243
|
return { dirty: files.length > 0, files }
|
|
213
244
|
} catch (error) {
|
|
214
245
|
return { dirty: false, files: [], error: String(error?.message ?? error) }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.31",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|