ralph-lisa-loop 0.3.0 → 0.3.8
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/README.md +31 -0
- package/dist/cli.js +4 -0
- package/dist/commands.d.ts +1 -0
- package/dist/commands.js +135 -48
- package/dist/test/cli.test.js +65 -0
- package/dist/test/watcher.test.js +377 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -144,6 +144,27 @@ Policy rules:
|
|
|
144
144
|
- Ralph's [RESEARCH] must have substantive content
|
|
145
145
|
- Lisa's [PASS]/[NEEDS_WORK] must include at least 1 reason
|
|
146
146
|
|
|
147
|
+
### Mid-Session Task Update
|
|
148
|
+
Change direction without restarting:
|
|
149
|
+
```bash
|
|
150
|
+
ralph-lisa update-task "switch to REST instead of GraphQL"
|
|
151
|
+
```
|
|
152
|
+
Appends to task.md (preserving history). Task context is auto-injected into work.md submissions and watcher trigger messages so both agents always see the current goal.
|
|
153
|
+
|
|
154
|
+
### Round 1 Mandatory Plan
|
|
155
|
+
Ralph's first submission must be `[PLAN]` — gives Lisa a chance to verify task understanding before coding begins.
|
|
156
|
+
|
|
157
|
+
### Goal Guardian
|
|
158
|
+
Lisa reads task.md before every review and checks for direction drift. Catching misalignment early is prioritized over code-level review.
|
|
159
|
+
|
|
160
|
+
### Watcher v3
|
|
161
|
+
- **Fire-and-forget triggering**: Removed output stability wait and delivery verification for faster turn transitions
|
|
162
|
+
- **30s cooldown**: Prevents re-triggering during normal work
|
|
163
|
+
- **Checkpoint system**: Set `RL_CHECKPOINT_ROUNDS=N` to pause for human review every N rounds
|
|
164
|
+
- **Auto-restart**: Watcher automatically restarts on crash (session-guarded)
|
|
165
|
+
- **Configurable log threshold**: `RL_LOG_MAX_MB` (default 5, min 1) with proportional tail retention
|
|
166
|
+
- **Heartbeat file**: `.dual-agent/.watcher_heartbeat` for external liveness checks
|
|
167
|
+
|
|
147
168
|
### Deadlock Escape
|
|
148
169
|
After 5 rounds without consensus: `[OVERRIDE]` (proceed anyway) or `[HANDOFF]` (escalate to human).
|
|
149
170
|
|
|
@@ -172,6 +193,7 @@ ralph-lisa history # Full history
|
|
|
172
193
|
|
|
173
194
|
# Flow control
|
|
174
195
|
ralph-lisa step "phase-name" # Enter new phase
|
|
196
|
+
ralph-lisa update-task "new direction" # Update task direction mid-session
|
|
175
197
|
ralph-lisa archive [name] # Archive session
|
|
176
198
|
ralph-lisa clean # Clean session
|
|
177
199
|
|
|
@@ -199,6 +221,7 @@ your-project/
|
|
|
199
221
|
│ └── skills/ # Codex skills
|
|
200
222
|
└── .dual-agent/ # Session state
|
|
201
223
|
├── turn.txt # Current turn
|
|
224
|
+
├── task.md # Task goal (updated via update-task)
|
|
202
225
|
├── work.md # Ralph's submissions
|
|
203
226
|
├── review.md # Lisa's submissions
|
|
204
227
|
└── history.md # Full history
|
|
@@ -220,6 +243,14 @@ For auto mode:
|
|
|
220
243
|
- tmux (required)
|
|
221
244
|
- fswatch (macOS) or inotify-tools (Linux) — optional, speeds up turn detection; falls back to polling without them
|
|
222
245
|
|
|
246
|
+
## Environment Variables
|
|
247
|
+
|
|
248
|
+
| Variable | Default | Description |
|
|
249
|
+
|----------|---------|-------------|
|
|
250
|
+
| `RL_POLICY_MODE` | `off` | Policy check mode: `off`, `warn`, `block` |
|
|
251
|
+
| `RL_CHECKPOINT_ROUNDS` | `0` (disabled) | Pause for human review every N rounds |
|
|
252
|
+
| `RL_LOG_MAX_MB` | `5` | Pane log truncation threshold in MB (min 1) |
|
|
253
|
+
|
|
223
254
|
## Ecosystem
|
|
224
255
|
|
|
225
256
|
Part of the [TigerHill](https://github.com/Click-Intelligence-LLC/TigerHill) project family.
|
package/dist/cli.js
CHANGED
|
@@ -65,6 +65,9 @@ switch (cmd) {
|
|
|
65
65
|
case "logs":
|
|
66
66
|
(0, commands_js_1.cmdLogs)(rest);
|
|
67
67
|
break;
|
|
68
|
+
case "update-task":
|
|
69
|
+
(0, commands_js_1.cmdUpdateTask)(rest);
|
|
70
|
+
break;
|
|
68
71
|
case "help":
|
|
69
72
|
case "--help":
|
|
70
73
|
case "-h":
|
|
@@ -111,6 +114,7 @@ function showHelp() {
|
|
|
111
114
|
console.log("");
|
|
112
115
|
console.log("Flow Control:");
|
|
113
116
|
console.log(' ralph-lisa step "name" Enter new step');
|
|
117
|
+
console.log(' ralph-lisa update-task "desc" Update task direction');
|
|
114
118
|
console.log(" ralph-lisa archive [name] Archive session");
|
|
115
119
|
console.log(" ralph-lisa clean Clean session");
|
|
116
120
|
console.log("");
|
package/dist/commands.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export declare function cmdStep(args: string[]): void;
|
|
|
13
13
|
export declare function cmdHistory(): void;
|
|
14
14
|
export declare function cmdArchive(args: string[]): void;
|
|
15
15
|
export declare function cmdClean(): void;
|
|
16
|
+
export declare function cmdUpdateTask(args: string[]): void;
|
|
16
17
|
export declare function cmdUninit(): void;
|
|
17
18
|
export declare function cmdInitProject(args: string[]): void;
|
|
18
19
|
export declare function cmdStart(args: string[]): void;
|
package/dist/commands.js
CHANGED
|
@@ -48,6 +48,7 @@ exports.cmdStep = cmdStep;
|
|
|
48
48
|
exports.cmdHistory = cmdHistory;
|
|
49
49
|
exports.cmdArchive = cmdArchive;
|
|
50
50
|
exports.cmdClean = cmdClean;
|
|
51
|
+
exports.cmdUpdateTask = cmdUpdateTask;
|
|
51
52
|
exports.cmdUninit = cmdUninit;
|
|
52
53
|
exports.cmdInitProject = cmdInitProject;
|
|
53
54
|
exports.cmdStart = cmdStart;
|
|
@@ -183,6 +184,11 @@ function cmdSubmitRalph(args) {
|
|
|
183
184
|
const ts = (0, state_js_1.timestamp)();
|
|
184
185
|
const summary = (0, state_js_1.extractSummary)(content);
|
|
185
186
|
const dir = (0, state_js_1.stateDir)();
|
|
187
|
+
// Auto-inject task context so Lisa always sees the task goal
|
|
188
|
+
// Use last meaningful line (update-task appends new directions at the end)
|
|
189
|
+
const taskFile = (0, state_js_1.readFile)(path.join(dir, "task.md"));
|
|
190
|
+
const taskLines = taskFile.split("\n").filter((l) => l.trim() && !l.startsWith("#") && !l.startsWith("---") && !l.startsWith("Created:") && !l.startsWith("Updated:"));
|
|
191
|
+
const taskContext = taskLines[taskLines.length - 1] || "";
|
|
186
192
|
// Auto-attach files_changed for CODE/FIX submissions
|
|
187
193
|
let filesChangedSection = "";
|
|
188
194
|
if (tag === "CODE" || tag === "FIX") {
|
|
@@ -191,7 +197,8 @@ function cmdSubmitRalph(args) {
|
|
|
191
197
|
filesChangedSection = `**Files Changed**:\n${files.map((f) => `- ${f}`).join("\n")}\n\n`;
|
|
192
198
|
}
|
|
193
199
|
}
|
|
194
|
-
|
|
200
|
+
const taskLine = taskContext ? `**Task**: ${taskContext}\n` : "";
|
|
201
|
+
(0, state_js_1.writeFile)(path.join(dir, "work.md"), `# Ralph Work\n\n## [${tag}] Round ${round} | Step: ${step}\n${taskLine}**Updated**: ${ts}\n**Summary**: ${summary}\n${filesChangedSection ? "\n" + filesChangedSection : "\n"}${content}\n`);
|
|
195
202
|
// External sources (--file/--stdin) get compact history to reduce context bloat
|
|
196
203
|
const historyContent = external
|
|
197
204
|
? `[${tag}] ${summary}\n\n(Full content in work.md)`
|
|
@@ -514,6 +521,22 @@ function cmdClean() {
|
|
|
514
521
|
console.log("Session cleaned");
|
|
515
522
|
}
|
|
516
523
|
}
|
|
524
|
+
// ─── update-task ─────────────────────────────────
|
|
525
|
+
function cmdUpdateTask(args) {
|
|
526
|
+
(0, state_js_1.checkSession)();
|
|
527
|
+
const newTask = args.join(" ");
|
|
528
|
+
if (!newTask) {
|
|
529
|
+
console.error('Usage: ralph-lisa update-task "new task description"');
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
const dir = (0, state_js_1.stateDir)();
|
|
533
|
+
const taskPath = path.join(dir, "task.md");
|
|
534
|
+
const ts = (0, state_js_1.timestamp)();
|
|
535
|
+
// Append new direction with timestamp (preserves history)
|
|
536
|
+
fs.appendFileSync(taskPath, `\n---\nUpdated: ${ts}\n\n${newTask}\n`, "utf-8");
|
|
537
|
+
console.log(`Task updated: ${newTask}`);
|
|
538
|
+
console.log(`(Appended to ${taskPath})`);
|
|
539
|
+
}
|
|
517
540
|
// ─── uninit ──────────────────────────────────────
|
|
518
541
|
const MARKER = "RALPH-LISA-LOOP";
|
|
519
542
|
function cmdUninit() {
|
|
@@ -1044,8 +1067,9 @@ function cmdAuto(args) {
|
|
|
1044
1067
|
// Create watcher script
|
|
1045
1068
|
const watcherScript = path.join(dir, "watcher.sh");
|
|
1046
1069
|
let watcherContent = `#!/bin/bash
|
|
1047
|
-
# Turn watcher
|
|
1070
|
+
# Turn watcher v3 - fire-and-forget agent triggering
|
|
1048
1071
|
# Architecture: polling main loop + optional event acceleration
|
|
1072
|
+
# v3: Removed output stability wait + delivery verification (RLL-001)
|
|
1049
1073
|
|
|
1050
1074
|
STATE_DIR=".dual-agent"
|
|
1051
1075
|
SESSION="${sessionName}"
|
|
@@ -1054,6 +1078,10 @@ SEEN_TURN=""
|
|
|
1054
1078
|
ACKED_TURN=""
|
|
1055
1079
|
FAIL_COUNT=0
|
|
1056
1080
|
ACCEL_PID=""
|
|
1081
|
+
LAST_ACK_TIME=0
|
|
1082
|
+
CHECKPOINT_ROUNDS=\${RL_CHECKPOINT_ROUNDS:-0}
|
|
1083
|
+
CHECKPOINT_REMIND_TIME=0
|
|
1084
|
+
CLEANUP_DONE=0
|
|
1057
1085
|
|
|
1058
1086
|
PANE0_LOG="\${STATE_DIR}/pane0.log"
|
|
1059
1087
|
PANE1_LOG="\${STATE_DIR}/pane1.log"
|
|
@@ -1089,6 +1117,8 @@ echo \$\$ > "\$PID_FILE"
|
|
|
1089
1117
|
# ─── Cleanup trap ────────────────────────────────
|
|
1090
1118
|
|
|
1091
1119
|
cleanup() {
|
|
1120
|
+
if (( CLEANUP_DONE )); then return; fi
|
|
1121
|
+
CLEANUP_DONE=1
|
|
1092
1122
|
echo "[Watcher] Shutting down..."
|
|
1093
1123
|
# Stop pipe-pane capture
|
|
1094
1124
|
tmux pipe-pane -t "\${SESSION}:0.0" 2>/dev/null || true
|
|
@@ -1192,15 +1222,18 @@ check_for_interactive_prompt() {
|
|
|
1192
1222
|
truncate_log_if_needed() {
|
|
1193
1223
|
local pane="\$1"
|
|
1194
1224
|
local log_file="\$2"
|
|
1195
|
-
local
|
|
1225
|
+
local max_mb=\${RL_LOG_MAX_MB:-5}
|
|
1226
|
+
if (( max_mb < 1 )); then max_mb=1; fi
|
|
1227
|
+
local max_bytes=$(( max_mb * 1048576 ))
|
|
1228
|
+
local tail_bytes=$(( max_mb * 102400 )) # ~10% of max
|
|
1196
1229
|
|
|
1197
1230
|
if [[ ! -f "\$log_file" ]]; then return; fi
|
|
1198
1231
|
local size
|
|
1199
1232
|
size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ')
|
|
1200
1233
|
if (( size > max_bytes )); then
|
|
1201
|
-
echo "[Watcher] Truncating \$log_file (\${size} bytes >
|
|
1234
|
+
echo "[Watcher] Truncating \$log_file (\${size} bytes > \${max_mb}MB)"
|
|
1202
1235
|
tmux pipe-pane -t "\${SESSION}:\${pane}" 2>/dev/null || true
|
|
1203
|
-
tail -c
|
|
1236
|
+
tail -c \$tail_bytes "\$log_file" > "\${log_file}.tmp" && mv "\${log_file}.tmp" "\$log_file"
|
|
1204
1237
|
tmux pipe-pane -o -t "\${SESSION}:\${pane}" "cat >> \\"\$log_file\\"" 2>/dev/null || true
|
|
1205
1238
|
fi
|
|
1206
1239
|
}
|
|
@@ -1227,35 +1260,8 @@ send_go_to_pane() {
|
|
|
1227
1260
|
return 1
|
|
1228
1261
|
fi
|
|
1229
1262
|
|
|
1230
|
-
# 3.
|
|
1231
|
-
|
|
1232
|
-
while ! check_output_stable "\$log_file" 5; do
|
|
1233
|
-
wait_count=\$((wait_count + 1))
|
|
1234
|
-
if (( wait_count > 30 )); then
|
|
1235
|
-
echo "[Watcher] WARNING: \$agent_name output not stabilizing after 60s, returning failure"
|
|
1236
|
-
return 1
|
|
1237
|
-
fi
|
|
1238
|
-
sleep 2
|
|
1239
|
-
done
|
|
1240
|
-
|
|
1241
|
-
# 4. Double-confirm stability
|
|
1242
|
-
sleep 2
|
|
1243
|
-
if ! check_output_stable "\$log_file" 2; then
|
|
1244
|
-
echo "[Watcher] \$agent_name output resumed during confirmation wait, returning failure"
|
|
1245
|
-
return 1
|
|
1246
|
-
fi
|
|
1247
|
-
|
|
1248
|
-
# 5. Re-check interactive prompt
|
|
1249
|
-
if check_for_interactive_prompt "\$pane"; then
|
|
1250
|
-
echo "[Watcher] Skipping \$agent_name - interactive prompt detected (post-wait)"
|
|
1251
|
-
return 1
|
|
1252
|
-
fi
|
|
1253
|
-
|
|
1254
|
-
# 6. Record log size before sending
|
|
1255
|
-
local pre_size
|
|
1256
|
-
pre_size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ' || echo 0)
|
|
1257
|
-
|
|
1258
|
-
# 7. Send trigger message + Enter with retry
|
|
1263
|
+
# 3. Send trigger message + Enter with retry
|
|
1264
|
+
# tmux send-keys is synchronous — no need to verify delivery via log growth
|
|
1259
1265
|
# Use first 20 chars as detection marker (long messages wrap in narrow panes)
|
|
1260
1266
|
local detect_marker="\${go_msg:0:20}"
|
|
1261
1267
|
while (( attempt < max_retries )); do
|
|
@@ -1277,16 +1283,13 @@ send_go_to_pane() {
|
|
|
1277
1283
|
fi
|
|
1278
1284
|
done
|
|
1279
1285
|
|
|
1280
|
-
#
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
post_size=\$(wc -c < "\$log_file" 2>/dev/null | tr -d ' ' || echo 0)
|
|
1284
|
-
if (( post_size <= pre_size )); then
|
|
1285
|
-
echo "[Watcher] WARNING: No new output from \$agent_name after sending 'go'"
|
|
1286
|
+
# Check if retries exhausted (message never submitted)
|
|
1287
|
+
if (( attempt >= max_retries )); then
|
|
1288
|
+
echo "[Watcher] FAILED: Could not deliver message to \$agent_name after \$max_retries retries"
|
|
1286
1289
|
return 1
|
|
1287
1290
|
fi
|
|
1288
1291
|
|
|
1289
|
-
echo "[Watcher] OK:
|
|
1292
|
+
echo "[Watcher] OK: Message sent to \$agent_name (fire-and-forget)"
|
|
1290
1293
|
return 0
|
|
1291
1294
|
}
|
|
1292
1295
|
|
|
@@ -1294,6 +1297,15 @@ send_go_to_pane() {
|
|
|
1294
1297
|
|
|
1295
1298
|
trigger_agent() {
|
|
1296
1299
|
local turn="\$1"
|
|
1300
|
+
|
|
1301
|
+
# Read task context for trigger messages (last meaningful line = latest direction)
|
|
1302
|
+
local task_ctx=""
|
|
1303
|
+
if [[ -f "\$STATE_DIR/task.md" ]]; then
|
|
1304
|
+
# Extract last meaningful line (skip header, separators, timestamps)
|
|
1305
|
+
# Consistent with cmdSubmitRalph which also uses last meaningful line
|
|
1306
|
+
task_ctx=\$(grep -v '^#\\|^---\\|^Created:\\|^Updated:\\|^$' "\$STATE_DIR/task.md" 2>/dev/null | tail -1)
|
|
1307
|
+
fi
|
|
1308
|
+
|
|
1297
1309
|
if [[ "\$turn" == "ralph" ]]; then
|
|
1298
1310
|
# Check pause state
|
|
1299
1311
|
if (( PANE0_PAUSED )); then
|
|
@@ -1308,7 +1320,11 @@ trigger_agent() {
|
|
|
1308
1320
|
return 1
|
|
1309
1321
|
fi
|
|
1310
1322
|
fi
|
|
1311
|
-
local ralph_msg="Your turn.
|
|
1323
|
+
local ralph_msg="Your turn."
|
|
1324
|
+
if [[ -n "\$task_ctx" ]]; then
|
|
1325
|
+
ralph_msg="Your turn. Task: \${task_ctx}."
|
|
1326
|
+
fi
|
|
1327
|
+
ralph_msg="\${ralph_msg} Lisa's feedback is ready — run: ralph-lisa read review.md"
|
|
1312
1328
|
send_go_to_pane "0.0" "Ralph" "\$PANE0_LOG" "\$ralph_msg"
|
|
1313
1329
|
local rc=\$?
|
|
1314
1330
|
if (( rc != 0 )); then
|
|
@@ -1338,7 +1354,11 @@ trigger_agent() {
|
|
|
1338
1354
|
return 1
|
|
1339
1355
|
fi
|
|
1340
1356
|
fi
|
|
1341
|
-
local lisa_msg="Your turn.
|
|
1357
|
+
local lisa_msg="Your turn."
|
|
1358
|
+
if [[ -n "\$task_ctx" ]]; then
|
|
1359
|
+
lisa_msg="Your turn. Task: \${task_ctx}."
|
|
1360
|
+
fi
|
|
1361
|
+
lisa_msg="\${lisa_msg} Ralph's work is ready — run: ralph-lisa read work.md"
|
|
1342
1362
|
send_go_to_pane "0.1" "Lisa" "\$PANE1_LOG" "\$lisa_msg"
|
|
1343
1363
|
local rc=\$?
|
|
1344
1364
|
if (( rc != 0 )); then
|
|
@@ -1363,6 +1383,9 @@ trigger_agent() {
|
|
|
1363
1383
|
check_and_trigger() {
|
|
1364
1384
|
check_session_alive
|
|
1365
1385
|
|
|
1386
|
+
# Heartbeat: write epoch so external tools can check watcher liveness
|
|
1387
|
+
echo \$(date +%s) > "\${STATE_DIR}/.watcher_heartbeat"
|
|
1388
|
+
|
|
1366
1389
|
# Truncate logs if too large
|
|
1367
1390
|
truncate_log_if_needed "0.0" "\$PANE0_LOG"
|
|
1368
1391
|
truncate_log_if_needed "0.1" "\$PANE1_LOG"
|
|
@@ -1370,11 +1393,12 @@ check_and_trigger() {
|
|
|
1370
1393
|
if [[ -f "\$STATE_DIR/turn.txt" ]]; then
|
|
1371
1394
|
CURRENT_TURN=\$(cat "\$STATE_DIR/turn.txt" 2>/dev/null || echo "")
|
|
1372
1395
|
|
|
1373
|
-
# Detect new turn change (reset fail count)
|
|
1396
|
+
# Detect new turn change (reset fail count + cooldown)
|
|
1374
1397
|
if [[ -n "\$CURRENT_TURN" && "\$CURRENT_TURN" != "\$SEEN_TURN" ]]; then
|
|
1375
1398
|
echo "[Watcher] Turn changed: \$SEEN_TURN -> \$CURRENT_TURN"
|
|
1376
1399
|
SEEN_TURN="\$CURRENT_TURN"
|
|
1377
1400
|
FAIL_COUNT=0
|
|
1401
|
+
LAST_ACK_TIME=0
|
|
1378
1402
|
|
|
1379
1403
|
# Write round separator to pane logs for transcript tracking
|
|
1380
1404
|
local round_ts
|
|
@@ -1384,6 +1408,43 @@ check_and_trigger() {
|
|
|
1384
1408
|
echo -e "\$round_marker" >> "\$PANE1_LOG" 2>/dev/null || true
|
|
1385
1409
|
fi
|
|
1386
1410
|
|
|
1411
|
+
# Cooldown: skip delivery if last ack was < 30s ago (prevents re-triggering during normal work)
|
|
1412
|
+
# Placed AFTER turn-change detection so new turns are never suppressed
|
|
1413
|
+
if (( LAST_ACK_TIME > 0 )); then
|
|
1414
|
+
local now_epoch
|
|
1415
|
+
now_epoch=\$(date +%s)
|
|
1416
|
+
local elapsed=\$(( now_epoch - LAST_ACK_TIME ))
|
|
1417
|
+
if (( elapsed < 30 )); then
|
|
1418
|
+
return
|
|
1419
|
+
fi
|
|
1420
|
+
fi
|
|
1421
|
+
|
|
1422
|
+
# Checkpoint: pause for user review at configured round intervals
|
|
1423
|
+
if (( CHECKPOINT_ROUNDS > 0 )); then
|
|
1424
|
+
local round
|
|
1425
|
+
round=\$(cat "\$STATE_DIR/round.txt" 2>/dev/null || echo 1)
|
|
1426
|
+
if (( round > 1 )) && (( (round - 1) % CHECKPOINT_ROUNDS == 0 )); then
|
|
1427
|
+
# At checkpoint round — file is source of truth (crash-safe)
|
|
1428
|
+
if [[ -f "\${STATE_DIR}/.checkpoint_ack" ]]; then
|
|
1429
|
+
# Acked — proceed (keep file until round advances past checkpoint)
|
|
1430
|
+
:
|
|
1431
|
+
else
|
|
1432
|
+
# Not acked — pause with periodic 30s reminder
|
|
1433
|
+
local now_epoch
|
|
1434
|
+
now_epoch=\$(date +%s)
|
|
1435
|
+
if (( CHECKPOINT_REMIND_TIME == 0 )) || (( now_epoch - CHECKPOINT_REMIND_TIME >= 30 )); then
|
|
1436
|
+
echo "[Watcher] CHECKPOINT: Round \$round. Review direction before continuing."
|
|
1437
|
+
echo "[Watcher] To continue: touch \${STATE_DIR}/.checkpoint_ack"
|
|
1438
|
+
CHECKPOINT_REMIND_TIME=\$now_epoch
|
|
1439
|
+
fi
|
|
1440
|
+
return
|
|
1441
|
+
fi
|
|
1442
|
+
else
|
|
1443
|
+
# Not at checkpoint round — clean up stale ack from previous checkpoint
|
|
1444
|
+
rm -f "\${STATE_DIR}/.checkpoint_ack"
|
|
1445
|
+
fi
|
|
1446
|
+
fi
|
|
1447
|
+
|
|
1387
1448
|
# Need to deliver? (seen but not yet acked)
|
|
1388
1449
|
if [[ -n "\$SEEN_TURN" && "\$SEEN_TURN" != "\$ACKED_TURN" ]]; then
|
|
1389
1450
|
# Backoff on repeated failures
|
|
@@ -1398,7 +1459,8 @@ check_and_trigger() {
|
|
|
1398
1459
|
if trigger_agent "\$SEEN_TURN"; then
|
|
1399
1460
|
ACKED_TURN="\$SEEN_TURN"
|
|
1400
1461
|
FAIL_COUNT=0
|
|
1401
|
-
|
|
1462
|
+
LAST_ACK_TIME=\$(date +%s)
|
|
1463
|
+
echo "[Watcher] Turn acknowledged: \$SEEN_TURN (cooldown 30s)"
|
|
1402
1464
|
else
|
|
1403
1465
|
FAIL_COUNT=\$((FAIL_COUNT + 1))
|
|
1404
1466
|
echo "[Watcher] Trigger failed (fail_count=\$FAIL_COUNT), will retry next cycle"
|
|
@@ -1409,9 +1471,12 @@ check_and_trigger() {
|
|
|
1409
1471
|
|
|
1410
1472
|
# ─── Main ────────────────────────────────────────
|
|
1411
1473
|
|
|
1412
|
-
echo "[Watcher] Starting
|
|
1474
|
+
echo "[Watcher] Starting v3... (Ctrl+C to stop)"
|
|
1413
1475
|
echo "[Watcher] Monitoring \$STATE_DIR/turn.txt"
|
|
1414
1476
|
echo "[Watcher] Pane logs: \$PANE0_LOG, \$PANE1_LOG"
|
|
1477
|
+
if (( CHECKPOINT_ROUNDS > 0 )); then
|
|
1478
|
+
echo "[Watcher] Checkpoint every \$CHECKPOINT_ROUNDS rounds (RL_CHECKPOINT_ROUNDS)"
|
|
1479
|
+
fi
|
|
1415
1480
|
echo "[Watcher] PID: \$\$"
|
|
1416
1481
|
|
|
1417
1482
|
sleep 5
|
|
@@ -1463,9 +1528,31 @@ done
|
|
|
1463
1528
|
execSync(`tmux send-keys -t "${sessionName}:0.0" "echo '=== Ralph (Claude Code) ===' && ${claudeCmd}" Enter`);
|
|
1464
1529
|
execSync(`tmux send-keys -t "${sessionName}:0.1" "echo '=== Lisa (Codex) ===' && ${codexCmd}" Enter`);
|
|
1465
1530
|
execSync(`tmux select-pane -t "${sessionName}:0.0"`);
|
|
1466
|
-
//
|
|
1531
|
+
// Kill old wrapper process if present (prevents duplication on repeated cmdAuto)
|
|
1532
|
+
const wrapperPidFile = path.join(dir, "watcher_wrapper.pid");
|
|
1533
|
+
if (fs.existsSync(wrapperPidFile)) {
|
|
1534
|
+
const oldWrapperPid = (0, state_js_1.readFile)(wrapperPidFile).trim();
|
|
1535
|
+
if (oldWrapperPid) {
|
|
1536
|
+
try {
|
|
1537
|
+
// Validate process identity before killing (avoid PID reuse hazard)
|
|
1538
|
+
const oldArgs = execSync(`ps -p ${oldWrapperPid} -o args= 2>/dev/null || true`).toString().trim();
|
|
1539
|
+
if (oldArgs && oldArgs.includes("tmux has-session")) {
|
|
1540
|
+
execSync(`kill ${oldWrapperPid} 2>/dev/null || true`);
|
|
1541
|
+
try {
|
|
1542
|
+
execSync(`sleep 0.5`);
|
|
1543
|
+
}
|
|
1544
|
+
catch { /* ignore */ }
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
catch {
|
|
1548
|
+
// ignore — process already dead or PID invalid
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
fs.unlinkSync(wrapperPidFile);
|
|
1552
|
+
}
|
|
1553
|
+
// Watcher runs in background with session-guarded restart loop
|
|
1467
1554
|
const watcherLog = path.join(dir, "watcher.log");
|
|
1468
|
-
execSync(`bash -c 'nohup "${watcherScript}" > "${watcherLog}" 2>&1 &'`);
|
|
1555
|
+
execSync(`bash -c 'nohup bash -c '"'"'while tmux has-session -t "${sessionName}" 2>/dev/null; do bash "${watcherScript}"; EXIT_CODE=$?; if ! tmux has-session -t "${sessionName}" 2>/dev/null; then echo "[Watcher] Session gone, not restarting." >> "${watcherLog}"; break; fi; echo "[Watcher] Exited ($EXIT_CODE), restarting in 5s..." >> "${watcherLog}"; sleep 5; done'"'"' > "${watcherLog}" 2>&1 & echo $! > "${wrapperPidFile}"'`);
|
|
1469
1556
|
console.log("");
|
|
1470
1557
|
console.log(line());
|
|
1471
1558
|
console.log("Auto Mode Started!");
|
package/dist/test/cli.test.js
CHANGED
|
@@ -592,3 +592,68 @@ function run(...args) {
|
|
|
592
592
|
assert.ok(r.stdout.includes("[CODE]"));
|
|
593
593
|
});
|
|
594
594
|
});
|
|
595
|
+
(0, node_test_1.describe)("CLI: update-task", () => {
|
|
596
|
+
(0, node_test_1.beforeEach)(() => {
|
|
597
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
598
|
+
fs.mkdirSync(TMP, { recursive: true });
|
|
599
|
+
run("init", "--minimal");
|
|
600
|
+
});
|
|
601
|
+
(0, node_test_1.afterEach)(() => {
|
|
602
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
603
|
+
});
|
|
604
|
+
(0, node_test_1.it)("creates task entry with timestamp", () => {
|
|
605
|
+
const r = run("update-task", "New direction for the project");
|
|
606
|
+
assert.strictEqual(r.exitCode, 0);
|
|
607
|
+
assert.ok(r.stdout.includes("Task updated"));
|
|
608
|
+
const task = fs.readFileSync(path.join(TMP, ".dual-agent", "task.md"), "utf-8");
|
|
609
|
+
assert.ok(task.includes("New direction for the project"));
|
|
610
|
+
assert.ok(task.includes("Updated:"));
|
|
611
|
+
});
|
|
612
|
+
(0, node_test_1.it)("preserves original task when updating", () => {
|
|
613
|
+
const task = fs.readFileSync(path.join(TMP, ".dual-agent", "task.md"), "utf-8");
|
|
614
|
+
const originalContent = task.trim();
|
|
615
|
+
run("update-task", "Changed direction");
|
|
616
|
+
const updated = fs.readFileSync(path.join(TMP, ".dual-agent", "task.md"), "utf-8");
|
|
617
|
+
// Original content still present
|
|
618
|
+
assert.ok(updated.includes("Waiting for task assignment"));
|
|
619
|
+
assert.ok(updated.includes("Changed direction"));
|
|
620
|
+
});
|
|
621
|
+
(0, node_test_1.it)("fails when no description given", () => {
|
|
622
|
+
const r = run("update-task");
|
|
623
|
+
assert.notStrictEqual(r.exitCode, 0);
|
|
624
|
+
assert.ok(r.stdout.includes("Usage"));
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
(0, node_test_1.describe)("CLI: task context in work.md", () => {
|
|
628
|
+
(0, node_test_1.beforeEach)(() => {
|
|
629
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
630
|
+
fs.mkdirSync(TMP, { recursive: true });
|
|
631
|
+
run("init", "--minimal");
|
|
632
|
+
});
|
|
633
|
+
(0, node_test_1.afterEach)(() => {
|
|
634
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
635
|
+
});
|
|
636
|
+
(0, node_test_1.it)("auto-injects Task field into work.md from task.md", () => {
|
|
637
|
+
// Update task to have a meaningful description
|
|
638
|
+
run("update-task", "Implement login feature");
|
|
639
|
+
run("submit-ralph", "[PLAN] My plan");
|
|
640
|
+
const work = fs.readFileSync(path.join(TMP, ".dual-agent", "work.md"), "utf-8");
|
|
641
|
+
assert.ok(work.includes("**Task**: Implement login feature"), "work.md should contain Task field");
|
|
642
|
+
});
|
|
643
|
+
(0, node_test_1.it)("work.md has no Task field when task.md has no meaningful content", () => {
|
|
644
|
+
// Default task.md has "Waiting for task assignment" — this IS meaningful content
|
|
645
|
+
run("submit-ralph", "[PLAN] My plan");
|
|
646
|
+
const work = fs.readFileSync(path.join(TMP, ".dual-agent", "work.md"), "utf-8");
|
|
647
|
+
// Should include the default task text
|
|
648
|
+
assert.ok(work.includes("**Task**:"), "work.md should have Task field even with default task");
|
|
649
|
+
});
|
|
650
|
+
(0, node_test_1.it)("uses latest task direction after multiple update-task calls", () => {
|
|
651
|
+
run("update-task", "First direction");
|
|
652
|
+
run("update-task", "Second direction");
|
|
653
|
+
run("update-task", "Final direction");
|
|
654
|
+
run("submit-ralph", "[PLAN] My plan");
|
|
655
|
+
const work = fs.readFileSync(path.join(TMP, ".dual-agent", "work.md"), "utf-8");
|
|
656
|
+
assert.ok(work.includes("**Task**: Final direction"), "work.md should use latest task direction");
|
|
657
|
+
assert.ok(!work.includes("**Task**: First direction"), "work.md should NOT use first task direction");
|
|
658
|
+
});
|
|
659
|
+
});
|
|
@@ -51,24 +51,34 @@ function newState() {
|
|
|
51
51
|
seenTurn: "",
|
|
52
52
|
ackedTurn: "",
|
|
53
53
|
failCount: 0,
|
|
54
|
+
lastAckTime: 0,
|
|
54
55
|
panePromptHits: 0,
|
|
55
56
|
panePaused: false,
|
|
56
57
|
panePauseSize: 0,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
/**
|
|
60
|
-
* Simulate check_and_trigger logic (matches bash watcher
|
|
61
|
+
* Simulate check_and_trigger logic (matches bash watcher v3).
|
|
61
62
|
* Two-variable approach: seenTurn (observed) vs ackedTurn (delivered).
|
|
62
63
|
* triggerResult: true = trigger succeeded, false = failed.
|
|
64
|
+
* nowTime: simulated current epoch time for cooldown testing.
|
|
63
65
|
* Returns the action taken.
|
|
64
66
|
*/
|
|
65
|
-
function checkAndTrigger(state, currentTurn, triggerResult) {
|
|
66
|
-
// Detect new turn change
|
|
67
|
+
function checkAndTrigger(state, currentTurn, triggerResult, nowTime = 0) {
|
|
68
|
+
// 1. Detect new turn change (BEFORE cooldown — new turns are never suppressed)
|
|
67
69
|
if (currentTurn && currentTurn !== state.seenTurn) {
|
|
68
70
|
state.seenTurn = currentTurn;
|
|
69
71
|
state.failCount = 0;
|
|
72
|
+
state.lastAckTime = 0; // Reset cooldown on new turn
|
|
70
73
|
}
|
|
71
|
-
//
|
|
74
|
+
// 2. Cooldown: skip delivery if last ack was < 30s ago
|
|
75
|
+
if (state.lastAckTime > 0 && nowTime > 0) {
|
|
76
|
+
const elapsed = nowTime - state.lastAckTime;
|
|
77
|
+
if (elapsed < 30) {
|
|
78
|
+
return "cooldown";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// 3. Need to deliver? (seen but not acked)
|
|
72
82
|
if (state.seenTurn && state.seenTurn !== state.ackedTurn) {
|
|
73
83
|
let mode = "retry";
|
|
74
84
|
if (state.failCount >= 30) {
|
|
@@ -80,6 +90,7 @@ function checkAndTrigger(state, currentTurn, triggerResult) {
|
|
|
80
90
|
if (triggerResult) {
|
|
81
91
|
state.ackedTurn = state.seenTurn;
|
|
82
92
|
state.failCount = 0;
|
|
93
|
+
state.lastAckTime = nowTime;
|
|
83
94
|
return "ack";
|
|
84
95
|
}
|
|
85
96
|
else {
|
|
@@ -89,6 +100,27 @@ function checkAndTrigger(state, currentTurn, triggerResult) {
|
|
|
89
100
|
}
|
|
90
101
|
return "noop";
|
|
91
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Simulate send_go_to_pane retry-exhaustion logic (v3).
|
|
105
|
+
* Returns true if message was delivered, false if retries exhausted.
|
|
106
|
+
*/
|
|
107
|
+
function simulateSendGo(agentAlive, interactivePrompt, enterRegistered, // per-attempt: did Enter register?
|
|
108
|
+
maxRetries = 3) {
|
|
109
|
+
if (!agentAlive)
|
|
110
|
+
return false;
|
|
111
|
+
if (interactivePrompt)
|
|
112
|
+
return false;
|
|
113
|
+
let attempt = 0;
|
|
114
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
115
|
+
if (enterRegistered[i] !== false) {
|
|
116
|
+
// Enter registered (message submitted)
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
attempt++;
|
|
120
|
+
}
|
|
121
|
+
// All retries exhausted — message never submitted
|
|
122
|
+
return attempt < maxRetries;
|
|
123
|
+
}
|
|
92
124
|
/**
|
|
93
125
|
* Simulate interactive prompt pause/resume logic.
|
|
94
126
|
* Returns whether send_go should proceed.
|
|
@@ -245,3 +277,344 @@ function handleInteractivePrompt(state, promptDetected, outputChanged, currentLo
|
|
|
245
277
|
assert.strictEqual(s.panePromptHits, 0);
|
|
246
278
|
});
|
|
247
279
|
});
|
|
280
|
+
(0, node_test_1.describe)("Watcher: send_go_to_pane retry exhaustion (RLL-001)", () => {
|
|
281
|
+
(0, node_test_1.it)("returns false when all retries fail (Enter never registers)", () => {
|
|
282
|
+
const result = simulateSendGo(true, false, [false, false, false]);
|
|
283
|
+
assert.strictEqual(result, false);
|
|
284
|
+
});
|
|
285
|
+
(0, node_test_1.it)("returns true when first attempt succeeds", () => {
|
|
286
|
+
const result = simulateSendGo(true, false, [true, false, false]);
|
|
287
|
+
assert.strictEqual(result, true);
|
|
288
|
+
});
|
|
289
|
+
(0, node_test_1.it)("returns true when second attempt succeeds", () => {
|
|
290
|
+
const result = simulateSendGo(true, false, [false, true, false]);
|
|
291
|
+
assert.strictEqual(result, true);
|
|
292
|
+
});
|
|
293
|
+
(0, node_test_1.it)("returns false when agent is dead", () => {
|
|
294
|
+
const result = simulateSendGo(false, false, [true, true, true]);
|
|
295
|
+
assert.strictEqual(result, false);
|
|
296
|
+
});
|
|
297
|
+
(0, node_test_1.it)("returns false when interactive prompt detected", () => {
|
|
298
|
+
const result = simulateSendGo(true, true, [true, true, true]);
|
|
299
|
+
assert.strictEqual(result, false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
(0, node_test_1.describe)("Watcher: cooldown does not block new turns (RLL-001)", () => {
|
|
303
|
+
(0, node_test_1.it)("cooldown suppresses re-delivery for same turn", () => {
|
|
304
|
+
const s = newState();
|
|
305
|
+
// Ack ralph at t=100
|
|
306
|
+
const action1 = checkAndTrigger(s, "ralph", true, 100);
|
|
307
|
+
assert.strictEqual(action1, "ack");
|
|
308
|
+
assert.strictEqual(s.lastAckTime, 100);
|
|
309
|
+
// Same turn at t=110 (within 30s) → cooldown kicks in before reaching noop
|
|
310
|
+
const action2 = checkAndTrigger(s, "ralph", true, 110);
|
|
311
|
+
assert.strictEqual(action2, "cooldown");
|
|
312
|
+
});
|
|
313
|
+
(0, node_test_1.it)("new turn within 30s is NOT suppressed by cooldown", () => {
|
|
314
|
+
const s = newState();
|
|
315
|
+
// Ack ralph at t=100
|
|
316
|
+
checkAndTrigger(s, "ralph", true, 100);
|
|
317
|
+
assert.strictEqual(s.lastAckTime, 100);
|
|
318
|
+
// New turn (lisa) at t=110 — within 30s of last ack
|
|
319
|
+
// Turn-change detection resets lastAckTime to 0, so cooldown does not apply
|
|
320
|
+
const action = checkAndTrigger(s, "lisa", true, 110);
|
|
321
|
+
assert.strictEqual(action, "ack");
|
|
322
|
+
assert.strictEqual(s.ackedTurn, "lisa");
|
|
323
|
+
assert.strictEqual(s.lastAckTime, 110);
|
|
324
|
+
});
|
|
325
|
+
(0, node_test_1.it)("cooldown expires after 30s for failed re-delivery", () => {
|
|
326
|
+
const s = newState();
|
|
327
|
+
// Ack ralph at t=100
|
|
328
|
+
checkAndTrigger(s, "ralph", true, 100);
|
|
329
|
+
// Force unacked state (simulate edge case: ack succeeded but need re-trigger)
|
|
330
|
+
s.ackedTurn = "";
|
|
331
|
+
// At t=110 (within 30s) → cooldown
|
|
332
|
+
const action1 = checkAndTrigger(s, "ralph", true, 110);
|
|
333
|
+
assert.strictEqual(action1, "cooldown");
|
|
334
|
+
// At t=135 (past 30s) → delivers
|
|
335
|
+
const action2 = checkAndTrigger(s, "ralph", true, 135);
|
|
336
|
+
assert.strictEqual(action2, "ack");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
function newCheckpointState(rounds) {
|
|
340
|
+
return { checkpointRounds: rounds, remindTime: 0 };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Simulate checkpoint check in check_and_trigger (matches bash watcher v3).
|
|
344
|
+
* ackFilePresent: whether .checkpoint_ack file exists on disk.
|
|
345
|
+
* nowTime: simulated epoch time.
|
|
346
|
+
* Returns: "pause" (blocked, reminder emitted), "pause_silent" (blocked, no reminder yet),
|
|
347
|
+
* "proceed" (acked or not a checkpoint round), "no_checkpoint" (disabled/not applicable),
|
|
348
|
+
* "cleanup" (not at checkpoint round, stale ack file should be removed).
|
|
349
|
+
*/
|
|
350
|
+
function checkCheckpoint(state, round, ackFilePresent, nowTime) {
|
|
351
|
+
if (state.checkpointRounds <= 0)
|
|
352
|
+
return "no_checkpoint";
|
|
353
|
+
if (round <= 1)
|
|
354
|
+
return "no_checkpoint";
|
|
355
|
+
const isCheckpointRound = (round - 1) % state.checkpointRounds === 0;
|
|
356
|
+
if (isCheckpointRound) {
|
|
357
|
+
// At checkpoint round — file is source of truth (crash-safe)
|
|
358
|
+
if (ackFilePresent) {
|
|
359
|
+
// Acked — proceed (keep file until round advances)
|
|
360
|
+
return "proceed";
|
|
361
|
+
}
|
|
362
|
+
// Not acked — pause with periodic 30s reminder
|
|
363
|
+
if (state.remindTime === 0 || nowTime - state.remindTime >= 30) {
|
|
364
|
+
state.remindTime = nowTime;
|
|
365
|
+
return "pause"; // reminder emitted
|
|
366
|
+
}
|
|
367
|
+
return "pause_silent"; // blocked but no new reminder
|
|
368
|
+
}
|
|
369
|
+
// Not at checkpoint round — clean up stale ack if present
|
|
370
|
+
if (ackFilePresent)
|
|
371
|
+
return "cleanup";
|
|
372
|
+
return "no_checkpoint";
|
|
373
|
+
}
|
|
374
|
+
(0, node_test_1.describe)("Watcher: checkpoint round detection (RLL-003)", () => {
|
|
375
|
+
(0, node_test_1.it)("pauses at checkpoint round when not acked", () => {
|
|
376
|
+
const s = newCheckpointState(3);
|
|
377
|
+
// round=4 → (4-1)%3==0 → pause
|
|
378
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 100), "pause");
|
|
379
|
+
});
|
|
380
|
+
(0, node_test_1.it)("proceeds at checkpoint round when ack file present", () => {
|
|
381
|
+
const s = newCheckpointState(3);
|
|
382
|
+
assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
|
|
383
|
+
});
|
|
384
|
+
(0, node_test_1.it)("does not checkpoint at non-checkpoint rounds", () => {
|
|
385
|
+
const s = newCheckpointState(3);
|
|
386
|
+
assert.strictEqual(checkCheckpoint(s, 2, false, 100), "no_checkpoint");
|
|
387
|
+
assert.strictEqual(checkCheckpoint(s, 3, false, 100), "no_checkpoint");
|
|
388
|
+
assert.strictEqual(checkCheckpoint(s, 5, false, 100), "no_checkpoint");
|
|
389
|
+
});
|
|
390
|
+
(0, node_test_1.it)("does not checkpoint at round 1", () => {
|
|
391
|
+
const s = newCheckpointState(3);
|
|
392
|
+
assert.strictEqual(checkCheckpoint(s, 1, false, 100), "no_checkpoint");
|
|
393
|
+
});
|
|
394
|
+
(0, node_test_1.it)("does not checkpoint when disabled (0)", () => {
|
|
395
|
+
const s = newCheckpointState(0);
|
|
396
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 100), "no_checkpoint");
|
|
397
|
+
});
|
|
398
|
+
(0, node_test_1.it)("checkpoints every N rounds correctly", () => {
|
|
399
|
+
const s = newCheckpointState(2);
|
|
400
|
+
// N=2: checkpoints at round 3, 5, 7...
|
|
401
|
+
assert.strictEqual(checkCheckpoint(s, 3, false, 100), "pause");
|
|
402
|
+
assert.strictEqual(checkCheckpoint(s, 5, false, 200), "pause");
|
|
403
|
+
assert.strictEqual(checkCheckpoint(s, 7, false, 300), "pause");
|
|
404
|
+
// Not at round 2, 4, 6
|
|
405
|
+
assert.strictEqual(checkCheckpoint(s, 2, false, 100), "no_checkpoint");
|
|
406
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 100), "no_checkpoint");
|
|
407
|
+
assert.strictEqual(checkCheckpoint(s, 6, false, 100), "no_checkpoint");
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
(0, node_test_1.describe)("Watcher: checkpoint reminder cadence (RLL-003)", () => {
|
|
411
|
+
(0, node_test_1.it)("emits reminder on first pause", () => {
|
|
412
|
+
const s = newCheckpointState(3);
|
|
413
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 100), "pause");
|
|
414
|
+
assert.strictEqual(s.remindTime, 100);
|
|
415
|
+
});
|
|
416
|
+
(0, node_test_1.it)("suppresses reminder within 30s", () => {
|
|
417
|
+
const s = newCheckpointState(3);
|
|
418
|
+
checkCheckpoint(s, 4, false, 100); // first reminder at t=100
|
|
419
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 110), "pause_silent"); // t=110 < 30s
|
|
420
|
+
assert.strictEqual(s.remindTime, 100); // unchanged
|
|
421
|
+
});
|
|
422
|
+
(0, node_test_1.it)("re-emits reminder after 30s", () => {
|
|
423
|
+
const s = newCheckpointState(3);
|
|
424
|
+
checkCheckpoint(s, 4, false, 100); // first at t=100
|
|
425
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 130), "pause"); // t=130 >= 30s
|
|
426
|
+
assert.strictEqual(s.remindTime, 130); // updated
|
|
427
|
+
});
|
|
428
|
+
(0, node_test_1.it)("re-emits multiple times at 30s intervals", () => {
|
|
429
|
+
const s = newCheckpointState(3);
|
|
430
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 100), "pause");
|
|
431
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 115), "pause_silent");
|
|
432
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 130), "pause");
|
|
433
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 145), "pause_silent");
|
|
434
|
+
assert.strictEqual(checkCheckpoint(s, 4, false, 160), "pause");
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
(0, node_test_1.describe)("Watcher: checkpoint ack lifecycle (RLL-003)", () => {
|
|
438
|
+
(0, node_test_1.it)("ack file persists — repeated checks at same round still proceed", () => {
|
|
439
|
+
const s = newCheckpointState(3);
|
|
440
|
+
// File present at round 4 — proceeds every time (file is NOT deleted)
|
|
441
|
+
assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
|
|
442
|
+
assert.strictEqual(checkCheckpoint(s, 4, true, 110), "proceed");
|
|
443
|
+
assert.strictEqual(checkCheckpoint(s, 4, true, 200), "proceed");
|
|
444
|
+
});
|
|
445
|
+
(0, node_test_1.it)("ack file cleaned up when round advances past checkpoint", () => {
|
|
446
|
+
const s = newCheckpointState(3);
|
|
447
|
+
// Ack at checkpoint round 4
|
|
448
|
+
assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
|
|
449
|
+
// Round advances to 5 (non-checkpoint) — stale ack triggers cleanup
|
|
450
|
+
assert.strictEqual(checkCheckpoint(s, 5, true, 200), "cleanup");
|
|
451
|
+
// After cleanup (file removed), normal no_checkpoint
|
|
452
|
+
assert.strictEqual(checkCheckpoint(s, 5, false, 200), "no_checkpoint");
|
|
453
|
+
});
|
|
454
|
+
(0, node_test_1.it)("next checkpoint round requires fresh ack", () => {
|
|
455
|
+
const s = newCheckpointState(3);
|
|
456
|
+
// Ack round 4, then round advances
|
|
457
|
+
assert.strictEqual(checkCheckpoint(s, 4, true, 100), "proceed");
|
|
458
|
+
// Next checkpoint: round 7 — no ack file → pauses
|
|
459
|
+
assert.strictEqual(checkCheckpoint(s, 7, false, 200), "pause");
|
|
460
|
+
});
|
|
461
|
+
(0, node_test_1.it)("watcher restart at same checkpoint round still proceeds if ack file exists", () => {
|
|
462
|
+
// Simulates crash+restart: in-memory state is fresh but file persists
|
|
463
|
+
const fresh = newCheckpointState(3);
|
|
464
|
+
// File still on disk from before crash — proceeds immediately
|
|
465
|
+
assert.strictEqual(checkCheckpoint(fresh, 4, true, 300), "proceed");
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
// ─── RLL-005: Watcher auto-restart simulations ──
|
|
469
|
+
/**
|
|
470
|
+
* Simulate cleanup guard flag (RLL-005).
|
|
471
|
+
* Returns number of times cleanup body actually executed.
|
|
472
|
+
*/
|
|
473
|
+
function simulateCleanup(signals) {
|
|
474
|
+
let cleanupDone = 0;
|
|
475
|
+
let executions = 0;
|
|
476
|
+
for (let i = 0; i < signals; i++) {
|
|
477
|
+
if (cleanupDone)
|
|
478
|
+
continue;
|
|
479
|
+
cleanupDone = 1;
|
|
480
|
+
executions++;
|
|
481
|
+
}
|
|
482
|
+
return executions;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Simulate session-guarded restart loop (RLL-005).
|
|
486
|
+
* watcherExitCodes: sequence of exit codes from watcher runs.
|
|
487
|
+
* sessionAliveAfter: for each run index, whether tmux session is alive after exit.
|
|
488
|
+
* Returns number of watcher launches.
|
|
489
|
+
*/
|
|
490
|
+
function simulateRestartLoop(watcherExitCodes, sessionAliveAfter) {
|
|
491
|
+
let launches = 0;
|
|
492
|
+
for (let i = 0; i < watcherExitCodes.length; i++) {
|
|
493
|
+
// Loop condition: session must be alive to enter
|
|
494
|
+
if (i > 0 && !sessionAliveAfter[i - 1])
|
|
495
|
+
break;
|
|
496
|
+
launches++;
|
|
497
|
+
// After watcher exits, check session
|
|
498
|
+
if (!sessionAliveAfter[i])
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
return launches;
|
|
502
|
+
}
|
|
503
|
+
(0, node_test_1.describe)("Watcher: cleanup guard flag (RLL-005)", () => {
|
|
504
|
+
(0, node_test_1.it)("executes cleanup exactly once on single signal", () => {
|
|
505
|
+
assert.strictEqual(simulateCleanup(1), 1);
|
|
506
|
+
});
|
|
507
|
+
(0, node_test_1.it)("executes cleanup exactly once on double signal", () => {
|
|
508
|
+
assert.strictEqual(simulateCleanup(2), 1);
|
|
509
|
+
});
|
|
510
|
+
(0, node_test_1.it)("executes cleanup exactly once on triple signal", () => {
|
|
511
|
+
assert.strictEqual(simulateCleanup(3), 1);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
(0, node_test_1.describe)("Watcher: session-guarded restart loop (RLL-005)", () => {
|
|
515
|
+
(0, node_test_1.it)("restarts after crash when session is alive", () => {
|
|
516
|
+
// Watcher crashes (exit 1), session alive → restarts, then exits clean
|
|
517
|
+
const launches = simulateRestartLoop([1, 0], [true, true]);
|
|
518
|
+
assert.strictEqual(launches, 2);
|
|
519
|
+
});
|
|
520
|
+
(0, node_test_1.it)("does NOT restart when session is gone after exit", () => {
|
|
521
|
+
// Watcher exits, session gone → no restart
|
|
522
|
+
const launches = simulateRestartLoop([0, 0], [false, true]);
|
|
523
|
+
assert.strictEqual(launches, 1);
|
|
524
|
+
});
|
|
525
|
+
(0, node_test_1.it)("does NOT restart when session disappears mid-crash", () => {
|
|
526
|
+
// Watcher crashes (exit 1), session gone → no restart
|
|
527
|
+
const launches = simulateRestartLoop([1, 0], [false, true]);
|
|
528
|
+
assert.strictEqual(launches, 1);
|
|
529
|
+
});
|
|
530
|
+
(0, node_test_1.it)("handles multiple crashes before stable run", () => {
|
|
531
|
+
// 3 crashes then stable exit, session alive throughout
|
|
532
|
+
const launches = simulateRestartLoop([1, 1, 1, 0], [true, true, true, true]);
|
|
533
|
+
assert.strictEqual(launches, 4);
|
|
534
|
+
});
|
|
535
|
+
(0, node_test_1.it)("stops after session teardown mid-sequence", () => {
|
|
536
|
+
// 2 crashes (session alive), then session gone on 3rd exit
|
|
537
|
+
const launches = simulateRestartLoop([1, 1, 1], [true, true, false]);
|
|
538
|
+
assert.strictEqual(launches, 3);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
/**
|
|
542
|
+
* Simulate wrapper singleton management with process-identity validation (RLL-005).
|
|
543
|
+
* processArgs: what `ps -p PID -o args=` returns for the old PID.
|
|
544
|
+
* - null = process already dead (PID not running)
|
|
545
|
+
* - string = process args (must contain "tmux has-session" to be a valid wrapper)
|
|
546
|
+
* Returns whether the old PID was killed.
|
|
547
|
+
*/
|
|
548
|
+
function simulateWrapperKill(oldPidExists, processArgs) {
|
|
549
|
+
if (!oldPidExists)
|
|
550
|
+
return "no_pid_file";
|
|
551
|
+
if (processArgs === null)
|
|
552
|
+
return "skipped_dead";
|
|
553
|
+
if (!processArgs.includes("tmux has-session"))
|
|
554
|
+
return "skipped_wrong_process";
|
|
555
|
+
return "killed";
|
|
556
|
+
}
|
|
557
|
+
(0, node_test_1.describe)("Watcher: wrapper singleton management (RLL-005)", () => {
|
|
558
|
+
(0, node_test_1.it)("kills old wrapper when process matches", () => {
|
|
559
|
+
assert.strictEqual(simulateWrapperKill(true, "bash -c while tmux has-session -t rll ..."), "killed");
|
|
560
|
+
});
|
|
561
|
+
(0, node_test_1.it)("skips kill when no PID file", () => {
|
|
562
|
+
assert.strictEqual(simulateWrapperKill(false, null), "no_pid_file");
|
|
563
|
+
});
|
|
564
|
+
(0, node_test_1.it)("skips kill when PID is dead (process not running)", () => {
|
|
565
|
+
assert.strictEqual(simulateWrapperKill(true, null), "skipped_dead");
|
|
566
|
+
});
|
|
567
|
+
(0, node_test_1.it)("skips kill when PID was reused by unrelated process", () => {
|
|
568
|
+
assert.strictEqual(simulateWrapperKill(true, "/usr/bin/python3 my_app.py"), "skipped_wrong_process");
|
|
569
|
+
});
|
|
570
|
+
(0, node_test_1.it)("skips kill when PID reused by similar but wrong command", () => {
|
|
571
|
+
assert.strictEqual(simulateWrapperKill(true, "bash -c some_other_script.sh"), "skipped_wrong_process");
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
// ─── RLL-006: Pane log threshold simulations ─────
|
|
575
|
+
/**
|
|
576
|
+
* Simulate truncate_log_if_needed logic (RLL-006).
|
|
577
|
+
* Returns whether truncation would occur, and the computed thresholds.
|
|
578
|
+
*/
|
|
579
|
+
function shouldTruncate(fileSize, maxMb) {
|
|
580
|
+
if (maxMb < 1)
|
|
581
|
+
maxMb = 1; // floor guard
|
|
582
|
+
const maxBytes = maxMb * 1048576;
|
|
583
|
+
const tailBytes = maxMb * 102400; // ~10% of max
|
|
584
|
+
return { truncate: fileSize > maxBytes, maxBytes, tailBytes };
|
|
585
|
+
}
|
|
586
|
+
(0, node_test_1.describe)("Watcher: pane log threshold (RLL-006)", () => {
|
|
587
|
+
(0, node_test_1.it)("default 5MB threshold: no truncation under limit", () => {
|
|
588
|
+
const r = shouldTruncate(4 * 1048576, 5); // 4MB
|
|
589
|
+
assert.strictEqual(r.truncate, false);
|
|
590
|
+
assert.strictEqual(r.maxBytes, 5 * 1048576);
|
|
591
|
+
});
|
|
592
|
+
(0, node_test_1.it)("default 5MB threshold: truncates over limit", () => {
|
|
593
|
+
const r = shouldTruncate(6 * 1048576, 5); // 6MB
|
|
594
|
+
assert.strictEqual(r.truncate, true);
|
|
595
|
+
assert.strictEqual(r.tailBytes, 5 * 102400); // 500KB retention
|
|
596
|
+
});
|
|
597
|
+
(0, node_test_1.it)("custom 10MB threshold via RL_LOG_MAX_MB", () => {
|
|
598
|
+
const r = shouldTruncate(8 * 1048576, 10); // 8MB < 10MB
|
|
599
|
+
assert.strictEqual(r.truncate, false);
|
|
600
|
+
assert.strictEqual(r.maxBytes, 10 * 1048576);
|
|
601
|
+
});
|
|
602
|
+
(0, node_test_1.it)("custom 10MB threshold: truncates over limit", () => {
|
|
603
|
+
const r = shouldTruncate(11 * 1048576, 10); // 11MB > 10MB
|
|
604
|
+
assert.strictEqual(r.truncate, true);
|
|
605
|
+
assert.strictEqual(r.tailBytes, 10 * 102400); // 1000KB retention
|
|
606
|
+
});
|
|
607
|
+
(0, node_test_1.it)("tail retention scales with max_mb", () => {
|
|
608
|
+
assert.strictEqual(shouldTruncate(0, 1).tailBytes, 102400); // 1MB → 100KB
|
|
609
|
+
assert.strictEqual(shouldTruncate(0, 5).tailBytes, 512000); // 5MB → 500KB
|
|
610
|
+
assert.strictEqual(shouldTruncate(0, 10).tailBytes, 1024000); // 10MB → 1000KB
|
|
611
|
+
});
|
|
612
|
+
(0, node_test_1.it)("floor guard: zero/negative RL_LOG_MAX_MB clamps to 1MB", () => {
|
|
613
|
+
const r0 = shouldTruncate(2 * 1048576, 0); // 0 → clamped to 1
|
|
614
|
+
assert.strictEqual(r0.maxBytes, 1048576);
|
|
615
|
+
assert.strictEqual(r0.truncate, true); // 2MB > 1MB
|
|
616
|
+
const rNeg = shouldTruncate(500000, -3); // negative → clamped to 1
|
|
617
|
+
assert.strictEqual(rNeg.maxBytes, 1048576);
|
|
618
|
+
assert.strictEqual(rNeg.truncate, false); // 500KB < 1MB
|
|
619
|
+
});
|
|
620
|
+
});
|