Recover non-progressing cast loops and bound host model prompt cache
Core tests / core (push) Successful in 57s

This commit is contained in:
Codex
2026-09-24 03:42:18 +12:00
parent 8e01cdee22
commit 1552e4c165
4 changed files with 113 additions and 18 deletions
+64 -15
View File
@@ -1,6 +1,11 @@
-- Conservative recovery for managed residents with fresh quest/explore intent. -- Conservative recovery for managed residents with fresh quest/explore intent.
-- Native position/progress is sampled independently of dashboard summaries. -- Native position/progress is sampled independently of dashboard summaries.
local intents, observations, pendingResults = {}, {}, {} local intents, observations, pendingResults = {}, {}, {}
local progress={}
-- Successful gameplay, rather than merely starting a cast, resets the idle clock.
for _,event in ipairs({32,37,54}) do
RegisterPlayerEvent(event,function(_,p) if p:IsPlayerbot() then progress[p:GetGUIDLow()]=os.time() end end)
end
local LIMIT = 600 local LIMIT = 600
local COOLDOWN = 1800 local COOLDOWN = 1800
local function sqltext(s) local function sqltext(s)
@@ -13,6 +18,22 @@ AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_log (
AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_results ( AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_results (
attempt_id BIGINT UNSIGNED PRIMARY KEY,status VARCHAR(24) NOT NULL, attempt_id BIGINT UNSIGNED PRIMARY KEY,status VARCHAR(24) NOT NULL,
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,detail VARCHAR(255) NOT NULL)]]) checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,detail VARCHAR(255) NOT NULL)]])
AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_leases (
guid INT UNSIGNED PRIMARY KEY,name VARCHAR(64),account_id INT UNSIGNED,
restore_at BIGINT UNSIGNED,gathering TINYINT,looting TINYINT)]])
local function restoreStrategies(now)
local q=AuthDBQuery('SELECT guid,name,account_id,gathering,looting FROM azerothai_recovery_leases WHERE restore_at<='..now)
if not q then return end
repeat
local guid,name,account=q:GetUInt32(0),q:GetString(1),q:GetUInt32(2)
local p=GetPlayerByName(name)
if p and p:IsPlayerbot() and p:GetGUIDLow()==guid and p:GetAccountId()==account and p:GetBotAI() and not p:GetBotAI():HasGameClientMaster() then
if q:GetUInt32(3)==1 then p:GetBotAI():ChangeStrategy('+gather',1) end
if q:GetUInt32(4)==1 then p:GetBotAI():ChangeStrategy('+loot',1) end
AuthDBExecute('DELETE FROM azerothai_recovery_leases WHERE guid='..guid)
end
until not q:NextRow()
end
local function checkResults(now) local function checkResults(now)
for guid,r in pairs(pendingResults) do for guid,r in pairs(pendingResults) do
local p=GetPlayerByName(r.name);local status,detail local p=GetPlayerByName(r.name);local status,detail
@@ -39,17 +60,55 @@ function AzerothAISetRecoveryIntents(rows)
return 'Recovery intentions refreshed' return 'Recovery intentions refreshed'
end end
local function safe(p,a) local function safe(p,a)
if p:IsDead() or p:IsInCombat() or p:IsCasting() or p:IsTaxi() or a:HasGameClientMaster() if p:IsDead() or p:IsInCombat() or p:IsTaxi() or a:HasGameClientMaster()
or p:IsRooted() or a:IsMovementImpaired() then return false end or p:IsRooted() or a:IsMovementImpaired() then return false end
local g=p:GetGroup() local g=p:GetGroup()
if g then if g then
if #g:GetMembers()~=g:GetMembersCount() then return false end if #g:GetMembers()~=g:GetMembersCount() then return false end
for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() then return false end end for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() or not m:GetBotAI() or m:GetBotAI():HasGameClientMaster() then return false end end
end end
return true return true
end end
local function recover(p,a,r,guid,now,followup)
local x,y,z,map,level=p:GetX(),p:GetY(),p:GetZ(),p:GetMapId(),p:GetLevel()
local q=AuthDBQuery('SELECT guid FROM azerothai_lifecycle WHERE guid='..guid..' AND account_id='..r.account..' AND BINARY name='..sqltext(r.name).." AND state='active'")
local recent=AuthDBQuery('SELECT id FROM azerothai_recovery_log WHERE guid='..guid..' AND at>NOW()-INTERVAL '..COOLDOWN..' SECOND LIMIT 1')
if recent and followup then
local failed=AuthDBQuery("SELECT l.id FROM azerothai_recovery_log l JOIN azerothai_recovery_results r ON r.attempt_id=l.id WHERE l.guid="..guid.." AND r.status='no_progress' AND l.at>NOW()-INTERVAL 1800 SECOND AND (SELECT COUNT(*) FROM azerothai_recovery_log x WHERE x.guid=l.guid AND x.at>NOW()-INTERVAL 1800 SECOND)=1 LIMIT 1")
if failed then recent=nil end
end
if q and not recent then
local interrupted=p:IsCasting()
if interrupted then p:StopSpellCast() end
local staleFlight=p:GetMovementType()==7 and not p:IsTaxi()
if staleFlight then p:MoveExpire();p:MoveClear() end
a:Reset(true)
local gathering,looting=a:HasStrategy('gather',1),a:HasStrategy('loot',1)
if gathering or looting then
AuthDBExecute('INSERT INTO azerothai_recovery_leases VALUES ('..guid..','..sqltext(r.name)..','..r.account..','..(now+120)..','..(gathering and 1 or 0)..','..(looting and 1 or 0)..') ON DUPLICATE KEY UPDATE restore_at=VALUES(restore_at),gathering=GREATEST(gathering,VALUES(gathering)),looting=GREATEST(looting,VALUES(looting))')
a:ChangeStrategy('-gather',1);a:ChangeStrategy('-loot',1)
a:DoSpecificAction('release loot',true)
end
local moved=a:DoSpecificAction('move random',true)
local detail=(interrupted and 'Interrupted non-progressing cast; ' or '')..(staleFlight and 'Cleared stale flight; ' or '')..'AI replanned; gathering paused 120s; walking request='..tostring(moved)
AuthDBExecute('INSERT INTO azerothai_recovery_log(guid,name,detail) VALUES ('..guid..','..sqltext(r.name)..','..sqltext(detail)..')')
pendingResults[guid]={name=r.name,account=r.account,at=now,x=x,y=y,z=z,map=map,level=level}
PrintInfo('[AzerothAI recovery] '..r.name..': '..detail)
return true
end
return false
end
-- Operator-only entry point for a stall already verified through repeated observations.
function AzerothAIRecoverObservedStall(name,guid,account)
local p=GetPlayerByName(name)
if not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=account then return 'Identity unavailable' end
local a=p:GetBotAI()
if not a or not safe(p,a) then return 'Unsafe to recover now' end
return recover(p,a,{name=name,account=account},guid,os.time(),true) and 'Recovery requested; awaiting observed progress' or 'Inactive identity or cooldown'
end
local function sample() local function sample()
local now=os.time() local now=os.time()
restoreStrategies(now)
checkResults(now) checkResults(now)
for guid,r in pairs(intents) do for guid,r in pairs(intents) do
local p=GetPlayerByName(r.name) local p=GetPlayerByName(r.name)
@@ -62,22 +121,11 @@ local function sample()
local x,y,z,map=p:GetX(),p:GetY(),p:GetZ(),p:GetMapId() local x,y,z,map=p:GetX(),p:GetY(),p:GetZ(),p:GetMapId()
local level,hp=p:GetLevel(),p:GetHealth() local level,hp=p:GetLevel(),p:GetHealth()
local old=observations[guid] local old=observations[guid]
if not old or old.map~=map or old.level~=level or old.hp~=hp or (x-old.x)^2+(y-old.y)^2+(z-old.z)^2>25 then if not old or old.map~=map or old.level~=level or old.hp~=hp or (progress[guid] or 0)>old.since or (x-old.x)^2+(y-old.y)^2+(z-old.z)^2>25 then
observations[guid]={x=x,y=y,z=z,map=map,level=level,hp=hp,since=now} observations[guid]={x=x,y=y,z=z,map=map,level=level,hp=hp,since=now}
elseif now-old.since>=LIMIT then elseif now-old.since>=LIMIT then
-- Persistent cooldown survives ALE reloads; exact identity must still be active. -- Persistent cooldown survives ALE reloads; exact identity must still be active.
local q=AuthDBQuery('SELECT guid FROM azerothai_lifecycle WHERE guid='..guid..' AND account_id='..r.account..' AND BINARY name='..sqltext(r.name).." AND state='active'") recover(p,a,r,guid,now)
local recent=AuthDBQuery('SELECT id FROM azerothai_recovery_log WHERE guid='..guid..' AND at>NOW()-INTERVAL '..COOLDOWN..' SECOND LIMIT 1')
if q and not recent then
local staleFlight=p:GetMovementType()==7 and not p:IsTaxi()
if staleFlight then p:MoveExpire();p:MoveClear() end
a:Reset(true)
local moved=a:DoSpecificAction('move random',true)
local detail=(staleFlight and 'Cleared stale flight; ' or '')..'AI replanned; walking request='..tostring(moved)
AuthDBExecute('INSERT INTO azerothai_recovery_log(guid,name,detail) VALUES ('..guid..','..sqltext(r.name)..','..sqltext(detail)..')')
pendingResults[guid]={name=r.name,account=r.account,at=now,x=x,y=y,z=z,map=map,level=level}
PrintInfo('[AzerothAI recovery] '..r.name..': '..detail)
end
old.since=now old.since=now
end end
end end
@@ -85,6 +133,7 @@ local function sample()
end end
-- Forget residents whose intent changed to resting/socializing or who went offline. -- Forget residents whose intent changed to resting/socializing or who went offline.
for guid in pairs(observations) do if not intents[guid] then observations[guid]=nil end end for guid in pairs(observations) do if not intents[guid] then observations[guid]=nil end end
for guid,at in pairs(progress) do if now-at>3600 then progress[guid]=nil end end
end end
CreateLuaEvent(function()local ok,err=pcall(sample);if not ok then PrintError('[AzerothAI recovery] '..tostring(err)) end end,30000,0) CreateLuaEvent(function()local ok,err=pcall(sample);if not ok then PrintError('[AzerothAI recovery] '..tostring(err)) end end,30000,0)
PrintInfo('[AzerothAI] Conservative stuck recovery enabled: 10 minutes idle, 30 minute cooldown') PrintInfo('[AzerothAI] Conservative stuck recovery enabled: 10 minutes idle, 30 minute cooldown')
+27
View File
@@ -0,0 +1,27 @@
# Bound the bundled llama-server prompt cache; preserve model and context size.
param([switch]$Restart)
$ErrorActionPreference='Stop'
$settings=@{LLAMA_ARG_CACHE_RAM='512';LLAMA_ARG_CTX_CHECKPOINTS='4'}
foreach($key in $settings.Keys){[Environment]::SetEnvironmentVariable($key,$settings[$key],'User');[Environment]::SetEnvironmentVariable($key,$settings[$key],'Process')}
$root=Join-Path $env:USERPROFILE 'Tools\AzerothAI'
$supervisor=Join-Path $root 'recovery.ps1'
if(Test-Path $supervisor){
$text=Get-Content -Raw $supervisor
if($text -notmatch 'LLAMA_ARG_CACHE_RAM'){
Copy-Item $supervisor "$supervisor.before-cache-limit" -Force
$prefix='$env:LLAMA_ARG_CACHE_RAM="512"; $env:LLAMA_ARG_CTX_CHECKPOINTS="4"'
Set-Content $supervisor ($prefix+"`r`n"+$text)
}
}
if($Restart){
$task=Get-ScheduledTask -TaskName 'AzerothAI-Recovery' -ErrorAction SilentlyContinue
if($task){Stop-ScheduledTask -InputObject $task}
try{Invoke-RestMethod -Method Post -Uri http://127.0.0.1:11434/api/generate -ContentType 'application/json' -Body '{"model":"qwen3.5:4b","keep_alive":0}' -TimeoutSec 45 | Out-Null}catch{Write-Warning $_.Exception.Message}
$binary=Join-Path $env:USERPROFILE 'Tools\Ollama\ollama.exe'
Get-Process ollama -ErrorAction SilentlyContinue | Where-Object {$_.Path -eq $binary} | Stop-Process
$logs=Split-Path $binary
foreach($file in @('server.out.log','server.err.log')){if(Test-Path "$logs\$file"){Copy-Item "$logs\$file" "$logs\$file.before-cache-limit" -Force}}
Start-Process $binary -ArgumentList 'serve' -WindowStyle Hidden -RedirectStandardOutput "$logs\server.out.log" -RedirectStandardError "$logs\server.err.log"
if($task){Start-ScheduledTask -InputObject $task}
}
Write-Output 'Prompt cache capped at 512 MiB; at most four context checkpoints per slot.'
+5
View File
@@ -28,3 +28,8 @@ The native guard now samples every two seconds. Retreat is requested below 50% h
Healthy followers can regroup over 12–200 yards on the same map/phase. Healthy noncombat leaders pause for at most 20 seconds per minute when a noncombat companion is 25–200 yards behind. Recovery requests now extend to 80% health after recent danger. No teleport, resurrection, population or stat changes are made. Healthy followers can regroup over 12–200 yards on the same map/phase. Healthy noncombat leaders pause for at most 20 seconds per minute when a noncombat companion is 25–200 yards behind. Recovery requests now extend to 80% health after recent danger. No teleport, resurrection, population or stat changes are made.
The auth table `azerothai_survival_audit` records request acceptance and observed outcomes: out of combat, movement while still in combat, no movement after 30 seconds, or death during observation. Movement means at least five yards from the starting position (or a map change); it does not prove successful escape. Audit records expire after seven days in bounded hourly batches. Snapshots continue to expose the latest action and timestamp. The auth table `azerothai_survival_audit` records request acceptance and observed outcomes: out of combat, movement while still in combat, no movement after 30 seconds, or death during observation. Movement means at least five yards from the starting position (or a map change); it does not prove successful escape. Audit records expire after seven days in bounded hourly batches. Snapshots continue to expose the latest action and timestamp.
Stall recovery keeps its ten-minute idle clock across casts. Real movement, level/health changes, successful loot or quest completion reset the clock. It can interrupt a noncombat cast only after that threshold; combat, taxi, human control, movement restrictions, fresh intentions and the persistent thirty-minute recovery cooldown remain enforced. Run `deploy/pc-lab/limit-model-memory.ps1 -Restart` on the Windows model host to cap prompt cache at 512 MiB and checkpoints at four. This trades cache reuse for host memory without changing model or context size.
Recovery briefly disables existing gather/loot strategies for two minutes after releasing loot, so an invalid gathering target cannot immediately cancel the walking request. A durable lease restores only previously enabled strategies, including after a Lua reload. An operator may make one follow-up attempt within the cooldown only when the sole recent attempt has a verified no-progress result; automatic attempts retain their thirty-minute cooldown.
+17 -3
View File
@@ -4,8 +4,9 @@ code=(Path(__file__).resolve().parents[1]/'ale/azerothai_recovery.lua').read_tex
setup=''' setup='''
now=1000;os.time=function()return now end;attempts=0;expired=0;last=nil;active=true;player={x=0};ai={};captured={} now=1000;os.time=function()return now end;attempts=0;expired=0;last=nil;active=true;player={x=0};ai={};captured={}
function AuthDBExecute(sql) table.insert(captured,sql);if string.find(sql,'INSERT INTO azerothai_recovery_log',1,true) then last=now end end function AuthDBExecute(sql) table.insert(captured,sql);if string.find(sql,'INSERT INTO azerothai_recovery_log',1,true) then last=now end end
function AuthDBQuery(sql) if string.find(sql,'recovery_log',1,true) then return last and now-last<1800 and {} or nil end;return active and {} or nil end function AuthDBQuery(sql) if string.find(sql,'recovery_leases',1,true) then return nil end;if string.find(sql,'recovery_log',1,true) then return last and now-last<1800 and {} or nil end;return active and {} or nil end
function GetPlayerByName(n) return n=='Test' and player or nil end function GetPlayerByName(n) return n=='Test' and player or nil end
hooks={};function RegisterPlayerEvent(n,fn)hooks[n]=fn end
function CreateLuaEvent(fn) tick=fn end function CreateLuaEvent(fn) tick=fn end
function PrintInfo(s) end function PrintInfo(s) end
function PrintError(s) error(s) end function PrintError(s) error(s) end
@@ -16,6 +17,7 @@ function player:GetBotAI()return ai end
function player:IsDead()return self.dead end function player:IsDead()return self.dead end
function player:IsInCombat()return self.combat end function player:IsInCombat()return self.combat end
function player:IsCasting()return self.casting end function player:IsCasting()return self.casting end
function player:StopSpellCast()self.casting=false;self.interrupted=true end
function player:IsTaxi()return self.taxi end function player:IsTaxi()return self.taxi end
function player:IsRooted()return self.root end function player:IsRooted()return self.root end
function player:GetGroup()return self.group end function player:GetGroup()return self.group end
@@ -31,7 +33,9 @@ function player:MoveClear()end
function ai:HasGameClientMaster()return player.master end function ai:HasGameClientMaster()return player.master end
function ai:IsMovementImpaired()return player.impaired end function ai:IsMovementImpaired()return player.impaired end
function ai:Reset(full)attempts=attempts+1 end function ai:Reset(full)attempts=attempts+1 end
function ai:DoSpecificAction(action,silent)assert(action=='move random');return true end function ai:HasStrategy(s,state)return false end
function ai:ChangeStrategy()end
function ai:DoSpecificAction(action,silent)assert(action=='move random' or action=='release loot');return true end
function step(seconds,action) function step(seconds,action)
now=now+seconds now=now+seconds
AzerothAISetRecoveryIntents({{guid=9,account=2,name='Test',action=action or 'quest'}}) AzerothAISetRecoveryIntents({{guid=9,account=2,name='Test',action=action or 'quest'}})
@@ -42,7 +46,7 @@ def new():
l=LuaRuntime();l.execute(setup);l.execute(code);return l l=LuaRuntime();l.execute(setup);l.execute(code);return l
l=new();l.execute('step(0);step(599);assert(attempts==0);step(1);assert(attempts==1);step(600);assert(attempts==1);step(1200);assert(attempts==2)') l=new();l.execute('step(0);step(599);assert(attempts==0);step(1);assert(attempts==1);step(600);assert(attempts==1);step(1200);assert(attempts==2)')
print('Threshold and persistent cooldown passed') print('Threshold and persistent cooldown passed')
for field in ['dead','combat','casting','taxi','root','master','impaired']: for field in ['dead','combat','taxi','root','master','impaired']:
l=new();l.execute(f'player.{field}=true;step(0);step(601);assert(attempts==0)') l=new();l.execute(f'player.{field}=true;step(0);step(601);assert(attempts==0)')
print('Death, combat, cast, taxi, root, master and impaired exclusions passed') print('Death, combat, cast, taxi, root, master and impaired exclusions passed')
for action in ['rest','socialize','trade']: for action in ['rest','socialize','trade']:
@@ -60,3 +64,13 @@ l=new();l.execute("step(0);step(600);player.x=8;step(30);assert(string.find(capt
l=new();l.execute("step(0);step(600);step(180);assert(string.find(captured[#captured], '6E6F5F70726F6772657373'))") l=new();l.execute("step(0);step(600);step(180);assert(string.find(captured[#captured], '6E6F5F70726F6772657373'))")
l=new();l.execute("step(0);step(600);player.dead=true;step(30);assert(string.find(captured[#captured], '756E7665726966696564'))") l=new();l.execute("step(0);step(600);player.dead=true;step(30);assert(string.find(captured[#captured], '756E7665726966696564'))")
print('Recovery outcomes distinguish native progress, no progress, and unavailable residents') print('Recovery outcomes distinguish native progress, no progress, and unavailable residents')
l=new();l.execute('player.casting=true;step(0);step(599);assert(attempts==0);step(1);assert(attempts==1 and player.interrupted)')
l=new();l.execute('player.casting=true;step(0);now=now+500;hooks[32](32,player);step(0);step(101);assert(attempts==0)')
l=new();l.execute('player.casting=true;step(0);player.combat=true;step(601);assert(attempts==0 and not player.interrupted)')
print('Repeated casts cannot reset idle evidence; productive casts and combat stay protected')
l=new();l.execute("function ai:HasStrategy()return true end;local changes={};function ai:ChangeStrategy(s)table.insert(changes,s)end;step(0);step(600);assert(changes[1]=='-gather' and changes[2]=='-loot');assert(attempts==1)")
print('Recovery pauses gathering before walking, with a durable restoration lease')
l=new();l.execute("local query=AuthDBQuery;local changes={};function ai:ChangeStrategy(s)table.insert(changes,s)end;function AuthDBQuery(sql)if string.find(sql,'SELECT guid,name,account_id,gathering,looting',1,true) then return {GetUInt32=function(_,i)return ({[0]=9,[2]=2,[3]=1,[4]=0})[i] end,GetString=function()return 'Test' end,NextRow=function()return false end} end;return query(sql)end;step(0);assert(changes[1]=='+gather' and #changes==1)")
print('Lease restoration restores only originally enabled strategies')