@goodandready/dsh-kanban 0.1.29 → 0.1.30

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.
@@ -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
- const meta = { cwd }
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.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
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.29",
3
+ "version": "0.1.30",
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",