@tiens.nguyen/gu-cli 1.0.769 → 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/gonext_agent_chat.py +217 -11
- package/gu-cli.mjs +56 -29
- package/gu-repl.mjs +195 -15
- package/image-paste.mjs +10 -0
- package/package.json +3 -1
- package/turn-stop.mjs +78 -0
- package/workspace-revert.mjs +106 -0
package/agent-stop.mjs
CHANGED
|
@@ -41,11 +41,28 @@ export const STOP_GRACE_MS = 3000;
|
|
|
41
41
|
* Stop a child process: SIGTERM now, SIGKILL after `graceMs` unless it has exited.
|
|
42
42
|
* → a function that cancels the pending SIGKILL (call it from the child's `close`).
|
|
43
43
|
*/
|
|
44
|
-
export function stopGracefully(child, {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
export function stopGracefully(child, {
|
|
45
|
+
graceMs = STOP_GRACE_MS,
|
|
46
|
+
setTimer = setTimeout,
|
|
47
|
+
clearTimer = clearTimeout,
|
|
48
|
+
platform = process.platform,
|
|
49
|
+
// WINDOWS HAS NO SIGTERM (task #302): child.kill("SIGTERM") there IS TerminateProcess, which no
|
|
50
|
+
// handler sees — so a stop could land in the middle of the agent writing a file. There, ask
|
|
51
|
+
// first (the agent watches GONEXT_STOP_FILE and stops at a safe point) and kill after the grace.
|
|
52
|
+
requestStop = null,
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (platform === "win32" && typeof requestStop === "function") {
|
|
55
|
+
try {
|
|
56
|
+
requestStop();
|
|
57
|
+
} catch {
|
|
58
|
+
/* could not ask — the kill after the grace still stops it */
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
child.kill("SIGTERM");
|
|
63
|
+
} catch {
|
|
64
|
+
/* already gone */
|
|
65
|
+
}
|
|
49
66
|
}
|
|
50
67
|
const t = setTimer(() => {
|
|
51
68
|
if (child.exitCode === null && child.signalCode === null) {
|
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",
|
package/gu-repl.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
27
27
|
import { readStepTokens } from "./step-tokens.mjs";
|
|
28
|
+
import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
|
|
28
29
|
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
29
30
|
import { spawn } from "node:child_process";
|
|
30
31
|
// FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
|
|
@@ -867,6 +868,7 @@ const COMMANDS = [
|
|
|
867
868
|
// they worked, Tab-completed to nothing, and appeared in no help. Aliases is what the registry
|
|
868
869
|
// already has for exactly this — /quit, /rag-local.
|
|
869
870
|
{ name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
|
|
871
|
+
{ name: "/undo", usage: "/undo [runId]", desc: "put back the files the last stopped turn changed (from its backups) — asks first" },
|
|
870
872
|
{ name: "/server", desc: "pick a deployment server (host/user) for this session" },
|
|
871
873
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
872
874
|
{ name: "/s3", usage: "/s3 [test]", desc: "set up YOUR S3 bucket (RAG index, and the PDFs/downloads gu makes) and check the AWS keys work — without it, files are saved on this computer" },
|
|
@@ -1505,6 +1507,14 @@ const stripAnsiEscapes = (s) =>
|
|
|
1505
1507
|
const PASTE_MIN_CHARS = 200;
|
|
1506
1508
|
const PASTE_MIN_LINES = 3;
|
|
1507
1509
|
const pasteStore = new Map(); // token -> the real text
|
|
1510
|
+
// Task #302: the last line as TYPED — placeholders and all — taken before it is expanded for the
|
|
1511
|
+
// model, so a turn stopped with Ctrl+C can give the question back for editing.
|
|
1512
|
+
let lastSubmittedInput = null;
|
|
1513
|
+
// Task #302: set while the input holds a stopped turn's question; the next line sent from it
|
|
1514
|
+
// REPLACES that question in history when it was edited. Cleared by "never mind" (Ctrl+C).
|
|
1515
|
+
let prefillFor = null;
|
|
1516
|
+
// Task #302: the backup run of the last stopped turn — what /undo reverts by default.
|
|
1517
|
+
let lastStoppedRun = null;
|
|
1508
1518
|
// ---------- pasted images (task #284) ----------
|
|
1509
1519
|
const imageStore = createImageStore();
|
|
1510
1520
|
// Shown on the bottom row after a refused or failed image paste; clears itself.
|
|
@@ -1619,6 +1629,23 @@ const isNetworkError = (e) =>
|
|
|
1619
1629
|
/fetch failed|network|ECONN|ETIMEDOUT|EAI_AGAIN|socket hang up|und_err|terminated/i.test(
|
|
1620
1630
|
String(e?.message ?? e)
|
|
1621
1631
|
);
|
|
1632
|
+
/** Is the API on this machine? Then "unreachable" means the API process, not the network (#302). */
|
|
1633
|
+
const apiIsLocal = () => {
|
|
1634
|
+
try {
|
|
1635
|
+
const h = new URL(apiBase).hostname;
|
|
1636
|
+
return h === "localhost" || h === "::1" || h === "[::1]" || h.startsWith("127.");
|
|
1637
|
+
} catch {
|
|
1638
|
+
return false;
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
/** How long a turn waits for the network (GONEXT_OFFLINE_WAIT_S, default 10 min) — the agent's own budget. */
|
|
1642
|
+
const offlineWaitMs = () => {
|
|
1643
|
+
const v = Number(process.env.GONEXT_OFFLINE_WAIT_S);
|
|
1644
|
+
return (Number.isFinite(v) && v >= 0 ? v : 600) * 1000;
|
|
1645
|
+
};
|
|
1646
|
+
// #302 part 3: a turn this terminal lost contact with (the network dropped) — its job may still be
|
|
1647
|
+
// running, so "continue" follows it again instead of asking the question a second time.
|
|
1648
|
+
let lostTurn = null;
|
|
1622
1649
|
/**
|
|
1623
1650
|
* Did OUR OWN deadline fire, rather than the connection failing? → boolean.
|
|
1624
1651
|
*
|
|
@@ -1731,6 +1758,10 @@ rl.on("line", (l) => {
|
|
|
1731
1758
|
// user pasted.
|
|
1732
1759
|
// Images too (task #284): "[Image #1]" goes out as "[Image #1: <file>]" for the agent to
|
|
1733
1760
|
// attach; the next message numbers its images from #1 again.
|
|
1761
|
+
lastSubmittedInput = snapshotInput(stripAnsiEscapes(submitted), {
|
|
1762
|
+
pastes: pasteStore,
|
|
1763
|
+
images: new Map(imageStore.entries()),
|
|
1764
|
+
});
|
|
1734
1765
|
const clean = imageStore.expand(expandPasteTokens(stripAnsiEscapes(submitted)));
|
|
1735
1766
|
imageStore.clear();
|
|
1736
1767
|
const waiter = _lineWaiters.pop(); // innermost reader first — see _lineWaiters
|
|
@@ -6646,6 +6677,103 @@ async function runSavePresetCommand(line) {
|
|
|
6646
6677
|
* key is saved under the preset's name and made that row's default, and the embedder is set. The
|
|
6647
6678
|
* user reconfirms before anything changes, like /load-settings.
|
|
6648
6679
|
*/
|
|
6680
|
+
/**
|
|
6681
|
+
* After Ctrl+C stopped THIS terminal's turn (task #302): say what it left behind, and give the
|
|
6682
|
+
* question back.
|
|
6683
|
+
*
|
|
6684
|
+
* · the command it was in the middle of, if any — that one cannot be made safe, only named
|
|
6685
|
+
* · the files it changed (its backup run's manifest) — kept as they are; /undo offers to put
|
|
6686
|
+
* them back, because part of the work may be wanted and nothing is reverted by itself
|
|
6687
|
+
* · the question, exactly as typed (placeholders and all), back in the input — edit it and
|
|
6688
|
+
* press Enter; Ctrl+C clears it. Never sent by itself.
|
|
6689
|
+
*
|
|
6690
|
+
* What the turn printed stays on screen: it is the record of what it did.
|
|
6691
|
+
*/
|
|
6692
|
+
async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand = "", typed = "" }) {
|
|
6693
|
+
let files = [];
|
|
6694
|
+
try {
|
|
6695
|
+
const ws = await import("./workspace-revert.mjs");
|
|
6696
|
+
const runId = ws.runSince(turnStartMs);
|
|
6697
|
+
if (runId) {
|
|
6698
|
+
const changes = (await ws.readRunChanges(runId)) ?? [];
|
|
6699
|
+
files = changes.filter((c) => c?.action !== "create_dir").map((c) => c.path);
|
|
6700
|
+
if (files.length) lastStoppedRun = runId;
|
|
6701
|
+
}
|
|
6702
|
+
} catch {
|
|
6703
|
+
/* no backups readable — nothing to name */
|
|
6704
|
+
}
|
|
6705
|
+
// A bare "continue" was the typed line: giving THAT back is no help, so only the note.
|
|
6706
|
+
const canRestore = Boolean(submittedInput?.raw) && !isResumeWord(typed);
|
|
6707
|
+
for (const l of stopNoteLines({ files, command: stoppedCommand, restored: canRestore })) {
|
|
6708
|
+
console.log(GUTTER + dim(l));
|
|
6709
|
+
}
|
|
6710
|
+
console.log("");
|
|
6711
|
+
if (!canRestore) return;
|
|
6712
|
+
const text = restoreInput(submittedInput, { pasteStore, imageStore });
|
|
6713
|
+
rl.line = text;
|
|
6714
|
+
rl.cursor = text.length;
|
|
6715
|
+
rl.historyIndex = -1;
|
|
6716
|
+
prefillFor = { task: taskLine };
|
|
6717
|
+
if (barPinned()) paintBar();
|
|
6718
|
+
}
|
|
6719
|
+
|
|
6720
|
+
/**
|
|
6721
|
+
* /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
|
|
6722
|
+
* run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
|
|
6723
|
+
* would lose those edits, and that is said too.
|
|
6724
|
+
*/
|
|
6725
|
+
async function runUndoCommand(line) {
|
|
6726
|
+
const ws = await import("./workspace-revert.mjs");
|
|
6727
|
+
const named = line.trim().slice("/undo".length).trim();
|
|
6728
|
+
const runId = named || lastStoppedRun;
|
|
6729
|
+
console.log("");
|
|
6730
|
+
if (!runId) {
|
|
6731
|
+
console.log(GUTTER + dim("nothing to undo — /undo puts back what a turn you stopped with Ctrl+C changed."));
|
|
6732
|
+
console.log(GUTTER + dim(`(older runs: ${ws.BACKUPS_ROOT} — /undo <run>)\n`));
|
|
6733
|
+
return;
|
|
6734
|
+
}
|
|
6735
|
+
const changes = await ws.readRunChanges(runId);
|
|
6736
|
+
if (!changes) {
|
|
6737
|
+
console.log(GUTTER + red(`no backups for run ${runId}.\n`));
|
|
6738
|
+
return;
|
|
6739
|
+
}
|
|
6740
|
+
const steps = ws.planRevert(changes);
|
|
6741
|
+
if (!steps.length) {
|
|
6742
|
+
console.log(GUTTER + dim("that turn changed no files.\n"));
|
|
6743
|
+
return;
|
|
6744
|
+
}
|
|
6745
|
+
let manifestAt = 0;
|
|
6746
|
+
try {
|
|
6747
|
+
manifestAt = statSync(join(ws.BACKUPS_ROOT, runId, "manifest.json")).mtimeMs;
|
|
6748
|
+
} catch {
|
|
6749
|
+
/* unknown — no "edited since" warnings */
|
|
6750
|
+
}
|
|
6751
|
+
const verb = { restore: "restore", delete: "delete ", rmdir: "remove " };
|
|
6752
|
+
for (const st of steps) {
|
|
6753
|
+
let since = "";
|
|
6754
|
+
try {
|
|
6755
|
+
if (manifestAt && st.op !== "rmdir" && statSync(st.path).mtimeMs > manifestAt + 1000) {
|
|
6756
|
+
since = yellow(" (changed since — those edits would be lost)");
|
|
6757
|
+
}
|
|
6758
|
+
} catch {
|
|
6759
|
+
/* gone already */
|
|
6760
|
+
}
|
|
6761
|
+
console.log(GUTTER + `${verb[st.op]} ${st.path}` + since);
|
|
6762
|
+
}
|
|
6763
|
+
if (!(await askYesNo("Put these back?", false))) {
|
|
6764
|
+
console.log(GUTTER + dim("(nothing changed)\n"));
|
|
6765
|
+
return;
|
|
6766
|
+
}
|
|
6767
|
+
const r = await ws.applyRevert(steps);
|
|
6768
|
+
for (const l of r.lines) console.log(GUTTER + dim(l));
|
|
6769
|
+
console.log(
|
|
6770
|
+
GUTTER + (r.failed ? yellow : dim)(
|
|
6771
|
+
`${r.restored} restored, ${r.deleted} removed${r.failed ? `, ${r.failed} failed` : ""} — the backups are kept.`
|
|
6772
|
+
) + "\n"
|
|
6773
|
+
);
|
|
6774
|
+
if (runId === lastStoppedRun) lastStoppedRun = null;
|
|
6775
|
+
}
|
|
6776
|
+
|
|
6649
6777
|
async function runLoadPresetsCommand(line) {
|
|
6650
6778
|
const pr = await import("./presets-transfer.mjs");
|
|
6651
6779
|
const home = homedir();
|
|
@@ -9551,24 +9679,29 @@ async function ensureTokenForTurn() {
|
|
|
9551
9679
|
return false;
|
|
9552
9680
|
}
|
|
9553
9681
|
|
|
9554
|
-
async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
9682
|
+
async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
|
|
9555
9683
|
// #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
|
|
9556
9684
|
// startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
|
|
9557
9685
|
if (licenceBlock) {
|
|
9558
9686
|
console.log(GUTTER + orange("Not sent.") + dim(" " + licenceBlock) + "\n");
|
|
9559
9687
|
return;
|
|
9560
9688
|
}
|
|
9561
|
-
|
|
9689
|
+
// TWO THINGS "attaching" used to mean at once (task #302, part 3): the question is NOT sent
|
|
9690
|
+
// again (skipAsk), and the job is SOMEONE ELSE'S (attached — Ctrl+C only stops watching, its
|
|
9691
|
+
// tokens are theirs to count). Re-attaching to this terminal's OWN turn after a lost connection
|
|
9692
|
+
// is the first without the second: Ctrl+C there is a real cancel.
|
|
9693
|
+
const skipAsk = Boolean(attachJobId);
|
|
9694
|
+
const attached = skipAsk && !ownJob;
|
|
9562
9695
|
// PRIVATE MODE NEEDS THE gu TOKEN (task #246): the local API fetches the agent's instructions
|
|
9563
9696
|
// from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
|
|
9564
9697
|
// than letting the turn fail and leaving the user to decode why.
|
|
9565
|
-
if (!
|
|
9698
|
+
if (!skipAsk && !(await ensureTokenForTurn())) return;
|
|
9566
9699
|
inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
|
|
9567
9700
|
// Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
|
|
9568
9701
|
// the next thing to draw itself is the turn's ticker — which stays silent until the daemon
|
|
9569
9702
|
// claims the job, seconds away. This names what is actually happening meanwhile.
|
|
9570
|
-
startPendingStatus(
|
|
9571
|
-
const res =
|
|
9703
|
+
startPendingStatus(skipAsk ? "attaching" : "sending");
|
|
9704
|
+
const res = skipAsk ? null : await fetch(`${apiBase}/api/worker/agent-ask`, {
|
|
9572
9705
|
method: "POST",
|
|
9573
9706
|
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
9574
9707
|
// cwd = this terminal's folder → agent puts download/unzip output here (when it's
|
|
@@ -9628,7 +9761,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9628
9761
|
}
|
|
9629
9762
|
throw new Error(data?.error || `agent-ask failed (HTTP ${res.status})`);
|
|
9630
9763
|
}
|
|
9631
|
-
const jobId =
|
|
9764
|
+
const jobId = skipAsk ? attachJobId : data?.jobId;
|
|
9632
9765
|
if (!jobId) throw new Error("agent-ask returned no jobId.");
|
|
9633
9766
|
|
|
9634
9767
|
// THE CODER WAS DROPPED, AND NOTHING SAID SO (task #222 phase 0). When the picked coding
|
|
@@ -9640,7 +9773,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9640
9773
|
// Read off codingKeyMissingKnown, never off `data`: the flag lives under `payload` and this
|
|
9641
9774
|
// response is a different shape, so reaching for it here would be undefined — falsy — and
|
|
9642
9775
|
// warn nobody, which is the bug agent-payload-shape.test.mjs exists to catch.
|
|
9643
|
-
if (!
|
|
9776
|
+
if (!skipAsk && codingKeyMissingKnown === true) {
|
|
9644
9777
|
console.log(
|
|
9645
9778
|
GUTTER + orange("⚠ no API key for the coding model") +
|
|
9646
9779
|
dim(" — coding is running on your chat model. ctrl+k to set it.\n")
|
|
@@ -9652,7 +9785,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9652
9785
|
// hand-over to the turn's own ticker happens the moment that worker claims it. An ATTACHED
|
|
9653
9786
|
// job is usually already running, so it says what is true of it instead — the poll below
|
|
9654
9787
|
// corrects either of them within a second.
|
|
9655
|
-
setPendingStatus(
|
|
9788
|
+
setPendingStatus(skipAsk ? "attaching — reading its progress" : "queued — waiting for the worker");
|
|
9656
9789
|
following = true;
|
|
9657
9790
|
followingAttached = attached;
|
|
9658
9791
|
typedDuringTurn = false;
|
|
@@ -9694,6 +9827,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9694
9827
|
// key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
|
|
9695
9828
|
// long-running turn. Tolerate a run of them; only give up after too many in a row.
|
|
9696
9829
|
let pollFailures = 0;
|
|
9830
|
+
let pollOfflineSince = 0; // #302 part 3: when a remote API stopped answering (0 = reachable)
|
|
9697
9831
|
const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
|
|
9698
9832
|
let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
|
|
9699
9833
|
// Sticky: the agent RAN, so the coding model was called ("thinking…" was on screen). A
|
|
@@ -10534,6 +10668,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10534
10668
|
if (jr.ok) {
|
|
10535
10669
|
job = parsed;
|
|
10536
10670
|
pollFailures = 0; // a clean poll resets the tolerance window
|
|
10671
|
+
pollOfflineSince = 0;
|
|
10537
10672
|
} else if (jr.status >= 400 && jr.status < 500) {
|
|
10538
10673
|
// Client error (bad/expired key, job genuinely gone) — NOT transient, fail fast.
|
|
10539
10674
|
throw new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`);
|
|
@@ -10549,11 +10684,33 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10549
10684
|
const transient = err?.transient === true || isNetworkError(err);
|
|
10550
10685
|
if (!transient) throw err;
|
|
10551
10686
|
pollFailures += 1;
|
|
10552
|
-
|
|
10687
|
+
// THE NETWORK, NOT THE API (task #302, part 3). A REMOTE API that cannot be reached is
|
|
10688
|
+
// usually this machine being offline — a Wi-Fi drop, a VPN reconnecting — while the turn
|
|
10689
|
+
// itself carries on on its worker. So a network error is waited out for the same budget
|
|
10690
|
+
// the agent gives its own calls, instead of giving up after ~13s. A LOCAL API unreachable
|
|
10691
|
+
// is the API process itself gone (see "Agent killed its own API"), which waiting will not
|
|
10692
|
+
// fix — that keeps the quick give-up.
|
|
10693
|
+
const offlineWait = isNetworkError(err) && !apiIsLocal() ? offlineWaitMs() : 0;
|
|
10694
|
+
if (offlineWait) {
|
|
10695
|
+
if (!pollOfflineSince) pollOfflineSince = Date.now();
|
|
10696
|
+
const waited = Date.now() - pollOfflineSince;
|
|
10697
|
+
if (waited < offlineWait) {
|
|
10698
|
+
setPendingStatus(`reconnecting to the API… (${Math.round(waited / 1000)}s)`);
|
|
10699
|
+
await sleep(Math.min(1000 * pollFailures, 10000)); // backoff, capped at 10s
|
|
10700
|
+
continue;
|
|
10701
|
+
}
|
|
10702
|
+
}
|
|
10703
|
+
if (offlineWait || pollFailures >= MAX_POLL_FAILURES) {
|
|
10553
10704
|
clearStatus();
|
|
10554
|
-
|
|
10555
|
-
|
|
10556
|
-
|
|
10705
|
+
// The job id travels with the error: the turn may well still be running on its worker,
|
|
10706
|
+
// and "continue" should FOLLOW it again rather than ask the same question twice.
|
|
10707
|
+
throw Object.assign(
|
|
10708
|
+
new Error(
|
|
10709
|
+
`lost contact with the API after ${offlineWait ? `${Math.round(offlineWait / 60000)} min` : `${pollFailures} tries`} ` +
|
|
10710
|
+
`(${String(err.message).slice(0, 80)}). The turn may still be running on the worker — ` +
|
|
10711
|
+
"type continue to follow it again."
|
|
10712
|
+
),
|
|
10713
|
+
{ lostJobId: jobId }
|
|
10557
10714
|
);
|
|
10558
10715
|
}
|
|
10559
10716
|
await sleep(Math.min(700 * pollFailures, 3000)); // linear backoff, capped at 3s
|
|
@@ -10784,7 +10941,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10784
10941
|
// turn (main() drops it from history).
|
|
10785
10942
|
clearStatus();
|
|
10786
10943
|
process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
|
|
10787
|
-
return { text: "", shown: false, cancelled: true, started: reachedModel };
|
|
10944
|
+
return { text: "", shown: false, cancelled: true, started: reachedModel, stoppedCommand: runningCmd || "" };
|
|
10788
10945
|
}
|
|
10789
10946
|
if (job.jobStatus === "failed") {
|
|
10790
10947
|
clearStatus();
|
|
@@ -11637,6 +11794,7 @@ async function main() {
|
|
|
11637
11794
|
rl.line = "";
|
|
11638
11795
|
rl.cursor = 0;
|
|
11639
11796
|
rl.historyIndex = -1;
|
|
11797
|
+
prefillFor = null; // "never mind" also lets go of a restored question (#302)
|
|
11640
11798
|
if (_lineQueue.length > 0) _lineQueue.length = 0;
|
|
11641
11799
|
prunePasteStore("");
|
|
11642
11800
|
imageStore.clear();
|
|
@@ -11768,6 +11926,12 @@ async function main() {
|
|
|
11768
11926
|
footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
|
|
11769
11927
|
if (lineRaw === null) break; // stdin closed
|
|
11770
11928
|
const line = lineRaw.trim();
|
|
11929
|
+
// #302: what was typed for THIS line (before expansion), and whether it was sent from a
|
|
11930
|
+
// stopped turn's restored question.
|
|
11931
|
+
const submittedInput = lastSubmittedInput;
|
|
11932
|
+
lastSubmittedInput = null;
|
|
11933
|
+
const editedFrom = prefillFor?.task ?? "";
|
|
11934
|
+
prefillFor = null;
|
|
11771
11935
|
// readline's echo is suppressed while the input line is frozen, so the submitted question
|
|
11772
11936
|
// is printed here instead — it belongs in the scrollback like every other line. Its marker
|
|
11773
11937
|
// is the GREY one: the cyan ">>" belongs to the live input line alone.
|
|
@@ -11902,6 +12066,10 @@ async function main() {
|
|
|
11902
12066
|
await runS3Command(line);
|
|
11903
12067
|
continue;
|
|
11904
12068
|
}
|
|
12069
|
+
if (line === "/undo" || line.startsWith("/undo ")) {
|
|
12070
|
+
await runUndoCommand(line);
|
|
12071
|
+
continue;
|
|
12072
|
+
}
|
|
11905
12073
|
if (line === "/save-preset" || line.startsWith("/save-preset ")) {
|
|
11906
12074
|
await runSavePresetCommand(line);
|
|
11907
12075
|
continue;
|
|
@@ -12154,6 +12322,10 @@ async function main() {
|
|
|
12154
12322
|
// mid-retry still has something to resume next time.
|
|
12155
12323
|
sessionUnfinishedTask = "";
|
|
12156
12324
|
const { task: taskLine, resumed, nothingToResume } = resumeTask(line, pendingResume);
|
|
12325
|
+
// #302 part 3: "continue" after a lost connection FOLLOWS the job that may still be running,
|
|
12326
|
+
// rather than sending the question again. Anything else typed lets it go.
|
|
12327
|
+
const reattach = lostTurn && isResumeWord(line) && lostTurn.taskLine === taskLine ? lostTurn : null;
|
|
12328
|
+
lostTurn = null;
|
|
12157
12329
|
// "continue" with nothing behind it runs NOTHING (task #201). It used to be sent to the
|
|
12158
12330
|
// model as the literal word, which then got remembered as the unfinished task — so every
|
|
12159
12331
|
// later "continue" retried "continue". Worse than the loop: with no task the model invents
|
|
@@ -12172,6 +12344,9 @@ async function main() {
|
|
|
12172
12344
|
// "where were we?" reached the model with no history and it said nothing had been asked.
|
|
12173
12345
|
// Only the failure NOTICE stays out of history (task #144). A resume, or the same question
|
|
12174
12346
|
// typed again after a failure, is already the last entry and is not pushed a second time.
|
|
12347
|
+
// #302: the restored question, EDITED and sent — a change of mind, so it takes the stopped
|
|
12348
|
+
// one's place instead of standing beside it (then the block below sees it as pending).
|
|
12349
|
+
if (editedFrom) replaceStoppedQuestion(history, editedFrom, taskLine);
|
|
12175
12350
|
if (isPendingQuestion(history, taskLine)) {
|
|
12176
12351
|
delete history[history.length - 1].status; // it is the current task again, not a stopped one
|
|
12177
12352
|
} else {
|
|
@@ -12179,7 +12354,9 @@ async function main() {
|
|
|
12179
12354
|
}
|
|
12180
12355
|
await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
|
|
12181
12356
|
try {
|
|
12182
|
-
const
|
|
12357
|
+
const turnStartMs = Date.now();
|
|
12358
|
+
if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
|
|
12359
|
+
const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
|
|
12183
12360
|
// NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
|
|
12184
12361
|
// Nothing began, and "continue" can send it once the cause is fixed (task #247).
|
|
12185
12362
|
if (!turn) {
|
|
@@ -12188,7 +12365,7 @@ async function main() {
|
|
|
12188
12365
|
continue;
|
|
12189
12366
|
}
|
|
12190
12367
|
const { text: answer, shown, cancelled, started, detached, inputTokens, outputTokens,
|
|
12191
|
-
turnOutputTokens, elapsedMs } = turn;
|
|
12368
|
+
turnOutputTokens, elapsedMs, stoppedCommand } = turn;
|
|
12192
12369
|
// Detached: the agent keeps running on the worker, so the question is neither answered
|
|
12193
12370
|
// nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
|
|
12194
12371
|
if (detached) continue;
|
|
@@ -12285,6 +12462,7 @@ async function main() {
|
|
|
12285
12462
|
// Stopped AFTER the model was called = too slow or stuck, never a change of mind: the
|
|
12286
12463
|
// request still stands (task #247, the user's rule). Before that, nothing had begun.
|
|
12287
12464
|
await markInterrupted(cwd, history, taskLine, started ? "stopped" : "cancelled");
|
|
12465
|
+
await afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand, typed: line });
|
|
12288
12466
|
} else {
|
|
12289
12467
|
await markInterrupted(cwd, history, taskLine, "failed");
|
|
12290
12468
|
// The turn ended cleanly but empty — no exception to summarize, so say exactly that.
|
|
@@ -12297,6 +12475,8 @@ async function main() {
|
|
|
12297
12475
|
// turn that just threw auto-fire the next one.
|
|
12298
12476
|
if (_lineQueue.length > 0) _lineQueue.length = 0;
|
|
12299
12477
|
await rememberUnfinishedTask(cwd, taskLine); // …and "continue" can retry it (task #150)
|
|
12478
|
+
// #302 part 3: the connection went, not the turn — keep its job so "continue" follows it.
|
|
12479
|
+
lostTurn = err?.lostJobId ? { jobId: err.lostJobId, taskLine } : null;
|
|
12300
12480
|
printTurnError(err?.message, {
|
|
12301
12481
|
resumable: true,
|
|
12302
12482
|
failures: noteTaskFailure(taskLine, classifyTurnFailure(err?.message)),
|
package/image-paste.mjs
CHANGED
|
@@ -248,6 +248,16 @@ export function createImageStore() {
|
|
|
248
248
|
const present = new Set(String(line ?? "").match(TOKEN_RE) ?? []);
|
|
249
249
|
for (const t of [...store.keys()]) if (!present.has(t)) store.delete(t);
|
|
250
250
|
},
|
|
251
|
+
/** The live entries — snapshotted when a line is sent, so a stopped turn can give it back (#302). */
|
|
252
|
+
entries() {
|
|
253
|
+
return [...store];
|
|
254
|
+
},
|
|
255
|
+
/** Put one back exactly as it was (#302): same token, and later images number after it. */
|
|
256
|
+
restore(token, path) {
|
|
257
|
+
store.set(token, path);
|
|
258
|
+
const n = Number(/#(\d+)/.exec(String(token))?.[1]);
|
|
259
|
+
if (Number.isFinite(n) && n > seq) seq = n;
|
|
260
|
+
},
|
|
251
261
|
/** After a message is sent: the next one numbers its images from #1 again. */
|
|
252
262
|
clear() {
|
|
253
263
|
store.clear();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.770",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
"step-tokens.mjs",
|
|
53
53
|
"model-setup-ui.mjs",
|
|
54
54
|
"setup-prompts.mjs",
|
|
55
|
+
"turn-stop.mjs",
|
|
56
|
+
"workspace-revert.mjs",
|
|
55
57
|
"terminal-hover.mjs",
|
|
56
58
|
"terminal-layout.mjs",
|
|
57
59
|
"terminal-viewport.mjs",
|
package/turn-stop.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AFTER Ctrl+C STOPS A TURN — the question goes back in the input, and what the turn changed is
|
|
3
|
+
* named (task #302). Pure helpers; gu-repl.mjs does the drawing.
|
|
4
|
+
*
|
|
5
|
+
* Asked for: "for a couple of seconds the thinking is still in process and the user thinks again
|
|
6
|
+
* and wants to change the question" — as Claude Code does. The screen keeps what the turn printed
|
|
7
|
+
* (the record of what it did); files are NOT reverted by themselves (part of the work may be
|
|
8
|
+
* wanted, and a command that ran cannot be undone) — /undo offers that as a choice.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const PASTE_TOKEN_RE = /\[Pasted text #\d+(?: \+\d+ lines)?\]/g;
|
|
12
|
+
const IMAGE_TOKEN_RE = /\[Image #\d+\]/g;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What was TYPED, captured at the moment the line is submitted — before the pasted blocks and the
|
|
16
|
+
* images are expanded for the model and their stores are cleared. Only the entries whose
|
|
17
|
+
* placeholder is actually in the line are kept.
|
|
18
|
+
*/
|
|
19
|
+
export function snapshotInput(raw, { pastes = new Map(), images = new Map() } = {}) {
|
|
20
|
+
const text = String(raw ?? "");
|
|
21
|
+
const keep = (re, store) => {
|
|
22
|
+
const present = new Set(text.match(re) ?? []);
|
|
23
|
+
return [...store].filter(([token]) => present.has(token));
|
|
24
|
+
};
|
|
25
|
+
return { raw: text, pastes: keep(PASTE_TOKEN_RE, pastes), images: keep(IMAGE_TOKEN_RE, images) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Put a snapshot back: its placeholders' payloads return to the stores, and the typed text is
|
|
30
|
+
* returned for the input line. Nothing is renumbered — the text is shown exactly as it was.
|
|
31
|
+
*/
|
|
32
|
+
export function restoreInput(snap, { pasteStore, imageStore } = {}) {
|
|
33
|
+
if (!snap) return "";
|
|
34
|
+
for (const [token, text] of snap.pastes ?? []) pasteStore?.set(token, text);
|
|
35
|
+
for (const [token, path] of snap.images ?? []) imageStore?.restore?.(token, path);
|
|
36
|
+
return String(snap.raw ?? "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The restored question was EDITED and sent: that is a change of mind, so the abandoned version
|
|
41
|
+
* must not stay in history beside the new one (the model would take both as asks). Only when the
|
|
42
|
+
* last entry IS that stopped question; returns whether it replaced it.
|
|
43
|
+
*
|
|
44
|
+
* Sending it UNCHANGED is not handled here — the existing pending-question path re-runs it. And a
|
|
45
|
+
* question cleared and replaced by something else keeps its "still stands" tag (task #247).
|
|
46
|
+
*/
|
|
47
|
+
export function replaceStoppedQuestion(history, oldTask, newTask) {
|
|
48
|
+
const last = Array.isArray(history) ? history[history.length - 1] : null;
|
|
49
|
+
const was = String(oldTask ?? "").trim();
|
|
50
|
+
const now = String(newTask ?? "").trim();
|
|
51
|
+
if (!last || last.role !== "user" || !last.status || !was || !now || was === now) return false;
|
|
52
|
+
if (String(last.content ?? "").trim() !== was) return false;
|
|
53
|
+
last.content = now;
|
|
54
|
+
delete last.status;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Short relative names for the stop note: the last two path parts. */
|
|
59
|
+
function shortPath(p) {
|
|
60
|
+
const parts = String(p ?? "").split(/[\\/]/).filter(Boolean);
|
|
61
|
+
return parts.slice(-2).join("/") || String(p ?? "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The lines under a stopped turn. `files` = the paths the turn changed (from its run's manifest),
|
|
66
|
+
* `command` = the command it was in the middle of, if any.
|
|
67
|
+
*/
|
|
68
|
+
export function stopNoteLines({ files = [], command = "", restored = true } = {}) {
|
|
69
|
+
const out = [];
|
|
70
|
+
if (command) out.push(`stopped while running \`${command}\` — it may be incomplete; run it again if you need it`);
|
|
71
|
+
const uniq = [...new Set(files)];
|
|
72
|
+
if (uniq.length) {
|
|
73
|
+
const shown = uniq.slice(0, 3).map(shortPath).join(", ") + (uniq.length > 3 ? ` +${uniq.length - 3} more` : "");
|
|
74
|
+
out.push(`this turn changed ${uniq.length} file${uniq.length === 1 ? "" : "s"}: ${shown} — /undo puts ${uniq.length === 1 ? "it" : "them"} back`);
|
|
75
|
+
}
|
|
76
|
+
if (restored) out.push("your question is back in the input — edit it and press Enter, or Ctrl+C to clear it");
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNDO AN AGENT RUN'S FILE CHANGES — shared by `gu-cli workspace revert` and the terminal's /undo
|
|
3
|
+
* (task #302).
|
|
4
|
+
*
|
|
5
|
+
* Every agent run keeps ~/.gonext/workspace-backups/<runId>/: a copy of each file taken before its
|
|
6
|
+
* FIRST edit in that run, and manifest.json listing every change (gonext_agent_chat.py
|
|
7
|
+
* _ws_snapshot / _ws_record_change). Undo puts the ORIGINAL state back.
|
|
8
|
+
*
|
|
9
|
+
* WHY A PLAN, NOT A REPLAY. The manifest is a log, and a file can appear in it more than once. The
|
|
10
|
+
* old revert replayed it in order — so a file the run CREATED and then edited was deleted (the
|
|
11
|
+
* "create" entry) and then brought back by the "edit" entry's backup, which is a copy of the file
|
|
12
|
+
* the run had just created. What matters is each path's EARLIEST entry: it says what the file was
|
|
13
|
+
* before the run touched it. Created → delete it; edited → restore the copy taken before the first
|
|
14
|
+
* edit; a created folder → remove it only if empty.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
17
|
+
import { copyFile, readFile, rmdir, unlink } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const BACKUPS_ROOT = join(homedir(), ".gonext", "workspace-backups");
|
|
22
|
+
|
|
23
|
+
/** The manifest's changes → one step per path, from its earliest entry. Pure. */
|
|
24
|
+
export function planRevert(changes) {
|
|
25
|
+
const first = new Map();
|
|
26
|
+
for (const c of Array.isArray(changes) ? changes : []) {
|
|
27
|
+
if (!c || typeof c.path !== "string" || !c.path) continue;
|
|
28
|
+
if (!first.has(c.path)) first.set(c.path, c);
|
|
29
|
+
}
|
|
30
|
+
const steps = [];
|
|
31
|
+
for (const c of first.values()) {
|
|
32
|
+
if (c.action === "edit" && c.backup) steps.push({ op: "restore", path: c.path, backup: c.backup });
|
|
33
|
+
else if (c.action === "create") steps.push({ op: "delete", path: c.path });
|
|
34
|
+
else if (c.action === "create_dir") steps.push({ op: "rmdir", path: c.path });
|
|
35
|
+
}
|
|
36
|
+
// Files before folders, deepest folders first — a created folder can only go once it is empty.
|
|
37
|
+
const rank = { restore: 0, delete: 0, rmdir: 1 };
|
|
38
|
+
return steps.sort((a, b) => rank[a.op] - rank[b.op] || (a.op === "rmdir" ? b.path.length - a.path.length : 0));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The run directories, oldest first (names are timestamps, so they sort). */
|
|
42
|
+
export function listRuns(root = BACKUPS_ROOT) {
|
|
43
|
+
try {
|
|
44
|
+
return readdirSync(root).filter((d) => !d.startsWith(".")).sort();
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The run a turn produced: the newest run whose manifest was written at or after `sinceMs`.
|
|
52
|
+
* Every run is one agent process, and every turn is one process — so a run touched after the turn
|
|
53
|
+
* began belongs to it. null when the turn changed nothing (or ran on another machine).
|
|
54
|
+
*/
|
|
55
|
+
export function runSince(sinceMs, root = BACKUPS_ROOT) {
|
|
56
|
+
const runs = listRuns(root).reverse();
|
|
57
|
+
for (const id of runs) {
|
|
58
|
+
try {
|
|
59
|
+
if (statSync(join(root, id, "manifest.json")).mtimeMs >= sinceMs - 1000) return id;
|
|
60
|
+
} catch {
|
|
61
|
+
/* a run with no manifest changed nothing */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A run's changes, or null when it has no readable manifest. */
|
|
68
|
+
export async function readRunChanges(runId, root = BACKUPS_ROOT) {
|
|
69
|
+
try {
|
|
70
|
+
const m = JSON.parse(await readFile(join(root, runId, "manifest.json"), "utf8"));
|
|
71
|
+
return Array.isArray(m?.changes) ? m.changes : [];
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Carry out a plan. → { restored, deleted, failed, lines } — `lines` says what happened to each
|
|
79
|
+
* path, for the caller to print. Never throws; a step that fails is reported and the rest go on.
|
|
80
|
+
*/
|
|
81
|
+
export async function applyRevert(steps, { exists = existsSync } = {}) {
|
|
82
|
+
let restored = 0, deleted = 0, failed = 0;
|
|
83
|
+
const lines = [];
|
|
84
|
+
for (const s of steps) {
|
|
85
|
+
try {
|
|
86
|
+
if (s.op === "restore" && exists(s.backup)) {
|
|
87
|
+
await copyFile(s.backup, s.path);
|
|
88
|
+
restored += 1;
|
|
89
|
+
lines.push(`restored ${s.path}`);
|
|
90
|
+
} else if (s.op === "delete" && exists(s.path)) {
|
|
91
|
+
await unlink(s.path);
|
|
92
|
+
deleted += 1;
|
|
93
|
+
lines.push(`deleted ${s.path}`);
|
|
94
|
+
} else if (s.op === "rmdir" && exists(s.path)) {
|
|
95
|
+
// rmdir removes only an EMPTY folder — undo never destroys what someone put inside later.
|
|
96
|
+
await rmdir(s.path);
|
|
97
|
+
deleted += 1;
|
|
98
|
+
lines.push(`removed ${s.path}/`);
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
failed += 1;
|
|
102
|
+
lines.push(`FAILED ${s.path}: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { restored, deleted, failed, lines };
|
|
106
|
+
}
|