@jc_stack/ez-agents 0.1.0-beta.19 → 0.1.0-beta.21

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.21
4
+
5
+ - Initialize fresh Telegram bots before reading their identity. Beta.19 could
6
+ retry forever before polling, causing main upgrades to fail their health gate.
7
+ - Restore the unchanged deployment layout required for upgrades from beta.18
8
+ and beta.19; extending health grace does not fix the initialization failure.
9
+ - Include beta.20 health diagnostics. Preserve the earlier immutable unpublished
10
+ candidate; existing beta acceptance limits remain.
11
+
12
+ ## 0.1.0-beta.20
13
+
14
+ - Retain a bounded, content-free health predicate in a failed main-upgrade
15
+ receipt before rollback replaces the candidate relay. This distinguishes relay
16
+ polling and host-executor heartbeat failures without persisting control-state,
17
+ provider, environment, or Docker diagnostic content.
18
+
3
19
  ## 0.1.0-beta.19
4
20
 
5
21
  - Preserve host-backed run completion and delivery evidence while the relay
@@ -1,8 +1,15 @@
1
1
  import { readFileSync } from 'node:fs';
2
- const value = JSON.parse(readFileSync('/state/control/heartbeat.json', 'utf8'));
3
- if (!value.polling || Date.now() - value.at > 20000) process.exit(1);
2
+ const relayControl = process.env.EZ_HEALTH_RELAY_CONTROL_DIR || '/state/control';
3
+ const fail = code => { process.stderr.write(`EZ_HEALTH_${code}\n`); process.exit(1); };
4
+ let value;
5
+ try { value = JSON.parse(readFileSync(relayControl + '/heartbeat.json', 'utf8')); }
6
+ catch { fail('RELAY_UNREADABLE'); }
7
+ if (!value.polling) fail('RELAY_NOT_POLLING');
8
+ if (!Number.isFinite(value.at) || Date.now() - value.at > 20000) fail('RELAY_STALE');
4
9
 
5
10
  if (process.env.EZ_EXECUTOR_TRANSPORT === 'host') {
6
- const host = JSON.parse(readFileSync(process.env.EZ_CONTROL_DIR + '/host-executor/heartbeat.json', 'utf8'));
7
- if (Date.now() - host.at > 15000) process.exit(1);
11
+ let host;
12
+ try { host = JSON.parse(readFileSync(process.env.EZ_CONTROL_DIR + '/host-executor/heartbeat.json', 'utf8')); }
13
+ catch { fail('HOST_UNREADABLE'); }
14
+ if (!Number.isFinite(host.at) || Date.now() - host.at > 15000) fail('HOST_STALE');
8
15
  }
@@ -28,8 +28,10 @@ offer their own default only in this slice. Refresh by opening the native client
28
28
  the relay does not install models, manage subscriptions or guess aliases.
29
29
 
30
30
  Setup initialization and relay startup seed one default choice per installed client.
31
- Settings Refresh available AIs repeats discovery. Active/default presets and
32
- queued snapshots are preserved; discovery only refreshes unused detected entries.
31
+ Choose AI opens the available installed-model catalog directly; Settings keeps
32
+ saved choices and Refresh available AIs repeats default discovery. Active/default
33
+ presets and queued snapshots are preserved; discovery only refreshes unused
34
+ detected entries.
33
35
  Codex uses its native `config/read` interface; Grok reads its documented user
34
36
  model/effort settings (or `models` for the default model). Claude reads user and
35
37
  workspace JSON settings; OpenCode reports resolved config. Unknown defaults and
@@ -61,11 +61,13 @@ Live smoke uses the same host CLI and requires an actual Telegram receipt.
61
61
  Restart preserves pairing and files. One kernel lock excludes relay/smoke
62
62
  writers; exit 73 means a writer is active. Do not delete its lock to bypass it.
63
63
  Health requires recent polling and host-transport heartbeats, not just a process.
64
- Fatal Telegram polling errors, including `409 Conflict`, stop intake and await
65
- worker cleanup plus in-flight task/outbox writes before exit. A conflict still
66
- requires the operator to stop the competing bot poller; the relay does not retry
67
- around that ownership error. Pending and uncertain deliveries keep their existing
68
- outbox/receipt semantics; executor stdout is not replayed as a reply.
64
+ Relay replacement retains the existing 30-second startup grace and deployment
65
+ layout, so compatible releases remain eligible for agent-owned upgrades.
66
+ An interrupted Telegram poller keeps authorized work and in-flight outbox writes
67
+ alive while it retries intake. A `409 Conflict` still requires the operator to
68
+ stop the competing poller; it remains unhealthy after the startup grace. Pending
69
+ and uncertain deliveries keep their existing outbox/receipt semantics; executor
70
+ stdout is not replayed as a reply.
69
71
  Signal and fatal-error paths share one shutdown; a secondary cleanup error is
70
72
  reported without replacing the original startup/polling failure.
71
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.19",
3
+ "version": "0.1.0-beta.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
package/src/index.ts CHANGED
@@ -1029,7 +1029,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
1029
1029
  await bot.api.deleteWebhook({ drop_pending_updates: false })
1030
1030
  await bot.api.setMyCommands(commands)
1031
1031
  await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
1032
- if (!bot.botInfo) await bot.init()
1032
+ await bot.init()
1033
1033
  await bot.start({
1034
1034
  drop_pending_updates: false,
1035
1035
  onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
package/src/menu.ts CHANGED
@@ -53,9 +53,14 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
53
53
  : 'Selected for this conversation. Queued work unchanged.'}`)
54
54
  }
55
55
  const list = async (ctx: Context, settings = false) => {
56
+ if (!settings) {
57
+ const models = await catalog()
58
+ if (models.length) return available(ctx, 0, models)
59
+ }
56
60
  const state = await control.aiState(initial)
57
61
  const keyboard = new InlineKeyboard()
58
- for (const preset of state.presets) button(keyboard,
62
+ const presets = settings ? state.presets : state.presets.filter((preset) => preset.id === initial.id)
63
+ for (const preset of presets) button(keyboard,
59
64
  `${preset.id === (settings ? state.defaultId : state.selectedId) ? '✓ ' : ''}${preset.name}`,
60
65
  async (next) => {
61
66
  if (settings) {
@@ -64,16 +69,17 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
64
69
  await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
65
70
  } else await choose(next, preset)
66
71
  })
67
- button(keyboard, 'Add AI…', (next) => available(next))
68
- if (settings) button(keyboard, 'Refresh available AIs', async (next) => {
72
+ button(keyboard, 'Browse available models', (next) => available(next))
73
+ button(keyboard, 'Refresh available AIs', async (next) => {
69
74
  await refresh()
70
75
  await list(next, true)
71
76
  })
72
- await ctx.reply(settings ? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
73
- : 'Choose AI\nChanging CLI starts a fresh conversation; files stay.', { reply_markup: keyboard })
77
+ await ctx.reply(settings
78
+ ? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
79
+ : 'Choose AI\nNo client catalog available. Showing the current client setup only.', { reply_markup: keyboard })
74
80
  }
75
- const available = async (ctx: Context, page = 0) => {
76
- const models = await catalog()
81
+ const available = async (ctx: Context, page = 0, listed?: ModelChoice[]) => {
82
+ const models = listed ?? await catalog()
77
83
  const keyboard = new InlineKeyboard()
78
84
  for (const model of models.slice(page * 8, page * 8 + 8)) {
79
85
  button(keyboard, `${model.cli} · ${model.name}`, async (next) => {
@@ -86,7 +92,7 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
86
92
  if (page > 0) button(keyboard, 'Previous', (next) => available(next, page - 1))
87
93
  if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, page + 1))
88
94
  await ctx.reply(models.length
89
- ? 'Installed client choices. Grok/Codex use their local model catalog; other clients use their own default. Adding saves the choice; it does not switch AI.'
95
+ ? 'Choose AI\nAvailable models are populated automatically from the installed clients. Grok/Codex use their local catalog; other clients use their own default. Choosing one saves it; it does not switch AI.'
90
96
  : 'No client catalog available. Open the installed CLI once, then try again.', { reply_markup: keyboard })
91
97
  }
92
98
  const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
@@ -43,6 +43,14 @@ export async function packageManager(root,run=execute) {
43
43
  throw Error(`Upgrade prerequisite unavailable: ${required}. ${failures.join('; ')}. Check the host supervisor service PATH (shell aliases do not count). Reuse its installed pnpm or Corepack; expose the launcher directory to that service and restart it after the current turn. If neither exists, provision the pinned manager first. Do not substitute npm install or reinstall the agent. After repair, prepare/apply a new job when status is failed; recover is only for recovery-required.`);
44
44
  }
45
45
  export const relayArgs = config => ['compose','--env-file',path.join(config.deploymentDir,'docker.env')];
46
+ const healthCodes = new Set(['RELAY_UNREADABLE','RELAY_NOT_POLLING','RELAY_STALE','HOST_UNREADABLE','HOST_STALE']);
47
+ async function healthEvidence(config,run) {
48
+ const id=(await run('docker',[...relayArgs(config),'ps','-q','relay'])).trim();
49
+ if(!/^[a-f0-9]{12,64}$/i.test(id))return '';
50
+ const raw=await run('docker',['inspect','--format','{{json .State.Health}}',id]);
51
+ const health=JSON.parse(raw),entry=health?.Log?.at(-1),match=typeof entry?.Output==='string'&&entry.Output.match(/^EZ_HEALTH_([A-Z_]+)\s*$/);
52
+ return match&&healthCodes.has(match[1])?` (health=${match[1].toLowerCase().replaceAll('_','-')})`:'';
53
+ }
46
54
  function envValue(text,key,value) {
47
55
  if(/[\r\n\0']/.test(value))throw Error('Unsafe deployment value');
48
56
  const line=`${key}='${value}'`;
@@ -118,7 +126,11 @@ export async function perform(home,job,hooks) {
118
126
  job.runtimeVerified=running;
119
127
  }
120
128
  job.status='completed';job.endedAt=new Date().toISOString();await save();return job;
121
- }catch(error){job.error=error.message;await save();return recover(home,job,hooks);}
129
+ }catch(error){
130
+ let message=error.message;
131
+ if(job.target==='main'&&job.rollback&&/is unhealthy/.test(message))message+=await healthEvidence(config,run).catch(()=> '');
132
+ job.error=message;await save();return recover(home,job,hooks);
133
+ }
122
134
  }
123
135
  export async function recover(home,job,hooks) {
124
136
  const run=hooks.execute||execute,{config}=await state(home);
@@ -12,6 +12,19 @@ delegation, model, and effort from the task—not a fixed routing rule. Keep one
12
12
  writer per workspace; the primary agent owns integration, verification, and
13
13
  external actions.
14
14
 
15
+ ## AI selection
16
+
17
+ An explicit owner request to change this conversation's AI or reasoning effort
18
+ is a supported Ez control, not a request to edit the host Codex configuration,
19
+ inspect a native session record, or restart the runtime. Run
20
+ `ezenciel-agents-ai list`, then select only a returned choice with
21
+ `ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`.
22
+ This changes subsequent owner messages only; a running or queued job retains
23
+ its captured choice, and the installation default is unchanged. Switching CLI
24
+ starts a fresh native conversation while preserving the workspace. Report the
25
+ confirmed selected choice from the command output; do not infer it from a
26
+ host-level setting or the current native session.
27
+
15
28
  ## Telegram replies
16
29
 
17
30
  Use the messaging CLI for the current run's source chat, normally the paired
@@ -45,6 +45,19 @@ test('shared guidance teaches source-chat delivery and real Telegram line breaks
45
45
  assert.ok(shared.includes('ezenciel-agents-message --text-file ./work/reply.md'))
46
46
  })
47
47
 
48
+ test('shared guidance makes owner AI selection a relay control, not host configuration', async () => {
49
+ const shared = await readFile(sharedGuidancePath, 'utf8')
50
+ for (const prompt of [
51
+ executorJobPrompt('tg_owner', ['change to Terra medium']),
52
+ desktopJobPrompt('tg_owner_gui', ['change to Terra medium'], undefined, '/tmp/bin', '/tmp/control'),
53
+ ]) {
54
+ assert.ok(prompt.includes('`ezenciel-agents-ai list`'))
55
+ assert.ok(prompt.includes('`ezenciel-agents-ai select --cli <cli> --model <model> --effort <effort>`'))
56
+ assert.ok(prompt.includes('not a request to edit the host Codex configuration'))
57
+ assert.match(prompt, /a running or queued job retains\s+its captured choice/)
58
+ }
59
+ })
60
+
48
61
  test('package guidance resolution ignores a workspace shadow file', async () => {
49
62
  const root = path.join(tmpdir(), `ez-guidance-${randomUUID()}`)
50
63
  await mkdir(root, { recursive: true })
package/test/ai.test.ts CHANGED
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { ControlStore } from '../src/control-state.js'
7
7
  import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
8
+ import { createAiMenu } from '../src/menu.js'
8
9
  import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
9
10
  import { InboxStore } from '../src/inbox.js'
10
11
  import type { Update } from 'grammy/types'
@@ -92,6 +93,45 @@ test('model catalog projects native metadata only, excluding hidden entries and
92
93
  } finally { await rm(home, { recursive: true, force: true }) }
93
94
  })
94
95
 
96
+ test('Choose AI opens the available installed-model catalog without an Add AI step', async () => {
97
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-'))
98
+ try {
99
+ const menu = createAiMenu(new ControlStore(dir, 1000), 'grok', async () => [{
100
+ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'],
101
+ }])
102
+ let reply = ''
103
+ let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
104
+ await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
105
+ reply = text
106
+ keyboard = options?.reply_markup as typeof keyboard
107
+ return {} as never
108
+ } } as never)
109
+ assert.match(reply, /Available models are populated automatically/)
110
+ assert.deepEqual(keyboard?.inline_keyboard?.flat().map((button) => button.text), ['codex · Fixture'])
111
+ } finally { await rm(dir, { recursive: true, force: true }) }
112
+ })
113
+
114
+ test('Choose AI does not expose saved model choices without a catalog to validate them', async () => {
115
+ const dir = await mkdtemp(join(tmpdir(), 'ez-ai-menu-empty-'))
116
+ try {
117
+ const store = new ControlStore(dir, 1000)
118
+ await store.aiState(initialPreset('grok'))
119
+ await store.savePreset({ id: 'saved', name: 'Saved model', cli: 'codex', model: 'fixture-model', effort: 'medium' })
120
+ await store.savePreset({ id: 'saved-default', name: 'Saved client default', cli: 'claude' })
121
+ const menu = createAiMenu(store, 'grok', async () => [])
122
+ let reply = ''
123
+ let keyboard: { inline_keyboard?: Array<Array<{ text: string }>> } | undefined
124
+ await menu.list({ reply: async (text: string, options?: { reply_markup?: unknown }) => {
125
+ reply = text
126
+ keyboard = options?.reply_markup as typeof keyboard
127
+ return {} as never
128
+ } } as never)
129
+ assert.match(reply, /current client setup only/)
130
+ assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved model')))
131
+ assert.ok(!keyboard?.inline_keyboard?.flat().some((button) => button.text.includes('Saved client default')))
132
+ } finally { await rm(dir, { recursive: true, force: true }) }
133
+ })
134
+
95
135
  test('model catalog can read an agent-bound Codex home', async () => {
96
136
  const home = await mkdtemp(join(tmpdir(), 'ez-catalog-home-'))
97
137
  const codexHome = await mkdtemp(join(tmpdir(), 'ez-catalog-codex-'))
@@ -17,7 +17,10 @@ import { TelegramSource } from '../src/telegram-source.js'
17
17
  import { packageVersion } from '../src/version.js'
18
18
  import type { Update } from 'grammy/types'
19
19
  const exec=promisify(execFile),bin=fileURLToPath(new URL('../bin/ezenciel-agents-schedule.mjs',import.meta.url))
20
- const until=async(check:()=>Promise<boolean>)=>{for(let n=0;n<200;n++){if(await check())return;await new Promise(r=>setTimeout(r,20))}throw new Error('Timed out')}
20
+ // This integration test starts subprocesses, persists their receipts, and invokes
21
+ // the schedule CLI. Give a busy CI worker time to settle without changing the
22
+ // production recovery deadline.
23
+ const until=async(check:()=>Promise<boolean>,timeoutMs=15_000)=>{const deadline=Date.now()+timeoutMs;while(!(await check())){if(Date.now()>=deadline)throw new Error(`Timed out after ${timeoutMs}ms`);await new Promise(r=>setTimeout(r,20))}}
21
24
 
22
25
  test('failure evidence is bounded and redacts configured credentials, headers, tokens, URLs and keys',()=>{
23
26
  const token='123456789:abcdefghijklmnopqrstuvwxyz123456789'
@@ -137,6 +140,29 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
137
140
  } finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
138
141
  })
139
142
 
143
+ test('a fresh relay initializes its bot identity before polling', async () => {
144
+ const dir=await mkdtemp(join(tmpdir(),'ez-cold-start-'))
145
+ const relay=createRelay({workspace:dir,controlDir:dir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'grok',telegramBotToken:'fixture'},async()=>{throw new Error('No executor expected')})
146
+ const methods:string[]=[]
147
+ relay.bot.api.config.use(async(_prev,method,_payload,signal)=>{
148
+ methods.push(method)
149
+ if(method==='getMe') return {ok:true,result:{id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'}} as any
150
+ if(method==='getUpdates') {
151
+ if(signal && !signal.aborted) await new Promise<void>(resolve=>signal.addEventListener('abort',()=>resolve(),{once:true}))
152
+ return {ok:true,result:[]} as any
153
+ }
154
+ return {ok:true,result:true} as any
155
+ })
156
+ const started=relay.start()
157
+ try {
158
+ await until(async()=>methods.includes('getUpdates'))
159
+ assert.equal(relay.bot.isRunning(),true)
160
+ assert.equal(methods.filter(method=>method==='getMe').length,1)
161
+ assert.ok(methods.indexOf('getMe')<methods.indexOf('getUpdates'))
162
+ } finally {await relay.stop();await started;await rm(dir,{recursive:true,force:true})}
163
+ assert.equal(relay.bot.isRunning(),false)
164
+ })
165
+
140
166
  for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
141
167
  const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
142
168
  let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
@@ -201,7 +201,7 @@ test('owner group discovery routes only to the private chat and rechecks identit
201
201
  } finally { await f.close() }
202
202
  })
203
203
 
204
- test('four-item menu is owner-only; saved AI buttons work and forged/stale buttons cannot change settings', async () => {
204
+ test('four-item menu is owner-only; available AI choices work and forged/stale buttons cannot change settings', async () => {
205
205
  const f = await fixture()
206
206
  const callback = (id: number, data: string, user = 101): Update => ({
207
207
  update_id: id,
@@ -220,10 +220,18 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
220
220
  await f.relay.bot.handleUpdate(callback(4, 'ai:forged'))
221
221
  assert.equal(await store.getActiveSession(), null)
222
222
  await f.relay.bot.handleUpdate(callback(5, pick))
223
+ if (!(await store.getActiveSession())) {
224
+ let useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')?.callback_data
225
+ if (!useNow) {
226
+ await f.relay.bot.handleUpdate(callback(6, f.keyboards.at(-1)!.flat()[0].callback_data))
227
+ useNow = f.keyboards.at(-1)!.flat().find((button) => button.text === 'Use now')!.callback_data
228
+ }
229
+ await f.relay.bot.handleUpdate(callback(7, useNow))
230
+ }
223
231
  assert.ok(await store.getActiveSession())
224
- await f.relay.bot.handleUpdate(callback(6, pick))
232
+ await f.relay.bot.handleUpdate(callback(8, pick))
225
233
  assert.match(f.replies.at(-1)!, /Menu expired/)
226
- await f.relay.bot.handleUpdate(message(7, '/settings'))
234
+ await f.relay.bot.handleUpdate(message(9, '/settings'))
227
235
  assert.match(f.replies.at(-1)!, /Default for new conversations/)
228
236
  await f.relay.bot.handleUpdate(message(8, '/status'))
229
237
  assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
@@ -86,6 +86,16 @@ test('status distinguishes installed, running, legacy and stale main versions wi
86
86
  const result=await exec(process.execPath,[new URL('../bin/ezenciel-agents-tools.mjs',import.meta.url).pathname,'--home',f.home,'status']);
87
87
  assert.equal(JSON.parse(result.stdout).main.installedVersion,'0.1.0');
88
88
  });
89
+ test('relay healthcheck emits bounded predicate evidence without state contents',async t=>{
90
+ const root=await fs.realpath(await fs.mkdtemp(path.join(tmpdir(),'ez-healthcheck-')));t.after(()=>fs.rm(root,{recursive:true,force:true}));
91
+ const relay=path.join(root,'relay'),host=path.join(root,'host');await fs.mkdir(relay,{recursive:true});await fs.mkdir(path.join(host,'host-executor'),{recursive:true});
92
+ const check=async()=>exec(process.execPath,[new URL('../docker/healthcheck.mjs',import.meta.url).pathname],{env:{...process.env,EZ_HEALTH_RELAY_CONTROL_DIR:relay,EZ_EXECUTOR_TRANSPORT:'host',EZ_CONTROL_DIR:host}});
93
+ await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()}));await check();
94
+ await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:false,at:Date.now(),private:'must-not-appear'}));
95
+ await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_RELAY_NOT_POLLING/);assert.doesNotMatch(error.stderr,/must-not-appear/);return true;});
96
+ await fs.writeFile(path.join(relay,'heartbeat.json'),JSON.stringify({polling:true,at:Date.now()}));await fs.writeFile(path.join(host,'host-executor/heartbeat.json'),JSON.stringify({at:Date.now()-20000}));
97
+ await assert.rejects(check(),error=>{assert.match(error.stderr,/EZ_HEALTH_HOST_STALE/);return true;});
98
+ });
89
99
  test('plugin status verifies images and reports stopped, mismatched and unreachable runtimes honestly',async t=>{
90
100
  const f=await fixture(t,'plugin'),id='a'.repeat(64),image='sha256:'+'b'.repeat(64);
91
101
  for(const mode of ['running','ndjson','stopped','missing','mismatched','offline']) {
@@ -157,6 +167,11 @@ test('failed preparation never stops runtime; failed activation rolls back code
157
167
  else assert((await fs.readFile(path.join(f.config.deploymentDir,'docker.env'),'utf8')).includes('sha256:'+'a'.repeat(64)));
158
168
  }
159
169
  });
170
+ test('main health-gate rollback retains only the recognized sanitized predicate',async t=>{
171
+ const f=await fixture(t),job=await queued(f),base=runtime(f);let failed=false;
172
+ const r={...base,execute:async(c,a,o)=>{if(a.includes('up')&&!failed){failed=true;throw Error('container relay is unhealthy');}return a.includes('ps')?'a'.repeat(64):a[0]==='inspect'&&a[2]==='{{json .State.Health}}'?JSON.stringify({Log:[{Output:'EZ_HEALTH_HOST_STALE\n'}]}):base.execute(c,a,o);}};
173
+ const result=await perform(f.home,job,r);assert.equal(result.status,'rolled-back');assert.match(result.error,/health=host-stale/);assert.doesNotMatch(result.error,/private-test-token/);
174
+ });
160
175
  test('plugin transaction preserves named volumes, backs up stopped data and rolls back failed health',async t=>{
161
176
  for(const failed of [false,true]) {
162
177
  const f=await fixture(t,'plugin'),job=await queued(f),r=runtime(f,{fail:(_c,a)=>failed&&a.includes('up')});