@sorenllm/opencode-forge 0.3.0 → 0.3.1
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 +419 -373
- package/dist/index.js +834 -99
- package/package.json +61 -61
package/dist/index.js
CHANGED
|
@@ -12334,9 +12334,9 @@ function tool(input) {
|
|
|
12334
12334
|
}
|
|
12335
12335
|
tool.schema = exports_external;
|
|
12336
12336
|
// plugin.ts
|
|
12337
|
-
import { execFileSync as
|
|
12338
|
-
import { appendFileSync
|
|
12339
|
-
import { isAbsolute, join as
|
|
12337
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
12338
|
+
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync6, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
12339
|
+
import { isAbsolute, join as join4, relative } from "node:path";
|
|
12340
12340
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
12341
12341
|
|
|
12342
12342
|
// src/plan-file.ts
|
|
@@ -13121,20 +13121,21 @@ import { readFileSync } from "node:fs";
|
|
|
13121
13121
|
import { join, resolve, sep } from "node:path";
|
|
13122
13122
|
|
|
13123
13123
|
// src/proc.ts
|
|
13124
|
-
import { spawn } from "node:child_process";
|
|
13124
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13125
13125
|
function shellSpawn(spawnFn, cmd, opts = {}) {
|
|
13126
13126
|
return spawnFn(cmd, {
|
|
13127
13127
|
shell: true,
|
|
13128
13128
|
...opts.cwd !== undefined ? { cwd: opts.cwd } : {},
|
|
13129
13129
|
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
13130
13130
|
windowsHide: opts.windowsHide ?? true,
|
|
13131
|
-
detached: opts.detached ?? process.platform !== "win32"
|
|
13131
|
+
detached: opts.detached ?? process.platform !== "win32",
|
|
13132
|
+
...opts.stdio !== undefined ? { stdio: opts.stdio } : {}
|
|
13132
13133
|
});
|
|
13133
13134
|
}
|
|
13134
|
-
function treeKillPlan(platform, pid) {
|
|
13135
|
+
function treeKillPlan(platform, pid, force = true) {
|
|
13135
13136
|
if (platform === "win32")
|
|
13136
|
-
return { kind: "taskkill", args: ["/pid", String(pid), "/F", "/T"] };
|
|
13137
|
-
return { kind: "group", signal: "SIGKILL" };
|
|
13137
|
+
return { kind: "taskkill", args: force ? ["/pid", String(pid), "/F", "/T"] : ["/pid", String(pid), "/T"] };
|
|
13138
|
+
return { kind: "group", signal: force ? "SIGKILL" : "SIGTERM" };
|
|
13138
13139
|
}
|
|
13139
13140
|
function killTree(child, opts = {}) {
|
|
13140
13141
|
const platform = opts.platform ?? process.platform;
|
|
@@ -13157,6 +13158,49 @@ function killTree(child, opts = {}) {
|
|
|
13157
13158
|
}
|
|
13158
13159
|
}
|
|
13159
13160
|
}
|
|
13161
|
+
function pidAlive(pid) {
|
|
13162
|
+
if (!pid || pid <= 0)
|
|
13163
|
+
return false;
|
|
13164
|
+
try {
|
|
13165
|
+
process.kill(pid, 0);
|
|
13166
|
+
return true;
|
|
13167
|
+
} catch (err) {
|
|
13168
|
+
return err.code === "EPERM";
|
|
13169
|
+
}
|
|
13170
|
+
}
|
|
13171
|
+
function terminateTreeSync(pid, opts = {}) {
|
|
13172
|
+
const graceMs = Math.max(0, opts.graceMs ?? 0);
|
|
13173
|
+
const platform = opts.platform ?? process.platform;
|
|
13174
|
+
const sync = opts.spawnSyncFn ?? spawnSync;
|
|
13175
|
+
const wait = opts.wait ?? true;
|
|
13176
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
13177
|
+
const run = (force) => {
|
|
13178
|
+
const plan = treeKillPlan(platform, pid, force);
|
|
13179
|
+
if (plan.kind === "taskkill") {
|
|
13180
|
+
try {
|
|
13181
|
+
sync("taskkill", plan.args, { windowsHide: true, stdio: "ignore", timeout: 1e4 });
|
|
13182
|
+
} catch {}
|
|
13183
|
+
} else {
|
|
13184
|
+
try {
|
|
13185
|
+
process.kill(-pid, plan.signal);
|
|
13186
|
+
} catch {
|
|
13187
|
+
try {
|
|
13188
|
+
process.kill(pid, plan.signal);
|
|
13189
|
+
} catch {}
|
|
13190
|
+
}
|
|
13191
|
+
}
|
|
13192
|
+
};
|
|
13193
|
+
if (graceMs > 0) {
|
|
13194
|
+
run(false);
|
|
13195
|
+
if (wait) {
|
|
13196
|
+
const deadline = Date.now() + graceMs;
|
|
13197
|
+
while (Date.now() < deadline && pidAlive(pid))
|
|
13198
|
+
sleep(Math.min(100, deadline - Date.now()));
|
|
13199
|
+
}
|
|
13200
|
+
}
|
|
13201
|
+
run(true);
|
|
13202
|
+
return { graceful: graceMs > 0, forced: true };
|
|
13203
|
+
}
|
|
13160
13204
|
|
|
13161
13205
|
// src/run-check.ts
|
|
13162
13206
|
var OUTPUT_LIMIT = 2048;
|
|
@@ -13349,6 +13393,9 @@ function createJobManager(opts = {}) {
|
|
|
13349
13393
|
enforceCaps();
|
|
13350
13394
|
return job;
|
|
13351
13395
|
}
|
|
13396
|
+
function setPid(job, pid) {
|
|
13397
|
+
job.pid = pid;
|
|
13398
|
+
}
|
|
13352
13399
|
function get(id) {
|
|
13353
13400
|
return jobs.get(id);
|
|
13354
13401
|
}
|
|
@@ -13423,6 +13470,8 @@ function createJobManager(opts = {}) {
|
|
|
13423
13470
|
for (const job of [...jobs.values()]) {
|
|
13424
13471
|
if (job.ownerSession !== sessionID)
|
|
13425
13472
|
continue;
|
|
13473
|
+
if (job.survive)
|
|
13474
|
+
continue;
|
|
13426
13475
|
if (!isTerminal2(job)) {
|
|
13427
13476
|
if (job.scope === "global")
|
|
13428
13477
|
continue;
|
|
@@ -13436,6 +13485,8 @@ function createJobManager(opts = {}) {
|
|
|
13436
13485
|
}
|
|
13437
13486
|
function disposeAll() {
|
|
13438
13487
|
for (const job of [...jobs.values()]) {
|
|
13488
|
+
if (job.survive)
|
|
13489
|
+
continue;
|
|
13439
13490
|
if (!isTerminal2(job)) {
|
|
13440
13491
|
kill(job);
|
|
13441
13492
|
emit("orphan-job", job, "plugin dispose; tree killed");
|
|
@@ -13468,6 +13519,7 @@ function createJobManager(opts = {}) {
|
|
|
13468
13519
|
}
|
|
13469
13520
|
return {
|
|
13470
13521
|
create,
|
|
13522
|
+
setPid,
|
|
13471
13523
|
get,
|
|
13472
13524
|
list,
|
|
13473
13525
|
appendOutput,
|
|
@@ -13493,7 +13545,7 @@ function newJobId(now = Date.now) {
|
|
|
13493
13545
|
|
|
13494
13546
|
// src/job-runner.ts
|
|
13495
13547
|
import { spawn as spawn3 } from "node:child_process";
|
|
13496
|
-
import {
|
|
13548
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readSync, readFileSync as readFileSync2, statSync, unlinkSync } from "node:fs";
|
|
13497
13549
|
import { join as join2 } from "node:path";
|
|
13498
13550
|
import { tmpdir } from "node:os";
|
|
13499
13551
|
var DEFAULT_IDLE_MS = 60000;
|
|
@@ -13502,9 +13554,61 @@ var HARD_MAX_WAIT_MS = 600000;
|
|
|
13502
13554
|
var POLL_WAIT_MAX_MS = 30000;
|
|
13503
13555
|
var DEFAULT_LOG_KEEP = 50;
|
|
13504
13556
|
var JOB_ENV_MARKER = "FORGE_JOB_ID";
|
|
13557
|
+
var TAIL_POLL_MS = 200;
|
|
13558
|
+
var ADOPT_LIVENESS_MS = 1000;
|
|
13505
13559
|
function jobsLogDir(base) {
|
|
13506
13560
|
return join2(base ?? join2(tmpdir(), "opencode-forge"), "jobs");
|
|
13507
13561
|
}
|
|
13562
|
+
var TAIL_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
13563
|
+
function createFileTail(logPath, onText, pollMs = TAIL_POLL_MS) {
|
|
13564
|
+
let pos = existsSync(logPath) ? statSync(logPath).size : 0;
|
|
13565
|
+
let stopped = false;
|
|
13566
|
+
const flush = () => {
|
|
13567
|
+
if (stopped)
|
|
13568
|
+
return;
|
|
13569
|
+
try {
|
|
13570
|
+
let size = existsSync(logPath) ? statSync(logPath).size : 0;
|
|
13571
|
+
if (size < pos)
|
|
13572
|
+
pos = size;
|
|
13573
|
+
const fd = openSync(logPath, "r");
|
|
13574
|
+
try {
|
|
13575
|
+
const buf = Buffer.allocUnsafe(TAIL_CHUNK_BYTES);
|
|
13576
|
+
while (pos < size) {
|
|
13577
|
+
const want = Math.min(TAIL_CHUNK_BYTES, size - pos);
|
|
13578
|
+
let read = 0;
|
|
13579
|
+
while (read < want) {
|
|
13580
|
+
const n = readSync(fd, buf, read, want - read, pos + read);
|
|
13581
|
+
if (n <= 0)
|
|
13582
|
+
break;
|
|
13583
|
+
read += n;
|
|
13584
|
+
}
|
|
13585
|
+
if (read <= 0)
|
|
13586
|
+
break;
|
|
13587
|
+
pos += read;
|
|
13588
|
+
onText(buf.toString("utf8", 0, read));
|
|
13589
|
+
size = existsSync(logPath) ? statSync(logPath).size : size;
|
|
13590
|
+
if (stopped)
|
|
13591
|
+
break;
|
|
13592
|
+
}
|
|
13593
|
+
} finally {
|
|
13594
|
+
closeSync(fd);
|
|
13595
|
+
}
|
|
13596
|
+
} catch {}
|
|
13597
|
+
};
|
|
13598
|
+
const timer = setInterval(flush, pollMs);
|
|
13599
|
+
timer.unref?.();
|
|
13600
|
+
return {
|
|
13601
|
+
flush,
|
|
13602
|
+
stop() {
|
|
13603
|
+
stopped = true;
|
|
13604
|
+
clearInterval(timer);
|
|
13605
|
+
}
|
|
13606
|
+
};
|
|
13607
|
+
}
|
|
13608
|
+
var liveTails = new Map;
|
|
13609
|
+
function flushJobOutput(job) {
|
|
13610
|
+
liveTails.get(job.id)?.flush();
|
|
13611
|
+
}
|
|
13508
13612
|
function startJob(manager, opts) {
|
|
13509
13613
|
const idleMs = Math.max(0, opts.idleMs ?? DEFAULT_IDLE_MS);
|
|
13510
13614
|
const maxWaitMs = Math.min(Math.max(0, opts.maxWaitMs ?? DEFAULT_MAX_WAIT_MS), HARD_MAX_WAIT_MS);
|
|
@@ -13535,6 +13639,7 @@ function startJob(manager, opts) {
|
|
|
13535
13639
|
idleTimer = null;
|
|
13536
13640
|
clearTimeout(maxWaitTimer);
|
|
13537
13641
|
};
|
|
13642
|
+
const firstOutputHooks = [];
|
|
13538
13643
|
function resolveStillRunning() {
|
|
13539
13644
|
if (settled)
|
|
13540
13645
|
return;
|
|
@@ -13549,25 +13654,67 @@ function startJob(manager, opts) {
|
|
|
13549
13654
|
ownerSession: opts.ownerSession,
|
|
13550
13655
|
logPath,
|
|
13551
13656
|
notify: opts.notify ?? true,
|
|
13552
|
-
killTree: () =>
|
|
13657
|
+
killTree: () => {
|
|
13658
|
+
killTree(child);
|
|
13659
|
+
if (opts.survive)
|
|
13660
|
+
opts.registry?.remove(id);
|
|
13661
|
+
},
|
|
13662
|
+
...opts.survive ? { survive: true } : {}
|
|
13553
13663
|
});
|
|
13664
|
+
let wfd;
|
|
13554
13665
|
try {
|
|
13666
|
+
wfd = openSync(logPath, "a");
|
|
13555
13667
|
child = shellSpawn(opts.spawnFn ?? spawn3, opts.cmd, {
|
|
13556
13668
|
cwd: opts.cwd,
|
|
13557
|
-
env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id }
|
|
13669
|
+
env: { ...opts.env ?? {}, [JOB_ENV_MARKER]: id },
|
|
13670
|
+
stdio: ["ignore", wfd, wfd]
|
|
13558
13671
|
});
|
|
13559
13672
|
} catch (err) {
|
|
13673
|
+
if (wfd !== undefined) {
|
|
13674
|
+
try {
|
|
13675
|
+
closeSync(wfd);
|
|
13676
|
+
} catch {}
|
|
13677
|
+
}
|
|
13560
13678
|
manager.markTerminal(job, "killed", null);
|
|
13561
13679
|
maxWaitTimer && clearTimeout(maxWaitTimer);
|
|
13562
13680
|
resolveSettle({ status: "exited", exitCode: null, outputTail: "", spawnError: String(err) });
|
|
13563
13681
|
return { job, settle };
|
|
13564
13682
|
}
|
|
13565
|
-
|
|
13566
|
-
const text = String(d);
|
|
13567
|
-
manager.appendOutput(job, text);
|
|
13683
|
+
if (wfd !== undefined) {
|
|
13568
13684
|
try {
|
|
13569
|
-
|
|
13685
|
+
closeSync(wfd);
|
|
13570
13686
|
} catch {}
|
|
13687
|
+
}
|
|
13688
|
+
manager.setPid(job, child.pid ?? 0);
|
|
13689
|
+
if (opts.survive) {
|
|
13690
|
+
opts.registry?.add({ id, pid: child.pid ?? 0, cmd: opts.cmd, logPath, startedAt: job.startedAt, ownerSession: opts.ownerSession, hostPid: process.pid });
|
|
13691
|
+
} else if (opts.fence) {
|
|
13692
|
+
opts.fence.assign(child.pid ?? 0);
|
|
13693
|
+
let reinforced = false;
|
|
13694
|
+
const reinforce = () => {
|
|
13695
|
+
if (reinforced)
|
|
13696
|
+
return;
|
|
13697
|
+
(async () => {
|
|
13698
|
+
const kid = await opts.relocateAsync?.(child.pid ?? 0) ?? opts.relocate?.(child.pid ?? 0) ?? null;
|
|
13699
|
+
if (kid !== null && kid > 0) {
|
|
13700
|
+
reinforced = true;
|
|
13701
|
+
opts.fence?.assign(kid);
|
|
13702
|
+
manager.setPid(job, kid);
|
|
13703
|
+
}
|
|
13704
|
+
})();
|
|
13705
|
+
};
|
|
13706
|
+
for (const delay of [50, 400, 1500]) {
|
|
13707
|
+
const t = setTimeout(reinforce, delay);
|
|
13708
|
+
t.unref?.();
|
|
13709
|
+
}
|
|
13710
|
+
firstOutputHooks.push(reinforce);
|
|
13711
|
+
}
|
|
13712
|
+
const onChunk = (text) => {
|
|
13713
|
+
if (firstOutputHooks.length > 0) {
|
|
13714
|
+
for (const hook of firstOutputHooks.splice(0))
|
|
13715
|
+
hook();
|
|
13716
|
+
}
|
|
13717
|
+
manager.appendOutput(job, text);
|
|
13571
13718
|
if (opts.successPattern && !settled && job.succeededAt === null) {
|
|
13572
13719
|
const m = opts.successPattern.exec(text) ?? opts.successPattern.exec(job.tail);
|
|
13573
13720
|
if (m) {
|
|
@@ -13577,14 +13724,16 @@ function startJob(manager, opts) {
|
|
|
13577
13724
|
const keptAlive = opts.keepAlive !== false;
|
|
13578
13725
|
if (!keptAlive) {
|
|
13579
13726
|
killTree(child);
|
|
13727
|
+
if (opts.survive)
|
|
13728
|
+
opts.registry?.remove(id);
|
|
13580
13729
|
manager.markTerminal(job, "succeeded", null);
|
|
13581
13730
|
}
|
|
13582
13731
|
resolveSettle({ status: "succeeded", matched: m[0], outputTail: job.tail, keptAlive });
|
|
13583
13732
|
}
|
|
13584
13733
|
}
|
|
13585
13734
|
};
|
|
13586
|
-
|
|
13587
|
-
|
|
13735
|
+
const tail = createFileTail(logPath, onChunk);
|
|
13736
|
+
liveTails.set(id, tail);
|
|
13588
13737
|
child.on("exit", (code) => {
|
|
13589
13738
|
if (exiting)
|
|
13590
13739
|
return;
|
|
@@ -13595,7 +13744,12 @@ function startJob(manager, opts) {
|
|
|
13595
13744
|
if (finished)
|
|
13596
13745
|
return;
|
|
13597
13746
|
finished = true;
|
|
13747
|
+
tail.flush();
|
|
13748
|
+
tail.stop();
|
|
13749
|
+
liveTails.delete(id);
|
|
13598
13750
|
manager.markTerminal(job, "exited", code);
|
|
13751
|
+
if (opts.survive)
|
|
13752
|
+
opts.registry?.remove(id);
|
|
13599
13753
|
rotateLogs(opts.logDir);
|
|
13600
13754
|
if (!settled) {
|
|
13601
13755
|
settled = true;
|
|
@@ -13604,22 +13758,12 @@ function startJob(manager, opts) {
|
|
|
13604
13758
|
};
|
|
13605
13759
|
const graceTimer = setTimeout(finish, opts.exitGraceMs ?? 500);
|
|
13606
13760
|
graceTimer.unref?.();
|
|
13607
|
-
const streams = [];
|
|
13608
|
-
if (child.stdout)
|
|
13609
|
-
streams.push(child.stdout);
|
|
13610
|
-
if (child.stderr)
|
|
13611
|
-
streams.push(child.stderr);
|
|
13612
|
-
let left = streams.length;
|
|
13613
|
-
if (left === 0)
|
|
13614
|
-
finish();
|
|
13615
|
-
else
|
|
13616
|
-
for (const s of streams)
|
|
13617
|
-
s.once("close", () => {
|
|
13618
|
-
if (--left === 0)
|
|
13619
|
-
finish();
|
|
13620
|
-
});
|
|
13621
13761
|
});
|
|
13622
13762
|
child.on("error", (err) => {
|
|
13763
|
+
tail.stop();
|
|
13764
|
+
liveTails.delete(id);
|
|
13765
|
+
if (opts.survive)
|
|
13766
|
+
opts.registry?.remove(id);
|
|
13623
13767
|
if (!settled) {
|
|
13624
13768
|
settled = true;
|
|
13625
13769
|
stopTimers();
|
|
@@ -13641,14 +13785,81 @@ async function pollJob(manager, jobId, waitMs = 0) {
|
|
|
13641
13785
|
const deadline = Date.now() + Math.min(Math.max(0, waitMs), POLL_WAIT_MAX_MS);
|
|
13642
13786
|
while (job.outLen === job.pollCursor && job.state === "running" && Date.now() < deadline) {
|
|
13643
13787
|
await new Promise((r) => setTimeout(r, 25));
|
|
13788
|
+
flushJobOutput(job);
|
|
13644
13789
|
}
|
|
13790
|
+
flushJobOutput(job);
|
|
13645
13791
|
return manager.poll(job);
|
|
13646
13792
|
}
|
|
13793
|
+
function adoptSurvivor(manager, entry, opts) {
|
|
13794
|
+
const kill = () => {
|
|
13795
|
+
let pid = entry.pid;
|
|
13796
|
+
for (let hop = 0;hop < 3 && !pidAlive(pid); hop++) {
|
|
13797
|
+
const kid = opts.relocate?.(pid) ?? null;
|
|
13798
|
+
if (kid === null || kid <= 0)
|
|
13799
|
+
break;
|
|
13800
|
+
pid = kid;
|
|
13801
|
+
}
|
|
13802
|
+
killTree({ pid, kill: (sig) => process.kill(pid, sig) });
|
|
13803
|
+
opts.registry.remove(entry.id);
|
|
13804
|
+
};
|
|
13805
|
+
const job = manager.create({
|
|
13806
|
+
id: entry.id,
|
|
13807
|
+
cmd: entry.cmd,
|
|
13808
|
+
worktree: opts.logDir,
|
|
13809
|
+
ownerSession: entry.ownerSession,
|
|
13810
|
+
logPath: entry.logPath,
|
|
13811
|
+
notify: false,
|
|
13812
|
+
killTree: kill,
|
|
13813
|
+
survive: true,
|
|
13814
|
+
previousRun: true
|
|
13815
|
+
});
|
|
13816
|
+
manager.setPid(job, entry.pid);
|
|
13817
|
+
const tail = createFileTail(entry.logPath, (t) => manager.appendOutput(job, t));
|
|
13818
|
+
liveTails.set(job.id, tail);
|
|
13819
|
+
const watcher = setInterval(() => {
|
|
13820
|
+
if (!pidAlive(entry.pid)) {
|
|
13821
|
+
clearInterval(watcher);
|
|
13822
|
+
tail.flush();
|
|
13823
|
+
tail.stop();
|
|
13824
|
+
liveTails.delete(job.id);
|
|
13825
|
+
opts.registry.remove(entry.id);
|
|
13826
|
+
manager.markTerminal(job, "exited", null);
|
|
13827
|
+
rotateLogs(opts.logDir);
|
|
13828
|
+
}
|
|
13829
|
+
}, ADOPT_LIVENESS_MS);
|
|
13830
|
+
watcher.unref?.();
|
|
13831
|
+
return job;
|
|
13832
|
+
}
|
|
13833
|
+
var LOG_READ_MAX_BYTES = 8 * 1024 * 1024;
|
|
13647
13834
|
function readJobLog(logPath, opts = {}) {
|
|
13648
13835
|
const limit = Math.max(1, opts.limit ?? 200);
|
|
13649
13836
|
let raw = "";
|
|
13837
|
+
let windowed = false;
|
|
13650
13838
|
try {
|
|
13651
|
-
|
|
13839
|
+
if (existsSync(logPath)) {
|
|
13840
|
+
const size = statSync(logPath).size;
|
|
13841
|
+
if (size > LOG_READ_MAX_BYTES) {
|
|
13842
|
+
const fd = openSync(logPath, "r");
|
|
13843
|
+
try {
|
|
13844
|
+
const buf = Buffer.allocUnsafe(LOG_READ_MAX_BYTES);
|
|
13845
|
+
let read = 0;
|
|
13846
|
+
while (read < LOG_READ_MAX_BYTES) {
|
|
13847
|
+
const n = readSync(fd, buf, read, LOG_READ_MAX_BYTES - read, size - LOG_READ_MAX_BYTES + read);
|
|
13848
|
+
if (n <= 0)
|
|
13849
|
+
break;
|
|
13850
|
+
read += n;
|
|
13851
|
+
}
|
|
13852
|
+
const text = buf.toString("utf8", 0, read);
|
|
13853
|
+
raw = text.slice(text.indexOf(`
|
|
13854
|
+
`) + 1);
|
|
13855
|
+
windowed = true;
|
|
13856
|
+
} finally {
|
|
13857
|
+
closeSync(fd);
|
|
13858
|
+
}
|
|
13859
|
+
} else {
|
|
13860
|
+
raw = readFileSync2(logPath, "utf8");
|
|
13861
|
+
}
|
|
13862
|
+
}
|
|
13652
13863
|
} catch {
|
|
13653
13864
|
raw = "";
|
|
13654
13865
|
}
|
|
@@ -13657,7 +13868,7 @@ function readJobLog(logPath, opts = {}) {
|
|
|
13657
13868
|
lines.pop();
|
|
13658
13869
|
const total = lines.length;
|
|
13659
13870
|
const offset = opts.offset !== undefined ? Math.max(0, Math.min(opts.offset, total)) : Math.max(0, total - limit);
|
|
13660
|
-
return { lines: lines.slice(offset, offset + limit), total, offset };
|
|
13871
|
+
return { lines: lines.slice(offset, offset + limit), total, offset, ...windowed ? { windowed: true } : {} };
|
|
13661
13872
|
}
|
|
13662
13873
|
function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
|
|
13663
13874
|
let entries = [];
|
|
@@ -13683,9 +13894,467 @@ function rotateLogs(logDir, keep = DEFAULT_LOG_KEEP) {
|
|
|
13683
13894
|
}
|
|
13684
13895
|
}
|
|
13685
13896
|
|
|
13897
|
+
// src/job-fence.ts
|
|
13898
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
13899
|
+
var FENCE_PS_SCRIPT = String.raw`
|
|
13900
|
+
Add-Type -TypeDefinition @"
|
|
13901
|
+
using System;
|
|
13902
|
+
using System.Collections.Generic;
|
|
13903
|
+
using System.IO;
|
|
13904
|
+
using System.Runtime.InteropServices;
|
|
13905
|
+
using System.Threading;
|
|
13906
|
+
public static class ForgeJobFence {
|
|
13907
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
13908
|
+
public struct BASIC_LIMITS {
|
|
13909
|
+
public long PerProcessUserTimeLimit;
|
|
13910
|
+
public long PerJobUserTimeLimit;
|
|
13911
|
+
public uint LimitFlags;
|
|
13912
|
+
public UIntPtr MinimumWorkingSetSize;
|
|
13913
|
+
public UIntPtr MaximumWorkingSetSize;
|
|
13914
|
+
public uint ActiveProcessLimit;
|
|
13915
|
+
public UIntPtr Affinity;
|
|
13916
|
+
public uint PriorityClass;
|
|
13917
|
+
public uint SchedulingClass;
|
|
13918
|
+
}
|
|
13919
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
13920
|
+
public struct IO_COUNTERS {
|
|
13921
|
+
public ulong ReadOperationCount;
|
|
13922
|
+
public ulong WriteOperationCount;
|
|
13923
|
+
public ulong OtherOperationCount;
|
|
13924
|
+
public ulong ReadTransferCount;
|
|
13925
|
+
public ulong WriteTransferCount;
|
|
13926
|
+
public ulong OtherTransferCount;
|
|
13927
|
+
}
|
|
13928
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
13929
|
+
public struct EXTENDED_LIMITS {
|
|
13930
|
+
public BASIC_LIMITS Basic;
|
|
13931
|
+
public IO_COUNTERS IoInfo;
|
|
13932
|
+
public UIntPtr ProcessMemoryLimit;
|
|
13933
|
+
public UIntPtr JobMemoryLimit;
|
|
13934
|
+
public UIntPtr PeakProcessMemoryUsed;
|
|
13935
|
+
public UIntPtr PeakJobMemoryUsed;
|
|
13936
|
+
}
|
|
13937
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
13938
|
+
public struct PE32 {
|
|
13939
|
+
public uint dwSize;
|
|
13940
|
+
public uint cntUsage;
|
|
13941
|
+
public uint th32ProcessID;
|
|
13942
|
+
public IntPtr th32DefaultHeapID;
|
|
13943
|
+
public uint th32ModuleID;
|
|
13944
|
+
public uint cntThreads;
|
|
13945
|
+
public uint th32ParentProcessID;
|
|
13946
|
+
public int pcPriClassBase;
|
|
13947
|
+
public uint dwFlags;
|
|
13948
|
+
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
|
|
13949
|
+
public string szExeFile;
|
|
13950
|
+
}
|
|
13951
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
13952
|
+
static extern IntPtr CreateJobObject(IntPtr a, string n);
|
|
13953
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
13954
|
+
static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, int cbInfo);
|
|
13955
|
+
[DllImport("kernel32.dll", SetLastError = true)]
|
|
13956
|
+
static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
|
13957
|
+
[DllImport("kernel32.dll")]
|
|
13958
|
+
static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
13959
|
+
[DllImport("kernel32.dll")]
|
|
13960
|
+
static extern bool CloseHandle(IntPtr h);
|
|
13961
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
13962
|
+
static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint pid);
|
|
13963
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
13964
|
+
static extern bool Process32FirstW(IntPtr h, ref PE32 e);
|
|
13965
|
+
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
|
13966
|
+
static extern bool Process32NextW(IntPtr h, ref PE32 e);
|
|
13967
|
+
|
|
13968
|
+
const int EXTENDED_LIMITS_CLASS = 9;
|
|
13969
|
+
const uint KILL_ON_JOB_CLOSE = 0x2000;
|
|
13970
|
+
const uint PROCESS_ALL_ACCESS = 0x1FFFFF;
|
|
13971
|
+
const uint SNAP_PROCESS = 0x2;
|
|
13972
|
+
// Descendant walk bound: deep enough for wrapper -> command -> children,
|
|
13973
|
+
// shallow enough that a recycled ancestor pid cannot pull unrelated
|
|
13974
|
+
// processes into the kill-on-close job.
|
|
13975
|
+
const int MAX_DEPTH = 4;
|
|
13976
|
+
|
|
13977
|
+
static IntPtr _job = IntPtr.Zero;
|
|
13978
|
+
static readonly HashSet<uint> _roots = new HashSet<uint>();
|
|
13979
|
+
static readonly HashSet<uint> _fenced = new HashSet<uint>();
|
|
13980
|
+
static readonly object _gate = new object();
|
|
13981
|
+
static Timer _sweep;
|
|
13982
|
+
|
|
13983
|
+
static void Sweep(object state) {
|
|
13984
|
+
try {
|
|
13985
|
+
var parents = new Dictionary<uint, uint>();
|
|
13986
|
+
var h = CreateToolhelp32Snapshot(SNAP_PROCESS, 0);
|
|
13987
|
+
if (h != IntPtr.Zero && h != new IntPtr(-1)) {
|
|
13988
|
+
var e = new PE32();
|
|
13989
|
+
e.dwSize = (uint)Marshal.SizeOf(typeof(PE32));
|
|
13990
|
+
if (Process32FirstW(h, ref e)) {
|
|
13991
|
+
do { parents[e.th32ProcessID] = e.th32ParentProcessID; } while (Process32NextW(h, ref e));
|
|
13992
|
+
}
|
|
13993
|
+
CloseHandle(h);
|
|
13994
|
+
}
|
|
13995
|
+
lock (_gate) {
|
|
13996
|
+
if (_job == IntPtr.Zero) return;
|
|
13997
|
+
foreach (var kv in parents) {
|
|
13998
|
+
if (_fenced.Contains(kv.Key)) continue;
|
|
13999
|
+
uint p = kv.Key;
|
|
14000
|
+
int depth = 0;
|
|
14001
|
+
bool tracked = false;
|
|
14002
|
+
while (p != 0 && depth <= MAX_DEPTH) {
|
|
14003
|
+
if (_roots.Contains(p)) { tracked = true; break; }
|
|
14004
|
+
uint up;
|
|
14005
|
+
if (parents.TryGetValue(p, out up)) { p = up; } else { p = 0; }
|
|
14006
|
+
depth++;
|
|
14007
|
+
}
|
|
14008
|
+
if (!tracked) continue;
|
|
14009
|
+
var ph = OpenProcess(PROCESS_ALL_ACCESS, false, (int)kv.Key);
|
|
14010
|
+
if (ph != IntPtr.Zero) {
|
|
14011
|
+
if (AssignProcessToJobObject(_job, ph)) _fenced.Add(kv.Key);
|
|
14012
|
+
CloseHandle(ph);
|
|
14013
|
+
}
|
|
14014
|
+
}
|
|
14015
|
+
}
|
|
14016
|
+
} catch { }
|
|
14017
|
+
}
|
|
14018
|
+
|
|
14019
|
+
public static int Run() {
|
|
14020
|
+
_job = CreateJobObject(IntPtr.Zero, null);
|
|
14021
|
+
if (_job == IntPtr.Zero) return 2;
|
|
14022
|
+
var info = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(EXTENDED_LIMITS)));
|
|
14023
|
+
try {
|
|
14024
|
+
Marshal.WriteInt32(info, 16, (int)KILL_ON_JOB_CLOSE); // Basic.LimitFlags offset
|
|
14025
|
+
if (!SetInformationJobObject(_job, EXTENDED_LIMITS_CLASS, info, Marshal.SizeOf(typeof(EXTENDED_LIMITS)))) return 3;
|
|
14026
|
+
} finally {
|
|
14027
|
+
Marshal.FreeHGlobal(info);
|
|
14028
|
+
}
|
|
14029
|
+
_sweep = new Timer(Sweep, null, 0, 400);
|
|
14030
|
+
string line;
|
|
14031
|
+
while ((line = Console.In.ReadLine()) != null) {
|
|
14032
|
+
uint pid;
|
|
14033
|
+
var t = line.Trim();
|
|
14034
|
+
if (t.Length > 0 && uint.TryParse(t, out pid) && pid > 0) {
|
|
14035
|
+
lock (_gate) { _roots.Add(pid); }
|
|
14036
|
+
var ph = OpenProcess(PROCESS_ALL_ACCESS, false, (int)pid);
|
|
14037
|
+
if (ph != IntPtr.Zero) {
|
|
14038
|
+
if (AssignProcessToJobObject(_job, ph)) {
|
|
14039
|
+
lock (_gate) { _fenced.Add(pid); }
|
|
14040
|
+
}
|
|
14041
|
+
CloseHandle(ph);
|
|
14042
|
+
}
|
|
14043
|
+
}
|
|
14044
|
+
}
|
|
14045
|
+
return 0; // stdin EOF: the host is gone -> exit -> handle closes -> kernel kills
|
|
14046
|
+
}
|
|
14047
|
+
}
|
|
14048
|
+
"@
|
|
14049
|
+
[ForgeJobFence]::Run()
|
|
14050
|
+
exit $LASTEXITCODE
|
|
14051
|
+
`;
|
|
14052
|
+
function createJobFence(opts = {}) {
|
|
14053
|
+
const platform = opts.platform ?? process.platform;
|
|
14054
|
+
if (platform !== "win32")
|
|
14055
|
+
return null;
|
|
14056
|
+
const spawnFn = opts.spawnFn ?? spawn4;
|
|
14057
|
+
let child;
|
|
14058
|
+
try {
|
|
14059
|
+
child = spawnFn("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", FENCE_PS_SCRIPT], {
|
|
14060
|
+
windowsHide: true,
|
|
14061
|
+
stdio: ["pipe", "ignore", "ignore"]
|
|
14062
|
+
});
|
|
14063
|
+
} catch (err) {
|
|
14064
|
+
opts.onDegrade?.(`job fence not started: spawn failed (${String(err).slice(0, 120)})`);
|
|
14065
|
+
return null;
|
|
14066
|
+
}
|
|
14067
|
+
const stdin = child.stdin;
|
|
14068
|
+
if (!stdin) {
|
|
14069
|
+
opts.onDegrade?.("job fence not started: watcher has no stdin");
|
|
14070
|
+
return null;
|
|
14071
|
+
}
|
|
14072
|
+
let healthy = true;
|
|
14073
|
+
child.on("exit", () => {
|
|
14074
|
+
healthy = false;
|
|
14075
|
+
});
|
|
14076
|
+
child.on("error", () => {
|
|
14077
|
+
healthy = false;
|
|
14078
|
+
});
|
|
14079
|
+
let reported = false;
|
|
14080
|
+
const fail = (why) => {
|
|
14081
|
+
healthy = false;
|
|
14082
|
+
if (reported)
|
|
14083
|
+
return;
|
|
14084
|
+
reported = true;
|
|
14085
|
+
opts.onDegrade?.(why);
|
|
14086
|
+
};
|
|
14087
|
+
return {
|
|
14088
|
+
assign(pid) {
|
|
14089
|
+
if (!healthy || !stdin.writable) {
|
|
14090
|
+
fail("job fence watcher gone; job relies on the JS exit matrix only");
|
|
14091
|
+
return;
|
|
14092
|
+
}
|
|
14093
|
+
try {
|
|
14094
|
+
stdin.write(`${pid}
|
|
14095
|
+
`);
|
|
14096
|
+
} catch (err) {
|
|
14097
|
+
fail(`job fence write failed (${String(err).slice(0, 120)})`);
|
|
14098
|
+
}
|
|
14099
|
+
},
|
|
14100
|
+
dispose() {
|
|
14101
|
+
try {
|
|
14102
|
+
stdin.end();
|
|
14103
|
+
} catch {}
|
|
14104
|
+
},
|
|
14105
|
+
get healthy() {
|
|
14106
|
+
return healthy;
|
|
14107
|
+
}
|
|
14108
|
+
};
|
|
14109
|
+
}
|
|
14110
|
+
|
|
14111
|
+
// src/job-registry.ts
|
|
14112
|
+
import { closeSync as closeSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
14113
|
+
import { execFileSync, spawn as spawn5 } from "node:child_process";
|
|
14114
|
+
import { dirname, join as join3 } from "node:path";
|
|
14115
|
+
var REGISTRY_KEEP = 100;
|
|
14116
|
+
function structuralRelocate(deadPid, platform = process.platform) {
|
|
14117
|
+
if (deadPid <= 0)
|
|
14118
|
+
return null;
|
|
14119
|
+
if (platform === "win32") {
|
|
14120
|
+
for (let attempt = 0;attempt < 2; attempt++) {
|
|
14121
|
+
if (attempt > 0)
|
|
14122
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300);
|
|
14123
|
+
try {
|
|
14124
|
+
const raw = execFileSync("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${deadPid} -and $_.Name -ne 'conhost.exe' } | Select-Object -First 1 -ExpandProperty ProcessId`], { encoding: "utf8", windowsHide: true, timeout: 1e4 });
|
|
14125
|
+
const pid = Number(String(raw).trim());
|
|
14126
|
+
if (Number.isInteger(pid) && pid > 0)
|
|
14127
|
+
return pid;
|
|
14128
|
+
} catch {}
|
|
14129
|
+
}
|
|
14130
|
+
return null;
|
|
14131
|
+
}
|
|
14132
|
+
try {
|
|
14133
|
+
for (const ent of readdirSync2("/proc")) {
|
|
14134
|
+
if (!/^\d+$/.test(ent))
|
|
14135
|
+
continue;
|
|
14136
|
+
try {
|
|
14137
|
+
const m = /^(\d+) \((.*)\) (\w) (\d+)/.exec(readFileSync3(`/proc/${ent}/stat`, "utf8"));
|
|
14138
|
+
if (m && Number(m[4]) === deadPid)
|
|
14139
|
+
return Number(m[1]);
|
|
14140
|
+
} catch {}
|
|
14141
|
+
}
|
|
14142
|
+
} catch {
|
|
14143
|
+
return null;
|
|
14144
|
+
}
|
|
14145
|
+
return null;
|
|
14146
|
+
}
|
|
14147
|
+
function structuralRelocateAsync(deadPid, platform = process.platform) {
|
|
14148
|
+
if (deadPid <= 0)
|
|
14149
|
+
return Promise.resolve(null);
|
|
14150
|
+
if (platform !== "win32")
|
|
14151
|
+
return Promise.resolve(structuralRelocate(deadPid, platform));
|
|
14152
|
+
return new Promise((resolve2) => {
|
|
14153
|
+
let out = "";
|
|
14154
|
+
let settled = false;
|
|
14155
|
+
const done = (pid) => {
|
|
14156
|
+
if (settled)
|
|
14157
|
+
return;
|
|
14158
|
+
settled = true;
|
|
14159
|
+
clearTimeout(timer);
|
|
14160
|
+
const n = Number(String(out).trim());
|
|
14161
|
+
resolve2(Number.isInteger(n) && n > 0 ? n : pid);
|
|
14162
|
+
};
|
|
14163
|
+
const child = spawn5("powershell", ["-NoProfile", "-Command", `Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${deadPid} -and $_.Name -ne 'conhost.exe' } | Select-Object -First 1 -ExpandProperty ProcessId`], { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
|
|
14164
|
+
const timer = setTimeout(() => {
|
|
14165
|
+
try {
|
|
14166
|
+
child.kill();
|
|
14167
|
+
} catch {}
|
|
14168
|
+
done(null);
|
|
14169
|
+
}, 1e4);
|
|
14170
|
+
child.stdout?.on("data", (d) => {
|
|
14171
|
+
out += d;
|
|
14172
|
+
});
|
|
14173
|
+
child.on("error", () => done(null));
|
|
14174
|
+
child.on("exit", () => done(null));
|
|
14175
|
+
});
|
|
14176
|
+
}
|
|
14177
|
+
function acquireLock(lockPath, timeoutMs = 2000) {
|
|
14178
|
+
const deadline = Date.now() + timeoutMs;
|
|
14179
|
+
for (;; ) {
|
|
14180
|
+
try {
|
|
14181
|
+
const fd = openSync2(lockPath, "wx");
|
|
14182
|
+
return {
|
|
14183
|
+
release: () => {
|
|
14184
|
+
try {
|
|
14185
|
+
closeSync2(fd);
|
|
14186
|
+
unlinkSync2(lockPath);
|
|
14187
|
+
} catch {}
|
|
14188
|
+
}
|
|
14189
|
+
};
|
|
14190
|
+
} catch (err) {
|
|
14191
|
+
if (err.code !== "EEXIST")
|
|
14192
|
+
throw err;
|
|
14193
|
+
try {
|
|
14194
|
+
if (Date.now() - statSync2(lockPath).mtimeMs > 5000) {
|
|
14195
|
+
unlinkSync2(lockPath);
|
|
14196
|
+
continue;
|
|
14197
|
+
}
|
|
14198
|
+
} catch {}
|
|
14199
|
+
if (Date.now() >= deadline)
|
|
14200
|
+
return null;
|
|
14201
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
14202
|
+
}
|
|
14203
|
+
}
|
|
14204
|
+
}
|
|
14205
|
+
function readRaw(path) {
|
|
14206
|
+
try {
|
|
14207
|
+
const raw = existsSync2(path) ? readFileSync3(path, "utf8") : "";
|
|
14208
|
+
if (!raw.trim())
|
|
14209
|
+
return [];
|
|
14210
|
+
const parsed = JSON.parse(raw);
|
|
14211
|
+
if (!Array.isArray(parsed.entries))
|
|
14212
|
+
return [];
|
|
14213
|
+
return parsed.entries.filter((e) => typeof e?.id === "string" && Number.isFinite(e?.pid) && e.pid > 0);
|
|
14214
|
+
} catch {
|
|
14215
|
+
return [];
|
|
14216
|
+
}
|
|
14217
|
+
}
|
|
14218
|
+
function writeRaw(path, entries) {
|
|
14219
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
14220
|
+
writeFileSync2(path, `${JSON.stringify({ version: 1, entries: entries.slice(0, REGISTRY_KEEP) }, null, 2)}
|
|
14221
|
+
`);
|
|
14222
|
+
}
|
|
14223
|
+
function createJobRegistry(registryPath) {
|
|
14224
|
+
const lockPath = `${registryPath}.lock`;
|
|
14225
|
+
const mutate = (fn) => {
|
|
14226
|
+
const lock = acquireLock(lockPath);
|
|
14227
|
+
if (!lock)
|
|
14228
|
+
return false;
|
|
14229
|
+
try {
|
|
14230
|
+
writeRaw(registryPath, fn(readRaw(registryPath)));
|
|
14231
|
+
return true;
|
|
14232
|
+
} finally {
|
|
14233
|
+
lock.release();
|
|
14234
|
+
}
|
|
14235
|
+
};
|
|
14236
|
+
return {
|
|
14237
|
+
add(entry) {
|
|
14238
|
+
mutate((entries) => [entry, ...entries.filter((e) => e.id !== entry.id)].slice(0, REGISTRY_KEEP));
|
|
14239
|
+
},
|
|
14240
|
+
remove(id) {
|
|
14241
|
+
mutate((entries) => entries.filter((e) => e.id !== id));
|
|
14242
|
+
},
|
|
14243
|
+
list() {
|
|
14244
|
+
return readRaw(registryPath);
|
|
14245
|
+
},
|
|
14246
|
+
rescan(isAlive, relocate) {
|
|
14247
|
+
const entries = readRaw(registryPath);
|
|
14248
|
+
const adopted = [];
|
|
14249
|
+
const dead = [];
|
|
14250
|
+
for (const e of entries) {
|
|
14251
|
+
if (isAlive(e.pid)) {
|
|
14252
|
+
adopted.push(e);
|
|
14253
|
+
continue;
|
|
14254
|
+
}
|
|
14255
|
+
const relocated = relocate?.(e.pid) ?? null;
|
|
14256
|
+
if (relocated !== null && relocated !== e.pid && isAlive(relocated)) {
|
|
14257
|
+
adopted.push({ ...e, pid: relocated });
|
|
14258
|
+
} else {
|
|
14259
|
+
dead.push(e);
|
|
14260
|
+
}
|
|
14261
|
+
}
|
|
14262
|
+
const lock = acquireLock(lockPath);
|
|
14263
|
+
if (lock) {
|
|
14264
|
+
try {
|
|
14265
|
+
writeRaw(registryPath, adopted);
|
|
14266
|
+
} finally {
|
|
14267
|
+
lock.release();
|
|
14268
|
+
}
|
|
14269
|
+
}
|
|
14270
|
+
return { adopted, dead };
|
|
14271
|
+
}
|
|
14272
|
+
};
|
|
14273
|
+
}
|
|
14274
|
+
function registryPathFor(jobsDir) {
|
|
14275
|
+
return join3(jobsDir, "registry.json");
|
|
14276
|
+
}
|
|
14277
|
+
|
|
14278
|
+
// src/host-exit.ts
|
|
14279
|
+
function createExitCleanup(manager, opts = {}) {
|
|
14280
|
+
const graceMs = opts.graceMs ?? 3000;
|
|
14281
|
+
let sequenced = false;
|
|
14282
|
+
let forceIssued = false;
|
|
14283
|
+
let afterRan = false;
|
|
14284
|
+
const installed = [];
|
|
14285
|
+
const liveTargets = () => {
|
|
14286
|
+
const targets = [];
|
|
14287
|
+
for (const job of manager.list()) {
|
|
14288
|
+
if (job.state === "running" && !job.survive)
|
|
14289
|
+
targets.push(job);
|
|
14290
|
+
}
|
|
14291
|
+
return targets;
|
|
14292
|
+
};
|
|
14293
|
+
const killOne = (job, graceful) => {
|
|
14294
|
+
const pid = job.pid ?? 0;
|
|
14295
|
+
if (pid > 0) {
|
|
14296
|
+
terminateTreeSync(pid, {
|
|
14297
|
+
graceMs: graceful ? graceMs : 0,
|
|
14298
|
+
spawnSyncFn: opts.spawnSyncFn,
|
|
14299
|
+
platform: opts.platform,
|
|
14300
|
+
...opts.noWait ? { wait: false } : {}
|
|
14301
|
+
});
|
|
14302
|
+
}
|
|
14303
|
+
try {
|
|
14304
|
+
job.killTree();
|
|
14305
|
+
} catch {}
|
|
14306
|
+
manager.markTerminal(job, "killed", null);
|
|
14307
|
+
};
|
|
14308
|
+
const forcePass = () => {
|
|
14309
|
+
if (forceIssued)
|
|
14310
|
+
return;
|
|
14311
|
+
forceIssued = true;
|
|
14312
|
+
for (const job of liveTargets())
|
|
14313
|
+
killOne(job, false);
|
|
14314
|
+
};
|
|
14315
|
+
const trigger = (kind) => {
|
|
14316
|
+
if (kind === "dispose") {
|
|
14317
|
+
if (sequenced)
|
|
14318
|
+
return;
|
|
14319
|
+
sequenced = true;
|
|
14320
|
+
for (const job of liveTargets())
|
|
14321
|
+
killOne(job, true);
|
|
14322
|
+
forcePass();
|
|
14323
|
+
if (!afterRan) {
|
|
14324
|
+
afterRan = true;
|
|
14325
|
+
opts.after?.();
|
|
14326
|
+
}
|
|
14327
|
+
return;
|
|
14328
|
+
}
|
|
14329
|
+
forcePass();
|
|
14330
|
+
};
|
|
14331
|
+
const onSignal = (kind) => () => trigger(kind);
|
|
14332
|
+
const wired = [
|
|
14333
|
+
["SIGINT", onSignal("SIGINT")],
|
|
14334
|
+
["SIGTERM", onSignal("SIGTERM")],
|
|
14335
|
+
["exit", onSignal("exit")],
|
|
14336
|
+
["uncaughtException", onSignal("uncaughtException")],
|
|
14337
|
+
["unhandledRejection", onSignal("unhandledRejection")]
|
|
14338
|
+
];
|
|
14339
|
+
for (const [ev, fn] of wired) {
|
|
14340
|
+
process.on(ev, fn);
|
|
14341
|
+
installed.push([ev, fn]);
|
|
14342
|
+
}
|
|
14343
|
+
return {
|
|
14344
|
+
trigger,
|
|
14345
|
+
uninstall() {
|
|
14346
|
+
for (const [ev, fn] of installed) {
|
|
14347
|
+
try {
|
|
14348
|
+
process.removeListener(ev, fn);
|
|
14349
|
+
} catch {}
|
|
14350
|
+
}
|
|
14351
|
+
}
|
|
14352
|
+
};
|
|
14353
|
+
}
|
|
14354
|
+
|
|
13686
14355
|
// src/watchdog.ts
|
|
13687
|
-
import { mkdirSync as
|
|
13688
|
-
import { dirname } from "node:path";
|
|
14356
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
14357
|
+
import { dirname as dirname2 } from "node:path";
|
|
13689
14358
|
var WATCHDOG_ENV_MARK = "FORGE_WATCHDOG_MARK";
|
|
13690
14359
|
var DEFAULT_STALL_MS = 600000;
|
|
13691
14360
|
var MIN_STALL_MS = 60000;
|
|
@@ -13903,10 +14572,10 @@ function watchdogLogDir(base) {
|
|
|
13903
14572
|
function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
|
|
13904
14573
|
const append = (entry) => {
|
|
13905
14574
|
try {
|
|
13906
|
-
|
|
14575
|
+
mkdirSync3(dirname2(logPath), { recursive: true });
|
|
13907
14576
|
let lines = [];
|
|
13908
14577
|
try {
|
|
13909
|
-
lines =
|
|
14578
|
+
lines = readFileSync4(logPath, "utf8").split(`
|
|
13910
14579
|
`).filter((l) => l.trim().length > 0);
|
|
13911
14580
|
} catch {}
|
|
13912
14581
|
if (lines.length >= maxEntries)
|
|
@@ -13921,12 +14590,12 @@ function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
|
|
|
13921
14590
|
`) + `
|
|
13922
14591
|
`;
|
|
13923
14592
|
}
|
|
13924
|
-
|
|
14593
|
+
writeFileSync3(logPath, text, "utf8");
|
|
13925
14594
|
} catch {}
|
|
13926
14595
|
};
|
|
13927
14596
|
const entries = () => {
|
|
13928
14597
|
try {
|
|
13929
|
-
return
|
|
14598
|
+
return readFileSync4(logPath, "utf8").split(`
|
|
13930
14599
|
`).filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
13931
14600
|
} catch {
|
|
13932
14601
|
return [];
|
|
@@ -13936,8 +14605,8 @@ function createFileLedger(logPath, maxEntries = 200, maxBytes = 1e6) {
|
|
|
13936
14605
|
}
|
|
13937
14606
|
|
|
13938
14607
|
// src/proc-locate.ts
|
|
13939
|
-
import { execFileSync } from "node:child_process";
|
|
13940
|
-
import { readdirSync as
|
|
14608
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
14609
|
+
import { readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
13941
14610
|
function commandNeedle(command) {
|
|
13942
14611
|
const trimmed = command.trim().replace(/^["']|["']$/g, "");
|
|
13943
14612
|
if (trimmed.length === 0)
|
|
@@ -13950,17 +14619,17 @@ function markerValue(callID) {
|
|
|
13950
14619
|
return `opencode-forge:${callID}`;
|
|
13951
14620
|
}
|
|
13952
14621
|
function createPosixLocator(deps = {}) {
|
|
13953
|
-
const listPids = deps.listPids ?? (() =>
|
|
14622
|
+
const listPids = deps.listPids ?? (() => readdirSync3("/proc").map(Number).filter((n) => Number.isInteger(n) && n > 0));
|
|
13954
14623
|
const readEnv = deps.readEnv ?? ((pid) => {
|
|
13955
14624
|
try {
|
|
13956
|
-
return
|
|
14625
|
+
return readFileSync5(`/proc/${pid}/environ`);
|
|
13957
14626
|
} catch {
|
|
13958
14627
|
return null;
|
|
13959
14628
|
}
|
|
13960
14629
|
});
|
|
13961
14630
|
const readCmd = deps.readCmd ?? ((pid) => {
|
|
13962
14631
|
try {
|
|
13963
|
-
return
|
|
14632
|
+
return readFileSync5(`/proc/${pid}/cmdline`).toString("utf8").split("\x00").filter(Boolean).join(" ");
|
|
13964
14633
|
} catch {
|
|
13965
14634
|
return String(pid);
|
|
13966
14635
|
}
|
|
@@ -14017,7 +14686,7 @@ function parseWindowsProcs(raw) {
|
|
|
14017
14686
|
var WINDOW_SLACK_MS = 2000;
|
|
14018
14687
|
function createWindowsLocator(deps = {}) {
|
|
14019
14688
|
const hostPid = deps.hostPid ?? process.pid;
|
|
14020
|
-
const execFn = deps.execFn ?? ((cmd) =>
|
|
14689
|
+
const execFn = deps.execFn ?? ((cmd) => execFileSync2("powershell", ["-NoProfile", "-Command", cmd], { encoding: "utf8", windowsHide: true, timeout: 15000 }));
|
|
14021
14690
|
return async (callID, t0, cmdNeedle, phase2 = false) => {
|
|
14022
14691
|
let raw = "[]";
|
|
14023
14692
|
try {
|
|
@@ -14119,6 +14788,47 @@ var sessions = new Map;
|
|
|
14119
14788
|
var forgeDisabled = false;
|
|
14120
14789
|
var jobsMode = "auto";
|
|
14121
14790
|
var jobsKeepBuiltinShell = false;
|
|
14791
|
+
var jobsSurviveMode = "never";
|
|
14792
|
+
function effectiveSurvive(config2, param) {
|
|
14793
|
+
if (config2 === "deny") {
|
|
14794
|
+
if (param === true)
|
|
14795
|
+
throw new Error("[forge] jobs.survive is explicitly denied in config; a per-call survive=true cannot override it.");
|
|
14796
|
+
return false;
|
|
14797
|
+
}
|
|
14798
|
+
if (param !== undefined)
|
|
14799
|
+
return param;
|
|
14800
|
+
return config2 === "always";
|
|
14801
|
+
}
|
|
14802
|
+
var jobFence = null;
|
|
14803
|
+
var jobRegistry = null;
|
|
14804
|
+
var exitCleanup = null;
|
|
14805
|
+
var jobsLifecycleStarted = false;
|
|
14806
|
+
function ensureJobLifecycle() {
|
|
14807
|
+
if (jobsLifecycleStarted)
|
|
14808
|
+
return { registry: jobRegistry, fence: jobFence };
|
|
14809
|
+
jobsLifecycleStarted = true;
|
|
14810
|
+
const registry2 = createJobRegistry(registryPathFor(jobLogDir));
|
|
14811
|
+
jobRegistry = registry2;
|
|
14812
|
+
const { adopted, dead } = registry2.rescan(pidAlive, structuralRelocate);
|
|
14813
|
+
for (const entry of adopted) {
|
|
14814
|
+
adoptSurvivor(jobManager, entry, { registry: registry2, logDir: jobLogDir, relocate: structuralRelocate });
|
|
14815
|
+
}
|
|
14816
|
+
for (const entry of dead) {
|
|
14817
|
+
jobLedgerSink({ at: new Date().toISOString(), kind: "orphan-job", jobId: entry.id, session: entry.ownerSession, detail: `previous-run survivor pid ${entry.pid} is dead (cmd: ${entry.cmd.slice(0, 120)})` });
|
|
14818
|
+
}
|
|
14819
|
+
jobFence = process.env.FORGE_TEST_NO_FENCE === "1" ? null : createJobFence({
|
|
14820
|
+
onDegrade: (reason) => {
|
|
14821
|
+
jobLedgerSink({ at: new Date().toISOString(), kind: "fence-degraded", jobId: "-", session: "-", detail: reason });
|
|
14822
|
+
}
|
|
14823
|
+
});
|
|
14824
|
+
exitCleanup = createExitCleanup(jobManager, {
|
|
14825
|
+
graceMs: 3000,
|
|
14826
|
+
after: () => {
|
|
14827
|
+
jobFence?.dispose();
|
|
14828
|
+
}
|
|
14829
|
+
});
|
|
14830
|
+
return { registry: registry2, fence: jobFence };
|
|
14831
|
+
}
|
|
14122
14832
|
var nativeBackgroundSeen = false;
|
|
14123
14833
|
function jobStage() {
|
|
14124
14834
|
if (jobsMode === "native")
|
|
@@ -14128,13 +14838,13 @@ function jobStage() {
|
|
|
14128
14838
|
return nativeBackgroundSeen ? 1 : 0;
|
|
14129
14839
|
}
|
|
14130
14840
|
var jobLogDir = jobsLogDir();
|
|
14131
|
-
var jobLedgerPath =
|
|
14841
|
+
var jobLedgerPath = join4(jobLogDir, "ledger.jsonl");
|
|
14132
14842
|
function jobLedgerSink(entry) {
|
|
14133
14843
|
try {
|
|
14134
|
-
|
|
14135
|
-
if (
|
|
14136
|
-
|
|
14137
|
-
|
|
14844
|
+
mkdirSync4(jobLogDir, { recursive: true });
|
|
14845
|
+
if (existsSync3(jobLedgerPath) && statSync3(jobLedgerPath).size > 1e6)
|
|
14846
|
+
writeFileSync4(jobLedgerPath, "");
|
|
14847
|
+
appendFileSync(jobLedgerPath, `${JSON.stringify(entry)}
|
|
14138
14848
|
`);
|
|
14139
14849
|
} catch {}
|
|
14140
14850
|
}
|
|
@@ -14188,13 +14898,13 @@ function nowIso() {
|
|
|
14188
14898
|
return new Date().toISOString();
|
|
14189
14899
|
}
|
|
14190
14900
|
function planDirOf(worktree) {
|
|
14191
|
-
return
|
|
14901
|
+
return join4(worktree, ".opencode", "plan");
|
|
14192
14902
|
}
|
|
14193
14903
|
function readPlanDir(worktree) {
|
|
14194
14904
|
const dir = planDirOf(worktree);
|
|
14195
|
-
if (!
|
|
14905
|
+
if (!existsSync3(dir))
|
|
14196
14906
|
return [];
|
|
14197
|
-
return
|
|
14907
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
|
|
14198
14908
|
}
|
|
14199
14909
|
function ensureSession(sessionID, worktree) {
|
|
14200
14910
|
const existing = sessions.get(sessionID);
|
|
@@ -14210,8 +14920,8 @@ function worktreeFor(context) {
|
|
|
14210
14920
|
return effectiveWorktree(context.worktree, hostWorktree) || context.worktree;
|
|
14211
14921
|
}
|
|
14212
14922
|
function resolveActivePlan(state) {
|
|
14213
|
-
if (state.planPath &&
|
|
14214
|
-
const doc2 = parsePlanLoose(
|
|
14923
|
+
if (state.planPath && existsSync3(state.planPath)) {
|
|
14924
|
+
const doc2 = parsePlanLoose(readFileSync6(state.planPath, "utf8"));
|
|
14215
14925
|
if (doc2 && !isTerminal(doc2.status))
|
|
14216
14926
|
return { path: state.planPath, doc: doc2 };
|
|
14217
14927
|
state.planPath = undefined;
|
|
@@ -14219,7 +14929,7 @@ function resolveActivePlan(state) {
|
|
|
14219
14929
|
const ranked = rankActivePlans(readPlanDir(state.worktree));
|
|
14220
14930
|
if (ranked.length === 0)
|
|
14221
14931
|
return null;
|
|
14222
|
-
const path =
|
|
14932
|
+
const path = join4(planDirOf(state.worktree), ranked[0].name);
|
|
14223
14933
|
state.planPath = path;
|
|
14224
14934
|
return { path, doc: ranked[0].doc };
|
|
14225
14935
|
}
|
|
@@ -14253,12 +14963,12 @@ var planWriteTool = tool({
|
|
|
14253
14963
|
throw new PlanError(`A plan in ${active.doc.status} state is already active (${relFrom(state.worktree, active.path)}). Finish it with plan_close, or /plan discard it before planning something new.`);
|
|
14254
14964
|
} else {
|
|
14255
14965
|
const dir = planDirOf(state.worktree);
|
|
14256
|
-
|
|
14257
|
-
path =
|
|
14966
|
+
mkdirSync4(dir, { recursive: true });
|
|
14967
|
+
path = join4(dir, planFileName(localDate(), slugify(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md"))));
|
|
14258
14968
|
mode = "created";
|
|
14259
14969
|
}
|
|
14260
14970
|
const text = renderPlan(args, now, created);
|
|
14261
|
-
|
|
14971
|
+
writeFileSync4(path, text);
|
|
14262
14972
|
state.planPath = path;
|
|
14263
14973
|
const doc2 = parsePlan(text);
|
|
14264
14974
|
context.metadata({ title: `${mode === "created" ? "Create" : "Revise"} plan: ${doc2.goal}` });
|
|
@@ -14286,8 +14996,8 @@ var planTickTool = tool({
|
|
|
14286
14996
|
if (active.doc.status !== "approved") {
|
|
14287
14997
|
throw new PlanError(`Plan status is ${active.doc.status}; only an approved plan can be ticked. Get user approval via plan_approve first.`);
|
|
14288
14998
|
}
|
|
14289
|
-
const next = tickTask(
|
|
14290
|
-
|
|
14999
|
+
const next = tickTask(readFileSync6(active.path, "utf8"), args.n, nowIso());
|
|
15000
|
+
writeFileSync4(active.path, next);
|
|
14291
15001
|
const doc2 = parsePlan(next);
|
|
14292
15002
|
const p = progressOf(doc2);
|
|
14293
15003
|
context.metadata({ title: `Tick task ${args.n} (${p.done}/${p.total})` });
|
|
@@ -14314,7 +15024,7 @@ var planApproveTool = tool({
|
|
|
14314
15024
|
throw new PlanError(`Plan status is ${active.doc.status}; only a draft plan can be approved.`);
|
|
14315
15025
|
}
|
|
14316
15026
|
await gate(context.ask, "plan_approve", `Approve plan: ${active.doc.goal}`);
|
|
14317
|
-
|
|
15027
|
+
writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "approved", nowIso()));
|
|
14318
15028
|
context.metadata({ title: `Plan approved: ${active.doc.goal}` });
|
|
14319
15029
|
return {
|
|
14320
15030
|
title: "plan approved",
|
|
@@ -14347,7 +15057,7 @@ var planCloseTool = tool({
|
|
|
14347
15057
|
Fix the implementation and retry, or revise the plan first.`);
|
|
14348
15058
|
}
|
|
14349
15059
|
await gate(context.ask, "plan_close", `Close plan: ${active.doc.goal}`);
|
|
14350
|
-
|
|
15060
|
+
writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "done", nowIso()));
|
|
14351
15061
|
context.metadata({ title: `Plan done: ${active.doc.goal}` });
|
|
14352
15062
|
return {
|
|
14353
15063
|
title: "plan done",
|
|
@@ -14365,31 +15075,31 @@ var planDiscardTool = tool({
|
|
|
14365
15075
|
const active = resolveActivePlan(state);
|
|
14366
15076
|
if (!active)
|
|
14367
15077
|
throw new PlanError("No plan to abandon in this workspace.");
|
|
14368
|
-
|
|
15078
|
+
writeFileSync4(active.path, transitionStatus(readFileSync6(active.path, "utf8"), "abandoned", nowIso()));
|
|
14369
15079
|
state.planPath = undefined;
|
|
14370
15080
|
context.metadata({ title: `Plan abandoned: ${active.doc.goal}` });
|
|
14371
15081
|
return { title: "plan abandoned", output: `Plan abandoned: ${relFrom(state.worktree, active.path)}. Write operations are restored.` };
|
|
14372
15082
|
}
|
|
14373
15083
|
});
|
|
14374
15084
|
function goalDirOf(worktree) {
|
|
14375
|
-
return
|
|
15085
|
+
return join4(worktree, ".opencode", "goal");
|
|
14376
15086
|
}
|
|
14377
15087
|
function readGoalDir(worktree) {
|
|
14378
15088
|
const dir = goalDirOf(worktree);
|
|
14379
|
-
if (!
|
|
15089
|
+
if (!existsSync3(dir))
|
|
14380
15090
|
return [];
|
|
14381
|
-
return
|
|
15091
|
+
return readdirSync4(dir).filter((f) => f.endsWith(".md")).map((name) => ({ name, text: readFileSync6(join4(dir, name), "utf8") }));
|
|
14382
15092
|
}
|
|
14383
15093
|
function resolveSessionGoal(state) {
|
|
14384
|
-
if (state.goalPath &&
|
|
14385
|
-
const doc2 = parseGoalLoose(
|
|
15094
|
+
if (state.goalPath && existsSync3(state.goalPath)) {
|
|
15095
|
+
const doc2 = parseGoalLoose(readFileSync6(state.goalPath, "utf8"));
|
|
14386
15096
|
if (doc2 && !isGoalTerminal(doc2.status))
|
|
14387
15097
|
return { path: state.goalPath, doc: doc2 };
|
|
14388
15098
|
state.goalPath = undefined;
|
|
14389
15099
|
}
|
|
14390
15100
|
const live = rankLiveGoals(readGoalDir(state.worktree))[0];
|
|
14391
15101
|
if (live) {
|
|
14392
|
-
const path =
|
|
15102
|
+
const path = join4(goalDirOf(state.worktree), live.name);
|
|
14393
15103
|
state.goalPath = path;
|
|
14394
15104
|
return { path, doc: live.doc };
|
|
14395
15105
|
}
|
|
@@ -14486,9 +15196,9 @@ var goalWriteTool = tool({
|
|
|
14486
15196
|
await gate(context.ask, "goal_write", `Arm goal: ${args.goal}`);
|
|
14487
15197
|
}
|
|
14488
15198
|
const dir = goalDirOf(state.worktree);
|
|
14489
|
-
|
|
14490
|
-
const name = goalFileName(localDateNow(), slugifyGoal(args.goal),
|
|
14491
|
-
const path =
|
|
15199
|
+
mkdirSync4(dir, { recursive: true });
|
|
15200
|
+
const name = goalFileName(localDateNow(), slugifyGoal(args.goal), readdirSync4(dir).filter((f) => f.endsWith(".md")));
|
|
15201
|
+
const path = join4(dir, name);
|
|
14492
15202
|
const text = renderGoal(input, {
|
|
14493
15203
|
now,
|
|
14494
15204
|
status: arm ? "active" : "queued",
|
|
@@ -14528,7 +15238,7 @@ var goalCheckTool = tool({
|
|
|
14528
15238
|
o.index = selected[i].n;
|
|
14529
15239
|
});
|
|
14530
15240
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
14531
|
-
const next = appendCheckLog(
|
|
15241
|
+
const next = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
14532
15242
|
atomicWrite(goal.path, next);
|
|
14533
15243
|
const ok = outcomesAllOk(outcomes);
|
|
14534
15244
|
context.metadata({ title: `goal_check: ${outcomes.filter((o) => o.ok).length}/${outcomes.length} pass` });
|
|
@@ -14562,7 +15272,7 @@ var goalCompleteTool = tool({
|
|
|
14562
15272
|
const failures = outcomes.filter((o) => !o.ok);
|
|
14563
15273
|
if (failures.length > 0) {
|
|
14564
15274
|
const runId2 = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
14565
|
-
atomicWrite(goal.path, appendCheckLog(
|
|
15275
|
+
atomicWrite(goal.path, appendCheckLog(readFileSync6(goal.path, "utf8"), runId2, outcomes, nowIso()));
|
|
14566
15276
|
throw new GoalError(`Completion gate: verification re-run failed (fail-closed). The goal stays active.
|
|
14567
15277
|
${formatOutcomes(failures)}
|
|
14568
15278
|
Fix the work and retry; recorded results never substitute for the gate's own re-run.`);
|
|
@@ -14575,7 +15285,7 @@ Fix the work and retry; recorded results never substitute for the gate's own re-
|
|
|
14575
15285
|
}
|
|
14576
15286
|
await gate(context.ask, "goal_complete", `Complete goal: ${goal.doc.goal}`);
|
|
14577
15287
|
const runId = `${nowIso()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
14578
|
-
let text = appendCheckLog(
|
|
15288
|
+
let text = appendCheckLog(readFileSync6(goal.path, "utf8"), runId, outcomes, nowIso());
|
|
14579
15289
|
text = transitionGoal(text, "completed", nowIso());
|
|
14580
15290
|
atomicWrite(goal.path, text);
|
|
14581
15291
|
context.metadata({ title: `Goal completed: ${goal.doc.goal}` });
|
|
@@ -14597,7 +15307,7 @@ var goalPauseTool = tool({
|
|
|
14597
15307
|
throw new GoalError(`No active goal to pause (status: ${goal?.doc.status ?? "none"}).`);
|
|
14598
15308
|
}
|
|
14599
15309
|
const stopReason = args.blocker ? "blocker" : "user";
|
|
14600
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15310
|
+
atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason }));
|
|
14601
15311
|
engineForgetSession(context.sessionID);
|
|
14602
15312
|
context.metadata({ title: `Goal paused (${stopReason}): ${goal.doc.goal}` });
|
|
14603
15313
|
return {
|
|
@@ -14617,7 +15327,7 @@ var goalResumeTool = tool({
|
|
|
14617
15327
|
if (!goal || goal.doc.status === "queued") {
|
|
14618
15328
|
const oldest = rankQueuedGoals(readGoalDir(state.worktree))[0];
|
|
14619
15329
|
if (oldest) {
|
|
14620
|
-
const path =
|
|
15330
|
+
const path = join4(goalDirOf(state.worktree), oldest.name);
|
|
14621
15331
|
state.goalPath = path;
|
|
14622
15332
|
goal = { path, doc: oldest.doc };
|
|
14623
15333
|
}
|
|
@@ -14627,7 +15337,7 @@ var goalResumeTool = tool({
|
|
|
14627
15337
|
}
|
|
14628
15338
|
const promoting = goal.doc.status === "queued";
|
|
14629
15339
|
await gate(context.ask, "goal_resume", `${promoting ? "Promote" : "Resume"} goal: ${goal.doc.goal}`);
|
|
14630
|
-
let text =
|
|
15340
|
+
let text = readFileSync6(goal.path, "utf8");
|
|
14631
15341
|
if (args.addTurns)
|
|
14632
15342
|
text = bumpBudget(text, args.addTurns, nowIso());
|
|
14633
15343
|
text = transitionGoal(text, "active", nowIso(), { session: context.sessionID });
|
|
@@ -14653,7 +15363,7 @@ var goalDiscardTool = tool({
|
|
|
14653
15363
|
if (!goal)
|
|
14654
15364
|
throw new GoalError("No goal to discard in this workspace.");
|
|
14655
15365
|
await gate(context.ask, "goal_discard", `Discard goal: ${goal.doc.goal}`);
|
|
14656
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15366
|
+
atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "abandoned", nowIso()));
|
|
14657
15367
|
state.goalPath = undefined;
|
|
14658
15368
|
engineForgetSession(context.sessionID);
|
|
14659
15369
|
context.metadata({ title: `Goal abandoned: ${goal.doc.goal}` });
|
|
@@ -14681,7 +15391,8 @@ var forgeShellTool = tool({
|
|
|
14681
15391
|
max_wait_ms: tool.schema.number().int().nonnegative().optional().describe("Hard cap on this call's wait in ms (default 120000, clamped to 600000); returns still-running, never kills"),
|
|
14682
15392
|
success_pattern: tool.schema.string().optional().describe("Regex; a match against new output completes the call as success immediately"),
|
|
14683
15393
|
keep_alive: tool.schema.boolean().optional().describe("After a success match: keep the process alive (default) or kill its tree (false)"),
|
|
14684
|
-
notify: tool.schema.boolean().optional().describe("Send a [forge:job-complete] message into this session when the job exits (default true)")
|
|
15394
|
+
notify: tool.schema.boolean().optional().describe("Send a [forge:job-complete] message into this session when the job exits (default true)"),
|
|
15395
|
+
survive: tool.schema.boolean().optional().describe("Opt this job OUT of dying with the host: it keeps running after opencode exits, recorded in the persistent registry so the next run can poll/kill it (config jobs.survive sets the default; an explicit config deny cannot be overridden)")
|
|
14685
15396
|
},
|
|
14686
15397
|
execute: async (args, context) => {
|
|
14687
15398
|
if (jobStage() >= 2) {
|
|
@@ -14689,6 +15400,7 @@ var forgeShellTool = tool({
|
|
|
14689
15400
|
}
|
|
14690
15401
|
const state = ensureSession(context.sessionID, worktreeFor(context));
|
|
14691
15402
|
await gate(context.ask, "forge_shell", `forge_shell: ${args.command.slice(0, 100)}`);
|
|
15403
|
+
const survive = effectiveSurvive(jobsSurviveMode, args.survive);
|
|
14692
15404
|
let successPattern = null;
|
|
14693
15405
|
if (args.success_pattern) {
|
|
14694
15406
|
try {
|
|
@@ -14697,7 +15409,8 @@ var forgeShellTool = tool({
|
|
|
14697
15409
|
throw new Error(`Invalid success_pattern: ${err.message}`);
|
|
14698
15410
|
}
|
|
14699
15411
|
}
|
|
14700
|
-
const
|
|
15412
|
+
const { registry: registry2, fence } = ensureJobLifecycle();
|
|
15413
|
+
const cwd = args.workdir ? isAbsolute(args.workdir) ? args.workdir : join4(state.worktree, args.workdir) : state.worktree;
|
|
14701
15414
|
const started = startJob(jobManager, {
|
|
14702
15415
|
cmd: args.command,
|
|
14703
15416
|
cwd,
|
|
@@ -14709,8 +15422,21 @@ var forgeShellTool = tool({
|
|
|
14709
15422
|
...args.max_wait_ms !== undefined ? { maxWaitMs: args.max_wait_ms } : {},
|
|
14710
15423
|
successPattern,
|
|
14711
15424
|
...args.keep_alive !== undefined ? { keepAlive: args.keep_alive } : {},
|
|
14712
|
-
...args.notify !== undefined ? { notify: args.notify } : {}
|
|
15425
|
+
...args.notify !== undefined ? { notify: args.notify } : {},
|
|
15426
|
+
...survive ? { survive: true, registry: registry2 ?? undefined } : { fence, relocate: structuralRelocate, relocateAsync: structuralRelocateAsync }
|
|
14713
15427
|
});
|
|
15428
|
+
if (survive) {
|
|
15429
|
+
return {
|
|
15430
|
+
title: `job started (survives host exit): ${started.job.id}`,
|
|
15431
|
+
output: [
|
|
15432
|
+
`[forge:job] Started in background — SURVIVES host exit (no fence, no exit kill).`,
|
|
15433
|
+
`jobId: ${started.job.id}`,
|
|
15434
|
+
`logPath: ${started.job.logPath}`,
|
|
15435
|
+
"Recorded in the persistent registry: the next opencode run can poll/log/kill it via forge_jobs. Stop it explicitly when done."
|
|
15436
|
+
].join(`
|
|
15437
|
+
`)
|
|
15438
|
+
};
|
|
15439
|
+
}
|
|
14714
15440
|
context.metadata({ title: `forge_shell: ${args.command.slice(0, 60)}` });
|
|
14715
15441
|
if (args.run_in_background === true) {
|
|
14716
15442
|
return {
|
|
@@ -14775,7 +15501,8 @@ var forgeJobsTool = tool({
|
|
|
14775
15501
|
throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
|
|
14776
15502
|
}
|
|
14777
15503
|
if (action === "list") {
|
|
14778
|
-
|
|
15504
|
+
ensureJobLifecycle();
|
|
15505
|
+
const rows = jobManager.list().map((j) => `${j.id} ${j.state}${j.exitCode !== null ? `(${j.exitCode})` : ""}${j.succeededAt ? "*" : ""} ${j.previousRun ? "previous-run" : j.survive ? "survive" : j.scope} ${j.cmd.slice(0, 60)}`);
|
|
14779
15506
|
return { title: `jobs (${rows.length})`, output: rows.length > 0 ? rows.join(`
|
|
14780
15507
|
`) : "(no jobs)" };
|
|
14781
15508
|
}
|
|
@@ -14806,8 +15533,12 @@ ${p.newOutput || "(none in this window)"}`,
|
|
|
14806
15533
|
});
|
|
14807
15534
|
return {
|
|
14808
15535
|
title: `${job.id} log ${page.offset}-${page.offset + page.lines.length}/${page.total}`,
|
|
14809
|
-
output:
|
|
15536
|
+
output: [
|
|
15537
|
+
...page.windowed ? [`(log exceeds ${8}MB — showing the most recent window; read the file directly for full history: ${job.logPath})`] : [],
|
|
15538
|
+
page.lines.length > 0 ? page.lines.join(`
|
|
14810
15539
|
`) : "(empty)"
|
|
15540
|
+
].join(`
|
|
15541
|
+
`)
|
|
14811
15542
|
};
|
|
14812
15543
|
}
|
|
14813
15544
|
if (action === "kill") {
|
|
@@ -14867,7 +15598,7 @@ var pendingContinuationTurn = new Set;
|
|
|
14867
15598
|
function goalProbe(line) {
|
|
14868
15599
|
if (process.env.FORGE_GOAL_PROBE) {
|
|
14869
15600
|
try {
|
|
14870
|
-
|
|
15601
|
+
appendFileSync(join4(tmpdir2(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
|
|
14871
15602
|
`);
|
|
14872
15603
|
} catch {}
|
|
14873
15604
|
}
|
|
@@ -14913,7 +15644,7 @@ function wrapupBriefText(goal, reason) {
|
|
|
14913
15644
|
}
|
|
14914
15645
|
async function autoPauseGoal(client, state, goal, reason, wrapup) {
|
|
14915
15646
|
goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
|
|
14916
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15647
|
+
atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
|
|
14917
15648
|
engineForgetSession(state.sessionID);
|
|
14918
15649
|
if (wrapup && goal.doc.session) {
|
|
14919
15650
|
try {
|
|
@@ -14960,11 +15691,11 @@ async function continueIfEligible(client, sessionID) {
|
|
|
14960
15691
|
turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
|
|
14961
15692
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
14962
15693
|
const ledgerPath = state.goalPath;
|
|
14963
|
-
if (ledgerPath &&
|
|
15694
|
+
if (ledgerPath && existsSync3(ledgerPath)) {
|
|
14964
15695
|
try {
|
|
14965
|
-
const fresh = parseGoalLoose(
|
|
15696
|
+
const fresh = parseGoalLoose(readFileSync6(ledgerPath, "utf8"));
|
|
14966
15697
|
if (fresh && fresh.turnsUsed > 0) {
|
|
14967
|
-
atomicWrite(ledgerPath, appendLedger(
|
|
15698
|
+
atomicWrite(ledgerPath, appendLedger(readFileSync6(ledgerPath, "utf8"), { turn: fresh.turnsUsed, revision: fresh.revision, at: nowIso(), activity: turnHadActivity, writes: act?.writes ?? 0, checks: act?.checks ?? 0 }, nowIso()));
|
|
14968
15699
|
}
|
|
14969
15700
|
} catch (err) {
|
|
14970
15701
|
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
@@ -15025,7 +15756,7 @@ async function continueIfEligible(client, sessionID) {
|
|
|
15025
15756
|
pendingContinuationTurn.add(sessionID);
|
|
15026
15757
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
15027
15758
|
state.goalPath = goal.path;
|
|
15028
|
-
atomicWrite(goal.path, incTurns(
|
|
15759
|
+
atomicWrite(goal.path, incTurns(readFileSync6(goal.path, "utf8"), nowIso()));
|
|
15029
15760
|
goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
|
|
15030
15761
|
} catch (err) {
|
|
15031
15762
|
const n = (transportFails.get(sessionID) ?? 0) + 1;
|
|
@@ -15060,6 +15791,8 @@ var server = async (input, options) => {
|
|
|
15060
15791
|
if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
|
|
15061
15792
|
jobsMode = jobsOpts.mode;
|
|
15062
15793
|
jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
|
|
15794
|
+
if (jobsOpts?.survive === "never" || jobsOpts?.survive === "always" || jobsOpts?.survive === "deny")
|
|
15795
|
+
jobsSurviveMode = jobsOpts.survive;
|
|
15063
15796
|
const wdOpts = options?.watchdog;
|
|
15064
15797
|
const wdFallbacks = [];
|
|
15065
15798
|
const wdMode = parseMode(wdOpts?.mode);
|
|
@@ -15071,14 +15804,14 @@ var server = async (input, options) => {
|
|
|
15071
15804
|
if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
|
|
15072
15805
|
wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
|
|
15073
15806
|
}
|
|
15074
|
-
const watchdogLedger = createFileLedger(
|
|
15807
|
+
const watchdogLedger = createFileLedger(join4(watchdogLogDir(), "log.jsonl"));
|
|
15075
15808
|
const rawLocator = createLocator();
|
|
15076
15809
|
const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
|
|
15077
15810
|
const probeLine = (text) => {
|
|
15078
15811
|
if (!probing())
|
|
15079
15812
|
return;
|
|
15080
15813
|
try {
|
|
15081
|
-
|
|
15814
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
|
|
15082
15815
|
`);
|
|
15083
15816
|
} catch {}
|
|
15084
15817
|
};
|
|
@@ -15088,7 +15821,7 @@ var server = async (input, options) => {
|
|
|
15088
15821
|
probeLine(`locate dur=${Date.now() - started}ms hits=${hits.length} phase2=${phase2 === true} needle=${JSON.stringify(cmdNeedle ?? null)}`);
|
|
15089
15822
|
if (hits.length === 0 && cmdNeedle) {
|
|
15090
15823
|
try {
|
|
15091
|
-
const raw =
|
|
15824
|
+
const raw = execFileSync3("powershell", ["-NoProfile", "-Command", "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,CreationDate | ConvertTo-Json -Compress"], { encoding: "utf8", windowsHide: true, timeout: 15000 });
|
|
15092
15825
|
const all = parseWindowsProcs(raw);
|
|
15093
15826
|
probeLine(`diag rawLen=${raw.length} procs=${all.length} windowStart=${new Date(t0 - 2000).toISOString()}`);
|
|
15094
15827
|
for (const p of all) {
|
|
@@ -15123,6 +15856,7 @@ var server = async (input, options) => {
|
|
|
15123
15856
|
return {
|
|
15124
15857
|
dispose: async () => {
|
|
15125
15858
|
engineForgetAll();
|
|
15859
|
+
exitCleanup?.trigger("dispose");
|
|
15126
15860
|
jobManager.disposeAll();
|
|
15127
15861
|
watchdog.dispose();
|
|
15128
15862
|
sessions.clear();
|
|
@@ -15175,7 +15909,7 @@ var server = async (input, options) => {
|
|
|
15175
15909
|
"tool.execute.before": async (input2, output) => {
|
|
15176
15910
|
if (process.env.FORGE_PERM_PROBE) {
|
|
15177
15911
|
try {
|
|
15178
|
-
|
|
15912
|
+
appendFileSync(join4(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
|
|
15179
15913
|
`);
|
|
15180
15914
|
} catch {}
|
|
15181
15915
|
}
|
|
@@ -15185,7 +15919,7 @@ var server = async (input, options) => {
|
|
|
15185
15919
|
watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
|
|
15186
15920
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15187
15921
|
try {
|
|
15188
|
-
|
|
15922
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} track ${input2.callID} needle=${JSON.stringify(cmdText !== undefined ? commandNeedle(cmdText) : undefined)} rawArgs=${JSON.stringify(output?.args ?? null).slice(0, 200)}
|
|
15189
15923
|
`);
|
|
15190
15924
|
} catch {}
|
|
15191
15925
|
}
|
|
@@ -15204,7 +15938,7 @@ var server = async (input, options) => {
|
|
|
15204
15938
|
const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
|
|
15205
15939
|
if (process.env.FORGE_PERM_PROBE) {
|
|
15206
15940
|
try {
|
|
15207
|
-
|
|
15941
|
+
appendFileSync(join4(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} ask name=${JSON.stringify(name)} in_status=${output.status} id=${JSON.stringify(input2.id)} type=${JSON.stringify(input2.type)} meta=${JSON.stringify(input2.metadata)}
|
|
15208
15942
|
`);
|
|
15209
15943
|
} catch {}
|
|
15210
15944
|
}
|
|
@@ -15253,7 +15987,7 @@ var server = async (input, options) => {
|
|
|
15253
15987
|
watchdog.markSeen(input2.callID);
|
|
15254
15988
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15255
15989
|
try {
|
|
15256
|
-
|
|
15990
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
|
|
15257
15991
|
`);
|
|
15258
15992
|
} catch {}
|
|
15259
15993
|
}
|
|
@@ -15263,7 +15997,7 @@ var server = async (input, options) => {
|
|
|
15263
15997
|
watchdog.untrack(input2.callID);
|
|
15264
15998
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15265
15999
|
try {
|
|
15266
|
-
|
|
16000
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
|
|
15267
16001
|
`);
|
|
15268
16002
|
} catch {}
|
|
15269
16003
|
}
|
|
@@ -15368,5 +16102,6 @@ export {
|
|
|
15368
16102
|
server,
|
|
15369
16103
|
isRootish,
|
|
15370
16104
|
effectiveWorktree,
|
|
16105
|
+
effectiveSurvive,
|
|
15371
16106
|
plugin_default as default
|
|
15372
16107
|
};
|