Background dispatch threads, the periodic scheduler scan, and HTTP
handlers all followed a load_tasks() → modify → save_tasks() pattern
without any mutual exclusion. The atomic_json_write only protects the
write itself; it does NOT prevent a concurrent reader from loading a
stale snapshot and overwriting another thread's changes.
Scenario (before this fix):
1. HTTP handler calls load_tasks() — gets snapshot A
2. Dispatch thread calls load_tasks() — gets the same snapshot A
3. HTTP handler modifies task X, calls save_tasks() — writes A'
4. Dispatch thread modifies task Y, calls save_tasks() — writes A''
(based on A, not A'), silently losing the HTTP handler's changes
to task X
This is a classic TOCTOU (Time-of-Check-Time-of-Use) race. The project
already has atomic_json_update() in file_lock.py that holds an exclusive
lock for the entire read-modify-write cycle, but none of the task
mutation paths used it.
Fix:
- Add modify_tasks(modifier) wrapper around atomic_json_update +
refresh trigger
- Add modify_task(task_id, updater) convenience wrapper for single-task
mutations
- Convert _update_task_scheduler (called from dispatch daemon threads)
to use modify_task
- Convert handle_scheduler_scan (periodic background scanner) to use
modify_tasks, with dispatch side-effects deferred until after the
lock is released
- Convert handle_scheduler_retry and handle_scheduler_rollback to use
modify_task
HTTP handler paths (handle_task_action, handle_review_action, etc.)
still use load_tasks/save_tasks for now — they run in the main thread
and are lower risk — but can be migrated incrementally.
Add 17 regression tests covering: atomic API correctness, scheduler
update persistence, scan stall detection, concurrent write survival
(the actual race), source-level audit of critical paths, and backward
compatibility of load_tasks/save_tasks.
Cherry-pick the SessionsPanel.tsx and server.py fixes from PR #278
that were not covered by the merged #282:
- SessionsPanel: use shared formatDashboardTime for activity timestamps
- server.py: convert lastActive to local timezone in get_task_activity()
OpenClaw only loads SOUL.md (uppercase), but deploy_soul_files() was
writing to soul.md (lowercase), causing the deployed SOUL to be ignored.
Fixes#294
fix: support OPENCLAW_HOME for non-standard OpenClaw paths\n\nAdds get_openclaw_home() helper in scripts/utils.py and updates all\ninstall/runtime scripts to resolve OpenClaw home from OPENCLAW_HOME\nenvironment variable with fallback to ~/.openclaw.\n\nCloses #271
fix: apply allowed_roots check to file:// URLs in add_remote_skill (CWE-22)\n\nAdds .resolve() and allowed_roots validation to the file:// URL branch\nin add_remote_skill(), closing a path traversal vulnerability.\nIncludes 3 regression tests.
All edict/backend/app/ files using PEP 604 union syntax (X | Y) now
import annotations from __future__ to ensure compatibility with
Python 3.9. Also remove tracked build artifact tsconfig.tsbuildinfo.
Two new test cases in test_sync_symlinks.py:
1. TestSyncScriptSymlink.test_skips_self_referential_via_directory_symlink
Unit test: verifies _sync_script_symlink() returns False and leaves
the real source file intact when dst_file is accessed through a
directory-level symlink that points back to the project scripts/ dir.
2. TestSyncScriptsToWorkspaces.test_no_self_referential_symlinks_when_workspace_scripts_is_dir_symlink
Integration test: simulates the install.sh scenario where
workspace-main/scripts -> project/scripts, then confirms that
sync_scripts_to_workspaces() does not convert any real source file
into a self-referential symlink."
When install.sh link_resources() creates workspace-*/scripts as a
directory-level symlink pointing to the project scripts/ dir, iterating
over it in sync_scripts_to_workspaces() produces dst_file paths that
resolve to the same real file as src_file.
The old idempotency check only skipped when dst_file itself was already a
symlink:
if dst_file.is_symlink() and dst_file.resolve() == src_resolved:
return False
For a workspace whose scripts/ directory is a symlink-to-directory,
dst_file appears as a regular file (is_symlink() == False), so the check
passes, the real source file is unlinked, and os.symlink() re-creates it
as a self-referential link (foo.py -> foo.py). Running run_loop.sh every
15 s makes the whole scripts/ directory unusable within one cycle.
Fix: resolve dst_file before any other check and bail out early when
dst_resolved == src_resolved, regardless of whether dst_file itself is
stored as a symlink entry.
- Add '自由下旨' textarea in templates tab for natural language edicts
- Add /api/task-output/<id> endpoint to read deliverable content
- Add '查看奏章' button in memorial modal to load output inline
Closes#92, Closes#29
- Opens GitHub issue creation with bug/feature templates
- Pre-fills title prefix and body template based on type
- Auto-sets appropriate label (bug/enhancement)
Closes#98
- Auto-detect task data dir from ~/.openclaw/workspace-*/data (#117)
- Score and select best task source (non-demo tasks preferred)
- Update healthz and live-status to use detected data dir
- Add robust parseDateFlexible() for timestamp handling (#67)
- Add UTF-8 encoding to file_lock reads for Windows compat (#96)
- Use absolute path in install.sh hint (#107)
Closes#117, Closes#107