@bybrawe/opencode-loop 0.6.6 → 0.6.7
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/CHANGELOG.md +12 -0
- package/README.md +7 -3
- package/examples/OPENCODE2-0.6.7.md +26 -0
- package/package.json +3 -3
- package/scripts/comprehensive-test.mjs +3 -3
- package/scripts/desktop-command-diagnostics-test.mjs +92 -0
- package/scripts/desktop-command-diagnostics.mjs +237 -0
- package/scripts/host-goal-bootstrap-test.mjs +46 -0
- package/scripts/host-goal-bootstrap.mjs +71 -0
- package/scripts/host-goal-coexistence-canary.mjs +11 -2
- package/scripts/install-with-goals.mjs +11 -0
- package/scripts/native-plugin-lifetime-test.mjs +39 -6
- package/scripts/native-release.mjs +3 -3
- package/scripts/native-v2-test.mjs +64 -2
- package/scripts/opencode2-loop-canary.mjs +55 -1
- package/scripts/package-native-smoke.mjs +40 -5
- package/scripts/publish-workflow-test.mjs +6 -2
- package/scripts/state-integrity-test.mjs +47 -0
- package/scripts/state-process-test.mjs +172 -0
- package/src/index.js +217 -11
- package/src/native.js +236 -19
- package/src/server.js +236 -19
- package/src/source/core/state.js +203 -9
- package/src/source/opencode2/events.js +1 -1
- package/src/source/opencode2/native-plugin.js +4 -6
- package/src/source/opencode2/native-runtime.js +30 -4
package/src/index.js
CHANGED
|
@@ -381,6 +381,7 @@ import { spawn } from "child_process";
|
|
|
381
381
|
|
|
382
382
|
// src/source/core/state.js
|
|
383
383
|
import { promises as fs } from "fs";
|
|
384
|
+
import { randomUUID as stateLockRandomUUID } from "crypto";
|
|
384
385
|
import os from "os";
|
|
385
386
|
import path from "path";
|
|
386
387
|
var STATE_DIR = ".opencode/opencode-loop";
|
|
@@ -403,6 +404,194 @@ async function pathExists(filePath) {
|
|
|
403
404
|
return false;
|
|
404
405
|
}
|
|
405
406
|
}
|
|
407
|
+
var STATE_PROCESS_LOCK_TIMEOUT_MS = 5000;
|
|
408
|
+
var STATE_PROCESS_LOCK_POLL_MS = 20;
|
|
409
|
+
function stateLockError(kind, detail) {
|
|
410
|
+
const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`);
|
|
411
|
+
error.code = "OPENCODE_LOOP_STATE_LOCK";
|
|
412
|
+
return error;
|
|
413
|
+
}
|
|
414
|
+
async function assertStatePathSafe(directory, target) {
|
|
415
|
+
const root = path.resolve(directory);
|
|
416
|
+
const relative = path.relative(root, path.resolve(target));
|
|
417
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
418
|
+
throw stateLockError("unsafe_path", `path escapes project: ${target}`);
|
|
419
|
+
}
|
|
420
|
+
let current = root;
|
|
421
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
422
|
+
current = path.join(current, part);
|
|
423
|
+
let info;
|
|
424
|
+
try {
|
|
425
|
+
info = await fs.lstat(current);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error?.code === "ENOENT")
|
|
428
|
+
return;
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
if (info.isSymbolicLink())
|
|
432
|
+
throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function stateProcessLockOwner() {
|
|
436
|
+
const token = stateLockRandomUUID();
|
|
437
|
+
return {
|
|
438
|
+
schemaVersion: 1,
|
|
439
|
+
pid: process.pid,
|
|
440
|
+
token,
|
|
441
|
+
acquiredAt: Date.now(),
|
|
442
|
+
candidateName: `.loop-lock-${process.pid}-${token}.json`
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function stateProcessLockOwnerValid(owner) {
|
|
446
|
+
return owner?.schemaVersion === 1 && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token) && Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0 && owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json` && path.basename(owner.candidateName) === owner.candidateName;
|
|
447
|
+
}
|
|
448
|
+
async function readStateProcessOwner(file) {
|
|
449
|
+
let raw;
|
|
450
|
+
for (let attempt = 0;; attempt++) {
|
|
451
|
+
try {
|
|
452
|
+
raw = await fs.readFile(file, "utf8");
|
|
453
|
+
break;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error?.code === "ENOENT")
|
|
456
|
+
return null;
|
|
457
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
458
|
+
throw error;
|
|
459
|
+
await delay(10 * (attempt + 1));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
let owner;
|
|
463
|
+
try {
|
|
464
|
+
owner = JSON.parse(raw);
|
|
465
|
+
} catch {
|
|
466
|
+
throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`);
|
|
467
|
+
}
|
|
468
|
+
if (!stateProcessLockOwnerValid(owner))
|
|
469
|
+
throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`);
|
|
470
|
+
return owner;
|
|
471
|
+
}
|
|
472
|
+
function stateProcessAlive(pid) {
|
|
473
|
+
if (pid === process.pid)
|
|
474
|
+
return true;
|
|
475
|
+
try {
|
|
476
|
+
process.kill(pid, 0);
|
|
477
|
+
return true;
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return error?.code !== "ESRCH";
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function removeStateProcessFile(file) {
|
|
483
|
+
for (let attempt = 0;; attempt++) {
|
|
484
|
+
try {
|
|
485
|
+
await fs.rm(file, { force: true });
|
|
486
|
+
return;
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
489
|
+
throw error;
|
|
490
|
+
await delay(10 * (attempt + 1));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function claimStateProcessLock(candidate, canonical) {
|
|
495
|
+
for (let attempt = 0;; attempt++) {
|
|
496
|
+
try {
|
|
497
|
+
await fs.link(candidate, canonical);
|
|
498
|
+
return true;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (error?.code === "EEXIST")
|
|
501
|
+
return false;
|
|
502
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
503
|
+
throw error;
|
|
504
|
+
await delay(10 * (attempt + 1));
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
async function createStateLockCandidate(directory, owner) {
|
|
509
|
+
const candidate = path.join(stateDir(directory), owner.candidateName);
|
|
510
|
+
await assertStatePathSafe(directory, candidate);
|
|
511
|
+
await fs.writeFile(candidate, `${JSON.stringify(owner)}
|
|
512
|
+
`, { flag: "wx", mode: 384 });
|
|
513
|
+
return candidate;
|
|
514
|
+
}
|
|
515
|
+
async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
|
|
516
|
+
const cleanupFile = `${canonical}.cleanup`;
|
|
517
|
+
const cleanupOwner = stateProcessLockOwner();
|
|
518
|
+
const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner);
|
|
519
|
+
let elected = false;
|
|
520
|
+
try {
|
|
521
|
+
await assertStatePathSafe(directory, cleanupFile);
|
|
522
|
+
elected = await claimStateProcessLock(cleanupCandidate, cleanupFile);
|
|
523
|
+
if (!elected) {
|
|
524
|
+
const existing = await readStateProcessOwner(cleanupFile);
|
|
525
|
+
if (existing && !stateProcessAlive(existing.pid)) {
|
|
526
|
+
if (depth >= 6)
|
|
527
|
+
throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`);
|
|
528
|
+
await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1);
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
const current = await readStateProcessOwner(canonical);
|
|
533
|
+
if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid))
|
|
534
|
+
return false;
|
|
535
|
+
await assertStatePathSafe(directory, canonical);
|
|
536
|
+
await removeStateProcessFile(canonical);
|
|
537
|
+
const staleCandidate = path.join(stateDir(directory), current.candidateName);
|
|
538
|
+
await assertStatePathSafe(directory, staleCandidate);
|
|
539
|
+
await removeStateProcessFile(staleCandidate).catch(() => {});
|
|
540
|
+
return true;
|
|
541
|
+
} finally {
|
|
542
|
+
if (elected) {
|
|
543
|
+
const current = await readStateProcessOwner(cleanupFile).catch(() => null);
|
|
544
|
+
if (current?.token === cleanupOwner.token)
|
|
545
|
+
await removeStateProcessFile(cleanupFile);
|
|
546
|
+
}
|
|
547
|
+
await removeStateProcessFile(cleanupCandidate).catch(() => {});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
async function acquireStateProcessLock(directory, sessionID) {
|
|
551
|
+
const targetDirectory = stateDir(directory);
|
|
552
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
553
|
+
await ensureDir(targetDirectory);
|
|
554
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
555
|
+
const target = statePath(directory, sessionID);
|
|
556
|
+
const canonical = `${target}.lock`;
|
|
557
|
+
await assertStatePathSafe(directory, target);
|
|
558
|
+
await assertStatePathSafe(directory, canonical);
|
|
559
|
+
const owner = stateProcessLockOwner();
|
|
560
|
+
const candidate = await createStateLockCandidate(directory, owner);
|
|
561
|
+
const started = Date.now();
|
|
562
|
+
let acquired = false;
|
|
563
|
+
try {
|
|
564
|
+
while (true) {
|
|
565
|
+
await assertStatePathSafe(directory, canonical);
|
|
566
|
+
if (await claimStateProcessLock(candidate, canonical)) {
|
|
567
|
+
acquired = true;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
const current = await readStateProcessOwner(canonical);
|
|
571
|
+
if (current && !stateProcessAlive(current.pid))
|
|
572
|
+
await recoverDeadStateProcessLock(directory, canonical, current);
|
|
573
|
+
if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
|
|
574
|
+
throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`);
|
|
575
|
+
}
|
|
576
|
+
await delay(STATE_PROCESS_LOCK_POLL_MS);
|
|
577
|
+
}
|
|
578
|
+
return async () => {
|
|
579
|
+
const current = await readStateProcessOwner(canonical);
|
|
580
|
+
if (current?.token !== owner.token || current.pid !== owner.pid) {
|
|
581
|
+
throw stateLockError("lost", `session ${sessionID} lock ownership changed`);
|
|
582
|
+
}
|
|
583
|
+
await assertStatePathSafe(directory, canonical);
|
|
584
|
+
await removeStateProcessFile(canonical);
|
|
585
|
+
await removeStateProcessFile(candidate);
|
|
586
|
+
};
|
|
587
|
+
} catch (error) {
|
|
588
|
+
if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
|
|
589
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
590
|
+
}
|
|
591
|
+
await removeStateProcessFile(candidate).catch(() => {});
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
406
595
|
function stateLockKey(directory, sessionID) {
|
|
407
596
|
return `${path.resolve(directory)}:${safeID(sessionID)}`;
|
|
408
597
|
}
|
|
@@ -416,21 +605,32 @@ async function withStateWriteLock(directory, sessionID, fn) {
|
|
|
416
605
|
const next = previous.catch(() => {}).then(() => current);
|
|
417
606
|
stateWriteLocks.set(key, next);
|
|
418
607
|
await previous.catch(() => {});
|
|
608
|
+
let unlock;
|
|
419
609
|
try {
|
|
610
|
+
unlock = await acquireStateProcessLock(directory, sessionID);
|
|
420
611
|
return await fn();
|
|
421
612
|
} finally {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
613
|
+
try {
|
|
614
|
+
if (unlock)
|
|
615
|
+
await unlock();
|
|
616
|
+
} finally {
|
|
617
|
+
release();
|
|
618
|
+
if (stateWriteLocks.get(key) === next)
|
|
619
|
+
stateWriteLocks.delete(key);
|
|
620
|
+
}
|
|
425
621
|
}
|
|
426
622
|
}
|
|
427
623
|
async function readStateFile(directory, sessionID) {
|
|
428
624
|
const target = statePath(directory, sessionID);
|
|
625
|
+
await assertStatePathSafe(directory, target);
|
|
429
626
|
const attempts = 5;
|
|
430
627
|
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
431
628
|
try {
|
|
432
629
|
const parsed = JSON.parse(await fs.readFile(target, "utf8"));
|
|
433
|
-
|
|
630
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
|
|
631
|
+
throw new SyntaxError("invalid Loop state schema: jobs must be an array");
|
|
632
|
+
}
|
|
633
|
+
return { version: 4, jobs: parsed.jobs };
|
|
434
634
|
} catch (error) {
|
|
435
635
|
if (error?.code === "ENOENT")
|
|
436
636
|
return { version: 4, jobs: [] };
|
|
@@ -444,7 +644,7 @@ async function readStateFile(directory, sessionID) {
|
|
|
444
644
|
await ensureDir(stateDir(directory));
|
|
445
645
|
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`);
|
|
446
646
|
} catch {}
|
|
447
|
-
|
|
647
|
+
throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`);
|
|
448
648
|
}
|
|
449
649
|
async function readState(directory, sessionID) {
|
|
450
650
|
const state = await readStateFile(directory, sessionID);
|
|
@@ -574,25 +774,31 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
|
574
774
|
}
|
|
575
775
|
async function writeState(directory, sessionID, state) {
|
|
576
776
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
577
|
-
await ensureDir(stateDir(directory));
|
|
578
777
|
const target = statePath(directory, sessionID);
|
|
778
|
+
await assertStatePathSafe(directory, target);
|
|
779
|
+
const current = await readStateFile(directory, sessionID);
|
|
579
780
|
const baseline = state?.[STATE_BASELINE];
|
|
580
781
|
let jobs = structuredClone(state.jobs || []);
|
|
581
782
|
if (Array.isArray(baseline)) {
|
|
582
|
-
const current = await readStateFile(directory, sessionID);
|
|
583
783
|
jobs = mergeStateJobs(baseline, jobs, current.jobs || []);
|
|
584
|
-
state.jobs = structuredClone(jobs);
|
|
585
|
-
state[STATE_BASELINE] = structuredClone(jobs);
|
|
586
784
|
}
|
|
587
785
|
const payload = JSON.stringify({ version: 4, jobs }, null, 2);
|
|
588
786
|
await writeFileAtomically(target, payload);
|
|
787
|
+
state.jobs = structuredClone(jobs);
|
|
788
|
+
if (Array.isArray(baseline))
|
|
789
|
+
state[STATE_BASELINE] = structuredClone(jobs);
|
|
589
790
|
});
|
|
590
791
|
}
|
|
591
792
|
async function removeState(directory, sessionID) {
|
|
592
793
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
794
|
+
const target = statePath(directory, sessionID);
|
|
795
|
+
await assertStatePathSafe(directory, target);
|
|
593
796
|
try {
|
|
594
|
-
await fs.unlink(
|
|
595
|
-
} catch {
|
|
797
|
+
await fs.unlink(target);
|
|
798
|
+
} catch (error) {
|
|
799
|
+
if (error?.code !== "ENOENT")
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
596
802
|
});
|
|
597
803
|
}
|
|
598
804
|
|
package/src/native.js
CHANGED
|
@@ -547,6 +547,7 @@ var init_jobs = __esm(() => {
|
|
|
547
547
|
|
|
548
548
|
// src/source/core/state.js
|
|
549
549
|
import { promises as fs } from "fs";
|
|
550
|
+
import { randomUUID as stateLockRandomUUID } from "crypto";
|
|
550
551
|
import os from "os";
|
|
551
552
|
import path3 from "path";
|
|
552
553
|
function stateDir(directory) {
|
|
@@ -558,6 +559,192 @@ function statePath(directory, sessionID) {
|
|
|
558
559
|
async function ensureDir(directory) {
|
|
559
560
|
await fs.mkdir(directory, { recursive: true });
|
|
560
561
|
}
|
|
562
|
+
function stateLockError(kind, detail) {
|
|
563
|
+
const error = new Error(`OpenCode Loop state lock ${kind}: ${detail}`);
|
|
564
|
+
error.code = "OPENCODE_LOOP_STATE_LOCK";
|
|
565
|
+
return error;
|
|
566
|
+
}
|
|
567
|
+
async function assertStatePathSafe(directory, target) {
|
|
568
|
+
const root = path3.resolve(directory);
|
|
569
|
+
const relative = path3.relative(root, path3.resolve(target));
|
|
570
|
+
if (relative === ".." || relative.startsWith(`..${path3.sep}`) || path3.isAbsolute(relative)) {
|
|
571
|
+
throw stateLockError("unsafe_path", `path escapes project: ${target}`);
|
|
572
|
+
}
|
|
573
|
+
let current = root;
|
|
574
|
+
for (const part of relative.split(path3.sep).filter(Boolean)) {
|
|
575
|
+
current = path3.join(current, part);
|
|
576
|
+
let info;
|
|
577
|
+
try {
|
|
578
|
+
info = await fs.lstat(current);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error?.code === "ENOENT")
|
|
581
|
+
return;
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
if (info.isSymbolicLink())
|
|
585
|
+
throw stateLockError("unsafe_path", `symbolic link or junction: ${current}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function stateProcessLockOwner() {
|
|
589
|
+
const token = stateLockRandomUUID();
|
|
590
|
+
return {
|
|
591
|
+
schemaVersion: 1,
|
|
592
|
+
pid: process.pid,
|
|
593
|
+
token,
|
|
594
|
+
acquiredAt: Date.now(),
|
|
595
|
+
candidateName: `.loop-lock-${process.pid}-${token}.json`
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
function stateProcessLockOwnerValid(owner) {
|
|
599
|
+
return owner?.schemaVersion === 1 && Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && /^[0-9a-f-]{36}$/i.test(owner.token) && Number.isFinite(owner.acquiredAt) && owner.acquiredAt > 0 && owner.candidateName === `.loop-lock-${owner.pid}-${owner.token}.json` && path3.basename(owner.candidateName) === owner.candidateName;
|
|
600
|
+
}
|
|
601
|
+
async function readStateProcessOwner(file) {
|
|
602
|
+
let raw;
|
|
603
|
+
for (let attempt = 0;; attempt++) {
|
|
604
|
+
try {
|
|
605
|
+
raw = await fs.readFile(file, "utf8");
|
|
606
|
+
break;
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (error?.code === "ENOENT")
|
|
609
|
+
return null;
|
|
610
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
611
|
+
throw error;
|
|
612
|
+
await delay(10 * (attempt + 1));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
let owner;
|
|
616
|
+
try {
|
|
617
|
+
owner = JSON.parse(raw);
|
|
618
|
+
} catch {
|
|
619
|
+
throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`);
|
|
620
|
+
}
|
|
621
|
+
if (!stateProcessLockOwnerValid(owner))
|
|
622
|
+
throw stateLockError("invalid_owner", `invalid lock metadata at ${file}`);
|
|
623
|
+
return owner;
|
|
624
|
+
}
|
|
625
|
+
function stateProcessAlive(pid) {
|
|
626
|
+
if (pid === process.pid)
|
|
627
|
+
return true;
|
|
628
|
+
try {
|
|
629
|
+
process.kill(pid, 0);
|
|
630
|
+
return true;
|
|
631
|
+
} catch (error) {
|
|
632
|
+
return error?.code !== "ESRCH";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function removeStateProcessFile(file) {
|
|
636
|
+
for (let attempt = 0;; attempt++) {
|
|
637
|
+
try {
|
|
638
|
+
await fs.rm(file, { force: true });
|
|
639
|
+
return;
|
|
640
|
+
} catch (error) {
|
|
641
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
642
|
+
throw error;
|
|
643
|
+
await delay(10 * (attempt + 1));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
async function claimStateProcessLock(candidate, canonical) {
|
|
648
|
+
for (let attempt = 0;; attempt++) {
|
|
649
|
+
try {
|
|
650
|
+
await fs.link(candidate, canonical);
|
|
651
|
+
return true;
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if (error?.code === "EEXIST")
|
|
654
|
+
return false;
|
|
655
|
+
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
656
|
+
throw error;
|
|
657
|
+
await delay(10 * (attempt + 1));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
async function createStateLockCandidate(directory, owner) {
|
|
662
|
+
const candidate = path3.join(stateDir(directory), owner.candidateName);
|
|
663
|
+
await assertStatePathSafe(directory, candidate);
|
|
664
|
+
await fs.writeFile(candidate, `${JSON.stringify(owner)}
|
|
665
|
+
`, { flag: "wx", mode: 384 });
|
|
666
|
+
return candidate;
|
|
667
|
+
}
|
|
668
|
+
async function recoverDeadStateProcessLock(directory, canonical, stale, depth = 0) {
|
|
669
|
+
const cleanupFile = `${canonical}.cleanup`;
|
|
670
|
+
const cleanupOwner = stateProcessLockOwner();
|
|
671
|
+
const cleanupCandidate = await createStateLockCandidate(directory, cleanupOwner);
|
|
672
|
+
let elected = false;
|
|
673
|
+
try {
|
|
674
|
+
await assertStatePathSafe(directory, cleanupFile);
|
|
675
|
+
elected = await claimStateProcessLock(cleanupCandidate, cleanupFile);
|
|
676
|
+
if (!elected) {
|
|
677
|
+
const existing = await readStateProcessOwner(cleanupFile);
|
|
678
|
+
if (existing && !stateProcessAlive(existing.pid)) {
|
|
679
|
+
if (depth >= 6)
|
|
680
|
+
throw stateLockError("recovery_depth", `too many orphaned cleanup elections for ${canonical}`);
|
|
681
|
+
await recoverDeadStateProcessLock(directory, cleanupFile, existing, depth + 1);
|
|
682
|
+
}
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
const current = await readStateProcessOwner(canonical);
|
|
686
|
+
if (!current || current.token !== stale.token || current.pid !== stale.pid || stateProcessAlive(current.pid))
|
|
687
|
+
return false;
|
|
688
|
+
await assertStatePathSafe(directory, canonical);
|
|
689
|
+
await removeStateProcessFile(canonical);
|
|
690
|
+
const staleCandidate = path3.join(stateDir(directory), current.candidateName);
|
|
691
|
+
await assertStatePathSafe(directory, staleCandidate);
|
|
692
|
+
await removeStateProcessFile(staleCandidate).catch(() => {});
|
|
693
|
+
return true;
|
|
694
|
+
} finally {
|
|
695
|
+
if (elected) {
|
|
696
|
+
const current = await readStateProcessOwner(cleanupFile).catch(() => null);
|
|
697
|
+
if (current?.token === cleanupOwner.token)
|
|
698
|
+
await removeStateProcessFile(cleanupFile);
|
|
699
|
+
}
|
|
700
|
+
await removeStateProcessFile(cleanupCandidate).catch(() => {});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async function acquireStateProcessLock(directory, sessionID) {
|
|
704
|
+
const targetDirectory = stateDir(directory);
|
|
705
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
706
|
+
await ensureDir(targetDirectory);
|
|
707
|
+
await assertStatePathSafe(directory, targetDirectory);
|
|
708
|
+
const target = statePath(directory, sessionID);
|
|
709
|
+
const canonical = `${target}.lock`;
|
|
710
|
+
await assertStatePathSafe(directory, target);
|
|
711
|
+
await assertStatePathSafe(directory, canonical);
|
|
712
|
+
const owner = stateProcessLockOwner();
|
|
713
|
+
const candidate = await createStateLockCandidate(directory, owner);
|
|
714
|
+
const started = Date.now();
|
|
715
|
+
let acquired = false;
|
|
716
|
+
try {
|
|
717
|
+
while (true) {
|
|
718
|
+
await assertStatePathSafe(directory, canonical);
|
|
719
|
+
if (await claimStateProcessLock(candidate, canonical)) {
|
|
720
|
+
acquired = true;
|
|
721
|
+
break;
|
|
722
|
+
}
|
|
723
|
+
const current = await readStateProcessOwner(canonical);
|
|
724
|
+
if (current && !stateProcessAlive(current.pid))
|
|
725
|
+
await recoverDeadStateProcessLock(directory, canonical, current);
|
|
726
|
+
if (Date.now() - started >= STATE_PROCESS_LOCK_TIMEOUT_MS) {
|
|
727
|
+
throw stateLockError("timeout", `session ${sessionID} remained locked for ${STATE_PROCESS_LOCK_TIMEOUT_MS}ms`);
|
|
728
|
+
}
|
|
729
|
+
await delay(STATE_PROCESS_LOCK_POLL_MS);
|
|
730
|
+
}
|
|
731
|
+
return async () => {
|
|
732
|
+
const current = await readStateProcessOwner(canonical);
|
|
733
|
+
if (current?.token !== owner.token || current.pid !== owner.pid) {
|
|
734
|
+
throw stateLockError("lost", `session ${sessionID} lock ownership changed`);
|
|
735
|
+
}
|
|
736
|
+
await assertStatePathSafe(directory, canonical);
|
|
737
|
+
await removeStateProcessFile(canonical);
|
|
738
|
+
await removeStateProcessFile(candidate);
|
|
739
|
+
};
|
|
740
|
+
} catch (error) {
|
|
741
|
+
if (acquired && (await readStateProcessOwner(canonical).catch(() => null))?.token === owner.token) {
|
|
742
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
743
|
+
}
|
|
744
|
+
await removeStateProcessFile(candidate).catch(() => {});
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
561
748
|
function stateLockKey(directory, sessionID) {
|
|
562
749
|
return `${path3.resolve(directory)}:${safeID(sessionID)}`;
|
|
563
750
|
}
|
|
@@ -571,21 +758,32 @@ async function withStateWriteLock(directory, sessionID, fn) {
|
|
|
571
758
|
const next = previous.catch(() => {}).then(() => current);
|
|
572
759
|
stateWriteLocks.set(key, next);
|
|
573
760
|
await previous.catch(() => {});
|
|
761
|
+
let unlock;
|
|
574
762
|
try {
|
|
763
|
+
unlock = await acquireStateProcessLock(directory, sessionID);
|
|
575
764
|
return await fn();
|
|
576
765
|
} finally {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
766
|
+
try {
|
|
767
|
+
if (unlock)
|
|
768
|
+
await unlock();
|
|
769
|
+
} finally {
|
|
770
|
+
release();
|
|
771
|
+
if (stateWriteLocks.get(key) === next)
|
|
772
|
+
stateWriteLocks.delete(key);
|
|
773
|
+
}
|
|
580
774
|
}
|
|
581
775
|
}
|
|
582
776
|
async function readStateFile(directory, sessionID) {
|
|
583
777
|
const target = statePath(directory, sessionID);
|
|
778
|
+
await assertStatePathSafe(directory, target);
|
|
584
779
|
const attempts = 5;
|
|
585
780
|
for (let attempt = 0;attempt < attempts; attempt++) {
|
|
586
781
|
try {
|
|
587
782
|
const parsed = JSON.parse(await fs.readFile(target, "utf8"));
|
|
588
|
-
|
|
783
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.jobs)) {
|
|
784
|
+
throw new SyntaxError("invalid Loop state schema: jobs must be an array");
|
|
785
|
+
}
|
|
786
|
+
return { version: 4, jobs: parsed.jobs };
|
|
589
787
|
} catch (error) {
|
|
590
788
|
if (error?.code === "ENOENT")
|
|
591
789
|
return { version: 4, jobs: [] };
|
|
@@ -599,7 +797,7 @@ async function readStateFile(directory, sessionID) {
|
|
|
599
797
|
await ensureDir(stateDir(directory));
|
|
600
798
|
await fs.copyFile(target, `${target}.corrupt-${Date.now()}`);
|
|
601
799
|
} catch {}
|
|
602
|
-
|
|
800
|
+
throw new Error(`OpenCode Loop state at ${target} could not be read; refusing to replace persisted jobs.`);
|
|
603
801
|
}
|
|
604
802
|
async function readState(directory, sessionID) {
|
|
605
803
|
const state = await readStateFile(directory, sessionID);
|
|
@@ -729,28 +927,34 @@ function mergeStateJobs(baseJobs, intendedJobs, currentJobs) {
|
|
|
729
927
|
}
|
|
730
928
|
async function writeState(directory, sessionID, state) {
|
|
731
929
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
732
|
-
await ensureDir(stateDir(directory));
|
|
733
930
|
const target = statePath(directory, sessionID);
|
|
931
|
+
await assertStatePathSafe(directory, target);
|
|
932
|
+
const current = await readStateFile(directory, sessionID);
|
|
734
933
|
const baseline = state?.[STATE_BASELINE];
|
|
735
934
|
let jobs = structuredClone(state.jobs || []);
|
|
736
935
|
if (Array.isArray(baseline)) {
|
|
737
|
-
const current = await readStateFile(directory, sessionID);
|
|
738
936
|
jobs = mergeStateJobs(baseline, jobs, current.jobs || []);
|
|
739
|
-
state.jobs = structuredClone(jobs);
|
|
740
|
-
state[STATE_BASELINE] = structuredClone(jobs);
|
|
741
937
|
}
|
|
742
938
|
const payload = JSON.stringify({ version: 4, jobs }, null, 2);
|
|
743
939
|
await writeFileAtomically(target, payload);
|
|
940
|
+
state.jobs = structuredClone(jobs);
|
|
941
|
+
if (Array.isArray(baseline))
|
|
942
|
+
state[STATE_BASELINE] = structuredClone(jobs);
|
|
744
943
|
});
|
|
745
944
|
}
|
|
746
945
|
async function removeState(directory, sessionID) {
|
|
747
946
|
await withStateWriteLock(directory, sessionID, async () => {
|
|
947
|
+
const target = statePath(directory, sessionID);
|
|
948
|
+
await assertStatePathSafe(directory, target);
|
|
748
949
|
try {
|
|
749
|
-
await fs.unlink(
|
|
750
|
-
} catch {
|
|
950
|
+
await fs.unlink(target);
|
|
951
|
+
} catch (error) {
|
|
952
|
+
if (error?.code !== "ENOENT")
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
751
955
|
});
|
|
752
956
|
}
|
|
753
|
-
var STATE_DIR = ".opencode/opencode-loop", STATE_BASELINE, stateWriteLocks;
|
|
957
|
+
var STATE_DIR = ".opencode/opencode-loop", STATE_BASELINE, stateWriteLocks, STATE_PROCESS_LOCK_TIMEOUT_MS = 5000, STATE_PROCESS_LOCK_POLL_MS = 20;
|
|
754
958
|
var init_state = __esm(() => {
|
|
755
959
|
STATE_BASELINE = Symbol("opencode-loop-state-baseline");
|
|
756
960
|
stateWriteLocks = new Map;
|
|
@@ -1824,7 +2028,7 @@ function createNativeLoopRuntime(options = {}) {
|
|
|
1824
2028
|
const run = scope.active;
|
|
1825
2029
|
if (!run || scope.compaction || scope.busy)
|
|
1826
2030
|
return result({ reason: "no-terminal-boundary" });
|
|
1827
|
-
if (run.kind === "prompt" && !run.delivered || run.kind === "command" && !run.commandFinished || run.kind === "shell" && !run.shellFinished || ["compact", "cadence"].includes(run.kind) && !run.compactFinished) {
|
|
2031
|
+
if (run.kind === "prompt" && !run.delivered || run.kind === "command" && !run.commandFinished || run.kind === "shell" && !run.shellFinished || ["compact", "cadence"].includes(run.kind) && (!run.compactionStarted || !run.compactFinished)) {
|
|
1828
2032
|
return result({ reason: "awaiting-owned-completion" });
|
|
1829
2033
|
}
|
|
1830
2034
|
clear(scope, "deadline");
|
|
@@ -2100,6 +2304,8 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
2100
2304
|
}
|
|
2101
2305
|
if (event.kind === "compaction" && event.action === "started") {
|
|
2102
2306
|
scope.compaction ||= { ended: false, terminal: false };
|
|
2307
|
+
if (["compact", "cadence"].includes(scope.active?.kind) && event.reason === "manual" && event.inputID && event.inputID === scope.active.inboxID)
|
|
2308
|
+
scope.active.compactionStarted = true;
|
|
2103
2309
|
scope.busy = true;
|
|
2104
2310
|
clear(scope, "timer");
|
|
2105
2311
|
return result({ reason: "compacting" });
|
|
@@ -2160,10 +2366,21 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
2160
2366
|
return finish(scope);
|
|
2161
2367
|
}
|
|
2162
2368
|
if (event.kind === "session" && ["error", "failed", "interrupted"].includes(event.action) || event.kind === "compaction" && event.action === "failed") {
|
|
2369
|
+
const run = scope.active;
|
|
2370
|
+
if (event.kind === "compaction" && event.action === "failed") {
|
|
2371
|
+
const compactRun = ["compact", "cadence"].includes(run?.kind);
|
|
2372
|
+
const owned = compactRun && event.reason === "manual" && (event.inputID && event.inputID === run.inboxID || !event.inputID && run.compactionStarted);
|
|
2373
|
+
if (!run || compactRun && !owned) {
|
|
2374
|
+
delete scope.compaction;
|
|
2375
|
+
scope.busy = false;
|
|
2376
|
+
return result({ reason: "unowned-compaction-failed" });
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2163
2379
|
delete scope.compaction;
|
|
2164
2380
|
scope.busy = false;
|
|
2165
|
-
const
|
|
2166
|
-
const
|
|
2381
|
+
const detail = String(event.error || "").replace(/\s+/g, " ").trim().slice(0, 160);
|
|
2382
|
+
const reason = event.kind === "compaction" ? `compaction-failed${detail ? `: ${detail}` : ""}` : event.reason || `${event.kind}-${event.action}`;
|
|
2383
|
+
const paused = await pauseActive(scope, reason);
|
|
2167
2384
|
if (run && (run.delivered || ["compact", "cadence", "shell"].includes(run.kind))) {
|
|
2168
2385
|
const state = await read(scope);
|
|
2169
2386
|
const job = (state.jobs || []).find((entry) => entry.id === run.jobID);
|
|
@@ -2179,7 +2396,7 @@ ${await policy.buildPrompt(scope.directory, job)}`, delivery: "queue", metadata:
|
|
|
2179
2396
|
if (event.kind === "compaction" && event.action === "ended") {
|
|
2180
2397
|
scope.compaction ||= { ended: false, terminal: false };
|
|
2181
2398
|
scope.compaction.ended = true;
|
|
2182
|
-
if (["compact", "cadence"].includes(scope.active?.kind))
|
|
2399
|
+
if (["compact", "cadence"].includes(scope.active?.kind) && scope.active.compactionStarted && event.reason === "manual")
|
|
2183
2400
|
scope.active.compactFinished = true;
|
|
2184
2401
|
if (!scope.compaction.terminal)
|
|
2185
2402
|
return result({ reason: "awaiting-execution-terminal" });
|
|
@@ -2663,7 +2880,7 @@ function normalizeOpenCode2NativeEvent(raw) {
|
|
|
2663
2880
|
if (["session.compaction.started", "session.compaction.ended", "session.compaction.failed"].includes(type)) {
|
|
2664
2881
|
if (!sessionID)
|
|
2665
2882
|
return;
|
|
2666
|
-
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory });
|
|
2883
|
+
return Object.freeze({ kind: "compaction", action: type.split(".").at(-1), sessionID, directory, reason: text2(data.reason), inputID: text2(data.inputID), error: text2(record2(data.error)?.message) });
|
|
2667
2884
|
}
|
|
2668
2885
|
if (["session.execution.failed", "session.execution.interrupted"].includes(type)) {
|
|
2669
2886
|
if (!sessionID)
|
|
@@ -2964,7 +3181,7 @@ Presets: /loop-dev, /loop-testfix, /loop-progress, /loop-safe-dev, /loop-ask, /l
|
|
|
2964
3181
|
Controls: /loop-now, /loop-pause, /loop-resume, /loop-stop, /loop-remove, /loop-clear, /loop-status, /loop-export.
|
|
2965
3182
|
Preflight, postrun, notifications, stop files, completion markers, runtime/failure/run limits, branches and checkpoints are supported.
|
|
2966
3183
|
Scheduled shell commands run as bounded local child processes managed by Loop; OpenCode 2's ctx.shell surface is a hook API, not a shell-execution method.
|
|
2967
|
-
|
|
3184
|
+
Manual /loop-compact, --compact and --compact-every use public session.compact when the V2 host exposes it. Loop matches the admitted manual compaction inputID and waits for host completion; older hosts without this capability reject the job before creation. Automatic compaction stays host-owned.
|
|
2968
3185
|
An unfinished dedicated Goal reserves its session; Loop will not override it.
|
|
2969
3186
|
--timeout pauses future iterations without aborting the current native model/tool/compaction operation.
|
|
2970
3187
|
--safe is a command heuristic, not a sandbox. --dry-run does not dispatch or run hooks.
|
|
@@ -3010,7 +3227,7 @@ var OpenCodeLoopNativePlugin = Object.freeze({
|
|
|
3010
3227
|
prompt,
|
|
3011
3228
|
command: typeof ctx.session.command === "function" ? (request) => ctx.session.command(request) : undefined,
|
|
3012
3229
|
wait: typeof ctx.session.wait === "function" ? (request) => ctx.session.wait(request) : undefined,
|
|
3013
|
-
compact: undefined,
|
|
3230
|
+
compact: typeof ctx.session.compact === "function" ? (request) => ctx.session.compact(request) : undefined,
|
|
3014
3231
|
shell: (request) => shellHost.dispatch(request),
|
|
3015
3232
|
cancel: undefined,
|
|
3016
3233
|
onError: (error) => {
|