diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index 56714dcf9..aeccbf4b1 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "55e9ce5724329ea6315394ed8969fc23d820f1163d82a9f3e1baef304e7ca30f", + "registryDigest": "3a5aa65956a41c77902b6e3287d8352f04a06e04343decec804d0a5e93e538f5", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index b9bc37bb9..ff0d3235f 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -586,22 +586,21 @@ touch "$ACTIVITY_FILE" 2>/dev/null || true should_signal=0 _clv2_bump_signal_counter() { + counter=0 if [ -f "$SIGNAL_COUNTER_FILE" ]; then - counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0) - # Guard against a corrupt counter file: a non-integer value would abort the - # hook under `set -e` at the arithmetic below. - case "$counter" in - ''|*[!0-9]*) counter=0 ;; - esac - counter=$((counter + 1)) - if [ "$counter" -ge "$SIGNAL_EVERY_N" ]; then - should_signal=1 - counter=0 - fi - echo "$counter" > "$SIGNAL_COUNTER_FILE" - else - echo "1" > "$SIGNAL_COUNTER_FILE" + counter=$(head -c 20 "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0) fi + # Bound parsing and avoid shell arithmetic overflow on corrupt local state. + case "$counter" in + ''|*[!0-9]*) counter=0 ;; + esac + if [ "${#counter}" -gt 18 ]; then counter=0; fi + counter=$((10#$counter + 1)) + if [ "$counter" -ge "$SIGNAL_EVERY_N" ]; then + should_signal=1 + counter=0 + fi + echo "$counter" > "$SIGNAL_COUNTER_FILE" } if command -v flock >/dev/null 2>&1 && exec 8>"$SIGNAL_COUNTER_LOCK" 2>/dev/null; then @@ -655,14 +654,14 @@ try: time.sleep(0.02) try: with open(sys.argv[2], encoding="ascii") as source: - value = source.read().strip() - counter = int(value) if value and value.isascii() and value.isdigit() else 0 - counter += 1 - signal = int(counter >= int(sys.argv[3])) - if signal: - counter = 0 + value = source.read(20).strip() except FileNotFoundError: - counter, signal = 1, 0 + value = "" + counter = int(value) if 0 < len(value) <= 18 and value.isascii() and value.isdigit() else 0 + counter += 1 + signal = int(counter >= int(sys.argv[3])) + if signal: + counter = 0 with open(sys.argv[2], "w", encoding="ascii") as target: target.write(str(counter) + "\n") print(signal) diff --git a/tests/hooks/observe-signal-counter-race.test.js b/tests/hooks/observe-signal-counter-race.test.js index c116a291d..fc63cc83a 100644 --- a/tests/hooks/observe-signal-counter-race.test.js +++ b/tests/hooks/observe-signal-counter-race.test.js @@ -269,6 +269,30 @@ async function runStrandedLock() { } } +async function runCounterBoundary(initial, threshold, expected, forcePython) { + const { testDir, projectDir, testObserve } = buildSandbox(); + try { + if (initial !== null) { + fs.writeFileSync(path.join(projectDir, '.observer-signal-counter'), initial); + } + const extraEnv = { ECC_OBSERVER_SIGNAL_EVERY_N: String(threshold) }; + if (forcePython || !hasFlock) { + const bashEnv = path.join(testDir, 'no-flock.sh'); + const fixture = forcePython + ? 'command() { if [ "$1" = "-v" ] && [ "$2" = "flock" ]; then return 1; fi; builtin command "$@"; }\n' + // Exercise the real shell counter on macOS with an inherited-fd OS + // lock fixture. Native GNU flock remains a Linux platform check. + : `flock() { python3 -c 'import fcntl,sys; fcntl.flock(int(sys.argv[-1]), fcntl.LOCK_UN if sys.argv[1] == "-u" else fcntl.LOCK_EX)' "$@"; }\n`; + fs.writeFileSync(bashEnv, fixture); + extraEnv.BASH_ENV = bashEnv; + } + await runObserve(testObserve, projectDir, extraEnv); + assert.strictEqual(readCounter(projectDir), expected); + } finally { + cleanupDir(testDir); + } +} + async function runKilledOwner() { const { testDir, projectDir, testObserve } = buildSandbox(); let owner; @@ -305,6 +329,17 @@ async function runKilledOwner() { await asyncTest('concurrent invocations never lose a counter increment', runConcurrent); await asyncTest('stranded mkdir lock cannot delay or freeze later observations', runStrandedLock); await asyncTest('busy lock skips safely and a killed owner releases it automatically', runKilledOwner); + for (const forcePython of [false, true]) { + const label = forcePython ? 'Python fallback' : hasFlock ? 'flock' : 'shell counter with OS-lock fixture'; + await asyncTest(`${label} applies threshold one to a missing first counter`, + () => runCounterBoundary(null, 1, 0, forcePython)); + await asyncTest(`${label} resets a malformed counter before incrementing`, + () => runCounterBoundary('invalid\n', 2, 1, forcePython)); + await asyncTest(`${label} resets an oversized counter before incrementing`, + () => runCounterBoundary('9'.repeat(5000) + '\n', 2, 1, forcePython)); + await asyncTest(`${label} parses leading-zero counters as decimal`, + () => runCounterBoundary('08\n', 20, 9, forcePython)); + } } else { console.log(' - skipping shell-execution tests (requires non-Windows + python3)'); }