@bojackduy/opencode-loopd 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server.js +120 -50
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -332,6 +332,76 @@ function delay(ms) {
|
|
|
332
332
|
var CURRENT_VERSION = 2, LOCK_STALE_MS = 1e4;
|
|
333
333
|
var init_state_repository = () => {};
|
|
334
334
|
|
|
335
|
+
// src/domain/runtime.ts
|
|
336
|
+
var exports_runtime = {};
|
|
337
|
+
__export(exports_runtime, {
|
|
338
|
+
acquireLease: () => acquireLease,
|
|
339
|
+
createRuntimeState: () => createRuntimeState,
|
|
340
|
+
leaseIsValid: () => leaseIsValid,
|
|
341
|
+
markParentNotified: () => markParentNotified,
|
|
342
|
+
markProgress: () => markProgress,
|
|
343
|
+
releaseLease: () => releaseLease,
|
|
344
|
+
shouldNotifyParent: () => shouldNotifyParent
|
|
345
|
+
});
|
|
346
|
+
function createRuntimeState(goalID) {
|
|
347
|
+
const now = new Date().toISOString();
|
|
348
|
+
return {
|
|
349
|
+
goalID,
|
|
350
|
+
phase: "idle",
|
|
351
|
+
consecutiveFailures: 0,
|
|
352
|
+
runCount: 0,
|
|
353
|
+
turnCount: 0,
|
|
354
|
+
noProgressCount: 0,
|
|
355
|
+
progressDuringTurn: false,
|
|
356
|
+
createdAt: now,
|
|
357
|
+
updatedAt: now
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function acquireLease(rt, timeoutMs) {
|
|
361
|
+
const now = Date.now();
|
|
362
|
+
const expires = new Date(now + timeoutMs).toISOString();
|
|
363
|
+
return {
|
|
364
|
+
...rt,
|
|
365
|
+
phase: "running",
|
|
366
|
+
leaseExpiresAt: expires,
|
|
367
|
+
turnStartedAt: new Date(now).toISOString(),
|
|
368
|
+
progressDuringTurn: false,
|
|
369
|
+
turnTokensUsed: 0,
|
|
370
|
+
updatedAt: new Date(now).toISOString()
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function releaseLease(rt) {
|
|
374
|
+
return {
|
|
375
|
+
...rt,
|
|
376
|
+
phase: "idle",
|
|
377
|
+
leaseExpiresAt: undefined,
|
|
378
|
+
turnStartedAt: undefined,
|
|
379
|
+
updatedAt: new Date().toISOString()
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function leaseIsValid(rt) {
|
|
383
|
+
if (!rt.leaseExpiresAt)
|
|
384
|
+
return false;
|
|
385
|
+
return Date.now() < Date.parse(rt.leaseExpiresAt);
|
|
386
|
+
}
|
|
387
|
+
function markProgress(rt) {
|
|
388
|
+
return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
|
|
389
|
+
}
|
|
390
|
+
function shouldNotifyParent(runtime, type) {
|
|
391
|
+
if (!runtime.lastParentNotifiedAt || !runtime.lastParentNotifiedFor)
|
|
392
|
+
return true;
|
|
393
|
+
if (runtime.lastParentNotifiedFor !== type)
|
|
394
|
+
return true;
|
|
395
|
+
const elapsed = Date.now() - Date.parse(runtime.lastParentNotifiedAt);
|
|
396
|
+
return !Number.isFinite(elapsed) || elapsed > PARENT_NOTIFY_DEDUPE_MS;
|
|
397
|
+
}
|
|
398
|
+
function markParentNotified(runtime, type) {
|
|
399
|
+
runtime.lastParentNotifiedFor = type;
|
|
400
|
+
runtime.lastParentNotifiedAt = new Date().toISOString();
|
|
401
|
+
runtime.updatedAt = new Date().toISOString();
|
|
402
|
+
}
|
|
403
|
+
var PARENT_NOTIFY_DEDUPE_MS = 60000;
|
|
404
|
+
|
|
335
405
|
// src/application/control-worker.ts
|
|
336
406
|
init_state_repository();
|
|
337
407
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
@@ -707,52 +777,6 @@ function createGoal(input) {
|
|
|
707
777
|
return { ...input, tokensUsed: 0, timeUsedSeconds: 0, createdAt: now, updatedAt: now };
|
|
708
778
|
}
|
|
709
779
|
|
|
710
|
-
// src/domain/runtime.ts
|
|
711
|
-
function createRuntimeState(goalID) {
|
|
712
|
-
const now = new Date().toISOString();
|
|
713
|
-
return {
|
|
714
|
-
goalID,
|
|
715
|
-
phase: "idle",
|
|
716
|
-
consecutiveFailures: 0,
|
|
717
|
-
runCount: 0,
|
|
718
|
-
turnCount: 0,
|
|
719
|
-
noProgressCount: 0,
|
|
720
|
-
progressDuringTurn: false,
|
|
721
|
-
createdAt: now,
|
|
722
|
-
updatedAt: now
|
|
723
|
-
};
|
|
724
|
-
}
|
|
725
|
-
function acquireLease(rt, timeoutMs) {
|
|
726
|
-
const now = Date.now();
|
|
727
|
-
const expires = new Date(now + timeoutMs).toISOString();
|
|
728
|
-
return {
|
|
729
|
-
...rt,
|
|
730
|
-
phase: "running",
|
|
731
|
-
leaseExpiresAt: expires,
|
|
732
|
-
turnStartedAt: new Date(now).toISOString(),
|
|
733
|
-
progressDuringTurn: false,
|
|
734
|
-
turnTokensUsed: 0,
|
|
735
|
-
updatedAt: new Date(now).toISOString()
|
|
736
|
-
};
|
|
737
|
-
}
|
|
738
|
-
function releaseLease(rt) {
|
|
739
|
-
return {
|
|
740
|
-
...rt,
|
|
741
|
-
phase: "idle",
|
|
742
|
-
leaseExpiresAt: undefined,
|
|
743
|
-
turnStartedAt: undefined,
|
|
744
|
-
updatedAt: new Date().toISOString()
|
|
745
|
-
};
|
|
746
|
-
}
|
|
747
|
-
function leaseIsValid(rt) {
|
|
748
|
-
if (!rt.leaseExpiresAt)
|
|
749
|
-
return false;
|
|
750
|
-
return Date.now() < Date.parse(rt.leaseExpiresAt);
|
|
751
|
-
}
|
|
752
|
-
function markProgress(rt) {
|
|
753
|
-
return { ...rt, progressDuringTurn: true, lastProgressAt: new Date().toISOString() };
|
|
754
|
-
}
|
|
755
|
-
|
|
756
780
|
// src/application/loop-engine.ts
|
|
757
781
|
var HANDLED_EVENT_TYPES = new Set([
|
|
758
782
|
"session.idle",
|
|
@@ -768,6 +792,7 @@ function createLoopEngine(options) {
|
|
|
768
792
|
let knownWorkerSessions = new Set;
|
|
769
793
|
let knownWorkerSessionsLoaded = false;
|
|
770
794
|
const inflightContinuations = new Set;
|
|
795
|
+
const recentForceFinishBlocked = new Map;
|
|
771
796
|
async function loadWorkerSessionsIfneeded() {
|
|
772
797
|
if (knownWorkerSessionsLoaded)
|
|
773
798
|
return;
|
|
@@ -890,6 +915,12 @@ function createLoopEngine(options) {
|
|
|
890
915
|
await goalService.continueTurn(directory, goal.id, { forceFinish: true });
|
|
891
916
|
return true;
|
|
892
917
|
}
|
|
918
|
+
const blockedKey = goal.id;
|
|
919
|
+
const nowBlocked = Date.now();
|
|
920
|
+
const lastBlocked = recentForceFinishBlocked.get(blockedKey);
|
|
921
|
+
if (lastBlocked !== undefined && nowBlocked - lastBlocked < 60000)
|
|
922
|
+
return true;
|
|
923
|
+
recentForceFinishBlocked.set(blockedKey, nowBlocked);
|
|
893
924
|
goal.status = "blocked";
|
|
894
925
|
goal.updatedAt = new Date().toISOString();
|
|
895
926
|
goal.blocker = {
|
|
@@ -898,6 +929,9 @@ function createLoopEngine(options) {
|
|
|
898
929
|
at: new Date().toISOString()
|
|
899
930
|
};
|
|
900
931
|
runtime.forceFinishRequested = undefined;
|
|
932
|
+
const shouldNotify = shouldNotifyParent(runtime, "stopped");
|
|
933
|
+
if (shouldNotify)
|
|
934
|
+
markParentNotified(runtime, "stopped");
|
|
901
935
|
await writeState(directory, state);
|
|
902
936
|
await appendEvent(directory, {
|
|
903
937
|
version: 1,
|
|
@@ -909,7 +943,9 @@ function createLoopEngine(options) {
|
|
|
909
943
|
timestamp: new Date().toISOString(),
|
|
910
944
|
revision: state.revision
|
|
911
945
|
});
|
|
912
|
-
|
|
946
|
+
if (shouldNotify) {
|
|
947
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" stopped: ${limitResult.reason} (child did not wrap up). Status: blocked. Last progress: ${goal.lastProgress?.summary || "none"}.`);
|
|
948
|
+
}
|
|
913
949
|
return true;
|
|
914
950
|
}
|
|
915
951
|
if (limitResult.stop === "budget") {
|
|
@@ -990,7 +1026,10 @@ function createLoopEngine(options) {
|
|
|
990
1026
|
timestamp: new Date().toISOString(),
|
|
991
1027
|
revision: state.revision
|
|
992
1028
|
});
|
|
993
|
-
|
|
1029
|
+
if (shouldNotifyParent(runtime, "failed")) {
|
|
1030
|
+
markParentNotified(runtime, "failed");
|
|
1031
|
+
await host.notifyOwner(goal.ownerSessionID, `Loop goal "${goal.name}" blocked after ${runtime.consecutiveFailures} failures. Last error: ${message}.`);
|
|
1032
|
+
}
|
|
994
1033
|
} else {
|
|
995
1034
|
const backoffMs = Math.min(30000, 1000 * Math.pow(2, runtime.consecutiveFailures));
|
|
996
1035
|
runtime.retryAfter = new Date(Date.now() + backoffMs).toISOString();
|
|
@@ -1139,7 +1178,8 @@ function createWorkerManager(host) {
|
|
|
1139
1178
|
const prompt = buildContinuationSteering(goal, runtime, context);
|
|
1140
1179
|
await host.promptWorker({
|
|
1141
1180
|
sessionID: worker.workerSessionID,
|
|
1142
|
-
prompt
|
|
1181
|
+
prompt,
|
|
1182
|
+
agent: goal.config.agent
|
|
1143
1183
|
});
|
|
1144
1184
|
},
|
|
1145
1185
|
async isIdle(workerSessionID) {
|
|
@@ -1481,6 +1521,8 @@ function createGoalService(host) {
|
|
|
1481
1521
|
runtime.consecutiveFailures = 0;
|
|
1482
1522
|
runtime.lastError = undefined;
|
|
1483
1523
|
runtime.forceFinishRequested = undefined;
|
|
1524
|
+
runtime.lastParentNotifiedAt = undefined;
|
|
1525
|
+
runtime.lastParentNotifiedFor = undefined;
|
|
1484
1526
|
runtime.phase = "idle";
|
|
1485
1527
|
runtime.updatedAt = new Date().toISOString();
|
|
1486
1528
|
}
|
|
@@ -1583,6 +1625,21 @@ function createGoalService(host) {
|
|
|
1583
1625
|
}
|
|
1584
1626
|
|
|
1585
1627
|
// src/server/host-adapter.ts
|
|
1628
|
+
var recentParentNotifies = new Map;
|
|
1629
|
+
function shouldDedupParentNotify(ownerSessionID, message) {
|
|
1630
|
+
const key = `${ownerSessionID}:${message.slice(0, 200)}`;
|
|
1631
|
+
const now = Date.now();
|
|
1632
|
+
const last = recentParentNotifies.get(key);
|
|
1633
|
+
if (last !== undefined && now - last < 60000)
|
|
1634
|
+
return true;
|
|
1635
|
+
recentParentNotifies.set(key, now);
|
|
1636
|
+
if (recentParentNotifies.size > 200) {
|
|
1637
|
+
for (const [k, t] of recentParentNotifies.entries())
|
|
1638
|
+
if (now - t > 60000)
|
|
1639
|
+
recentParentNotifies.delete(k);
|
|
1640
|
+
}
|
|
1641
|
+
return false;
|
|
1642
|
+
}
|
|
1586
1643
|
function createRealHost(client, directory) {
|
|
1587
1644
|
return {
|
|
1588
1645
|
async createWorker({ parentID, title, agent }) {
|
|
@@ -1674,6 +1731,10 @@ function createRealHost(client, directory) {
|
|
|
1674
1731
|
} catch {}
|
|
1675
1732
|
},
|
|
1676
1733
|
async notifyOwner(ownerSessionID, message) {
|
|
1734
|
+
if (shouldDedupParentNotify(ownerSessionID, message)) {
|
|
1735
|
+
await logServerEvent(directory, "parent.notify.deduped", { ownerSessionID, preview: message.slice(0, 160) });
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1677
1738
|
try {
|
|
1678
1739
|
const result = await withTimeout(client.session.promptAsync({
|
|
1679
1740
|
path: { id: ownerSessionID },
|
|
@@ -2417,11 +2478,20 @@ var server = async ({ client, directory }) => {
|
|
|
2417
2478
|
const goalID = parsed.goalID;
|
|
2418
2479
|
if (!goalID)
|
|
2419
2480
|
return;
|
|
2420
|
-
const { readState: readState2 } = await Promise.resolve().then(() => (init_state_repository(), exports_state_repository));
|
|
2481
|
+
const { readState: readState2, writeState: writeState2 } = await Promise.resolve().then(() => (init_state_repository(), exports_state_repository));
|
|
2482
|
+
const { shouldNotifyParent: shouldNotifyParent2, markParentNotified: markParentNotified2 } = await Promise.resolve().then(() => exports_runtime);
|
|
2421
2483
|
const state = await readState2(directory);
|
|
2422
2484
|
const goal = state.goals.find((g) => g.id === goalID);
|
|
2423
2485
|
if (!goal)
|
|
2424
2486
|
return;
|
|
2487
|
+
const runtime = state.runtimes.find((r) => r.goalID === goalID);
|
|
2488
|
+
const notifyType = parsed.status === "complete" ? "complete" : "blocked";
|
|
2489
|
+
if (runtime && !shouldNotifyParent2(runtime, notifyType))
|
|
2490
|
+
return;
|
|
2491
|
+
if (runtime) {
|
|
2492
|
+
markParentNotified2(runtime, notifyType);
|
|
2493
|
+
await writeState2(directory, state);
|
|
2494
|
+
}
|
|
2425
2495
|
const message = parsed.status === "complete" ? `Loop goal "${goal.name}" completed: ${parsed.summary || ""}. Evidence: ${parsed.evidence || ""}. Artifacts: ${goal.config.artifactDir || "n/a"}.` : `Loop goal "${goal.name}" blocked: ${parsed.reason || ""}. Needed: ${parsed.needed || ""}.`;
|
|
2426
2496
|
await host.notifyOwner(goal.ownerSessionID, message);
|
|
2427
2497
|
} catch {}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@bojackduy/opencode-loopd",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.0",
|
|
5
5
|
"description": "Codex-inspired background goal engine for OpenCode — autonomous subagents, engine-driven loop, child worker sessions and modal TUI dashboard. Like Claude Code loop for OpenCode.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "AGPL-3.0-only",
|