zcode-acp-server 0.59.0 → 0.61.0

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.
Files changed (45) hide show
  1. package/README.md +17 -13
  2. package/dist/code-fingerprint.json +2 -2
  3. package/dist/config/settings.d.ts +7 -0
  4. package/dist/config/settings.d.ts.map +1 -1
  5. package/dist/config/settings.js +15 -0
  6. package/dist/config/settings.js.map +1 -1
  7. package/dist/config/user-config.d.ts +8 -1
  8. package/dist/config/user-config.d.ts.map +1 -1
  9. package/dist/config/user-config.js +2 -0
  10. package/dist/config/user-config.js.map +1 -1
  11. package/dist/goal-loop/driver.d.ts +18 -0
  12. package/dist/goal-loop/driver.d.ts.map +1 -1
  13. package/dist/goal-loop/driver.js +100 -1
  14. package/dist/goal-loop/driver.js.map +1 -1
  15. package/dist/i18n.d.ts +6 -0
  16. package/dist/i18n.d.ts.map +1 -1
  17. package/dist/i18n.js +8 -0
  18. package/dist/i18n.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +23 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/push/config.d.ts +1 -1
  23. package/dist/push/config.d.ts.map +1 -1
  24. package/dist/quota/resume.d.ts +66 -0
  25. package/dist/quota/resume.d.ts.map +1 -0
  26. package/dist/quota/resume.js +217 -0
  27. package/dist/quota/resume.js.map +1 -0
  28. package/dist/quota/thresholds.d.ts +5 -4
  29. package/dist/quota/thresholds.d.ts.map +1 -1
  30. package/dist/quota/thresholds.js +7 -6
  31. package/dist/quota/thresholds.js.map +1 -1
  32. package/dist/remote/hub-server.d.ts.map +1 -1
  33. package/dist/remote/hub-server.js +21 -0
  34. package/dist/remote/hub-server.js.map +1 -1
  35. package/dist/remote/workflow-overview.d.ts +84 -0
  36. package/dist/remote/workflow-overview.d.ts.map +1 -0
  37. package/dist/remote/workflow-overview.js +364 -0
  38. package/dist/remote/workflow-overview.js.map +1 -0
  39. package/dist/workflow/quota-resume.d.ts +135 -0
  40. package/dist/workflow/quota-resume.d.ts.map +1 -0
  41. package/dist/workflow/quota-resume.js +773 -0
  42. package/dist/workflow/quota-resume.js.map +1 -0
  43. package/docs/DEVELOPMENT.md +7 -0
  44. package/docs/REMOTE-CLIENTS.md +40 -5
  45. package/package.json +1 -1
@@ -0,0 +1,773 @@
1
+ /**
2
+ * Quota auto-resume watchdog for dynamic-workflow runs.
3
+ *
4
+ * Why: when a workflow subagent hits a usage cap (GLM 1308 …), upstream stops
5
+ * the WHOLE run as `stopped(provider)` with a `ProviderStopDetails{kind:
6
+ * "quota"}` and its notification instructs the agent to call
7
+ * ResumeWorkflowRun AFTER the reset (workflow-notification-copy.ts:55-64).
8
+ * Nothing schedules that "after" — the run sits stopped until a human acts.
9
+ * The bridge owns the process that can wait, so it does: a small persisted
10
+ * registry of pending resumes, a periodic scan over the project's run journal,
11
+ * and a timer per entry.
12
+ *
13
+ * Detection is deliberately journal-first: the v4 session summary carries the
14
+ * failure message (with the provider's business code in brackets) but NOT the
15
+ * structured providerStop/resetAt, and the workflow progress poller gives up
16
+ * after 10 minutes — long runs would never be seen there. The scan reads
17
+ * `workflows/runs` (one session-less RPC), keeps rows that are
18
+ * `stopped(provider)`, and only then resolves the few parent sessions through
19
+ * `v4/conversation/workflowRuns` to read their failure messages.
20
+ *
21
+ * Firing is defensive at every step: quota card first (a known future reset
22
+ * defers without burning an attempt), then a live state read (a run that is
23
+ * running again / finished / no longer resumable drops the entry), then the
24
+ * same `resumeWorkflowRun` command the App's Resume button uses. An accepted
25
+ * resume is re-verified after {@link VERIFY_AFTER_MS}; only a stopped-again run
26
+ * with no known reset burns an attempt and backs off.
27
+ *
28
+ * Store: `~/.zcode/v2/acp-quota-resumes.json`, shared by every bridge process
29
+ * and written only via temp+rename with merge-at-write (the dismissals-store
30
+ * discipline — a torn write would lose every pending resume).
31
+ */
32
+ import { randomUUID } from "node:crypto";
33
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
34
+ import path from "node:path";
35
+ import process from "node:process";
36
+ import { quotaAutoResumeEnabled } from "../config/settings.js";
37
+ import { sendTextChunk } from "../handlers/io.js";
38
+ import { messages } from "../i18n.js";
39
+ import { loadAliasesByZcodeSid } from "../lazy-sessions.js";
40
+ import { pushSettled, pushSourceLabel } from "../push/push.js";
41
+ import { queryQuota } from "../quota/index.js";
42
+ import { isQuotaStopFailureMessage, quotaBackoffMs, quotaResetAtFromResult, } from "../quota/resume.js";
43
+ import { withFileLock } from "../settings/file-lock.js";
44
+ import { WorkflowApiError, conversationRuns, resumeWorkflowRun } from "../settings/workflow.js";
45
+ import { dismissedRunIds } from "../settings/workflow-run-dismissals.js";
46
+ import { log, warn, zcodeHomeDir } from "../utils.js";
47
+ /** Scan cadence for newly quota-stopped runs. */
48
+ export const QUOTA_RESUME_SCAN_INTERVAL_MS = 5 * 60_000;
49
+ /** First scan after bridge start (lets the backend come up without spawning it). */
50
+ const FIRST_SCAN_DELAY_MS = 30_000;
51
+ /** Delay before the first resume attempt when no reset moment is known. */
52
+ const FIRST_FIRE_DELAY_MS = 15_000;
53
+ /** Try again this long after the computed window reset (clock slack). */
54
+ const FIRE_MARGIN_MS = 60_000;
55
+ /** Re-check an accepted resume this long later (a churned run shows stopped again). */
56
+ const VERIFY_AFTER_MS = 10 * 60_000;
57
+ /** Backoff base for repeated failed attempts with no known reset. */
58
+ const BACKOFF_BASE_MS = 5 * 60_000;
59
+ /** Attempts before giving up (push + chat notice; manual resume still works). */
60
+ const MAX_ATTEMPTS = 6;
61
+ /** Longer timers are chunked: fire re-checks and re-arms. */
62
+ const MAX_TIMER_MS = 6 * 60 * 60_000;
63
+ /** Overdue entries untouched longer than this are pruned (the journal window is 50). */
64
+ const ENTRY_MAX_AGE_MS = 7 * 24 * 60 * 60_000;
65
+ /** A scan only backfilleds stops this fresh; older ones stay a manual decision. */
66
+ const BACKFILL_MAX_AGE_MS = 24 * 60 * 60_000;
67
+ /** Session-busy rejections retry at this fixed short delay. */
68
+ const SESSION_BUSY_RETRY_MS = 5 * 60_000;
69
+ /** Permanently-failed resume rejections — drop the entry instead of retrying. */
70
+ const PERMANENT_REASONS = new Set([
71
+ "not_resumable",
72
+ "not_found",
73
+ "superseded",
74
+ "workflow_disabled",
75
+ "capabilityUnsupported",
76
+ "unknown_session",
77
+ "compile_failed",
78
+ "script_missing",
79
+ "script_mismatch",
80
+ "invalid_name",
81
+ ]);
82
+ const STORE_FILENAME = "acp-quota-resumes.json";
83
+ function storePath() {
84
+ return path.join(zcodeHomeDir(), "v2", STORE_FILENAME);
85
+ }
86
+ function asRecord(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value)
88
+ ? value
89
+ : undefined;
90
+ }
91
+ /** Read + validate the shared store; stale entries are dropped on read. */
92
+ function readTable() {
93
+ try {
94
+ const p = storePath();
95
+ if (!existsSync(p))
96
+ return {};
97
+ const raw = JSON.parse(readFileSync(p, "utf8"));
98
+ const table = asRecord(raw);
99
+ if (!table)
100
+ return {};
101
+ const now = Date.now();
102
+ const out = {};
103
+ for (const [runId, value] of Object.entries(table)) {
104
+ const rec = asRecord(value);
105
+ if (!rec)
106
+ continue;
107
+ const { zcodeSid, cwd, resumeAt, attempts, scheduledAt, updatedAt } = rec;
108
+ if (typeof runId !== "string" ||
109
+ typeof zcodeSid !== "string" ||
110
+ typeof cwd !== "string" ||
111
+ typeof resumeAt !== "number" ||
112
+ typeof attempts !== "number" ||
113
+ typeof scheduledAt !== "number" ||
114
+ typeof updatedAt !== "number") {
115
+ continue;
116
+ }
117
+ // Prune only OVERDUE-and-abandoned entries. A future-dated entry must
118
+ // never age out mid-wait: a weekly window's reset can sit a full ~7
119
+ // days out, and dropping the entry at the TTL boundary would silently
120
+ // cancel the wait right before it fires.
121
+ if (resumeAt <= now && now - updatedAt > ENTRY_MAX_AGE_MS)
122
+ continue;
123
+ const firing = asRecord(rec["firing"]);
124
+ const firingValid = firing !== undefined &&
125
+ typeof firing["pid"] === "number" &&
126
+ Number.isSafeInteger(firing["pid"]) &&
127
+ typeof firing["at"] === "number";
128
+ out[runId] = {
129
+ runId,
130
+ zcodeSid,
131
+ cwd,
132
+ ...(typeof rec["acpSid"] === "string" ? { acpSid: rec["acpSid"] } : {}),
133
+ ...(typeof rec["name"] === "string" ? { name: rec["name"] } : {}),
134
+ resumeAt,
135
+ attempts,
136
+ ...(rec["awaitingVerify"] === true ? { awaitingVerify: true } : {}),
137
+ ...(typeof rec["gaveUp"] === "string" && rec["gaveUp"] !== ""
138
+ ? { gaveUp: rec["gaveUp"] }
139
+ : {}),
140
+ ...(firingValid
141
+ ? { firing: { pid: firing["pid"], at: firing["at"] } }
142
+ : {}),
143
+ scheduledAt,
144
+ updatedAt,
145
+ };
146
+ }
147
+ return out;
148
+ }
149
+ catch (e) {
150
+ warn(`quota-resume: store read failed (${e instanceof Error ? e.message : String(e)}) — treating as empty`);
151
+ return {};
152
+ }
153
+ }
154
+ /** Persist the whole table (temp+rename). False when the write did not stick. */
155
+ function writeTable(table) {
156
+ try {
157
+ const p = storePath();
158
+ mkdirSync(path.dirname(p), { recursive: true });
159
+ const tmp = `${p}.tmp-${process.pid}`;
160
+ writeFileSync(tmp, JSON.stringify(table, null, 2));
161
+ renameSync(tmp, p);
162
+ return true;
163
+ }
164
+ catch (e) {
165
+ warn(`quota-resume: store write failed (${e instanceof Error ? e.message : String(e)})`);
166
+ return false;
167
+ }
168
+ }
169
+ /** How long a store mutation waits for the cross-process file lock. */
170
+ const STORE_LOCK_MAX_WAIT_MS = 3000;
171
+ /**
172
+ * Read-modify-write one entry under the store file lock (merge-at-write; null
173
+ * removes). Returns the entry that was persisted, or undefined when the lock
174
+ * or the write did not go through.
175
+ *
176
+ * The lock matters: an UNLOCKED whole-table write built from a stale snapshot
177
+ * could erase a sibling bridge's live `firing` claim mid-fire — two bridges
178
+ * would then both resume one run (a money-level double execution). Every
179
+ * mutator therefore serializes on the same lock `claimFire` uses.
180
+ */
181
+ async function updateEntry(runId, patch) {
182
+ let result;
183
+ try {
184
+ await withFileLock(storePath(), async () => {
185
+ const table = readTable();
186
+ const existing = table[runId];
187
+ let next;
188
+ if (patch === null) {
189
+ if (!existing)
190
+ return;
191
+ delete table[runId];
192
+ }
193
+ else if (existing) {
194
+ next = { ...existing, ...patch, updatedAt: Date.now() };
195
+ }
196
+ else if (patch.zcodeSid && patch.cwd && typeof patch.resumeAt === "number") {
197
+ const now = Date.now();
198
+ next = {
199
+ runId,
200
+ zcodeSid: patch.zcodeSid,
201
+ cwd: patch.cwd,
202
+ ...patch,
203
+ resumeAt: patch.resumeAt,
204
+ attempts: patch.attempts ?? 0,
205
+ scheduledAt: patch.scheduledAt ?? now,
206
+ updatedAt: now,
207
+ };
208
+ }
209
+ else {
210
+ return;
211
+ }
212
+ if (next) {
213
+ // A normal mutation ENDS the fire claim: updateEntry is only reached
214
+ // by a fire attempt of the local process (every outcome of fire()
215
+ // records through here), and the scan's schedule merge — which must
216
+ // NOT stomp a sibling's in-flight claim — has its own locked path.
217
+ delete next.firing;
218
+ table[runId] = next;
219
+ }
220
+ if (writeTable(table))
221
+ result = next;
222
+ }, { lockMaxWaitMs: STORE_LOCK_MAX_WAIT_MS });
223
+ }
224
+ catch (e) {
225
+ warn(`quota-resume: store update failed (${e instanceof Error ? e.message : String(e)})`);
226
+ }
227
+ return result;
228
+ }
229
+ // ---------- cross-process fire claim ----------
230
+ /** A fire claim older than this is considered abandoned (dead holder). */
231
+ const FIRE_CLAIM_STALE_MS = 30 * 60_000;
232
+ /** `process.kill(pid, 0)` liveness probe; EPERM means the process exists. */
233
+ function isProcessAlive(pid) {
234
+ try {
235
+ process.kill(pid, 0);
236
+ return true;
237
+ }
238
+ catch (e) {
239
+ return e?.code !== "ESRCH";
240
+ }
241
+ }
242
+ /**
243
+ * Claim the right to fire one entry, cross-process. Under the ZCode settings
244
+ * file lock (the same protocol the desktop app uses), re-read the entry and
245
+ * install `firing:{pid}`; a real claim by a LIVE other process returns
246
+ * undefined. A stale claim (dead pid, or older than the cap) is taken over.
247
+ * Fail CLOSED: a claim whose write does not persist is not granted (the
248
+ * single-flight guarantee must not hinge on a failed disk write). No explicit
249
+ * release: every outcome of {@link fire} records through {@link updateEntry},
250
+ * which clears the claim; a crashed holder is recovered by the stale-claim
251
+ * takeover.
252
+ */
253
+ async function claimFire(runId) {
254
+ let claimed;
255
+ try {
256
+ await withFileLock(storePath(), async () => {
257
+ const table = readTable();
258
+ const entry = table[runId];
259
+ if (!entry || entry.gaveUp)
260
+ return;
261
+ const firing = entry.firing;
262
+ if (firing && firing.pid !== process.pid) {
263
+ const stale = Date.now() - firing.at > FIRE_CLAIM_STALE_MS;
264
+ if (!stale && isProcessAlive(firing.pid))
265
+ return;
266
+ }
267
+ const next = {
268
+ ...entry,
269
+ firing: { pid: process.pid, at: Date.now() },
270
+ updatedAt: Date.now(),
271
+ };
272
+ table[runId] = next;
273
+ if (writeTable(table))
274
+ claimed = next;
275
+ }, { lockMaxWaitMs: STORE_LOCK_MAX_WAIT_MS });
276
+ }
277
+ catch (e) {
278
+ warn(`quota-resume: fire claim failed (${e instanceof Error ? e.message : String(e)})`);
279
+ }
280
+ return claimed;
281
+ }
282
+ // ---------- timers ----------
283
+ const timers = new Map();
284
+ const scanning = new WeakSet();
285
+ function clearTimer(runId) {
286
+ const t = timers.get(runId);
287
+ if (t)
288
+ clearTimeout(t);
289
+ timers.delete(runId);
290
+ }
291
+ /** Arm (or re-arm) the fire timer for one entry, chunking long waits. */
292
+ function setTimer(server, entry) {
293
+ clearTimer(entry.runId);
294
+ const delay = Math.max(0, Math.min(entry.resumeAt - Date.now(), MAX_TIMER_MS));
295
+ const t = setTimeout(() => {
296
+ timers.delete(entry.runId);
297
+ void fire(server, entry.runId).catch((e) => {
298
+ warn(`quota-resume: fire failed (${e instanceof Error ? e.message : String(e)})`);
299
+ });
300
+ }, delay);
301
+ t.unref?.();
302
+ timers.set(entry.runId, t);
303
+ }
304
+ /** Re-arm a LOCAL timer without touching the shared store (claim lost etc.). */
305
+ function rearmLocally(server, entry, delayMs) {
306
+ setTimer(server, { ...entry, resumeAt: Date.now() + delayMs });
307
+ }
308
+ /** Reschedule an entry and persist it in one move. */
309
+ async function reschedule(server, entry, patch) {
310
+ const next = { ...entry, ...patch, updatedAt: Date.now() };
311
+ const saved = await updateEntry(entry.runId, next);
312
+ setTimer(server, saved ?? next);
313
+ }
314
+ /**
315
+ * Retire an entry for good (see {@link QuotaResumeEntry.gaveUp}): keep the
316
+ * record so the scan never re-schedules the same journal row, drop the timer.
317
+ */
318
+ async function markGaveUp(entry, reason) {
319
+ clearTimer(entry.runId);
320
+ await updateEntry(entry.runId, {
321
+ ...entry,
322
+ gaveUp: reason,
323
+ awaitingVerify: false,
324
+ updatedAt: Date.now(),
325
+ });
326
+ log(`quota-resume: run ${entry.runId.slice(-8)} gave up (${reason})`);
327
+ }
328
+ /**
329
+ * Register (or merge) a pending auto-resume. Idempotent per runId; an earlier
330
+ * resumeAt wins so a duplicate detection never delays an armed timer.
331
+ *
332
+ * The whole read-decide-write runs under the store file lock: the merge must
333
+ * see the CURRENT record (a sibling's in-flight fire claim included — which it
334
+ * must never stomp) rather than a snapshot read before the lock was taken.
335
+ */
336
+ export async function scheduleQuotaResume(server, input) {
337
+ if (!quotaAutoResumeEnabled())
338
+ return;
339
+ const now = Date.now();
340
+ const resumeAt = input.resetAt !== undefined && input.resetAt > now
341
+ ? input.resetAt + FIRE_MARGIN_MS
342
+ : now + FIRST_FIRE_DELAY_MS;
343
+ let armed;
344
+ try {
345
+ await withFileLock(storePath(), async () => {
346
+ const table = readTable();
347
+ const existing = table[input.runId];
348
+ if (existing && !existing.gaveUp) {
349
+ const merged = {
350
+ ...existing,
351
+ ...(input.name !== undefined ? { name: input.name } : {}),
352
+ ...(input.acpSid !== undefined ? { acpSid: input.acpSid } : {}),
353
+ resumeAt: Math.min(existing.resumeAt, resumeAt),
354
+ updatedAt: now,
355
+ };
356
+ table[input.runId] = merged;
357
+ if (writeTable(table))
358
+ armed = merged;
359
+ return;
360
+ }
361
+ if (existing?.gaveUp) {
362
+ // Explicit re-schedule lifts a prior give-up (a fresh detection
363
+ // pass): the record below replaces it wholesale, so the sticky
364
+ // `gaveUp` marker cannot survive.
365
+ log(`quota-resume: run ${input.runId.slice(-8)} re-scheduled after a previous give-up`);
366
+ }
367
+ const entry = {
368
+ runId: input.runId,
369
+ zcodeSid: input.zcodeSid,
370
+ cwd: input.cwd,
371
+ ...(input.name !== undefined ? { name: input.name } : {}),
372
+ ...(input.acpSid !== undefined ? { acpSid: input.acpSid } : {}),
373
+ resumeAt,
374
+ attempts: 0,
375
+ scheduledAt: now,
376
+ updatedAt: now,
377
+ };
378
+ table[input.runId] = entry;
379
+ if (writeTable(table))
380
+ armed = entry;
381
+ }, { lockMaxWaitMs: STORE_LOCK_MAX_WAIT_MS });
382
+ }
383
+ catch (e) {
384
+ warn(`quota-resume: schedule failed (${e instanceof Error ? e.message : String(e)})`);
385
+ }
386
+ if (!armed)
387
+ return;
388
+ setTimer(server, armed);
389
+ log(`quota-resume: scheduled run ${input.runId.slice(-8)} for ` +
390
+ `${new Date(armed.resumeAt).toISOString()} (session ${input.zcodeSid.slice(-8)})`);
391
+ }
392
+ /**
393
+ * Failures the watchdog classifies as permanent (message patterns for
394
+ * transport-wrapped rejections). `workflow_disabled` is EXEMPT when the
395
+ * rejection is the fail-closed face of an UNRESOLVED gate fetch
396
+ * (`gate mode=unknown …`): the remote verdict is fetched once per backend
397
+ * generation and cached, so a transient network blip must back off and retry,
398
+ * not retire a pending resume machine-wide. A real disabled verdict (the
399
+ * server pulled the flag, or the local switch) stays permanent.
400
+ */
401
+ function isPermanentWorkflowError(e) {
402
+ if (e instanceof WorkflowApiError) {
403
+ if (e.reason === "workflow_disabled")
404
+ return !e.message.includes("mode=unknown");
405
+ if (PERMANENT_REASONS.has(e.reason))
406
+ return true;
407
+ }
408
+ const message = e instanceof Error ? e.message : "";
409
+ return /sessionNotFound|Session not found|Session ID 不存在|not_resumable|superseded/.test(message);
410
+ }
411
+ /** Await a gate promise without ever hanging a scan (it may never settle). */
412
+ async function gateVerdict(server) {
413
+ const promise = server.backendWorkflowGate;
414
+ if (!promise)
415
+ return null;
416
+ return new Promise((resolve) => {
417
+ const t = setTimeout(() => resolve(null), 1500);
418
+ t.unref?.();
419
+ void promise.then((g) => {
420
+ clearTimeout(t);
421
+ resolve(g);
422
+ }, () => {
423
+ clearTimeout(t);
424
+ resolve(null);
425
+ });
426
+ });
427
+ }
428
+ /**
429
+ * One scan over the project's journal for quota-stopped runs. Best-effort and
430
+ * side-effect free when nothing matches; never spawns a backend (the scan is
431
+ * discovery — only a fire may act).
432
+ */
433
+ export async function scanQuotaStoppedRuns(server) {
434
+ if (!quotaAutoResumeEnabled())
435
+ return;
436
+ const backend = server.backend;
437
+ if (!backend || backend.isDead)
438
+ return;
439
+ const gate = await gateVerdict(server);
440
+ if (gate && !gate.enabled)
441
+ return;
442
+ const cwd = server.projectCwd();
443
+ const resp = await backend.request(server.nextId(), "workflows/runs", { workspace: { workspacePath: cwd, workspaceKey: cwd }, limit: 50 }, 8000);
444
+ if (resp.error)
445
+ return;
446
+ const runs = (asRecord(resp.result)?.["runs"] ?? []);
447
+ if (!Array.isArray(runs))
448
+ return;
449
+ const now = Date.now();
450
+ const dismissed = dismissedRunIds();
451
+ const table = readTable();
452
+ const candidates = runs
453
+ .map((row) => asRecord(row))
454
+ .filter((row) => {
455
+ if (!row)
456
+ return false;
457
+ if (row["status"] !== "stopped" || row["stopReason"] !== "provider")
458
+ return false;
459
+ const runId = row["runId"];
460
+ const parent = row["parentSessionId"];
461
+ if (typeof runId !== "string" || !runId)
462
+ return false;
463
+ if (typeof parent !== "string" || !parent)
464
+ return false;
465
+ if (dismissed.has(runId) || table[runId])
466
+ return false;
467
+ const updated = row["updatedAt"];
468
+ if (typeof updated === "number" && now - updated > BACKFILL_MAX_AGE_MS)
469
+ return false;
470
+ return true;
471
+ });
472
+ if (candidates.length === 0)
473
+ return;
474
+ const bySession = new Map();
475
+ for (const row of candidates) {
476
+ const sid = row["parentSessionId"];
477
+ const list = bySession.get(sid) ?? [];
478
+ list.push(row);
479
+ bySession.set(sid, list);
480
+ }
481
+ for (const [sid, rows] of bySession) {
482
+ const result = await backend.request(server.nextId(), "v4/conversation/workflowRuns", { sessionId: sid, limit: 64 }, 8000);
483
+ if (result.error)
484
+ continue;
485
+ const summaries = (asRecord(result.result)?.["runs"] ?? []);
486
+ if (!Array.isArray(summaries))
487
+ continue;
488
+ for (const row of rows) {
489
+ const runId = row["runId"];
490
+ // Re-read the store: another bridge may have scheduled this run already.
491
+ if (readTable()[runId])
492
+ continue;
493
+ const summary = summaries.map((s) => asRecord(s)).find((s) => s && s["runId"] === runId);
494
+ if (!summary)
495
+ continue;
496
+ if (summary["status"] !== "stopped" || summary["resumable"] !== true)
497
+ continue;
498
+ if (!isQuotaStopFailureMessage(summary["failureMessage"]))
499
+ continue;
500
+ const name = typeof row["name"] === "string" && row["name"]
501
+ ? row["name"]
502
+ : typeof summary["label"] === "string"
503
+ ? summary["label"]
504
+ : undefined;
505
+ await scheduleQuotaResume(server, {
506
+ runId,
507
+ zcodeSid: sid,
508
+ cwd,
509
+ ...(name !== undefined ? { name } : {}),
510
+ });
511
+ }
512
+ }
513
+ }
514
+ /**
515
+ * Fire one pending entry. Everything is re-checked here because the state may
516
+ * have moved since scheduling (user resumed/stopped/dismissed the run, quota
517
+ * already recovered, bridge slept past the reset).
518
+ *
519
+ * Cross-process single-flight: the side-effecting tail runs only under a
520
+ * locked fire claim (see {@link claimFire}); a sibling bridge that loses the
521
+ * claim re-arms its local timer and lets the winner's store write (reschedule
522
+ * / retire) become the truth.
523
+ */
524
+ export async function fire(server, runId, deps = {}) {
525
+ const entry = readTable()[runId];
526
+ if (!entry)
527
+ return;
528
+ if (entry.gaveUp) {
529
+ // Defensive: a retired entry must never fire again.
530
+ clearTimer(runId);
531
+ return;
532
+ }
533
+ if (!quotaAutoResumeEnabled()) {
534
+ await updateEntry(runId, null);
535
+ return;
536
+ }
537
+ if (Date.now() < entry.resumeAt) {
538
+ // Long-wait chunk boundary — re-arm for the remainder.
539
+ setTimer(server, entry);
540
+ return;
541
+ }
542
+ if (dismissedRunIds().has(runId)) {
543
+ await updateEntry(runId, null);
544
+ return;
545
+ }
546
+ // Single-flight across bridges: no side-effecting step below runs without
547
+ // the claim. A lost claim means a sibling is mid-fire; re-arm LOCALLY only
548
+ // (writing the shared entry here would stomp the sibling's claim) and
549
+ // re-read its outcome on the next pass.
550
+ const claimed = await claimFire(runId);
551
+ if (!claimed) {
552
+ rearmLocally(server, entry, SESSION_BUSY_RETRY_MS);
553
+ return;
554
+ }
555
+ // A known future reset defers without burning an attempt (structured reset
556
+ // from the provider is preferred over the card — checked by the caller).
557
+ const query = deps.queryQuota ?? queryQuota;
558
+ const quota = await query().catch(() => ({ kind: "unavailable" }));
559
+ const resetAt = quotaResetAtFromResult(quota);
560
+ if (resetAt !== undefined) {
561
+ await reschedule(server, claimed, {
562
+ resumeAt: resetAt + FIRE_MARGIN_MS,
563
+ awaitingVerify: false,
564
+ });
565
+ return;
566
+ }
567
+ const acpSid = server.resolveAcpSid(claimed.zcodeSid) ??
568
+ loadAliasesByZcodeSid().get(claimed.zcodeSid) ??
569
+ claimed.acpSid;
570
+ if (!acpSid) {
571
+ warn(`quota-resume: no ACP alias for session ${claimed.zcodeSid.slice(-8)} — ` +
572
+ `cannot resume run ${runId.slice(-8)}`);
573
+ await markGaveUp(claimed, "no ACP alias for the parent session");
574
+ return;
575
+ }
576
+ // Live state check: a run that is running again, finished, or no longer
577
+ // resumable drops the entry (someone else acted, or the history is done).
578
+ let row;
579
+ try {
580
+ const state = await conversationRuns(server, claimed.zcodeSid, 64);
581
+ const rows = (asRecord(state)?.["runs"] ?? []);
582
+ row = Array.isArray(rows)
583
+ ? rows.map((r) => asRecord(r)).find((r) => r && r["runId"] === runId)
584
+ : undefined;
585
+ }
586
+ catch (e) {
587
+ if (isPermanentWorkflowError(e)) {
588
+ log(`quota-resume: run ${runId.slice(-8)} dropped (${e instanceof Error ? e.message : e})`);
589
+ await markGaveUp(claimed, "parent session unresolvable");
590
+ return;
591
+ }
592
+ await retryLater(server, claimed, e instanceof Error ? e.message : String(e));
593
+ return;
594
+ }
595
+ if (!row) {
596
+ await updateEntry(runId, null);
597
+ return;
598
+ }
599
+ if (row["status"] === "running" || row["status"] === "pending") {
600
+ await updateEntry(runId, null);
601
+ return;
602
+ }
603
+ if (row["status"] !== "stopped" || row["resumable"] !== true) {
604
+ await updateEntry(runId, null);
605
+ return;
606
+ }
607
+ if (claimed.awaitingVerify) {
608
+ // The previous attempt was accepted but the run is stopped again while the
609
+ // quota card shows no future reset: count it as a failed attempt.
610
+ await retryLater(server, claimed, "resumed run stopped again");
611
+ return;
612
+ }
613
+ try {
614
+ await resumeWorkflowRun(server, {
615
+ runId,
616
+ acpSessionId: acpSid,
617
+ ...(claimed.name !== undefined ? { name: claimed.name } : {}),
618
+ });
619
+ }
620
+ catch (e) {
621
+ if (isPermanentWorkflowError(e)) {
622
+ log(`quota-resume: run ${runId.slice(-8)} rejected permanently — retiring`);
623
+ await markGaveUp(claimed, e instanceof Error ? e.message : "permanent rejection");
624
+ return;
625
+ }
626
+ if (e instanceof WorkflowApiError && e.reason === "session_busy") {
627
+ await reschedule(server, claimed, { resumeAt: Date.now() + SESSION_BUSY_RETRY_MS });
628
+ return;
629
+ }
630
+ if (e instanceof WorkflowApiError && e.reason === "already_running") {
631
+ // Another actor (a user click, a sibling bridge outside our store)
632
+ // resumed it first — the watchdog's job is done.
633
+ log(`quota-resume: run ${runId.slice(-8)} is already running — dropping`);
634
+ await updateEntry(runId, null);
635
+ return;
636
+ }
637
+ await retryLater(server, claimed, e instanceof Error ? e.message : String(e));
638
+ return;
639
+ }
640
+ // Accepted: verify later that it actually sticks; announce once (first attempt).
641
+ await reschedule(server, claimed, {
642
+ resumeAt: Date.now() + VERIFY_AFTER_MS,
643
+ awaitingVerify: true,
644
+ });
645
+ if (claimed.attempts === 0)
646
+ announceResumed(server, claimed, acpSid);
647
+ log(`quota-resume: run ${runId.slice(-8)} resumed (attempt ${claimed.attempts + 1}); ` +
648
+ `verifying in ${Math.round(VERIFY_AFTER_MS / 60000)}min`);
649
+ }
650
+ /** Backoff a failed attempt; give up (and say so) past the cap. */
651
+ async function retryLater(server, entry, reason) {
652
+ const attempts = entry.attempts + 1;
653
+ if (attempts > MAX_ATTEMPTS) {
654
+ warn(`quota-resume: run ${entry.runId.slice(-8)} gave up after ${MAX_ATTEMPTS} attempts (${reason})`);
655
+ const acpSid = server.resolveAcpSid(entry.zcodeSid) ?? loadAliasesByZcodeSid().get(entry.zcodeSid);
656
+ if (acpSid)
657
+ announceGaveUp(server, entry, acpSid);
658
+ await markGaveUp(entry, `${MAX_ATTEMPTS} failed attempts: ${reason}`);
659
+ return;
660
+ }
661
+ const delay = quotaBackoffMs(attempts, BACKOFF_BASE_MS);
662
+ await reschedule(server, entry, {
663
+ resumeAt: Date.now() + delay,
664
+ attempts,
665
+ awaitingVerify: false,
666
+ });
667
+ log(`quota-resume: run ${entry.runId.slice(-8)} attempt ${attempts} failed (${reason}); ` +
668
+ `retrying in ${Math.round(delay / 60000)}min`);
669
+ }
670
+ function resumeLabel(entry) {
671
+ return entry.name ?? `run ${entry.runId.slice(0, 8)}`;
672
+ }
673
+ function announceResumed(server, entry, acpSid) {
674
+ void sendTextChunk(server.clients.broadcast(), acpSid, messages().workflowQuotaResumed(resumeLabel(entry)), randomUUID()).catch(() => undefined);
675
+ pushSettled(server, {
676
+ kind: "run",
677
+ label: pushSourceLabel(server, acpSid),
678
+ title: `workflow ${resumeLabel(entry)} auto-resumed after quota reset`,
679
+ });
680
+ }
681
+ function announceGaveUp(server, entry, acpSid) {
682
+ void sendTextChunk(server.clients.broadcast(), acpSid, messages().workflowQuotaResumeGaveUp(resumeLabel(entry)), randomUUID()).catch(() => undefined);
683
+ pushSettled(server, {
684
+ kind: "run",
685
+ label: pushSourceLabel(server, acpSid),
686
+ title: `workflow ${resumeLabel(entry)} auto-resume gave up — resume manually`,
687
+ });
688
+ }
689
+ // ---------- watchdog lifecycle ----------
690
+ const armedServers = new WeakSet();
691
+ /**
692
+ * Arm the watchdog for one bridge: re-arm its project's persisted entries and
693
+ * start the periodic scan. Idempotent per server. Scans never spawn a backend
694
+ * and never throw; a bridge that never sees a backend stays silent.
695
+ */
696
+ export function armQuotaResumeWatchdog(server) {
697
+ if (armedServers.has(server))
698
+ return;
699
+ armedServers.add(server);
700
+ if (quotaAutoResumeEnabled()) {
701
+ const cwd = server.projectCwd();
702
+ for (const entry of Object.values(readTable())) {
703
+ if (entry.cwd !== cwd || entry.gaveUp)
704
+ continue;
705
+ setTimer(server, entry);
706
+ log(`quota-resume: re-armed run ${entry.runId.slice(-8)} for ` +
707
+ `${new Date(entry.resumeAt).toISOString()}`);
708
+ }
709
+ }
710
+ const scan = () => {
711
+ if (scanning.has(server))
712
+ return;
713
+ scanning.add(server);
714
+ void scanQuotaStoppedRuns(server)
715
+ .catch((e) => {
716
+ warn(`quota-resume: scan failed (${e instanceof Error ? e.message : String(e)})`);
717
+ })
718
+ .finally(() => scanning.delete(server));
719
+ };
720
+ const interval = setInterval(scan, QUOTA_RESUME_SCAN_INTERVAL_MS);
721
+ interval.unref?.();
722
+ const first = setTimeout(scan, FIRST_SCAN_DELAY_MS);
723
+ first.unref?.();
724
+ }
725
+ /** Is a resume armed for this run? (test/observability hook) */
726
+ export function quotaResumePending(runId) {
727
+ const entry = readTable()[runId];
728
+ if (entry?.gaveUp)
729
+ return false;
730
+ return timers.has(runId) || entry !== undefined;
731
+ }
732
+ /**
733
+ * Is any resume pending for a workspace? The headless serve bridge consults
734
+ * this in its idle decision: exiting would kill the backend the pending
735
+ * resume must act on, breaking "the bridge owns the wait" exactly when the
736
+ * app is asleep (the case the watchdog exists for). Retired (gave-up) entries
737
+ * do not hold a serve bridge alive.
738
+ */
739
+ export function hasQuotaResumePendingForCwd(cwd) {
740
+ for (const entry of Object.values(readTable())) {
741
+ if (entry.cwd === cwd && !entry.gaveUp)
742
+ return true;
743
+ }
744
+ return false;
745
+ }
746
+ /** Read one entry (test hook). */
747
+ export function quotaResumeEntryForTest(runId) {
748
+ return readTable()[runId];
749
+ }
750
+ /** Patch an entry and drop its timer (test hook; patches may include `firing`). */
751
+ export function updateQuotaResumeEntryForTest(runId, patch) {
752
+ const table = readTable();
753
+ const entry = table[runId];
754
+ if (!entry)
755
+ return;
756
+ // `updatedAt: Date.now()` is the default, but a patch may pin it (TTL tests).
757
+ table[runId] = { ...entry, updatedAt: Date.now(), ...patch };
758
+ writeTable(table);
759
+ clearTimer(runId);
760
+ }
761
+ /** Drop every timer and the store file (test hook). */
762
+ export function resetQuotaResumeForTest() {
763
+ for (const t of timers.values())
764
+ clearTimeout(t);
765
+ timers.clear();
766
+ try {
767
+ unlinkSync(storePath());
768
+ }
769
+ catch {
770
+ /* absent — fine */
771
+ }
772
+ }
773
+ //# sourceMappingURL=quota-resume.js.map