gitdone-agent 0.8.3 → 0.8.4

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 +128 -38
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -29,7 +29,7 @@ import { randomUUID, createHash } from 'node:crypto'
29
29
  // Reported to the server on every sync so the web UI can flag outdated agents.
30
30
  // Keep in lockstep with packages/agent/package.json "version" AND
31
31
  // src/lib/agentVersion.ts LATEST_AGENT_VERSION.
32
- const AGENT_VERSION = '0.8.3'
32
+ const AGENT_VERSION = '0.8.4'
33
33
 
34
34
  const AGENT_DIR = join(homedir(), '.gitdone-agent')
35
35
  const CONFIG_PATH = join(AGENT_DIR, 'config.json')
@@ -999,7 +999,7 @@ function scanRepos(roots, maxDepth = 5) {
999
999
 
1000
1000
  // ─── Server sync + commands ─────────────────────────────────────────────────────
1001
1001
 
1002
- async function api(cfg, path, body) {
1002
+ async function apiOnce(cfg, path, body) {
1003
1003
  const res = await fetch(`${cfg.url}${path}`, {
1004
1004
  method: 'POST',
1005
1005
  headers: { Authorization: `Bearer ${cfg.key}`, 'Content-Type': 'application/json' },
@@ -1007,11 +1007,45 @@ async function api(cfg, path, body) {
1007
1007
  })
1008
1008
  if (!res.ok) {
1009
1009
  const text = await res.text().catch(() => '')
1010
- throw new Error(`HTTP ${res.status} ${path}: ${text}`)
1010
+ const err = new Error(`HTTP ${res.status} ${path}: ${text}`)
1011
+ err.status = res.status
1012
+ throw err
1011
1013
  }
1012
1014
  return res.json().catch(() => ({}))
1013
1015
  }
1014
1016
 
1017
+ // gd-514: statuses where the app server demonstrably never processed the body,
1018
+ // so re-sending it can't double-apply anything. 502/503/504 is Caddy answering
1019
+ // while `systemctl restart gitdone` is mid-flight — i.e. EVERY deploy. A thrown
1020
+ // fetch (no status) is a connection that never completed, same story. 4xx and
1021
+ // 500 are NOT retried: they won't get better, and a 500 may have half-applied.
1022
+ function isRetriableApiError(err) {
1023
+ return err?.status === undefined || [408, 429, 502, 503, 504].includes(err.status)
1024
+ }
1025
+
1026
+ /**
1027
+ * POST to the app, retrying the failures that are purely "the server wasn't
1028
+ * there". Console events used to be strictly best-effort, which meant a single
1029
+ * 502 during a deploy could swallow the terminal "turn finished" post and leave
1030
+ * the session RUNNING forever, showing a half-written reply that never moved
1031
+ * again (gd-514).
1032
+ */
1033
+ async function api(cfg, path, body, attempts = 4) {
1034
+ let lastErr
1035
+ for (let i = 0; i < attempts; i++) {
1036
+ try {
1037
+ return await apiOnce(cfg, path, body)
1038
+ } catch (err) {
1039
+ lastErr = err
1040
+ if (i === attempts - 1 || !isRetriableApiError(err)) break
1041
+ // 1s, 2s, 4s — covers the restart window of a deploy without stalling the
1042
+ // console noticeably when the server is merely slow.
1043
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** i))
1044
+ }
1045
+ }
1046
+ throw lastErr
1047
+ }
1048
+
1015
1049
  async function reportCommandResult(cfg, id, status, result) {
1016
1050
  await api(cfg, '/api/v1/agent/command-result', { id, status, result }).catch(() => {})
1017
1051
  }
@@ -1021,20 +1055,28 @@ async function reportCommandResult(cfg, id, status, result) {
1021
1055
  // how many tokens the run cost. `streamingText` / `activityText` (gd-419) carry
1022
1056
  // the live "being typed" preview and the latest thinking snippet — explicit ''
1023
1057
  // clears them; `undefined` leaves them untouched.
1024
- async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText, extra) {
1025
- await api(cfg, '/api/v1/agent/ai-run/events', {
1026
- runId,
1027
- events,
1028
- ...(status ? { status } : {}),
1029
- ...(result !== undefined ? { result } : {}),
1030
- ...(usage ? { usage } : {}),
1031
- ...(streamingText !== undefined ? { streamingText } : {}),
1032
- ...(activityText !== undefined ? { activityText } : {}),
1033
- // gd-466: claude's own session id (for --resume) + precise stop cause.
1034
- ...(extra?.claudeSessionId ? { claudeSessionId: extra.claudeSessionId } : {}),
1035
- ...(extra?.stopReason ? { stopReason: extra.stopReason } : {}),
1036
- ...(typeof extra?.resetAt === 'number' ? { resetAt: extra.resetAt } : {}),
1037
- }).catch((e) => log(`✗ ai-run events post failed: ${e.message}`))
1058
+ // Same contract as postSessionEvents: true when it landed, false when the
1059
+ // caller must keep the batch and retry it (gd-514).
1060
+ async function postRunEvents(cfg, runId, events, status, result, usage, streamingText, activityText, extra, attempts) {
1061
+ try {
1062
+ await api(cfg, '/api/v1/agent/ai-run/events', {
1063
+ runId,
1064
+ events,
1065
+ ...(status ? { status } : {}),
1066
+ ...(result !== undefined ? { result } : {}),
1067
+ ...(usage ? { usage } : {}),
1068
+ ...(streamingText !== undefined ? { streamingText } : {}),
1069
+ ...(activityText !== undefined ? { activityText } : {}),
1070
+ // gd-466: claude's own session id (for --resume) + precise stop cause.
1071
+ ...(extra?.claudeSessionId ? { claudeSessionId: extra.claudeSessionId } : {}),
1072
+ ...(extra?.stopReason ? { stopReason: extra.stopReason } : {}),
1073
+ ...(typeof extra?.resetAt === 'number' ? { resetAt: extra.resetAt } : {}),
1074
+ }, attempts)
1075
+ return true
1076
+ } catch (e) {
1077
+ log(`✗ ai-run events post failed: ${e.message}`)
1078
+ return false
1079
+ }
1038
1080
  }
1039
1081
 
1040
1082
  // gd-466: parse a hard usage-limit stop out of claude's headless output. On the
@@ -1058,17 +1100,26 @@ function detectUsageLimit(text) {
1058
1100
 
1059
1101
  // Post transcript lines (and optional turn status / Claude session id) for an
1060
1102
  // interactive AiSession chat turn.
1061
- async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText, activityText) {
1062
- await api(cfg, '/api/v1/agent/ai-session/events', {
1063
- sessionId,
1064
- events,
1065
- ...(status ? { status } : {}),
1066
- ...(claudeSessionId ? { claudeSessionId } : {}),
1067
- // Explicit '' clears the live preview; `undefined` leaves it untouched.
1068
- ...(streamingText !== undefined ? { streamingText } : {}),
1069
- // Same semantics for the live thinking snippet (gd-419).
1070
- ...(activityText !== undefined ? { activityText } : {}),
1071
- }).catch((e) => log(`✗ ai-session events post failed: ${e.message}`))
1103
+ // Returns true when the post landed. A false is the caller's cue to put the
1104
+ // batch back and re-send it next tick, instead of dropping it on the floor
1105
+ // (gd-514) — that is how whole chunks of a reply used to go missing.
1106
+ async function postSessionEvents(cfg, sessionId, events, status, claudeSessionId, streamingText, activityText, attempts) {
1107
+ try {
1108
+ await api(cfg, '/api/v1/agent/ai-session/events', {
1109
+ sessionId,
1110
+ events,
1111
+ ...(status ? { status } : {}),
1112
+ ...(claudeSessionId ? { claudeSessionId } : {}),
1113
+ // Explicit '' clears the live preview; `undefined` leaves it untouched.
1114
+ ...(streamingText !== undefined ? { streamingText } : {}),
1115
+ // Same semantics for the live thinking snippet (gd-419).
1116
+ ...(activityText !== undefined ? { activityText } : {}),
1117
+ }, attempts)
1118
+ return true
1119
+ } catch (e) {
1120
+ log(`✗ ai-session events post failed: ${e.message}`)
1121
+ return false
1122
+ }
1072
1123
  }
1073
1124
 
1074
1125
  // Turn one stream-json line from `claude -p` into console events. Phase 1 shows
@@ -1645,7 +1696,9 @@ function runAiCommand(cfg, cmd, repoPath) {
1645
1696
  const activityChanged = liveActivity !== sentActivity
1646
1697
  if (pending.length === 0 && !liveChanged && !activityChanged) return
1647
1698
  flushing = true
1648
- const batch = pending; pending = []
1699
+ const taken = pending; pending = []
1700
+ const prevSentLive = sentLive
1701
+ const prevSentActivity = sentActivity
1649
1702
  const streamingText = liveChanged ? liveText : undefined
1650
1703
  const activityText = activityChanged ? liveActivity : undefined
1651
1704
  sentLive = liveText
@@ -1654,7 +1707,16 @@ function runAiCommand(cfg, cmd, repoPath) {
1654
1707
  // resume survives even if the agent dies before the clean close handler.
1655
1708
  const extra = (!sentSession && capturedSession) ? { claudeSessionId: capturedSession } : undefined
1656
1709
  if (extra) sentSession = true
1657
- await postRunEvents(cfg, runId, batch, undefined, undefined, undefined, streamingText, activityText, extra)
1710
+ const ok = await postRunEvents(cfg, runId, taken, undefined, undefined, undefined, streamingText, activityText, extra)
1711
+ if (!ok) {
1712
+ // Keep everything for the next tick rather than losing console lines to
1713
+ // one bad request (gd-514) — including the session id, so a resume still
1714
+ // gets it.
1715
+ pending = taken.concat(pending)
1716
+ sentLive = prevSentLive
1717
+ sentActivity = prevSentActivity
1718
+ if (extra) sentSession = false
1719
+ }
1658
1720
  flushing = false
1659
1721
  }
1660
1722
  const push = (kind, text) => {
@@ -1757,7 +1819,9 @@ function runAiCommand(cfg, cmd, repoPath) {
1757
1819
  : (ok ? '✓ Готово.' : `✗ Процесът приключи с код ${code}.`),
1758
1820
  }]
1759
1821
  if (usage) events.push({ kind: 'SYSTEM', text: `📊 Токени: ${fmtTokens(usage.inputTokens)} вход · ${fmtTokens(usage.outputTokens)} изход${usage.cacheReadTokens ? ` · ${fmtTokens(usage.cacheReadTokens)} кеш` : ''}${typeof usage.costUsd === 'number' ? ` · $${usage.costUsd.toFixed(4)}` : ''}` })
1760
- await postRunEvents(
1822
+ // Terminal post — retried hard (gd-514): losing it leaves the run stuck on
1823
+ // RUNNING, and with it the token-limit park/resume the server keys off.
1824
+ const landed = await postRunEvents(
1761
1825
  cfg, runId,
1762
1826
  events,
1763
1827
  // A limit stop is not a clean finish — report it as 'error' so the run
@@ -1768,7 +1832,9 @@ function runAiCommand(cfg, cmd, repoPath) {
1768
1832
  undefined,
1769
1833
  undefined,
1770
1834
  extra,
1835
+ 7,
1771
1836
  )
1837
+ if (!landed) log(`✗ ai_run ${runId}: терминалният пост не мина — сървърният watchdog поема`)
1772
1838
  reportCommandResult(cfg, cmd.id, ok && !limit.limited ? 'done' : 'error', limit.limited ? 'usage_limit' : `exit ${code}`)
1773
1839
  log(`■ ai_run ${runId} приключи (code ${code}${limit.limited ? ', usage_limit' : ''})`)
1774
1840
  })
@@ -1863,12 +1929,26 @@ async function flushChatTurn(cfg, entry) {
1863
1929
  const heartbeat = !hasEvents && !liveChanged && !activityChanged && Date.now() - t.lastPostAt > 2500
1864
1930
  if (!hasEvents && !liveChanged && !activityChanged && !heartbeat) return
1865
1931
  t.flushing = true
1866
- const batch = t.pending.map((e) => ({ role: chatRoleFor(e.kind), text: e.text })); t.pending = []
1932
+ const taken = t.pending; t.pending = []
1933
+ const prevSentLive = t.sentLive
1934
+ const prevSentActivity = t.sentActivity
1867
1935
  const streamingText = liveChanged ? t.liveText : undefined
1868
1936
  const activityText = activityChanged ? t.liveActivity : undefined
1869
1937
  t.sentLive = t.liveText
1870
1938
  t.sentActivity = t.liveActivity
1871
- await postSessionEvents(cfg, entry.sessionId, batch, 'running', entry.capturedSession || undefined, streamingText, activityText)
1939
+ const ok = await postSessionEvents(
1940
+ cfg, entry.sessionId,
1941
+ taken.map((e) => ({ role: chatRoleFor(e.kind), text: e.text })),
1942
+ 'running', entry.capturedSession || undefined, streamingText, activityText,
1943
+ )
1944
+ if (!ok) {
1945
+ // Put it all back so the next tick re-sends it (gd-514). Dropping the batch
1946
+ // is what used to tear holes in the transcript; rewinding sentLive makes the
1947
+ // next post carry the current live text again (it's a replace, not a delta).
1948
+ t.pending = taken.concat(t.pending)
1949
+ t.sentLive = prevSentLive
1950
+ t.sentActivity = prevSentActivity
1951
+ }
1872
1952
  t.lastPostAt = Date.now()
1873
1953
  t.flushing = false
1874
1954
  }
@@ -1889,21 +1969,31 @@ async function finishChatTurn(cfg, entry, outcome) {
1889
1969
  for (let i = 0; i < 20 && t.flushing; i++) await new Promise((r) => setTimeout(r, 100))
1890
1970
  const batch = t.pending.map((e) => ({ role: chatRoleFor(e.kind), text: e.text }))
1891
1971
  t.pending = []
1972
+ // The one post that MUST land: it carries the last of the reply and the
1973
+ // terminal status. Lose it and the console shows "АИ пише…" over a
1974
+ // half-written answer forever (gd-514) — so it gets ~1 minute of retries,
1975
+ // enough to outlast an app restart. The server's stall watchdog is the net if
1976
+ // even that fails.
1977
+ const terminal = async (status, extra) => {
1978
+ const ok = await postSessionEvents(
1979
+ cfg, entry.sessionId, extra ? batch.concat(extra) : batch, status,
1980
+ entry.capturedSession || undefined, '', '', 7,
1981
+ )
1982
+ if (!ok) log(`✗ ai_chat ${entry.sessionId}: терминалният пост не мина — сървърът ще помири сесията`)
1983
+ }
1892
1984
  if (outcome.stopped) {
1893
- batch.push({ role: 'SYSTEM', text: '⏹ Спряно. Напиши още нещо, за да продължим разговора.' })
1894
- await postSessionEvents(cfg, entry.sessionId, batch, 'idle', entry.capturedSession || undefined, '', '')
1985
+ await terminal('idle', { role: 'SYSTEM', text: '⏹ Спряно. Напиши още нещо, за да продължим разговора.' })
1895
1986
  reportCommandResult(cfg, t.cmdId, 'done', 'stopped by user')
1896
1987
  log(`⏹ ai_chat ${entry.sessionId} спряно от потребителя`)
1897
1988
  return
1898
1989
  }
1899
1990
  if (!outcome.ok) {
1900
- batch.push({ role: 'SYSTEM', text: `✗ Ходът приключи с код ${outcome.code}.` })
1901
- await postSessionEvents(cfg, entry.sessionId, batch, 'error', entry.capturedSession || undefined, '', '')
1991
+ await terminal('error', { role: 'SYSTEM', text: `✗ Ходът приключи с код ${outcome.code}.` })
1902
1992
  reportCommandResult(cfg, t.cmdId, 'error', `exit ${outcome.code}`)
1903
1993
  log(`✗ ai_chat ход в ${entry.sessionId} падна (code ${outcome.code})`)
1904
1994
  return
1905
1995
  }
1906
- await postSessionEvents(cfg, entry.sessionId, batch, 'idle', entry.capturedSession || undefined, '', '')
1996
+ await terminal('idle')
1907
1997
  reportCommandResult(cfg, t.cmdId, 'done', 'ok')
1908
1998
  log(`✓ ai_chat ход в ${entry.sessionId} приключи (процесът остава жив)`)
1909
1999
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gitdone-agent",
3
- "version": "0.8.3",
4
- "description": "Local git agent for gitdone — watches a local repo and sends snapshots to gitdone.eu",
3
+ "version": "0.8.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": {
7
7
  "gitdone-agent": "index.js"