@goodandready/dsh-kanban 0.2.34 → 0.2.36

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.
@@ -0,0 +1,204 @@
1
+ import { computeNextRun } from '../cron.js'
2
+ import { PERMISSION_RANKS, resolveModel, runTask } from '../launcher.js'
3
+ import { createOutbox, planOutbound } from '../outbound.js'
4
+ import { createSyncState, syncAll, intakeAll, archiveOverdue } from '../sync.js'
5
+ import { SessionId } from '@deepseek-ai/dsh-session'
6
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
7
+ import { randomUUID } from 'node:crypto'
8
+
9
+ export function registerKanbanTimers(ctx, deps) {
10
+ const {
11
+ store,
12
+ getConfig,
13
+ eventHub,
14
+ syncPower,
15
+ git,
16
+ gitea,
17
+ github,
18
+ rearmers,
19
+ watchdog,
20
+ } = deps
21
+
22
+ ctx.effect(() => {
23
+ let cronTimer
24
+ const armCron = () => {
25
+ clearInterval(cronTimer)
26
+ cronTimer = undefined
27
+ if (getConfig().cronEnabled !== true) return
28
+ cronTimer = setInterval(async () => {
29
+ try {
30
+ if (!store) return
31
+ const now = Date.now()
32
+ const dueTasks = store.listDueTasks(now)
33
+ for (const task of dueTasks) {
34
+ if (task.column === 'in-progress') {
35
+ const next = computeNextRun(task.cron, now)
36
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
37
+ continue
38
+ }
39
+ if (getConfig().permissionGateEnabled) {
40
+ const perm = task.permission || 'read-only'
41
+ const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
42
+ if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
43
+ const next = computeNextRun(task.cron, now)
44
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
45
+ store.addTransition({
46
+ taskId: task.id,
47
+ fromCol: task.column,
48
+ toCol: task.column,
49
+ source: 'tool',
50
+ detail: 'cron: run rejected (elevated permission required)',
51
+ })
52
+ continue
53
+ }
54
+ }
55
+ const picked = resolveModel({
56
+ requested: { model: task.model, provider: task.provider },
57
+ fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
58
+ })
59
+ if (picked.model) {
60
+ await runTask({
61
+ agents: ctx.agents,
62
+ store,
63
+ task,
64
+ config: getConfig(),
65
+ provider: picked.provider,
66
+ model: picked.model,
67
+ mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
68
+ createMessage: createUserMessage,
69
+ logger: ctx.logger,
70
+ workspaceRegistry: ctx.get('workspaceRegistry'),
71
+ claimAs: 'cron',
72
+ })
73
+ syncPower()
74
+ }
75
+ const next = computeNextRun(task.cron, now)
76
+ store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
77
+ }
78
+ } catch (err) {
79
+ ctx.logger?.warn?.(`dsh-kanban: cron scheduler error: ${err?.message}`)
80
+ }
81
+ }, 30_000)
82
+ if (typeof cronTimer.unref === 'function') cronTimer.unref()
83
+ }
84
+ armCron()
85
+ rearmers.add(armCron)
86
+ return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
87
+ }, 'dsh-kanban: cron scheduler')
88
+
89
+ const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
90
+
91
+ const queueOutbound = (taskId, column) => {
92
+ if (getConfig().pushToGitea !== true) return
93
+ const task = store.getTask(taskId)
94
+ if (task === undefined) return
95
+ const ops = planOutbound(task, column, getConfig())
96
+ if (ops.length > 0) outbox.push(taskId, ops)
97
+ }
98
+
99
+ let discoveredOwner
100
+ const defaultOwner = async () => {
101
+ const named = String(getConfig().giteaOwner ?? '').trim()
102
+ if (named !== '') return named
103
+ for (const task of store.listWatchable()) {
104
+ if (typeof task.owner === 'string' && task.owner !== '') return task.owner
105
+ }
106
+ if (discoveredOwner !== undefined) return discoveredOwner
107
+ const orgs = await gitea.listOrgs()
108
+ if (orgs.length === 1) { discoveredOwner = orgs[0]; return discoveredOwner }
109
+ return undefined
110
+ }
111
+
112
+ const syncState = createSyncState()
113
+
114
+ let syncing = false
115
+ const runSync = async () => {
116
+ if (syncing) return { skipped: true }
117
+ syncing = true
118
+ syncState.started()
119
+ try {
120
+ const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
121
+ const providersToSync = []
122
+ if (mode === 'github') {
123
+ providersToSync.push(github)
124
+ } else if (mode === 'gitea') {
125
+ providersToSync.push(gitea)
126
+ } else {
127
+ if (await gitea.isConfigured()) providersToSync.push(gitea)
128
+ if (await github.isConfigured()) providersToSync.push(github)
129
+ if (providersToSync.length === 0) providersToSync.push(gitea)
130
+ }
131
+
132
+ let taken = { added: 0, skipped: 0, failed: 0 }
133
+ for (const p of providersToSync) {
134
+ const owner = p === github
135
+ ? (String(getConfig()?.githubOwner ?? '').trim() || await p.me().catch(() => ''))
136
+ : await defaultOwner()
137
+ const res = await intakeAll({
138
+ gitea: p, store, config: getConfig(), owner, logger: ctx.logger,
139
+ })
140
+ if (res && typeof res.added === 'number') {
141
+ taken.added = (taken.added || 0) + res.added
142
+ taken.skipped = (taken.skipped || 0) + (res.skipped || 0)
143
+ taken.failed = (taken.failed || 0) + (res.failed || 0)
144
+ }
145
+ if (res?.problem) taken.problem = res.problem
146
+ }
147
+ const out = await syncAll({ gitea: git, store, logger: ctx.logger })
148
+ const tidied = archiveOverdue({ store, config: getConfig() })
149
+ const pushed = await outbox.flush()
150
+ syncState.finished(out.problem === undefined && taken.problem !== undefined
151
+ ? { ...out, problem: taken.problem }
152
+ : out)
153
+ return { ...out, pushed, taken, tidied }
154
+ } catch (error) {
155
+ syncState.failedToStart('sync', error?.message)
156
+ throw error
157
+ } finally {
158
+ syncing = false
159
+ }
160
+ }
161
+
162
+ ctx.effect(() => {
163
+ let timer
164
+ const arm = () => {
165
+ const seconds = Number(getConfig().syncIntervalSec) || 0
166
+ clearInterval(timer)
167
+ timer = undefined
168
+ if (seconds <= 0) return
169
+ timer = setInterval(() => { runSync().catch(() => {}) }, Math.max(seconds, 15) * 1000)
170
+ if (typeof timer.unref === 'function') timer.unref()
171
+ }
172
+ arm()
173
+ rearmers.add(arm)
174
+ return () => { rearmers.delete(arm); clearInterval(timer) }
175
+ }, 'dsh-kanban: sync timer')
176
+
177
+ ctx.effect(() => {
178
+ let watchdogTimer
179
+ const armWatchdog = () => {
180
+ clearInterval(watchdogTimer)
181
+ watchdogTimer = undefined
182
+ if (getConfig().watchdogEnabled === false || !watchdog) return
183
+ watchdogTimer = setInterval(() => {
184
+ try {
185
+ watchdog.checkTasks({
186
+ store,
187
+ agents: ctx.agents,
188
+ config: getConfig(),
189
+ eventHub,
190
+ logger: ctx.logger,
191
+ })
192
+ } catch (err) {
193
+ ctx.logger?.warn?.(`dsh-kanban: watchdog check error: ${err?.message}`)
194
+ }
195
+ }, 15000)
196
+ if (typeof watchdogTimer.unref === 'function') watchdogTimer.unref()
197
+ }
198
+ armWatchdog()
199
+ rearmers.add(armWatchdog)
200
+ return () => { rearmers.delete(armWatchdog); clearInterval(watchdogTimer) }
201
+ }, 'dsh-kanban: watchdog timer')
202
+
203
+ return { syncState, runSync, defaultOwner, queueOutbound }
204
+ }
package/lib/store.js CHANGED
@@ -76,6 +76,11 @@ CREATE TABLE IF NOT EXISTS tasks (
76
76
  forge TEXT NOT NULL DEFAULT '',
77
77
  permissionConfirmedTier TEXT NOT NULL DEFAULT '',
78
78
  queuedRequest TEXT NOT NULL DEFAULT '',
79
+ stalled INTEGER NOT NULL DEFAULT 0,
80
+ stalledReason TEXT NOT NULL DEFAULT '',
81
+ stalledAt INTEGER NOT NULL DEFAULT 0,
82
+ blockedBy TEXT NOT NULL DEFAULT '[]',
83
+ inceptionSnapshotId TEXT NOT NULL DEFAULT '',
79
84
  updatedAt INTEGER NOT NULL
80
85
  );
81
86
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -110,14 +115,16 @@ const WRITABLE = [
110
115
  'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
111
116
  'verificationCommand', 'verificationStatus',
112
117
  'costMetrics', 'costLimitExceeded',
118
+ 'stalled', 'stalledReason', 'stalledAt',
119
+ 'blockedBy', 'inceptionSnapshotId',
113
120
  ]
114
121
 
115
122
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
116
- const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow'])
123
+ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow', 'watchdog'])
117
124
 
118
125
  function rowToTask(row) {
119
126
  if (row === undefined) return undefined
120
- const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, ...rest } = row
127
+ const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, blockedBy, inceptionSnapshotId, ...rest } = row
121
128
  let parsedChecklist = []
122
129
  try {
123
130
  const value = JSON.parse(checklist ?? '[]')
@@ -173,6 +180,13 @@ function rowToTask(row) {
173
180
  return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
174
181
  })(),
175
182
  costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
183
+ blockedBy: (() => {
184
+ try {
185
+ const val = JSON.parse(blockedBy ?? '[]')
186
+ return Array.isArray(val) ? val.filter((x) => typeof x === 'string') : []
187
+ } catch { return [] }
188
+ })(),
189
+ inceptionSnapshotId: typeof inceptionSnapshotId === 'string' ? inceptionSnapshotId : (typeof rest.inceptionSnapshotId === 'string' ? rest.inceptionSnapshotId : ''),
176
190
  }
177
191
  }
178
192
 
@@ -265,6 +279,11 @@ export function openStore({ dir }) {
265
279
  ['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
266
280
  ['costMetrics', "TEXT NOT NULL DEFAULT ''"],
267
281
  ['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
282
+ ['stalled', 'INTEGER NOT NULL DEFAULT 0'],
283
+ ['stalledReason', "TEXT NOT NULL DEFAULT ''"],
284
+ ['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
285
+ ['blockedBy', "TEXT NOT NULL DEFAULT '[]'"],
286
+ ['inceptionSnapshotId', "TEXT NOT NULL DEFAULT ''"],
268
287
  ]) {
269
288
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
270
289
  }
@@ -607,6 +626,8 @@ export function openStore({ dir }) {
607
626
  else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
608
627
  else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
609
628
  else if (key === 'parentId') values.push(String(patch[key] ?? ''))
629
+ else if (key === 'blockedBy') values.push(Array.isArray(patch[key]) ? JSON.stringify(patch[key]) : (typeof patch[key] === 'string' ? patch[key] : '[]'))
630
+ else if (key === 'inceptionSnapshotId') values.push(String(patch[key] ?? ''))
610
631
  else values.push(patch[key] ?? null)
611
632
  }
612
633
  if (fields.length > 0) {
@@ -0,0 +1,214 @@
1
+ // Integration service for @goodandready/dsh-time-machine snapshot and rollback capabilities.
2
+ // Provides automatic inception snapshots on task start and one-click rollback to clean state.
3
+ // Degrades gracefully when @goodandready/dsh-time-machine plugin is not installed in DSH profile.
4
+
5
+ import { resolveTaskWorktreePath } from './worktree.js'
6
+
7
+ /**
8
+ * Check whether timeMachineEngine is available in the Cordis context.
9
+ *
10
+ * @param {object} ctx Cordis context or harness
11
+ * @returns {boolean} true if time machine engine is available
12
+ */
13
+ export function isTimeMachineAvailable(ctx) {
14
+ if (!ctx || typeof ctx !== 'object') return false
15
+ const engine = ctx.timeMachineEngine || (typeof ctx.get === 'function' ? (ctx.get('timeMachineEngine') || ctx.get('timeMachine')) : null)
16
+ return !!(engine && (typeof engine.createSnapshot === 'function' || typeof engine.rollbackSnapshot === 'function' || typeof engine.listSnapshots === 'function'))
17
+ }
18
+
19
+ /**
20
+ * Resolve the timeMachineEngine instance from context.
21
+ *
22
+ * @param {object} ctx Cordis context
23
+ * @returns {object|null}
24
+ */
25
+ export function getTimeMachineEngine(ctx) {
26
+ if (!ctx || typeof ctx !== 'object') return null
27
+ const engine = ctx.timeMachineEngine || (typeof ctx.get === 'function' ? (ctx.get('timeMachineEngine') || ctx.get('timeMachine')) : null)
28
+ if (engine && (typeof engine.createSnapshot === 'function' || typeof engine.rollbackSnapshot === 'function' || typeof engine.listSnapshots === 'function')) {
29
+ return engine
30
+ }
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Resolve the directory path to snapshot for a task.
36
+ *
37
+ * @param {{task: object, config?: object, env?: object, cwdOf?: Function}} options
38
+ * @returns {string}
39
+ */
40
+ export function resolveSnapshotDir({ task, config, env, cwdOf }) {
41
+ if (task?.worktree && typeof task.worktree === 'string' && task.worktree.trim() !== '') {
42
+ return task.worktree.trim()
43
+ }
44
+ try {
45
+ const resolved = resolveTaskWorktreePath({ task, config, env, cwdOf: cwdOf || (() => process.cwd()) })
46
+ if (resolved) return resolved
47
+ } catch {
48
+ /* fallback to cwd */
49
+ }
50
+ return typeof cwdOf === 'function' ? cwdOf() : process.cwd()
51
+ }
52
+
53
+ /**
54
+ * Automatically create inception snapshot when task moves to in-progress.
55
+ *
56
+ * @param {{ctx: object, task: object, config?: object, env?: object, cwdOf?: Function, store?: object, logger?: object}} options
57
+ * @returns {Promise<{available: boolean, created: boolean, snapshotId?: string, alreadyExists?: boolean, snapshot?: object, error?: string}>}
58
+ */
59
+ export async function createInceptionSnapshot({ ctx, task, config, env, cwdOf, store, logger }) {
60
+ const engine = getTimeMachineEngine(ctx)
61
+ if (!engine) {
62
+ return { available: false, created: false }
63
+ }
64
+
65
+ if (task?.inceptionSnapshotId && typeof task.inceptionSnapshotId === 'string' && task.inceptionSnapshotId.trim() !== '') {
66
+ return {
67
+ available: true,
68
+ created: false,
69
+ alreadyExists: true,
70
+ snapshotId: task.inceptionSnapshotId.trim(),
71
+ }
72
+ }
73
+
74
+ const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
75
+ const label = `Inception: ${task?.id || 'task'} (${task?.title || 'untitled'})`
76
+ const sessionId = `task-${task?.id || 'default'}`
77
+
78
+ try {
79
+ const snapshot = await engine.createSnapshot(label, {
80
+ sessionId,
81
+ cwd: targetDir,
82
+ skipIfNoChanges: false,
83
+ })
84
+
85
+ if (snapshot?.id && store && typeof store.updateTask === 'function' && task?.id) {
86
+ store.updateTask(task.id, { inceptionSnapshotId: snapshot.id })
87
+ }
88
+
89
+ return {
90
+ available: true,
91
+ created: true,
92
+ snapshotId: snapshot?.id,
93
+ snapshot,
94
+ }
95
+ } catch (err) {
96
+ logger?.warn?.('[dsh-kanban] Failed to create inception snapshot:', err?.message || err)
97
+ return {
98
+ available: true,
99
+ created: false,
100
+ error: err?.message || String(err),
101
+ }
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Manually create a checkpoint snapshot for a task.
107
+ *
108
+ * @param {{ctx: object, task: object, label?: string, config?: object, env?: object, cwdOf?: Function, logger?: object}} options
109
+ * @returns {Promise<{available: boolean, created: boolean, snapshot?: object, error?: string}>}
110
+ */
111
+ export async function createCheckpointSnapshot({ ctx, task, label, config, env, cwdOf, logger }) {
112
+ const engine = getTimeMachineEngine(ctx)
113
+ if (!engine) {
114
+ return { available: false, created: false }
115
+ }
116
+
117
+ const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
118
+ const snapshotLabel = `Checkpoint: ${label || 'manual'} (${task?.id || 'task'})`
119
+ const sessionId = `task-${task?.id || 'default'}`
120
+
121
+ try {
122
+ const snapshot = await engine.createSnapshot(snapshotLabel, {
123
+ sessionId,
124
+ cwd: targetDir,
125
+ skipIfNoChanges: false,
126
+ })
127
+ return {
128
+ available: true,
129
+ created: true,
130
+ snapshot,
131
+ }
132
+ } catch (err) {
133
+ logger?.warn?.('[dsh-kanban] Failed to create checkpoint snapshot:', err?.message || err)
134
+ return {
135
+ available: true,
136
+ created: false,
137
+ error: err?.message || String(err),
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Roll back task files to a snapshot (default: inception snapshot).
144
+ *
145
+ * @param {{ctx: object, task: object, snapshotId?: string, config?: object, env?: object, cwdOf?: Function, logger?: object}} options
146
+ * @returns {Promise<{available: boolean, rolledBack: boolean, snapshotId?: string, result?: object, error?: string, message?: string}>}
147
+ */
148
+ export async function rollbackToSnapshot({ ctx, task, snapshotId, config, env, cwdOf, logger }) {
149
+ const engine = getTimeMachineEngine(ctx)
150
+ if (!engine) {
151
+ return {
152
+ available: false,
153
+ rolledBack: false,
154
+ error: 'time-machine-unavailable',
155
+ message: 'Plugin @goodandready/dsh-time-machine is not installed or active in this profile.',
156
+ }
157
+ }
158
+
159
+ const targetId = snapshotId || task?.inceptionSnapshotId
160
+ if (!targetId || typeof targetId !== 'string' || targetId.trim() === '') {
161
+ return {
162
+ available: true,
163
+ rolledBack: false,
164
+ error: 'no-snapshot-id',
165
+ message: 'No snapshot ID specified and task has no inception snapshot.',
166
+ }
167
+ }
168
+
169
+ const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
170
+
171
+ try {
172
+ const result = await engine.rollbackSnapshot(String(targetId).trim(), {
173
+ confirm: true,
174
+ cwd: targetDir,
175
+ })
176
+ return {
177
+ available: true,
178
+ rolledBack: true,
179
+ snapshotId: targetId,
180
+ result,
181
+ }
182
+ } catch (err) {
183
+ logger?.error?.('[dsh-kanban] Failed to rollback snapshot:', err?.message || err)
184
+ return {
185
+ available: true,
186
+ rolledBack: false,
187
+ error: 'rollback-failed',
188
+ message: err?.message || String(err),
189
+ }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * List snapshots for a task.
195
+ *
196
+ * @param {{ctx: object, task: object}} options
197
+ * @returns {Promise<Array<object>>}
198
+ */
199
+ export async function listTaskSnapshots({ ctx, task }) {
200
+ const engine = getTimeMachineEngine(ctx)
201
+ if (!engine || !task?.id) return []
202
+
203
+ const sessionId = `task-${task.id}`
204
+ try {
205
+ if (typeof engine.listSnapshots === 'function') {
206
+ const list = engine.listSnapshots(sessionId)
207
+ return Array.isArray(list) ? list : []
208
+ }
209
+ } catch {
210
+ /* ignore error */
211
+ }
212
+ return []
213
+ }
214
+
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Watchdog timer and Circuit Breaker service for agent tasks.
3
+ *
4
+ * Automatically monitors tasks in 'in-progress'.
5
+ * If a task is inactive longer than watchdogStallTimeoutMin or incurs
6
+ * watchdogMaxConsecutiveErrors consecutive execution errors,
7
+ * the circuit breaker trips:
8
+ * 1. The agent session is aborted/paused to avoid burning quota.
9
+ * 2. The task is marked as stalled with diagnostic detail.
10
+ * 3. A diagnostic transition is added to the task journal.
11
+ * 4. A 'task:change' event is broadcast via eventHub.
12
+ *
13
+ * Tasks can be resumed manually via resumeTask or /task/:id/resume.
14
+ */
15
+ export class WatchdogService {
16
+ constructor() {
17
+ this.consecutiveErrors = new Map()
18
+ this.lastActive = new Map()
19
+ this.lastError = new Map()
20
+ }
21
+
22
+ recordActivity(taskId, _type = 'activity') {
23
+ if (!taskId) return
24
+ this.lastActive.set(taskId, Date.now())
25
+ }
26
+
27
+ recordError(taskId, error) {
28
+ if (!taskId) return
29
+ const count = (this.consecutiveErrors.get(taskId) || 0) + 1
30
+ this.consecutiveErrors.set(taskId, count)
31
+ const msg = typeof error === 'string' ? error : (error?.message || error?.name || 'Execution error')
32
+ this.lastError.set(taskId, msg)
33
+ this.lastActive.set(taskId, Date.now())
34
+ return count
35
+ }
36
+
37
+ clearError(taskId) {
38
+ if (!taskId) return
39
+ this.consecutiveErrors.delete(taskId)
40
+ this.lastError.delete(taskId)
41
+ }
42
+
43
+ clearTask(taskId) {
44
+ if (!taskId) return
45
+ this.consecutiveErrors.delete(taskId)
46
+ this.lastError.delete(taskId)
47
+ this.lastActive.delete(taskId)
48
+ }
49
+
50
+ checkTask(task, options = {}) {
51
+ const { config = {}, now = Date.now() } = options
52
+ if (config.watchdogEnabled === false) return { shouldTrip: false }
53
+ if (!task || task.column !== 'in-progress' || task.stalled) return { shouldTrip: false }
54
+
55
+ const timeoutMs = (Number(config.watchdogStallTimeoutMin) || 15) * 60 * 1000
56
+ const maxErrors = Number(config.watchdogMaxConsecutiveErrors) || 5
57
+
58
+ // Check consecutive errors
59
+ const errCount = this.consecutiveErrors.get(task.id) || 0
60
+ if (errCount >= maxErrors) {
61
+ const lastErr = this.lastError.get(task.id) || 'ToolExecutionError'
62
+ return {
63
+ shouldTrip: true,
64
+ reason: `Circuit breaker tripped: ${errCount} consecutive execution errors (${lastErr})`,
65
+ errCount,
66
+ }
67
+ }
68
+
69
+ // Check inactivity timeout
70
+ const last = this.lastActive.get(task.id) || task.updatedAt || task.columnAt || task.createdAt || now
71
+ const idleMs = now - last
72
+ if (idleMs >= timeoutMs) {
73
+ const idleMin = Math.round(idleMs / 60000)
74
+ return {
75
+ shouldTrip: true,
76
+ reason: `Watchdog timeout: task inactive for ${idleMin} minutes (threshold ${config.watchdogStallTimeoutMin || 15}m)`,
77
+ idleMs,
78
+ }
79
+ }
80
+
81
+ return { shouldTrip: false }
82
+ }
83
+
84
+ tripTask(task, reason, options = {}) {
85
+ const { store, agents, eventHub, logger } = options
86
+ if (!task) return
87
+
88
+ // 1. Abort/pause session if running
89
+ if (task.sessionId && agents) {
90
+ try {
91
+ const agent = agents.get?.(task.sessionId)
92
+ if (agent && typeof agent.abort === 'function') {
93
+ agent.abort('watchdog: circuit breaker tripped')
94
+ } else if (agent && typeof agent.pause === 'function') {
95
+ agent.pause()
96
+ }
97
+ } catch (err) {
98
+ logger?.warn?.(`dsh-kanban: watchdog failed to abort agent for session ${task.sessionId}: ${err?.message}`)
99
+ }
100
+ }
101
+
102
+ // 2. Update task in store
103
+ if (store && typeof store.updateTask === 'function') {
104
+ store.updateTask(task.id, {
105
+ stalled: 1,
106
+ stalledReason: reason,
107
+ stalledAt: Date.now(),
108
+ })
109
+
110
+ // 3. Journal transition
111
+ if (typeof store.addTransition === 'function') {
112
+ store.addTransition({
113
+ taskId: task.id,
114
+ fromCol: task.column,
115
+ toCol: task.column,
116
+ source: 'watchdog',
117
+ detail: reason,
118
+ })
119
+ }
120
+ }
121
+
122
+ // 4. Broadcast event
123
+ eventHub?.broadcast?.('task:change', {
124
+ taskId: task.id,
125
+ action: 'stalled',
126
+ stalled: true,
127
+ reason,
128
+ })
129
+
130
+ logger?.warn?.(`dsh-kanban: watchdog tripped for task ${task.id}: ${reason}`)
131
+ }
132
+
133
+ checkTasks(options = {}) {
134
+ const { store, agents, config = {}, eventHub, logger } = options
135
+ if (config.watchdogEnabled === false || !store) return []
136
+
137
+ const tripped = []
138
+ const inProgressTasks = store.listTasks({ column: 'in-progress' })
139
+ const now = Date.now()
140
+
141
+ for (const task of inProgressTasks) {
142
+ const check = this.checkTask(task, { config, now })
143
+ if (check.shouldTrip) {
144
+ this.tripTask(task, check.reason, { store, agents, eventHub, logger })
145
+ tripped.push({ taskId: task.id, reason: check.reason })
146
+ }
147
+ }
148
+ return tripped
149
+ }
150
+
151
+ resumeTask(options = {}) {
152
+ const { store, taskId, eventHub } = options
153
+ if (!taskId) return
154
+ if (store && typeof store.updateTask === 'function') {
155
+ store.updateTask(taskId, {
156
+ stalled: 0,
157
+ stalledReason: '',
158
+ stalledAt: 0,
159
+ })
160
+ }
161
+ this.clearTask(taskId)
162
+ this.recordActivity(taskId, 'resume')
163
+ eventHub?.broadcast?.('task:change', { taskId, action: 'resumed', stalled: false })
164
+ }
165
+ }
166
+
167
+ export function createWatchdogService() {
168
+ return new WatchdogService()
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",