@matt82198/aesop 0.1.0-beta.2 → 0.1.0-beta.4
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 +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- package/ui/templates/dashboard.html +1202 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
r"""Drain UI inbox submissions, tracking which have been processed.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python inbox_drain.py pending # List unprocessed inbox items
|
|
6
|
+
python inbox_drain.py mark <ISO-ts> # Mark one timestamp as processed
|
|
7
|
+
python inbox_drain.py mark-all # Mark all pending items as processed
|
|
8
|
+
|
|
9
|
+
Purpose:
|
|
10
|
+
Makes inbox submissions (written by the aesop dashboard) survive sessions.
|
|
11
|
+
Maintains a processed-marker file that tracks which items have been actioned.
|
|
12
|
+
Enables /power and daemons to pick up queued work that arrived while no
|
|
13
|
+
session was running.
|
|
14
|
+
|
|
15
|
+
Configuration:
|
|
16
|
+
- Reads aesop.config.json for inbox_path and inbox_seen_path (optional).
|
|
17
|
+
- Env vars override config: AESOP_INBOX_PATH, AESOP_INBOX_SEEN_PATH.
|
|
18
|
+
- Falls back to defaults: state/ui-inbox.md, state/.ui-inbox-seen.
|
|
19
|
+
|
|
20
|
+
Format (ui-inbox.md):
|
|
21
|
+
- [ISO-TS] item text
|
|
22
|
+
- [ISO-TS] item text
|
|
23
|
+
|
|
24
|
+
Subcommands:
|
|
25
|
+
pending: Read inbox, compare against processed-marker, print unprocessed.
|
|
26
|
+
Exit 0. If none exist, print "NO PENDING" and exit 0.
|
|
27
|
+
mark <ts>: Append ISO timestamp to seen-file. If already marked, noop.
|
|
28
|
+
mark-all: Append ALL pending timestamps to seen-file in one write.
|
|
29
|
+
|
|
30
|
+
Robustness:
|
|
31
|
+
- Missing inbox or seen files treated as empty (no crash).
|
|
32
|
+
- Timestamps are exact match (whitespace-preserved).
|
|
33
|
+
- Exit 0 always (never fails on missing files).
|
|
34
|
+
|
|
35
|
+
Output:
|
|
36
|
+
pending: Each unprocessed item as "[TS] text" (one per line).
|
|
37
|
+
mark/mark-all: Prints "[i/total] marked" summary to stderr.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config():
|
|
49
|
+
"""Load aesop.config.json if present; return dict."""
|
|
50
|
+
try:
|
|
51
|
+
config_path = Path.cwd() / 'aesop.config.json'
|
|
52
|
+
if config_path.exists():
|
|
53
|
+
with open(config_path) as f:
|
|
54
|
+
return json.load(f)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_paths(config):
|
|
61
|
+
"""Resolve paths with precedence: env var > config > default."""
|
|
62
|
+
aesop_root = Path.cwd()
|
|
63
|
+
state_root = Path(
|
|
64
|
+
os.environ.get(
|
|
65
|
+
'AESOP_STATE_ROOT',
|
|
66
|
+
config.get('state_root', str(aesop_root / 'state'))
|
|
67
|
+
)
|
|
68
|
+
).expanduser()
|
|
69
|
+
|
|
70
|
+
# inbox_path: env AESOP_INBOX_PATH > config inbox_path > default state/ui-inbox.md
|
|
71
|
+
inbox_path = Path(
|
|
72
|
+
os.environ.get(
|
|
73
|
+
'AESOP_INBOX_PATH',
|
|
74
|
+
config.get('inbox_path', str(state_root / 'ui-inbox.md'))
|
|
75
|
+
)
|
|
76
|
+
).expanduser()
|
|
77
|
+
|
|
78
|
+
# inbox_seen_path: env AESOP_INBOX_SEEN_PATH > config inbox_seen_path > default state/.ui-inbox-seen
|
|
79
|
+
inbox_seen_path = Path(
|
|
80
|
+
os.environ.get(
|
|
81
|
+
'AESOP_INBOX_SEEN_PATH',
|
|
82
|
+
config.get('inbox_seen_path', str(state_root / '.ui-inbox-seen'))
|
|
83
|
+
)
|
|
84
|
+
).expanduser()
|
|
85
|
+
|
|
86
|
+
return inbox_path, inbox_seen_path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
config = load_config()
|
|
90
|
+
INBOX_PATH, SEEN_PATH = resolve_paths(config)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_inbox():
|
|
94
|
+
"""Read inbox.md and return list of (timestamp, text) tuples.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of (ts, text) tuples; empty list if file missing/empty.
|
|
98
|
+
"""
|
|
99
|
+
if not INBOX_PATH.exists():
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
items = []
|
|
103
|
+
try:
|
|
104
|
+
# Try UTF-8 first, fall back to latin-1 for extended-ASCII files
|
|
105
|
+
try:
|
|
106
|
+
with open(INBOX_PATH, "r", encoding="utf-8") as f:
|
|
107
|
+
content = f.read()
|
|
108
|
+
except UnicodeDecodeError:
|
|
109
|
+
with open(INBOX_PATH, "r", encoding="latin-1") as f:
|
|
110
|
+
content = f.read()
|
|
111
|
+
|
|
112
|
+
for line in content.splitlines():
|
|
113
|
+
line = line.rstrip("\r\n")
|
|
114
|
+
if line.startswith("- [") and "]" in line:
|
|
115
|
+
# Parse "- [ISO-TS] text"
|
|
116
|
+
end_bracket = line.index("]")
|
|
117
|
+
ts = line[3:end_bracket] # Extract ISO-TS from - [ISO-TS]
|
|
118
|
+
text = line[end_bracket + 2 :] if end_bracket + 2 < len(line) else ""
|
|
119
|
+
items.append((ts, text))
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
return items
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_seen():
|
|
127
|
+
"""Read seen-file and return set of marked timestamps.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Set of already-processed ISO timestamps; empty set if file missing.
|
|
131
|
+
"""
|
|
132
|
+
if not SEEN_PATH.exists():
|
|
133
|
+
return set()
|
|
134
|
+
|
|
135
|
+
seen = set()
|
|
136
|
+
try:
|
|
137
|
+
try:
|
|
138
|
+
with open(SEEN_PATH, "r", encoding="utf-8") as f:
|
|
139
|
+
content = f.read()
|
|
140
|
+
except UnicodeDecodeError:
|
|
141
|
+
with open(SEEN_PATH, "r", encoding="latin-1") as f:
|
|
142
|
+
content = f.read()
|
|
143
|
+
|
|
144
|
+
for line in content.splitlines():
|
|
145
|
+
ts = line.rstrip("\r\n")
|
|
146
|
+
if ts:
|
|
147
|
+
seen.add(ts)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
return seen
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_pending():
|
|
155
|
+
"""Get list of unprocessed items.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of (ts, text) tuples that haven't been seen yet.
|
|
159
|
+
"""
|
|
160
|
+
inbox = read_inbox()
|
|
161
|
+
seen = read_seen()
|
|
162
|
+
return [(ts, text) for ts, text in inbox if ts not in seen]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_pending():
|
|
166
|
+
"""List unprocessed inbox items. Exit 0."""
|
|
167
|
+
pending = get_pending()
|
|
168
|
+
if not pending:
|
|
169
|
+
print("NO PENDING")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
for ts, text in pending:
|
|
173
|
+
print(f"[{ts}] {text}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_mark(timestamp):
|
|
177
|
+
"""Mark one timestamp as processed.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
timestamp: ISO timestamp string.
|
|
181
|
+
"""
|
|
182
|
+
seen = read_seen()
|
|
183
|
+
if timestamp in seen:
|
|
184
|
+
print(f"[1/1] already marked", file=sys.stderr)
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
SEEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
with open(SEEN_PATH, "a", encoding="utf-8") as f:
|
|
189
|
+
f.write(timestamp + "\n")
|
|
190
|
+
|
|
191
|
+
print(f"[1/1] marked", file=sys.stderr)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def cmd_mark_all():
|
|
195
|
+
"""Mark all pending items as processed in one write.
|
|
196
|
+
|
|
197
|
+
Prints [i/total] summary to stderr.
|
|
198
|
+
"""
|
|
199
|
+
pending = get_pending()
|
|
200
|
+
if not pending:
|
|
201
|
+
print(f"[0/0] no pending items", file=sys.stderr)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
seen = read_seen()
|
|
205
|
+
to_mark = [ts for ts, _ in pending if ts not in seen]
|
|
206
|
+
if not to_mark:
|
|
207
|
+
print(f"[0/{len(pending)}] no new items", file=sys.stderr)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
SEEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
with open(SEEN_PATH, "a", encoding="utf-8") as f:
|
|
212
|
+
for ts in to_mark:
|
|
213
|
+
f.write(ts + "\n")
|
|
214
|
+
|
|
215
|
+
print(f"[{len(to_mark)}/{len(pending)}] marked all", file=sys.stderr)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main():
|
|
219
|
+
"""Main entry point."""
|
|
220
|
+
parser = argparse.ArgumentParser(
|
|
221
|
+
description="Drain UI inbox submissions, tracking processed items.",
|
|
222
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
223
|
+
)
|
|
224
|
+
subparsers = parser.add_subparsers(dest="command", help="Subcommand")
|
|
225
|
+
|
|
226
|
+
# pending subcommand
|
|
227
|
+
subparsers.add_parser("pending", help="List unprocessed inbox items")
|
|
228
|
+
|
|
229
|
+
# mark subcommand
|
|
230
|
+
mark_parser = subparsers.add_parser("mark", help="Mark one item as processed")
|
|
231
|
+
mark_parser.add_argument("timestamp", help="ISO timestamp to mark")
|
|
232
|
+
|
|
233
|
+
# mark-all subcommand
|
|
234
|
+
subparsers.add_parser("mark-all", help="Mark all pending items as processed")
|
|
235
|
+
|
|
236
|
+
args = parser.parse_args()
|
|
237
|
+
|
|
238
|
+
if args.command == "pending":
|
|
239
|
+
cmd_pending()
|
|
240
|
+
elif args.command == "mark":
|
|
241
|
+
cmd_mark(args.timestamp)
|
|
242
|
+
elif args.command == "mark-all":
|
|
243
|
+
cmd_mark_all()
|
|
244
|
+
else:
|
|
245
|
+
parser.print_help()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
main()
|
package/tools/launch_tui.py
CHANGED
|
@@ -22,6 +22,55 @@ import time
|
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def resolve_git_bash_path():
|
|
26
|
+
r"""
|
|
27
|
+
Resolve git-bash.exe path portably in priority order:
|
|
28
|
+
1. env var AESOP_GIT_BASH (if set and exists)
|
|
29
|
+
2. shutil.which('git-bash') (on PATH)
|
|
30
|
+
3. Derived from shutil.which('git') -> ../git-bash.exe on Windows
|
|
31
|
+
4. Hardcoded default: C:\Program Files\Git\git-bash.exe
|
|
32
|
+
|
|
33
|
+
Raises FileNotFoundError with clear message if none found.
|
|
34
|
+
"""
|
|
35
|
+
attempted = []
|
|
36
|
+
|
|
37
|
+
# 1. Check env var
|
|
38
|
+
env_path = os.environ.get("AESOP_GIT_BASH")
|
|
39
|
+
if env_path:
|
|
40
|
+
attempted.append(f"AESOP_GIT_BASH={env_path}")
|
|
41
|
+
if os.path.exists(env_path):
|
|
42
|
+
return env_path
|
|
43
|
+
|
|
44
|
+
# 2. Check which('git-bash')
|
|
45
|
+
which_bash = shutil.which("git-bash")
|
|
46
|
+
if which_bash:
|
|
47
|
+
attempted.append(f"which('git-bash')={which_bash}")
|
|
48
|
+
if os.path.exists(which_bash):
|
|
49
|
+
return which_bash
|
|
50
|
+
|
|
51
|
+
# 3. Derive from which('git')
|
|
52
|
+
which_git = shutil.which("git")
|
|
53
|
+
if which_git:
|
|
54
|
+
# git is typically at C:\Program Files\Git\cmd\git.exe
|
|
55
|
+
# We want C:\Program Files\Git\git-bash.exe (two levels up, then git-bash.exe)
|
|
56
|
+
git_path = Path(which_git)
|
|
57
|
+
derived_bash = git_path.parent.parent / "git-bash.exe"
|
|
58
|
+
attempted.append(f"derived from git={derived_bash}")
|
|
59
|
+
if derived_bash.exists():
|
|
60
|
+
return str(derived_bash)
|
|
61
|
+
|
|
62
|
+
# 4. Hardcoded default
|
|
63
|
+
default_path = r"C:\Program Files\Git\git-bash.exe"
|
|
64
|
+
attempted.append(f"default={default_path}")
|
|
65
|
+
if os.path.exists(default_path):
|
|
66
|
+
return default_path
|
|
67
|
+
|
|
68
|
+
# None found
|
|
69
|
+
raise FileNotFoundError(
|
|
70
|
+
"git-bash.exe not found. Tried (in order): " + ", ".join(attempted)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
25
74
|
def find_terminal():
|
|
26
75
|
"""
|
|
27
76
|
Locate a terminal executable.
|
|
@@ -30,7 +79,7 @@ def find_terminal():
|
|
|
30
79
|
command_builder_fn(script_path, title) returns command list to spawn.
|
|
31
80
|
is_wt_bool: True if using wt.exe (needs bash process tracking); False if git-bash (stable pid).
|
|
32
81
|
"""
|
|
33
|
-
git_bash =
|
|
82
|
+
git_bash = resolve_git_bash_path()
|
|
34
83
|
wt_exe = shutil.which("wt.exe")
|
|
35
84
|
bash_exe = r"C:\Program Files\Git\bin\bash.exe"
|
|
36
85
|
|
|
@@ -56,8 +105,7 @@ def find_terminal():
|
|
|
56
105
|
return wt_exe, wt_cmd, True
|
|
57
106
|
|
|
58
107
|
raise FileNotFoundError(
|
|
59
|
-
"Terminal not found. Tried: Git Bash at "
|
|
60
|
-
f"{git_bash}, Windows Terminal (wt.exe) with bash at {bash_exe}"
|
|
108
|
+
"Terminal not found. Tried: Git Bash, Windows Terminal (wt.exe) with bash at {bash_exe}"
|
|
61
109
|
)
|
|
62
110
|
|
|
63
111
|
|
package/tools/lock.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lock.mjs — Fail-closed atomic lock acquisition for PROPOSALS.md
|
|
3
|
+
*
|
|
4
|
+
* Provides atomic lock operations (mkdir-based) with:
|
|
5
|
+
* - Exponential backoff (configurable base delay, default 10ms)
|
|
6
|
+
* - Configurable timeout (env var AESOP_LOCK_TIMEOUT_MS or config, default 30s)
|
|
7
|
+
* - Stale lock detection and breaking (locks older than 10min are stale)
|
|
8
|
+
* - Fail-closed: throws on timeout instead of proceeding unlocked
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { acquireLock, releaseLock } from './lock.mjs';
|
|
12
|
+
* const lock = acquireLock(proposalsFile);
|
|
13
|
+
* try { ... } finally { releaseLock(lock); }
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
// Configuration: precedence env > default
|
|
20
|
+
const LOCK_BASE_DELAY_MS = parseInt(process.env.AESOP_LOCK_BASE_DELAY_MS || '10', 10);
|
|
21
|
+
const LOCK_TIMEOUT_MS = parseInt(process.env.AESOP_LOCK_TIMEOUT_MS || '30000', 10); // 30s default
|
|
22
|
+
const STALE_LOCK_THRESHOLD = 10 * 60 * 1000; // 10 minutes
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check whether a pid is a live process, cross-platform (Windows + POSIX).
|
|
26
|
+
*
|
|
27
|
+
* A stale lock marker's timestamp alone is not trustworthy: a process with
|
|
28
|
+
* write access to state/ could forge an old timestamp to force-break a
|
|
29
|
+
* LEGITIMATE live lock (data loss on PROPOSALS.md). Signal-0 delivery via
|
|
30
|
+
* process.kill() lets us verify the recorded owner is actually gone before
|
|
31
|
+
* we ever remove its lock directory.
|
|
32
|
+
*
|
|
33
|
+
* @param {number} pid
|
|
34
|
+
* @returns {boolean} true if the pid is alive (or owned by another user —
|
|
35
|
+
* EPERM means a process with that pid exists but we can't signal it, which
|
|
36
|
+
* still counts as alive for staleness purposes), false if no such process.
|
|
37
|
+
*/
|
|
38
|
+
function isPidAlive(pid) {
|
|
39
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
// Signal 0 does not actually send a signal; it only checks existence/permission.
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (e.code === 'ESRCH') {
|
|
48
|
+
return false; // No such process — definitely dead.
|
|
49
|
+
}
|
|
50
|
+
if (e.code === 'EPERM') {
|
|
51
|
+
return true; // Process exists but we lack permission to signal it — alive.
|
|
52
|
+
}
|
|
53
|
+
// Unknown error (e.g. platform quirk): fail closed and assume alive so we
|
|
54
|
+
// never break a lock we can't actually prove is dead.
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Acquire an atomic lock directory for a file.
|
|
61
|
+
* Implements exponential backoff + stale lock breaking.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} filePath - Path to the file to lock (lock dir is {filePath}.lock)
|
|
64
|
+
* @param {Object} opts - Optional: { timeoutMs: number }
|
|
65
|
+
* @returns {string} Lock directory path
|
|
66
|
+
* @throws {Error} On timeout (fail-closed; never returns null/undefined on success)
|
|
67
|
+
*/
|
|
68
|
+
export function acquireLock(filePath, opts = {}) {
|
|
69
|
+
const lockDir = filePath + '.lock';
|
|
70
|
+
const lockMarkerFile = path.join(lockDir, 'pid-timestamp.txt');
|
|
71
|
+
const timeoutMs = opts.timeoutMs !== undefined ? opts.timeoutMs : LOCK_TIMEOUT_MS;
|
|
72
|
+
const startTime = Date.now();
|
|
73
|
+
|
|
74
|
+
let attempt = 0;
|
|
75
|
+
|
|
76
|
+
while (true) {
|
|
77
|
+
const elapsedMs = Date.now() - startTime;
|
|
78
|
+
if (elapsedMs >= timeoutMs) {
|
|
79
|
+
// Timeout exceeded; throw with diagnostics
|
|
80
|
+
let diagnostic = '';
|
|
81
|
+
try {
|
|
82
|
+
if (fs.existsSync(lockDir)) {
|
|
83
|
+
const markerPath = path.join(lockDir, 'pid-timestamp.txt');
|
|
84
|
+
const content = fs.readFileSync(markerPath, 'utf8').trim();
|
|
85
|
+
const lines = content.split('\n');
|
|
86
|
+
if (lines.length >= 2) {
|
|
87
|
+
const holderPid = lines[0];
|
|
88
|
+
const holderAge = Date.now() - parseInt(lines[1], 10) * 1000;
|
|
89
|
+
diagnostic = ` (holder pid: ${holderPid}, lock age: ${Math.round(holderAge / 1000)}s)`;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Ignore diagnostics errors
|
|
94
|
+
}
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Failed to acquire ${path.basename(filePath)}.lock after ${timeoutMs}ms${diagnostic}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
fs.mkdirSync(lockDir, { exclusive: true });
|
|
102
|
+
// Lock acquired; write pid+timestamp for staleness detection
|
|
103
|
+
const lockMarker = `${process.pid}\n${Math.floor(Date.now() / 1000)}\n`;
|
|
104
|
+
try {
|
|
105
|
+
fs.writeFileSync(lockMarkerFile, lockMarker, 'utf8');
|
|
106
|
+
} catch {
|
|
107
|
+
// Marker write failed, but lock is held; continue
|
|
108
|
+
}
|
|
109
|
+
return lockDir;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e.code === 'EEXIST') {
|
|
112
|
+
// Lock exists; check if it's stale
|
|
113
|
+
try {
|
|
114
|
+
const markerPath = path.join(lockDir, 'pid-timestamp.txt');
|
|
115
|
+
const markerContent = fs.readFileSync(markerPath, 'utf8').trim();
|
|
116
|
+
const lines = markerContent.split('\n');
|
|
117
|
+
if (lines.length >= 2) {
|
|
118
|
+
const lockEpoch = parseInt(lines[1], 10);
|
|
119
|
+
const lockAge = Date.now() - lockEpoch * 1000;
|
|
120
|
+
if (lockAge > STALE_LOCK_THRESHOLD) {
|
|
121
|
+
// Timestamp alone is not trustworthy: a process with write
|
|
122
|
+
// access to state/ could forge an old timestamp to break a
|
|
123
|
+
// LEGITIMATE live lock (data loss on PROPOSALS.md). Verify the
|
|
124
|
+
// recorded owner pid is actually dead before reclaiming.
|
|
125
|
+
const pidField = lines[0];
|
|
126
|
+
const holderPid = parseInt(pidField, 10);
|
|
127
|
+
const pidValid = Number.isInteger(holderPid) && holderPid > 0 && String(holderPid) === pidField.trim();
|
|
128
|
+
|
|
129
|
+
let ownerDead;
|
|
130
|
+
if (!pidValid) {
|
|
131
|
+
console.error(
|
|
132
|
+
`Warning: Stale lock marker for ${path.basename(filePath)} has missing/unparseable pid ("${pidField}"); treating as stale-eligible`
|
|
133
|
+
);
|
|
134
|
+
ownerDead = true;
|
|
135
|
+
} else {
|
|
136
|
+
ownerDead = !isPidAlive(holderPid);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (ownerDead) {
|
|
140
|
+
// Stale lock detected; warn and reclaim it
|
|
141
|
+
console.error(
|
|
142
|
+
`Warning: Stale lock detected for ${path.basename(filePath)} (age: ${Math.round(lockAge / 1000)}s, pid: ${pidValid ? holderPid : 'unknown'}); breaking lock`
|
|
143
|
+
);
|
|
144
|
+
try {
|
|
145
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
146
|
+
} catch {
|
|
147
|
+
// Cleanup failed; will retry or timeout
|
|
148
|
+
}
|
|
149
|
+
// Retry immediately after cleanup
|
|
150
|
+
attempt++;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Timestamp is stale but the recorded owner process is still
|
|
154
|
+
// alive — do not break a live holder's lock. Fall through to
|
|
155
|
+
// the backoff/retry below; fail-closed timeout still applies
|
|
156
|
+
// if the holder never releases.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Could not read marker; assume lock is active
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Lock is held; wait and retry with exponential backoff
|
|
164
|
+
attempt++;
|
|
165
|
+
const delayMs = LOCK_BASE_DELAY_MS * Math.pow(2, Math.min(attempt - 1, 5)); // Cap exponential growth at 2^5 = 32x
|
|
166
|
+
const start = Date.now();
|
|
167
|
+
while (Date.now() - start < delayMs && Date.now() - startTime < timeoutMs) {
|
|
168
|
+
// Busy-wait for calculated backoff delay
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Release an atomic lock directory (must be owned by this process).
|
|
179
|
+
* Verifies pid ownership before deletion (fail-safe: won't delete locks from other processes).
|
|
180
|
+
*
|
|
181
|
+
* @param {string|null} lockDir - Lock directory path (safe to pass null)
|
|
182
|
+
*/
|
|
183
|
+
export function releaseLock(lockDir) {
|
|
184
|
+
if (!lockDir) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const markerFile = path.join(lockDir, 'pid-timestamp.txt');
|
|
190
|
+
let shouldDelete = false;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const markerContent = fs.readFileSync(markerFile, 'utf8').trim();
|
|
194
|
+
const lines = markerContent.split('\n');
|
|
195
|
+
if (lines.length >= 1) {
|
|
196
|
+
const lockPid = lines[0];
|
|
197
|
+
if (lockPid === String(process.pid)) {
|
|
198
|
+
shouldDelete = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// If we can't read the marker, don't delete (fail-safe)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (shouldDelete) {
|
|
206
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Ignore cleanup errors
|
|
210
|
+
}
|
|
211
|
+
}
|