oh-my-opencode 5.0.0-beta.60 → 5.0.0-beta.63
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/THIRD-PARTY-NOTICES.md +8 -6
- package/dist/cli/doctor/checks/browser-provider.d.ts +2 -0
- package/dist/cli/index.js +465 -348
- package/dist/cli-node/index.js +465 -348
- package/dist/config/schema/agent-names.d.ts +0 -1
- package/dist/config/schema/browser-automation.d.ts +0 -2
- package/dist/config/schema/oh-my-opencode-config.d.ts +0 -1
- package/dist/hooks/comment-checker/__fixtures__/download-probe.fixture.d.ts +1 -0
- package/dist/index.js +61 -535
- package/dist/oh-my-opencode.schema.json +0 -1
- package/dist/skills/ast-grep/SKILL.md +12 -0
- package/dist/skills/debugging/references/tools/playwright-cli.md +79 -161
- package/dist/skills/frontend/SKILL.md +1 -1
- package/dist/skills/frontend/references/design/aside.md +1 -1
- package/dist/skills/frontend/references/design/clone-from-url.md +1 -1
- package/dist/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
- package/dist/skills/review-work/SKILL.md +1 -1
- package/dist/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
- package/dist/skills/ultimate-browsing/SKILL.md +17 -29
- package/dist/skills/ultimate-browsing/engine/AGENTS.md +2 -1
- package/dist/skills/ultimate-browsing/engine/templates/package.json +2 -2
- package/dist/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
- package/dist/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
- package/dist/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
- package/dist/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
- package/dist/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
- package/dist/skills/ultimate-browsing/references/insane-search/README.md +9 -3
- package/dist/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
- package/dist/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
- package/dist/skills/ulw-execute/SKILL.md +1 -1
- package/dist/skills/ulw-plan/scripts/scaffold-plan.mjs +1 -1
- package/dist/skills/ulw-research/SKILL.md +3 -3
- package/dist/skills/visual-qa/AGENTS.md +1 -1
- package/dist/skills/visual-qa/SKILL.md +1 -1
- package/dist/skills/visual-qa/references/browser-setup.md +75 -0
- package/dist/tui.js +12 -5
- package/package.json +16 -23
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +6887 -249
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/src/setup.ts +7 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/agents/plan.toml +2 -2
- package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +25 -15
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +15 -0
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +25 -15
- package/packages/omo-codex/plugin/components/ulw-loop/dist/checkpoint.js +5 -4
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +640 -327
- package/packages/omo-codex/plugin/components/ulw-loop/dist/domain-types.d.ts +5 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/evidence.js +16 -12
- package/packages/omo-codex/plugin/components/ulw-loop/dist/ledger.d.ts +4 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/ledger.js +34 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-commit.d.ts +19 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-commit.js +107 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.d.ts +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.js +48 -22
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +9 -7
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +105 -114
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-log.d.ts +15 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-log.js +86 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.d.ts +5 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.js +15 -3
- package/packages/omo-codex/plugin/components/ulw-loop/dist/review-blockers.js +3 -4
- package/packages/omo-codex/plugin/components/ulw-loop/dist/sdk/factory.js +29 -6
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-budget-io.d.ts +5 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-budget-io.js +67 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.d.ts +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +29 -66
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.d.ts +2 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-role-guard.js +22 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.d.ts +11 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.js +120 -88
- package/packages/omo-codex/plugin/components/ulw-loop/dist/status-next-actions.d.ts +3 -2
- package/packages/omo-codex/plugin/components/ulw-loop/dist/status-next-actions.js +18 -10
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-batch.js +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +8 -17
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +7 -7
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +2 -2
- package/packages/omo-codex/plugin/components/ulw-loop/src/checkpoint.ts +5 -4
- package/packages/omo-codex/plugin/components/ulw-loop/src/domain-types.ts +5 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/evidence.ts +16 -16
- package/packages/omo-codex/plugin/components/ulw-loop/src/ledger.ts +33 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-commit.ts +124 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts +58 -51
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +126 -140
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-log.ts +84 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-missing-recovery.ts +26 -3
- package/packages/omo-codex/plugin/components/ulw-loop/src/review-blockers.ts +3 -3
- package/packages/omo-codex/plugin/components/ulw-loop/src/sdk/factory.ts +55 -6
- package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-budget-io.ts +59 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +29 -62
- package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-role-guard.ts +22 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/state-lock.ts +129 -111
- package/packages/omo-codex/plugin/components/ulw-loop/src/status-next-actions.ts +19 -10
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering-batch.ts +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +5 -4
- package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +8 -16
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/barrier.ts +13 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-child.ts +65 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-cli-qa.mjs +76 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-crash.ts +23 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-mutations.mjs +58 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/commit-writer.ts +65 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/lease-clock.ts +23 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-commit-log.test.ts +252 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-commit-recovery.test.ts +190 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +50 -35
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-log-newest.test.ts +72 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-completed-plan.test.ts +43 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-driver-states.test.ts +145 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/sdk-session-context.test.ts +21 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +7 -1
- package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-role-matrix.test.ts +113 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/state-lock.test.ts +92 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/status-next-actions-surface.test.ts +133 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/steering-batch.test.ts +12 -12
- package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +4 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.d.mts +4 -0
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +18 -10
- package/packages/omo-codex/plugin/skills/ast-grep/SKILL.md +12 -0
- package/packages/omo-codex/plugin/skills/debugging/references/tools/playwright-cli.md +79 -161
- package/packages/omo-codex/plugin/skills/frontend/SKILL.md +1 -1
- package/packages/omo-codex/plugin/skills/frontend/references/design/aside.md +1 -1
- package/packages/omo-codex/plugin/skills/frontend/references/design/clone-from-url.md +1 -1
- package/packages/omo-codex/plugin/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
- package/packages/omo-codex/plugin/skills/init-deep/SKILL.md +3 -3
- package/packages/omo-codex/plugin/skills/refactor/SKILL.md +3 -3
- package/packages/omo-codex/plugin/skills/remove-ai-slops/SKILL.md +3 -3
- package/packages/omo-codex/plugin/skills/review-work/SKILL.md +4 -4
- package/packages/omo-codex/plugin/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
- package/packages/omo-codex/plugin/skills/ultimate-browsing/SKILL.md +17 -29
- package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/AGENTS.md +2 -1
- package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/package.json +2 -2
- package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
- package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
- package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
- package/packages/omo-codex/plugin/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
- package/packages/omo-codex/plugin/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
- package/packages/omo-codex/plugin/skills/ultimate-browsing/references/insane-search/README.md +9 -3
- package/packages/omo-codex/plugin/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
- package/packages/omo-codex/plugin/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +25 -15
- package/packages/omo-codex/plugin/skills/ulw-execute/SKILL.md +4 -4
- package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +3 -3
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +2 -2
- package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +6 -6
- package/packages/omo-codex/plugin/skills/visual-qa/AGENTS.md +1 -1
- package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +4 -4
- package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +75 -0
- package/packages/omo-codex/plugin/test/bootstrap-default-role.test.mjs +66 -0
- package/packages/omo-codex/plugin/test/component-hook-contract-cases.mjs +13 -1
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +7060 -417
- package/packages/prompts-core/prompts/ultrawork/codex.md +25 -15
- package/packages/shared-skills/skills/ast-grep/SKILL.md +12 -0
- package/packages/shared-skills/skills/debugging/references/tools/playwright-cli.md +79 -161
- package/packages/shared-skills/skills/frontend/SKILL.md +1 -1
- package/packages/shared-skills/skills/frontend/references/design/aside.md +1 -1
- package/packages/shared-skills/skills/frontend/references/design/clone-from-url.md +1 -1
- package/packages/shared-skills/skills/frontend/scripts/perfection/lighthouse-audit.py +2 -2
- package/packages/shared-skills/skills/review-work/SKILL.md +1 -1
- package/packages/shared-skills/skills/ultimate-browsing/ATTRIBUTION.md +1 -82
- package/packages/shared-skills/skills/ultimate-browsing/SKILL.md +17 -29
- package/packages/shared-skills/skills/ultimate-browsing/engine/AGENTS.md +2 -1
- package/packages/shared-skills/skills/ultimate-browsing/engine/templates/package.json +2 -2
- package/packages/shared-skills/skills/ultimate-browsing/engine/templates/playwright_mobile_chrome.js +5 -2
- package/packages/shared-skills/skills/ultimate-browsing/engine/templates/playwright_real_chrome.js +8 -6
- package/packages/shared-skills/skills/ultimate-browsing/engine/tests/test_playwright_stealth.py +86 -0
- package/packages/shared-skills/skills/ultimate-browsing/engine/tests/test_playwright_templates.py +4 -7
- package/packages/shared-skills/skills/ultimate-browsing/references/chrome-stealth.md +86 -100
- package/packages/shared-skills/skills/ultimate-browsing/references/insane-search/README.md +9 -3
- package/packages/shared-skills/skills/ultimate-browsing/references/insane-search/playwright.md +16 -13
- package/packages/shared-skills/skills/ultimate-browsing/scripts/extract_cookies.py +3 -3
- package/packages/shared-skills/skills/ulw-execute/SKILL.md +1 -1
- package/packages/shared-skills/skills/ulw-plan/scripts/scaffold-plan.mjs +1 -1
- package/packages/shared-skills/skills/ulw-research/SKILL.md +3 -3
- package/packages/shared-skills/skills/visual-qa/AGENTS.md +1 -1
- package/packages/shared-skills/skills/visual-qa/SKILL.md +1 -1
- package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +75 -0
- package/dist/skills/visual-qa/references/agent-browser-setup.md +0 -45
- package/packages/omo-codex/plugin/skills/visual-qa/references/agent-browser-setup.md +0 -45
- package/packages/shared-skills/skills/visual-qa/references/agent-browser-setup.md +0 -45
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function readAdmissionBreaker(sessionId: string): string | null {
|
|
6
|
+
const dataDir = process.env["PLUGIN_DATA"];
|
|
7
|
+
if (typeof dataDir !== "string") return null;
|
|
8
|
+
try {
|
|
9
|
+
const value: unknown = JSON.parse(readFileSync(join(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8"));
|
|
10
|
+
return typeof value === "object" && value !== null && "reason" in value && typeof value.reason === "string"
|
|
11
|
+
? value.reason
|
|
12
|
+
: "capacity limit";
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// These hook budget files are not plan/audit state and remain outside the log.
|
|
19
|
+
export function atomicWriteJson(targetPath: string, data: unknown): void {
|
|
20
|
+
const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
|
|
21
|
+
writeFileSync(tmp, JSON.stringify(data));
|
|
22
|
+
renameSync(tmp, targetPath);
|
|
23
|
+
}
|
|
24
|
+
export function isNonEmptyFile(path: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
return existsSync(path) && statSync(path).size > 0;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error instanceof Error) return false;
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function readCount(counterPath: string): number {
|
|
33
|
+
try {
|
|
34
|
+
const parsed: unknown = JSON.parse(readFileSync(counterPath, "utf8"));
|
|
35
|
+
return typeof parsed === "object" &&
|
|
36
|
+
parsed !== null &&
|
|
37
|
+
"count" in parsed &&
|
|
38
|
+
typeof parsed.count === "number" &&
|
|
39
|
+
parsed.count >= 0
|
|
40
|
+
? parsed.count
|
|
41
|
+
: 0;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error instanceof Error) return 0;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function readCounts(counterPath: string): Record<string, number> {
|
|
48
|
+
try {
|
|
49
|
+
const parsed: unknown = JSON.parse(readFileSync(counterPath, "utf8"));
|
|
50
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
51
|
+
const counts: Record<string, number> = {};
|
|
52
|
+
for (const [key, value] of Object.entries(parsed))
|
|
53
|
+
if (typeof value === "number" && value >= 0) counts[key] = value;
|
|
54
|
+
return counts;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof Error) return {};
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
|
|
5
4
|
import type { PreToolUsePayload } from "./codex-hook.js";
|
|
6
5
|
import { parsePreToolUsePayload } from "./codex-hook.js";
|
|
7
6
|
import { isFinalRunCompletionCandidate } from "./goal-status.js";
|
|
8
7
|
import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
|
|
8
|
+
import { readUlwLoopPlanSync } from "./plan-io.js";
|
|
9
|
+
import { atomicWriteJson, isNonEmptyFile, readAdmissionBreaker, readCount, readCounts } from "./spawn-budget-io.js";
|
|
10
|
+
import { spawnRoleDenial } from "./spawn-role-guard.js";
|
|
9
11
|
import { isStateLockTimeout, type StateLockOptions, withStateLockSync } from "./state-lock.js";
|
|
10
12
|
import {
|
|
11
13
|
GATE_REVIEWER_AGENT_NAMES,
|
|
@@ -18,7 +20,12 @@ import type { UlwLoopPlan } from "./types.js";
|
|
|
18
20
|
// spawn_agent = v1; collaborationspawn_agent = the delimiter-free flattened v2
|
|
19
21
|
// hook token from codex-rs hook_names.rs; collaboration.spawn_agent = the
|
|
20
22
|
// dotted token observed live in the task-1 probe (hook-tool-tokens.txt).
|
|
21
|
-
const SPAWN_TOOL_TOKENS = new Set([
|
|
23
|
+
const SPAWN_TOOL_TOKENS = new Set([
|
|
24
|
+
"spawn_agent",
|
|
25
|
+
"multi_agent_v1.spawn_agent",
|
|
26
|
+
"collaborationspawn_agent",
|
|
27
|
+
"collaboration.spawn_agent",
|
|
28
|
+
]);
|
|
22
29
|
export const DEFAULT_FANOUT_LIMIT = 24;
|
|
23
30
|
const DEFAULT_REVIEW_SPAWN_LIMIT = 3;
|
|
24
31
|
const GATE_MESSAGE_PATTERN = /lazycodex-gate-reviewer|omo-senpi-gate-reviewer|final gate review/i;
|
|
@@ -34,6 +41,15 @@ export interface SpawnGuardOptions {
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
|
|
44
|
+
if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
|
|
45
|
+
if (resolveToolkitSurface() === "lazycodex") {
|
|
46
|
+
const reason = spawnRoleDenial(payload.tool_input);
|
|
47
|
+
if (reason !== null) return deny(reason);
|
|
48
|
+
}
|
|
49
|
+
return applySpawnBudgetGuards(payload, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function applySpawnBudgetGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
|
|
37
53
|
if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
|
|
38
54
|
const breaker = readAdmissionBreaker(payload.session_id);
|
|
39
55
|
if (breaker !== null)
|
|
@@ -42,7 +58,7 @@ export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuard
|
|
|
42
58
|
);
|
|
43
59
|
const scope = { sessionId: payload.session_id } as const;
|
|
44
60
|
const stateDir = ulwLoopDir(payload.cwd, scope);
|
|
45
|
-
const plan = readPlan(
|
|
61
|
+
const plan = readPlan(payload.cwd, payload.session_id);
|
|
46
62
|
if (plan === null) return "";
|
|
47
63
|
const lockOptions: StateLockOptions =
|
|
48
64
|
options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs };
|
|
@@ -110,11 +126,14 @@ export async function runSpawnGuardCli(stdin: NodeJS.ReadableStream, stdout: Nod
|
|
|
110
126
|
const chunks: Buffer[] = [];
|
|
111
127
|
for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
|
|
112
128
|
const payload = parsePreToolUsePayload(Buffer.concat(chunks).toString("utf8"));
|
|
113
|
-
if (payload === null)
|
|
129
|
+
if (payload === null) {
|
|
130
|
+
stdout.write(deny("LazyCodex spawn guard received an invalid hook payload; role routing was not verified."));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
114
133
|
const output = applySpawnGuards(payload);
|
|
115
134
|
if (output.length > 0) stdout.write(output);
|
|
116
135
|
} catch (error) {
|
|
117
|
-
|
|
136
|
+
stdout.write(deny(`LazyCodex spawn guard failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
|
|
@@ -130,6 +149,7 @@ function peekFanOutBudget(stateDir: string): string | null {
|
|
|
130
149
|
return `ulw-loop spawn fan-out cap reached (${count}/${limit}). Consolidate work into the agents already running, or raise OMO_SPAWN_FANOUT_LIMIT if this volume is intentional.`;
|
|
131
150
|
}
|
|
132
151
|
|
|
152
|
+
// Hook budget counters are exempt from plan/audit commits.
|
|
133
153
|
// Per-session spawn counter; depth/lineage tracking is descoped — this is a
|
|
134
154
|
// total-volume backstop against fan-out explosions, not a recursion tracker.
|
|
135
155
|
function consumeFanOutBudget(stateDir: string): string | null {
|
|
@@ -227,12 +247,6 @@ function activeSurfaceReviewerAlias(reviewer: string): string {
|
|
|
227
247
|
return reviewer;
|
|
228
248
|
}
|
|
229
249
|
|
|
230
|
-
function atomicWriteJson(targetPath: string, data: unknown): void {
|
|
231
|
-
const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
|
|
232
|
-
writeFileSync(tmp, JSON.stringify(data));
|
|
233
|
-
renameSync(tmp, targetPath);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
250
|
function deny(reason: string): string {
|
|
237
251
|
return `${JSON.stringify({
|
|
238
252
|
hookSpecificOutput: {
|
|
@@ -244,19 +258,6 @@ function deny(reason: string): string {
|
|
|
244
258
|
})}\n`;
|
|
245
259
|
}
|
|
246
260
|
|
|
247
|
-
function readAdmissionBreaker(sessionId: string): string | null {
|
|
248
|
-
const dataDir = process.env["PLUGIN_DATA"];
|
|
249
|
-
if (typeof dataDir !== "string") return null;
|
|
250
|
-
try {
|
|
251
|
-
const value = JSON.parse(readFileSync(join(dataDir, "spawn-breaker", `${sessionId}.json`), "utf8")) as {
|
|
252
|
-
reason?: unknown;
|
|
253
|
-
};
|
|
254
|
-
return typeof value.reason === "string" ? value.reason : "capacity limit";
|
|
255
|
-
} catch {
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
261
|
function fanOutLimit(): number {
|
|
261
262
|
const raw = process.env["OMO_SPAWN_FANOUT_LIMIT"];
|
|
262
263
|
if (raw === undefined) return DEFAULT_FANOUT_LIMIT;
|
|
@@ -271,43 +272,9 @@ function reviewSpawnLimit(): number {
|
|
|
271
272
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_REVIEW_SPAWN_LIMIT;
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
function
|
|
275
|
-
try {
|
|
276
|
-
return existsSync(path) && statSync(path).size > 0;
|
|
277
|
-
} catch (error) {
|
|
278
|
-
if (error instanceof Error) return false;
|
|
279
|
-
throw error;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function readCount(counterPath: string): number {
|
|
284
|
-
try {
|
|
285
|
-
const parsed = JSON.parse(readFileSync(counterPath, "utf8")) as Record<string, unknown>;
|
|
286
|
-
return typeof parsed["count"] === "number" && parsed["count"] >= 0 ? parsed["count"] : 0;
|
|
287
|
-
} catch (error) {
|
|
288
|
-
if (error instanceof Error) return 0;
|
|
289
|
-
throw error;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function readCounts(counterPath: string): Record<string, number> {
|
|
294
|
-
try {
|
|
295
|
-
const parsed: unknown = JSON.parse(readFileSync(counterPath, "utf8"));
|
|
296
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
297
|
-
const counts: Record<string, number> = {};
|
|
298
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
299
|
-
if (typeof value === "number" && value >= 0) counts[key] = value;
|
|
300
|
-
}
|
|
301
|
-
return counts;
|
|
302
|
-
} catch (error) {
|
|
303
|
-
if (error instanceof Error) return {};
|
|
304
|
-
throw error;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function readPlan(goalsPath: string): UlwLoopPlan | null {
|
|
275
|
+
function readPlan(repoRoot: string, sessionId: string): UlwLoopPlan | null {
|
|
309
276
|
try {
|
|
310
|
-
return
|
|
277
|
+
return readUlwLoopPlanSync(repoRoot, { sessionId });
|
|
311
278
|
} catch (error) {
|
|
312
279
|
if (error instanceof Error) return null;
|
|
313
280
|
throw error;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Explicit selectors, pinned by a test to the bundled role TOMLs. The generated
|
|
2
|
+
// default is a non-fork fallback, not a selector: a user can opt out of owning it.
|
|
3
|
+
export const LAZYCODEX_SPAWN_ROLES: ReadonlySet<string> = new Set([
|
|
4
|
+
"explorer",
|
|
5
|
+
"lazycodex-clone-fidelity-reviewer",
|
|
6
|
+
"lazycodex-code-reviewer",
|
|
7
|
+
"lazycodex-gate-reviewer",
|
|
8
|
+
"lazycodex-qa-executor",
|
|
9
|
+
"lazycodex-worker-high",
|
|
10
|
+
"lazycodex-worker-low",
|
|
11
|
+
"lazycodex-worker-medium",
|
|
12
|
+
"librarian",
|
|
13
|
+
"metis",
|
|
14
|
+
"momus",
|
|
15
|
+
"plan",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export function spawnRoleDenial(input: unknown): string | null {
|
|
19
|
+
const role = typeof input === "object" && input !== null && "agent_type" in input ? input.agent_type : undefined;
|
|
20
|
+
if (typeof role === "string" && LAZYCODEX_SPAWN_ROLES.has(role)) return null;
|
|
21
|
+
return `LazyCodex requires an explicit registered agent_type: ${[...LAZYCODEX_SPAWN_ROLES].join(", ")}. Received ${JSON.stringify(role) ?? "no agent_type"}. Use the matching role and fork_turns: "none" (V2) or fork_context: false (V1), unless full history is deliberately required. The hook cannot see the tool schema; if agent_type is unavailable, stop and report incompatible role routing rather than spawning a generic agent. Describing a role in message does not select its TOML.`;
|
|
22
|
+
}
|
|
@@ -1,120 +1,159 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { closeSync, ftruncateSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
-
|
|
6
4
|
import { UlwLoopError } from "./types.js";
|
|
7
5
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// owner (pid + a per-acquisition token). A waiter reclaims it only when the
|
|
15
|
-
// owner is provably gone (pid dead) or the body never became a record within
|
|
16
|
-
// `staleMs` (a creator that died mid-write); a live owner is never reclaimed by
|
|
17
|
-
// age alone, because overlapping two bodies is exactly the lost update this lock
|
|
18
|
-
// exists to prevent. Waiters back off and fail closed at `timeoutMs`. Release
|
|
19
|
-
// unlinks only a lock that still carries the releaser's own token.
|
|
20
|
-
|
|
6
|
+
// O_EXCL establishes ownership. Async holders keep the fd open and refresh a
|
|
7
|
+
// lease through that fd, never through the pathname: a reclaimed inode is invisible.
|
|
8
|
+
// Sync/legacy holders are lease-less and only dead pids retire their records.
|
|
9
|
+
// Expiry permits overlapping bodies; immutable revision publication fences them.
|
|
10
|
+
// Reclaim's reread/unlink is not atomic. On Windows an open inode may not unlink:
|
|
11
|
+
// treat that refusal as a live owner and fail closed. Release checks its token.
|
|
21
12
|
export const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
|
|
22
|
-
|
|
13
|
+
export interface StateLockClock {
|
|
14
|
+
readonly now: () => number;
|
|
15
|
+
readonly schedule: (fn: () => void, ms: number) => { unref(): void; cancel(): void };
|
|
16
|
+
}
|
|
23
17
|
export interface StateLockOptions {
|
|
24
18
|
readonly timeoutMs?: number;
|
|
25
19
|
readonly staleMs?: number;
|
|
20
|
+
readonly leaseMs?: number;
|
|
21
|
+
readonly heartbeatMs?: number;
|
|
22
|
+
readonly clock?: StateLockClock;
|
|
26
23
|
}
|
|
27
|
-
|
|
28
24
|
interface LockRecord {
|
|
29
25
|
readonly pid: number;
|
|
30
26
|
readonly createdAt: string;
|
|
31
27
|
readonly token: string;
|
|
28
|
+
leaseUntil?: number;
|
|
29
|
+
}
|
|
30
|
+
interface Holder {
|
|
31
|
+
readonly fd: number;
|
|
32
|
+
readonly record: LockRecord;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
34
|
interface LockSnapshot {
|
|
35
35
|
readonly raw: string;
|
|
36
36
|
readonly record: LockRecord | null;
|
|
37
37
|
readonly ageMs: number;
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
type AttemptOutcome = { readonly kind: "acquired"; readonly token: string } | { readonly kind: "retry" | "wait" };
|
|
41
|
-
|
|
39
|
+
type AttemptOutcome = { readonly kind: "acquired"; readonly holder: Holder } | { readonly kind: "retry" | "wait" };
|
|
42
40
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
43
41
|
const DEFAULT_STALE_MS = 60_000;
|
|
44
|
-
const
|
|
45
|
-
const MAX_DELAY_MS = 100;
|
|
42
|
+
const DEFAULT_LEASE_MS = 30_000;
|
|
46
43
|
const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
44
|
+
const systemClock: StateLockClock = {
|
|
45
|
+
now: Date.now,
|
|
46
|
+
schedule(fn, ms) {
|
|
47
|
+
const timer = setTimeout(fn, ms);
|
|
48
|
+
return {
|
|
49
|
+
unref: () => {
|
|
50
|
+
timer.unref();
|
|
51
|
+
},
|
|
52
|
+
cancel: () => clearTimeout(timer),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
47
56
|
|
|
48
57
|
export async function withStateLock<T>(
|
|
49
58
|
lockPath: string,
|
|
50
|
-
fn: () => Promise<T>,
|
|
59
|
+
fn: (token: string) => Promise<T>,
|
|
51
60
|
options: StateLockOptions = {},
|
|
52
61
|
): Promise<T> {
|
|
53
|
-
const
|
|
62
|
+
const holder = await acquireAsync(lockPath, options);
|
|
63
|
+
const clock = options.clock ?? systemClock;
|
|
64
|
+
let timer: ReturnType<StateLockClock["schedule"]> | undefined;
|
|
65
|
+
let heartbeatError: unknown;
|
|
66
|
+
const beat = () => {
|
|
67
|
+
try {
|
|
68
|
+
holder.record.leaseUntil = clock.now() + (options.leaseMs ?? DEFAULT_LEASE_MS);
|
|
69
|
+
writeRecord(holder);
|
|
70
|
+
schedule();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
heartbeatError = error;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const schedule = () => {
|
|
76
|
+
if (options.heartbeatMs === 0) return;
|
|
77
|
+
timer = clock.schedule(beat, options.heartbeatMs ?? (options.leaseMs ?? DEFAULT_LEASE_MS) / 3);
|
|
78
|
+
timer.unref();
|
|
79
|
+
};
|
|
80
|
+
schedule();
|
|
54
81
|
try {
|
|
55
|
-
|
|
82
|
+
const result = await fn(holder.record.token);
|
|
83
|
+
if (heartbeatError !== undefined) throw heartbeatError;
|
|
84
|
+
return result;
|
|
56
85
|
} finally {
|
|
57
|
-
|
|
86
|
+
timer?.cancel();
|
|
87
|
+
closeSync(holder.fd);
|
|
88
|
+
release(lockPath, holder.record.token);
|
|
58
89
|
}
|
|
59
90
|
}
|
|
60
|
-
|
|
61
91
|
export function withStateLockSync<T>(lockPath: string, fn: () => T, options: StateLockOptions = {}): T {
|
|
62
|
-
const
|
|
92
|
+
const holder = acquireSync(lockPath, options);
|
|
63
93
|
try {
|
|
64
94
|
return fn();
|
|
65
95
|
} finally {
|
|
66
|
-
|
|
96
|
+
closeSync(holder.fd);
|
|
97
|
+
release(lockPath, holder.record.token);
|
|
67
98
|
}
|
|
68
99
|
}
|
|
69
|
-
|
|
70
100
|
export function isStateLockTimeout(error: unknown): error is UlwLoopError {
|
|
71
101
|
return error instanceof UlwLoopError && error.code === ULW_LOOP_LOCK_TIMEOUT_CODE;
|
|
72
102
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const deadline =
|
|
76
|
-
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
103
|
+
async function acquireAsync(lockPath: string, options: StateLockOptions): Promise<Holder> {
|
|
104
|
+
const clock = options.clock ?? systemClock;
|
|
105
|
+
const deadline = clock.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
77
106
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
78
107
|
for (let attempt = 0; ; ) {
|
|
79
|
-
const outcome = attemptOnce(lockPath,
|
|
80
|
-
if (outcome.kind === "acquired") return outcome.
|
|
108
|
+
const outcome = attemptOnce(lockPath, options, clock.now(), options.leaseMs ?? DEFAULT_LEASE_MS);
|
|
109
|
+
if (outcome.kind === "acquired") return outcome.holder;
|
|
81
110
|
if (outcome.kind === "retry") continue;
|
|
82
|
-
if (
|
|
83
|
-
await
|
|
111
|
+
if (clock.now() >= deadline) throw lockTimeout(lockPath, options);
|
|
112
|
+
await new Promise<void>((resolve) => clock.schedule(resolve, backoffMs(attempt)));
|
|
84
113
|
attempt += 1;
|
|
85
114
|
}
|
|
86
115
|
}
|
|
87
|
-
|
|
88
|
-
function acquireSync(lockPath: string, options: StateLockOptions): string {
|
|
116
|
+
function acquireSync(lockPath: string, options: StateLockOptions): Holder {
|
|
89
117
|
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
90
|
-
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
91
118
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
92
119
|
for (let attempt = 0; ; ) {
|
|
93
|
-
const outcome = attemptOnce(lockPath,
|
|
94
|
-
if (outcome.kind === "acquired") return outcome.
|
|
120
|
+
const outcome = attemptOnce(lockPath, options, Date.now());
|
|
121
|
+
if (outcome.kind === "acquired") return outcome.holder;
|
|
95
122
|
if (outcome.kind === "retry") continue;
|
|
96
|
-
if (Date.now() >= deadline) throw lockTimeout(lockPath, options
|
|
123
|
+
if (Date.now() >= deadline) throw lockTimeout(lockPath, options);
|
|
97
124
|
Atomics.wait(SLEEP_CELL, 0, 0, backoffMs(attempt));
|
|
98
125
|
attempt += 1;
|
|
99
126
|
}
|
|
100
127
|
}
|
|
101
|
-
|
|
102
|
-
// EINTR surfaces raw from macOS fs syscalls under some runtimes; it is a retry, never a verdict.
|
|
103
|
-
function attemptOnce(lockPath: string, staleMs: number): AttemptOutcome {
|
|
128
|
+
function attemptOnce(lockPath: string, options: StateLockOptions, now: number, leaseMs?: number): AttemptOutcome {
|
|
104
129
|
try {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
const snapshot = readSnapshot(lockPath);
|
|
130
|
+
const holder = tryCreate(lockPath, now, leaseMs);
|
|
131
|
+
if (holder !== null) return { kind: "acquired", holder };
|
|
132
|
+
const snapshot = readSnapshot(lockPath, now);
|
|
108
133
|
if (snapshot === null) return { kind: "retry" };
|
|
109
|
-
|
|
134
|
+
const record = snapshot.record;
|
|
135
|
+
const stale =
|
|
136
|
+
record === null
|
|
137
|
+
? snapshot.ageMs > (options.staleMs ?? DEFAULT_STALE_MS)
|
|
138
|
+
: !isProcessAlive(record.pid) || (record.leaseUntil !== undefined && now > record.leaseUntil);
|
|
139
|
+
if (stale && reclaim(lockPath, snapshot.raw)) return { kind: "retry" };
|
|
110
140
|
return { kind: "wait" };
|
|
111
141
|
} catch (error) {
|
|
112
142
|
if (hasCode(error, "EINTR")) return { kind: "wait" };
|
|
113
143
|
throw error;
|
|
114
144
|
}
|
|
115
145
|
}
|
|
116
|
-
|
|
117
|
-
|
|
146
|
+
function writeRecord(holder: Holder): void {
|
|
147
|
+
const buf = Buffer.from(JSON.stringify(holder.record));
|
|
148
|
+
ftruncateSync(holder.fd, 0);
|
|
149
|
+
let offset = 0;
|
|
150
|
+
while (offset < buf.length) {
|
|
151
|
+
const written = writeSync(holder.fd, buf, offset, buf.length - offset, offset);
|
|
152
|
+
if (written === 0) throw new Error("State lock write made no progress.");
|
|
153
|
+
offset += written;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function tryCreate(lockPath: string, now: number, leaseMs?: number): Holder | null {
|
|
118
157
|
let fd: number;
|
|
119
158
|
try {
|
|
120
159
|
fd = openSync(lockPath, "wx");
|
|
@@ -122,60 +161,54 @@ function tryCreate(lockPath: string): string | null {
|
|
|
122
161
|
if (hasCode(error, "EEXIST")) return null;
|
|
123
162
|
throw error;
|
|
124
163
|
}
|
|
125
|
-
const record: LockRecord = {
|
|
164
|
+
const record: LockRecord = {
|
|
165
|
+
pid: process.pid,
|
|
166
|
+
createdAt: new Date(now).toISOString(),
|
|
167
|
+
token: randomUUID(),
|
|
168
|
+
...(leaseMs === undefined ? {} : { leaseUntil: now + leaseMs }),
|
|
169
|
+
};
|
|
170
|
+
const holder = { fd, record };
|
|
126
171
|
try {
|
|
127
|
-
|
|
172
|
+
writeRecord(holder);
|
|
128
173
|
} catch (error) {
|
|
129
174
|
closeSync(fd);
|
|
130
|
-
// A failed write (e.g. EINTR) must not leave a partial lock the owner never recorded;
|
|
131
|
-
// otherwise later readers see a young ownerless lock and only age can retire it.
|
|
132
175
|
try {
|
|
133
176
|
unlinkSync(lockPath);
|
|
134
|
-
} catch {
|
|
135
|
-
|
|
177
|
+
} catch (cleanup) {
|
|
178
|
+
if (!hasCode(cleanup, "ENOENT")) throw cleanup;
|
|
136
179
|
}
|
|
137
180
|
throw error;
|
|
138
181
|
}
|
|
139
|
-
|
|
140
|
-
return record.token;
|
|
182
|
+
return holder;
|
|
141
183
|
}
|
|
142
|
-
|
|
143
|
-
function readSnapshot(lockPath: string): LockSnapshot | null {
|
|
184
|
+
function readSnapshot(lockPath: string, now: number): LockSnapshot | null {
|
|
144
185
|
try {
|
|
145
186
|
const raw = readFileSync(lockPath, "utf8");
|
|
146
|
-
|
|
147
|
-
return { raw, record: parseRecord(raw), ageMs };
|
|
187
|
+
return { raw, record: parseRecord(raw), ageMs: now - statSync(lockPath).mtimeMs };
|
|
148
188
|
} catch (error) {
|
|
149
189
|
if (hasCode(error, "ENOENT")) return null;
|
|
150
190
|
throw error;
|
|
151
191
|
}
|
|
152
192
|
}
|
|
153
|
-
|
|
154
193
|
function parseRecord(raw: string): LockRecord | null {
|
|
155
194
|
try {
|
|
156
|
-
const
|
|
157
|
-
if (typeof
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
195
|
+
const record: unknown = JSON.parse(raw);
|
|
196
|
+
if (typeof record !== "object" || record === null) return null;
|
|
197
|
+
if (!("pid" in record) || typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0)
|
|
198
|
+
return null;
|
|
199
|
+
if (!("createdAt" in record) || typeof record.createdAt !== "string") return null;
|
|
200
|
+
if (!("token" in record) || typeof record.token !== "string" || record.token.length === 0) return null;
|
|
201
|
+
return {
|
|
202
|
+
pid: record.pid,
|
|
203
|
+
createdAt: record.createdAt,
|
|
204
|
+
token: record.token,
|
|
205
|
+
...("leaseUntil" in record && typeof record.leaseUntil === "number" ? { leaseUntil: record.leaseUntil } : {}),
|
|
206
|
+
};
|
|
165
207
|
} catch (error) {
|
|
166
208
|
if (error instanceof SyntaxError) return null;
|
|
167
209
|
throw error;
|
|
168
210
|
}
|
|
169
211
|
}
|
|
170
|
-
|
|
171
|
-
// A body that is not a record is a lock mid-write (or a foreign/legacy file); only
|
|
172
|
-
// age retires it. A parsable record is stale only when its owner is dead: a live
|
|
173
|
-
// owner running past staleMs is slow, not gone, and the waiter fails closed instead.
|
|
174
|
-
function isStale(snapshot: LockSnapshot, staleMs: number): boolean {
|
|
175
|
-
if (snapshot.record === null) return snapshot.ageMs > staleMs;
|
|
176
|
-
return !isProcessAlive(snapshot.record.pid);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
212
|
function isProcessAlive(pid: number): boolean {
|
|
180
213
|
try {
|
|
181
214
|
process.kill(pid, 0);
|
|
@@ -186,54 +219,39 @@ function isProcessAlive(pid: number): boolean {
|
|
|
186
219
|
throw error;
|
|
187
220
|
}
|
|
188
221
|
}
|
|
189
|
-
|
|
190
|
-
// Re-read right before unlinking so a lock that changed hands since the stale
|
|
191
|
-
// verdict (a sibling waiter reclaimed and re-acquired it) is left alone.
|
|
192
222
|
function reclaim(lockPath: string, expectedRaw: string): boolean {
|
|
193
|
-
const current = readSnapshot(lockPath);
|
|
223
|
+
const current = readSnapshot(lockPath, Date.now());
|
|
194
224
|
if (current === null) return true;
|
|
195
225
|
if (current.raw !== expectedRaw) return false;
|
|
196
226
|
try {
|
|
197
227
|
unlinkSync(lockPath);
|
|
198
228
|
} catch (error) {
|
|
229
|
+
if (hasCode(error, "EPERM") || hasCode(error, "EACCES")) return false;
|
|
199
230
|
if (!hasCode(error, "ENOENT")) throw error;
|
|
200
231
|
}
|
|
201
232
|
return true;
|
|
202
233
|
}
|
|
203
|
-
|
|
204
|
-
// Only the acquisition that wrote this token may unlink: if the file now carries
|
|
205
|
-
// another token, a waiter has legitimately taken over and its lock must stand.
|
|
206
234
|
function release(lockPath: string, token: string): void {
|
|
207
|
-
|
|
208
|
-
if (current === null || current.record?.token !== token) return;
|
|
235
|
+
if (readSnapshot(lockPath, Date.now())?.record?.token !== token) return;
|
|
209
236
|
try {
|
|
210
237
|
unlinkSync(lockPath);
|
|
211
238
|
} catch (error) {
|
|
212
239
|
if (!hasCode(error, "ENOENT")) throw error;
|
|
213
240
|
}
|
|
214
241
|
}
|
|
215
|
-
|
|
216
242
|
function backoffMs(attempt: number): number {
|
|
217
|
-
|
|
218
|
-
return exponential + Math.random() * MIN_DELAY_MS;
|
|
243
|
+
return Math.min(100, 5 * 2 ** attempt) + Math.random() * 5;
|
|
219
244
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const owner = holder
|
|
245
|
+
function lockTimeout(lockPath: string, options: StateLockOptions): UlwLoopError {
|
|
246
|
+
const holder = readSnapshot(lockPath, Date.now())?.record;
|
|
247
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
248
|
+
const owner = holder == null ? "another process" : `pid ${holder.pid}`;
|
|
224
249
|
return new UlwLoopError(
|
|
225
|
-
`ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms
|
|
250
|
+
`ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms. The lock owner is still alive. If a JS eval kernel was interrupted while writing, wait for its lease to expire (${options.leaseMs ?? DEFAULT_LEASE_MS} ms) or restart the owning senpi process; never delete a lock owned by a live process.`,
|
|
226
251
|
ULW_LOOP_LOCK_TIMEOUT_CODE,
|
|
227
|
-
{
|
|
228
|
-
details: {
|
|
229
|
-
lockPath,
|
|
230
|
-
timeoutMs,
|
|
231
|
-
...(holder === undefined || holder === null ? {} : { holderPid: holder.pid }),
|
|
232
|
-
},
|
|
233
|
-
},
|
|
252
|
+
{ details: { lockPath, timeoutMs, ...(holder == null ? {} : { holderPid: holder.pid }) } },
|
|
234
253
|
);
|
|
235
254
|
}
|
|
236
|
-
|
|
237
255
|
function hasCode(error: unknown, code: string): boolean {
|
|
238
256
|
return error instanceof Error && "code" in error && error.code === code;
|
|
239
257
|
}
|