gitdone-agent 0.7.0 → 0.7.1

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.
Files changed (2) hide show
  1. package/index.js +120 -1
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -27,7 +27,7 @@ import { randomUUID } from 'node:crypto'
27
27
  // Reported to the server on every sync so the web UI can flag outdated agents.
28
28
  // Keep in lockstep with packages/agent/package.json "version" AND
29
29
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
30
- const AGENT_VERSION = '0.7.0'
30
+ const AGENT_VERSION = '0.7.1'
31
31
 
32
32
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
33
33
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -253,6 +253,99 @@ function stopRunningAgents() {
253
253
  } catch { /* best-effort — at worst the old agent lingers until next login */ }
254
254
  }
255
255
 
256
+ // ─── Watchdog (gd-431) ────────────────────────────────────────────────────────
257
+ // Third, OUTER layer of crash safety. Layers 1-2 (survive() in-process, the
258
+ // run-agent.cmd supervisor loop) still leave two ways for a machine to stay
259
+ // offline: the supervisor cmd.exe itself dying (nothing restarts IT), and the
260
+ // agent HANGING without exiting (process alive → supervisor waits forever).
261
+ // So: the agent touches a heartbeat file every 30s, and a Windows scheduled
262
+ // task runs watchdog.ps1 every 5 minutes (hidden via watchdog.vbs). A stale
263
+ // heartbeat (>10 min) means dead OR hung → the watchdog kills any leftover
264
+ // agent/supervisor processes and relaunches run-agent.cmd. The relaunch goes
265
+ // through WMI Win32_Process.Create so the agent does NOT live inside the task's
266
+ // job object — otherwise Task Scheduler would consider the task "still running"
267
+ // forever and never fire it again (and stopping the task would kill the agent).
268
+ //
269
+ // Broken-session guard (gd-405): if a restart attempt does NOT revive the
270
+ // heartbeat (0xc0000142 storms after a hardware crash — every start pops a
271
+ // blocking error dialog), the watchdog backs off to one attempt per 30 min via
272
+ // a stamp file instead of retrying every 5 — and a fresh heartbeat clears it.
273
+ const WATCHDOG_TASK = 'GitdoneAgentWatchdog'
274
+ const WATCHDOG_PS1 = join(AGENT_DIR, 'watchdog.ps1')
275
+ const WATCHDOG_VBS = join(AGENT_DIR, 'watchdog.vbs')
276
+ const HEARTBEAT_PATH = join(AGENT_DIR, 'heartbeat.txt')
277
+
278
+ function startHeartbeat() {
279
+ const beat = () => {
280
+ try { ensureAgentDir(); writeFileSync(HEARTBEAT_PATH, new Date().toISOString()) } catch { /* best-effort */ }
281
+ }
282
+ beat()
283
+ setInterval(beat, 30_000)
284
+ }
285
+
286
+ // Idempotent: (re)writes the watchdog scripts and (re)creates the scheduled
287
+ // task. Called on EVERY agent start — not just --install — so an in-place
288
+ // update (gd-420) gains the watchdog too, and a deleted task self-heals.
289
+ // The ps1 stays ASCII-only: PowerShell 5.1 misreads UTF-8-without-BOM files.
290
+ function ensureWatchdog() {
291
+ if (process.platform !== 'win32') return
292
+ try {
293
+ ensureAgentDir()
294
+ const runCmd = join(AGENT_DIR, 'run-agent.cmd')
295
+ const ps1 = [
296
+ "$ErrorActionPreference = 'SilentlyContinue'",
297
+ `$dir = '${AGENT_DIR.replace(/'/g, "''")}'`,
298
+ "$hb = Join-Path $dir 'heartbeat.txt'",
299
+ "$stamp = Join-Path $dir 'watchdog-restart.stamp'",
300
+ "$wlog = Join-Path $dir 'watchdog.log'",
301
+ "$runCmd = Join-Path $dir 'run-agent.cmd'",
302
+ 'function WLog([string]$m) { Add-Content -Path $wlog -Value ("[{0}] {1}" -f (Get-Date -Format \'yyyy-MM-dd HH:mm:ss\'), $m) }',
303
+ 'if ((Test-Path $wlog) -and ((Get-Item $wlog).Length -gt 1048576)) { Move-Item -Path $wlog -Destination ($wlog + \'.old\') -Force }',
304
+ '# Fresh heartbeat (<10 min) = agent alive and ticking - nothing to do.',
305
+ 'if ((Test-Path $hb) -and (((Get-Date) - (Get-Item $hb).LastWriteTime).TotalSeconds -lt 600)) {',
306
+ ' if (Test-Path $stamp) { Remove-Item -Path $stamp -Force }',
307
+ ' exit 0',
308
+ '}',
309
+ '# Last restart attempt did not revive the heartbeat - broken login session',
310
+ '# (e.g. 0xc0000142 after a hardware crash). Back off to one try per 30 min',
311
+ '# so retries cannot turn into a blocking-dialog storm (gd-405).',
312
+ 'if ((Test-Path $stamp) -and (((Get-Date) - (Get-Item $stamp).LastWriteTime).TotalMinutes -lt 30)) { exit 0 }',
313
+ 'New-Item -ItemType File -Path $stamp -Force | Out-Null',
314
+ "if (-not (Test-Path $runCmd)) { WLog 'run-agent.cmd missing - cannot restart; run gitdone-agent --install'; exit 0 }",
315
+ "WLog 'heartbeat stale - killing leftover agent processes and restarting'",
316
+ '# Same sweep as stopRunningAgents(): supervisors first, then agents, so a',
317
+ '# surviving supervisor cannot respawn a second agent mid-cleanup.',
318
+ 'Get-CimInstance Win32_Process -Filter "Name=\'cmd.exe\'" | Where-Object { $_.CommandLine -like \'*run-agent.cmd*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
319
+ 'Get-CimInstance Win32_Process -Filter "Name=\'node.exe\'" | Where-Object { $_.CommandLine -like \'*gitdone-agent*\' -or $_.CommandLine -like \'*agent.mjs*\' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }',
320
+ 'Start-Sleep -Seconds 2',
321
+ '# WMI Create = the new process tree lives OUTSIDE the scheduled task job.',
322
+ '# (wmiclass accelerator, not Invoke-CimMethod - CIM cannot marshal the',
323
+ '# embedded Win32_ProcessStartup instance and dies with "Type mismatch".)',
324
+ "$sup = ([wmiclass]'Win32_ProcessStartup').CreateInstance()",
325
+ '$sup.ShowWindow = 0',
326
+ "$r = ([wmiclass]'Win32_Process').Create(('\"' + $env:ComSpec + '\" /c \"\"' + $runCmd + '\"\"'), $null, $sup)",
327
+ 'WLog ("restart issued (ReturnValue={0}, Pid={1})" -f $r.ReturnValue, $r.ProcessId)',
328
+ ].join('\r\n')
329
+ writeFileSync(WATCHDOG_PS1, ps1, 'utf8')
330
+
331
+ // VBS wrapper so the 5-minute task never flashes a window.
332
+ const vbs = [
333
+ 'Set WshShell = CreateObject("WScript.Shell")',
334
+ `WshShell.Run "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ""${WATCHDOG_PS1}""", 0, True`,
335
+ ].join('\r\n')
336
+ writeFileSync(WATCHDOG_VBS, vbs, 'utf8')
337
+
338
+ execFileSync('schtasks', [
339
+ '/Create', '/F', '/SC', 'MINUTE', '/MO', '5',
340
+ '/TN', WATCHDOG_TASK,
341
+ '/TR', `wscript.exe "${WATCHDOG_VBS}"`,
342
+ ], { stdio: 'ignore', timeout: 30_000 })
343
+ log(`✓ watchdog: планирана задача „${WATCHDOG_TASK}" проверява агента на всеки 5 мин`)
344
+ } catch (err) {
345
+ log(`✗ watchdog setup failed: ${err?.message || err}`)
346
+ }
347
+ }
348
+
256
349
  function installStartup() {
257
350
  ensureAgentDir()
258
351
  const nodePath = getNodePath()
@@ -317,6 +410,9 @@ function installStartup() {
317
410
  ].join('\r\n')
318
411
 
319
412
  writeFileSync(getVbsPath(), vbs, 'utf8')
413
+
414
+ // Outer safety layer: the 5-minute watchdog task (gd-431).
415
+ ensureWatchdog()
320
416
  }
321
417
 
322
418
  function uninstallStartup() {
@@ -329,6 +425,16 @@ function uninstallStartup() {
329
425
  }
330
426
  const cmdPath = join(AGENT_DIR, 'run-agent.cmd')
331
427
  if (existsSync(cmdPath)) unlinkSync(cmdPath)
428
+ // Drop the watchdog task too — otherwise it resurrects the agent in ≤5 min.
429
+ if (process.platform === 'win32') {
430
+ try {
431
+ execFileSync('schtasks', ['/Delete', '/F', '/TN', WATCHDOG_TASK], { stdio: 'ignore', timeout: 30_000 })
432
+ console.log('✓ Премахнат watchdog (планирана задача)')
433
+ } catch { /* not installed */ }
434
+ }
435
+ for (const f of [WATCHDOG_PS1, WATCHDOG_VBS, HEARTBEAT_PATH]) {
436
+ if (existsSync(f)) unlinkSync(f)
437
+ }
332
438
  // Also stop the live supervisor + agent — otherwise they keep running (and
333
439
  // the supervisor keeps resurrecting the agent) until the next reboot.
334
440
  stopRunningAgents()
@@ -344,6 +450,14 @@ function runDoctor() {
344
450
  console.log(` stable agent : ${STABLE_AGENT} ${existsSync(STABLE_AGENT) ? '(ok)' : '(not installed)'}`)
345
451
  console.log(` autostart vbs : ${getVbsPath()} ${existsSync(getVbsPath()) ? '(ok)' : '(not installed)'}`)
346
452
  console.log(` supervisor : ${join(AGENT_DIR, 'run-agent.cmd')} ${existsSync(join(AGENT_DIR, 'run-agent.cmd')) ? '(ok)' : '(not installed — пусни --install за авторестарт при crash)'}`)
453
+ if (process.platform === 'win32') {
454
+ let wd = '(not installed — пусни --install или рестартирай агента)'
455
+ try { execFileSync('schtasks', ['/Query', '/TN', WATCHDOG_TASK], { stdio: 'ignore', timeout: 30_000 }); wd = '(ok — на всеки 5 мин)' } catch { /* missing */ }
456
+ console.log(` watchdog : ${WATCHDOG_TASK} ${wd}`)
457
+ }
458
+ let hb = '(none yet)'
459
+ try { hb = `(последен: преди ${Math.round((Date.now() - statSync(HEARTBEAT_PATH).mtimeMs) / 1000)}s)` } catch { /* no beat yet */ }
460
+ console.log(` heartbeat : ${HEARTBEAT_PATH} ${hb}`)
347
461
  console.log(` config : ${CONFIG_PATH} ${existsSync(CONFIG_PATH) ? '(ok)' : '(MISSING)'}`)
348
462
  console.log(` log file : ${LOG_PATH} ${existsSync(LOG_PATH) ? '(ok)' : '(none yet)'}`)
349
463
  console.log(` crash log : ${join(AGENT_DIR, 'agent-crash.log')} ${existsSync(join(AGENT_DIR, 'agent-crash.log')) ? '(има записи — виж го при проблеми)' : '(празен — няма крашове)'}`)
@@ -1809,6 +1923,11 @@ async function streamCommands(cfg) {
1809
1923
 
1810
1924
  async function runLoop(cfg) {
1811
1925
  ensureSingleInstance()
1926
+ // Watchdog food: a heartbeat file every 30s. Stale >10 min (dead OR hung
1927
+ // event loop) → the scheduled task restarts us (gd-431). Registered on every
1928
+ // start so in-place-updated agents get the watchdog without a re-install.
1929
+ startHeartbeat()
1930
+ ensureWatchdog()
1812
1931
  log(`gitdone-agent started — machine: ${cfg.hostname}, server: ${cfg.url}, interval: ${cfg.interval}s`)
1813
1932
  log(` roots: ${cfg.roots.join(', ') || '(none — add one with --root)'}`)
1814
1933
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
5
5
  "type": "module",
6
6
  "bin": {