lazycodex-ai 5.0.0-beta.45 → 5.0.0-beta.47
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/cli/index.js +43 -27
- package/dist/cli-node/index.js +43 -27
- package/package.json +1 -1
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- 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/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/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -7
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.d.ts +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.js +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +294 -96
- package/packages/omo-codex/plugin/components/ulw-loop/dist/constants.d.ts +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/constants.js +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/paths.d.ts +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/paths.js +12 -3
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +21 -6
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.d.ts +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-missing-recovery.js +10 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.d.ts +4 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/spawn-guard.js +25 -57
- package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.d.ts +9 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/state-lock.js +227 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +17 -3
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +10 -7
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +18 -7
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/src/constants.ts +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/paths.ts +24 -3
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +25 -5
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-missing-recovery.ts +11 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/spawn-guard.ts +34 -64
- package/packages/omo-codex/plugin/components/ulw-loop/src/state-lock.ts +239 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +16 -3
- package/packages/omo-codex/plugin/components/ulw-loop/test/checkpoint-template.test.ts +14 -4
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-checkpoint-continuation.test.ts +4 -2
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-checkpoint.test.ts +4 -2
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-commands.test.ts +2 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-complete-goals.test.ts +3 -1
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +10 -8
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-entrypoint.test.ts +12 -2
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-json-errors.test.ts +2 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-scope-required.test.ts +144 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-status-next-actions.test.ts +7 -5
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-steering-batch.test.ts +8 -3
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-validation-batch.test.ts +6 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/cli-session.ts +4 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/fixtures/quality-gate-builder.ts +2 -1
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io-cross-process.test.ts +106 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +36 -2
- package/packages/omo-codex/plugin/components/ulw-loop/test/spawn-guard.test.ts +37 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/state-lock.test.ts +218 -0
- 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/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-checking-ulw-execute-continuation.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/skills/ulw-loop/SKILL.md +2 -1
- package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +10 -7
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md
CHANGED
|
@@ -13,7 +13,7 @@ Use GPT-5.x style: outcome-first, evidence-bound, atomic decisions, no nested br
|
|
|
13
13
|
Deliver every goal in `.omo/ulw-loop/goals.json` end-to-end.
|
|
14
14
|
Prove EVERY success criterion with captured observable evidence from a real-usage scenario you ran (HTTP / tmux / browser / computer-use below).
|
|
15
15
|
TESTS ALONE NEVER PROVE DONE. A green test suite is supporting evidence, not completion proof.
|
|
16
|
-
Audit each pass, fail, block, steering change, and checkpoint in `.omo/ulw-loop
|
|
16
|
+
Audit each pass, fail, block, steering change, and checkpoint in `.omo/ulw-loop/<session-id>/ledger.jsonl`.
|
|
17
17
|
|
|
18
18
|
## Manual-QA channels
|
|
19
19
|
Run each criterion's real-surface proof yourself through the channel that faithfully exercises it; capture the artifact before recording PASS.
|
|
@@ -110,13 +110,16 @@ If `ULW_LOOP_CLI` is empty, open the durable notepad first, record the missing C
|
|
|
110
110
|
|
|
111
111
|
Run one form:
|
|
112
112
|
```sh
|
|
113
|
-
omo-agent-toolkit ulw-loop create-goals --brief "<brief>" [--validation-batch-json <json-or-path>] --json
|
|
114
|
-
omo-agent-toolkit ulw-loop create-goals --brief-file <path> [--validation-batch-json <json-or-path>] --json
|
|
115
|
-
cat <brief> | omo-agent-toolkit ulw-loop create-goals --from-stdin [--validation-batch-json <json-or-path>] --json
|
|
113
|
+
omo-agent-toolkit ulw-loop create-goals --session-id <id> --brief "<brief>" [--validation-batch-json <json-or-path>] --json
|
|
114
|
+
omo-agent-toolkit ulw-loop create-goals --session-id <id> --brief-file <path> [--validation-batch-json <json-or-path>] --json
|
|
115
|
+
cat <brief> | omo-agent-toolkit ulw-loop create-goals --session-id <id> --from-stdin [--validation-batch-json <json-or-path>] --json
|
|
116
116
|
```
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
Every state subcommand runs against exactly one session scope: pass `--session-id <id>` on every call (the printed handoff and the Stop hook resume directive carry this session's id; `PI_SESSION_ID`, `CODEX_THREAD_ID`, `CODEX_SESSION_ID`, or `OMO_ULW_LOOP_SESSION_ID` in the environment also resolve it). The CLI refuses with `ULW_LOOP_SESSION_SCOPE_REQUIRED` when neither is present instead of touching the shared `.omo/ulw-loop` root, because eval kernels, subprocesses, and hooks do not inherit the session env and every session in the directory would otherwise read and overwrite the same plan. Mutations are serialized across processes by `.omo/ulw-loop/<id>/.state.lock`, so parallel `record-evidence` calls from several workers are safe; `ULW_LOOP_LOCK_TIMEOUT` means another live process held the state for more than 10s — retry, never delete the lock while that process is alive.
|
|
118
|
+
If this session's aggregate is already complete, do not steer or force the
|
|
119
|
+
completed state for unrelated new work. Start a fresh run with
|
|
120
|
+
`omo-agent-toolkit ulw-loop create-goals --session-id <new-id> ...` and keep passing
|
|
121
|
+
that id on every later call; the Stop hook auto-resume follows only the
|
|
122
|
+
Codex session's own id, so a run under a custom id is resumed by hand. Use `--force`
|
|
120
123
|
only when deliberately overwriting completed evidence.
|
|
121
124
|
Write state through the CLI path. Do not hand-edit state files.
|
|
122
125
|
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
steer,
|
|
13
13
|
} from "./cli-subcommands.js";
|
|
14
14
|
import { resolveUlwLoopSessionIdFromEnv, type UlwLoopScope } from "./paths.js";
|
|
15
|
-
import {
|
|
15
|
+
import { listUlwLoopSessionIds } from "./plan-io.js";
|
|
16
|
+
import { sessionIdRequiredMessage, sessionScopeRequiredMessage } from "./plan-missing-recovery.js";
|
|
16
17
|
import { UlwLoopError } from "./types.js";
|
|
17
18
|
|
|
18
19
|
export const ULW_LOOP_SUBCOMMANDS = [
|
|
@@ -41,7 +42,6 @@ export async function ulwLoopCommand(argv: readonly string[]): Promise<number> {
|
|
|
41
42
|
const repoRoot = process.cwd();
|
|
42
43
|
const json = hasFlag(rest, "--json");
|
|
43
44
|
try {
|
|
44
|
-
const scope = commandScope(rest);
|
|
45
45
|
if (!isUlwLoopSubcommand(command)) {
|
|
46
46
|
if (json) {
|
|
47
47
|
printJsonError(
|
|
@@ -58,10 +58,12 @@ export async function ulwLoopCommand(argv: readonly string[]): Promise<number> {
|
|
|
58
58
|
process.stdout.write(`${subcommandHelp(command)}\n`);
|
|
59
59
|
return 0;
|
|
60
60
|
}
|
|
61
|
+
if (command === "help") {
|
|
62
|
+
process.stdout.write(`${ULW_LOOP_HELP}\n`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const scope = commandScope(repoRoot, rest);
|
|
61
66
|
switch (command) {
|
|
62
|
-
case "help":
|
|
63
|
-
process.stdout.write(`${ULW_LOOP_HELP}\n`);
|
|
64
|
-
return 0;
|
|
65
67
|
case "create-goals":
|
|
66
68
|
return await createGoals(repoRoot, rest, json, scope);
|
|
67
69
|
case "status":
|
|
@@ -105,7 +107,10 @@ function sessionIdFlagPresent(argv: readonly string[]): boolean {
|
|
|
105
107
|
return hasFlag(argv, SESSION_ID_FLAG) || argv.some((arg) => arg.startsWith(`${SESSION_ID_FLAG}=`));
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
// Every state subcommand runs against exactly one session directory. Without a flag or
|
|
111
|
+
// a session env there is no owner to resolve, so the command refuses instead of
|
|
112
|
+
// falling back to the repo-global root that every session in the cwd would share.
|
|
113
|
+
function commandScope(repoRoot: string, argv: readonly string[]): UlwLoopScope {
|
|
109
114
|
if (sessionIdFlagPresent(argv)) {
|
|
110
115
|
const sessionId = readValue(argv, SESSION_ID_FLAG)?.trim();
|
|
111
116
|
if (!sessionId) {
|
|
@@ -116,5 +121,11 @@ function commandScope(argv: readonly string[]): UlwLoopScope | undefined {
|
|
|
116
121
|
return { sessionId };
|
|
117
122
|
}
|
|
118
123
|
const sessionId = resolveUlwLoopSessionIdFromEnv();
|
|
119
|
-
|
|
124
|
+
if (sessionId !== null) return { sessionId };
|
|
125
|
+
const existingSessionIds = listUlwLoopSessionIds(repoRoot);
|
|
126
|
+
throw new UlwLoopError(
|
|
127
|
+
sessionScopeRequiredMessage(SESSION_ID_FLAG, existingSessionIds),
|
|
128
|
+
"ULW_LOOP_SESSION_SCOPE_REQUIRED",
|
|
129
|
+
{ details: { flag: SESSION_ID_FLAG, existingSessionIds } },
|
|
130
|
+
);
|
|
120
131
|
}
|
|
@@ -16,7 +16,7 @@ export const ULW_LOOP_HELP = `Usage:
|
|
|
16
16
|
omo-agent-toolkit ulw-loop add-goal --title "..." --objective "..." [--json]
|
|
17
17
|
omo-agent-toolkit ulw-loop record-review-blockers --goal-id <id> --title "..." --objective "..." --evidence "..." --codex-goal-json <...> [--json]
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Every state subcommand needs a session scope: [--session-id <id>] or the session env (OMO_ULW_LOOP_SESSION_ID / CODEX_SESSION_ID / CODEX_THREAD_ID / PI_SESSION_ID); state lives under .omo/ulw-loop/<id>/ and the unscoped root is never used implicitly.
|
|
20
20
|
Every subcommand accepts --help | -h to print its own usage line.`;
|
|
21
21
|
|
|
22
22
|
export function subcommandHelp(subcommand: string): string {
|
|
@@ -2,6 +2,7 @@ export const ULW_LOOP_DIR = ".omo/ulw-loop";
|
|
|
2
2
|
export const ULW_LOOP_BRIEF = "brief.md";
|
|
3
3
|
export const ULW_LOOP_GOALS = "goals.json";
|
|
4
4
|
export const ULW_LOOP_LEDGER = "ledger.jsonl";
|
|
5
|
+
export const ULW_LOOP_STATE_LOCK = ".state.lock";
|
|
5
6
|
|
|
6
7
|
export type UlwLoopStatus =
|
|
7
8
|
| "pending"
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ULW_LOOP_BRIEF,
|
|
4
|
+
ULW_LOOP_DIR,
|
|
5
|
+
ULW_LOOP_GOALS,
|
|
6
|
+
ULW_LOOP_LEDGER,
|
|
7
|
+
ULW_LOOP_STATE_LOCK,
|
|
8
|
+
UlwLoopError,
|
|
9
|
+
} from "./types.js";
|
|
3
10
|
|
|
4
11
|
export interface UlwLoopScope {
|
|
5
12
|
readonly sessionId?: string | null;
|
|
@@ -63,6 +70,12 @@ export function ulwLoopLedgerPath(repoRoot: string, scope?: UlwLoopScope): strin
|
|
|
63
70
|
return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_LEDGER);
|
|
64
71
|
}
|
|
65
72
|
|
|
73
|
+
// One lock per state directory covers goals.json, ledger.jsonl, and the hook
|
|
74
|
+
// counters beside them; the CLI mutations and the Codex hooks all take it.
|
|
75
|
+
export function ulwLoopStateLockPath(repoRoot: string, scope?: UlwLoopScope): string {
|
|
76
|
+
return join(ulwLoopDir(repoRoot, scope), ULW_LOOP_STATE_LOCK);
|
|
77
|
+
}
|
|
78
|
+
|
|
66
79
|
export function repoRelative(absolutePath: string, repoRoot: string): string {
|
|
67
80
|
const slashPrefix = `${repoRoot}/`;
|
|
68
81
|
const backslashPrefix = `${repoRoot}\\`;
|
|
@@ -73,9 +86,17 @@ export function repoRelative(absolutePath: string, repoRoot: string): string {
|
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
// Both the status --json emitter and the checkpoint enforcement resolve the attempt dir through
|
|
76
|
-
// this function; a second resolution path
|
|
89
|
+
// this function from the scope alone; a second resolution path (env, a literal placeholder)
|
|
90
|
+
// would let the gate reject its own advertised directory.
|
|
77
91
|
export function ulwLoopAttemptEvidenceDir(goalId: string, attempt: number, scope?: UlwLoopScope): string {
|
|
78
|
-
const sessionId = normalizeUlwLoopSessionId(scope?.sessionId)
|
|
92
|
+
const sessionId = normalizeUlwLoopSessionId(scope?.sessionId);
|
|
93
|
+
if (sessionId === null) {
|
|
94
|
+
throw new UlwLoopError(
|
|
95
|
+
`Evidence for ${goalId} attempt ${attempt} needs a session scope; pass --session-id <id> so the attempt directory lives under .omo/evidence/ulw/<id>/.`,
|
|
96
|
+
"ULW_LOOP_SESSION_SCOPE_REQUIRED",
|
|
97
|
+
{ details: { goalId, attempt } },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
79
100
|
return `.omo/evidence/ulw/${sessionId}/${goalId}/a${attempt}`;
|
|
80
101
|
}
|
|
81
102
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import { createReadStream, readdirSync } from "node:fs";
|
|
2
3
|
import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
4
|
import { createInterface } from "node:readline";
|
|
@@ -10,14 +11,20 @@ import {
|
|
|
10
11
|
ulwLoopGoalsPath,
|
|
11
12
|
ulwLoopLedgerPath,
|
|
12
13
|
ulwLoopRelativeDir,
|
|
14
|
+
ulwLoopStateLockPath,
|
|
13
15
|
} from "./paths.js";
|
|
14
16
|
import { planMissingRecovery } from "./plan-missing-recovery.js";
|
|
17
|
+
import { withStateLock } from "./state-lock.js";
|
|
15
18
|
import type { UlwLoopLedgerEntry, UlwLoopPlan } from "./types.js";
|
|
16
19
|
import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js";
|
|
17
20
|
|
|
18
21
|
const LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `;
|
|
19
22
|
const LEGACY_OBJECTIVE = `Complete all ulw-loop stories listed in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}. Use ${ULW_LOOP_DIR}/${ULW_LOOP_LEDGER} as the durable audit trail.`;
|
|
20
23
|
const locks = new Map<string, Promise<undefined>>();
|
|
24
|
+
// Tracks which state dirs the CURRENT async continuation holds, so a read nested
|
|
25
|
+
// inside a locked mutation can tell itself apart from an unlocked read elsewhere
|
|
26
|
+
// in the same process.
|
|
27
|
+
const heldLocks = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
21
28
|
|
|
22
29
|
function hasCode(error: unknown, code: string): boolean {
|
|
23
30
|
return error instanceof Error && "code" in error && error.code === code;
|
|
@@ -51,8 +58,13 @@ export async function withUlwLoopMutationLock<T>(
|
|
|
51
58
|
const fn = typeof scopeOrFn === "function" ? scopeOrFn : maybeFn;
|
|
52
59
|
if (fn === undefined) throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING");
|
|
53
60
|
const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`;
|
|
61
|
+
const lockPath = ulwLoopStateLockPath(repoRoot, scope);
|
|
62
|
+
// The promise chain orders callers inside this process; the file lock is what
|
|
63
|
+
// excludes every other process (each CLI invocation) touching the same state dir.
|
|
64
|
+
const locked = (): Promise<T> =>
|
|
65
|
+
withStateLock(lockPath, () => heldLocks.run(new Set([...(heldLocks.getStore() ?? []), lockKey]), fn));
|
|
54
66
|
const prior = locks.get(lockKey) ?? Promise.resolve(undefined);
|
|
55
|
-
const run = prior.then(
|
|
67
|
+
const run = prior.then(locked, locked);
|
|
56
68
|
// The stored gate resolves to undefined so the map never retains fn's result
|
|
57
69
|
// (plans/audits), and it removes itself once no newer waiter replaced it —
|
|
58
70
|
// otherwise a long-lived host leaks one entry per (repo, scope) forever.
|
|
@@ -74,7 +86,7 @@ export async function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): P
|
|
|
74
86
|
raw = await readFile(path, "utf8");
|
|
75
87
|
} catch (error) {
|
|
76
88
|
if (!hasCode(error, "ENOENT")) throw error;
|
|
77
|
-
const recovery = planMissingRecovery(
|
|
89
|
+
const recovery = planMissingRecovery(listUlwLoopSessionIds(repoRoot));
|
|
78
90
|
throw new UlwLoopError(
|
|
79
91
|
`No ulw-loop plan found at ${repoRelative(path, repoRoot)}.\n${recovery.message}`,
|
|
80
92
|
"ULW_LOOP_PLAN_MISSING",
|
|
@@ -90,6 +102,14 @@ export async function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): P
|
|
|
90
102
|
(parsed.codexGoalMode ?? "per_story") === "aggregate" &&
|
|
91
103
|
isLegacyEnumeratedAggregateObjective(previousObjective)
|
|
92
104
|
) {
|
|
105
|
+
if (!(heldLocks.getStore()?.has(`${repoRoot}\0${ulwLoopRelativeDir(scope)}`) ?? false)) {
|
|
106
|
+
// A read path (status/criteria) must not mutate state: mutating here runs
|
|
107
|
+
// unlocked and a second reader could write a partially-migrated plan.
|
|
108
|
+
throw new UlwLoopError(
|
|
109
|
+
`The ulw-loop plan at ${repoRelative(path, repoRoot)} carries a legacy enumerated aggregate objective that must be migrated before reads continue. Run any state-mutating ulw-loop command once (e.g. \`record-evidence\`, \`steer\`, \`checkpoint\`) to migrate it under the state lock, then retry.`,
|
|
110
|
+
"ULW_LOOP_MIGRATION_REQUIRED",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
93
113
|
const now = iso();
|
|
94
114
|
parsed.codexObjective = aggregateCodexObjectiveForScope(scope);
|
|
95
115
|
parsed.codexObjectiveAliases = [...new Set([...(parsed.codexObjectiveAliases ?? []), previousObjective])];
|
|
@@ -110,9 +130,9 @@ export async function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope): P
|
|
|
110
130
|
return parsed;
|
|
111
131
|
}
|
|
112
132
|
|
|
113
|
-
// Session dirs are the only recovery hint that matters when a plan
|
|
114
|
-
// caller is almost always
|
|
115
|
-
function
|
|
133
|
+
// Session dirs are the only recovery hint that matters when a plan or a scope is
|
|
134
|
+
// missing: the caller is almost always meant to target one of these siblings.
|
|
135
|
+
export function listUlwLoopSessionIds(repoRoot: string): readonly string[] {
|
|
116
136
|
try {
|
|
117
137
|
return readdirSync(ulwLoopDir(repoRoot), { withFileTypes: true })
|
|
118
138
|
.filter((entry) => entry.isDirectory())
|
|
@@ -18,6 +18,17 @@ export function planMissingRecovery(existingSessionIds: readonly string[]): Plan
|
|
|
18
18
|
return { message: lines.join("\n"), details: { existingSessionIds } };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function sessionScopeRequiredMessage(flag: string, existingSessionIds: readonly string[]): string {
|
|
22
|
+
const lines = [
|
|
23
|
+
"No ulw-loop session scope: neither the session env (OMO_ULW_LOOP_SESSION_ID / CODEX_SESSION_ID / CODEX_THREAD_ID / PI_SESSION_ID) nor the flag names this run, and the shared .omo/ulw-loop root is never used implicitly because every session in this directory would read and overwrite it.",
|
|
24
|
+
`Recovery: pass the scope explicitly: \`${flag} <id>\` (subprocess, eval, and hook contexts do not inherit the session env).`,
|
|
25
|
+
];
|
|
26
|
+
if (existingSessionIds.length > 0) {
|
|
27
|
+
lines.push(`Existing ulw-loop session ids under .omo/ulw-loop/: ${existingSessionIds.join(", ")}.`);
|
|
28
|
+
}
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
export function sessionIdRequiredMessage(flag: string): string {
|
|
22
33
|
return [
|
|
23
34
|
`${flag} requires a non-empty value.`,
|
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
openSync,
|
|
6
|
-
readdirSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
renameSync,
|
|
9
|
-
statSync,
|
|
10
|
-
unlinkSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
13
3
|
import { dirname, join } from "node:path";
|
|
14
4
|
|
|
15
5
|
import type { PreToolUsePayload } from "./codex-hook.js";
|
|
16
6
|
import { parsePreToolUsePayload } from "./codex-hook.js";
|
|
17
7
|
import { isFinalRunCompletionCandidate } from "./goal-status.js";
|
|
18
|
-
import { ulwLoopAttemptEvidenceDir, ulwLoopDir } from "./paths.js";
|
|
8
|
+
import { ulwLoopAttemptEvidenceDir, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
|
|
9
|
+
import { isStateLockTimeout, type StateLockOptions, withStateLockSync } from "./state-lock.js";
|
|
19
10
|
import {
|
|
20
11
|
GATE_REVIEWER_AGENT_NAMES,
|
|
21
12
|
REVIEWER_ROLES_BY_SURFACE,
|
|
@@ -38,11 +29,32 @@ const REVIEW_AGENT_TYPES = [
|
|
|
38
29
|
] as const;
|
|
39
30
|
const REVIEW_AGENT_TYPE_SET = new Set<string>(REVIEW_AGENT_TYPES);
|
|
40
31
|
|
|
41
|
-
export
|
|
32
|
+
export interface SpawnGuardOptions {
|
|
33
|
+
readonly lockTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function applySpawnGuards(payload: PreToolUsePayload, options: SpawnGuardOptions = {}): string {
|
|
42
37
|
if (payload.hook_event_name !== "PreToolUse" || !SPAWN_TOOL_TOKENS.has(payload.tool_name)) return "";
|
|
43
|
-
const
|
|
38
|
+
const scope = { sessionId: payload.session_id } as const;
|
|
39
|
+
const stateDir = ulwLoopDir(payload.cwd, scope);
|
|
44
40
|
const plan = readPlan(join(stateDir, "goals.json"));
|
|
45
41
|
if (plan === null) return "";
|
|
42
|
+
const lockOptions: StateLockOptions =
|
|
43
|
+
options.lockTimeoutMs === undefined ? {} : { timeoutMs: options.lockTimeoutMs };
|
|
44
|
+
try {
|
|
45
|
+
return withStateLockSync(
|
|
46
|
+
ulwLoopStateLockPath(payload.cwd, scope),
|
|
47
|
+
() => evaluateGuards(payload, plan, stateDir),
|
|
48
|
+
lockOptions,
|
|
49
|
+
);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isStateLockTimeout(error))
|
|
52
|
+
return deny(`ulw-loop spawn guard could not take the session state lock: ${error.message}`);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function evaluateGuards(payload: PreToolUsePayload, plan: UlwLoopPlan, stateDir: string): string {
|
|
46
58
|
const fanOutPeek = peekFanOutBudget(stateDir);
|
|
47
59
|
if (fanOutPeek !== null) return deny(fanOutPeek);
|
|
48
60
|
const missingArtifact = missingGateArtifact(payload, plan);
|
|
@@ -99,18 +111,15 @@ function consumeReviewSpawnBudget(payload: PreToolUsePayload, plan: UlwLoopPlan,
|
|
|
99
111
|
plan.goals.find((candidate) => isFinalRunCompletionCandidate(plan, candidate));
|
|
100
112
|
if (goal === undefined) return null;
|
|
101
113
|
const counterPath = join(stateDir, "review-spawn-counts.json");
|
|
102
|
-
const lockPath = `${counterPath}.lock`;
|
|
103
114
|
const limit = reviewSpawnLimit();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return null;
|
|
113
|
-
});
|
|
115
|
+
const counts = readCounts(counterPath);
|
|
116
|
+
const key = `${agentType}:${goal.id}:a${goal.attempt}`;
|
|
117
|
+
const count = (counts[key] ?? 0) + 1;
|
|
118
|
+
if (count > limit)
|
|
119
|
+
return `ulw-loop reviewer no-progress cap reached (${agentType} ${count}/${limit}) for ${goal.id} attempt ${goal.attempt}. Consolidate existing review findings, or checkpoint and start a new attempt after concrete progress.`;
|
|
120
|
+
counts[key] = count;
|
|
121
|
+
atomicWriteJson(counterPath, counts);
|
|
122
|
+
return null;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
function missingGateArtifact(payload: PreToolUsePayload, plan: UlwLoopPlan): string | null {
|
|
@@ -190,45 +199,6 @@ function activeSurfaceReviewerAlias(reviewer: string): string {
|
|
|
190
199
|
return reviewer;
|
|
191
200
|
}
|
|
192
201
|
|
|
193
|
-
function withExclusiveLock<T>(lockPath: string, fn: () => T): T {
|
|
194
|
-
mkdirSync(dirname(lockPath), { recursive: true });
|
|
195
|
-
const maxAttempts = 10;
|
|
196
|
-
const baseDelayMs = 10;
|
|
197
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
198
|
-
let fd: number | null = null;
|
|
199
|
-
try {
|
|
200
|
-
fd = openSync(lockPath, "wx");
|
|
201
|
-
writeFileSync(fd, process.pid.toString());
|
|
202
|
-
try {
|
|
203
|
-
return fn();
|
|
204
|
-
} finally {
|
|
205
|
-
try {
|
|
206
|
-
unlinkSync(lockPath);
|
|
207
|
-
} catch {
|
|
208
|
-
/* empty */
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
} catch (error) {
|
|
212
|
-
if (fd !== null) {
|
|
213
|
-
try {
|
|
214
|
-
unlinkSync(lockPath);
|
|
215
|
-
} catch {
|
|
216
|
-
/* empty */
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
220
|
-
return fn();
|
|
221
|
-
}
|
|
222
|
-
const delayMs = baseDelayMs * 2 ** attempt + Math.random() * baseDelayMs;
|
|
223
|
-
const deadline = Date.now() + delayMs;
|
|
224
|
-
while (Date.now() < deadline) {
|
|
225
|
-
/* spin */
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return fn();
|
|
230
|
-
}
|
|
231
|
-
|
|
232
202
|
function atomicWriteJson(targetPath: string, data: unknown): void {
|
|
233
203
|
const tmp = join(dirname(targetPath), `.tmp-${randomBytes(6).toString("hex")}`);
|
|
234
204
|
writeFileSync(tmp, JSON.stringify(data));
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
import { UlwLoopError } from "./types.js";
|
|
7
|
+
|
|
8
|
+
// Cross-process exclusive lock for one ulw-loop state directory. Every CLI
|
|
9
|
+
// invocation is its own process, so the in-process promise chain in plan-io
|
|
10
|
+
// serializes nothing across them; this file-level lock is what makes the
|
|
11
|
+
// read-modify-write of goals.json (and the counters next to it) atomic.
|
|
12
|
+
//
|
|
13
|
+
// Protocol: the lock is a file created with O_EXCL whose body records the
|
|
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
|
+
|
|
21
|
+
export const ULW_LOOP_LOCK_TIMEOUT_CODE = "ULW_LOOP_LOCK_TIMEOUT";
|
|
22
|
+
|
|
23
|
+
export interface StateLockOptions {
|
|
24
|
+
readonly timeoutMs?: number;
|
|
25
|
+
readonly staleMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface LockRecord {
|
|
29
|
+
readonly pid: number;
|
|
30
|
+
readonly createdAt: string;
|
|
31
|
+
readonly token: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LockSnapshot {
|
|
35
|
+
readonly raw: string;
|
|
36
|
+
readonly record: LockRecord | null;
|
|
37
|
+
readonly ageMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type AttemptOutcome = { readonly kind: "acquired"; readonly token: string } | { readonly kind: "retry" | "wait" };
|
|
41
|
+
|
|
42
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
43
|
+
const DEFAULT_STALE_MS = 60_000;
|
|
44
|
+
const MIN_DELAY_MS = 5;
|
|
45
|
+
const MAX_DELAY_MS = 100;
|
|
46
|
+
const SLEEP_CELL = new Int32Array(new SharedArrayBuffer(4));
|
|
47
|
+
|
|
48
|
+
export async function withStateLock<T>(
|
|
49
|
+
lockPath: string,
|
|
50
|
+
fn: () => Promise<T>,
|
|
51
|
+
options: StateLockOptions = {},
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
const token = await acquireAsync(lockPath, options);
|
|
54
|
+
try {
|
|
55
|
+
return await fn();
|
|
56
|
+
} finally {
|
|
57
|
+
release(lockPath, token);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function withStateLockSync<T>(lockPath: string, fn: () => T, options: StateLockOptions = {}): T {
|
|
62
|
+
const token = acquireSync(lockPath, options);
|
|
63
|
+
try {
|
|
64
|
+
return fn();
|
|
65
|
+
} finally {
|
|
66
|
+
release(lockPath, token);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isStateLockTimeout(error: unknown): error is UlwLoopError {
|
|
71
|
+
return error instanceof UlwLoopError && error.code === ULW_LOOP_LOCK_TIMEOUT_CODE;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function acquireAsync(lockPath: string, options: StateLockOptions): Promise<string> {
|
|
75
|
+
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
76
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
77
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
78
|
+
for (let attempt = 0; ; ) {
|
|
79
|
+
const outcome = attemptOnce(lockPath, staleMs);
|
|
80
|
+
if (outcome.kind === "acquired") return outcome.token;
|
|
81
|
+
if (outcome.kind === "retry") continue;
|
|
82
|
+
if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
83
|
+
await sleep(backoffMs(attempt));
|
|
84
|
+
attempt += 1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function acquireSync(lockPath: string, options: StateLockOptions): string {
|
|
89
|
+
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
90
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
91
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
92
|
+
for (let attempt = 0; ; ) {
|
|
93
|
+
const outcome = attemptOnce(lockPath, staleMs);
|
|
94
|
+
if (outcome.kind === "acquired") return outcome.token;
|
|
95
|
+
if (outcome.kind === "retry") continue;
|
|
96
|
+
if (Date.now() >= deadline) throw lockTimeout(lockPath, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
97
|
+
Atomics.wait(SLEEP_CELL, 0, 0, backoffMs(attempt));
|
|
98
|
+
attempt += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
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 {
|
|
104
|
+
try {
|
|
105
|
+
const token = tryCreate(lockPath);
|
|
106
|
+
if (token !== null) return { kind: "acquired", token };
|
|
107
|
+
const snapshot = readSnapshot(lockPath);
|
|
108
|
+
if (snapshot === null) return { kind: "retry" };
|
|
109
|
+
if (isStale(snapshot, staleMs) && reclaim(lockPath, snapshot.raw)) return { kind: "retry" };
|
|
110
|
+
return { kind: "wait" };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (hasCode(error, "EINTR")) return { kind: "wait" };
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tryCreate(lockPath: string): string | null {
|
|
118
|
+
let fd: number;
|
|
119
|
+
try {
|
|
120
|
+
fd = openSync(lockPath, "wx");
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (hasCode(error, "EEXIST")) return null;
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
const record: LockRecord = { pid: process.pid, createdAt: new Date().toISOString(), token: randomUUID() };
|
|
126
|
+
try {
|
|
127
|
+
writeSync(fd, JSON.stringify(record));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
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
|
+
try {
|
|
133
|
+
unlinkSync(lockPath);
|
|
134
|
+
} catch {
|
|
135
|
+
// another process already reclaimed the partial file; leave it alone
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
closeSync(fd);
|
|
140
|
+
return record.token;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readSnapshot(lockPath: string): LockSnapshot | null {
|
|
144
|
+
try {
|
|
145
|
+
const raw = readFileSync(lockPath, "utf8");
|
|
146
|
+
const ageMs = Date.now() - statSync(lockPath).mtimeMs;
|
|
147
|
+
return { raw, record: parseRecord(raw), ageMs };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (hasCode(error, "ENOENT")) return null;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseRecord(raw: string): LockRecord | null {
|
|
155
|
+
try {
|
|
156
|
+
const parsed: unknown = JSON.parse(raw);
|
|
157
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
158
|
+
const record = parsed as Record<string, unknown>;
|
|
159
|
+
const pid = record["pid"];
|
|
160
|
+
const createdAt = record["createdAt"];
|
|
161
|
+
const token = record["token"];
|
|
162
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || typeof createdAt !== "string") return null;
|
|
163
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
164
|
+
return { pid, createdAt, token };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof SyntaxError) return null;
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
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
|
+
function isProcessAlive(pid: number): boolean {
|
|
180
|
+
try {
|
|
181
|
+
process.kill(pid, 0);
|
|
182
|
+
return true;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (hasCode(error, "ESRCH")) return false;
|
|
185
|
+
if (hasCode(error, "EPERM")) return true;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
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
|
+
function reclaim(lockPath: string, expectedRaw: string): boolean {
|
|
193
|
+
const current = readSnapshot(lockPath);
|
|
194
|
+
if (current === null) return true;
|
|
195
|
+
if (current.raw !== expectedRaw) return false;
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(lockPath);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
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
|
+
function release(lockPath: string, token: string): void {
|
|
207
|
+
const current = readSnapshot(lockPath);
|
|
208
|
+
if (current === null || current.record?.token !== token) return;
|
|
209
|
+
try {
|
|
210
|
+
unlinkSync(lockPath);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function backoffMs(attempt: number): number {
|
|
217
|
+
const exponential = Math.min(MAX_DELAY_MS, MIN_DELAY_MS * 2 ** attempt);
|
|
218
|
+
return exponential + Math.random() * MIN_DELAY_MS;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function lockTimeout(lockPath: string, timeoutMs: number): UlwLoopError {
|
|
222
|
+
const holder = readSnapshot(lockPath)?.record;
|
|
223
|
+
const owner = holder === undefined || holder === null ? "another process" : `pid ${holder.pid}`;
|
|
224
|
+
return new UlwLoopError(
|
|
225
|
+
`ulw-loop state lock ${lockPath} is held by ${owner} for more than ${timeoutMs}ms; retry once that process finishes, or delete the lock file if that process is gone.`,
|
|
226
|
+
ULW_LOOP_LOCK_TIMEOUT_CODE,
|
|
227
|
+
{
|
|
228
|
+
details: {
|
|
229
|
+
lockPath,
|
|
230
|
+
timeoutMs,
|
|
231
|
+
...(holder === undefined || holder === null ? {} : { holderPid: holder.pid }),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
238
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
239
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
|
-
import { normalizeUlwLoopSessionId, ulwLoopDir } from "./paths.js";
|
|
4
|
+
import { normalizeUlwLoopSessionId, ulwLoopDir, ulwLoopStateLockPath } from "./paths.js";
|
|
5
|
+
import { isStateLockTimeout, withStateLockSync } from "./state-lock.js";
|
|
5
6
|
import type { UlwLoopItem, UlwLoopPlan } from "./types.js";
|
|
6
7
|
|
|
7
8
|
// Turn-death recovery only: Codex emits Stop when a turn ends, so a run that
|
|
@@ -34,12 +35,13 @@ export function runStopResumeHook(input: unknown): string {
|
|
|
34
35
|
if (payload === null || payload.stop_hook_active) return "";
|
|
35
36
|
if (transcriptShowsContextPressure(payload.transcript_path)) return "";
|
|
36
37
|
if (boulderContinuationWillFire(payload.cwd, payload.session_id)) return "";
|
|
37
|
-
const
|
|
38
|
+
const scope = { sessionId: payload.session_id } as const;
|
|
39
|
+
const stateDir = ulwLoopDir(payload.cwd, scope);
|
|
38
40
|
const plan = readPlan(join(stateDir, "goals.json"));
|
|
39
41
|
if (plan === null || plan.aggregateCompletion?.status === "complete") return "";
|
|
40
42
|
const goal = resumableGoal(plan);
|
|
41
43
|
if (goal === undefined) return "";
|
|
42
|
-
if (!
|
|
44
|
+
if (!consumeResumeBudgetLocked(ulwLoopStateLockPath(payload.cwd, scope), stateDir, goal.id)) return "";
|
|
43
45
|
const output: { decision: "block"; reason: string } = {
|
|
44
46
|
decision: "block",
|
|
45
47
|
reason: renderResumeDirective(plan, goal, payload.session_id),
|
|
@@ -68,6 +70,17 @@ function isResumableStatus(status: UlwLoopItem["status"]): boolean {
|
|
|
68
70
|
return status === "pending" || status === "in_progress";
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
// A resume is a budgeted side effect; when the session lock cannot be taken the
|
|
74
|
+
// hook stays silent (fail closed) instead of charging the counter unlocked.
|
|
75
|
+
function consumeResumeBudgetLocked(lockPath: string, stateDir: string, goalId: string): boolean {
|
|
76
|
+
try {
|
|
77
|
+
return withStateLockSync(lockPath, () => consumeResumeBudget(stateDir, goalId));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (isStateLockTimeout(error)) return false;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
// Two-strike cap keyed on ledger movement: an unchanged ledger.jsonl line
|
|
72
85
|
// count across resumes means the loop is not progressing. The stuck marker is
|
|
73
86
|
// a separate file — a ledger append would change the count and self-reset.
|