@tiens.nguyen/gonext-local-worker 1.0.308 → 1.0.310

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-repl.mjs CHANGED
@@ -1113,6 +1113,63 @@ function pickFromList(items, initialSel = 0, opts = {}) {
1113
1113
  });
1114
1114
  }
1115
1115
 
1116
+ // Cancel whichever approval picker is currently showing (Yes/No or the N-row list),
1117
+ // resolving it as a DENY. Used when the job ends server-side while we're still waiting
1118
+ // for the user, so the picker promise never leaks and no late choice is sent (#110).
1119
+ function cancelActivePicker() {
1120
+ if (approvalActive) finishApproval(false);
1121
+ if (listPickerActive) finishList(-1);
1122
+ }
1123
+
1124
+ // Show an approval picker, but RACE it against the job going terminal. The old code
1125
+ // awaited the picker INLINE inside the poll loop, so while the user thought, the REPL
1126
+ // stopped polling and couldn't notice that python had already given up (its own approval
1127
+ // deadline) and completed the job — the user's late "Yes" then hit a finished job and was
1128
+ // silently lost (#110). Here a background watcher polls the job; if it goes
1129
+ // completed/failed/cancelled first, we dismiss the picker and report `ended:true` so the
1130
+ // caller skips the POST and lets the normal terminal-status handling run.
1131
+ async function approvalPromptRacingJob(command) {
1132
+ let watching = true;
1133
+ const watcher = (async () => {
1134
+ while (watching) {
1135
+ await sleep(1000);
1136
+ if (!watching) return null;
1137
+ try {
1138
+ const r = await fetch(`${apiBase}/api/worker/jobs/${jobIdForApprovalRace}`, {
1139
+ headers: { "X-Worker-Key": workerKey },
1140
+ });
1141
+ if (!r.ok) continue;
1142
+ const j = await r.json().catch(() => ({}));
1143
+ if (
1144
+ j?.jobStatus === "completed" ||
1145
+ j?.jobStatus === "failed" ||
1146
+ j?.jobStatus === "cancelled"
1147
+ ) {
1148
+ return "ended";
1149
+ }
1150
+ } catch {
1151
+ /* transient poll error — keep watching */
1152
+ }
1153
+ }
1154
+ return null;
1155
+ })();
1156
+ const picker = approvalPrompt(command).then((r) => ({ kind: "answer", ...r }));
1157
+ const raced = await Promise.race([
1158
+ picker,
1159
+ watcher.then((v) => (v === "ended" ? { kind: "ended" } : new Promise(() => {}))),
1160
+ ]);
1161
+ watching = false;
1162
+ if (raced.kind === "ended") {
1163
+ cancelActivePicker(); // unblock the picker promise so it doesn't leak
1164
+ await picker.catch(() => {}); // let it settle after the cancel
1165
+ return { ended: true, allow: false, dontAsk: false };
1166
+ }
1167
+ // The user answered first — stop the watcher and return their choice.
1168
+ return { ended: false, allow: raced.allow, dontAsk: raced.dontAsk };
1169
+ }
1170
+ // The jobId the approval race should watch — set by runAgentTurn before it can prompt.
1171
+ let jobIdForApprovalRace = "";
1172
+
1116
1173
  // Per-session coding-model override chosen via /model (empty = use the account default).
1117
1174
  // Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
1118
1175
  let sessionCodingModel = "";
@@ -1326,6 +1383,7 @@ async function runAgentTurn(history) {
1326
1383
  following = true;
1327
1384
  followAborted = false;
1328
1385
  currentJobId = jobId;
1386
+ jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
1329
1387
  cancelRequested = false;
1330
1388
  const startedAt = Date.now();
1331
1389
  let shownChars = 0;
@@ -1894,7 +1952,19 @@ async function runAgentTurn(history) {
1894
1952
  ) {
1895
1953
  lastApprovalId = job.pendingApproval.id;
1896
1954
  clearStatus();
1897
- const { allow, dontAsk } = await approvalPrompt(job.pendingApproval.command || "");
1955
+ // Race the picker against the job ending server-side (#110): if python gives up
1956
+ // (its own approval deadline) and completes the job while we're waiting, dismiss
1957
+ // the picker and DON'T post a now-useless choice — fall through to the normal
1958
+ // completed/failed/cancelled handling below on the next poll.
1959
+ const { ended, allow, dontAsk } = await approvalPromptRacingJob(
1960
+ job.pendingApproval.command || ""
1961
+ );
1962
+ if (ended) {
1963
+ process.stdout.write(
1964
+ dim("\n (the run already ended before you chose — reply 'continue' to resume)\n")
1965
+ );
1966
+ continue; // re-poll; the terminal-status branches below take it from here
1967
+ }
1898
1968
  // "Yes, don't ask again this session" (max-step prompt only): remember it so every
1899
1969
  // later turn sends alwaysExtendOnMaxStep and the agent auto-extends silently (#80).
1900
1970
  if (dontAsk) {
@@ -2835,10 +2835,16 @@ def _ws_command_risk(command, argv, denyset):
2835
2835
  return (None, None)
2836
2836
 
2837
2837
 
2838
- def _ws_request_approval(api_base, worker_key, job_id, command, reason):
2838
+ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline_s=180):
2839
2839
  """Pause and ask the user (via the terminal REPL's Yes/No picker) to allow a risky
2840
2840
  command. Registers a pending approval on the job through the API, then polls the job
2841
2841
  for the user's decision. Returns True (allow) or False (deny / timeout / cancelled).
2842
+
2843
+ deadline_s bounds the wait. 180s is right for a RISKY command (absent user → deny,
2844
+ never auto-run). But the __MAXSTEP__ "keep going?" prompt is NOT security-sensitive and
2845
+ the coder can be slow, so it passes a much larger deadline (#110): the REPL now
2846
+ dismisses the picker the instant this job ends, and the worker's own job-cap kills a
2847
+ truly-abandoned run, so a long wait here is safe and lets a real "Yes" actually land.
2842
2848
  Mirrors _pdf_upload_via_api's worker-key auth — no new creds on the worker."""
2843
2849
  import urllib.request as _u
2844
2850
  import uuid as _uuid
@@ -2866,7 +2872,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason):
2866
2872
  _emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
2867
2873
  else:
2868
2874
  _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
2869
- deadline = _t.time() + 180 # 3 min; absent user → deny (never auto-run something risky)
2875
+ deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
2870
2876
  while _t.time() < deadline:
2871
2877
  _t.sleep(1.2)
2872
2878
  try:
@@ -6543,10 +6549,14 @@ def run_agent_chat(cfg):
6543
6549
  "(don't-ask-again session flag)")
6544
6550
  _do_extend = True
6545
6551
  elif _interactive_approval and _job_id and pdf_api_base and pdf_worker_key:
6552
+ # Long deadline (#110): the "keep going?" prompt isn't risky, and the
6553
+ # slow coder + a thinking user shouldn't get auto-denied at 180s. The
6554
+ # REPL dismisses the picker if this job ends, and the worker job-cap
6555
+ # bounds a walked-away user, so waiting here is safe.
6546
6556
  _do_extend = _ws_request_approval(
6547
6557
  pdf_api_base, pdf_worker_key, _job_id,
6548
6558
  f"__MAXSTEP__::Reached the step budget ({agent.max_steps}). "
6549
- "Keep going?", "max-steps")
6559
+ "Keep going?", "max-steps", deadline_s=1500)
6550
6560
  else:
6551
6561
  _do_extend = False # web / no interactive channel → current wrap-up
6552
6562
  if not _do_extend:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.308",
3
+ "version": "1.0.310",
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",