@goodandready/dsh-kanban 0.1.30 → 0.1.32

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.
@@ -64,4 +64,10 @@
64
64
  - Привязка проекта сессии: при создании агента с воркдеревом поле meta.cwd сохраняет канонический projectRoot (repoDir), предотвращая выпадение сессий в категорию «Ungrouped» в сайдбаре DSH; путь воркдерева передается в meta.worktreePath и инструкциях агента (#227).
65
65
  - Режим «↻ Продолжить» (Resume Execution): эндпоинт POST /dsh-kanban/tasks/:id/resume и кнопка в TaskDrawer; сохраняет существующее воркдерево, ветку и незакоммиченные файлы, передавая агенту преамбулу продолжения и актуальный PROGRESSDUMP (#226).
66
66
  - Фильтрация structural noise в проверках dirtyFiles: в checkWorktreeDirty добавлен флаг --ignore-submodules=all, исключающий ложные блокировки удаления воркдеревьев из-за субмодулей и дрейфа gitlinks (#213).
67
- - Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
67
+ - Фильтрация сессий субагентов: сессии с маркером subagent (origin === 'subagent' или parentId !== undefined) исключаются из liveSessions и не перезаписывают состояние корневых задач (#215).
68
+ ## Locked Decisions
69
+
70
+ ### 2026-09-08: Релиз Vector 2 (Управление параллелизмом и безопасность) (#214, #216, #224)
71
+ - **maxConcurrentSessions**: Лимит параллельных сессий агентов (по умолчанию 3) предотвращает исчерпание памяти/CPU на хосте. Задачи сверх лимита переводятся в очередь (`queuedAt > 0`) и автоматически запускаются диспетчером при завершении активных сессий.
72
+ - **workspaceClaimBoundaries**: Инструмент `board_move` проверяет принадлежность задачи к воркспейсу текущей сессии агента, предотвращая случайный межпроектный захват карточек.
73
+ - **Страховочный бэкап при импорте снимка**: `importBoard` в `/dsh-kanban/snapshot` автоматически делает снимок базы SQLite в `backups/kanban-backup-<timestamp>.db` с ротацией последних 10 копий перед перезаписью данных.
@@ -0,0 +1,8 @@
1
+ # Implementation Plan: Vector 2 — Concurrency & Safety (#214, #216, #224)
2
+
3
+ Implementation of resource protection, workspace claim boundaries, and database backup before snapshot import in `@goodandready/dsh-kanban` (v0.1.32).
4
+
5
+ ## Issues Scope
6
+ 1. **#214**: Global concurrent session limit and task queue (`maxConcurrentSessions`).
7
+ 2. **#216**: Project workspace claim boundaries (`workspaceClaimBoundaries`).
8
+ 3. **#224**: Automatic database safety backup before board snapshot import.
package/lib/board-tool.js CHANGED
@@ -80,7 +80,7 @@ function nameOf(task) {
80
80
  }
81
81
 
82
82
  /** Определение board_move, готовое к передаче в `defineTool`. */
83
- export function boardMoveDefinition({ store }) {
83
+ export function boardMoveDefinition({ store, config }) {
84
84
  return {
85
85
  name: 'board_move',
86
86
  description: 'Move the kanban card of the current task to another column. '
@@ -109,6 +109,15 @@ export function boardMoveDefinition({ store }) {
109
109
  const picked = pickTask(store, exec, ref)
110
110
  if (picked.error !== undefined) return picked.error
111
111
  const task = picked.task
112
+
113
+ // Проверка границ владения воркспейсом (#216)
114
+ const claimBoundaries = typeof config === 'function' ? config()?.workspaceClaimBoundaries : config?.workspaceClaimBoundaries
115
+ if (claimBoundaries !== false && typeof task.workspaceId === 'string' && task.workspaceId.trim() !== '') {
116
+ const sessionWs = String(exec?.agent?.session?.meta?.workspaceId ?? exec?.session?.meta?.workspaceId ?? '').trim()
117
+ if (sessionWs !== '' && sessionWs !== task.workspaceId.trim()) {
118
+ return `Cannot move card: task belongs to workspace "${task.workspaceId}", but current session belongs to workspace "${sessionWs}". Cross-workspace moves are restricted by workspaceClaimBoundaries.`
119
+ }
120
+ }
112
121
  // Перечисление уже не предлагает `done`, но модель может прислать его
113
122
  // мимо схемы, а завершение задачи не должно опираться на заявление.
114
123
  if (column === 'done' || TOOL_FORBIDDEN_COLUMNS.includes(column)) {
package/lib/client.js CHANGED
@@ -873,6 +873,8 @@ window.__ModuleLoader__.load({
873
873
  'field.sessionDefaultPermission': 'Default permission baseline',
874
874
  'hint.sessionDefaultPermission': 'Tasks requesting permissions above this rank require confirmation.',
875
875
  'field.announceToAgent': 'Announce board in system prompt',
876
+ 'field.maxConcurrentSessions': 'Max concurrent agent sessions',
877
+ 'field.workspaceClaimBoundaries': 'Workspace claim boundaries',
876
878
  'hint.announceToAgent': 'Injects guidance section into the agent system prompt.',
877
879
  'task.runs': 'Execution History',
878
880
  'task.noRuns': 'No runs yet',
@@ -1195,6 +1197,8 @@ window.__ModuleLoader__.load({
1195
1197
  'field.sessionDefaultPermission': '基线默认权限',
1196
1198
  'hint.sessionDefaultPermission': '超出此权限的任务需要点击人工确认。',
1197
1199
  'field.announceToAgent': '在系统提示词中声明看板',
1200
+ 'field.maxConcurrentSessions': '最大并发会话数',
1201
+ 'field.workspaceClaimBoundaries': '工作区认领边界',
1198
1202
  'hint.announceToAgent': '向 Agent 系统提示词注入看板与进度协作说明。',
1199
1203
  'task.runs': '执行历史',
1200
1204
  'task.noRuns': '暂无执行记录',
@@ -1513,6 +1517,8 @@ window.__ModuleLoader__.load({
1513
1517
  { field: 'permissionGateEnabled', kind: 'bool', group: 'general' },
1514
1518
  { field: 'sessionDefaultPermission', kind: 'text', group: 'general' },
1515
1519
  { field: 'announceToAgent', kind: 'bool', group: 'general' },
1520
+ { field: 'maxConcurrentSessions', kind: 'number', group: 'limits' },
1521
+ { field: 'workspaceClaimBoundaries', kind: 'bool', group: 'general' },
1516
1522
  ]
1517
1523
 
1518
1524
  function hintKey(spec) {
package/lib/config.js CHANGED
@@ -79,6 +79,8 @@ export const CONFIG_DEFAULTS = {
79
79
  permissionGateEnabled: false,
80
80
  sessionDefaultPermission: 'read-only',
81
81
  announceToAgent: true,
82
+ maxConcurrentSessions: 3,
83
+ workspaceClaimBoundaries: true,
82
84
  }
83
85
 
84
86
  /** Пояснения к полям; повторно используются схемой и карточкой настроек. */
package/lib/index.js CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
29
29
  import {
30
30
  runTask, resolveModel, buildStartMessage,
31
- runBatch, queueTask, unqueueTask, liveSessions,
31
+ runBatch, queueTask, unqueueTask, liveSessions, dispatchNextQueuedTask,
32
32
  PERMISSION_RANKS,
33
33
  } from './launcher.js'
34
34
  import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
@@ -265,7 +265,7 @@ export function apply(ctx, config) {
265
265
  ctx.effect(() => ctx.on('session/event', (session, event) => {
266
266
  try {
267
267
  const text = event?.content?.[0]?.text ?? event?.text ?? ''
268
- handleSessionEvent({
268
+ const res = handleSessionEvent({
269
269
  store,
270
270
  sessionId: String(session?.id ?? ''),
271
271
  session,
@@ -274,6 +274,20 @@ export function apply(ctx, config) {
274
274
  error: event?.error,
275
275
  })
276
276
  syncPower()
277
+ // Если сессия завершила шаг или упала — пробуем продвинуть очередь задач (#214)
278
+ if (event?.type === 'turn/end' || event?.type === 'turn/completed' || event?.type === 'session/error' || event?.type === 'turn/error') {
279
+ dispatchNextQueuedTask({
280
+ agents: ctx.agents,
281
+ store,
282
+ config: getConfig(),
283
+ mintSessionId: () => SessionId(`kanban-queued-${randomUUID()}`),
284
+ createMessage: createUserMessage,
285
+ logger: ctx.logger,
286
+ permissions: ctx.get('permissionPresets'),
287
+ workspaceRegistry: ctx.get('workspaceRegistry'),
288
+ claimAs: 'queue-dispatcher',
289
+ }).catch(() => {})
290
+ }
277
291
  } catch (error) {
278
292
  ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
279
293
  }
@@ -623,7 +637,7 @@ export function apply(ctx, config) {
623
637
  // множить настройки ради разницы, которой человек не почувствует.
624
638
  const registerBoardTool = () => {
625
639
  const off = [
626
- ctx.tools.register(defineTool(boardMoveDefinition({ store }))),
640
+ ctx.tools.register(defineTool(boardMoveDefinition({ store, config: getConfig }))),
627
641
  ctx.tools.register(defineTool(boardPlanDefinition({ store }))),
628
642
  ctx.tools.register(defineTool(boardChecklistDefinition({ store }))),
629
643
  ctx.tools.register(defineTool(boardReportDefinition({ store }))),
package/lib/launcher.js CHANGED
@@ -296,6 +296,25 @@ export async function runTask({
296
296
  agentPreset, permission, permissions, claimAs,
297
297
  gitRunner, workspaceRegistry, resume = false,
298
298
  }) {
299
+ // Проверка лимита параллелизма (#214)
300
+ const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
301
+ if (maxConcurrent > 0) {
302
+ const activeCount = countActiveSessions({ store, agents })
303
+ // Если задача уже в очереди или это новый запуск, и слоты исчерпаны
304
+ const isAlreadyRunning = task.sessionId && agents?.get?.(task.sessionId)?.status === 'running'
305
+ if (!isAlreadyRunning && activeCount >= maxConcurrent) {
306
+ const now = Date.now()
307
+ store.updateTask(task.id, { queuedAt: task.queuedAt > 0 ? task.queuedAt : now })
308
+ store.addTransition({
309
+ taskId: task.id,
310
+ fromCol: task.column,
311
+ toCol: task.column,
312
+ source: 'session',
313
+ detail: `превышен лимит параллельных сессий (${activeCount}/${maxConcurrent}), задача поставлена в очередь`,
314
+ })
315
+ return { mode: 'queued', queued: true, taskId: task.id, activeCount, maxConcurrent }
316
+ }
317
+ }
299
318
  if (config?.permissionGateEnabled) {
300
319
  const perm = permission || task.permission || 'read-only'
301
320
  const defaultPerm = config?.sessionDefaultPermission || 'read-only'
@@ -314,6 +333,10 @@ export async function runTask({
314
333
  agentPreset, permission, permissions, gitRunner, workspaceRegistry,
315
334
  })
316
335
 
336
+ if (task.queuedAt > 0) {
337
+ store.updateTask(task.id, { queuedAt: 0 })
338
+ }
339
+
317
340
  // Фиксируем запуск в истории задачи
318
341
  if (typeof store.recordTaskRun === 'function') {
319
342
  store.recordTaskRun(task.id, {
@@ -601,6 +624,11 @@ export function unqueueTask({ store, task }) {
601
624
  * Сессия без живого агента в список не попадает — предлагать её значило бы
602
625
  * предлагать очередь, из которой никто ничего не возьмёт.
603
626
  */
627
+ export function countActiveSessions({ store, agents }) {
628
+ const sessions = liveSessions({ store, agents })
629
+ return sessions.filter((s) => s.status === 'running' || s.status === 'busy' || s.status === 'thinking').length
630
+ }
631
+
604
632
  export function liveSessions({ store, agents }) {
605
633
  const byId = new Map()
606
634
  const all = [...store.listTasks({ board: 'main' }), ...store.listTasks({ board: 'simple' })]
@@ -622,3 +650,45 @@ export function liveSessions({ store, agents }) {
622
650
  }
623
651
  return [...byId.values()]
624
652
  }
653
+
654
+ /**
655
+ * Автоматический запуск следующей задачи из очереди при освобождении слота (#214).
656
+ */
657
+ export async function dispatchNextQueuedTask({
658
+ agents, store, config, mintSessionId, createMessage, cwdOf, logger,
659
+ agentPreset, permissions, workspaceRegistry, claimAs, gitRunner,
660
+ }) {
661
+ if (!store || !agents) return null
662
+ const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
663
+ if (maxConcurrent > 0) {
664
+ const active = countActiveSessions({ store, agents })
665
+ if (active >= maxConcurrent) return null
666
+ }
667
+
668
+ const queued = typeof store.listQueuedTasks === 'function' ? store.listQueuedTasks() : []
669
+ if (queued.length === 0) return null
670
+
671
+ const nextTask = queued[0]
672
+ try {
673
+ return await runTask({
674
+ agents,
675
+ store,
676
+ task: nextTask,
677
+ config,
678
+ provider: nextTask.provider || undefined,
679
+ model: nextTask.model || undefined,
680
+ mintSessionId: mintSessionId || (() => `kanban-${nextTask.id}-${randomUUID()}`),
681
+ createMessage: createMessage || ((msg) => msg),
682
+ cwdOf,
683
+ logger,
684
+ agentPreset,
685
+ permissions,
686
+ workspaceRegistry,
687
+ claimAs: claimAs || 'queue-dispatcher',
688
+ gitRunner,
689
+ })
690
+ } catch (err) {
691
+ logger?.warn?.(`dsh-kanban: не удалось запустить задачу ${nextTask.id} из очереди: ${err?.message}`)
692
+ return null
693
+ }
694
+ }
package/lib/routes.js CHANGED
@@ -382,6 +382,12 @@ export function exportBoard({ store }) {
382
382
  * @returns {{imported: number, skipped: number, error?: string}}
383
383
  */
384
384
  export function importBoard({ store, input }) {
385
+ let backupMeta = undefined
386
+ if (typeof store.backupDatabase === 'function') {
387
+ try {
388
+ backupMeta = store.backupDatabase()
389
+ } catch {}
390
+ }
385
391
  if (input === null || typeof input !== 'object') return { error: 'bad-import', status: 400 }
386
392
  if (input.version !== 1) return { error: 'bad-import-version', status: 400 }
387
393
  if (!Array.isArray(input.tasks)) return { error: 'bad-import', status: 400 }
@@ -419,5 +425,5 @@ export function importBoard({ store, input }) {
419
425
  imported += 1
420
426
  }
421
427
 
422
- return { imported, skipped }
428
+ return { imported, skipped, backup: backupMeta?.backup }
423
429
  }
package/lib/store.js CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { DatabaseSync } from 'node:sqlite'
15
15
  import { randomUUID } from 'node:crypto'
16
- import { mkdirSync } from 'node:fs'
16
+ import { mkdirSync, copyFileSync, readdirSync, unlinkSync, statSync } from 'node:fs'
17
17
  import { join } from 'node:path'
18
18
  import { keyBetween } from './order.js'
19
19
  import { DEFAULT_BOARDS, normalizeKind } from './config.js'
@@ -257,6 +257,37 @@ export function openStore({ dir }) {
257
257
  }
258
258
 
259
259
  return {
260
+ backupDatabase() {
261
+ const backupsDir = join(dir, 'backups')
262
+ mkdirSync(backupsDir, { recursive: true })
263
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
264
+ const backupName = `kanban-backup-${stamp}.db`
265
+ const dest = join(backupsDir, backupName)
266
+ try {
267
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)')
268
+ } catch { /* wal checkpoint may not be strictly required */ }
269
+ copyFileSync(join(dir, 'kanban.db'), dest)
270
+
271
+ // Ротация: сохраняем только последние 10 бэкапов
272
+ try {
273
+ const files = readdirSync(backupsDir)
274
+ .filter((f) => f.startsWith('kanban-backup-') && f.endsWith('.db'))
275
+ .map((f) => ({ name: f, path: join(backupsDir, f), time: statSync(join(backupsDir, f)).mtimeMs }))
276
+ .sort((a, b) => b.time - a.time)
277
+ if (files.length > 10) {
278
+ for (const old of files.slice(10)) {
279
+ try { unlinkSync(old.path) } catch {}
280
+ }
281
+ }
282
+ } catch {}
283
+ return { backup: backupName, path: dest }
284
+ },
285
+
286
+ listQueuedTasks() {
287
+ const rows = db.prepare('SELECT * FROM tasks WHERE queuedAt > 0 AND archivedAt = 0 ORDER BY queuedAt ASC').all()
288
+ return rows.map(rowToTask)
289
+ },
290
+
260
291
  listBoards() {
261
292
  return q.boards.all()
262
293
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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",