fix(webui): E2E6 re-check round, consumed-decision wording, autonomy focus and read errors (#1873)

Re-check finding 1 (P2): the wording for refusals that can come after the
decision is consumed told the user to look for the proposal "as answered"
in the inbox and otherwise read "nothing was applied". The bus inbox lists
no closed decision, so a consumed one drops out of it and the user would
read the false claim. Those codes now say only that part of it may already
be applied and to check the Agents page. decision-http tests assert no
"nothing was applied" in any case and no inbox pointer for those codes.

Re-check P3-b: the autonomy controls (Save, Discard, notices, review
summary and buttons) carry data-focus-key, so a focused button in a clean
form gets its focus back after the poll repaints the section.

Re-check P3-c: a failed autonomy read keeps the last read only while the
page holds the form (dirty, or focus inside it). A clean form shows the
read error. org-live-edit-browser test 4 covers both.

Re-check P3-a (harness-off after the consume through a corrupt prior
settings file) is deferred: wording it exactly needs the bus to mark the
phase on the refusal.
This commit was merged in pull request #1961.
This commit is contained in:
jason.woltje committed 2026-11-13 20:05:10 -06:00
1 parent 266f3a45ff
commit 23b5e24ebc
4 files changed
+32 -15

No files matched your search

+4 -2
View File
@@ -48,14 +48,16 @@ const BUS_CODES = {
// the decision, then #hireConsole checks it, consumes it, and applies it. apply() runs check() again // the decision, then #hireConsole checks it, consumes it, and applies it. apply() runs check() again
// and the writers, authorityHost and #fireApproved run after the consume, so most codes can come from // and the writers, authorityHost and #fireApproved run after the consume, so most codes can come from
// either side of it; for a fire the agent's runs may already be stopped. The code alone does not say // either side of it; for a fire the agent's runs may already be stopped. The code alone does not say
// which, so those say how to tell: a consumed decision shows as answered in the inbox. Only codes // which, and the inbox cannot tell either: it lists no closed decision (broker inbox verb), so a
// consumed one simply drops out of it. Those codes therefore say part of the change may already be
// applied and send the user to the Agents page (re-check finding 1). Only codes
// that prepare() alone raises say "Nothing was applied.": the route checks (checkRoutes runs in // that prepare() alone raises say "Nothing was applied.": the route checks (checkRoutes runs in
// prepare(), and in adjusted() only for an adjust, which a start never carries), definition-shared, // prepare(), and in adjusted() only for an adjust, which a start never carries), definition-shared,
// definition-not-hireable and no-chief-of-staff. organization-uncertain, a lost reply and the // definition-not-hireable and no-chief-of-staff. organization-uncertain, a lost reply and the
// bus server's own request codes (request-timeout and invalid-envelope can fire while the request is // bus server's own request codes (request-timeout and invalid-envelope can fire while the request is
// processing) stay outcome-unknown. // processing) stay outcome-unknown.
const NONE = ' Nothing was applied.'; const NONE = ' Nothing was applied.';
const EITHER = ' If the inbox shows this proposal as answered, part of it may already be applied: check the Agents page before trying again. Otherwise nothing was applied.'; const EITHER = ' Part of it may already be applied: check the Agents page before trying again.';
const CHECK = ' Check the Agents page and the inbox before trying again.'; const CHECK = ' Check the Agents page and the inbox before trying again.';
const HIRE_START_REFUSALS = { const HIRE_START_REFUSALS = {
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy.' + NONE], 'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy.' + NONE],
+8 -6
View File
@@ -23,22 +23,24 @@
const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value; const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value;
} }
// E2E6 P1-1: a poll reloads this state; the last save's receipt and the open review list stay. // E2E6 P1-1: a poll reloads this state; the last save's receipt and the open review list stay.
// A failed poll keeps the last good read (review P3), so a dirty form the page holds is not dropped. // A failed poll keeps the last good read only while the page holds the form (dirty, or a field has
async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,prev&&!prev.error?{...prev,...keep}:{error:error.message,...keep});}} // focus), so the held form is not dropped (review P3). A clean form shows the read error (re-check P3).
const held=org=>{const form=[...document.querySelectorAll('[data-autonomy-org]')].find(n=>n.dataset.autonomyOrg===org)?.querySelector('[data-autonomy-form]');return !!form&&(form.hasAttribute('data-live-dirty')||form.contains(document.activeElement));};
async function load(org) {const prev=states.get(org),keep={status:prev?.status??'',reviewOpen:prev?.reviewOpen===true};try{states.set(org,{...await call(org,''),...keep});}catch(error){states.set(org,prev&&!prev.error&&held(org)?{...prev,...keep}:{error:error.message,...keep});}}
function section(org) { function section(org) {
const s=states.get(org); const s=states.get(org);
if(!s || s.error)return `<section class="panel" data-autonomy><h3>Autonomy</h3><p>${esc(s?.error??'Autonomy could not be read.')}</p></section>`; if(!s || s.error)return `<section class="panel" data-autonomy><h3>Autonomy</h3><p>${esc(s?.error??'Autonomy could not be read.')}</p></section>`;
const v=s.autonomy??{}; const v=s.autonomy??{},fk=k=>` data-focus-key="${esc(`autonomy-${k}:${org}`)}"`;
// The form carries the revision it was drawn from: a form a poll kept (workspace holdLive) saves // The form carries the revision it was drawn from: a form a poll kept (workspace holdLive) saves
// against that revision, so it cannot overwrite a change it never showed. // against that revision, so it cannot overwrite a change it never showed.
return `<section class="panel" data-autonomy data-autonomy-org="${esc(org)}" data-live-key="${esc(`autonomy:${org}`)}"><h3>Autonomy</h3><p>Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.</p><form data-autonomy-form data-revision="${esc(JSON.stringify(s.revision??null))}"> return `<section class="panel" data-autonomy data-autonomy-org="${esc(org)}" data-live-key="${esc(`autonomy:${org}`)}"><h3>Autonomy</h3><p>Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.</p><form data-autonomy-form data-revision="${esc(JSON.stringify(s.revision??null))}">
<div class="autonomy-fields">${classes.map(k=>`<label>${labels[k]}<select name="${k}"${s.writable?'':' disabled'}>${levels.map(l=>`<option${s.levels[k]===l?' selected':''}>${l}</option>`).join('')}</select></label>`).join('')}</div> <div class="autonomy-fields">${classes.map(k=>`<label>${labels[k]}<select name="${k}"${s.writable?'':' disabled'}>${levels.map(l=>`<option${s.levels[k]===l?' selected':''}>${l}</option>`).join('')}</select></label>`).join('')}</div>
<div class="autonomy-fields"><label>Reverse window (hours)<input type="number" name="reverseWindowHours" min="1" max="168" value="${v.reverseWindowHours??24}"${s.writable?'':' disabled'}></label><label>Authority resolver<input name="authorityResolver" maxlength="64" value="${esc(v.authorityResolver??'')}" autocomplete="off"${s.writable?'':' disabled'}></label></div> <div class="autonomy-fields"><label>Reverse window (hours)<input type="number" name="reverseWindowHours" min="1" max="168" value="${v.reverseWindowHours??24}"${s.writable?'':' disabled'}></label><label>Authority resolver<input name="authorityResolver" maxlength="64" value="${esc(v.authorityResolver??'')}" autocomplete="off"${s.writable?'':' disabled'}></label></div>
<label class="autonomy-checkbox"><input name="notices" type="checkbox"${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label> <label class="autonomy-checkbox"><input name="notices" type="checkbox"${fk('notices')}${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label>
<p>In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.</p> <p>In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.</p>
${s.writable?'<p><button class="btn" type="submit">Save autonomy</button> <button class="btn" type="button" data-autonomy-discard>Discard changes</button></p>':'<p>Only the owner can change autonomy.</p>'} ${s.writable?`<p><button class="btn" type="submit"${fk('save')}>Save autonomy</button> <button class="btn" type="button" data-autonomy-discard${fk('discard')}>Discard changes</button></p>`:'<p>Only the owner can change autonomy.</p>'}
<p data-autonomy-status role="status">${esc(s.status)}</p></form> <p data-autonomy-status role="status">${esc(s.status)}</p></form>
${s.writable?`<details data-autonomy-review-list${s.reviewOpen?' open':''}><summary>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}">Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`; ${s.writable?`<details data-autonomy-review-list${s.reviewOpen?' open':''}><summary${fk('review-list')}>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}"${fk(`review-${k}`)}>Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`;
} }
function notice(d) { function notice(d) {
if(!d?.autonomy)return ''; if(!d?.autonomy)return '';
+8 -6
View File
@@ -335,7 +335,7 @@ test('E2E6 hire start: an unknown project, an unknown agent on fire and an unkno
assert.equal(r.json().error,code);assert.match(r.json().message,re); assert.equal(r.json().error,code);assert.match(r.json().message,re);
// harness-off comes only from prepare()'s route check; the other two can also come from apply(). // harness-off comes only from prepare()'s route check; the other two can also come from apply().
if(code==='harness-off')assert.match(r.json().message,/ Nothing was applied\.$/); if(code==='harness-off')assert.match(r.json().message,/ Nothing was applied\.$/);
else assert.match(r.json().message,/inbox shows this proposal as answered.*Otherwise nothing was applied\.$/); else {assert.match(r.json().message,/ Part of it may already be applied: check the Agents page before trying again\.$/);assert.doesNotMatch(r.json().message,/nothing was applied/i);}
assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`)); assert.match(s.lines.at(-1),new RegExp(`step=start error=${code}$`));
} }
}); });
@@ -345,12 +345,14 @@ test('E2E6 hire start: the other proposal and apply codes keep their code; apply
assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/ Nothing was applied\.$/); assert.equal(r.json().error,code,`${code}: ${r.text}`);assert.match(r.json().message,/ Nothing was applied\.$/);
} }
// Review finding 1: apply() runs check() again and the writers run after the consume, so these // Review finding 1: apply() runs check() again and the writers run after the consume, so these
// never claim nothing changed outright; they say how to tell from the inbox. // never claim nothing changed, in any case or clause. The inbox lists no closed decision, so they
// do not send the user there to tell which side of the consume the refusal came from (re-check).
for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused','definition-secret-check-required','not-configured','invalid-request']) { for(const code of ['organization-instance-exists','instance-kept','root-fire-shared','definition-rev-conflict','definition-missing','credential-leak','decision-stale','organization-instance-retired','organization-instance-referenced','organization-refused','definition-secret-check-required','not-configured','invalid-request']) {
const {r}=await startWith(new BusReadError(code)); const {r}=await startWith(new BusReadError(code));
assert.equal(r.json().error,code,`${code}: ${r.text}`); assert.equal(r.json().error,code,`${code}: ${r.text}`);
assert.doesNotMatch(r.json().message,/(^|\. )Nothing was applied/,`${code} does not claim nothing changed`); assert.doesNotMatch(r.json().message,/nothing was applied/i,`${code} does not claim nothing changed`);
assert.match(r.json().message,/If the inbox shows this proposal as answered, part of it may already be applied: check the Agents page/); assert.doesNotMatch(r.json().message,/inbox/,`${code} does not point at the inbox, which drops a consumed decision`);
assert.match(r.json().message,/Part of it may already be applied: check the Agents page/);
} }
for(const code of ['organization-busy','fire-in-progress','credential-revoking']) { for(const code of ['organization-busy','fire-in-progress','credential-revoking']) {
const {r}=await startWith(new BusReadError(code)); const {r}=await startWith(new BusReadError(code));
@@ -365,8 +367,8 @@ test('E2E6 hire start: an apply-phase decision-stale on a fire does not say noth
const s=setup({resolve:()=>{throw new BusReadError('decision-stale');}}); const s=setup({resolve:()=>{throw new BusReadError('decision-stale');}});
const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{op:'fire',level:'project',project:'site',name:'coder',reason:'done'}}})); const r=await s.run('/api/decisions/hire/start',request({body:{business:'demo',hire:{op:'fire',level:'project',project:'site',name:'coder',reason:'done'}}}));
assert.equal(s.calls.length,1);assert.equal(r.status,409,r.text);assert.equal(r.json().error,'decision-stale'); assert.equal(s.calls.length,1);assert.equal(r.status,409,r.text);assert.equal(r.json().error,'decision-stale');
assert.doesNotMatch(r.json().message,/(^|\. )Nothing was applied/); assert.doesNotMatch(r.json().message,/nothing was applied/i);
assert.doesNotMatch(r.json().message,/answer it there/,'a consumed decision cannot be answered again'); assert.doesNotMatch(r.json().message,/inbox|answer it there/,'a consumed decision is gone from the inbox');
assert.match(r.json().message,/check the Agents page/); assert.match(r.json().message,/check the Agents page/);
}); });
test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{ test('E2E6 hire start: invalid-request from the bus says the values, not the shape, were refused',async()=>{
@@ -120,7 +120,7 @@ test('E2E6 P2-2: a pack apply re-reads the other sections and keeps its receipt'
assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read'); assert.match(await b.evaluate(`document.querySelector('[data-packs-status]').textContent`), /^Applied\. Added board\./, 'the receipt survives the re-read');
}); });
test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, and a focused button does not hold its section', { timeout: 60000 }, async t => { test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held form, a focused button does not hold its section, and a clean form shows a failed read', { timeout: 60000 }, async t => {
const { b, ctl, refresh } = await fixture(t); const { b, ctl, refresh } = await fixture(t);
await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')&&document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-preview-button]')`, 'sections'); await waitFor(b, `document.querySelector('[data-wf="intake"] [data-wf-edit]')&&document.querySelector('[data-autonomy-form] select[name=merge]')&&document.querySelector('[data-pack-preview-button]')`, 'sections');
// A focused Preview button alone no longer holds the packs section: the next poll shows a changed // A focused Preview button alone no longer holds the packs section: the next poll shows a changed
@@ -150,4 +150,15 @@ test('E2E6 review P3: a failed read keeps an open editor, Discard drops a held f
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'out of the loop', 'the stored value is back'); assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`), 'out of the loop', 'the stored value is back');
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]').hasAttribute('data-live-dirty')`), false); assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form]').hasAttribute('data-live-dirty')`), false);
assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-autonomy-discard')`), true, 'focus stays on Discard'); assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-autonomy-discard')`), true, 'focus stays on Discard');
// Re-check P3: a focused button in a clean form gets its focus back after the poll repaints it.
await refresh({ poll: true });
assert.equal(await b.evaluate(`document.activeElement.dataset.focusKey??document.activeElement.tagName`), 'autonomy-discard:acme', 'focus returns to Discard after a poll');
// Re-check P3: a clean form the page does not hold shows a failed read instead of hiding it.
await b.evaluate(`document.activeElement.blur();true`);
ctl.autonomyFail = true;
await refresh({ poll: true });
await waitFor(b, `!document.querySelector('[data-autonomy-form]')&&/bus-unreachable|could not|refused/i.test(document.querySelector('[data-autonomy]')?.textContent??'')`, 'the autonomy read error');
ctl.autonomyFail = false;
await refresh({ poll: true });
await waitFor(b, `document.querySelector('[data-autonomy-form] select[name=merge]')`, 'the form after a good read');
}); });