@goodandready/dsh-kanban 0.2.19 → 0.2.21

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
- * было. Поэтому новую сессию поднимаем только когда прежней взять неоткуда.
115
+ * Retrieve agent for task: active, resumed, or newly spawned.
130
116
  *
131
- * Возобновление не проверяем заранее отдельным вопросом «сохранена ли сессия»:
132
- * такого вопроса у ядра нет, а `resume` сам скажет отказом. Пробуем и ловим.
133
- *
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
- * Запустить или продолжить работу по задаче.
244
- *
245
- * Порядок важен: сначала берём агента, дожидаемся простоя, отправляем
246
- * сообщение и только потом пишем в хранилище. Запись до запуска оставила бы
247
- * задачу в «В работе» без сессии, если запуск упадёт.
224
+ * Launch or resume task execution.
248
225
  *
249
- * Колонку двигаем ТОЛЬКО при первом запуске из бэклога. Задача в «Ревью», по
250
- * которой нажали «Продолжить», обязана остаться в «Ревью»: продолжение — это
251
- * не откат к началу.
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).
252
230
  *
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
- * Что случилось при попытке продолжить работу.
235
+ * Mode explanation for task continuation: opened, resumed, or created.
258
236
  *
259
- * «Продолжил» и «начал заново» — разные вещи, и человек обязан видеть, какая
260
- * из них произошла: в первом случае переписка на месте, во втором её больше
261
- * нет.
262
- *
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
- * Взять задачу на себя при запуске.
271
- *
272
- * Запустить работу и оставить задачу ничьей — значит показать её свободной,
273
- * когда над ней уже работают. Чужое назначение не трогаем: задачу могли отдать
274
- * другому осознанно.
244
+ * Claim task upon launch if currently unassigned.
245
+ * Does not overwrite existing assignee.
275
246
  *
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
  }
@@ -318,9 +289,11 @@ export async function runTask({
318
289
  if (config?.permissionGateEnabled) {
319
290
  const perm = permission || task.permission || 'read-only'
320
291
  const defaultPerm = config?.sessionDefaultPermission || 'read-only'
321
- if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
292
+ const permRank = PERMISSION_RANKS[perm] ?? Infinity
293
+ const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
294
+ if (permRank > defaultRank) {
322
295
  if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
323
- const err = new Error('Требуется подтверждение прав')
296
+ const err = new Error('Permission confirmation required')
324
297
  err.key = 'permission-unconfirmed'
325
298
  err.status = 403
326
299
  throw err
@@ -337,7 +310,7 @@ export async function runTask({
337
310
  store.updateTask(task.id, { queuedAt: 0 })
338
311
  }
339
312
 
340
- // Фиксируем запуск в истории задачи
313
+ // Record run in task execution history
341
314
  if (typeof store.recordTaskRun === 'function') {
342
315
  store.recordTaskRun(task.id, {
343
316
  id: randomUUID(),
@@ -350,8 +323,7 @@ export async function runTask({
350
323
 
351
324
  await taken.agent.whenIdle()
352
325
  taken.agent.followup(createMessage({
353
- // Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
354
- // (`content.some(...)`), и строка роняет ход целиком.
326
+ // Content must be an array of blocks for core message schema.
355
327
  content: [{ type: 'text', text: messageFor(task, config, text, { resume, mode: taken.mode }) }],
356
328
  source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: resume ? 'task-resume' : 'task-start' },
357
329
  }))
@@ -367,8 +339,7 @@ export async function runTask({
367
339
  ...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
368
340
  }
369
341
  store.updateTask(task.id, patch)
370
- // Запустил — значит отвечаешь: свободная задача становится твоей, чужая
371
- // остаётся чужой.
342
+ // Claim task on launch if unassigned.
372
343
  claimOnStart({ store, task: before, login: claimAs })
373
344
 
374
345
  if (resume) {
@@ -377,30 +348,29 @@ export async function runTask({
377
348
  fromCol: before.column,
378
349
  toCol: before.column,
379
350
  source: 'session',
380
- detail: `продолжение работы над задачей (сессия ${taken.sessionId})`,
351
+ detail: `continuing task execution (session ${taken.sessionId})`,
381
352
  })
382
353
  }
383
354
 
384
- // Изоляция сессии в отдельном воркдереве фиксируется в журнале.
355
+ // Record worktree isolation transition in task log.
385
356
  if (taken.worktreeCreated) {
386
357
  store.addTransition({
387
358
  taskId: task.id,
388
359
  fromCol: before.column,
389
360
  toCol: before.column,
390
361
  source: 'session',
391
- detail: `изоляция: создано воркдерево ${taken.worktree} (${taken.branch})`,
362
+ detail: `isolation: created worktree ${taken.worktree} (${taken.branch})`,
392
363
  })
393
364
  }
394
365
 
395
- // Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
396
- // поймёт, куда делась переписка.
366
+ // Log notice if unable to resume previous session and new session had to be spawned.
397
367
  if (taken.mode === 'created' && before.sessionId) {
398
368
  store.addTransition({
399
369
  taskId: task.id,
400
370
  fromCol: before.column,
401
371
  toCol: before.column,
402
372
  source: 'session',
403
- detail: `прежняя сессия не возобновилась, поднята новая: ${taken.sessionId}`,
373
+ detail: `previous session not resumed, spawned new: ${taken.sessionId}`,
404
374
  })
405
375
  }
406
376
 
@@ -412,33 +382,33 @@ export async function runTask({
412
382
  fromCol: before.column,
413
383
  toCol: 'in-progress',
414
384
  source: 'session',
415
- detail: `сессия ${taken.sessionId}, модель ${model}`,
385
+ detail: `session ${taken.sessionId}, model ${model}`,
416
386
  })
417
387
  }
418
388
 
419
389
  return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
420
390
  }
421
391
 
422
- /** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
392
+ /** Build prompt text: custom user message if provided, otherwise standard template. */
423
393
  export function messageFor(task, config, text, options = {}) {
424
394
  const own = typeof text === 'string' ? text.trim() : ''
425
395
  let base = own !== '' ? own : buildStartMessage(task, config)
426
396
  let resumePreamble = ''
427
397
  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`
398
+ const wt = task?.worktree ? ` in existing worktree ${task.worktree}` : ''
399
+ const br = task?.branch ? ` on branch ${task.branch}` : ''
400
+ resumePreamble = `### ↻ Resumed task execution\nContinuing work${wt}${br}. Preserve existing changes and proceed from the current state.\n\n`
431
401
  }
432
402
  let qualityNotes = ''
433
403
  if (task?.rejectReason) {
434
- qualityNotes += `### ⚠️ Внимание: Задача возвращена на доработку\nЗамечания к приёмке (причина возврата):\n${task.rejectReason}\n\n`
404
+ qualityNotes += `### ⚠️ Notice: Task returned for rework\nReview notes (rejection reason):\n${task.rejectReason}\n\n`
435
405
  }
436
406
  if (Array.isArray(task?.checklist) && task.checklist.length > 0) {
437
407
  const uncompleted = task.checklist.filter((i) => i.required !== false && !i.completed)
438
408
  if (uncompleted.length > 0) {
439
- qualityNotes += `### Обязательные критерии готовности (DoD):\n`
409
+ qualityNotes += `### Definition of Done (DoD):\n`
440
410
  + uncompleted.map((item, idx) => `${idx + 1}. [ ] ${item.text}`).join('\n')
441
- + `\nДля фиксации выполнения используй инструмент board_checklist(item, evidence).\n\n`
411
+ + `\nUse the board_checklist(item, evidence) tool to record completed items.\n\n`
442
412
  }
443
413
  }
444
414
  if (qualityNotes) {
@@ -463,9 +433,7 @@ export function messageFor(task, config, text, options = {}) {
463
433
  }
464
434
 
465
435
  /**
466
- * Провайдер и модель для запуска: сначала выбор человека, затем модель по
467
- * умолчанию из харнесса. Если не выбрано ни там, ни там — внятный отказ, а не
468
- * падение.
436
+ * Resolve provider and model for launch: user choice first, then harness default.
469
437
  */
470
438
  export function resolveModel({ requested, fallback }) {
471
439
  const provider = requested?.provider || fallback?.provider
@@ -475,32 +443,16 @@ export function resolveModel({ requested, fallback }) {
475
443
  }
476
444
 
477
445
  /**
478
- * Сообщение об одной задаче пачки.
479
- *
480
- * Номер и общее число — не украшение. По воркфлоу агент после задачи делает
481
- * cleanup: удаляет ветку, worktree, закрывает issue. Получив первую из пяти и
482
- * не зная про остальные, он честно объявит работу законченной и приберёт за
483
- * собой — а следующая придёт в уже прибранный чат.
484
- *
485
- * Номер превращает пять отдельных работ в одну из пяти частей.
446
+ * Message prompt for an individual task in a batch execution.
486
447
  */
487
448
  export function buildQueuedMessage(task, config, at, total) {
488
449
  const body = buildStartMessage(task, config)
489
- return total > 1 ? joinBlocks([`Задача ${at} из ${total}.`, body]) : body
450
+ return total > 1 ? joinBlocks([`Task ${at} of ${total}.`, body]) : body
490
451
  }
491
452
 
492
453
  /**
493
- * Запустить пачку задач ОДНОЙ сессией, отдавая их по очереди.
494
- *
495
- * «Разом» не бывает: пачка всегда идёт по одной. Решение владельца, и правило
496
- * лучше выбора — одним переключателем меньше и одним путём в коде меньше.
497
- *
498
- * В работу уезжает ТОЛЬКО первая. Остальные остаются на своих местах с
499
- * отметкой очереди: десять карточек в «В работе», из которых делается одна, —
500
- * это ложь, и предел колонки от неё теряет смысл.
501
- *
502
- * Порядок важен: сперва агент и сообщения, потом хранилище. Запись до запуска
503
- * оставила бы карточки с идентификатором сессии, которой нет.
454
+ * Launch a batch of tasks within a single session, processed sequentially.
455
+ * Only the first task transitions to 'in-progress'; remaining tasks are queued.
504
456
  */
505
457
  export async function runBatch({
506
458
  agents, store, tasks, config, provider, model,
@@ -523,35 +475,34 @@ export async function runBatch({
523
475
  }))
524
476
 
525
477
  if (own !== '' && list.length === 1) {
526
- send(own, 'task-start')
478
+ await send(own, 'task-start')
527
479
  } else {
528
- list.forEach((task, at) => send(buildQueuedMessage(task, config, at + 1, list.length), 'batch-queued'))
480
+ for (let at = 0; at < list.length; at++) {
481
+ await send(buildQueuedMessage(list[at], config, at + 1, list.length), 'batch-queued')
482
+ }
529
483
  }
530
484
 
531
485
  const started = []
532
486
  list.forEach((task, at) => {
533
487
  const before = store.getTask(task.id)
534
488
  if (before === undefined) return
535
- // Первая — в работу; остальные ждут очереди там, где лежат. Отметка
536
- // постановки идёт с шагом, чтобы порядок очереди совпал с порядком выбора
537
- // даже когда всё случилось в одну миллисекунду.
489
+ // First task moves to in-progress; remainder are queued with staggered queuedAt.
538
490
  const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
539
491
  store.updateTask(task.id, patch)
540
- // Пачку берёт на себя тот, кто её запустил: пять карточек в работе без
541
- // ответственного выглядят свободными.
492
+ // Claim all tasks in batch under launching user.
542
493
  claimOnStart({ store, task: before, login: claimAs })
543
494
  if (at === 0 && before.column === 'backlog') {
544
495
  store.moveTask(task.id, { column: 'in-progress' })
545
496
  store.addTransition({
546
497
  taskId: task.id, fromCol: before.column, toCol: 'in-progress', source: 'session',
547
498
  detail: list.length > 1
548
- ? `сессия ${taken.sessionId} на ${list.length} задач, модель ${model}`
549
- : `сессия ${taken.sessionId}, модель ${model}`,
499
+ ? `session ${taken.sessionId} for ${list.length} tasks, model ${model}`
500
+ : `session ${taken.sessionId}, model ${model}`,
550
501
  })
551
502
  } else if (at > 0) {
552
503
  store.addTransition({
553
504
  taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
554
- detail: `в очереди сессии ${taken.sessionId}, ${at + 1} из ${list.length}`,
505
+ detail: `in session queue ${taken.sessionId}, ${at + 1} of ${list.length}`,
555
506
  })
556
507
  }
557
508
  started.push(task.id)
@@ -561,11 +512,7 @@ export async function runBatch({
561
512
  }
562
513
 
563
514
  /**
564
- * Поставить задачу в очередь уже идущей сессии.
565
- *
566
- * Своей очереди не заводим: у ядра она есть. `followup` кладёт сообщение, а
567
- * агент берёт его на ближайшей границе шага — то есть закончив текущее.
568
- * Вторая очередь рядом с настоящей разошлась бы с ней при первой же заминке.
515
+ * Queue a task to an active session using core followup queue.
569
516
  */
570
517
  export async function queueTask({
571
518
  agents, store, task, sessionId, config, createMessage, text, now = Date.now(),
@@ -576,8 +523,7 @@ export async function queueTask({
576
523
 
577
524
  let agent
578
525
  try { agent = agents.get(id) } catch { agent = undefined }
579
- // Мёртвой сессии ставить в очередь нечего: сообщение исчезнет, а карточка
580
- // останется привязанной к тому, чего нет.
526
+ // Reject queueing to dead or non-existent session.
581
527
  if (agent === undefined) return { error: 'session-not-live', status: 409 }
582
528
 
583
529
  const own = typeof text === 'string' ? text.trim() : ''
@@ -590,20 +536,14 @@ export async function queueTask({
590
536
  store.updateTask(task.id, { sessionId: id, queuedAt: now })
591
537
  store.addTransition({
592
538
  taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
593
- detail: `поставлена в очередь сессии ${id}`,
539
+ detail: `queued for session ${id}`,
594
540
  })
595
541
  return { task: store.getTask(task.id), sessionId: id }
596
542
  }
597
543
 
598
544
  /**
599
- * Снять задачу с очереди.
600
- *
601
- * Обязательная пара к постановке: если поставить можно, а передумать нельзя,
602
- * человек перестанет пользоваться и постановкой.
603
- *
604
- * Сообщение из очереди ядра не выдернуть — оно уже отдано. Поэтому снимаем
605
- * привязку и честно говорим об этом в журнале: агент может дойти до задачи и
606
- * увидеть карточку, которая его больше не ждёт.
545
+ * Remove task from queue.
546
+ * Clears queue association in store and logs note that message was already sent to agent.
607
547
  */
608
548
  export function unqueueTask({ store, task }) {
609
549
  if (task === undefined) return { error: 'task-not-found', status: 404 }
@@ -613,16 +553,13 @@ export function unqueueTask({ store, task }) {
613
553
  store.updateTask(task.id, { queuedAt: 0, sessionId: null })
614
554
  store.addTransition({
615
555
  taskId: task.id, fromCol: task.column, toCol: task.column, source: 'manual',
616
- detail: 'снята с очереди; сообщение агенту уже отдано и отозвать его нечем',
556
+ detail: 'removed from queue; message already sent to agent',
617
557
  })
618
558
  return { task: store.getTask(task.id) }
619
559
  }
620
560
 
621
561
  /**
622
- * Живые сессии доски: куда вообще можно поставить задачу.
623
- *
624
- * Сессия без живого агента в список не попадает — предлагать её значило бы
625
- * предлагать очередь, из которой никто ничего не возьмёт.
562
+ * Query active sessions eligible for queueing.
626
563
  */
627
564
  export function countActiveSessions({ store, agents }) {
628
565
  const sessions = liveSessions({ store, agents })
@@ -638,7 +575,7 @@ export function liveSessions({ store, agents }) {
638
575
  let live
639
576
  try { live = agents.get(id) } catch { live = undefined }
640
577
  if (live === undefined) continue
641
- // Исключаем дочерние сессии субагентов (#215)
578
+ // Exclude child subagent sessions (#215)
642
579
  if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
643
580
  continue
644
581
  }
@@ -652,7 +589,7 @@ export function liveSessions({ store, agents }) {
652
589
  }
653
590
 
654
591
  /**
655
- * Автоматический запуск следующей задачи из очереди при освобождении слота (#214).
592
+ * Auto-dispatch next queued task when a concurrency slot opens (#214).
656
593
  */
657
594
  export async function dispatchNextQueuedTask({
658
595
  agents, store, config, mintSessionId, createMessage, cwdOf, logger,
@@ -688,7 +625,7 @@ export async function dispatchNextQueuedTask({
688
625
  gitRunner,
689
626
  })
690
627
  } catch (err) {
691
- logger?.warn?.(`dsh-kanban: не удалось запустить задачу ${nextTask.id} из очереди: ${err?.message}`)
628
+ logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
692
629
  return null
693
630
  }
694
631
  }