@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.
- package/agent-stop.mjs +22 -5
- package/client-expert-api.mjs +89 -8
- package/client-expert-run.mjs +104 -12
- package/gonext_agent_chat.py +217 -11
- package/gu-cli.mjs +56 -29
- package/gu-repl.mjs +311 -31
- package/image-paste.mjs +10 -0
- package/model-setup-ui.mjs +114 -0
- package/ollama-setup.mjs +128 -23
- package/package.json +5 -1
- package/setup-prompts.mjs +82 -0
- package/terminal-layout.mjs +31 -1
- package/turn-stop.mjs +78 -0
- package/workspace-revert.mjs +106 -0
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|