@goodandready/dsh-clinebot 0.5.10 → 0.5.12
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/CHANGELOG.md +19 -0
- package/lib/account-pool.js +17 -3
- package/lib/cline-client.js +2 -0
- package/lib/config.js +1 -1
- package/lib/index.js +8 -3
- package/lib/routes/accounts.js +66 -6
- package/lib/routes/models.js +6 -1
- package/lib/routes/proxy.js +6 -0
- package/lib/routes/settings.js +4 -0
- package/lib/slash-command.js +8 -1
- package/lib/stats-storage.js +23 -0
- package/lib/updater.js +35 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.12] - 2026-10-02
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Credential Ownership Enforcement on Deletion Retry (Issue #155)**: Reject deletion of unowned or non-existent credentials with HTTP 404 and zero calls to the credentials store, preventing unconfirmed secrets deletion. Retries of previously failed secret cleanups are explicitly tracked in-memory to safely allow recovery without exposing arbitrary secret deletion.
|
|
12
|
+
- **Catalog Lifecycle Across All Account Switch Paths (Issue #168)**: Reset dynamic model catalog (`dynamicModels: []`) and plan synchronization state (`planSyncedAt: 0`) uniformly across slash-command switches (`/cline switch`), automatic account pool rotations, and manual configuration updates, preventing stale or cross-account models from being considered verified.
|
|
13
|
+
- **Rollback Concurrency & Active Account Selection Preservation (Issue #187)**: Ensure transactional account deletion rollback does not overwrite concurrent changes to `activeAccount`. Only restore the deleted account as active if no concurrent change has occurred, preserving external selection alongside other settings.
|
|
14
|
+
- **Test Hardening & Multi-Environment Portability (Issue #173)**: Hardened regression tests to detect surviving descendants across parent exits under genuine failure conditions, updated account deletion tests to assert honest partial state (`partial: true`), and replaced hardcoded path references with dynamic DSH adapter discovery and graceful skips across diverse environments.
|
|
15
|
+
|
|
16
|
+
## [0.5.11] - 2026-10-01
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **Dynamic Stats Storage Switching & Counter Preservation (Issue #158)**: Flushes and synchronizes in-memory metrics before switching file storage targets when `statsPath` is updated dynamically, avoiding overwriting accumulated statistics when paths change.
|
|
20
|
+
- **Session Affinity Native Transport Bridge via Prompt Cache Key (Issue #163)**: Bridge native DSH `llm-pi-ai` requests to ClineBot sticky sessions by enabling `cacheRetention: 'long'` in the provider contract, and extracting session identifiers from `prompt_cache_key` on the proxy route, maintaining consistent account affinity across turns.
|
|
21
|
+
- **Catalog Lifecycle & Cache Identity Isolation (Issue #168)**: Isolated disk model cache records by specific account and endpoint scopes, clearing dynamic model cache and sync timestamps on active account switch, and reporting `planSynced: false` when offline cached plans cannot be actively verified against the provider.
|
|
22
|
+
- **Updater Process Group Escalation on Parent Exit (Issue #172)**: Ensured process group supervision continues after parent SIGTERM exit in `updater.js`, checking `isProcessGroupAlive(child.pid)` and escalating to SIGKILL across the detached process group (`-child.pid`) to eliminate lingering background processes.
|
|
23
|
+
- **Transactional Account Delete Surgical Rollback & Retry (Issues #184, #187)**: Replaced full-config rollback with surgical restoration of removed accounts into fresh live configuration on secret deletion failure, preserving concurrent settings updates. Exposed partial deletion state (`partial: true`) and allowed safe retry of credential cleanup without HTTP 404 errors.
|
|
24
|
+
- **DESIGN Contract Alignment with Native Runtime Contracts (Issue #177)**: Synchronized `docs/design/DESIGN.md` with active implementation: documented native DSH transport bridge, dynamic catalog lifecycle, updater process group cleanup, and dynamic stats storage switching.
|
|
25
|
+
- **Regression & Hardening Test Coverage (Issue #173)**: Added regression tests for dynamic stats switching, shifting quota session stickiness, process group lifecycle escalation on parent SIGTERM, and transactional partial account deletion and retry.
|
|
26
|
+
|
|
8
27
|
## [0.5.10] - 2026-10-01
|
|
9
28
|
|
|
10
29
|
### Fixed
|
package/lib/account-pool.js
CHANGED
|
@@ -168,10 +168,19 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
|
|
|
168
168
|
// Fallback if all candidates are either exhausted or uncached: pick next in round-robin
|
|
169
169
|
const nextAcc = bestCandidate || candidates[currentIndex % candidates.length] || candidates[0]
|
|
170
170
|
|
|
171
|
+
const prevActive = String(cfg.activeAccount || cfg.apiKeyEnv || "").trim()
|
|
172
|
+
const nextActive = String(nextAcc.apiKeyEnv || "").trim()
|
|
173
|
+
const isDifferent = nextActive !== prevActive
|
|
174
|
+
|
|
171
175
|
let updated = false
|
|
172
176
|
if (settingsApi?.replace) {
|
|
173
177
|
try {
|
|
174
|
-
const next = {
|
|
178
|
+
const next = {
|
|
179
|
+
...toPlainSnapshot(cfg),
|
|
180
|
+
activeAccount: nextAcc.apiKeyEnv,
|
|
181
|
+
dynamicModels: isDifferent ? [] : (cfg.dynamicModels || []),
|
|
182
|
+
planSyncedAt: isDifferent ? 0 : Number(cfg.planSyncedAt || 0),
|
|
183
|
+
}
|
|
175
184
|
await settingsApi.replace(next)
|
|
176
185
|
updated = true
|
|
177
186
|
} catch (err) {
|
|
@@ -181,9 +190,14 @@ export async function rotateToNextAccount(ctx, cfg, reason = 'rate_limit', setti
|
|
|
181
190
|
const settings = (ctx?.get && ctx.get('settings')) || ctx?.settings
|
|
182
191
|
if (settings?.mutate) {
|
|
183
192
|
try {
|
|
184
|
-
|
|
193
|
+
const ops = [
|
|
185
194
|
{ op: 'set', path: ['activeAccount'], value: nextAcc.apiKeyEnv },
|
|
186
|
-
]
|
|
195
|
+
]
|
|
196
|
+
if (isDifferent) {
|
|
197
|
+
ops.push({ op: 'set', path: ['dynamicModels'], value: [] })
|
|
198
|
+
ops.push({ op: 'set', path: ['planSyncedAt'], value: 0 })
|
|
199
|
+
}
|
|
200
|
+
await settings.mutate('dsh-clinebot', ops)
|
|
187
201
|
updated = true
|
|
188
202
|
} catch (err) {
|
|
189
203
|
ctx?.logger?.warn?.('[dsh-clinebot] Failed to persist rotated activeAccount via settings.mutate: ' + (err?.message || err))
|
package/lib/cline-client.js
CHANGED
|
@@ -547,8 +547,10 @@ export function buildPiAiProvider({
|
|
|
547
547
|
api: 'openai-completions',
|
|
548
548
|
baseURL: normalizeBaseUrl(baseUrl),
|
|
549
549
|
apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
|
|
550
|
+
cacheRetention: 'long',
|
|
550
551
|
compat: {
|
|
551
552
|
supportsReasoningEffort: true,
|
|
553
|
+
supportsLongCacheRetention: true,
|
|
552
554
|
},
|
|
553
555
|
models: modelList,
|
|
554
556
|
}
|
package/lib/config.js
CHANGED
|
@@ -129,7 +129,7 @@ export function publicConfig(cfg) {
|
|
|
129
129
|
const dynamic = Array.isArray(cfg?.dynamicModels) ? cfg.dynamicModels : []
|
|
130
130
|
const custom = Array.isArray(cfg?.customModels) ? cfg.customModels : []
|
|
131
131
|
const modelContextOverrides = Array.isArray(cfg?.modelContextOverrides) ? cfg.modelContextOverrides : []
|
|
132
|
-
const isSynced = dynamic.length > 0
|
|
132
|
+
const isSynced = dynamic.length > 0 && (Number(cfg?.planSyncedAt) > 0)
|
|
133
133
|
const allModels = getAllModels(dynamic, modelContextOverrides, custom)
|
|
134
134
|
const allDefaultIds = allModels.map((m) => m.id)
|
|
135
135
|
|
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { registerModelsRoutes } from './routes/models.js'
|
|
|
22
22
|
import { registerAuthRoutes } from './routes/auth.js'
|
|
23
23
|
import { registerProxyRoutes } from './routes/proxy.js'
|
|
24
24
|
import { registerSlashCommand } from './slash-command.js'
|
|
25
|
-
import { recordUsage, loadStats, flushStats, resolvePath } from './stats-storage.js'
|
|
25
|
+
import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
|
|
26
26
|
|
|
27
27
|
export const name = '@goodandready/dsh-clinebot'
|
|
28
28
|
export const inject = ['webServer', 'credentials']
|
|
@@ -99,12 +99,17 @@ export function apply(ctx, config) {
|
|
|
99
99
|
const syncStatsStorage = (cfg) => {
|
|
100
100
|
const nextPath = resolvePath(publicConfig(cfg).statsPath)
|
|
101
101
|
if (nextPath !== activeStatsPath) {
|
|
102
|
-
|
|
102
|
+
switchStatsStorage(nextPath)
|
|
103
103
|
activeStatsPath = nextPath
|
|
104
|
-
loadStats(nextPath)
|
|
105
104
|
}
|
|
106
105
|
}
|
|
107
106
|
|
|
107
|
+
if (typeof ctx?.on === 'function') {
|
|
108
|
+
ctx.on('loader/volatile-update', () => {
|
|
109
|
+
syncStatsStorage(live())
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
108
113
|
const getRevision = (svc) => {
|
|
109
114
|
try {
|
|
110
115
|
return svc?.describe?.().find((row) => row.ns === NS)?.revision
|
package/lib/routes/accounts.js
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
DEFAULT_API_KEY_ENV,
|
|
12
12
|
} from '../cline-client.js'
|
|
13
13
|
|
|
14
|
+
const pendingSecretCleanupAccounts = new Set()
|
|
15
|
+
|
|
14
16
|
export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
|
|
15
17
|
// POST /dsh-clinebot/accounts — add or update account in pool
|
|
16
18
|
ctx.effect(() => ctx.webServer.register({
|
|
@@ -155,6 +157,27 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
155
157
|
const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
|
|
156
158
|
const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
|
|
157
159
|
if (!accountToDelete) {
|
|
160
|
+
if (body.deleteSecret && pendingSecretCleanupAccounts.has(targetEnv) && ctx?.get?.('credentials')) {
|
|
161
|
+
let retryDelRes = null
|
|
162
|
+
try {
|
|
163
|
+
retryDelRes = await deleteCredentialKey(ctx, targetEnv)
|
|
164
|
+
} catch {
|
|
165
|
+
retryDelRes = null
|
|
166
|
+
}
|
|
167
|
+
if (retryDelRes && retryDelRes.ok) {
|
|
168
|
+
pendingSecretCleanupAccounts.delete(targetEnv)
|
|
169
|
+
clearUsageCache()
|
|
170
|
+
clearProbeCache()
|
|
171
|
+
return writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: true })
|
|
172
|
+
}
|
|
173
|
+
return writeJson(res, 500, {
|
|
174
|
+
ok: false,
|
|
175
|
+
partial: true,
|
|
176
|
+
accountRemoved: true,
|
|
177
|
+
secretDeleted: false,
|
|
178
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store on retry`,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
158
181
|
return writeJson(res, 404, {
|
|
159
182
|
ok: false,
|
|
160
183
|
error: `Account "${targetEnv}" not found in accounts pool`,
|
|
@@ -172,31 +195,62 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
172
195
|
await settingsApi.replace(next)
|
|
173
196
|
await syncProviderState(next)
|
|
174
197
|
|
|
198
|
+
let delRes = null
|
|
175
199
|
if (body.deleteSecret) {
|
|
176
|
-
let delRes = null
|
|
177
200
|
try {
|
|
178
201
|
delRes = await deleteCredentialKey(ctx, targetEnv)
|
|
179
202
|
} catch {
|
|
180
203
|
delRes = null
|
|
181
204
|
}
|
|
182
205
|
if (!delRes || !delRes.ok) {
|
|
206
|
+
let rollbackSuccess = false
|
|
183
207
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
208
|
+
const fresh = plainConfig(live())
|
|
209
|
+
const freshAccounts = Array.isArray(fresh.accounts) ? [...fresh.accounts] : []
|
|
210
|
+
if (!freshAccounts.some((a) => a && a.apiKeyEnv === targetEnv)) {
|
|
211
|
+
freshAccounts.push(accountToDelete)
|
|
212
|
+
}
|
|
213
|
+
const shouldRestoreActive = (curr.activeAccount === targetEnv && (!fresh.activeAccount || fresh.activeAccount === targetEnv))
|
|
214
|
+
? targetEnv
|
|
215
|
+
: fresh.activeAccount
|
|
216
|
+
const restoredConfig = plainConfig(Config({
|
|
217
|
+
...fresh,
|
|
218
|
+
accounts: freshAccounts,
|
|
219
|
+
activeAccount: shouldRestoreActive,
|
|
220
|
+
}))
|
|
221
|
+
await settingsApi.replace(restoredConfig)
|
|
222
|
+
await syncProviderState(restoredConfig)
|
|
223
|
+
rollbackSuccess = true
|
|
186
224
|
} catch {
|
|
187
|
-
|
|
225
|
+
rollbackSuccess = false
|
|
188
226
|
}
|
|
227
|
+
|
|
228
|
+
if (rollbackSuccess) {
|
|
229
|
+
return writeJson(res, 500, {
|
|
230
|
+
ok: false,
|
|
231
|
+
partial: false,
|
|
232
|
+
accountRemoved: false,
|
|
233
|
+
secretDeleted: false,
|
|
234
|
+
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
pendingSecretCleanupAccounts.add(targetEnv)
|
|
189
239
|
return writeJson(res, 500, {
|
|
190
240
|
ok: false,
|
|
241
|
+
partial: true,
|
|
242
|
+
accountRemoved: true,
|
|
243
|
+
secretDeleted: false,
|
|
191
244
|
error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
|
|
192
245
|
})
|
|
193
246
|
}
|
|
194
247
|
}
|
|
195
248
|
|
|
249
|
+
pendingSecretCleanupAccounts.delete(targetEnv)
|
|
196
250
|
clearUsageCache()
|
|
197
251
|
clearProbeCache()
|
|
198
252
|
|
|
199
|
-
writeJson(res, 200, { ok: true, removed: targetEnv })
|
|
253
|
+
writeJson(res, 200, { ok: true, removed: targetEnv, secretDeleted: Boolean(body.deleteSecret && delRes?.ok) })
|
|
200
254
|
} catch (err) {
|
|
201
255
|
writeJson(res, 500, { ok: false, error: String(err?.message || err) })
|
|
202
256
|
}
|
|
@@ -234,7 +288,13 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
234
288
|
return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
|
|
235
289
|
}
|
|
236
290
|
|
|
237
|
-
const
|
|
291
|
+
const currentLive = plainConfig(live())
|
|
292
|
+
const accountChanged = currentLive.activeAccount !== account
|
|
293
|
+
const next = plainConfig(Config({
|
|
294
|
+
...currentLive,
|
|
295
|
+
activeAccount: account,
|
|
296
|
+
...(accountChanged ? { dynamicModels: [], planSyncedAt: 0 } : {}),
|
|
297
|
+
}))
|
|
238
298
|
await settingsApi.replace(next)
|
|
239
299
|
await syncProviderState(next)
|
|
240
300
|
writeJson(res, 200, { ok: true, activeAccount: account })
|
package/lib/routes/models.js
CHANGED
|
@@ -49,7 +49,12 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
49
49
|
await syncProviderState(next)
|
|
50
50
|
const cacheFile = resolvePathWithHome(pub.modelsCachePath)
|
|
51
51
|
if (cacheFile) {
|
|
52
|
-
await
|
|
52
|
+
const activeAcc = await resolveActiveAccountKey(ctx, live())
|
|
53
|
+
const accountRef = activeAcc?.envName || activeAcc?.apiKeyEnv || pub.activeAccount || pub.apiKeyEnv || 'CLINEBOT_API_KEY'
|
|
54
|
+
await saveModelsDiskCache(cacheFile, dynamicModels, now, {
|
|
55
|
+
endpoint: pub.baseUrl,
|
|
56
|
+
accountRef,
|
|
57
|
+
})
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
return writeJson(res, 200, {
|
package/lib/routes/proxy.js
CHANGED
|
@@ -112,11 +112,17 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
|
|
|
112
112
|
req.headers['x-client-request-id'] ||
|
|
113
113
|
body?.session_id ||
|
|
114
114
|
body?.conversation_id ||
|
|
115
|
+
body?.prompt_cache_key ||
|
|
115
116
|
body?.chat_id ||
|
|
116
117
|
body?.user ||
|
|
117
118
|
''
|
|
118
119
|
).trim()
|
|
119
120
|
|
|
121
|
+
if (sessionId) {
|
|
122
|
+
if (!body.session_id) body.session_id = sessionId
|
|
123
|
+
if (!body.conversation_id) body.conversation_id = sessionId
|
|
124
|
+
}
|
|
125
|
+
|
|
120
126
|
// Ensure model is set, fallback to defaultModel (#140)
|
|
121
127
|
body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
|
|
122
128
|
|
package/lib/routes/settings.js
CHANGED
|
@@ -93,6 +93,10 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
|
|
|
93
93
|
const base = plainConfig(live()) || {}
|
|
94
94
|
delete base.enabledModels
|
|
95
95
|
const merged = { ...base, ...rawPayload }
|
|
96
|
+
if (rawPayload.activeAccount !== undefined && String(rawPayload.activeAccount).trim() !== String(base.activeAccount || "").trim()) {
|
|
97
|
+
if (!("dynamicModels" in rawPayload)) merged.dynamicModels = []
|
|
98
|
+
if (!("planSyncedAt" in rawPayload)) merged.planSyncedAt = 0
|
|
99
|
+
}
|
|
96
100
|
const parsed = plainConfig(Config(merged))
|
|
97
101
|
await settingsApi.replace(parsed)
|
|
98
102
|
await syncProviderState(parsed)
|
package/lib/slash-command.js
CHANGED
|
@@ -100,7 +100,14 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
100
100
|
clearProbeCache()
|
|
101
101
|
const settingsApi = getSettingsApi()
|
|
102
102
|
if (settingsApi?.replace) {
|
|
103
|
-
const
|
|
103
|
+
const curr = plainConfig(live())
|
|
104
|
+
const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
|
|
105
|
+
const next = Config({
|
|
106
|
+
...curr,
|
|
107
|
+
activeAccount: param,
|
|
108
|
+
dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
|
|
109
|
+
planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
|
|
110
|
+
})
|
|
104
111
|
await settingsApi.replace(next)
|
|
105
112
|
await syncProviderState(next)
|
|
106
113
|
return `Active account switched to ${param}`
|
package/lib/stats-storage.js
CHANGED
|
@@ -119,6 +119,26 @@ export function loadStats(customPath) {
|
|
|
119
119
|
return inMemoryStats
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Switch active stats storage to a new path.
|
|
124
|
+
* Flushes pending writes of current storage and loads stats from the new path.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} newPath
|
|
127
|
+
* @returns {string} active stats path
|
|
128
|
+
*/
|
|
129
|
+
export function switchStatsStorage(newPath) {
|
|
130
|
+
const resolved = resolvePath(newPath)
|
|
131
|
+
if (resolved && resolved !== activeStatsPath) {
|
|
132
|
+
if (activeStatsPath) {
|
|
133
|
+
flushStats(activeStatsPath)
|
|
134
|
+
}
|
|
135
|
+
activeStatsPath = resolved
|
|
136
|
+
loadStats(resolved)
|
|
137
|
+
}
|
|
138
|
+
return activeStatsPath
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
122
142
|
/**
|
|
123
143
|
* Flush any debounced pending writes immediately to disk.
|
|
124
144
|
*
|
|
@@ -201,6 +221,9 @@ export function recordUsage({
|
|
|
201
221
|
is429 = false,
|
|
202
222
|
statsPath
|
|
203
223
|
} = {}) {
|
|
224
|
+
if (statsPath) {
|
|
225
|
+
switchStatsStorage(statsPath)
|
|
226
|
+
}
|
|
204
227
|
const now = new Date()
|
|
205
228
|
const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
|
|
206
229
|
const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
|
package/lib/updater.js
CHANGED
|
@@ -264,6 +264,16 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
264
264
|
let sigtermTimer = null
|
|
265
265
|
let sigkillTimer = null
|
|
266
266
|
|
|
267
|
+
function isProcessGroupAlive(pid) {
|
|
268
|
+
if (typeof pid !== 'number' || pid <= 0 || process.platform === 'win32') return false
|
|
269
|
+
try {
|
|
270
|
+
process.kill(-pid, 0)
|
|
271
|
+
return true
|
|
272
|
+
} catch {
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
267
277
|
function cleanupTimers() {
|
|
268
278
|
clearTimeout(mainTimer)
|
|
269
279
|
if (sigtermTimer) clearTimeout(sigtermTimer)
|
|
@@ -272,16 +282,26 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
272
282
|
|
|
273
283
|
function onProcessExit(code, signal) {
|
|
274
284
|
if (exited) return
|
|
275
|
-
exited = true
|
|
276
|
-
cleanupTimers()
|
|
277
285
|
|
|
278
286
|
if (isTimedOut) {
|
|
287
|
+
if (isProcessGroupAlive(child.pid)) {
|
|
288
|
+
// Parent exited, but descendants in process group are still alive;
|
|
289
|
+
// keep escalation timer active (#172)
|
|
290
|
+
clearTimeout(mainTimer)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
exited = true
|
|
294
|
+
cleanupTimers()
|
|
279
295
|
cleanStaleLock(target.profileDir)
|
|
280
296
|
reject(new Error('Update timed out.'))
|
|
281
|
-
} else if (code === 0) {
|
|
282
|
-
resolvePromise()
|
|
283
297
|
} else {
|
|
284
|
-
|
|
298
|
+
exited = true
|
|
299
|
+
cleanupTimers()
|
|
300
|
+
if (code === 0) {
|
|
301
|
+
resolvePromise()
|
|
302
|
+
} else {
|
|
303
|
+
reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
|
|
304
|
+
}
|
|
285
305
|
}
|
|
286
306
|
}
|
|
287
307
|
|
|
@@ -316,18 +336,25 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
316
336
|
|
|
317
337
|
// Step 2: Wait for grace period, if process has not exited escalate to SIGKILL (#172)
|
|
318
338
|
sigtermTimer = setTimeout(() => {
|
|
319
|
-
const
|
|
320
|
-
|
|
339
|
+
const groupAlive = isProcessGroupAlive(child.pid)
|
|
340
|
+
const parentExited = (child.exitCode !== null && child.exitCode !== undefined) || (child.signalCode !== null && child.signalCode !== undefined)
|
|
341
|
+
if (!parentExited || groupAlive) {
|
|
321
342
|
killProcessTree('SIGKILL')
|
|
322
343
|
|
|
323
344
|
// Step 3: Hard deadline for exit notification
|
|
324
345
|
sigkillTimer = setTimeout(() => {
|
|
325
346
|
if (!exited) {
|
|
326
347
|
exited = true
|
|
348
|
+
cleanupTimers()
|
|
327
349
|
cleanStaleLock(target.profileDir)
|
|
328
350
|
reject(new Error('Update timed out.'))
|
|
329
351
|
}
|
|
330
|
-
}, sigkillGraceMs)
|
|
352
|
+
}, Math.min(sigkillGraceMs, 50))
|
|
353
|
+
} else if (!exited) {
|
|
354
|
+
exited = true
|
|
355
|
+
cleanupTimers()
|
|
356
|
+
cleanStaleLock(target.profileDir)
|
|
357
|
+
reject(new Error('Update timed out.'))
|
|
331
358
|
}
|
|
332
359
|
}, sigtermGraceMs)
|
|
333
360
|
}, timeoutMs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.12",
|
|
4
4
|
"description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|