zcode-acp-server 0.60.0 → 0.62.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.
- package/README.md +17 -13
- package/dist/code-fingerprint.json +2 -2
- package/dist/config/settings.d.ts +7 -0
- package/dist/config/settings.d.ts.map +1 -1
- package/dist/config/settings.js +15 -0
- package/dist/config/settings.js.map +1 -1
- package/dist/config/user-config.d.ts +7 -0
- package/dist/config/user-config.d.ts.map +1 -1
- package/dist/config/user-config.js +2 -0
- package/dist/config/user-config.js.map +1 -1
- package/dist/goal-loop/driver.d.ts +18 -0
- package/dist/goal-loop/driver.d.ts.map +1 -1
- package/dist/goal-loop/driver.js +100 -1
- package/dist/goal-loop/driver.js.map +1 -1
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +8 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/quota/resume.d.ts +66 -0
- package/dist/quota/resume.d.ts.map +1 -0
- package/dist/quota/resume.js +217 -0
- package/dist/quota/resume.js.map +1 -0
- package/dist/remote/hub-server.d.ts.map +1 -1
- package/dist/remote/hub-server.js +29 -0
- package/dist/remote/hub-server.js.map +1 -1
- package/dist/remote/system-stats.d.ts +137 -0
- package/dist/remote/system-stats.d.ts.map +1 -0
- package/dist/remote/system-stats.js +335 -0
- package/dist/remote/system-stats.js.map +1 -0
- package/dist/remote/workflow-overview.d.ts +7 -1
- package/dist/remote/workflow-overview.d.ts.map +1 -1
- package/dist/remote/workflow-overview.js +135 -2
- package/dist/remote/workflow-overview.js.map +1 -1
- package/dist/workflow/quota-resume.d.ts +135 -0
- package/dist/workflow/quota-resume.d.ts.map +1 -0
- package/dist/workflow/quota-resume.js +773 -0
- package/dist/workflow/quota-resume.js.map +1 -0
- package/docs/DEVELOPMENT.md +7 -0
- package/docs/REMOTE-CLIENTS.md +40 -5
- 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
|