loki-mode 6.62.0 → 6.62.1
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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/hooks/migration-hooks.sh +10 -1
- package/autonomy/issue-providers.sh +7 -2
- package/autonomy/run.sh +133 -61
- package/autonomy/sandbox.sh +5 -2
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/memory/engine.py +1 -0
- package/package.json +1 -1
- package/state/manager.py +127 -32
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: loki-mode
|
|
|
3
3
|
description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Loki Mode v6.62.
|
|
6
|
+
# Loki Mode v6.62.1
|
|
7
7
|
|
|
8
8
|
**You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
|
|
9
9
|
|
|
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
|
|
|
267
267
|
| Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
|
|
268
268
|
| Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
|
|
269
269
|
|
|
270
|
-
**v6.62.
|
|
270
|
+
**v6.62.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
6.62.
|
|
1
|
+
6.62.1
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
set -euo pipefail
|
|
33
33
|
|
|
34
|
+
# shellcheck disable=SC2034
|
|
34
35
|
HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
35
36
|
|
|
36
37
|
# Load project-specific hook config if it exists
|
|
@@ -38,14 +39,22 @@ load_migration_hook_config() {
|
|
|
38
39
|
local codebase_path="${1:-.}"
|
|
39
40
|
local config_file="${codebase_path}/.loki/migration-hooks.yaml"
|
|
40
41
|
|
|
41
|
-
# Defaults
|
|
42
|
+
# Defaults (used by other functions in this file; SC2034 disabled for globals-via-function pattern)
|
|
43
|
+
# shellcheck disable=SC2034
|
|
42
44
|
HOOK_POST_FILE_EDIT_ENABLED=true
|
|
45
|
+
# shellcheck disable=SC2034
|
|
43
46
|
HOOK_POST_STEP_ENABLED=true
|
|
47
|
+
# shellcheck disable=SC2034
|
|
44
48
|
HOOK_PRE_PHASE_GATE_ENABLED=true
|
|
49
|
+
# shellcheck disable=SC2034
|
|
45
50
|
HOOK_ON_AGENT_STOP_ENABLED=true
|
|
51
|
+
# shellcheck disable=SC2034
|
|
46
52
|
HOOK_POST_FILE_EDIT_ACTION="run_tests"
|
|
53
|
+
# shellcheck disable=SC2034
|
|
47
54
|
HOOK_POST_FILE_EDIT_ON_FAILURE="block_and_rollback"
|
|
55
|
+
# shellcheck disable=SC2034
|
|
48
56
|
HOOK_POST_STEP_ON_FAILURE="reject_completion"
|
|
57
|
+
# shellcheck disable=SC2034
|
|
49
58
|
HOOK_ON_AGENT_STOP_ON_FAILURE="force_continue"
|
|
50
59
|
|
|
51
60
|
if [[ -f "$config_file" ]] && command -v python3 &>/dev/null; then
|
|
@@ -15,14 +15,19 @@
|
|
|
15
15
|
# JSON with normalized fields: provider, number, title, body, labels, author, url, created_at
|
|
16
16
|
#===============================================================================
|
|
17
17
|
|
|
18
|
-
# Colors (safe to re-source)
|
|
18
|
+
# Colors (safe to re-source; used by scripts that source this file)
|
|
19
|
+
# shellcheck disable=SC2034
|
|
19
20
|
RED='\033[0;31m'
|
|
21
|
+
# shellcheck disable=SC2034
|
|
20
22
|
GREEN='\033[0;32m'
|
|
23
|
+
# shellcheck disable=SC2034
|
|
21
24
|
YELLOW='\033[1;33m'
|
|
25
|
+
# shellcheck disable=SC2034
|
|
22
26
|
CYAN='\033[0;36m'
|
|
23
27
|
NC='\033[0m'
|
|
24
28
|
|
|
25
|
-
# Supported issue providers
|
|
29
|
+
# Supported issue providers (exported for sourcing scripts)
|
|
30
|
+
# shellcheck disable=SC2034
|
|
26
31
|
ISSUE_PROVIDERS=("github" "gitlab" "jira" "azure_devops")
|
|
27
32
|
|
|
28
33
|
# Detect issue provider from a URL or reference
|
package/autonomy/run.sh
CHANGED
|
@@ -183,7 +183,7 @@ if [[ -z "${LOKI_RUNNING_FROM_TEMP:-}" ]] && [[ "${BASH_SOURCE[0]}" == "${0}" ]]
|
|
|
183
183
|
cp "${BASH_SOURCE[0]}" "$TEMP_SCRIPT"
|
|
184
184
|
chmod 700 "$TEMP_SCRIPT"
|
|
185
185
|
# BUG-XC-011: Set trap BEFORE exec so the temp file gets cleaned up
|
|
186
|
-
trap
|
|
186
|
+
trap 'rm -f "$TEMP_SCRIPT"' EXIT
|
|
187
187
|
export LOKI_RUNNING_FROM_TEMP=1
|
|
188
188
|
export LOKI_ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR"
|
|
189
189
|
export LOKI_ORIGINAL_PROJECT_DIR="$PROJECT_DIR"
|
|
@@ -508,6 +508,13 @@ mapping = {
|
|
|
508
508
|
'model.fast': 'LOKI_MODEL_FAST',
|
|
509
509
|
'notify.slack': 'LOKI_SLACK_WEBHOOK',
|
|
510
510
|
'notify.discord': 'LOKI_DISCORD_WEBHOOK',
|
|
511
|
+
'provider': 'LOKI_PROVIDER',
|
|
512
|
+
'issue.provider': 'LOKI_ISSUE_PROVIDER',
|
|
513
|
+
'blind_validation': 'LOKI_BLIND_VALIDATION',
|
|
514
|
+
'adversarial_testing': 'LOKI_ADVERSARIAL_TESTING',
|
|
515
|
+
'spawn_timeout': 'LOKI_SPAWN_TIMEOUT',
|
|
516
|
+
'spawn_retries': 'LOKI_SPAWN_RETRIES',
|
|
517
|
+
'budget': 'LOKI_BUDGET_LIMIT',
|
|
511
518
|
}
|
|
512
519
|
for key, env_var in mapping.items():
|
|
513
520
|
# Try nested dict lookup first, then flat key, then underscore variant
|
|
@@ -2108,8 +2115,10 @@ create_worktree() {
|
|
|
2108
2115
|
git -C "$TARGET_DIR" worktree add "$worktree_path" -b "$branch_name" 2>/dev/null && wt_exit=0 || \
|
|
2109
2116
|
{ git -C "$TARGET_DIR" worktree add "$worktree_path" "$branch_name" 2>/dev/null && wt_exit=0; }
|
|
2110
2117
|
else
|
|
2111
|
-
#
|
|
2112
|
-
|
|
2118
|
+
# BUG-PAR-001: Testing/docs worktrees use -b parallel-<stream> main (not bare main checkout)
|
|
2119
|
+
# This avoids "already checked out" errors and keeps each worktree on its own branch
|
|
2120
|
+
git -C "$TARGET_DIR" worktree add "$worktree_path" -b "parallel-${stream_name}" main 2>/dev/null && wt_exit=0 || \
|
|
2121
|
+
{ git -C "$TARGET_DIR" worktree add "$worktree_path" "parallel-${stream_name}" 2>/dev/null && wt_exit=0; } || \
|
|
2113
2122
|
{ git -C "$TARGET_DIR" worktree add "$worktree_path" HEAD 2>/dev/null && wt_exit=0; }
|
|
2114
2123
|
fi
|
|
2115
2124
|
|
|
@@ -2164,8 +2173,11 @@ remove_worktree() {
|
|
|
2164
2173
|
|
|
2165
2174
|
# Remove worktree (with safety check for rm -rf)
|
|
2166
2175
|
git -C "$TARGET_DIR" worktree remove "$worktree_path" --force 2>/dev/null || {
|
|
2167
|
-
# Safety check
|
|
2168
|
-
|
|
2176
|
+
# BUG-PAR-005: Safety check uses dirname with trailing / to prevent prefix-match false positives
|
|
2177
|
+
# e.g. TARGET_DIR=/foo/bar must not match /foo/bar-other
|
|
2178
|
+
local parent_dir
|
|
2179
|
+
parent_dir="$(dirname "$TARGET_DIR")/"
|
|
2180
|
+
if [[ -n "$worktree_path" && "$worktree_path" != "/" && "$worktree_path" == "${parent_dir}"* ]]; then
|
|
2169
2181
|
rm -rf "$worktree_path" 2>/dev/null
|
|
2170
2182
|
else
|
|
2171
2183
|
log_warn "Skipping unsafe rm -rf for path: $worktree_path"
|
|
@@ -2198,7 +2210,11 @@ spawn_worktree_session() {
|
|
|
2198
2210
|
done
|
|
2199
2211
|
|
|
2200
2212
|
if [ "$active_count" -ge "$MAX_PARALLEL_SESSIONS" ]; then
|
|
2201
|
-
|
|
2213
|
+
# BUG-PAR-014: Max-sessions rejection queues spawn for retry
|
|
2214
|
+
log_warn "Max parallel sessions reached ($MAX_PARALLEL_SESSIONS). Queuing $stream_name for retry."
|
|
2215
|
+
mkdir -p "${TARGET_DIR:-.}/.loki/signals"
|
|
2216
|
+
echo "{\"stream\":\"$stream_name\",\"task\":\"$(echo "$task_prompt" | head -c 200)\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
|
|
2217
|
+
> "${TARGET_DIR:-.}/.loki/signals/SPAWN_QUEUED_${stream_name}"
|
|
2202
2218
|
return 1
|
|
2203
2219
|
fi
|
|
2204
2220
|
|
|
@@ -2249,19 +2265,31 @@ spawn_worktree_session() {
|
|
|
2249
2265
|
|
|
2250
2266
|
# Completion signaling (v6.7.0)
|
|
2251
2267
|
if [ $_wt_exit -eq 0 ]; then
|
|
2252
|
-
#
|
|
2253
|
-
git -C "$worktree_path" add -A
|
|
2268
|
+
# BUG-PAR-006: git add excludes .env, *.key, *.pem, credentials*
|
|
2269
|
+
git -C "$worktree_path" add -A \
|
|
2270
|
+
':!.env' ':!*.key' ':!*.pem' ':!credentials*' 2>/dev/null
|
|
2254
2271
|
git -C "$worktree_path" commit -m "feat($stream_name): worktree work complete" 2>/dev/null || true
|
|
2255
|
-
# Signal
|
|
2272
|
+
# BUG-PAR-008: Signal files written atomically (temp + mv)
|
|
2256
2273
|
mkdir -p "${TARGET_DIR:-.}/.loki/signals"
|
|
2257
|
-
|
|
2274
|
+
local _sig_tmp
|
|
2275
|
+
_sig_tmp=$(mktemp "${TARGET_DIR:-.}/.loki/signals/.tmp.XXXXXX") || true
|
|
2276
|
+
cat > "$_sig_tmp" <<EOSIG
|
|
2258
2277
|
{"stream":"$stream_name","branch":"$(git -C "$worktree_path" branch --show-current 2>/dev/null)","worktree":"$worktree_path","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","exit_code":$_wt_exit}
|
|
2259
2278
|
EOSIG
|
|
2279
|
+
mv "$_sig_tmp" "${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}" 2>/dev/null || \
|
|
2280
|
+
cp "$_sig_tmp" "${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}" 2>/dev/null
|
|
2281
|
+
rm -f "$_sig_tmp" 2>/dev/null
|
|
2260
2282
|
echo "WORKTREE_COMPLETE: $stream_name" >> "$log_file"
|
|
2261
2283
|
else
|
|
2284
|
+
# BUG-PAR-008: Signal files written atomically (temp + mv)
|
|
2262
2285
|
mkdir -p "${TARGET_DIR:-.}/.loki/signals"
|
|
2286
|
+
local _fail_tmp
|
|
2287
|
+
_fail_tmp=$(mktemp "${TARGET_DIR:-.}/.loki/signals/.tmp.XXXXXX") || true
|
|
2263
2288
|
echo "{\"stream\":\"$stream_name\",\"status\":\"failed\",\"exit_code\":$_wt_exit,\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
|
|
2264
|
-
> "$
|
|
2289
|
+
> "$_fail_tmp"
|
|
2290
|
+
mv "$_fail_tmp" "${TARGET_DIR:-.}/.loki/signals/WORKTREE_FAILED_${stream_name}" 2>/dev/null || \
|
|
2291
|
+
cp "$_fail_tmp" "${TARGET_DIR:-.}/.loki/signals/WORKTREE_FAILED_${stream_name}" 2>/dev/null
|
|
2292
|
+
rm -f "$_fail_tmp" 2>/dev/null
|
|
2265
2293
|
fi
|
|
2266
2294
|
) &
|
|
2267
2295
|
|
|
@@ -2284,9 +2312,12 @@ merge_worktree() {
|
|
|
2284
2312
|
return 1
|
|
2285
2313
|
fi
|
|
2286
2314
|
|
|
2315
|
+
# BUG-PAR-013: Signal file parsing falls back to jq when python3 unavailable
|
|
2287
2316
|
local branch worktree_path
|
|
2288
|
-
branch=$(python3 -c "import json; print(json.load(open('$signal_file'))['branch'])" 2>/dev/null)
|
|
2289
|
-
|
|
2317
|
+
branch=$(python3 -c "import json; print(json.load(open('$signal_file'))['branch'])" 2>/dev/null) || \
|
|
2318
|
+
branch=$(jq -r '.branch' "$signal_file" 2>/dev/null) || true
|
|
2319
|
+
worktree_path=$(python3 -c "import json; print(json.load(open('$signal_file'))['worktree'])" 2>/dev/null) || \
|
|
2320
|
+
worktree_path=$(jq -r '.worktree' "$signal_file" 2>/dev/null) || true
|
|
2290
2321
|
|
|
2291
2322
|
if [ -z "$branch" ]; then
|
|
2292
2323
|
log_error "Could not determine branch for: $stream_name"
|
|
@@ -2295,9 +2326,16 @@ merge_worktree() {
|
|
|
2295
2326
|
|
|
2296
2327
|
log_step "Merging worktree: $stream_name (branch: $branch)"
|
|
2297
2328
|
|
|
2298
|
-
#
|
|
2329
|
+
# BUG-PAR-009: Verify git checkout main before merge
|
|
2299
2330
|
local current_branch
|
|
2300
2331
|
current_branch=$(git -C "${TARGET_DIR:-.}" branch --show-current 2>/dev/null)
|
|
2332
|
+
if [ "$current_branch" != "main" ]; then
|
|
2333
|
+
log_info "Switching to main before merge (was on: $current_branch)"
|
|
2334
|
+
if ! git -C "${TARGET_DIR:-.}" checkout main 2>/dev/null; then
|
|
2335
|
+
log_error "Failed to checkout main for merge: $stream_name"
|
|
2336
|
+
return 1
|
|
2337
|
+
fi
|
|
2338
|
+
fi
|
|
2301
2339
|
|
|
2302
2340
|
if git -C "${TARGET_DIR:-.}" merge --no-ff "$branch" -m "merge($stream_name): auto-merge from parallel worktree" 2>&1; then
|
|
2303
2341
|
log_info "Merge successful: $stream_name"
|
|
@@ -2440,50 +2478,51 @@ Output ONLY the resolved file content with no conflict markers. No explanations.
|
|
|
2440
2478
|
}
|
|
2441
2479
|
|
|
2442
2480
|
# Merge a completed feature branch (with AI conflict resolution)
|
|
2481
|
+
# BUG-PAR-011: Not in a subshell -- uses git -C instead of cd
|
|
2482
|
+
# BUG-PAR-003: Strips feature- prefix to avoid feature/feature-auth double-prefix
|
|
2443
2483
|
merge_feature() {
|
|
2444
2484
|
local feature="$1"
|
|
2445
|
-
|
|
2485
|
+
# BUG-PAR-003: Strip feature- prefix if present to avoid double-prefix (feature/feature-auth)
|
|
2486
|
+
local clean_feature="${feature#feature-}"
|
|
2487
|
+
local branch="feature/$clean_feature"
|
|
2446
2488
|
|
|
2447
|
-
log_step "Merging feature: $
|
|
2489
|
+
log_step "Merging feature: $clean_feature"
|
|
2448
2490
|
|
|
2449
|
-
(
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
# Ensure we're on main
|
|
2453
|
-
git checkout main 2>/dev/null
|
|
2491
|
+
# BUG-PAR-011: Ensure we're on main using git -C (no subshell)
|
|
2492
|
+
git -C "$TARGET_DIR" checkout main 2>/dev/null
|
|
2454
2493
|
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2494
|
+
# Attempt merge with no-ff for clear history
|
|
2495
|
+
if git -C "$TARGET_DIR" merge "$branch" --no-ff -m "feat: Merge $clean_feature" 2>/dev/null; then
|
|
2496
|
+
log_info "Merged cleanly: $clean_feature"
|
|
2497
|
+
else
|
|
2498
|
+
# Merge has conflicts - try AI resolution
|
|
2499
|
+
log_warn "Merge conflicts detected - attempting AI resolution"
|
|
2500
|
+
|
|
2501
|
+
if resolve_conflicts_with_ai "$clean_feature"; then
|
|
2502
|
+
# AI resolved conflicts, commit the merge
|
|
2503
|
+
git -C "$TARGET_DIR" commit -m "feat: Merge $clean_feature (AI-resolved conflicts)"
|
|
2504
|
+
audit_agent_action "git_commit" "Committed changes" "merge=$clean_feature,resolution=ai"
|
|
2505
|
+
log_info "Merged with AI conflict resolution: $clean_feature"
|
|
2458
2506
|
else
|
|
2459
|
-
#
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
# AI resolved conflicts, commit the merge
|
|
2464
|
-
git commit -m "feat: Merge $feature (AI-resolved conflicts)"
|
|
2465
|
-
audit_agent_action "git_commit" "Committed changes" "merge=$feature,resolution=ai"
|
|
2466
|
-
log_info "Merged with AI conflict resolution: $feature"
|
|
2467
|
-
else
|
|
2468
|
-
# AI resolution failed, abort merge
|
|
2469
|
-
log_error "AI conflict resolution failed: $feature"
|
|
2470
|
-
git merge --abort 2>/dev/null || true
|
|
2471
|
-
return 1
|
|
2472
|
-
fi
|
|
2507
|
+
# AI resolution failed, abort merge
|
|
2508
|
+
log_error "AI conflict resolution failed: $clean_feature"
|
|
2509
|
+
git -C "$TARGET_DIR" merge --abort 2>/dev/null || true
|
|
2510
|
+
return 1
|
|
2473
2511
|
fi
|
|
2512
|
+
fi
|
|
2474
2513
|
|
|
2475
|
-
|
|
2476
|
-
|
|
2514
|
+
# Remove signal
|
|
2515
|
+
rm -f "$TARGET_DIR/.loki/signals/MERGE_REQUESTED_$feature"
|
|
2477
2516
|
|
|
2478
|
-
|
|
2479
|
-
|
|
2517
|
+
# Remove worktree
|
|
2518
|
+
remove_worktree "feature-$clean_feature"
|
|
2480
2519
|
|
|
2481
|
-
|
|
2482
|
-
|
|
2520
|
+
# Delete branch
|
|
2521
|
+
git -C "$TARGET_DIR" branch -d "$branch" 2>/dev/null || true
|
|
2483
2522
|
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2523
|
+
# Signal for docs update
|
|
2524
|
+
mkdir -p "$TARGET_DIR/.loki/signals"
|
|
2525
|
+
touch "$TARGET_DIR/.loki/signals/DOCS_NEEDED"
|
|
2487
2526
|
}
|
|
2488
2527
|
|
|
2489
2528
|
# Initialize parallel workflow streams
|
|
@@ -2525,7 +2564,9 @@ spawn_feature_stream() {
|
|
|
2525
2564
|
local task_description="$2"
|
|
2526
2565
|
|
|
2527
2566
|
# Check worktree limit
|
|
2528
|
-
|
|
2567
|
+
# BUG-PAR-012: Worktree count subtracts 1 for main (git worktree list includes main)
|
|
2568
|
+
local worktree_count_raw=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l)
|
|
2569
|
+
local worktree_count=$((worktree_count_raw > 0 ? worktree_count_raw - 1 : 0))
|
|
2529
2570
|
if [ "$worktree_count" -ge "$MAX_WORKTREES" ]; then
|
|
2530
2571
|
log_warn "Max worktrees reached ($MAX_WORKTREES). Queuing feature: $feature_name"
|
|
2531
2572
|
return 1
|
|
@@ -2594,12 +2635,37 @@ run_parallel_orchestrator() {
|
|
|
2594
2635
|
|
|
2595
2636
|
# Main orchestrator loop
|
|
2596
2637
|
local running=true
|
|
2597
|
-
trap
|
|
2638
|
+
# BUG-PAR-004: Orchestrator trap handles SIGTERM properly (cleanup + restore global trap + exit)
|
|
2639
|
+
trap 'running=false; cleanup_parallel_streams; trap cleanup INT TERM; exit 0' TERM
|
|
2640
|
+
trap 'running=false; cleanup_parallel_streams' INT
|
|
2598
2641
|
|
|
2599
2642
|
while $running; do
|
|
2600
2643
|
# Check for merge requests
|
|
2601
2644
|
check_merge_queue
|
|
2602
2645
|
|
|
2646
|
+
# BUG-PAR-014: Retry queued spawns when sessions free up
|
|
2647
|
+
local active_count=0
|
|
2648
|
+
for _qpid in "${WORKTREE_PIDS[@]}"; do
|
|
2649
|
+
if kill -0 "$_qpid" 2>/dev/null; then
|
|
2650
|
+
((active_count++))
|
|
2651
|
+
fi
|
|
2652
|
+
done
|
|
2653
|
+
if [ "$active_count" -lt "$MAX_PARALLEL_SESSIONS" ]; then
|
|
2654
|
+
for queued_signal in "${TARGET_DIR:-.}"/.loki/signals/SPAWN_QUEUED_*; do
|
|
2655
|
+
[ -f "$queued_signal" ] || continue
|
|
2656
|
+
local queued_stream
|
|
2657
|
+
queued_stream=$(basename "$queued_signal" | sed 's/SPAWN_QUEUED_//')
|
|
2658
|
+
local queued_task=""
|
|
2659
|
+
queued_task=$(python3 -c "import json; print(json.load(open('$queued_signal'))['task'])" 2>/dev/null) || \
|
|
2660
|
+
queued_task=$(jq -r '.task' "$queued_signal" 2>/dev/null) || true
|
|
2661
|
+
if [ -n "$queued_task" ] && [ -n "${WORKTREE_PATHS[$queued_stream]:-}" ]; then
|
|
2662
|
+
rm -f "$queued_signal"
|
|
2663
|
+
spawn_worktree_session "$queued_stream" "$queued_task" && \
|
|
2664
|
+
log_info "Retried queued spawn: $queued_stream"
|
|
2665
|
+
fi
|
|
2666
|
+
done
|
|
2667
|
+
fi
|
|
2668
|
+
|
|
2603
2669
|
# Check session health
|
|
2604
2670
|
for stream in "${!WORKTREE_PIDS[@]}"; do
|
|
2605
2671
|
local pid="${WORKTREE_PIDS[$stream]}"
|
|
@@ -2613,22 +2679,28 @@ run_parallel_orchestrator() {
|
|
|
2613
2679
|
local state_file="$TARGET_DIR/.loki/state/parallel-streams.json"
|
|
2614
2680
|
mkdir -p "$(dirname "$state_file")"
|
|
2615
2681
|
|
|
2682
|
+
# BUG-PAR-007: Empty worktree map produces valid JSON
|
|
2683
|
+
local worktree_json=""
|
|
2684
|
+
if [ ${#WORKTREE_PATHS[@]} -gt 0 ]; then
|
|
2685
|
+
worktree_json=$(for stream in "${!WORKTREE_PATHS[@]}"; do
|
|
2686
|
+
local path="${WORKTREE_PATHS[$stream]}"
|
|
2687
|
+
local pid="null"
|
|
2688
|
+
if [ -n "${WORKTREE_PIDS[$stream]+x}" ]; then
|
|
2689
|
+
pid="${WORKTREE_PIDS[$stream]}"
|
|
2690
|
+
fi
|
|
2691
|
+
local status="stopped"
|
|
2692
|
+
if [ "$pid" != "null" ] && kill -0 "$pid" 2>/dev/null; then
|
|
2693
|
+
status="running"
|
|
2694
|
+
fi
|
|
2695
|
+
echo " \"$stream\": {\"path\": \"$path\", \"pid\": $pid, \"status\": \"$status\"},"
|
|
2696
|
+
done | sed '$ s/,$//')
|
|
2697
|
+
fi
|
|
2698
|
+
|
|
2616
2699
|
cat > "$state_file" << EOF
|
|
2617
2700
|
{
|
|
2618
2701
|
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
2619
2702
|
"worktrees": {
|
|
2620
|
-
$
|
|
2621
|
-
local path="${WORKTREE_PATHS[$stream]}"
|
|
2622
|
-
local pid="null"
|
|
2623
|
-
if [ -n "${WORKTREE_PIDS[$stream]+x}" ]; then
|
|
2624
|
-
pid="${WORKTREE_PIDS[$stream]}"
|
|
2625
|
-
fi
|
|
2626
|
-
local status="stopped"
|
|
2627
|
-
if [ "$pid" != "null" ] && kill -0 "$pid" 2>/dev/null; then
|
|
2628
|
-
status="running"
|
|
2629
|
-
fi
|
|
2630
|
-
echo " \"$stream\": {\"path\": \"$path\", \"pid\": $pid, \"status\": \"$status\"},"
|
|
2631
|
-
done | sed '$ s/,$//')
|
|
2703
|
+
${worktree_json}
|
|
2632
2704
|
},
|
|
2633
2705
|
"active_sessions": ${#WORKTREE_PIDS[@]},
|
|
2634
2706
|
"max_sessions": $MAX_PARALLEL_SESSIONS
|
package/autonomy/sandbox.sh
CHANGED
|
@@ -904,7 +904,9 @@ except: pass
|
|
|
904
904
|
local container_path="${parts[1]}"
|
|
905
905
|
local mode="${parts[2]:-ro}"
|
|
906
906
|
|
|
907
|
-
# Safe tilde expansion (no eval)
|
|
907
|
+
# Safe tilde expansion (no eval) - SC2088 disabled: tilde is intentionally
|
|
908
|
+
# treated as a literal string here for safe expansion without eval
|
|
909
|
+
# shellcheck disable=SC2088
|
|
908
910
|
if [[ "$host_path" == "~/"* ]]; then
|
|
909
911
|
host_path="$HOME/${host_path#\~/}"
|
|
910
912
|
elif [[ "$host_path" == "~" ]]; then
|
|
@@ -1121,7 +1123,8 @@ start_sandbox() {
|
|
|
1121
1123
|
local c_container="${mount_parts[1]:-}"
|
|
1122
1124
|
local c_mode="${mount_parts[2]:-ro}"
|
|
1123
1125
|
if [[ -n "$c_host" ]] && [[ -n "$c_container" ]]; then
|
|
1124
|
-
# Safe tilde expansion (no eval)
|
|
1126
|
+
# Safe tilde expansion (no eval) - SC2088 disabled: intentional literal match
|
|
1127
|
+
# shellcheck disable=SC2088
|
|
1125
1128
|
if [[ "$c_host" == "~/"* ]]; then
|
|
1126
1129
|
c_host="$HOME/${c_host#\~/}"
|
|
1127
1130
|
elif [[ "$c_host" == "~" ]]; then
|
package/dashboard/__init__.py
CHANGED
package/docs/INSTALLATION.md
CHANGED
package/mcp/__init__.py
CHANGED
package/memory/engine.py
CHANGED
|
@@ -360,6 +360,7 @@ class MemoryEngine:
|
|
|
360
360
|
pattern_id = self.storage.save_pattern(pattern)
|
|
361
361
|
|
|
362
362
|
# Update index
|
|
363
|
+
pattern_dict = pattern.model_dump() if hasattr(pattern, "model_dump") else pattern.__dict__
|
|
363
364
|
self._update_index_with_pattern(pattern_dict)
|
|
364
365
|
|
|
365
366
|
return pattern_id
|
package/package.json
CHANGED
package/state/manager.py
CHANGED
|
@@ -22,6 +22,7 @@ from pathlib import Path
|
|
|
22
22
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
|
23
23
|
from enum import Enum
|
|
24
24
|
from contextlib import contextmanager
|
|
25
|
+
import copy
|
|
25
26
|
import uuid
|
|
26
27
|
import glob as glob_module
|
|
27
28
|
|
|
@@ -102,11 +103,11 @@ class VersionVector:
|
|
|
102
103
|
def concurrent_with(self, other: "VersionVector") -> bool:
|
|
103
104
|
"""Check if two vectors are concurrent (neither dominates).
|
|
104
105
|
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
Identical vectors represent the same causal point, NOT
|
|
107
|
+
concurrent operations, so they return False (BUG-ST-006).
|
|
107
108
|
"""
|
|
108
109
|
if self.versions == other.versions:
|
|
109
|
-
return
|
|
110
|
+
return False
|
|
110
111
|
return not self.dominates(other) and not other.dominates(self)
|
|
111
112
|
|
|
112
113
|
def to_dict(self) -> Dict[str, int]:
|
|
@@ -796,6 +797,9 @@ class StateManager:
|
|
|
796
797
|
"""
|
|
797
798
|
Merge updates into existing state.
|
|
798
799
|
|
|
800
|
+
Holds a file lock across the entire read-modify-write to prevent
|
|
801
|
+
lost updates from concurrent callers (BUG-ST-002).
|
|
802
|
+
|
|
799
803
|
Args:
|
|
800
804
|
file_ref: File reference
|
|
801
805
|
updates: Dictionary of updates to merge
|
|
@@ -804,9 +808,57 @@ class StateManager:
|
|
|
804
808
|
Returns:
|
|
805
809
|
StateChange object
|
|
806
810
|
"""
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
811
|
+
path = self._resolve_path(file_ref)
|
|
812
|
+
|
|
813
|
+
with self._file_lock(path, exclusive=True):
|
|
814
|
+
# Read current state under the lock (bypass cache to get
|
|
815
|
+
# the true on-disk value while we hold the exclusive lock)
|
|
816
|
+
old_value = None
|
|
817
|
+
if path.exists():
|
|
818
|
+
try:
|
|
819
|
+
with open(path, "r") as f:
|
|
820
|
+
old_value = json.load(f)
|
|
821
|
+
except (json.JSONDecodeError, IOError):
|
|
822
|
+
old_value = None
|
|
823
|
+
|
|
824
|
+
current = old_value if old_value is not None else {}
|
|
825
|
+
merged = {**current, **updates}
|
|
826
|
+
change_type = "create" if old_value is None else "update"
|
|
827
|
+
|
|
828
|
+
# Save version before writing new data (SYN-015)
|
|
829
|
+
if self.enable_versioning and old_value is not None:
|
|
830
|
+
self._save_version(file_ref, old_value, source, change_type)
|
|
831
|
+
|
|
832
|
+
# Write atomically (temp file + rename) while still under lock
|
|
833
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
834
|
+
fd, temp_path = tempfile.mkstemp(
|
|
835
|
+
dir=path.parent, prefix=".tmp_", suffix=".json"
|
|
836
|
+
)
|
|
837
|
+
try:
|
|
838
|
+
with os.fdopen(fd, "w") as f:
|
|
839
|
+
json.dump(merged, f, indent=2, default=str)
|
|
840
|
+
shutil.move(temp_path, path)
|
|
841
|
+
except Exception:
|
|
842
|
+
if os.path.exists(temp_path):
|
|
843
|
+
os.unlink(temp_path)
|
|
844
|
+
raise
|
|
845
|
+
|
|
846
|
+
# Update cache
|
|
847
|
+
self._put_in_cache(path, merged)
|
|
848
|
+
|
|
849
|
+
# Create change object
|
|
850
|
+
change = StateChange(
|
|
851
|
+
file_path=str(path.relative_to(self.loki_dir)),
|
|
852
|
+
old_value=old_value,
|
|
853
|
+
new_value=merged,
|
|
854
|
+
change_type=change_type,
|
|
855
|
+
source=source
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
# Broadcast change
|
|
859
|
+
self._broadcast(change)
|
|
860
|
+
|
|
861
|
+
return change
|
|
810
862
|
|
|
811
863
|
def delete_state(
|
|
812
864
|
self,
|
|
@@ -1147,16 +1199,38 @@ class StateManager:
|
|
|
1147
1199
|
return states
|
|
1148
1200
|
|
|
1149
1201
|
def refresh_cache(self) -> None:
|
|
1150
|
-
"""Refresh all cached entries from disk.
|
|
1202
|
+
"""Refresh all cached entries from disk.
|
|
1203
|
+
|
|
1204
|
+
Collects paths under _cache_lock, releases it, reads files
|
|
1205
|
+
(which acquire file locks), then re-acquires _cache_lock to
|
|
1206
|
+
update entries. This avoids an ABBA deadlock between the
|
|
1207
|
+
cache lock and file locks (BUG-ST-001).
|
|
1208
|
+
"""
|
|
1209
|
+
# Step 1: collect paths under the cache lock
|
|
1210
|
+
with self._cache_lock:
|
|
1211
|
+
paths_to_refresh = list(self._cache.keys())
|
|
1212
|
+
|
|
1213
|
+
# Step 2: read files WITHOUT holding _cache_lock
|
|
1214
|
+
refreshed: dict = {}
|
|
1215
|
+
gone: list = []
|
|
1216
|
+
for path_str in paths_to_refresh:
|
|
1217
|
+
path = Path(path_str)
|
|
1218
|
+
if path.exists():
|
|
1219
|
+
data = self._read_file(path)
|
|
1220
|
+
if data:
|
|
1221
|
+
refreshed[path_str] = data
|
|
1222
|
+
else:
|
|
1223
|
+
gone.append(path_str)
|
|
1224
|
+
|
|
1225
|
+
# Step 3: re-acquire _cache_lock and update entries
|
|
1151
1226
|
with self._cache_lock:
|
|
1152
|
-
for path_str in
|
|
1227
|
+
for path_str, data in refreshed.items():
|
|
1153
1228
|
path = Path(path_str)
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
del self._cache[path_str]
|
|
1229
|
+
data_hash = self._compute_hash(data)
|
|
1230
|
+
mtime = path.stat().st_mtime if path.exists() else 0
|
|
1231
|
+
self._cache[path_str] = (data, data_hash, mtime)
|
|
1232
|
+
for path_str in gone:
|
|
1233
|
+
self._cache.pop(path_str, None)
|
|
1160
1234
|
|
|
1161
1235
|
# -------------------------------------------------------------------------
|
|
1162
1236
|
# Optimistic Updates (SYN-014)
|
|
@@ -1231,8 +1305,9 @@ class StateManager:
|
|
|
1231
1305
|
self._pending_updates[path_str] = []
|
|
1232
1306
|
self._pending_updates[path_str].append(pending)
|
|
1233
1307
|
|
|
1234
|
-
# Apply optimistically to local state
|
|
1235
|
-
|
|
1308
|
+
# Apply optimistically to local state -- deepcopy to avoid
|
|
1309
|
+
# mutating the cached dict in-place (BUG-ST-011)
|
|
1310
|
+
current_state = copy.deepcopy(self.get_state(file_ref, default={}))
|
|
1236
1311
|
current_state[key] = value
|
|
1237
1312
|
current_state["_version_vector"] = version_vector.to_dict()
|
|
1238
1313
|
current_state["_last_source"] = source
|
|
@@ -1394,14 +1469,23 @@ class StateManager:
|
|
|
1394
1469
|
return merged
|
|
1395
1470
|
|
|
1396
1471
|
elif isinstance(local, list) and isinstance(remote, list):
|
|
1397
|
-
# Concatenate and deduplicate (preserving order)
|
|
1472
|
+
# Concatenate and deduplicate (preserving order).
|
|
1473
|
+
# Use try/except to handle unhashable types gracefully
|
|
1474
|
+
# by falling back to JSON serialization (BUG-ST-013).
|
|
1398
1475
|
seen = set()
|
|
1399
1476
|
merged = []
|
|
1400
1477
|
for item in local + remote:
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
seen
|
|
1404
|
-
|
|
1478
|
+
try:
|
|
1479
|
+
item_key = json.dumps(item, sort_keys=True, default=str) if isinstance(item, (dict, list)) else item
|
|
1480
|
+
if item_key not in seen:
|
|
1481
|
+
seen.add(item_key)
|
|
1482
|
+
merged.append(item)
|
|
1483
|
+
except TypeError:
|
|
1484
|
+
# Unhashable type -- fall back to JSON string as key
|
|
1485
|
+
item_key = json.dumps(item, sort_keys=True, default=str)
|
|
1486
|
+
if item_key not in seen:
|
|
1487
|
+
seen.add(item_key)
|
|
1488
|
+
merged.append(item)
|
|
1405
1489
|
return merged
|
|
1406
1490
|
|
|
1407
1491
|
else:
|
|
@@ -1598,23 +1682,27 @@ class StateManager:
|
|
|
1598
1682
|
return version
|
|
1599
1683
|
|
|
1600
1684
|
def _cleanup_old_versions(self, file_ref: Union[str, ManagedFile]) -> None:
|
|
1601
|
-
"""Remove versions beyond the retention limit.
|
|
1685
|
+
"""Remove versions beyond the retention limit.
|
|
1686
|
+
|
|
1687
|
+
Only considers files with purely numeric stems so that orphan
|
|
1688
|
+
temp files (e.g. .tmp_xxx.json) are not counted toward the
|
|
1689
|
+
retention limit (BUG-ST-010).
|
|
1690
|
+
"""
|
|
1602
1691
|
history_dir = self._get_history_dir(file_ref)
|
|
1603
1692
|
if not history_dir.exists():
|
|
1604
1693
|
return
|
|
1605
1694
|
|
|
1606
1695
|
version_files = glob_module.glob(str(history_dir / "*.json"))
|
|
1607
|
-
if len(version_files) <= self.version_retention:
|
|
1608
|
-
return
|
|
1609
1696
|
|
|
1610
|
-
#
|
|
1697
|
+
# Filter to numeric stems only (skip temp/orphan files)
|
|
1611
1698
|
version_nums = []
|
|
1612
1699
|
for vf in version_files:
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
version_nums.append((
|
|
1616
|
-
|
|
1617
|
-
|
|
1700
|
+
stem = Path(vf).stem
|
|
1701
|
+
if stem.isdigit():
|
|
1702
|
+
version_nums.append((int(stem), vf))
|
|
1703
|
+
|
|
1704
|
+
if len(version_nums) <= self.version_retention:
|
|
1705
|
+
return
|
|
1618
1706
|
|
|
1619
1707
|
version_nums.sort(key=lambda x: x[0])
|
|
1620
1708
|
to_remove = version_nums[:-self.version_retention]
|
|
@@ -1777,17 +1865,24 @@ class StateManager:
|
|
|
1777
1865
|
|
|
1778
1866
|
# Singleton instance for convenience
|
|
1779
1867
|
_default_manager: Optional[StateManager] = None
|
|
1868
|
+
_default_manager_lock = threading.Lock()
|
|
1780
1869
|
|
|
1781
1870
|
|
|
1782
1871
|
def get_state_manager(
|
|
1783
1872
|
loki_dir: Optional[Union[str, Path]] = None,
|
|
1784
1873
|
**kwargs
|
|
1785
1874
|
) -> StateManager:
|
|
1786
|
-
"""Get the default state manager instance.
|
|
1875
|
+
"""Get the default state manager instance.
|
|
1876
|
+
|
|
1877
|
+
Uses double-checked locking to avoid a race where two threads
|
|
1878
|
+
could both create a StateManager simultaneously (BUG-ST-007).
|
|
1879
|
+
"""
|
|
1787
1880
|
global _default_manager
|
|
1788
1881
|
|
|
1789
1882
|
if _default_manager is None:
|
|
1790
|
-
|
|
1883
|
+
with _default_manager_lock:
|
|
1884
|
+
if _default_manager is None:
|
|
1885
|
+
_default_manager = StateManager(loki_dir, **kwargs)
|
|
1791
1886
|
|
|
1792
1887
|
return _default_manager
|
|
1793
1888
|
|