@tiens.nguyen/gonext-local-worker 1.0.247 → 1.0.249
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/gonext-local-worker.mjs +94 -13
- package/gonext-repl.mjs +29 -1
- package/package.json +1 -1
package/gonext-local-worker.mjs
CHANGED
|
@@ -867,7 +867,7 @@ async function performHttpMeasurement(params) {
|
|
|
867
867
|
* line as they arrive. Resolves when the process exits cleanly; rejects on
|
|
868
868
|
* non-zero exit or timeout. stderr is collected and appended to error messages.
|
|
869
869
|
*/
|
|
870
|
-
function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv, abortSignal) {
|
|
870
|
+
function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine, extraEnv, abortSignal, onTimeout) {
|
|
871
871
|
return new Promise((resolve, reject) => {
|
|
872
872
|
const spawnOpts = { stdio: ["pipe", "pipe", "pipe"] };
|
|
873
873
|
if (extraEnv && Object.keys(extraEnv).length > 0) {
|
|
@@ -878,10 +878,36 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
878
878
|
let lineBuffer = "";
|
|
879
879
|
let timedOut = false;
|
|
880
880
|
let aborted = false;
|
|
881
|
-
|
|
881
|
+
let closed = false;
|
|
882
|
+
let timerLive = true;
|
|
883
|
+
// On the time budget: with no onTimeout handler (probe/legacy) this is a hard cap —
|
|
884
|
+
// kill immediately. With a handler (task #81, interactive terminal) ask the user
|
|
885
|
+
// whether to keep waiting; the child keeps running/streaming during the ~60s prompt.
|
|
886
|
+
// "Keep waiting" disables the cap for the rest of the run; No / no-answer kills it.
|
|
887
|
+
const fireTimeout = async () => {
|
|
888
|
+
if (!timerLive || closed || aborted) return;
|
|
889
|
+
if (typeof onTimeout !== "function") {
|
|
890
|
+
timedOut = true;
|
|
891
|
+
timerLive = false;
|
|
892
|
+
child.kill("SIGKILL");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
let extend = false;
|
|
896
|
+
try {
|
|
897
|
+
extend = await onTimeout();
|
|
898
|
+
} catch {
|
|
899
|
+
extend = false;
|
|
900
|
+
}
|
|
901
|
+
if (closed || aborted || !timerLive) return; // finished/cancelled during the prompt
|
|
902
|
+
if (extend) {
|
|
903
|
+
timerLive = false; // ignore the time budget for the rest of this run
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
882
906
|
timedOut = true;
|
|
907
|
+
timerLive = false;
|
|
883
908
|
child.kill("SIGKILL");
|
|
884
|
-
}
|
|
909
|
+
};
|
|
910
|
+
const timer = setTimeout(fireTimeout, timeoutMs);
|
|
885
911
|
// User-requested cancel (gonext Ctrl+C): kill the Python agent so it stops burning
|
|
886
912
|
// steps/tokens. On close we resolve cleanly (not reject) — the caller checks the
|
|
887
913
|
// cancel flag and leaves the job in its already-"cancelled" state.
|
|
@@ -913,10 +939,14 @@ function runProcessWithStreamingStdout(cmd, cmdArgs, stdinStr, timeoutMs, onLine
|
|
|
913
939
|
stderr += d;
|
|
914
940
|
});
|
|
915
941
|
child.on("error", (e) => {
|
|
942
|
+
closed = true;
|
|
943
|
+
timerLive = false;
|
|
916
944
|
clearTimeout(timer);
|
|
917
945
|
reject(e);
|
|
918
946
|
});
|
|
919
947
|
child.on("close", (code) => {
|
|
948
|
+
closed = true;
|
|
949
|
+
timerLive = false;
|
|
920
950
|
clearTimeout(timer);
|
|
921
951
|
if (abortSignal) abortSignal.removeEventListener?.("abort", onAbort);
|
|
922
952
|
// Drain any remaining buffered line
|
|
@@ -1925,15 +1955,27 @@ async function runAgentChatJob(job) {
|
|
|
1925
1955
|
// re-validates). Terminal = its launch cwd; web = the temp scratch folder.
|
|
1926
1956
|
activeWorkspace: jobActiveWorkspace,
|
|
1927
1957
|
});
|
|
1928
|
-
//
|
|
1929
|
-
//
|
|
1930
|
-
//
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
1933
|
-
//
|
|
1934
|
-
//
|
|
1935
|
-
//
|
|
1936
|
-
|
|
1958
|
+
// Agent run time budget (task #81). Default 60 min; user-configurable in web Settings
|
|
1959
|
+
// (agentTimeoutMinutes → payload.agentTimeoutMs). Multi-step ReAct on a 14B/31B with a
|
|
1960
|
+
// cold prompt cache — or a remote Ollama box slow-cold-loading a big model — can run
|
|
1961
|
+
// for many minutes per step, so a generous budget is normal. On expiry the TERMINAL
|
|
1962
|
+
// (interactive) asks the user whether to keep waiting (onAgentTimeout below); "yes"
|
|
1963
|
+
// ignores the budget for the rest of the run. WEB has no picker AND a hard WS deadline
|
|
1964
|
+
// (1_860_000 ms), so it's capped at 30 min and just aborts — our clean timeout error
|
|
1965
|
+
// then reaches the user instead of a generic WS timeout. On a hard stop the python
|
|
1966
|
+
// child is SIGKILLed (shows as a BrokenPipeError in the model server log — expected).
|
|
1967
|
+
// Liveness during long model calls comes from the python heartbeat (a keepalive step
|
|
1968
|
+
// chunk every 45s), not from this cap.
|
|
1969
|
+
const DEFAULT_AGENT_TIMEOUT_MS = 3_600_000; // 1 hour
|
|
1970
|
+
const WEB_AGENT_TIMEOUT_CAP_MS = 1_800_000; // stay under the web WS deadline
|
|
1971
|
+
const isInteractiveTimeout = payload?.interactiveApproval === true;
|
|
1972
|
+
const requestedTimeoutMs =
|
|
1973
|
+
Number.isFinite(payload?.agentTimeoutMs) && payload.agentTimeoutMs > 0
|
|
1974
|
+
? payload.agentTimeoutMs
|
|
1975
|
+
: DEFAULT_AGENT_TIMEOUT_MS;
|
|
1976
|
+
const timeoutMs = isInteractiveTimeout
|
|
1977
|
+
? requestedTimeoutMs
|
|
1978
|
+
: Math.min(requestedTimeoutMs, WEB_AGENT_TIMEOUT_CAP_MS);
|
|
1937
1979
|
|
|
1938
1980
|
let inThink = false;
|
|
1939
1981
|
let finalText = "";
|
|
@@ -1985,6 +2027,45 @@ async function runAgentChatJob(job) {
|
|
|
1985
2027
|
}, 1500);
|
|
1986
2028
|
cancelAc.signal.addEventListener("abort", () => { cancelled = true; }, { once: true });
|
|
1987
2029
|
|
|
2030
|
+
// Task #81: when the time budget is reached in the TERMINAL, ask the user whether to
|
|
2031
|
+
// keep waiting instead of just aborting. Reuses the #67 approval plumbing
|
|
2032
|
+
// (approval-request → poll approvalDecision) with a __TIMEOUT__:: sentinel the REPL
|
|
2033
|
+
// recognizes and renders as a Yes/No picker (auto-No at 60s). Returns true = keep
|
|
2034
|
+
// waiting (disable the cap), false = stop. Only wired for interactive jobs (web never
|
|
2035
|
+
// reaches here — it isn't passed as onTimeout).
|
|
2036
|
+
const onAgentTimeout = async () => {
|
|
2037
|
+
if (!isInteractiveTimeout || !jobId) return false;
|
|
2038
|
+
const approvalId = `timeout-${Date.now()}`;
|
|
2039
|
+
const mins = Math.round(timeoutMs / 60000);
|
|
2040
|
+
const command = `__TIMEOUT__::The agent has run for ${mins} min (the time budget). Keep waiting?`;
|
|
2041
|
+
try {
|
|
2042
|
+
const r = await workerFetch(`/api/worker/jobs/${jobId}/approval-request`, {
|
|
2043
|
+
method: "POST",
|
|
2044
|
+
body: JSON.stringify({ id: approvalId, command }),
|
|
2045
|
+
});
|
|
2046
|
+
if (!r.ok) return false;
|
|
2047
|
+
} catch {
|
|
2048
|
+
return false;
|
|
2049
|
+
}
|
|
2050
|
+
// Poll for the user's Yes/No up to ~65s (the REPL auto-answers No at 60s).
|
|
2051
|
+
const deadline = Date.now() + 65_000;
|
|
2052
|
+
while (Date.now() < deadline) {
|
|
2053
|
+
if (cancelAc.signal.aborted) return false;
|
|
2054
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
2055
|
+
try {
|
|
2056
|
+
const r = await workerFetch(`/api/worker/jobs/${jobId}`);
|
|
2057
|
+
if (r.ok) {
|
|
2058
|
+
const j = await r.json().catch(() => ({}));
|
|
2059
|
+
const dec = j?.approvalDecision;
|
|
2060
|
+
if (dec && dec.id === approvalId) return dec.allow === true;
|
|
2061
|
+
}
|
|
2062
|
+
} catch {
|
|
2063
|
+
/* transient poll error — keep trying */
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
return false; // no answer in time → treat as No (stop)
|
|
2067
|
+
};
|
|
2068
|
+
|
|
1988
2069
|
try {
|
|
1989
2070
|
await runProcessWithStreamingStdout(python, [scriptPath], input, timeoutMs, (event) => {
|
|
1990
2071
|
if (event.type === "log" && typeof event.text === "string") {
|
|
@@ -2039,7 +2120,7 @@ async function runAgentChatJob(job) {
|
|
|
2039
2120
|
enqueueText(event.text);
|
|
2040
2121
|
}
|
|
2041
2122
|
}
|
|
2042
|
-
}, pdfEnv, cancelAc.signal);
|
|
2123
|
+
}, pdfEnv, cancelAc.signal, isInteractiveTimeout ? onAgentTimeout : undefined);
|
|
2043
2124
|
} finally {
|
|
2044
2125
|
clearInterval(cancelPoll);
|
|
2045
2126
|
}
|
package/gonext-repl.mjs
CHANGED
|
@@ -784,7 +784,21 @@ function onApprovalKey(key) {
|
|
|
784
784
|
// Returns { allow, dontAsk } — dontAsk is only ever true for the max-step "don't ask
|
|
785
785
|
// again this session" choice (the caller then stops sending the prompt).
|
|
786
786
|
const MAXSTEP_PREFIX = "__MAXSTEP__::";
|
|
787
|
+
const TIMEOUT_PREFIX = "__TIMEOUT__::";
|
|
787
788
|
async function approvalPrompt(command) {
|
|
789
|
+
// Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
|
|
790
|
+
// keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
|
|
791
|
+
if (command.startsWith(TIMEOUT_PREFIX)) {
|
|
792
|
+
const label = command.slice(TIMEOUT_PREFIX.length);
|
|
793
|
+
if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
|
|
794
|
+
process.stdout.write("\n" + yellow("⏳ " + label) + dim(" (no answer in 60s = stop)") + "\n");
|
|
795
|
+
const idx = await pickFromList(
|
|
796
|
+
["Yes, keep waiting", "No, stop now"],
|
|
797
|
+
0,
|
|
798
|
+
{ timeoutMs: 60000, timeoutIndex: 1 }
|
|
799
|
+
);
|
|
800
|
+
return { allow: idx === 0, dontAsk: false };
|
|
801
|
+
}
|
|
788
802
|
const isMax = command.startsWith(MAXSTEP_PREFIX);
|
|
789
803
|
if (isMax) {
|
|
790
804
|
const label = command.slice(MAXSTEP_PREFIX.length);
|
|
@@ -857,7 +871,7 @@ function onListKey(key) {
|
|
|
857
871
|
finishList(-1);
|
|
858
872
|
}
|
|
859
873
|
}
|
|
860
|
-
function pickFromList(items, initialSel = 0) {
|
|
874
|
+
function pickFromList(items, initialSel = 0, opts = {}) {
|
|
861
875
|
return new Promise((resolve) => {
|
|
862
876
|
if (!process.stdin.isTTY || items.length === 0) {
|
|
863
877
|
resolve(-1);
|
|
@@ -868,6 +882,20 @@ function pickFromList(items, initialSel = 0) {
|
|
|
868
882
|
listResolve = resolve;
|
|
869
883
|
listPickerActive = true;
|
|
870
884
|
drawList(false);
|
|
885
|
+
// Optional auto-resolve after opts.timeoutMs (task #81 time-budget prompt): if the
|
|
886
|
+
// user doesn't choose in time, finish with opts.timeoutIndex (default -1). Wrap
|
|
887
|
+
// listResolve so any real selection clears the timer.
|
|
888
|
+
if (opts && opts.timeoutMs > 0) {
|
|
889
|
+
const to = setTimeout(
|
|
890
|
+
() => finishList(opts.timeoutIndex ?? -1),
|
|
891
|
+
opts.timeoutMs
|
|
892
|
+
);
|
|
893
|
+
const orig = listResolve;
|
|
894
|
+
listResolve = (v) => {
|
|
895
|
+
clearTimeout(to);
|
|
896
|
+
orig(v);
|
|
897
|
+
};
|
|
898
|
+
}
|
|
871
899
|
});
|
|
872
900
|
}
|
|
873
901
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gonext-local-worker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.249",
|
|
4
4
|
"description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|