@sorenllm/opencode-forge 0.3.0 → 0.3.2
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 +422 -373
- package/dist/index.js +838 -100
- package/package.json +1 -1
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,13 +15422,26 @@ 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 {
|
|
14717
15443
|
title: `job started: ${started.job.id}`,
|
|
14718
|
-
output: [`[forge:job] Started in background.`, `jobId: ${started.job.id}`, `logPath: ${started.job.logPath}`, "Track with forge_jobs poll; a [forge:job-complete] message arrives on exit."].join(`
|
|
15444
|
+
output: [`[forge:job] Started in background.`, `cwd: ${cwd}`, `jobId: ${started.job.id}`, `logPath: ${started.job.logPath}`, "Track with forge_jobs poll; a [forge:job-complete] message arrives on exit."].join(`
|
|
14719
15445
|
`)
|
|
14720
15446
|
};
|
|
14721
15447
|
}
|
|
@@ -14725,6 +15451,7 @@ var forgeShellTool = tool({
|
|
|
14725
15451
|
title: `exit ${r.exitCode ?? "?"}`,
|
|
14726
15452
|
output: [
|
|
14727
15453
|
`[forge:job] Command finished (exit=${r.exitCode ?? "none"}).`,
|
|
15454
|
+
`cwd: ${cwd}`,
|
|
14728
15455
|
...r.spawnError ? [`spawn error: ${r.spawnError}`] : [],
|
|
14729
15456
|
`output:
|
|
14730
15457
|
${r.outputTail || "(none)"}`
|
|
@@ -14737,6 +15464,7 @@ ${r.outputTail || "(none)"}`
|
|
|
14737
15464
|
title: `success: ${r.matched}`,
|
|
14738
15465
|
output: [
|
|
14739
15466
|
`[forge:job] Success pattern matched: "${r.matched}".`,
|
|
15467
|
+
`cwd: ${cwd}`,
|
|
14740
15468
|
r.keptAlive ? `The process was kept alive as job ${started.job.id} (stop it with forge_jobs kill when done).` : "The process tree was terminated (keep_alive=false).",
|
|
14741
15469
|
`output:
|
|
14742
15470
|
${r.outputTail}`
|
|
@@ -14748,6 +15476,7 @@ ${r.outputTail}`
|
|
|
14748
15476
|
title: `still running (${Math.round(r.idleForMs / 1000)}s idle)`,
|
|
14749
15477
|
output: [
|
|
14750
15478
|
`[forge:job] Still running — ${Math.round(r.idleForMs / 1000)}s without new output (or the wait budget ran out). The process is alive as job ${started.job.id}.`,
|
|
15479
|
+
`cwd: ${cwd}`,
|
|
14751
15480
|
`logPath: ${started.job.logPath}`,
|
|
14752
15481
|
`recent output:
|
|
14753
15482
|
${r.outputTail || "(none yet)"}`,
|
|
@@ -14775,7 +15504,8 @@ var forgeJobsTool = tool({
|
|
|
14775
15504
|
throw new Error(`Unknown action "${action}" — use one of: ${FORGE_JOBS_ACTIONS.join(", ")}.`);
|
|
14776
15505
|
}
|
|
14777
15506
|
if (action === "list") {
|
|
14778
|
-
|
|
15507
|
+
ensureJobLifecycle();
|
|
15508
|
+
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
15509
|
return { title: `jobs (${rows.length})`, output: rows.length > 0 ? rows.join(`
|
|
14780
15510
|
`) : "(no jobs)" };
|
|
14781
15511
|
}
|
|
@@ -14806,8 +15536,12 @@ ${p.newOutput || "(none in this window)"}`,
|
|
|
14806
15536
|
});
|
|
14807
15537
|
return {
|
|
14808
15538
|
title: `${job.id} log ${page.offset}-${page.offset + page.lines.length}/${page.total}`,
|
|
14809
|
-
output:
|
|
15539
|
+
output: [
|
|
15540
|
+
...page.windowed ? [`(log exceeds ${8}MB — showing the most recent window; read the file directly for full history: ${job.logPath})`] : [],
|
|
15541
|
+
page.lines.length > 0 ? page.lines.join(`
|
|
14810
15542
|
`) : "(empty)"
|
|
15543
|
+
].join(`
|
|
15544
|
+
`)
|
|
14811
15545
|
};
|
|
14812
15546
|
}
|
|
14813
15547
|
if (action === "kill") {
|
|
@@ -14867,7 +15601,7 @@ var pendingContinuationTurn = new Set;
|
|
|
14867
15601
|
function goalProbe(line) {
|
|
14868
15602
|
if (process.env.FORGE_GOAL_PROBE) {
|
|
14869
15603
|
try {
|
|
14870
|
-
|
|
15604
|
+
appendFileSync(join4(tmpdir2(), "forge-goal-probe.log"), `${new Date().toISOString()} ${line}
|
|
14871
15605
|
`);
|
|
14872
15606
|
} catch {}
|
|
14873
15607
|
}
|
|
@@ -14913,7 +15647,7 @@ function wrapupBriefText(goal, reason) {
|
|
|
14913
15647
|
}
|
|
14914
15648
|
async function autoPauseGoal(client, state, goal, reason, wrapup) {
|
|
14915
15649
|
goalProbe(`auto-pause session=${state.sessionID} reason=${reason} wrapup=${wrapup}`);
|
|
14916
|
-
atomicWrite(goal.path, transitionGoal(
|
|
15650
|
+
atomicWrite(goal.path, transitionGoal(readFileSync6(goal.path, "utf8"), "paused", nowIso(), { stopReason: reason }));
|
|
14917
15651
|
engineForgetSession(state.sessionID);
|
|
14918
15652
|
if (wrapup && goal.doc.session) {
|
|
14919
15653
|
try {
|
|
@@ -14960,11 +15694,11 @@ async function continueIfEligible(client, sessionID) {
|
|
|
14960
15694
|
turnHadActivity = !!act && (act.writes > 0 || act.checks > 0);
|
|
14961
15695
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
14962
15696
|
const ledgerPath = state.goalPath;
|
|
14963
|
-
if (ledgerPath &&
|
|
15697
|
+
if (ledgerPath && existsSync3(ledgerPath)) {
|
|
14964
15698
|
try {
|
|
14965
|
-
const fresh = parseGoalLoose(
|
|
15699
|
+
const fresh = parseGoalLoose(readFileSync6(ledgerPath, "utf8"));
|
|
14966
15700
|
if (fresh && fresh.turnsUsed > 0) {
|
|
14967
|
-
atomicWrite(ledgerPath, appendLedger(
|
|
15701
|
+
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
15702
|
}
|
|
14969
15703
|
} catch (err) {
|
|
14970
15704
|
goalProbe(`ledger append failed session=${sessionID} err=${String(err)}`);
|
|
@@ -15025,7 +15759,7 @@ async function continueIfEligible(client, sessionID) {
|
|
|
15025
15759
|
pendingContinuationTurn.add(sessionID);
|
|
15026
15760
|
turnActivity.set(sessionID, { writes: 0, checks: 0 });
|
|
15027
15761
|
state.goalPath = goal.path;
|
|
15028
|
-
atomicWrite(goal.path, incTurns(
|
|
15762
|
+
atomicWrite(goal.path, incTurns(readFileSync6(goal.path, "utf8"), nowIso()));
|
|
15029
15763
|
goalProbe(`continued session=${sessionID} turn=${goal.doc.turnsUsed + 1}/${goal.doc.maxTurns}`);
|
|
15030
15764
|
} catch (err) {
|
|
15031
15765
|
const n = (transportFails.get(sessionID) ?? 0) + 1;
|
|
@@ -15060,6 +15794,8 @@ var server = async (input, options) => {
|
|
|
15060
15794
|
if (jobsOpts?.mode === "auto" || jobsOpts?.mode === "forge" || jobsOpts?.mode === "native")
|
|
15061
15795
|
jobsMode = jobsOpts.mode;
|
|
15062
15796
|
jobsKeepBuiltinShell = jobsOpts?.keepBuiltinShell === true;
|
|
15797
|
+
if (jobsOpts?.survive === "never" || jobsOpts?.survive === "always" || jobsOpts?.survive === "deny")
|
|
15798
|
+
jobsSurviveMode = jobsOpts.survive;
|
|
15063
15799
|
const wdOpts = options?.watchdog;
|
|
15064
15800
|
const wdFallbacks = [];
|
|
15065
15801
|
const wdMode = parseMode(wdOpts?.mode);
|
|
@@ -15071,14 +15807,14 @@ var server = async (input, options) => {
|
|
|
15071
15807
|
if (wdStallRaw !== undefined && wdStallRaw !== wdStall) {
|
|
15072
15808
|
wdFallbacks.push(`watchdog.stallMs ${JSON.stringify(String(wdStallRaw))} adjusted to ${wdStall} (floor/default applied)`);
|
|
15073
15809
|
}
|
|
15074
|
-
const watchdogLedger = createFileLedger(
|
|
15810
|
+
const watchdogLedger = createFileLedger(join4(watchdogLogDir(), "log.jsonl"));
|
|
15075
15811
|
const rawLocator = createLocator();
|
|
15076
15812
|
const probing = () => process.env.FORGE_WATCHDOG_PROBE === "1";
|
|
15077
15813
|
const probeLine = (text) => {
|
|
15078
15814
|
if (!probing())
|
|
15079
15815
|
return;
|
|
15080
15816
|
try {
|
|
15081
|
-
|
|
15817
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} ${text}
|
|
15082
15818
|
`);
|
|
15083
15819
|
} catch {}
|
|
15084
15820
|
};
|
|
@@ -15088,7 +15824,7 @@ var server = async (input, options) => {
|
|
|
15088
15824
|
probeLine(`locate dur=${Date.now() - started}ms hits=${hits.length} phase2=${phase2 === true} needle=${JSON.stringify(cmdNeedle ?? null)}`);
|
|
15089
15825
|
if (hits.length === 0 && cmdNeedle) {
|
|
15090
15826
|
try {
|
|
15091
|
-
const raw =
|
|
15827
|
+
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
15828
|
const all = parseWindowsProcs(raw);
|
|
15093
15829
|
probeLine(`diag rawLen=${raw.length} procs=${all.length} windowStart=${new Date(t0 - 2000).toISOString()}`);
|
|
15094
15830
|
for (const p of all) {
|
|
@@ -15123,6 +15859,7 @@ var server = async (input, options) => {
|
|
|
15123
15859
|
return {
|
|
15124
15860
|
dispose: async () => {
|
|
15125
15861
|
engineForgetAll();
|
|
15862
|
+
exitCleanup?.trigger("dispose");
|
|
15126
15863
|
jobManager.disposeAll();
|
|
15127
15864
|
watchdog.dispose();
|
|
15128
15865
|
sessions.clear();
|
|
@@ -15175,7 +15912,7 @@ var server = async (input, options) => {
|
|
|
15175
15912
|
"tool.execute.before": async (input2, output) => {
|
|
15176
15913
|
if (process.env.FORGE_PERM_PROBE) {
|
|
15177
15914
|
try {
|
|
15178
|
-
|
|
15915
|
+
appendFileSync(join4(tmpdir2(), "forge-perm-probe.log"), `${new Date().toISOString()} before tool=${JSON.stringify(input2.tool)} session=${input2.sessionID}
|
|
15179
15916
|
`);
|
|
15180
15917
|
} catch {}
|
|
15181
15918
|
}
|
|
@@ -15185,7 +15922,7 @@ var server = async (input, options) => {
|
|
|
15185
15922
|
watchdog.track(input2.callID, input2.sessionID, input2.tool, undefined, cmdText !== undefined ? commandNeedle(cmdText) : undefined);
|
|
15186
15923
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15187
15924
|
try {
|
|
15188
|
-
|
|
15925
|
+
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
15926
|
`);
|
|
15190
15927
|
} catch {}
|
|
15191
15928
|
}
|
|
@@ -15204,7 +15941,7 @@ var server = async (input, options) => {
|
|
|
15204
15941
|
const name = (typeof meta.tool === "string" ? meta.tool : undefined) ?? (typeof permissionField === "string" ? permissionField : undefined) ?? input2.id ?? input2.type;
|
|
15205
15942
|
if (process.env.FORGE_PERM_PROBE) {
|
|
15206
15943
|
try {
|
|
15207
|
-
|
|
15944
|
+
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
15945
|
`);
|
|
15209
15946
|
} catch {}
|
|
15210
15947
|
}
|
|
@@ -15253,7 +15990,7 @@ var server = async (input, options) => {
|
|
|
15253
15990
|
watchdog.markSeen(input2.callID);
|
|
15254
15991
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15255
15992
|
try {
|
|
15256
|
-
|
|
15993
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} mark ${input2.callID} hostpid=${process.pid}
|
|
15257
15994
|
`);
|
|
15258
15995
|
} catch {}
|
|
15259
15996
|
}
|
|
@@ -15263,7 +16000,7 @@ var server = async (input, options) => {
|
|
|
15263
16000
|
watchdog.untrack(input2.callID);
|
|
15264
16001
|
if (process.env.FORGE_WATCHDOG_PROBE) {
|
|
15265
16002
|
try {
|
|
15266
|
-
|
|
16003
|
+
appendFileSync(join4(tmpdir2(), "forge-watchdog-probe.log"), `${new Date().toISOString()} untrack ${input2.callID}
|
|
15267
16004
|
`);
|
|
15268
16005
|
} catch {}
|
|
15269
16006
|
}
|
|
@@ -15368,5 +16105,6 @@ export {
|
|
|
15368
16105
|
server,
|
|
15369
16106
|
isRootish,
|
|
15370
16107
|
effectiveWorktree,
|
|
16108
|
+
effectiveSurvive,
|
|
15371
16109
|
plugin_default as default
|
|
15372
16110
|
};
|