@bojackduy/opencode-learn 1.4.4 → 1.4.5
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/dist/server.js +28 -43
- package/package.json +1 -1
- package/plugins/learn.ts +33 -27
package/dist/server.js
CHANGED
|
@@ -540,40 +540,6 @@ function randomId() {
|
|
|
540
540
|
} catch {}
|
|
541
541
|
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
542
542
|
}
|
|
543
|
-
function ownerLockPath(dir, id) {
|
|
544
|
-
return path.join(dir, `owner-${id}.lock`);
|
|
545
|
-
}
|
|
546
|
-
function acquireOwnerLock(dir, id) {
|
|
547
|
-
const p = ownerLockPath(dir, id);
|
|
548
|
-
const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
549
|
-
try {
|
|
550
|
-
fs.writeFileSync(p, payload, { flag: "wx" });
|
|
551
|
-
return true;
|
|
552
|
-
} catch {}
|
|
553
|
-
try {
|
|
554
|
-
const age = Date.now() - fs.statSync(p).mtimeMs;
|
|
555
|
-
if (age > 15000) {
|
|
556
|
-
fs.writeFileSync(p, payload, "utf8");
|
|
557
|
-
return true;
|
|
558
|
-
}
|
|
559
|
-
} catch {
|
|
560
|
-
try {
|
|
561
|
-
fs.writeFileSync(p, payload, { flag: "wx" });
|
|
562
|
-
return true;
|
|
563
|
-
} catch {}
|
|
564
|
-
}
|
|
565
|
-
return false;
|
|
566
|
-
}
|
|
567
|
-
function refreshOwnerLock(dir, id) {
|
|
568
|
-
try {
|
|
569
|
-
fs.writeFileSync(ownerLockPath(dir, id), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8");
|
|
570
|
-
} catch {}
|
|
571
|
-
}
|
|
572
|
-
function releaseOwnerLock(dir, id) {
|
|
573
|
-
try {
|
|
574
|
-
fs.unlinkSync(ownerLockPath(dir, id));
|
|
575
|
-
} catch {}
|
|
576
|
-
}
|
|
577
543
|
var PENDING_TTL_MS = 24 * 60 * 60 * 1000;
|
|
578
544
|
function isPendingExpired(j) {
|
|
579
545
|
try {
|
|
@@ -604,16 +570,15 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
604
570
|
}
|
|
605
571
|
activeWatchers.get(id)?.();
|
|
606
572
|
const dir = pendingDir(directory);
|
|
607
|
-
if (!acquireOwnerLock(dir, id)) {
|
|
608
|
-
slog("watchAndInject lock busy, another process owns this id", id);
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
573
|
const respPath = path.join(dir, `response-${id}.json`);
|
|
612
574
|
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
|
|
613
575
|
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
|
|
614
576
|
let watcher;
|
|
615
577
|
let pollTimer;
|
|
616
578
|
let closed = false;
|
|
579
|
+
let failCount = 0;
|
|
580
|
+
let nextAllowedAt = 0;
|
|
581
|
+
let lastSeenMtime = 0;
|
|
617
582
|
const closeWatcher = () => {
|
|
618
583
|
if (closed)
|
|
619
584
|
return;
|
|
@@ -625,10 +590,23 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
625
590
|
clearInterval(pollTimer);
|
|
626
591
|
if (activeWatchers.get(id) === closeWatcher)
|
|
627
592
|
activeWatchers.delete(id);
|
|
628
|
-
releaseOwnerLock(dir, id);
|
|
629
593
|
};
|
|
630
594
|
activeWatchers.set(id, closeWatcher);
|
|
631
595
|
const fire = async (attempt = 0) => {
|
|
596
|
+
if (closed)
|
|
597
|
+
return;
|
|
598
|
+
try {
|
|
599
|
+
const mtime = fs.statSync(respPath).mtimeMs;
|
|
600
|
+
if (mtime !== lastSeenMtime) {
|
|
601
|
+
lastSeenMtime = mtime;
|
|
602
|
+
failCount = 0;
|
|
603
|
+
nextAllowedAt = 0;
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (Date.now() < nextAllowedAt)
|
|
609
|
+
return;
|
|
632
610
|
try {
|
|
633
611
|
fs.renameSync(respPath, claimPath);
|
|
634
612
|
} catch {
|
|
@@ -710,8 +688,9 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
710
688
|
}
|
|
711
689
|
}
|
|
712
690
|
if (!ok) {
|
|
713
|
-
|
|
714
|
-
|
|
691
|
+
failCount++;
|
|
692
|
+
nextAllowedAt = Date.now() + Math.min(30000, 1000 * 2 ** Math.min(failCount, 5));
|
|
693
|
+
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, `retry in ~${Math.round((nextAllowedAt - Date.now()) / 1000)}s`, String(firstErr).slice(0, 200));
|
|
715
694
|
try {
|
|
716
695
|
fs.renameSync(claimPath, respPath);
|
|
717
696
|
} catch {
|
|
@@ -720,7 +699,7 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
720
699
|
} catch {}
|
|
721
700
|
}
|
|
722
701
|
try {
|
|
723
|
-
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
|
|
702
|
+
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID}), will retry`, extra: { id } } });
|
|
724
703
|
} catch {}
|
|
725
704
|
return;
|
|
726
705
|
}
|
|
@@ -744,7 +723,8 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
|
|
|
744
723
|
watcher.on("error", () => {});
|
|
745
724
|
} catch {}
|
|
746
725
|
pollTimer = setInterval(() => {
|
|
747
|
-
|
|
726
|
+
if (closed)
|
|
727
|
+
return;
|
|
748
728
|
if (fs.existsSync(respPath)) {
|
|
749
729
|
fire();
|
|
750
730
|
return;
|
|
@@ -1014,6 +994,11 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
1014
994
|
try {
|
|
1015
995
|
const dir = pendingDir(directory);
|
|
1016
996
|
if (fs.existsSync(dir)) {
|
|
997
|
+
for (const f of fs.readdirSync(dir).filter((x) => x.startsWith("owner-") && x.endsWith(".lock"))) {
|
|
998
|
+
try {
|
|
999
|
+
fs.unlinkSync(path.join(dir, f));
|
|
1000
|
+
} catch {}
|
|
1001
|
+
}
|
|
1017
1002
|
for (const f of fs.readdirSync(dir).filter((x) => x.includes(".claim-") && x.endsWith(".json"))) {
|
|
1018
1003
|
try {
|
|
1019
1004
|
const m = f.match(/^response-(.+)\.claim-.*\.json$/);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-learn",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.5",
|
|
5
5
|
"description": "Pi learn system for OpenCode — Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to OpenCode.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "AGPL-3.0-or-later",
|
package/plugins/learn.ts
CHANGED
|
@@ -496,26 +496,14 @@ async function waitForResponse(directory: string, id: string, abort: AbortSignal
|
|
|
496
496
|
})
|
|
497
497
|
}
|
|
498
498
|
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const payload = JSON.stringify({ pid: process.pid, at: Date.now() })
|
|
508
|
-
try { fs.writeFileSync(p, payload, { flag: "wx" }); return true } catch {}
|
|
509
|
-
try {
|
|
510
|
-
const age = Date.now() - fs.statSync(p).mtimeMs
|
|
511
|
-
if (age > 15000) { fs.writeFileSync(p, payload, "utf8"); return true }
|
|
512
|
-
} catch {
|
|
513
|
-
try { fs.writeFileSync(p, payload, { flag: "wx" }); return true } catch {}
|
|
514
|
-
}
|
|
515
|
-
return false
|
|
516
|
-
}
|
|
517
|
-
function refreshOwnerLock(dir: string, id: string) { try { fs.writeFileSync(ownerLockPath(dir, id), JSON.stringify({ pid: process.pid, at: Date.now() }), "utf8") } catch {} }
|
|
518
|
-
function releaseOwnerLock(dir: string, id: string) { try { fs.unlinkSync(ownerLockPath(dir, id)) } catch {} }
|
|
499
|
+
// Single-consumer arbitration lives ENTIRELY in fire()'s atomic claim rename
|
|
500
|
+
// (response-<id>.json -> response-<id>.claim-<pid>.json): exactly one process can win it,
|
|
501
|
+
// so every live process may supervise every pending id with no coordination. An earlier
|
|
502
|
+
// revision gated supervision behind an owner lock file; that created an abandonment race
|
|
503
|
+
// (startup loser bailed permanently, winner died mid-watch, inject failure closed the only
|
|
504
|
+
// watcher) that silently dropped answers after restarts. The lock is deliberately gone —
|
|
505
|
+
// fewer mechanisms, fewer races. TUI-side popup ownership (opening-<id>.lock) is unaffected:
|
|
506
|
+
// it prevents double-ANSWERS, a different mechanism from double-inject.
|
|
519
507
|
|
|
520
508
|
// Pending quizzes answered via the no-TUI fallback (native question / manual chat) leave a
|
|
521
509
|
// pending file nobody will ever answer through the popup. Without expiry these rot forever:
|
|
@@ -548,25 +536,34 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
548
536
|
if (!sessionID) { slog("watchAndInject no sessionID", id); return }
|
|
549
537
|
activeWatchers.get(id)?.()
|
|
550
538
|
const dir = pendingDir(directory)
|
|
551
|
-
if (!acquireOwnerLock(dir, id)) { slog("watchAndInject lock busy, another process owns this id", id); return }
|
|
552
539
|
const respPath = path.join(dir, `response-${id}.json`)
|
|
553
540
|
const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
|
|
554
541
|
const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
|
|
555
542
|
let watcher: fs.FSWatcher | undefined
|
|
556
543
|
let pollTimer: ReturnType<typeof setInterval> | undefined
|
|
557
544
|
let closed = false
|
|
545
|
+
// Consecutive inject-failure backoff: never hot-loop the SDK while preserving the answer.
|
|
546
|
+
// A rewritten response file (TUI watchdog re-touch) resets the streak for an early retry.
|
|
547
|
+
let failCount = 0
|
|
548
|
+
let nextAllowedAt = 0
|
|
549
|
+
let lastSeenMtime = 0
|
|
558
550
|
const closeWatcher = () => {
|
|
559
551
|
if (closed) return
|
|
560
552
|
closed = true
|
|
561
553
|
try { watcher?.close() } catch {}
|
|
562
554
|
if (pollTimer) clearInterval(pollTimer)
|
|
563
555
|
if (activeWatchers.get(id) === closeWatcher) activeWatchers.delete(id)
|
|
564
|
-
releaseOwnerLock(dir, id)
|
|
565
556
|
}
|
|
566
557
|
activeWatchers.set(id, closeWatcher)
|
|
567
558
|
const fire = async (attempt = 0): Promise<void> => {
|
|
559
|
+
if (closed) return
|
|
560
|
+
try {
|
|
561
|
+
const mtime = fs.statSync(respPath).mtimeMs
|
|
562
|
+
if (mtime !== lastSeenMtime) { lastSeenMtime = mtime; failCount = 0; nextAllowedAt = 0 }
|
|
563
|
+
} catch { return } // no answer yet
|
|
564
|
+
if (Date.now() < nextAllowedAt) return // backing off after inject failure; response stays put
|
|
568
565
|
// Atomic single-consumer claim: exactly one process proceeds (fixes double-inject across processes)
|
|
569
|
-
try { fs.renameSync(respPath, claimPath) } catch { return } //
|
|
566
|
+
try { fs.renameSync(respPath, claimPath) } catch { return } // another process claimed it first
|
|
570
567
|
let data: any
|
|
571
568
|
try {
|
|
572
569
|
data = JSON.parse(fs.readFileSync(claimPath, "utf8"))
|
|
@@ -625,13 +622,16 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
625
622
|
try { await sdkCall(client.session.prompt.bind(client.session), ...shapes); ok = true } catch (e) { firstErr = firstErr || e }
|
|
626
623
|
}
|
|
627
624
|
if (!ok) {
|
|
628
|
-
|
|
629
|
-
|
|
625
|
+
// NEVER abandon: keep supervising so a later retry (or another live process) delivers.
|
|
626
|
+
// Closing the watcher here used to strand the preserved response with nobody watching.
|
|
627
|
+
failCount++
|
|
628
|
+
nextAllowedAt = Date.now() + Math.min(30000, 1000 * 2 ** Math.min(failCount, 5))
|
|
629
|
+
slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, `retry in ~${Math.round((nextAllowedAt - Date.now()) / 1000)}s`, String(firstErr).slice(0,200))
|
|
630
630
|
try { fs.renameSync(claimPath, respPath) } catch {
|
|
631
631
|
try { fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`)) } catch {}
|
|
632
632
|
}
|
|
633
633
|
try {
|
|
634
|
-
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
|
|
634
|
+
await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID}), will retry`, extra: { id } } })
|
|
635
635
|
} catch {}
|
|
636
636
|
return
|
|
637
637
|
}
|
|
@@ -654,7 +654,7 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
|
|
|
654
654
|
// fs.watch is lossy by design. Polling keeps persisted answers moving after a TUI/server restart
|
|
655
655
|
// even when the filesystem event is dropped.
|
|
656
656
|
pollTimer = setInterval(() => {
|
|
657
|
-
|
|
657
|
+
if (closed) return
|
|
658
658
|
if (fs.existsSync(respPath)) { void fire(); return }
|
|
659
659
|
if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath)) closeWatcher()
|
|
660
660
|
}, 500)
|
|
@@ -917,6 +917,12 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
|
|
|
917
917
|
try {
|
|
918
918
|
const dir = pendingDir(directory)
|
|
919
919
|
if (fs.existsSync(dir)) {
|
|
920
|
+
// 0) Retired mechanism cleanup: owner-*.lock files belong to the removed owner-lock
|
|
921
|
+
// gate (single-consumer arbitration is the atomic claim rename now). Delete them so a
|
|
922
|
+
// dead owner's leftovers can never confuse future logic.
|
|
923
|
+
for (const f of fs.readdirSync(dir).filter(x => x.startsWith("owner-") && x.endsWith(".lock"))) {
|
|
924
|
+
try { fs.unlinkSync(path.join(dir, f)) } catch {}
|
|
925
|
+
}
|
|
920
926
|
// 1) Requeue stale claims from consumers that died mid-flight (>30s old, no live response)
|
|
921
927
|
for (const f of fs.readdirSync(dir).filter(x => x.includes(".claim-") && x.endsWith(".json"))) {
|
|
922
928
|
try {
|