@goodandready/dsh-kanban 0.2.18 → 0.2.20

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/lib/launcher.js CHANGED
@@ -1,16 +1,14 @@
1
- // Запуск работы по задаче: отдельная сессия агента с выбранной моделью.
1
+ // Task execution launcher: dedicated agent session with chosen model.
2
2
  //
3
- // Плагин НЕ готовит ничего, кроме сессии. Ветку и worktree он не создаёт: по
4
- // воркфлоу агент сначала делает preflight — смотрит открытые ветки и worktree,
5
- // проверяет рабочее дерево — и только потом заводит worktree от свежего
6
- // origin/main. Плагин, создающий ветку заранее, ломает preflight: ветка
7
- // появится до того, как выяснится, что задачу вообще нельзя начинать.
3
+ // The plugin does not prepare branches or worktrees in advance: the agent conducts
4
+ // preflight first to check existing worktrees and branch status before branching from origin/main.
5
+ // Pre-creating branches would interfere with preflight validation.
8
6
  //
9
- // По той же причине плагин не пишет стартовый комментарий в issue. Его пишет
10
- // агент после preflight, потому что до preflight ветка и план неизвестны.
7
+ // The agent writes the start comment on the issue after preflight, as branch
8
+ // and plan are determined during preflight.
11
9
  //
12
- // Пакеты ядра здесь не импортируются: идентификатор сессии и сборка сообщения
13
- // приходят параметрами. Так модуль проверяется без харнесса и без сети.
10
+ // Core packages are not imported directly; session ID and message factory
11
+ // are passed as parameters to support unit testing without harness runtime.
14
12
 
15
13
  import { isAbsolute, join, resolve, sep } from 'node:path'
16
14
  import { randomUUID } from 'node:crypto'
@@ -20,34 +18,30 @@ import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
20
18
  export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
21
19
 
22
20
  /**
23
- * Хвост своей задачи — УСЛОВИЕ, а не приказ.
21
+ * Tail instructions for local board tasks.
24
22
  *
25
- * На доску вешают что угодно: «прибраться в логах», «найди мне вот такую
26
- * информацию». Распоряжение делать preflight веток и заводить worktree для
27
- * такой задачи бессмысленно — агент честно уходил искать репозитории и
28
- * перечислять worktree вместо того, чтобы заняться написанным.
29
- *
30
- * Поэтому решает агент по содержанию задачи, а не доска заранее.
23
+ * Local tasks cover ad-hoc activities where repo preflight and branch creation
24
+ * may not apply. The agent evaluates task context rather than forcing repo workflows.
31
25
  */
32
- const OWN_TAIL = 'Если задача окажется про код проекта — придерживайся его воркфлоу; '
33
- + 'если нет — просто сделай, что написано выше.'
26
+ const OWN_TAIL = 'If the task is about project code, follow its workflow; '
27
+ + 'if not, just perform what is written above.'
34
28
 
35
- /** Хвост про воркфлоу — ТОЛЬКО для задач, за которыми стоит issue. */
29
+ /** Tail instructions for workflow tasks backed by a Gitea issue. */
36
30
  const WORKFLOW_TAIL = [
37
- 'Работай по действующему воркфлоу проекта: сначала preflight открытых веток и',
38
- 'worktree, затем worktree от свежего origin/main, стартовый комментарий в issue',
39
- 'с планом и проверками, дальше реализация.',
31
+ 'Follow active project workflow: first preflight open branches and',
32
+ 'worktrees, then worktree from fresh origin/main, initial issue comment',
33
+ 'with plan and checks, then implementation.',
40
34
  ].join('\n')
41
35
 
42
36
  /**
43
- * Первое сообщение агенту.
37
+ * Initial message prompt for a task.
44
38
  *
45
- * Сообщение НЕ содержит указаний создать ветку с конкретным именем: имя ветки
46
- * выбирает агент после preflight.
39
+ * Does not instruct the agent to use a specific branch name; the agent selects
40
+ * branch naming following project workflow after preflight.
47
41
  *
48
- * @param {object} task задача доски
49
- * @param {object} config настройки плагина
50
- * @returns {string} текст сообщения
42
+ * @param {object} task board task
43
+ * @param {object} config plugin settings
44
+ * @returns {string} prompt message body
51
45
  */
52
46
  export function buildStartMessage(task, config) {
53
47
  const vars = {
@@ -63,22 +57,22 @@ export function buildStartMessage(task, config) {
63
57
  const custom = typeof config?.startPrompt === 'string' ? config.startPrompt.trim() : ''
64
58
  if (custom !== '') return joinBlocks([substitute(custom, vars), note])
65
59
 
66
- // Задача из issue и своя задача — разные сообщения, а не одно с пропусками.
60
+ // Issue tasks and local board tasks use distinct prompt layouts.
67
61
  const fromIssue = vars.repo !== '' && vars.issueNumber !== ''
68
62
  const head = fromIssue
69
- ? `Задача ${vars.repo}#${vars.issueNumber}: ${vars.title}`
70
- : `Задача: ${vars.title}`
63
+ ? `Task ${vars.repo}#${vars.issueNumber}: ${vars.title}`
64
+ : `Task: ${vars.title}`
71
65
 
72
66
  return joinBlocks([
73
67
  head,
74
68
  vars.body,
75
- fromIssue && vars.issueUrl !== '' ? `Ссылка: ${vars.issueUrl}` : '',
69
+ fromIssue && vars.issueUrl !== '' ? `Link: ${vars.issueUrl}` : '',
76
70
  fromIssue ? WORKFLOW_TAIL : OWN_TAIL,
77
71
  note,
78
72
  ])
79
73
  }
80
74
 
81
- /** Склеить непустые куски пустой строкой между ними. */
75
+ /** Join non-empty blocks with double newlines. */
82
76
  function joinBlocks(parts) {
83
77
  return parts.map((p) => String(p ?? '').trim()).filter(Boolean).join('\n\n')
84
78
  }
@@ -92,23 +86,18 @@ function substitute(template, vars) {
92
86
  }
93
87
 
94
88
  /**
95
- * Рабочая папка сессии — КОРНЕВОЙ checkout проекта, не worktree.
96
- *
97
- * Корневой checkout по воркфлоу только для чтения, и это ровно то место, где
98
- * положено начинать: preflight читает состояние репозитория. Worktree заведёт
99
- * агент.
89
+ * Working directory for session — root checkout of the project, not a worktree.
100
90
  *
101
- * Имя репозитория приходит из данных Gitea, а не из кода, поэтому выход за
102
- * пределы корня отвергается: иначе задача с подделанным именем увела бы сессию
103
- * в произвольный каталог.
91
+ * Root checkout is read-only per workflow guidelines; preflight checks run here.
92
+ * Path traversal outside project root is rejected.
104
93
  *
105
- * @throws если корень не абсолютный либо имя репозитория выводит за его пределы
94
+ * @throws if root is not absolute or repository path escapes project root fence
106
95
  */
107
96
  export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
108
97
  const root = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
109
98
  ? config.defaultProjectRoot.trim()
110
99
  : cwdOf()
111
- if (!isAbsolute(root)) throw new Error('корень проектов должен быть абсолютным путём')
100
+ if (!isAbsolute(root)) throw new Error('project root must be an absolute path')
112
101
 
113
102
  const repo = typeof task?.repo === 'string' ? task.repo.trim() : ''
114
103
  if (repo === '') return root
@@ -117,27 +106,20 @@ export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
117
106
  const fence = resolve(root)
118
107
  const fenceSep = fence.endsWith(sep) || fence.endsWith('/') ? fence : fence + sep
119
108
  if (target !== fence && !target.startsWith(fenceSep) && !target.startsWith(fence.endsWith('/') ? fence : fence + '/')) {
120
- throw new Error('имя репозитория выводит за пределы корня проектов')
109
+ throw new Error('repository name escapes project root')
121
110
  }
122
111
  return target
123
112
  }
124
113
 
125
114
  /**
126
- * Взять агента задачи: живого, возобновлённого или нового.
127
- *
128
- * Правило владельца — **одна задача, один чат**, сколько бы этапов в ней ни
129
- * было. Поэтому новую сессию поднимаем только когда прежней взять неоткуда.
130
- *
131
- * Возобновление не проверяем заранее отдельным вопросом «сохранена ли сессия»:
132
- * такого вопроса у ядра нет, а `resume` сам скажет отказом. Пробуем и ловим.
115
+ * Retrieve agent for task: active, resumed, or newly spawned.
133
116
  *
134
- * @returns {{agent: object, sessionId: string, mode: 'opened'|'resumed'|'created'}}
117
+ * Enforces one chat session per task. Reuses existing session when possible.
135
118
  */
136
119
  export function applyPermission({ permissions, agent, name }) {
137
120
  if (typeof name !== 'string' || name === '') return { skipped: 'not-picked' }
138
- // Служба необязательна: развёртывание может её не собирать. Но если человек
139
- // выбрал уровень доступа, а поставить его нечем — запускать нельзя: сессия
140
- // пошла бы с правами по умолчанию, то есть, возможно, БОЛЬШИМИ, чем просили.
121
+ // Permission service is optional. If permission level is selected but service
122
+ // is unavailable, execution fails rather than running with broader default permissions.
141
123
  if (permissions === undefined || typeof permissions.set !== 'function') {
142
124
  return { error: 'permission-unavailable' }
143
125
  }
@@ -156,17 +138,17 @@ export async function obtainAgent({
156
138
  const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
157
139
 
158
140
  if (known !== undefined) {
159
- // Живой агент — просто берём его.
141
+ // Live agent — reuse active session.
160
142
  let live
161
143
  try { live = agents.get(known) } catch { live = undefined }
162
144
  if (live !== undefined) return { agent: live, sessionId: known, mode: 'opened' }
163
145
 
164
- // Сессия выгружена, но могла сохраниться.
146
+ // Session suspended; attempt resume.
165
147
  try {
166
148
  const handle = await agents.resume({ resumeSessionId: known, agentOptions: { provider, model } })
167
149
  return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'resumed' }
168
150
  } catch (error) {
169
- logger?.warn?.(`dsh-kanban: сессия ${known} не возобновилась: ${error?.message}`)
151
+ logger?.warn?.(`dsh-kanban: session ${known} resume failed: ${error?.message}`)
170
152
  }
171
153
  }
172
154
 
@@ -200,11 +182,11 @@ export async function obtainAgent({
200
182
  }
201
183
  }
202
184
  } catch (err) {
203
- logger?.warn?.(`dsh-kanban: не удалось создать worktree для ${task.id}: ${err.message}`)
185
+ logger?.warn?.(`dsh-kanban: failed to create worktree for ${task.id}: ${err.message}`)
204
186
  }
205
187
  }
206
188
 
207
- // meta.cwd сохраняет projectRoot, чтобы DSH группировал сессию внутри проекта (#227)
189
+ // meta.cwd preserves projectRoot so DSH groups session under the project (#227)
208
190
  const meta = { cwd: projectRoot, projectRoot }
209
191
  if (worktreePath) meta.worktreePath = worktreePath
210
192
  if (branchName) meta.branch = branchName
@@ -213,17 +195,16 @@ export async function obtainAgent({
213
195
 
214
196
  const handle = await agents.create({
215
197
  sessionId: mintSessionId(),
216
- // Профиль — факт создания сессии: у идущей его не сменить, поэтому он
217
- // едет здесь, а не отдельным вызовом после.
198
+ // Profile is set during session creation.
218
199
  meta,
219
200
  agentOptions: { provider, model },
220
201
  })
221
202
  const access = applyPermission({ permissions, agent: handle.agent, name: permission })
222
203
  if (access.error !== undefined) {
223
- // Сессия уже поднята, но работать в ней с чужими правами не дадим.
224
- try { handle.dispose?.() } catch { /* уборка не важнее отказа */ }
225
- logger?.warn?.(`dsh-kanban: уровень доступа не поставлен: ${access.reason ?? access.error}`)
226
- const error = new Error('уровень доступа не поставлен')
204
+ // Session already initialized; preserve configured permissions.
205
+ try { handle.dispose?.() } catch { /* cleanup is secondary to rejection */ }
206
+ logger?.warn?.(`dsh-kanban: access tier not set: ${access.reason ?? access.error}`)
207
+ const error = new Error('access tier not set')
227
208
  error.key = access.error
228
209
  error.status = 409
229
210
  throw error
@@ -240,40 +221,30 @@ export async function obtainAgent({
240
221
  }
241
222
 
242
223
  /**
243
- * Запустить или продолжить работу по задаче.
224
+ * Launch or resume task execution.
244
225
  *
245
- * Порядок важен: сначала берём агента, дожидаемся простоя, отправляем
246
- * сообщение и только потом пишем в хранилище. Запись до запуска оставила бы
247
- * задачу в «В работе» без сессии, если запуск упадёт.
226
+ * Order of operations: acquire agent, wait for idle, send prompt, then update store.
227
+ * Prevents leaving orphaned in-progress cards if agent launch fails.
228
+ * Column moves from backlog to in-progress only on initial launch. Resumed tasks
229
+ * retain their current column (e.g. review).
248
230
  *
249
- * Колонку двигаем ТОЛЬКО при первом запуске из бэклога. Задача в «Ревью», по
250
- * которой нажали «Продолжить», обязана остаться в «Ревью»: продолжение — это
251
- * не откат к началу.
252
- *
253
- * @param {string} [text] сообщение человека; пусто — встроенная заготовка
231
+ * @param {string} [text] user message; defaults to standard template if empty
254
232
  * @returns {{sessionId: string, mode: string, task: object}}
255
233
  */
256
234
  /**
257
- * Что случилось при попытке продолжить работу.
258
- *
259
- * «Продолжил» и «начал заново» — разные вещи, и человек обязан видеть, какая
260
- * из них произошла: в первом случае переписка на месте, во втором её больше
261
- * нет.
235
+ * Mode explanation for task continuation: opened, resumed, or created.
262
236
  *
263
- * @returns {'opened'|'resumed'|'created'} как в obtainAgent
237
+ * @returns {'opened'|'resumed'|'created'} matching obtainAgent mode
264
238
  */
265
239
  export function revivalKind(mode) {
266
240
  return mode === 'opened' || mode === 'resumed' ? mode : 'created'
267
241
  }
268
242
 
269
243
  /**
270
- * Взять задачу на себя при запуске.
244
+ * Claim task upon launch if currently unassigned.
245
+ * Does not overwrite existing assignee.
271
246
  *
272
- * Запустить работу и оставить задачу ничьей — значит показать её свободной,
273
- * когда над ней уже работают. Чужое назначение не трогаем: задачу могли отдать
274
- * другому осознанно.
275
- *
276
- * @returns {string} логин, если назначение случилось; иначе пустая строка
247
+ * @returns {string} assignee login if claimed; otherwise empty string
277
248
  */
278
249
  export function claimOnStart({ store, task, login }) {
279
250
  const who = typeof login === 'string' ? login.trim() : ''
@@ -285,7 +256,7 @@ export function claimOnStart({ store, task, login }) {
285
256
  fromCol: task.column,
286
257
  toCol: task.column,
287
258
  source: 'manual',
288
- detail: `ответственный: ${who} (взял при запуске)`,
259
+ detail: `assigned: ${who} (claimed on launch)`,
289
260
  })
290
261
  return who
291
262
  }
@@ -296,11 +267,11 @@ export async function runTask({
296
267
  agentPreset, permission, permissions, claimAs,
297
268
  gitRunner, workspaceRegistry, resume = false,
298
269
  }) {
299
- // Проверка лимита параллелизма (#214)
270
+ // Concurrency limit check (#214)
300
271
  const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
301
272
  if (maxConcurrent > 0) {
302
273
  const activeCount = countActiveSessions({ store, agents })
303
- // Если задача уже в очереди или это новый запуск, и слоты исчерпаны
274
+ // Check active session slots if task is not already running
304
275
  const isAlreadyRunning = task.sessionId && agents?.get?.(task.sessionId)?.status === 'running'
305
276
  if (!isAlreadyRunning && activeCount >= maxConcurrent) {
306
277
  const now = Date.now()
@@ -310,7 +281,7 @@ export async function runTask({
310
281
  fromCol: task.column,
311
282
  toCol: task.column,
312
283
  source: 'session',
313
- detail: `превышен лимит параллельных сессий (${activeCount}/${maxConcurrent}), задача поставлена в очередь`,
284
+ detail: `concurrent session limit reached (${activeCount}/${maxConcurrent}), task queued`,
314
285
  })
315
286
  return { mode: 'queued', queued: true, taskId: task.id, activeCount, maxConcurrent }
316
287
  }
@@ -320,7 +291,7 @@ export async function runTask({
320
291
  const defaultPerm = config?.sessionDefaultPermission || 'read-only'
321
292
  if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
322
293
  if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
323
- const err = new Error('Требуется подтверждение прав')
294
+ const err = new Error('Permission confirmation required')
324
295
  err.key = 'permission-unconfirmed'
325
296
  err.status = 403
326
297
  throw err
@@ -337,7 +308,7 @@ export async function runTask({
337
308
  store.updateTask(task.id, { queuedAt: 0 })
338
309
  }
339
310
 
340
- // Фиксируем запуск в истории задачи
311
+ // Record run in task execution history
341
312
  if (typeof store.recordTaskRun === 'function') {
342
313
  store.recordTaskRun(task.id, {
343
314
  id: randomUUID(),
@@ -350,8 +321,7 @@ export async function runTask({
350
321
 
351
322
  await taken.agent.whenIdle()
352
323
  taken.agent.followup(createMessage({
353
- // Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
354
- // (`content.some(...)`), и строка роняет ход целиком.
324
+ // Content must be an array of blocks for core message schema.
355
325
  content: [{ type: 'text', text: messageFor(task, config, text, { resume, mode: taken.mode }) }],
356
326
  source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: resume ? 'task-resume' : 'task-start' },
357
327
  }))
@@ -367,8 +337,7 @@ export async function runTask({
367
337
  ...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
368
338
  }
369
339
  store.updateTask(task.id, patch)
370
- // Запустил — значит отвечаешь: свободная задача становится твоей, чужая
371
- // остаётся чужой.
340
+ // Claim task on launch if unassigned.
372
341
  claimOnStart({ store, task: before, login: claimAs })
373
342
 
374
343
  if (resume) {
@@ -377,30 +346,29 @@ export async function runTask({
377
346
  fromCol: before.column,
378
347
  toCol: before.column,
379
348
  source: 'session',
380
- detail: `продолжение работы над задачей (сессия ${taken.sessionId})`,
349
+ detail: `continuing task execution (session ${taken.sessionId})`,
381
350
  })
382
351
  }
383
352
 
384
- // Изоляция сессии в отдельном воркдереве фиксируется в журнале.
353
+ // Record worktree isolation transition in task log.
385
354
  if (taken.worktreeCreated) {
386
355
  store.addTransition({
387
356
  taskId: task.id,
388
357
  fromCol: before.column,
389
358
  toCol: before.column,
390
359
  source: 'session',
391
- detail: `изоляция: создано воркдерево ${taken.worktree} (${taken.branch})`,
360
+ detail: `isolation: created worktree ${taken.worktree} (${taken.branch})`,
392
361
  })
393
362
  }
394
363
 
395
- // Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
396
- // поймёт, куда делась переписка.
364
+ // Log notice if unable to resume previous session and new session had to be spawned.
397
365
  if (taken.mode === 'created' && before.sessionId) {
398
366
  store.addTransition({
399
367
  taskId: task.id,
400
368
  fromCol: before.column,
401
369
  toCol: before.column,
402
370
  source: 'session',
403
- detail: `прежняя сессия не возобновилась, поднята новая: ${taken.sessionId}`,
371
+ detail: `previous session not resumed, spawned new: ${taken.sessionId}`,
404
372
  })
405
373
  }
406
374
 
@@ -412,33 +380,33 @@ export async function runTask({
412
380
  fromCol: before.column,
413
381
  toCol: 'in-progress',
414
382
  source: 'session',
415
- detail: `сессия ${taken.sessionId}, модель ${model}`,
383
+ detail: `session ${taken.sessionId}, model ${model}`,
416
384
  })
417
385
  }
418
386
 
419
387
  return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
420
388
  }
421
389
 
422
- /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
390
+ /** Build prompt text: custom user message if provided, otherwise standard template. */
423
391
  export function messageFor(task, config, text, options = {}) {
424
392
  const own = typeof text === 'string' ? text.trim() : ''
425
393
  let base = own !== '' ? own : buildStartMessage(task, config)
426
394
  let resumePreamble = ''
427
395
  if (options.resume || options.mode === 'resumed') {
428
- const wt = task?.worktree ? ` в существующем воркдереве ${task.worktree}` : ''
429
- const br = task?.branch ? ` на ветке ${task.branch}` : ''
430
- resumePreamble = `### ↻ Режим продолжения работы над задачей\nРабота продолжается${wt}${br}. Сохраняй существующие изменения и продолжай выполнение с текущего состояния.\n\n`
396
+ const wt = task?.worktree ? ` in existing worktree ${task.worktree}` : ''
397
+ const br = task?.branch ? ` on branch ${task.branch}` : ''
398
+ resumePreamble = `### ↻ Resumed task execution\nContinuing work${wt}${br}. Preserve existing changes and proceed from the current state.\n\n`
431
399
  }
432
400
  let qualityNotes = ''
433
401
  if (task?.rejectReason) {
434
- qualityNotes += `### ⚠️ Внимание: Задача возвращена на доработку\nЗамечания к приёмке (причина возврата):\n${task.rejectReason}\n\n`
402
+ qualityNotes += `### ⚠️ Notice: Task returned for rework\nReview notes (rejection reason):\n${task.rejectReason}\n\n`
435
403
  }
436
404
  if (Array.isArray(task?.checklist) && task.checklist.length > 0) {
437
405
  const uncompleted = task.checklist.filter((i) => i.required !== false && !i.completed)
438
406
  if (uncompleted.length > 0) {
439
- qualityNotes += `### Обязательные критерии готовности (DoD):\n`
407
+ qualityNotes += `### Definition of Done (DoD):\n`
440
408
  + uncompleted.map((item, idx) => `${idx + 1}. [ ] ${item.text}`).join('\n')
441
- + `\nДля фиксации выполнения используй инструмент board_checklist(item, evidence).\n\n`
409
+ + `\nUse the board_checklist(item, evidence) tool to record completed items.\n\n`
442
410
  }
443
411
  }
444
412
  if (qualityNotes) {
@@ -463,9 +431,7 @@ export function messageFor(task, config, text, options = {}) {
463
431
  }
464
432
 
465
433
  /**
466
- * Провайдер и модель для запуска: сначала выбор человека, затем модель по
467
- * умолчанию из харнесса. Если не выбрано ни там, ни там — внятный отказ, а не
468
- * падение.
434
+ * Resolve provider and model for launch: user choice first, then harness default.
469
435
  */
470
436
  export function resolveModel({ requested, fallback }) {
471
437
  const provider = requested?.provider || fallback?.provider
@@ -475,32 +441,16 @@ export function resolveModel({ requested, fallback }) {
475
441
  }
476
442
 
477
443
  /**
478
- * Сообщение об одной задаче пачки.
479
- *
480
- * Номер и общее число — не украшение. По воркфлоу агент после задачи делает
481
- * cleanup: удаляет ветку, worktree, закрывает issue. Получив первую из пяти и
482
- * не зная про остальные, он честно объявит работу законченной и приберёт за
483
- * собой — а следующая придёт в уже прибранный чат.
484
- *
485
- * Номер превращает пять отдельных работ в одну из пяти частей.
444
+ * Message prompt for an individual task in a batch execution.
486
445
  */
487
446
  export function buildQueuedMessage(task, config, at, total) {
488
447
  const body = buildStartMessage(task, config)
489
- return total > 1 ? joinBlocks([`Задача ${at} из ${total}.`, body]) : body
448
+ return total > 1 ? joinBlocks([`Task ${at} of ${total}.`, body]) : body
490
449
  }
491
450
 
492
451
  /**
493
- * Запустить пачку задач ОДНОЙ сессией, отдавая их по очереди.
494
- *
495
- * «Разом» не бывает: пачка всегда идёт по одной. Решение владельца, и правило
496
- * лучше выбора — одним переключателем меньше и одним путём в коде меньше.
497
- *
498
- * В работу уезжает ТОЛЬКО первая. Остальные остаются на своих местах с
499
- * отметкой очереди: десять карточек в «В работе», из которых делается одна, —
500
- * это ложь, и предел колонки от неё теряет смысл.
501
- *
502
- * Порядок важен: сперва агент и сообщения, потом хранилище. Запись до запуска
503
- * оставила бы карточки с идентификатором сессии, которой нет.
452
+ * Launch a batch of tasks within a single session, processed sequentially.
453
+ * Only the first task transitions to 'in-progress'; remaining tasks are queued.
504
454
  */
505
455
  export async function runBatch({
506
456
  agents, store, tasks, config, provider, model,
@@ -532,26 +482,23 @@ export async function runBatch({
532
482
  list.forEach((task, at) => {
533
483
  const before = store.getTask(task.id)
534
484
  if (before === undefined) return
535
- // Первая — в работу; остальные ждут очереди там, где лежат. Отметка
536
- // постановки идёт с шагом, чтобы порядок очереди совпал с порядком выбора
537
- // даже когда всё случилось в одну миллисекунду.
485
+ // First task moves to in-progress; remainder are queued with staggered queuedAt.
538
486
  const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
539
487
  store.updateTask(task.id, patch)
540
- // Пачку берёт на себя тот, кто её запустил: пять карточек в работе без
541
- // ответственного выглядят свободными.
488
+ // Claim all tasks in batch under launching user.
542
489
  claimOnStart({ store, task: before, login: claimAs })
543
490
  if (at === 0 && before.column === 'backlog') {
544
491
  store.moveTask(task.id, { column: 'in-progress' })
545
492
  store.addTransition({
546
493
  taskId: task.id, fromCol: before.column, toCol: 'in-progress', source: 'session',
547
494
  detail: list.length > 1
548
- ? `сессия ${taken.sessionId} на ${list.length} задач, модель ${model}`
549
- : `сессия ${taken.sessionId}, модель ${model}`,
495
+ ? `session ${taken.sessionId} for ${list.length} tasks, model ${model}`
496
+ : `session ${taken.sessionId}, model ${model}`,
550
497
  })
551
498
  } else if (at > 0) {
552
499
  store.addTransition({
553
500
  taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
554
- detail: `в очереди сессии ${taken.sessionId}, ${at + 1} из ${list.length}`,
501
+ detail: `in session queue ${taken.sessionId}, ${at + 1} of ${list.length}`,
555
502
  })
556
503
  }
557
504
  started.push(task.id)
@@ -561,11 +508,7 @@ export async function runBatch({
561
508
  }
562
509
 
563
510
  /**
564
- * Поставить задачу в очередь уже идущей сессии.
565
- *
566
- * Своей очереди не заводим: у ядра она есть. `followup` кладёт сообщение, а
567
- * агент берёт его на ближайшей границе шага — то есть закончив текущее.
568
- * Вторая очередь рядом с настоящей разошлась бы с ней при первой же заминке.
511
+ * Queue a task to an active session using core followup queue.
569
512
  */
570
513
  export async function queueTask({
571
514
  agents, store, task, sessionId, config, createMessage, text, now = Date.now(),
@@ -576,8 +519,7 @@ export async function queueTask({
576
519
 
577
520
  let agent
578
521
  try { agent = agents.get(id) } catch { agent = undefined }
579
- // Мёртвой сессии ставить в очередь нечего: сообщение исчезнет, а карточка
580
- // останется привязанной к тому, чего нет.
522
+ // Reject queueing to dead or non-existent session.
581
523
  if (agent === undefined) return { error: 'session-not-live', status: 409 }
582
524
 
583
525
  const own = typeof text === 'string' ? text.trim() : ''
@@ -590,20 +532,14 @@ export async function queueTask({
590
532
  store.updateTask(task.id, { sessionId: id, queuedAt: now })
591
533
  store.addTransition({
592
534
  taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
593
- detail: `поставлена в очередь сессии ${id}`,
535
+ detail: `queued for session ${id}`,
594
536
  })
595
537
  return { task: store.getTask(task.id), sessionId: id }
596
538
  }
597
539
 
598
540
  /**
599
- * Снять задачу с очереди.
600
- *
601
- * Обязательная пара к постановке: если поставить можно, а передумать нельзя,
602
- * человек перестанет пользоваться и постановкой.
603
- *
604
- * Сообщение из очереди ядра не выдернуть — оно уже отдано. Поэтому снимаем
605
- * привязку и честно говорим об этом в журнале: агент может дойти до задачи и
606
- * увидеть карточку, которая его больше не ждёт.
541
+ * Remove task from queue.
542
+ * Clears queue association in store and logs note that message was already sent to agent.
607
543
  */
608
544
  export function unqueueTask({ store, task }) {
609
545
  if (task === undefined) return { error: 'task-not-found', status: 404 }
@@ -613,16 +549,13 @@ export function unqueueTask({ store, task }) {
613
549
  store.updateTask(task.id, { queuedAt: 0, sessionId: null })
614
550
  store.addTransition({
615
551
  taskId: task.id, fromCol: task.column, toCol: task.column, source: 'manual',
616
- detail: 'снята с очереди; сообщение агенту уже отдано и отозвать его нечем',
552
+ detail: 'removed from queue; message already sent to agent',
617
553
  })
618
554
  return { task: store.getTask(task.id) }
619
555
  }
620
556
 
621
557
  /**
622
- * Живые сессии доски: куда вообще можно поставить задачу.
623
- *
624
- * Сессия без живого агента в список не попадает — предлагать её значило бы
625
- * предлагать очередь, из которой никто ничего не возьмёт.
558
+ * Query active sessions eligible for queueing.
626
559
  */
627
560
  export function countActiveSessions({ store, agents }) {
628
561
  const sessions = liveSessions({ store, agents })
@@ -638,7 +571,7 @@ export function liveSessions({ store, agents }) {
638
571
  let live
639
572
  try { live = agents.get(id) } catch { live = undefined }
640
573
  if (live === undefined) continue
641
- // Исключаем дочерние сессии субагентов (#215)
574
+ // Exclude child subagent sessions (#215)
642
575
  if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
643
576
  continue
644
577
  }
@@ -652,7 +585,7 @@ export function liveSessions({ store, agents }) {
652
585
  }
653
586
 
654
587
  /**
655
- * Автоматический запуск следующей задачи из очереди при освобождении слота (#214).
588
+ * Auto-dispatch next queued task when a concurrency slot opens (#214).
656
589
  */
657
590
  export async function dispatchNextQueuedTask({
658
591
  agents, store, config, mintSessionId, createMessage, cwdOf, logger,
@@ -688,7 +621,7 @@ export async function dispatchNextQueuedTask({
688
621
  gitRunner,
689
622
  })
690
623
  } catch (err) {
691
- logger?.warn?.(`dsh-kanban: не удалось запустить задачу ${nextTask.id} из очереди: ${err?.message}`)
624
+ logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
692
625
  return null
693
626
  }
694
627
  }