@goodandready/dsh-kanban 0.2.34 → 0.2.35

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,9 @@ 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,
79
82
  updatedAt INTEGER NOT NULL
80
83
  );
81
84
  CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
@@ -110,14 +113,15 @@ const WRITABLE = [
110
113
  'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
111
114
  'verificationCommand', 'verificationStatus',
112
115
  'costMetrics', 'costLimitExceeded',
116
+ 'stalled', 'stalledReason', 'stalledAt',
113
117
  ]
114
118
 
115
119
  /** Valid transition sources; unexpected values are rejected to preserve log integrity. */
116
- const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow'])
120
+ const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow', 'watchdog'])
117
121
 
118
122
  function rowToTask(row) {
119
123
  if (row === undefined) return undefined
120
- const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, ...rest } = row
124
+ const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, ...rest } = row
121
125
  let parsedChecklist = []
122
126
  try {
123
127
  const value = JSON.parse(checklist ?? '[]')
@@ -265,6 +269,9 @@ export function openStore({ dir }) {
265
269
  ['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
266
270
  ['costMetrics', "TEXT NOT NULL DEFAULT ''"],
267
271
  ['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
272
+ ['stalled', 'INTEGER NOT NULL DEFAULT 0'],
273
+ ['stalledReason', "TEXT NOT NULL DEFAULT ''"],
274
+ ['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
268
275
  ]) {
269
276
  try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
270
277
  }
@@ -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.35",
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",