storyforge 0.5.3 → 0.6.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.
|
@@ -9,6 +9,7 @@ import * as crypto from "crypto";
|
|
|
9
9
|
import * as fs from "fs";
|
|
10
10
|
var HEARTBEAT_INTERVAL_MS = 6e4;
|
|
11
11
|
var POLL_INTERVAL_MS = 8e3;
|
|
12
|
+
var CANCEL_POLL_INTERVAL_MS = 5e3;
|
|
12
13
|
var CLI_TIMEOUT_MS = Number(process.env.SCRIPT_GEN_TIMEOUT_MS) || 20 * 60 * 1e3;
|
|
13
14
|
var IMAGE_GEN_TIMEOUT_MS = Number(process.env.CODEX_IMAGE_GEN_TIMEOUT_MS) || Number(process.env.IMAGE_GEN_TIMEOUT_MS) || 8 * 60 * 1e3;
|
|
14
15
|
var MAX_IN_FLIGHT_JOBS = Math.max(1, Math.min(6, Number(process.env.FORGE_BRIDGE_MAX_IN_FLIGHT) || 4));
|
|
@@ -21,7 +22,14 @@ var BridgePoller = class {
|
|
|
21
22
|
instanceId;
|
|
22
23
|
heartbeatTimer = null;
|
|
23
24
|
pollTimer = null;
|
|
25
|
+
cancelPollTimer = null;
|
|
24
26
|
inFlight = /* @__PURE__ */ new Set();
|
|
27
|
+
/**
|
|
28
|
+
* P-Concern-J: track each in-flight job's child process so we can
|
|
29
|
+
* SIGTERM it when the cancel_requested flag fires. Cleared on
|
|
30
|
+
* runJob exit.
|
|
31
|
+
*/
|
|
32
|
+
inFlightChildren = /* @__PURE__ */ new Map();
|
|
25
33
|
stopped = false;
|
|
26
34
|
constructor(opts) {
|
|
27
35
|
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
@@ -39,13 +47,18 @@ var BridgePoller = class {
|
|
|
39
47
|
this.pollTimer = setInterval(() => {
|
|
40
48
|
void this.poll();
|
|
41
49
|
}, POLL_INTERVAL_MS);
|
|
50
|
+
this.cancelPollTimer = setInterval(() => {
|
|
51
|
+
void this.checkCancels();
|
|
52
|
+
}, CANCEL_POLL_INTERVAL_MS);
|
|
42
53
|
}
|
|
43
54
|
async stop() {
|
|
44
55
|
this.stopped = true;
|
|
45
56
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
46
57
|
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
58
|
+
if (this.cancelPollTimer) clearInterval(this.cancelPollTimer);
|
|
47
59
|
this.heartbeatTimer = null;
|
|
48
60
|
this.pollTimer = null;
|
|
61
|
+
this.cancelPollTimer = null;
|
|
49
62
|
}
|
|
50
63
|
probeBinary(name) {
|
|
51
64
|
if (!/^[a-z][a-z0-9-]*$/i.test(name)) return { available: false, path: null };
|
|
@@ -76,11 +89,68 @@ var BridgePoller = class {
|
|
|
76
89
|
if (!resp.ok) {
|
|
77
90
|
const text = await resp.text().catch(() => "");
|
|
78
91
|
log.warn(`[bridge] heartbeat HTTP ${resp.status}: ${text.slice(0, 120)}`);
|
|
92
|
+
return;
|
|
79
93
|
}
|
|
94
|
+
const data = await resp.json().catch(() => null);
|
|
95
|
+
this.handleCancelRequestedIds(data?.cancelRequestedJobs ?? []);
|
|
80
96
|
} catch (err) {
|
|
81
97
|
log.warn(`[bridge] heartbeat failed: ${err.message}`);
|
|
82
98
|
}
|
|
83
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* P-Concern-J: tighter-cadence cancel detection. Polls the
|
|
102
|
+
* heartbeat-style endpoint every 5s asking only for the cancel list
|
|
103
|
+
* (cheap query — partial index on cancel_requested). When a flagged
|
|
104
|
+
* job ID matches an in-flight one, SIGTERM the child process.
|
|
105
|
+
*
|
|
106
|
+
* We reuse the heartbeat endpoint itself rather than adding a new
|
|
107
|
+
* route; the endpoint already returns cancelRequestedJobs and the
|
|
108
|
+
* cost of the inner UPDATE is constant (idempotent).
|
|
109
|
+
*/
|
|
110
|
+
async checkCancels() {
|
|
111
|
+
if (this.stopped || this.inFlight.size === 0) return;
|
|
112
|
+
try {
|
|
113
|
+
const resp = await fetch(`${this.baseUrl}/api/cli-bridge/heartbeat`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: {
|
|
116
|
+
Authorization: `Bearer ${this.token}`,
|
|
117
|
+
"Content-Type": "application/json"
|
|
118
|
+
},
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
availableClis: {
|
|
121
|
+
claude: this.probeBinary("claude"),
|
|
122
|
+
codex: this.probeBinary("codex")
|
|
123
|
+
},
|
|
124
|
+
clientVersion: this.clientVersion,
|
|
125
|
+
instanceId: this.instanceId
|
|
126
|
+
}),
|
|
127
|
+
signal: AbortSignal.timeout(15e3)
|
|
128
|
+
});
|
|
129
|
+
if (!resp.ok) return;
|
|
130
|
+
const data = await resp.json().catch(() => null);
|
|
131
|
+
this.handleCancelRequestedIds(data?.cancelRequestedJobs ?? []);
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
handleCancelRequestedIds(ids) {
|
|
136
|
+
if (!ids.length) return;
|
|
137
|
+
for (const id of ids) {
|
|
138
|
+
const child = this.inFlightChildren.get(id);
|
|
139
|
+
if (!child) continue;
|
|
140
|
+
log.warn(`[bridge] cancel_requested for job ${id.slice(0, 8)} \u2014 SIGTERM-ing child pid=${child.pid}`);
|
|
141
|
+
try {
|
|
142
|
+
child.kill("SIGTERM");
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
try {
|
|
145
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
146
|
+
} catch {
|
|
147
|
+
}
|
|
148
|
+
}, 3e3);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
log.warn(`[bridge] SIGTERM failed for ${id.slice(0, 8)}: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
84
154
|
async poll() {
|
|
85
155
|
if (this.stopped) return;
|
|
86
156
|
const availableSlots = MAX_IN_FLIGHT_JOBS - this.inFlight.size;
|
|
@@ -136,9 +206,10 @@ var BridgePoller = class {
|
|
|
136
206
|
await this.respond(job.id, { error: err.message, latencyMs });
|
|
137
207
|
} finally {
|
|
138
208
|
this.inFlight.delete(job.id);
|
|
209
|
+
this.inFlightChildren.delete(job.id);
|
|
139
210
|
}
|
|
140
211
|
}
|
|
141
|
-
async invokeCli(cli, model, prompt) {
|
|
212
|
+
async invokeCli(cli, model, prompt, onSpawn) {
|
|
142
213
|
const candidates = cli === "codex" ? model && /^[a-z0-9.\-]+$/i.test(model) ? [model] : ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"] : model && /^[a-z0-9-]+$/i.test(model) ? [model] : ["opus", "sonnet"];
|
|
143
214
|
let lastErr = "";
|
|
144
215
|
for (const m of candidates) {
|
|
@@ -151,7 +222,7 @@ var BridgePoller = class {
|
|
|
151
222
|
"--allowedTools",
|
|
152
223
|
"WebSearch,WebFetch"
|
|
153
224
|
];
|
|
154
|
-
const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS);
|
|
225
|
+
const text = await runSpawn(cli, args, prompt, CLI_TIMEOUT_MS, onSpawn);
|
|
155
226
|
if (text.trim()) return { text, modelUsed: `${cli}:${m}` };
|
|
156
227
|
lastErr = `${cli}/${m} returned empty stdout`;
|
|
157
228
|
} catch (err) {
|
|
@@ -161,24 +232,27 @@ var BridgePoller = class {
|
|
|
161
232
|
return { error: lastErr || "no model produced output" };
|
|
162
233
|
}
|
|
163
234
|
async invokeJob(job) {
|
|
235
|
+
const onSpawn = (child) => {
|
|
236
|
+
this.inFlightChildren.set(job.id, child);
|
|
237
|
+
};
|
|
164
238
|
const claudePromptEnvelope = parseClaudePromptEnvelope(job.prompt);
|
|
165
239
|
if (claudePromptEnvelope) {
|
|
166
240
|
if (job.cli !== "claude") return { error: `${claudePromptEnvelope.__forgeJobKind} envelope requires claude CLI` };
|
|
167
|
-
return this.invokeClaudeWithEnvelope(job.model, claudePromptEnvelope);
|
|
241
|
+
return this.invokeClaudeWithEnvelope(job.model, claudePromptEnvelope, onSpawn);
|
|
168
242
|
}
|
|
169
243
|
const imagePayload = parseImageGenPayload(job.prompt);
|
|
170
244
|
if (imagePayload) {
|
|
171
245
|
if (job.cli !== "codex") return { error: "image-gen bridge jobs require codex CLI" };
|
|
172
|
-
return this.invokeCodexImageGen(job.model, imagePayload);
|
|
246
|
+
return this.invokeCodexImageGen(job.model, imagePayload, onSpawn);
|
|
173
247
|
}
|
|
174
|
-
return this.invokeCli(job.cli, job.model, job.prompt);
|
|
248
|
+
return this.invokeCli(job.cli, job.model, job.prompt, onSpawn);
|
|
175
249
|
}
|
|
176
250
|
/**
|
|
177
251
|
* Run a Claude prompt envelope. Web tools are gated by envelope.webTools:
|
|
178
252
|
* ['WebSearch','WebFetch'] for fact-pack-build (research stage)
|
|
179
253
|
* [] for script-from-pack (pack-only writing)
|
|
180
254
|
*/
|
|
181
|
-
async invokeClaudeWithEnvelope(jobModel, envelope) {
|
|
255
|
+
async invokeClaudeWithEnvelope(jobModel, envelope, onSpawn) {
|
|
182
256
|
const requestedModel = envelope.model || jobModel;
|
|
183
257
|
const fallbackChain = envelope.__forgeJobKind === "fact-pack-build" ? ["opus", "sonnet"] : ["sonnet", "haiku"];
|
|
184
258
|
const candidates = requestedModel && /^[a-z0-9-]+$/i.test(requestedModel) ? [requestedModel] : fallbackChain;
|
|
@@ -191,7 +265,7 @@ var BridgePoller = class {
|
|
|
191
265
|
for (const m of candidates) {
|
|
192
266
|
try {
|
|
193
267
|
const args = baseArgs.map((a) => a === "<m>" ? m : a);
|
|
194
|
-
const text = await runSpawn("claude", args, envelope.prompt, CLI_TIMEOUT_MS);
|
|
268
|
+
const text = await runSpawn("claude", args, envelope.prompt, CLI_TIMEOUT_MS, onSpawn);
|
|
195
269
|
if (text.trim()) return { text, modelUsed: `claude:${m}${tools.length === 0 ? " (no-web)" : ""}` };
|
|
196
270
|
lastErr = `claude/${m} returned empty stdout`;
|
|
197
271
|
} catch (err) {
|
|
@@ -200,7 +274,7 @@ var BridgePoller = class {
|
|
|
200
274
|
}
|
|
201
275
|
return { error: lastErr || "no model produced output" };
|
|
202
276
|
}
|
|
203
|
-
async invokeCodexImageGen(model, payload) {
|
|
277
|
+
async invokeCodexImageGen(model, payload, onSpawn) {
|
|
204
278
|
const candidates = codexImagegenModelCandidates(model);
|
|
205
279
|
const count = Math.max(1, Math.min(4, Number(payload.count) || 1));
|
|
206
280
|
const prompt = [
|
|
@@ -220,7 +294,7 @@ var BridgePoller = class {
|
|
|
220
294
|
let lastErr = "";
|
|
221
295
|
for (const m of candidates) {
|
|
222
296
|
try {
|
|
223
|
-
const stdout = await runSpawn("codex", ["exec", "-", "--model", m], prompt, IMAGE_GEN_TIMEOUT_MS);
|
|
297
|
+
const stdout = await runSpawn("codex", ["exec", "-", "--model", m], prompt, IMAGE_GEN_TIMEOUT_MS, onSpawn);
|
|
224
298
|
const images = collectImagesFromCodexStdout(stdout, count);
|
|
225
299
|
if (images.length === 0) {
|
|
226
300
|
lastErr = `codex/${m} imagegen returned no readable image paths`;
|
|
@@ -332,9 +406,15 @@ function mimeTypeForPath(p) {
|
|
|
332
406
|
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
333
407
|
return "image/png";
|
|
334
408
|
}
|
|
335
|
-
function runSpawn(cmd, args, stdin, timeoutMs) {
|
|
409
|
+
function runSpawn(cmd, args, stdin, timeoutMs, onSpawn) {
|
|
336
410
|
return new Promise((resolve, reject) => {
|
|
337
411
|
const proc = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
412
|
+
if (onSpawn) {
|
|
413
|
+
try {
|
|
414
|
+
onSpawn(proc);
|
|
415
|
+
} catch {
|
|
416
|
+
}
|
|
417
|
+
}
|
|
338
418
|
let stdout = "";
|
|
339
419
|
let stderr = "";
|
|
340
420
|
const timer = setTimeout(() => {
|
|
@@ -354,8 +434,12 @@ function runSpawn(cmd, args, stdin, timeoutMs) {
|
|
|
354
434
|
clearTimeout(timer);
|
|
355
435
|
reject(err);
|
|
356
436
|
});
|
|
357
|
-
proc.on("close", (code) => {
|
|
437
|
+
proc.on("close", (code, signal) => {
|
|
358
438
|
clearTimeout(timer);
|
|
439
|
+
if (signal === "SIGTERM" || signal === "SIGKILL") {
|
|
440
|
+
reject(new Error(`${cmd} terminated by ${signal} (likely user cancel)`));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
359
443
|
if (code !== 0) reject(new Error(`${cmd} exit ${code}: ${stderr.slice(-300)}`));
|
|
360
444
|
else resolve(stdout);
|
|
361
445
|
});
|
package/dist/index.js
CHANGED
|
@@ -1614,7 +1614,7 @@ Return ONLY the complete updated TSX. No markdown fences, no explanation.`;
|
|
|
1614
1614
|
return "0.0.0";
|
|
1615
1615
|
})();
|
|
1616
1616
|
void (async () => {
|
|
1617
|
-
const { BridgePoller } = await import("./bridge-poller-
|
|
1617
|
+
const { BridgePoller } = await import("./bridge-poller-MM6K2GKM.js");
|
|
1618
1618
|
const poller = new BridgePoller({ baseUrl: bridgeUrl, token: bridgeToken, clientVersion: `storyforge ${pkgVersion}` });
|
|
1619
1619
|
poller.start();
|
|
1620
1620
|
})();
|