@lyupro/codex-bridge 0.6.8 → 0.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +6 -4
- package/bin/codex-bridge.mjs +35 -177
- package/cli/command-help.mjs +49 -0
- package/cli/command-registry.mjs +284 -0
- package/cli/copy.mjs +42 -5
- package/cli/dispatcher-contract-record.mjs +3 -2
- package/cli/doctor-host-side.mjs +110 -0
- package/cli/doctor.mjs +17 -1
- package/cli/home-inspection.mjs +122 -0
- package/cli/home-plan-execute.mjs +140 -0
- package/cli/home-removal-plan.mjs +61 -0
- package/cli/hook-recognizer.mjs +78 -0
- package/cli/host-contract.mjs +11 -5
- package/cli/host-inspection.mjs +156 -0
- package/cli/host-removal.mjs +91 -0
- package/cli/image-evidence.mjs +44 -0
- package/cli/install-owners.mjs +244 -0
- package/cli/install-record.mjs +71 -9
- package/cli/install.mjs +88 -75
- package/cli/inventory-removal.mjs +84 -0
- package/cli/inventory-transition.mjs +66 -0
- package/cli/lifecycle-lock-clear.mjs +104 -0
- package/cli/lifecycle-lock-inspect.mjs +91 -0
- package/cli/lifecycle-lock.mjs +335 -0
- package/cli/lifecycle-transaction.mjs +57 -0
- package/cli/link-segments.mjs +45 -0
- package/cli/model-set.mjs +4 -3
- package/cli/model-speed.mjs +9 -2
- package/cli/model.mjs +5 -3
- package/cli/owner-record.mjs +40 -0
- package/cli/owner-sync-apply.mjs +108 -0
- package/cli/owner-sync.mjs +115 -0
- package/cli/projects.mjs +5 -2
- package/cli/prune-args.mjs +9 -4
- package/cli/prune.mjs +23 -72
- package/cli/purge-live-runs.mjs +98 -0
- package/cli/purge-preflight.mjs +103 -0
- package/cli/read.mjs +2 -1
- package/cli/record-removal.mjs +43 -0
- package/cli/removal-outcomes.mjs +115 -0
- package/cli/removal-plan.mjs +187 -0
- package/cli/remove-layout.mjs +40 -1
- package/cli/rules-owners.mjs +4 -2
- package/cli/terminal-question.mjs +41 -0
- package/cli/uninstall-purge.mjs +139 -0
- package/cli/uninstall-rules.mjs +72 -0
- package/cli/uninstall.mjs +208 -127
- package/cli/unlock-lifecycle.mjs +102 -0
- package/cli/unlock.mjs +7 -4
- package/cli/update.mjs +102 -27
- package/package.json +4 -3
- package/scripts/home-write-audit.mjs +286 -0
- package/scripts/home-write-inventory.mjs +49 -0
- package/scripts/home-write-lexer.mjs +182 -0
- package/scripts/isolated-roots.mjs +6 -1
- package/scripts/open-work/check.mjs +180 -0
- package/scripts/open-work/live-thresholds.mjs +155 -0
- package/scripts/open-work/register.mjs +56 -0
- package/scripts/root-guard/root-entries.mjs +15 -0
- package/scripts/run-tests.mjs +26 -1
- package/src/agents/codex-advisor.md +5 -2
- package/src/home/conventions.md +9 -0
- package/src/home/hooks/guard-tries.mjs +6 -4
- package/src/home/hooks/order-gate.mjs +2 -8
- package/src/home/hooks/reply-guard.mjs +2 -7
- package/src/home/hooks/worktree-witness.mjs +6 -6
- package/src/home/lib/agents.mjs +1 -1
- package/src/home/lib/atomic-json.mjs +24 -6
- package/src/home/lib/config-edit.mjs +25 -10
- package/src/home/lib/dispatcher-state.mjs +25 -10
- package/src/home/lib/file-lock.mjs +41 -10
- package/src/home/lib/handback-witness.mjs +7 -4
- package/src/home/lib/home-registry.mjs +211 -0
- package/src/home/lib/home-write.mjs +189 -0
- package/src/home/lib/hook-diagnostics.mjs +20 -0
- package/src/home/lib/host-observations.mjs +7 -4
- package/src/home/lib/meta/advice-status.mjs +2 -2
- package/src/home/lib/meta/advice-verdict.mjs +30 -1
- package/src/home/lib/meta/events.mjs +2 -1
- package/src/home/lib/meta/paths.mjs +24 -24
- package/src/home/lib/meta/run-snapshots.mjs +29 -0
- package/src/home/lib/meta/snapshot-format.mjs +96 -0
- package/src/home/lib/meta/verdict.mjs +14 -16
- package/src/home/lib/run-config.mjs +10 -1
- package/src/home/lib/runner/git-paths.mjs +74 -0
- package/src/home/lib/runner/git-state.mjs +38 -34
- package/src/home/lib/runner/launcher.mjs +2 -2
- package/src/home/lib/runner/preflight.mjs +7 -0
- package/src/home/lib/runner/prompts.mjs +35 -2
- package/src/home/lib/runner/schemas.mjs +4 -1
- package/src/home/lib/runner/scope-check.mjs +29 -20
- package/src/home/lib/runner/worker.mjs +3 -3
|
@@ -83,8 +83,9 @@ codex-bridge run --agent codex-advisor --repo "<repository-path or .>" --phase "
|
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
That is one command line. Add only the bare `--continue` when the task text contains a line
|
|
86
|
-
beginning with `continue:`;
|
|
87
|
-
|
|
86
|
+
beginning with `continue:`; the flag itself takes no value (the runner accepts only
|
|
87
|
+
`1/true/yes/0/false/no` there). The grant line names a run folder and a reason, and the runner parses
|
|
88
|
+
it, so a malformed grant is passed through for the runner to refuse. If
|
|
88
89
|
there is no grant line, do not add the flag. Add `--effort "<value>"` only if the orchestrator
|
|
89
90
|
named a depth, and only with one of `none|low|medium|high|xhigh|max`. Never add free text or any
|
|
90
91
|
flags beyond those two conditional flags.
|
|
@@ -158,6 +159,8 @@ verbatim; compose nothing.
|
|
|
158
159
|
The run folder contains its status and artifacts. An abandoned run is not a reason to start over
|
|
159
160
|
yourself: the orchestrator decides whether to continue, and without the `--continue` it issued,
|
|
160
161
|
the runner will reject that continuation itself.
|
|
162
|
+
The orchestrator repeats a failed scope under a NEW order id, never with `continue:` or `--continue`:
|
|
163
|
+
the runner refuses a continued scope, because it would spend the continuation that advise needs.
|
|
161
164
|
|
|
162
165
|
## Codex is unavailable
|
|
163
166
|
|
package/src/home/conventions.md
CHANGED
|
@@ -42,3 +42,12 @@ boundary is needed.
|
|
|
42
42
|
The 2026-08-05 step 2b acceptance found the abandoned-run check below the commit check. Test the
|
|
43
43
|
ordering invariant itself, and leave the incident in the comment so a later cleanup does not
|
|
44
44
|
silently undo it.
|
|
45
|
+
|
|
46
|
+
## Failing tests are evidence - Plan_73 B3 fixture incident
|
|
47
|
+
|
|
48
|
+
- Never change a fixture or an expectation only to make a failing test pass.
|
|
49
|
+
- When an existing test fails for a reason the task does not describe, leave it failing and name
|
|
50
|
+
it under deviations.
|
|
51
|
+
|
|
52
|
+
The 2026-09-30 Plan_73 B3 run rewrote a witness fixture so that a snapshot codec defect stopped
|
|
53
|
+
showing; that defect would have failed every build started on a clean tree.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
2
|
+
import { BRAND_HOME, BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
3
|
+
import { createHomeWriter } from '../lib/home-write.mjs';
|
|
4
4
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -49,8 +49,10 @@ export function takeTry(agentId, kind, maxBlocks = kind === STATE ? MAX_STATE_BL
|
|
|
49
49
|
const ids = Object.keys(seen);
|
|
50
50
|
if (ids.length > 200) ids.slice(0, ids.length - 200).forEach((id) => delete seen[id]);
|
|
51
51
|
try {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Plan_65 B4: the budget is the registered guard-tries artifact, so purge can name and remove it.
|
|
53
|
+
const writer = createHomeWriter({ root: BRAND_HOME.root });
|
|
54
|
+
writer.mkdirSync('guard-tries', path.dirname(BLOCKED_FILE), { recursive: true });
|
|
55
|
+
writer.writeFileSync('guard-tries', BLOCKED_FILE, `${JSON.stringify(seen)}\n`);
|
|
54
56
|
} catch {
|
|
55
57
|
return 'untracked';
|
|
56
58
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { AGENTS } from '../lib/agents.mjs';
|
|
17
|
-
import {
|
|
17
|
+
import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
|
|
18
18
|
import { readJsonFileSync } from '../lib/json-file.mjs';
|
|
19
19
|
import {
|
|
20
20
|
diagnoseInput,
|
|
@@ -30,7 +30,6 @@ import { resolveProjectRunsDir } from '../lib/runner/project-dir.mjs';
|
|
|
30
30
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
31
31
|
import { parseTaskDocument } from '../lib/runner/task-file.mjs';
|
|
32
32
|
|
|
33
|
-
const DIAGNOSTICS_DIR = path.join(BRAND_STATE_DIR, 'diagnostics');
|
|
34
33
|
const GUARDED = new Set(Object.keys(AGENTS));
|
|
35
34
|
/**
|
|
36
35
|
* Both spellings of the subagent-launching tool, from the same list the installer builds its
|
|
@@ -58,12 +57,7 @@ try {
|
|
|
58
57
|
pass();
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true });
|
|
63
|
-
fs.writeFileSync(path.join(DIAGNOSTICS_DIR, 'order-gate.last.json'), `${JSON.stringify(input, null, 2)}\n`);
|
|
64
|
-
} catch {
|
|
65
|
-
// Diagnostics are a convenience, never a reason to fail the turn.
|
|
66
|
-
}
|
|
60
|
+
recordHookDiagnostic('order-gate', input);
|
|
67
61
|
|
|
68
62
|
if (!input || typeof input !== 'object' || Array.isArray(input)) pass();
|
|
69
63
|
if (!SUBAGENT_TOOL_NAMES.has(input.tool_name)) pass();
|
|
@@ -33,6 +33,7 @@ import fs from 'node:fs';
|
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import { AGENTS } from '../lib/agents.mjs';
|
|
35
35
|
import { BRAND_STATE_DIR } from '../lib/brand-home.mjs';
|
|
36
|
+
import { recordHookDiagnostic } from '../lib/hook-diagnostics.mjs';
|
|
36
37
|
import { runOrderMismatch, transcriptOrderId } from '../lib/dispatcher-order.mjs';
|
|
37
38
|
import { decideDispatcherStop, transcriptToolUses } from '../lib/dispatcher-stop.mjs';
|
|
38
39
|
import { recognizeHostRefusal } from '../lib/host-refusal.mjs';
|
|
@@ -111,13 +112,7 @@ try {
|
|
|
111
112
|
pass();
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
const diagnosticsDir = path.join(BRAND_STATE_DIR, 'diagnostics');
|
|
116
|
-
fs.mkdirSync(diagnosticsDir, { recursive: true });
|
|
117
|
-
fs.writeFileSync(path.join(diagnosticsDir, 'reply-guard.last.json'), `${JSON.stringify(input, null, 2)}\n`);
|
|
118
|
-
} catch {
|
|
119
|
-
// Diagnostics are a convenience, never a reason to fail the turn.
|
|
120
|
-
}
|
|
115
|
+
recordHookDiagnostic('reply-guard', input);
|
|
121
116
|
|
|
122
117
|
// A host that stops sending agent_type would let every dispatcher past the gate and this
|
|
123
118
|
// audit without a word (Plan_66 D2 (c)); say so instead of passing silently.
|
|
@@ -15,7 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import { SHELL_TOOLS } from '../lib/hook-definitions.mjs';
|
|
16
16
|
import { parseJsonText } from '../lib/json-file.mjs';
|
|
17
17
|
import { splitRunChanges } from '../lib/meta/environment.mjs';
|
|
18
|
-
import {
|
|
18
|
+
import { compareSnapshots } from '../lib/meta/snapshot-format.mjs';
|
|
19
19
|
import { outOfScope } from '../lib/meta/verdict.mjs';
|
|
20
20
|
import { git, worktreeSnapshot } from '../lib/runner/git-state.mjs';
|
|
21
21
|
import { runsRoot } from '../lib/runner/runs-root.mjs';
|
|
@@ -64,16 +64,16 @@ if (beforeText === null || scopeText === null) pass();
|
|
|
64
64
|
|
|
65
65
|
let outside;
|
|
66
66
|
try {
|
|
67
|
-
// changedPaths tolerates malformed rows; a hook must instead fail open on a damaged baseline.
|
|
68
|
-
if (beforeText.split(/\r?\n/).filter(Boolean).some((row) =>
|
|
69
|
-
!/^(?:\d+\t\d+|-\t-|U\t\d+)\t\S.*$/.test(row))) pass();
|
|
70
67
|
const patterns = scopeText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
71
68
|
if (!patterns.length) pass();
|
|
72
69
|
// The shared snapshot returns empty output on git errors; do not call that a restored tree.
|
|
73
70
|
const repository = git(status.repo, ['rev-parse', '--is-inside-work-tree']);
|
|
74
71
|
if (repository.error || repository.status !== 0 || repository.stdout?.trim() !== 'true') pass();
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
// Plan_73 / 2026-09-30: a damaged or incompatible baseline proves neither a clean
|
|
73
|
+
// tree nor a stray edit. The advisory witness stays silent when the codec refuses it.
|
|
74
|
+
const comparison = compareSnapshots(beforeText, worktreeSnapshot(status.repo));
|
|
75
|
+
if (!comparison.ok) pass();
|
|
76
|
+
const { work } = splitRunChanges(dir, comparison.changed);
|
|
77
77
|
outside = outOfScope(work, patterns);
|
|
78
78
|
} catch {
|
|
79
79
|
pass();
|
package/src/home/lib/agents.mjs
CHANGED
|
@@ -12,7 +12,7 @@ export const AGENTS = {
|
|
|
12
12
|
'codex-build': { role: 'build', budget: 25, writes: true, result: 'result.json' },
|
|
13
13
|
'codex-review': { role: 'review', budget: 20, writes: false, result: 'review.json' },
|
|
14
14
|
// Plan_59: design advice needs a bounded scope pass before the continued decision pass.
|
|
15
|
-
'codex-advisor': { role: 'advisor', budget: { scope:
|
|
15
|
+
'codex-advisor': { role: 'advisor', budget: { scope: 10, advise: 25 }, writes: false, result: 'result.json' },
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
export const agentRole = (agent) => AGENTS[agent]?.role;
|
|
@@ -6,16 +6,34 @@
|
|
|
6
6
|
* (`config-edit.mjs` keeps its own compare-and-swap publish for that reason).
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import fs from 'node:fs';
|
|
10
9
|
import path from 'node:path';
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
function removeIfPresent(remove, temporary) {
|
|
12
|
+
try {
|
|
13
|
+
remove(temporary);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function publishJsonAtomic(file, record, operations) {
|
|
14
20
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
15
21
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
22
|
+
operations.mkdir(path.dirname(file), { recursive: true });
|
|
23
|
+
operations.write(temporary, `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx' });
|
|
24
|
+
operations.rename(temporary, file);
|
|
18
25
|
} finally {
|
|
19
|
-
|
|
26
|
+
removeIfPresent(operations.unlink, temporary);
|
|
20
27
|
}
|
|
21
28
|
}
|
|
29
|
+
|
|
30
|
+
export function writeHomeJsonAtomic(writer, id, file, record) {
|
|
31
|
+
// Plan_65 B2: `state/` is shared by artifacts, so validate the file before mkdir can create it.
|
|
32
|
+
writer.assertArtifact(id, file);
|
|
33
|
+
return publishJsonAtomic(file, record, {
|
|
34
|
+
mkdir(directory, options) { return writer.mkdirSync(id, directory, options); },
|
|
35
|
+
write(temporary, contents, options) { return writer.writeFileSync(id, temporary, contents, options); },
|
|
36
|
+
rename(temporary, target) { return writer.renameSync(id, temporary, target); },
|
|
37
|
+
unlink(temporary) { return writer.unlinkSync(id, temporary); },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -13,7 +13,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
13
13
|
import { BRAND_CONFIG_PATH } from './brand-home.mjs';
|
|
14
14
|
import { parseJsonText } from './json-file.mjs';
|
|
15
15
|
import { validateRunConfig } from './config-validate.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { createHomeWriter } from './home-write.mjs';
|
|
17
|
+
import { withHomeFileLock } from './file-lock.mjs';
|
|
17
18
|
|
|
18
19
|
function replaceValue(file, raw, key, value) {
|
|
19
20
|
// The shared reader has already checked JSON syntax; tokens locate top-level value spans.
|
|
@@ -72,6 +73,14 @@ async function readBytes(file) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
async function removeTemporary(writer, temporary) {
|
|
77
|
+
try {
|
|
78
|
+
await writer.unlink('config', temporary);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code !== 'ENOENT') throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
75
84
|
function readBytesSync(file) {
|
|
76
85
|
try {
|
|
77
86
|
return fsSync.readFileSync(file);
|
|
@@ -82,15 +91,17 @@ function readBytesSync(file) {
|
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
|
|
94
|
+
const directory = path.dirname(path.resolve(file));
|
|
95
|
+
const writer = createHomeWriter({ root: directory });
|
|
96
|
+
writer.assertArtifact('config', file);
|
|
85
97
|
const reset = key !== null && typeof key === 'object' && !Array.isArray(key)
|
|
86
98
|
&& key.reset === true && Object.keys(key).length === 1 && transform === undefined;
|
|
87
99
|
if (!reset && (typeof key !== 'string' || !key || typeof transform !== 'function')) {
|
|
88
100
|
throw new Error('Config edit requires a non-empty string key and a transformer function or { reset: true }');
|
|
89
101
|
}
|
|
90
|
-
const
|
|
91
|
-
const created = await fs.mkdir(directory, { recursive: true });
|
|
102
|
+
const created = await writer.mkdir('config', directory, { recursive: true });
|
|
92
103
|
try {
|
|
93
|
-
return await
|
|
104
|
+
return await withHomeFileLock(writer, 'config', `${file}.lock`, async () => {
|
|
94
105
|
let collision;
|
|
95
106
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
96
107
|
const original = await readBytes(file);
|
|
@@ -109,9 +120,9 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
|
|
|
109
120
|
}
|
|
110
121
|
// Validate the exact bytes to be persisted, before even creating a temporary file (D27).
|
|
111
122
|
const config = validateRunConfig(file, parseJsonText(file, text));
|
|
112
|
-
await
|
|
123
|
+
await writer.mkdir('config', directory, { recursive: true });
|
|
113
124
|
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);
|
|
114
|
-
const handle = await
|
|
125
|
+
const handle = await writer.open('config', temporary, 'wx');
|
|
115
126
|
try {
|
|
116
127
|
await handle.writeFile(text, 'utf8');
|
|
117
128
|
await handle.close();
|
|
@@ -121,14 +132,14 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
|
|
|
121
132
|
// suspension point between the two, so a competing edit is seen by whoever compares second.
|
|
122
133
|
const current = readBytesSync(file);
|
|
123
134
|
if (original === null ? current !== null : current === null || !original.equals(current)) {
|
|
124
|
-
await
|
|
135
|
+
await removeTemporary(writer, temporary);
|
|
125
136
|
continue;
|
|
126
137
|
}
|
|
127
|
-
|
|
138
|
+
writer.renameSync('config', temporary, file);
|
|
128
139
|
return config;
|
|
129
140
|
} catch (error) {
|
|
130
141
|
await handle.close().catch(() => {});
|
|
131
|
-
await
|
|
142
|
+
await removeTemporary(writer, temporary);
|
|
132
143
|
// Windows refuses the rename outright when another process is publishing onto the same name
|
|
133
144
|
// at that instant: two concurrent `model set` calls returned EPERM here on a live probe, and
|
|
134
145
|
// one operator edit was lost with the file system's wording instead of an answer. A collision
|
|
@@ -149,9 +160,13 @@ export async function editRunConfig(key, transform, file = BRAND_CONFIG_PATH) {
|
|
|
149
160
|
if (created) {
|
|
150
161
|
// Windows mkdir returns a namespaced path; compare that form on both sides of the boundary.
|
|
151
162
|
const boundary = path.toNamespacedPath(path.resolve(created));
|
|
163
|
+
const homeRoot = path.toNamespacedPath(path.resolve(directory));
|
|
152
164
|
for (let current = path.toNamespacedPath(path.resolve(directory));
|
|
153
165
|
current === boundary || current.startsWith(`${boundary}${path.sep}`); current = path.dirname(current)) {
|
|
154
|
-
try {
|
|
166
|
+
try {
|
|
167
|
+
if (current === homeRoot) writer.rmdirSync('config', directory);
|
|
168
|
+
else await fs.rmdir(current);
|
|
169
|
+
} catch { break; }
|
|
155
170
|
}
|
|
156
171
|
}
|
|
157
172
|
throw error;
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { writeHomeJsonAtomic } from './atomic-json.mjs';
|
|
6
6
|
import { parseJsonText } from './json-file.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { withHomeFileLock } from './file-lock.mjs';
|
|
8
|
+
import { stateDirWriter } from './home-write.mjs';
|
|
9
|
+
|
|
10
|
+
// Plan_65 B5: every write goes through the registry id so purge accounts for records, locks and
|
|
11
|
+
// temporaries alike.
|
|
12
|
+
const ARTIFACT = 'dispatcher-state';
|
|
8
13
|
|
|
9
14
|
function assertIdentity(sessionId, agentId) {
|
|
10
15
|
if (typeof sessionId !== 'string' || sessionId.length === 0
|
|
@@ -55,7 +60,8 @@ export async function updateDispatcherState(ids, mutate) {
|
|
|
55
60
|
if (typeof mutate !== 'function') throw new TypeError('mutate must be a function');
|
|
56
61
|
const file = dispatcherStatePath(ids);
|
|
57
62
|
const { sessionId, agentId } = ids;
|
|
58
|
-
|
|
63
|
+
const writer = stateDirWriter(ids.stateDir);
|
|
64
|
+
return withHomeFileLock(writer, ARTIFACT, `${file}.lock`, async () => {
|
|
59
65
|
let current = readDispatcherState(ids);
|
|
60
66
|
if (current === null) {
|
|
61
67
|
current = { sessionId, agentId, createdAt: timestamp(clockValue(ids)) };
|
|
@@ -71,7 +77,7 @@ export async function updateDispatcherState(ids, mutate) {
|
|
|
71
77
|
agentId,
|
|
72
78
|
updatedAt: timestamp(clockValue(ids)),
|
|
73
79
|
};
|
|
74
|
-
|
|
80
|
+
writeHomeJsonAtomic(writer, ARTIFACT, file, record);
|
|
75
81
|
return record;
|
|
76
82
|
}, { description: 'dispatcher state' });
|
|
77
83
|
}
|
|
@@ -86,11 +92,19 @@ export function pruneDispatcherStates({ stateDir, olderThanMs = 7 * 24 * 3600 *
|
|
|
86
92
|
throw error;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
const writer = stateDirWriter(stateDir);
|
|
89
96
|
const cutoff = now - olderThanMs;
|
|
90
97
|
let removed = 0;
|
|
91
98
|
for (const entry of entries) {
|
|
92
99
|
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
93
100
|
const file = path.join(directory, entry.name);
|
|
101
|
+
// Only the registry's 32-hex records are ours to prune; any other name here is kept (Plan_65 D3).
|
|
102
|
+
try {
|
|
103
|
+
writer.assertArtifact(ARTIFACT, file);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error.code === 'EHOMEREGISTRY') continue;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
94
108
|
let old;
|
|
95
109
|
try {
|
|
96
110
|
old = fs.statSync(file).mtimeMs < cutoff;
|
|
@@ -99,13 +113,14 @@ export function pruneDispatcherStates({ stateDir, olderThanMs = 7 * 24 * 3600 *
|
|
|
99
113
|
throw error;
|
|
100
114
|
}
|
|
101
115
|
if (!old) continue;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
116
|
+
for (const target of [file, `${file}.lock`]) {
|
|
117
|
+
try {
|
|
118
|
+
writer.unlinkSync(ARTIFACT, target);
|
|
119
|
+
if (target === file) removed += 1;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error.code !== 'ENOENT') throw error;
|
|
122
|
+
}
|
|
107
123
|
}
|
|
108
|
-
fs.rmSync(`${file}.lock`, { force: true });
|
|
109
124
|
}
|
|
110
125
|
return removed;
|
|
111
126
|
}
|
|
@@ -15,11 +15,11 @@ function waitForLock(delayMs) {
|
|
|
15
15
|
* 2026-08-04). An owner update takes milliseconds, so a lock older than the staleness window
|
|
16
16
|
* belongs to a process that is gone.
|
|
17
17
|
*/
|
|
18
|
-
async function dropStaleLock(lockPath, staleMs) {
|
|
18
|
+
async function dropStaleLock(lockPath, staleMs, remove) {
|
|
19
19
|
try {
|
|
20
20
|
const { mtimeMs } = await fs.stat(lockPath);
|
|
21
21
|
if (Date.now() - mtimeMs < staleMs) return false;
|
|
22
|
-
await
|
|
22
|
+
await remove(lockPath);
|
|
23
23
|
return true;
|
|
24
24
|
} catch {
|
|
25
25
|
// Vanished or unreadable — let the next open() decide rather than guess here.
|
|
@@ -42,17 +42,17 @@ export function isLockTaken(err) {
|
|
|
42
42
|
return LOCK_TAKEN_CODES.has(err?.code);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
async function acquireFileLock(lockPath, { retries, delayMs, staleMs, description }) {
|
|
46
|
-
await
|
|
45
|
+
async function acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, operations) {
|
|
46
|
+
await operations.mkdir(path.dirname(lockPath), { recursive: true });
|
|
47
47
|
let lastTaken;
|
|
48
48
|
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
49
49
|
try {
|
|
50
|
-
const handle = await
|
|
51
|
-
return { handle, lockPath };
|
|
50
|
+
const handle = await operations.open(lockPath);
|
|
51
|
+
return { handle, lockPath, remove: operations.remove };
|
|
52
52
|
} catch (err) {
|
|
53
53
|
if (!isLockTaken(err)) throw err;
|
|
54
54
|
lastTaken = err;
|
|
55
|
-
if (!(await dropStaleLock(lockPath, staleMs))) await waitForLock(delayMs);
|
|
55
|
+
if (!(await dropStaleLock(lockPath, staleMs, operations.remove))) await waitForLock(delayMs);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
// The code is part of the message: a timeout on EPERM points at a delete that never completed,
|
|
@@ -63,14 +63,45 @@ async function acquireFileLock(lockPath, { retries, delayMs, staleMs, descriptio
|
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export async function
|
|
66
|
+
export async function withOutsideFileLock(writer, lockPath, action, {
|
|
67
67
|
retries = 200, delayMs = 5, staleMs = 30_000, description = 'file',
|
|
68
68
|
} = {}) {
|
|
69
|
-
|
|
69
|
+
writer.assertOutside(lockPath);
|
|
70
|
+
const remove = (target) => fs.rm(target, { force: true });
|
|
71
|
+
const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, {
|
|
72
|
+
mkdir(directory, options) { return fs.mkdir(directory, options); },
|
|
73
|
+
open(lockFile) { return fs.open(lockFile, 'wx'); },
|
|
74
|
+
remove,
|
|
75
|
+
});
|
|
70
76
|
try {
|
|
71
77
|
return await action();
|
|
72
78
|
} finally {
|
|
73
79
|
await lock.handle.close().catch(() => {});
|
|
74
|
-
await
|
|
80
|
+
await lock.remove(lock.lockPath).catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function withHomeFileLock(writer, id, lockPath, action, {
|
|
85
|
+
retries = 200, delayMs = 5, staleMs = 30_000, description = 'file',
|
|
86
|
+
} = {}) {
|
|
87
|
+
// Plan_65 B2: the lock's parent is shared by artifacts, so a wrong id is refused before mkdir.
|
|
88
|
+
writer.assertArtifact(id, lockPath);
|
|
89
|
+
const remove = async (target) => {
|
|
90
|
+
try {
|
|
91
|
+
return await writer.unlink(id, target);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const lock = await acquireFileLock(lockPath, { retries, delayMs, staleMs, description }, {
|
|
97
|
+
mkdir(directory, options) { return writer.mkdir(id, directory, options); },
|
|
98
|
+
open(lockFile) { return writer.open(id, lockFile, 'wx'); },
|
|
99
|
+
remove,
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
return await action();
|
|
103
|
+
} finally {
|
|
104
|
+
await lock.handle.close().catch(() => {});
|
|
105
|
+
await lock.remove(lock.lockPath).catch(() => {});
|
|
75
106
|
}
|
|
76
107
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** Records the 2026-09-17..24 handback break; 2026-09-25 proved SDK env is inherited, so identify hosts from transcripts. */
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { writeHomeJsonAtomic } from './atomic-json.mjs';
|
|
5
5
|
import { parseJsonText } from './json-file.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import { withHomeFileLock } from './file-lock.mjs';
|
|
7
|
+
import { stateDirWriter } from './home-write.mjs';
|
|
7
8
|
import { transcriptHostVersion } from './host-version.mjs';
|
|
8
9
|
|
|
9
10
|
export const WITNESS_FILE = 'handback-witness.json';
|
|
@@ -57,14 +58,16 @@ export async function recordHandbackWitness({ stateDir, kind, hostVersion, detai
|
|
|
57
58
|
throw new TypeError('hostVersion must be a string or null');
|
|
58
59
|
}
|
|
59
60
|
const file = path.join(stateDir, WITNESS_FILE);
|
|
60
|
-
|
|
61
|
+
// Plan_65 B6: the witness, its lock and temporaries are the registered handback-witness artifact.
|
|
62
|
+
const writer = stateDirWriter(stateDir);
|
|
63
|
+
return withHomeFileLock(writer, 'handback-witness', `${file}.lock`, async () => {
|
|
61
64
|
const current = readHandbackWitness({ stateDir });
|
|
62
65
|
if (current.corrupt) throw new Error(`Cannot update corrupt handback witness: ${file}`);
|
|
63
66
|
const at = timestamp(now);
|
|
64
67
|
const record = kind === 'seen'
|
|
65
68
|
? { ...current, lastSeen: hostVersion === null ? current.lastSeen : { ...current.lastSeen, [hostVersion]: at } }
|
|
66
69
|
: { ...current, alarms: [...current.alarms, { hostVersion, at, detail }].slice(-20) };
|
|
67
|
-
|
|
70
|
+
writeHomeJsonAtomic(writer, 'handback-witness', file, record);
|
|
68
71
|
return record;
|
|
69
72
|
}, { description: 'handback witness' });
|
|
70
73
|
}
|