@tiens.nguyen/gu-cli 1.0.768 → 1.0.770

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.
@@ -997,6 +997,13 @@ class _AgentConfigError(RuntimeError):
997
997
  worth a plain-reply degrade — the user needs the message itself to fix Settings."""
998
998
 
999
999
 
1000
+ class _AgentOffline(_AgentConfigError):
1001
+ """The network was gone for longer than the wait budget (task #302, part 3). Ends the turn
1002
+ as FAILED — never as a stop — with the work so far kept and "continue" as the way on. A
1003
+ subclass of the config error so it takes the same honest exit: no degrade to a plain reply,
1004
+ which would need the network anyway and would answer as if the work had been done."""
1005
+
1006
+
1000
1007
  class _AgentBackendOverloaded(RuntimeError):
1001
1008
  """The coding backend refused every attempt with 429/rate-limit (task #114). Carries
1002
1009
  the user-facing explanation; like a gateway outage it must NOT degrade to a plain
@@ -3539,6 +3546,78 @@ def _retry_delay(attempt: int, overloaded: bool, gateway: bool, err=None) -> flo
3539
3546
  return 1.5 * attempt
3540
3547
 
3541
3548
 
3549
+ _OFFLINE_ALWAYS = (
3550
+ # The NAME did not resolve, or there is no route at all: this machine is off the network.
3551
+ "nodename nor servname", "name or service not known", "getaddrinfo failed",
3552
+ "temporary failure in name resolution", "name resolution", "network is unreachable",
3553
+ "no route to host", "network is down", "connection error",
3554
+ )
3555
+ _OFFLINE_REMOTE = (
3556
+ # Nothing answered at the other end. For a PUBLIC host that is the network (Wi-Fi dropped, a
3557
+ # VPN reconnecting); for a local/LAN box it is the server itself being down, which the
3558
+ # gateway budget already handles — waiting ten minutes for a stopped Ollama would be wrong.
3559
+ "connection refused", "connection reset", "connection aborted",
3560
+ "failed to establish a new connection", "remote end closed connection",
3561
+ "server disconnected", "connecterror",
3562
+ )
3563
+ _OFFLINE_TYPES = ("APIConnectionError", "ConnectError", "ConnectionError", "RemoteProtocolError",
3564
+ "ConnectionRefusedError", "ConnectionResetError", "ConnectionAbortedError",
3565
+ "gaierror", "NewConnectionError")
3566
+
3567
+
3568
+ def _is_offline(err, host: str = "") -> bool:
3569
+ """Did this call fail because the NETWORK went away, rather than because a server answered?
3570
+ (task #302, part 3). Such a failure is waited out — the same step is retried when the
3571
+ connection is back — instead of costing the turn a few seconds after the Wi-Fi drops.
3572
+
3573
+ Something that answered with a status is never offline, and a timeout is its own class
3574
+ (openai's APITimeoutError subclasses APIConnectionError, so it is excluded by name)."""
3575
+ if err is None or _http_status_of(err) is not None or _is_request_timeout(err):
3576
+ return False
3577
+ local = _host_locality_is_local(host)
3578
+ cur, depth = err, 0
3579
+ while cur is not None and depth < 6:
3580
+ name = type(cur).__name__
3581
+ text = str(cur or "").lower()
3582
+ if any(w in text for w in _OFFLINE_ALWAYS):
3583
+ return True
3584
+ if not local and (name in _OFFLINE_TYPES or any(w in text for w in _OFFLINE_REMOTE)):
3585
+ return True
3586
+ cur, depth = (getattr(cur, "__cause__", None) or getattr(cur, "__context__", None)), depth + 1
3587
+ return False
3588
+
3589
+
3590
+ def _host_locality_is_local(host: str) -> bool:
3591
+ """localhost, 127.x, 10.x, 172.16-31.x, 192.168.x, *.local, a bare name → True."""
3592
+ import re as _re
3593
+ h = str(host or "").strip().lower()
3594
+ h = _re.sub(r"^https?://", "", h).split("/")[0]
3595
+ h = _re.sub(r":\d+$", "", h) if not h.startswith("[") else h
3596
+ if not h:
3597
+ return False
3598
+ if h in ("localhost", "::1", "[::1]") or h.startswith("127."):
3599
+ return True
3600
+ m = _re.match(r"^(\d+)\.(\d+)\.\d+\.\d+$", h)
3601
+ if m:
3602
+ a, b = int(m.group(1)), int(m.group(2))
3603
+ return a == 10 or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168)
3604
+ return "." not in h or h.endswith((".local", ".lan", ".internal", ".home"))
3605
+
3606
+
3607
+ def _offline_wait_budget() -> float:
3608
+ """How long a turn waits for the network before giving up (GONEXT_OFFLINE_WAIT_S, default 10 min)."""
3609
+ try:
3610
+ v = float(os.environ.get("GONEXT_OFFLINE_WAIT_S", "") or 600)
3611
+ except ValueError:
3612
+ v = 600.0
3613
+ return max(0.0, v)
3614
+
3615
+
3616
+ def _offline_delay(n: int) -> float:
3617
+ """Seconds before offline retry #n: 2 → 5 → 10 → 15 (capped). Pure."""
3618
+ return (2.0, 5.0, 10.0)[n - 1] if 1 <= n <= 3 else 15.0
3619
+
3620
+
3542
3621
  def _is_backend_unavailable(err) -> bool:
3543
3622
  s = str(err or "").lower()
3544
3623
  # An overload (429) is its own class — never let it fall into the gateway bucket, whose
@@ -3737,6 +3816,12 @@ def _stream_fallback_decision(err, first_token_seen: bool) -> str:
3737
3816
  return "raise"
3738
3817
  if _is_request_timeout(err) or _is_backend_unavailable(err):
3739
3818
  return "raise"
3819
+ # THE NETWORK WENT (task #302, part 3). Found by the mid-turn offline test: "Connection
3820
+ # error." matched nothing above, so a dropped Wi-Fi re-sent the step UNSTREAMED and marked the
3821
+ # server as unable to stream for the rest of the process. A lost connection says nothing about
3822
+ # the request's shape — raise, and the retry loop waits for the network.
3823
+ if _is_offline(err):
3824
+ return "raise"
3740
3825
  return "fallback"
3741
3826
 
3742
3827
 
@@ -6875,24 +6960,115 @@ def _ws_kill_tree(pid: int, force: bool = False) -> None:
6875
6960
  # what is still in flight and exits. Servers/apps already left running are not in this set.
6876
6961
  _INFLIGHT_CMDS: set = set()
6877
6962
 
6963
+ # A STOP NEVER LANDS INSIDE A FILE WRITE (task #302). The handler used to os._exit at once, and a
6964
+ # workspace write was open(path, "w") — which EMPTIES the file first — then write(): a Ctrl+C in
6965
+ # between left the user's file empty or cut off. Writes are now atomic (_ws_write_file), and a
6966
+ # stop that arrives DURING one only sets `requested`; the writer exits the moment it is done.
6967
+ # Anywhere else — a model call, which is most of a turn — the stop is immediate, as before.
6968
+ _STOP = {"writing": 0, "requested": False}
6878
6969
 
6879
- def _on_sigterm(signum, frame): # noqa: ARG001
6970
+
6971
+ def _stop_now():
6880
6972
  for pid in list(_INFLIGHT_CMDS):
6881
6973
  _ws_kill_tree(pid, force=True)
6882
6974
  _INFLIGHT_CMDS.clear()
6883
6975
  try:
6884
- _log("stopped by the worker (SIGTERM) — killed the command in flight")
6976
+ _log("stopped by the worker — killed the command in flight")
6885
6977
  except Exception: # noqa: BLE001
6886
6978
  pass
6887
6979
  os._exit(143)
6888
6980
 
6889
6981
 
6982
+ def _request_stop():
6983
+ if _STOP["writing"] > 0:
6984
+ _STOP["requested"] = True
6985
+ return
6986
+ _stop_now()
6987
+
6988
+
6989
+ def _on_sigterm(signum, frame): # noqa: ARG001
6990
+ _request_stop()
6991
+
6992
+
6993
+ def _watch_stop_file(path: str):
6994
+ """WINDOWS HAS NO SIGTERM: the worker's kill there is TerminateProcess, which no handler sees.
6995
+ So the worker creates this file first and kills only after a grace period — and this thread
6996
+ turns the file into the same safe stop the POSIX signal gets."""
6997
+ import threading
6998
+ import time as _t
6999
+
7000
+ def _loop():
7001
+ while True:
7002
+ try:
7003
+ if os.path.exists(path):
7004
+ _request_stop()
7005
+ return
7006
+ except Exception: # noqa: BLE001
7007
+ pass
7008
+ _t.sleep(0.2)
7009
+
7010
+ threading.Thread(target=_loop, name="gu-stop-file", daemon=True).start()
7011
+
7012
+
6890
7013
  def _install_sigterm_handler():
6891
7014
  import signal
6892
7015
  try:
6893
7016
  signal.signal(signal.SIGTERM, _on_sigterm)
6894
7017
  except (ValueError, OSError): # not the main thread, or a platform without it
6895
7018
  pass
7019
+ stop_file = os.environ.get("GONEXT_STOP_FILE", "").strip()
7020
+ if stop_file:
7021
+ _watch_stop_file(stop_file)
7022
+
7023
+
7024
+ def _ws_write_file(path: str, content: str, action: str, backup) -> None:
7025
+ """Write a workspace file SAFELY TO STOP AT ANY MOMENT (task #302).
7026
+
7027
+ 1. The change goes on the undo list FIRST — a stop right after the write is still undoable.
7028
+ 2. The content goes to a temp file beside the target and is swapped in with os.replace, so
7029
+ the file is always wholly the old version or wholly the new one, even under a hard kill.
7030
+ 3. A stop requested meanwhile waits for the swap (see _STOP), then happens.
7031
+
7032
+ A symlink is written THROUGH (its target is replaced, the link stays), as open(path, "w")
7033
+ did. Windows can refuse the swap while another program holds the file; then it falls back to
7034
+ the direct write rather than failing the edit.
7035
+ """
7036
+ import tempfile
7037
+ _ws_record_change(action, path, backup)
7038
+ target = os.path.realpath(path)
7039
+ _STOP["writing"] += 1
7040
+ try:
7041
+ fd, tmp = tempfile.mkstemp(prefix=".gu-write-", suffix=".tmp", dir=os.path.dirname(target) or ".")
7042
+ try:
7043
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
7044
+ fh.write(content)
7045
+ fh.flush()
7046
+ os.fsync(fh.fileno())
7047
+ if os.path.exists(target):
7048
+ import shutil
7049
+ shutil.copymode(target, tmp)
7050
+ else:
7051
+ mask = os.umask(0)
7052
+ os.umask(mask)
7053
+ os.chmod(tmp, 0o666 & ~mask)
7054
+ try:
7055
+ os.replace(tmp, target)
7056
+ except PermissionError:
7057
+ if not _IS_WINDOWS:
7058
+ raise
7059
+ with open(target, "w", encoding="utf-8") as fh:
7060
+ fh.write(content)
7061
+ os.unlink(tmp)
7062
+ except BaseException:
7063
+ try:
7064
+ os.unlink(tmp)
7065
+ except OSError:
7066
+ pass
7067
+ raise
7068
+ finally:
7069
+ _STOP["writing"] -= 1
7070
+ if _STOP["writing"] == 0 and _STOP["requested"]:
7071
+ _stop_now()
6896
7072
 
6897
7073
 
6898
7074
  def _ws_pgroup_pids(pgid: int) -> list:
@@ -10328,10 +10504,15 @@ def run_agent_chat(cfg):
10328
10504
  # tries a normal error gets. The attempt count is decided per-error: we start
10329
10505
  # with the generic budget and extend it the moment we see a gateway error.
10330
10506
  attempt, max_attempts = 0, 3
10507
+ # Task #302 part 3: when the network went away. None = online.
10508
+ offline = {"since": None, "n": 0, "said": 0.0}
10331
10509
  while attempt < max_attempts:
10332
10510
  try:
10333
10511
  msg = self._generate_once(*args, **kwargs)
10334
10512
  last_err = None
10513
+ if offline["since"] is not None:
10514
+ _emit({"type": "step", "text": "Connection back — continuing"})
10515
+ _log(f"connection back after {time.monotonic() - offline['since']:.0f}s")
10335
10516
  break
10336
10517
  except Exception as e: # noqa: BLE001
10337
10518
  emsg = str(e)
@@ -10395,6 +10576,37 @@ def run_agent_chat(cfg):
10395
10576
  "names (for Ollama use the tag, e.g. qwen3:14b)."
10396
10577
  ) from e
10397
10578
  last_err = e
10579
+ # THE NETWORK WENT AWAY (task #302, part 3). Not an attempt: the same
10580
+ # step waits for the connection and is sent again — everything this turn
10581
+ # did so far is still in this process, so nothing is redone and nothing is
10582
+ # undone. Only a wait past the budget ends the turn, as FAILED (never as a
10583
+ # stop), with "continue" as the way on.
10584
+ if _is_offline(e, _host_of(coding_base_url)):
10585
+ _host = _host_of(coding_base_url)
10586
+ now = time.monotonic()
10587
+ if offline["since"] is None:
10588
+ offline.update(since=now, n=0, said=now)
10589
+ _emit({"type": "step", "text": (
10590
+ f"Lost the connection to {_host} — waiting for it to come back…")})
10591
+ waited = now - offline["since"]
10592
+ budget = _offline_wait_budget()
10593
+ if waited < budget:
10594
+ offline["n"] += 1
10595
+ if now - offline["said"] >= 60:
10596
+ offline["said"] = now
10597
+ _emit({"type": "step", "text": (
10598
+ f"Still waiting for the connection to {_host}… "
10599
+ f"({waited / 60:.0f} min)")})
10600
+ delay = min(_offline_delay(offline["n"]), max(1.0, budget - waited))
10601
+ _log(f"offline ({_clip(emsg, 120)}) — waited {waited:.0f}s, "
10602
+ f"retrying the same step in {delay:.0f}s")
10603
+ time.sleep(delay)
10604
+ continue
10605
+ raise _AgentOffline(
10606
+ f"Lost the connection to {_host} for {budget / 60:.0f} minutes, so "
10607
+ "the turn could not go on. The work so far is kept — nothing was "
10608
+ "undone. Type continue to pick up once you are back online."
10609
+ ) from e
10398
10610
  # An OVERLOAD (429/rate limit, task #114) is its own class: the
10399
10611
  # server answered and told us to slow down, so it needs a PATIENT,
10400
10612
  # jittered backoff rather than the gateway policy's prompt re-send.
@@ -11997,9 +12209,7 @@ def run_agent_chat(cfg):
11997
12209
  new_lines[-1] += "\n"
11998
12210
  old_lines = lines[s - 1:e]
11999
12211
  lines[s - 1:e] = new_lines
12000
- with open(rp, "w", encoding="utf-8") as fh:
12001
- fh.writelines(lines)
12002
- _ws_record_change("edit", rp, backup)
12212
+ _ws_write_file(rp, "".join(lines), "edit", backup)
12003
12213
  _emit({"type": "step", "text": _render_edit_card(
12004
12214
  "Update", rp,
12005
12215
  f"Replaced lines {s}-{e} with {len(new_lines)} line(s)",
@@ -12046,9 +12256,7 @@ def run_agent_chat(cfg):
12046
12256
  _last_obs["text"] = msg
12047
12257
  return msg
12048
12258
  backup = _ws_snapshot(rp)
12049
- with open(rp, "w", encoding="utf-8") as fh:
12050
- fh.write(text.replace(old_string, new_string, 1))
12051
- _ws_record_change("edit", rp, backup)
12259
+ _ws_write_file(rp, text.replace(old_string, new_string, 1), "edit", backup)
12052
12260
  start_no = text[:text.index(old_string)].count("\n") + 1
12053
12261
  old_ls = old_string.splitlines() or [""]
12054
12262
  new_ls = new_string.splitlines() or [""]
@@ -12114,9 +12322,7 @@ def run_agent_chat(cfg):
12114
12322
  return msg
12115
12323
  backup = _ws_snapshot(rp) if existed else ""
12116
12324
  os.makedirs(os.path.dirname(rp) or ".", exist_ok=True)
12117
- with open(rp, "w", encoding="utf-8") as fh:
12118
- fh.write(content)
12119
- _ws_record_change("edit" if existed else "create", rp, backup or None)
12325
+ _ws_write_file(rp, content, "edit" if existed else "create", backup or None)
12120
12326
  content_ls = content.splitlines() or [""]
12121
12327
  _emit({"type": "step", "text": _render_edit_card(
12122
12328
  "Update" if existed else "Create", rp,
package/gu-cli.mjs CHANGED
@@ -17,7 +17,8 @@ import { accent } from "./accent.mjs";
17
17
  requireSupportedNode();
18
18
  import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
19
19
  import { execFile as execFileCallback, spawn } from "node:child_process";
20
- import { createHash } from "node:crypto";
20
+ import { createHash, randomUUID } from "node:crypto";
21
+ import { writeFileSync, unlinkSync } from "node:fs";
21
22
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
22
23
  import { NO_STEP, foldStepTokens, withStepTokens } from "./step-tokens.mjs";
23
24
  import { agentStoppedLine, jobEnded, jobStatusFrom409, stopGracefully } from "./agent-stop.mjs";
@@ -1743,6 +1744,39 @@ async function workerFetch(path, init = {}) {
1743
1744
  return fetch(url, { ...init, headers });
1744
1745
  }
1745
1746
 
1747
+ /**
1748
+ * workerFetch that WAITS OUT A LOST NETWORK (task #302, part 3) — for the writes that must land,
1749
+ * like a finished turn's result. A remote API unreachable is usually this machine being offline for
1750
+ * a moment; the work is done and only its report is waiting, so it is retried with backoff for the
1751
+ * offline budget (GONEXT_OFFLINE_WAIT_S, default 10 min) rather than lost. Only NETWORK errors are
1752
+ * retried — an HTTP answer, even an error, is returned as it is — and a local API is not waited for
1753
+ * (unreachable there means the API process is gone).
1754
+ */
1755
+ async function workerFetchPatient(path, init = {}, { sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
1756
+ let local = false;
1757
+ try {
1758
+ const h = new URL(apiBase).hostname;
1759
+ local = h === "localhost" || h === "::1" || h.startsWith("127.");
1760
+ } catch {
1761
+ /* no base — the plain call will say so */
1762
+ }
1763
+ const budgetMs = (() => {
1764
+ const v = Number(process.env.GONEXT_OFFLINE_WAIT_S);
1765
+ return (Number.isFinite(v) && v >= 0 ? v : 600) * 1000;
1766
+ })();
1767
+ const started = Date.now();
1768
+ for (let n = 1; ; n++) {
1769
+ try {
1770
+ return await workerFetch(path, init);
1771
+ } catch (err) {
1772
+ const network = err instanceof TypeError || /fetch failed|ECONN|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up/i.test(String(err?.message ?? err));
1773
+ if (local || !network || Date.now() - started >= budgetMs) throw err;
1774
+ if (n === 1) console.log(`[gu-worker] ${path}: the API is unreachable (${String(err?.message ?? err).slice(0, 80)}) — waiting for the network`);
1775
+ await sleep(Math.min(2000 * n, 15000));
1776
+ }
1777
+ }
1778
+ }
1779
+
1746
1780
  async function ensureWorkerOk(res, context) {
1747
1781
  if (res.ok) return;
1748
1782
  const snippet = (await res.text().catch(() => "")).trim().slice(0, 500);
@@ -1887,29 +1921,12 @@ if (args[0] === "workspace") {
1887
1921
  console.error(`[gu-worker] workspace revert: no manifest for run ${runId} (${manifestPath})`);
1888
1922
  process.exit(1);
1889
1923
  }
1890
- let restored = 0, deleted = 0, failed = 0;
1891
- for (const c of manifest?.changes ?? []) {
1892
- try {
1893
- if (c.action === "edit" && c.backup && ex(c.backup)) {
1894
- await copyFile(c.backup, c.path);
1895
- restored += 1;
1896
- console.log(` restored ${c.path}`);
1897
- } else if (c.action === "create" && ex(c.path)) {
1898
- await unlink(c.path);
1899
- deleted += 1;
1900
- console.log(` deleted ${c.path}`);
1901
- } else if (c.action === "create_dir" && ex(c.path)) {
1902
- // rmdir only removes an EMPTY dir — a revert must never destroy content
1903
- // the user (or a later run) put inside the created folder afterwards.
1904
- await rmdir(c.path);
1905
- deleted += 1;
1906
- console.log(` removed ${c.path}/`);
1907
- }
1908
- } catch (err) {
1909
- failed += 1;
1910
- console.error(` FAILED ${c.path}: ${err.message}`);
1911
- }
1912
- }
1924
+ // One step per path from its EARLIEST entry (workspace-revert.mjs, #302): replaying the log
1925
+ // in order deleted a file the run created and then brought it back from its "edit" backup.
1926
+ const { planRevert, applyRevert } = await import("./workspace-revert.mjs");
1927
+ const r = await applyRevert(planRevert(manifest?.changes ?? []), { exists: ex });
1928
+ for (const l of r.lines) (l.startsWith("FAILED") ? console.error : console.log)(` ${l}`);
1929
+ const { restored, deleted, failed } = r;
1913
1930
  console.log(
1914
1931
  `[gu-worker] workspace revert ${runId}: ${restored} restored, ${deleted} created-file(s) deleted` +
1915
1932
  (failed ? `, ${failed} FAILED` : "") +
@@ -2452,7 +2469,11 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2452
2469
  // lone surrogates and only blows up later, encoding the model request. gonext_agent_chat.py
2453
2470
  // also reconfigures its own streams; this covers the scripts that don't.
2454
2471
  const spawnOpts = { stdio: ["pipe", "pipe", "pipe"] };
2455
- spawnOpts.env = { ...process.env, PYTHONUTF8: "1", ...(extraEnv ?? {}) };
2472
+ // Task #302: where to ask the agent to stop on Windows (see stopGracefully) — a file it
2473
+ // watches, so a stop waits for a file write in progress instead of cutting it in half.
2474
+ const stopFile = join(tmpdir(), `gu-agent-stop-${randomUUID()}`);
2475
+ spawnOpts.env = { ...process.env, PYTHONUTF8: "1", GONEXT_STOP_FILE: stopFile, ...(extraEnv ?? {}) };
2476
+ const requestStop = () => writeFileSync(stopFile, "stop");
2456
2477
  const child = spawn(cmd, cmdArgs, spawnOpts);
2457
2478
  let stderr = "";
2458
2479
  let lineBuffer = "";
@@ -2470,7 +2491,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2470
2491
  if (typeof onTimeout !== "function") {
2471
2492
  timedOut = true;
2472
2493
  timerLive = false;
2473
- cancelHardKill = stopGracefully(child);
2494
+ cancelHardKill = stopGracefully(child, { requestStop });
2474
2495
  return;
2475
2496
  }
2476
2497
  let extend = false;
@@ -2486,7 +2507,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2486
2507
  }
2487
2508
  timedOut = true;
2488
2509
  timerLive = false;
2489
- cancelHardKill = stopGracefully(child);
2510
+ cancelHardKill = stopGracefully(child, { requestStop });
2490
2511
  };
2491
2512
  const timer = setTimeout(fireTimeout, timeoutMs);
2492
2513
  // User-requested cancel (gu Ctrl+C): kill the Python agent so it stops burning
@@ -2497,7 +2518,7 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2497
2518
  const onAbort = () => {
2498
2519
  if (aborted) return;
2499
2520
  aborted = true;
2500
- cancelHardKill = stopGracefully(child);
2521
+ cancelHardKill = stopGracefully(child, { requestStop });
2501
2522
  };
2502
2523
  if (abortSignal) {
2503
2524
  if (abortSignal.aborted) onAbort();
@@ -2532,6 +2553,11 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
2532
2553
  timerLive = false;
2533
2554
  clearTimeout(timer);
2534
2555
  cancelHardKill();
2556
+ try {
2557
+ unlinkSync(stopFile);
2558
+ } catch {
2559
+ /* never asked to stop — no file */
2560
+ }
2535
2561
  if (abortSignal) abortSignal.removeEventListener?.("abort", onAbort);
2536
2562
  // Drain any remaining buffered line
2537
2563
  const remaining = lineBuffer.trim();
@@ -4257,7 +4283,8 @@ async function runAgentChatJob(job) {
4257
4283
  await flushChunks();
4258
4284
 
4259
4285
  const totalTimeSeconds = (Date.now() - start) / 1000;
4260
- const doneRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
4286
+ // Patient (#302 part 3): the turn is DONE; a network blip now must not throw its result away.
4287
+ const doneRes = await workerFetchPatient(`/api/worker/jobs/${jobId}`, {
4261
4288
  method: "PATCH",
4262
4289
  body: JSON.stringify({
4263
4290
  jobStatus: "completed",