diff --git a/AGENTS.md b/AGENTS.md index a2a8afa..6570ab1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,8 +60,12 @@ and accepting that JSON shape over teaching callers about Autohub internals. Keep task identity and task start separate from session identity and session start. Missing data stays unavailable. Preserve genuine requests separately from assistant updates; never export raw private thinking blocks. A missing PR -is unknown, not confirmed none. Shared PRs count once, and stale or conflicting -evidence cannot show ready. GitHub and transcript adapters remain read-only. +is unknown, not confirmed none. Shared PRs count once. For standalone sources, +stale or conflicting evidence cannot show ready. Hub-backed tasks use Hub as +the authority for readiness and head validation: preserve its canonical state +and freshness, including the last verified state during an outage. Raw labels +and client cache age must not override Hub status. GitHub and transcript +adapters remain read-only. Task writes require an explicit user Send through a supported messaging route; never send autonomous test messages to real agents. Recheck identity, status, and transport before sending. Preserve receipt deduplication across restarts; diff --git a/docs/COTTAGE_FEED.md b/docs/COTTAGE_FEED.md index c816bb0..154764d 100644 --- a/docs/COTTAGE_FEED.md +++ b/docs/COTTAGE_FEED.md @@ -457,8 +457,8 @@ Dashboard membership controls the default map, matching the voice TUI's Live + recent inventory across all towns. Remaining history is available through the settled toggle. External children excluded by the Hub dashboard retain their parent identities in history; historical PR links do not override this membership. -Background GitHub enrichment is reserved for dashboard tasks; history retains -the PR evidence supplied by the Hub without polling hundreds of old links. +Both dashboard and history retain the PR evidence supplied by the Hub without +independent GitHub polling. Failed pages in either scope keep the previous complete snapshot and mark the feed stale. `id`, `parent`, and `taskId` retain Hub canonical task identities, including observed external sessions. Their @@ -472,3 +472,13 @@ and capabilities; a null target is read-only. Replies also use Hub's optional `expected_attention_version` guard when supplied by detail. The existing receipt ledger and uncertain-delivery behavior remain in effect. No new cancel or autonomous messaging behavior is added. + +### Canonical Hub delivery lifecycle + +When `AUTOHUB_HUB_BASE` is configured, Hub owns PR evidence and inventory membership. CottageCode does not independently query GitHub for those tasks. `pr` is the canonical Hub observation; an absent observation stays unknown. Standalone discovery retains its read-only GitHub adapter. + +Hub cottages also expose `executionStatus`, `version`, `finalization` (`status`, `disposition`, `summary`, `blocker`) and `cleanup` (`status`, `reason`, `checkedAt`). `ready_for_merge` renders as idle with “Ready for merge”; `blocked` renders blocked. Only successful completed work renders done. Failed and cancelled runs render offline, with their precise task state retained. + +Hub PR observations carry `authority: "hub"`: `reviewState` comes from canonical finalization, while raw `labels` remain evidence. The browser preserves that verified state during an outage and marks its evidence stale. It does not infer readiness from a stale ready label or overrule Hub's head-change blocker. Standalone observations retain conservative freshness-based classification. + +Hub `durationMs` is the recorded execution time; review and merge waits do not inflate elapsed time. Hub also owns PR freshness, including immutable merged observations. Once cleanup is `removed`, the historical worktree path remains copyable but no longer offers an open action. diff --git a/src/feed-client.mjs b/src/feed-client.mjs index c9dd7a5..841fc60 100644 --- a/src/feed-client.mjs +++ b/src/feed-client.mjs @@ -62,6 +62,8 @@ export function mergeActivity(current,incoming){ }).sort((a,b)=>a.order-b.order).map(item=>item.event); } export function elapsedMs(agent,now=Date.now()){ + // Delivery may finish long after execution, when its PR merges. + if(Number.isFinite(agent.durationMs)&&agent.durationMs>=0)return agent.durationMs; const start=validTime(agent.taskStartedAt); if(!start)return null; const terminal=['done','offline'].includes(agent.status)||agent.terminal===true; diff --git a/src/feed.mjs b/src/feed.mjs index 1bac076..421a387 100644 --- a/src/feed.mjs +++ b/src/feed.mjs @@ -366,14 +366,12 @@ export function createFeed({ if (agent.inputRequest && nextErrors.length) agent.inputRequest = { ...agent.inputRequest, stale: true }; } let enriched = combined; - try { - // Historical inventories can contain hundreds of old PRs. Keep their - // supplied evidence without spending live GitHub polling on hidden work. - const current = hubInventory.configured ? combined.filter(agent => agent.inventoryScope !== "history") : combined; - const observations = new Map((await enrich(await resolveRepos(current))).map(agent => [agent.id, agent])); - enriched = combined.map(agent => observations.get(agent.id) || agent); + // Hub reconciles its own PRs. Independent enrichment could replace a newer + // shared task state and make CottageCode disagree with the TUI again. + if (!hubInventory.configured) { + try { enriched = await enrich(await resolveRepos(combined)); } + catch { nextErrors.push("PR metadata is temporarily unavailable"); } } - catch { nextErrors.push("PR metadata is temporarily unavailable"); } cache = stampOccupancy(sortCottages(enriched), now()); // Retain activity for retained PR cottages; discard unrelated old sessions. activity = new Map(cache.map(agent => [agent.id, nextActivity.get(agent.id) || activity.get(agent.id) || []])); @@ -426,7 +424,7 @@ export function createFeed({ return scanning; }, async flushEnrichment() { - if (typeof enrich.flush !== "function") return snapshot(); + if (hubInventory.configured || typeof enrich.flush !== "function") return snapshot(); await enrich.flush(); return doScan(); }, diff --git a/src/hub-inventory.mjs b/src/hub-inventory.mjs index b5b1de5..d85b925 100644 --- a/src/hub-inventory.mjs +++ b/src/hub-inventory.mjs @@ -4,6 +4,7 @@ import {toCottage} from './hub.mjs'; import {timestampMs,safeActivityUrl} from './activity.mjs'; import {taskText} from './task-text.mjs'; import {classifyOccupancy} from './occupancy.mjs'; +import {normalizePr} from './pr.mjs'; export function toInventoryCottage(task, now=Date.now()) { const context=typeof task.context==='object' && task.context ? task.context : {}; @@ -19,13 +20,30 @@ export function toInventoryCottage(task, now=Date.now()) { status:task.normalizedStatus==='completed_without_report'?'completed':task.normalizedStatus || task.status, queued_at:task.queuedAt,started_at:task.startedAt,completed_at:task.completedAt,updated_at:task.updatedAt, input_tokens:task.inputTokens,output_tokens:task.outputTokens,cache_write_tokens:task.cacheWriteTokens,cache_read_tokens:task.cacheReadTokens,total_cost:task.totalCost, - result_summary:task.resultSummary || task.displayResult, + result_summary:task.displayResult || task.resultSummary, context:{...context,projectPath:task.projectPath || context.projectPath,workFolder:task.worktreePath || task.projectPath, branch:task.branch,originalAsk:task.originalRequest, ...(prLink ? {pr:{url:prLink.url,state:'unknown',source:'context'}} : {})}, },now); const request=taskText(task.originalRequest); cottage.taskId=task.id; + // The shared inventory owns lifecycle evidence. Old local context and prose + // must never override a fresher reconciliation (or invent a missing PR). + const finalizationState=task.finalization?.status; + const blocker=taskText(typeof task.finalization?.blocker==='string' ? task.finalization.blocker : task.finalization?.blocker?.message); + cottage.pr=normalizePr(task.pr ? {...task.pr,authority:'hub', + reviewState:['ready','blocked'].includes(finalizationState) ? finalizationState : task.pr.reviewState, + reason:blocker || task.pr.reason, + } : (prLink ? {url:prLink.url,state:'unknown',source:'hub',authority:'hub'} : null),now); + cottage.durationMs=Number.isFinite(task.durationMs)&&task.durationMs>=0?task.durationMs:null; + cottage.executionStatus=task.normalizedStatus || task.status; + cottage.finalization=task.finalization ? { + status:task.finalization.status,disposition:task.finalization.disposition, + summary:taskText(task.finalization.summary), + blocker:taskText(typeof task.finalization.blocker==='string' ? task.finalization.blocker : task.finalization.blocker?.message), + } : null; + cottage.cleanup=task.cleanup ? {status:task.cleanup.status,reason:taskText(task.cleanup.reason),checkedAt:task.cleanup.checkedAt} : null; + cottage.version=task.version || 0; cottage.originalAsk=request.slice(0,32768); cottage.originalAskTruncated=request.length>32768; cottage.originalAskSource=task.recordKind==='external_session'?'session':'task'; diff --git a/src/hub.mjs b/src/hub.mjs index 5b0590a..2e7ff41 100644 --- a/src/hub.mjs +++ b/src/hub.mjs @@ -92,7 +92,8 @@ export function mapHubStatus(row, now = Date.now()) { const stale = Number.isFinite(updated) && now - updated > STALE_MS; if (status === "running") return stale ? "offline" : "working"; - if (status === "pending" || status === "queued") return "idle"; + if (status === "pending" || status === "queued" || status === "ready_for_merge") return "idle"; + if (status === "blocked") return "blocked"; if ( status === "awaiting_input" || status === "needs_input" || @@ -106,7 +107,7 @@ export function mapHubStatus(row, now = Date.now()) { } if (status === "failed" || status === "cancelled" || status === "interrupted") { if (!attentionResolved(row) && (row.attention_type || row.attention_message)) return "blocked"; - return aged(updated) ? "offline" : "done"; + return "offline"; } if (status === "stale") return "offline"; return "idle"; diff --git a/src/messages.mjs b/src/messages.mjs index 0774ccb..a75e924 100644 --- a/src/messages.mjs +++ b/src/messages.mjs @@ -61,7 +61,7 @@ export function createHubMessenger({ // redirect: wait until Hub offers the matching response route. else if(shownInput)return unavailable('This task has a pending input request. Wait for AutoHub to expose its response route.'); else if(target.taskStatus==='running'&&(canonical ? target.capabilities?.canSteer===true : target.transport==='tmux'&&target.supportsRedirection===true))mode='redirect'; - else return unavailable(['completed','failed','cancelled','interrupted'].includes(target.taskStatus)?'This task has finished. Start any follow-up in its original workflow.':target.transport==='direct'?'This direct session does not support mid-task messages.':'This task has no supported live message route.'); + else return unavailable(['completed','failed','cancelled','interrupted','blocked','ready_for_merge'].includes(target.taskStatus)?'This task has finished. Start any follow-up in its original workflow.':target.transport==='direct'?'This direct session does not support mid-task messages.':'This task has no supported live message route.'); const resolution=agent.inputRequestResolution; const newerThanUnidentifiedResolution=!resolution?.id&&shownInput?.updatedAt&&resolution?.resolvedAt&&shownInput.updatedAt>resolution.resolvedAt; if(mode==='respond'&&resolution&&(!shownInput||(resolution.id?shownInput.id===resolution.id:!newerThanUnidentifiedResolution)))return unavailable('This input request was already resolved. Refresh before replying.'); diff --git a/src/observatory.mjs b/src/observatory.mjs index f949786..59e2a25 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -100,6 +100,22 @@ export function inspectorPrCounts(agents,{showSettled=false,now=Date.now()}={}){ return prCounts(agents.filter(agent=>agent.inventoryScope!=='history'||isCottageVisible(agent,{showSettled,now})),now); } const link=(url,text)=>safeUrl(url)?''+esc(text)+'':''; +export function worktreeActionsHtml(agent) { + const path=agent.worktreePath; + if(!path)return ''; + const open=agent.cleanup?.status!=='removed'&&path.startsWith('/')?'Open worktree':''; + return '
'+button('copy','Copy worktree path')+open+'
'; +} + +export function lifecycleHtml(agent) { + const label={ready_for_merge:'Ready for merge',completed:'Complete',blocked:'Blocked',failed:'Failed',cancelled:'Cancelled',interrupted:'Interrupted'}[agent.executionStatus]; + const f=agent.finalization,c=agent.cleanup; + if(!label&&!f&&!c)return ''; + return '
'+(label?'
Task
'+esc(label)+'
':'')+ + (f?.blocker?'
Blocker
'+esc(f.blocker)+'
':'')+ + (c?'
Cleanup
'+esc([c.status,c.reason].filter(Boolean).join(' · '))+'
':'')+'
'; +} + export function prSnapshotHtml(value){ const pr=normalizePr(value),stage=pr.stage,s=STAGES[stage],ci=prCi(pr); const labels=pr.labels.length?'':'No GitHub labels supplied.'; @@ -496,7 +512,7 @@ export function createObservatory(api){ const presentation=activityJournalPresentation(cache); content='

At the workbench

'+activityPagingButtons(cache)+'

'+esc(presentation.text)+'

'+(cache.warning?'

'+esc(cache.warning)+'

':'')+(cache.unavailable?'

This source has not made a task journal available.

':'
    '+eventHtml(cache.events)+'
'); }else if(tab==='review'){ - content='

Review desk

'+prSnapshotHtml(pr)+'

The parcel opens the PR. Review and merge stay in your existing workflow.

'; + content='

Review desk

'+lifecycleHtml(a)+prSnapshotHtml(pr)+'

The parcel opens the PR. Review and merge stay in your existing workflow.

'; }else if(tab==='artifacts'){ const arts=[...(Array.isArray(a.artifacts)?a.artifacts:[]),...cache.events.filter(e=>e.url).map(e=>({url:e.url,title:e.text}))]; if(pr.url)arts.unshift({url:pr.url,title:'PR #'+pr.number+' · '+(pr.title||s.label)}); @@ -504,12 +520,12 @@ export function createObservatory(api){ content='

On the shelves

'+(a.result?'
'+esc(a.result)+'
':'')+(rows.length?'':'

Artifacts appear here when the feed supplies them.

'); if(stage==='merged')content='

A keepsake from this work

Merged · '+esc(clock(pr.mergedAt))+'

'+content; }else{ - content='

Today’s work

'+esc(a.task&&a.task!=='-'?a.task:'Task description unavailable')+'

'+esc(latestLine(a)||'No current activity supplied.')+'
'+'

Pull request

'+prSnapshotHtml(pr)+'
Task started
'+esc(clock(a.taskStartedAt))+'
Elapsed
'+esc(elapsed(a))+'
Session started
'+esc(clock(a.sessionStartedAt))+'
Last signal
'+esc(clock(a.updatedAt))+'
Model
'+esc(a.model||'Unavailable')+'
Branch
'+esc(a.branch||'Unavailable')+'

Pinned request

'+esc(a.originalAsk?a.originalAsk.slice(0,230)+(a.originalAsk.length>230?'…':''):'Original request not supplied.')+'

'+button('tab:request','Read the pinned note'); + content='

Today’s work

'+esc(a.task&&a.task!=='-'?a.task:'Task description unavailable')+'

'+esc(latestLine(a)||'No current activity supplied.')+'
'+lifecycleHtml(a)+'

Pull request

'+prSnapshotHtml(pr)+'
Task started
'+esc(clock(a.taskStartedAt))+'
Elapsed
'+esc(elapsed(a))+'
Session started
'+esc(clock(a.sessionStartedAt))+'
Last signal
'+esc(clock(a.updatedAt))+'
Model
'+esc(a.model||'Unavailable')+'
Branch
'+esc(a.branch||'Unavailable')+'

Pinned request

'+esc(a.originalAsk?a.originalAsk.slice(0,230)+(a.originalAsk.length>230?'…':''):'Original request not supplied.')+'

'+button('tab:request','Read the pinned note'); } const nav=[['overview','Clock'],['request','Request'],['journal','Journal'],['todos','To-do'],['review','PR desk'],['artifacts','Shelves']].map(([key,label])=>button('tab:'+key,label,'aria-pressed="'+(tab===key)+'"')).join(''); const header='
'+esc(a.town)+' · '+(mode==='room'?'INSIDE':'COTTAGE')+'

'+esc(a.name)+'

'+esc(a.status)+''+s.symbol+' '+s.label+'
'; const actions='
'+button(mode==='room'?'leave':'enter',mode==='room'?'Leave cottage ↗':'Enter cottage ↗')+button('talk','Talk to '+esc(a.name),'aria-pressed="'+(tab==='talk')+'"')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+button('postcard','Make a postcard')+'
'; - const worktree=a.worktreePath?'
'+button('copy','Copy worktree path')+(a.worktreePath.startsWith('/')?'Open worktree':'')+'
':''; + const worktree=worktreeActionsHtml(a); replacePanel(header+actions+''+content+worktree,(mode==='room'?interiorId:id)+'|'+(a.taskId||'')+':'+tab); return true; } diff --git a/src/pr.mjs b/src/pr.mjs index 0ba158e..7e5d2f6 100644 --- a/src/pr.mjs +++ b/src/pr.mjs @@ -33,11 +33,17 @@ function safeCheckUrl(value) { function checkName(value) { return str(value).replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 160); } function checkWord(value) { return str(value).toUpperCase().replace(/[^A-Z_]/g, "").slice(0, 40); } function normalizeChecks(value) { + if (Array.isArray(value?.entries)) value = value.entries; if (!Array.isArray(value)) return []; return value.map((entry) => { let status = checkWord(entry?.status); let conclusion = checkWord(entry?.conclusion); const state = checkWord(entry?.state); + // Hub folds StatusContext state into status/conclusion. + if (["SUCCESS", "FAILURE", "ERROR"].includes(status)) { + conclusion ||= status; + status = "COMPLETED"; + } // StatusContext has `state` instead of CheckRun's `status`/`conclusion`. if (!status && !conclusion && state) { if (["SUCCESS", "FAILURE", "ERROR"].includes(state)) { @@ -54,7 +60,7 @@ function normalizeChecks(value) { /** A concise, factual rollup for presentation. It never determines merge readiness. */ export function prCi(pr) { - const source = Array.isArray(pr?.checks) && pr.checks.length ? pr.checks : pr?.statusCheckRollup; + const source = Array.isArray(pr?.checks?.entries) ? pr.checks.entries : (Array.isArray(pr?.checks) && pr.checks.length ? pr.checks : pr?.statusCheckRollup); const checks = normalizeChecks(source); if (!checks.length) return { state: "unavailable", total: 0, passed: 0, failed: 0, pending: 0, url: "" }; const failed = checks.filter(check => ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(check.conclusion)); @@ -87,6 +93,7 @@ export function parsePrUrl(value) { */ export function normalizePr(value, now = Date.now()) { const p = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + const canonical = p.authority === "hub"; const receipt = p.finalization && typeof p.finalization === "object" ? p.finalization : null; const liveUrl = parsePrUrl(p.url); const receiptUrl = parsePrUrl(receipt?.pullRequestUrl); @@ -97,7 +104,7 @@ export function normalizePr(value, now = Date.now()) { const labels = labelNames(p.labels); const checks = normalizeChecks(p.checks || p.statusCheckRollup); const checkedAt = timestamp(p.checkedAt) || timestamp(receipt?.checkedAt); - const stale = p.stale === true || !checkedAt || now - checkedAt > PR_FRESH_MS || checkedAt > now + 60_000; + const stale = canonical ? p.stale === true : p.stale === true || !checkedAt || now - checkedAt > PR_FRESH_MS || checkedAt > now + 60_000; const headSha = str(p.headSha || p.headRefOid); const reviewedHeadSha = str(p.reviewedHeadSha || receipt?.branchHeadSha); const source = str(p.source) || (receipt ? "finalization" : value ? "feed" : "unavailable"); @@ -140,28 +147,28 @@ export function normalizePr(value, now = Date.now()) { const labelReview = babysitLabels.length === 1 ? reviewName(babysitLabels[0]) : null; const directReview = reviewName(p.reviewState); const receiptReview = reviewName(receipt?.terminalLabel) || reviewName(receipt?.status); - let reviewState = labelReview || (!liveLabels ? directReview || receiptReview : null) || "unknown"; + let reviewState = canonical ? directReview || (state !== "open" ? labelReview : null) || "unknown" : labelReview || (!liveLabels ? directReview || receiptReview : null) || "unknown"; - if (babysitLabels.length > 1 || (babysitLabels.length === 1 && !labelReview)) { + if (!canonical && (babysitLabels.length > 1 || (babysitLabels.length === 1 && !labelReview))) { uncertain = true; reason = "Conflicting or unrecognized babysit labels; review state is uncertain."; - } else if (labelReview && directReview && labelReview !== directReview) { + } else if (!canonical && labelReview && directReview && labelReview !== directReview) { uncertain = true; reason = "PR labels and the supplied review state disagree."; } const receiptTime = timestamp(receipt?.checkedAt); - if (labelReview && receiptReview && labelReview !== receiptReview && + if (!canonical && labelReview && receiptReview && labelReview !== receiptReview && (!checkedAt || !receiptTime || receiptTime >= checkedAt)) { uncertain = true; reason = "PR labels and the finalization receipt disagree."; } - if (receipt && reviewName(receipt.status) && reviewName(receipt.terminalLabel) && + if (!canonical && receipt && reviewName(receipt.status) && reviewName(receipt.terminalLabel) && reviewName(receipt.status) !== reviewName(receipt.terminalLabel)) { uncertain = true; reason = "Finalization status and terminal label disagree."; } - if (reviewState === "ready") { + if (reviewState === "ready" && !canonical) { if (stale) { uncertain = true; reason = "Readiness observation is stale or has no verification time."; @@ -191,7 +198,7 @@ export function normalizePr(value, now = Date.now()) { labels, checks, headSha, reviewedHeadSha, source, checkedAt, stale, reason, stage, reviewUncertain: uncertain, }; - for (const key of ["repo", "host", "observedReadyHeadSha", "isDraft", "mergedAt", "closedAt", "openedAt"]) + for (const key of ["authority", "repo", "host", "observedReadyHeadSha", "isDraft", "mergedAt", "closedAt", "openedAt"]) if (p[key] !== undefined) result[key] = p[key]; if (receipt) result.finalization = { ...receipt }; return result; diff --git a/test/feed-client.test.mjs b/test/feed-client.test.mjs index d7426a7..140ce14 100644 --- a/test/feed-client.test.mjs +++ b/test/feed-client.test.mjs @@ -76,3 +76,12 @@ test('feed note separates live and recent cottages from historical inventory',() assert.equal(feedStatusNote([],{source:'claude'}),'Claude sessions · 0 live + recent · 0 settled'); assert.equal(feedStatusNote([]),'Custom feed · 0 live + recent · 0 settled'); }); + +test('recorded execution duration excludes delivery wait and supports zero',()=>{ + const start=1700000000000; + const merged={taskStartedAt:start,status:'done',endedAt:start+169*60000,durationMs:63*60000}; + assert.equal(elapsedMs(merged),63*60000); + assert.equal(elapsedMs({...merged,durationMs:0}),0); + assert.equal(elapsedMs({durationMs:500}),500,'recorded duration does not need a start date'); + for(const durationMs of [null,undefined,-1,Infinity,NaN,'1000'])assert.equal(elapsedMs({...merged,durationMs}),169*60000); +}); diff --git a/test/feed.test.mjs b/test/feed.test.mjs index 95fc402..190769a 100644 --- a/test/feed.test.mjs +++ b/test/feed.test.mjs @@ -221,8 +221,8 @@ test("resolved terminal Hub attention does not remain blocked or visible", () => ...resolution, }; const cottage = toCottage(row, now); - assert.equal(mapHubStatus(row, now), "done", `${status} remains terminal after attention resolves`); - assert.equal(cottage.status, "done"); + assert.equal(mapHubStatus(row, now), "offline", `${status} remains terminal after attention resolves`); + assert.equal(cottage.status, "offline"); assert.equal(cottage.occupancy, "settled"); assert.equal(cottage.inputRequest, null); assert.equal(cottage.attention, ""); diff --git a/test/hub-inventory.test.mjs b/test/hub-inventory.test.mjs index 4b94bff..2793d5d 100644 --- a/test/hub-inventory.test.mjs +++ b/test/hub-inventory.test.mjs @@ -2,6 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {createHubInventoryReader,toInventoryCottage} from '../src/hub-inventory.mjs'; import {createFeed} from '../src/feed.mjs'; +import {elapsedMs,normalizeCottage} from '../src/feed-client.mjs'; +import {prCi,normalizePr} from '../src/pr.mjs'; const now=Date.parse('2026-09-22T06:00:00Z'); const task=(id,extra={})=>({id,recordKind:'logical_task',task:'Short task',originalRequest:'Full request',status:'running',updatedAt:new Date(now).toISOString(),projectPath:'/projects/autohub',worktreePath:'/projects/autohub/.worktrees/fix',branch:'fix/cursor',source:'codex_app_server',provider:'openai',backend:'codex',sessionId:'session',currentActivity:'Testing pagination',controlTargetId:null,capabilities:{canInspect:true,canRespond:false,canSteer:false},...extra}); test('canonical model hints preserve known providers without inventing a model family',()=>{ @@ -92,7 +94,7 @@ test('configured hub mode never calls standalone scanner or SQLite reader',async await feed.scan();assert.equal(scans,0);assert.equal(reads,0);assert.equal(feed.snapshot().agents[0].id,'canonical');assert.equal(feed.snapshot().source,'hub'); }); -test('historical inventory does not consume live repository and GitHub polling',async()=>{ +test('Hub inventory never consumes local repository or GitHub polling',async()=>{ const agents=[{...toInventoryCottage(task('current'),now),inventoryScope:'dashboard'}, {...toInventoryCottage(task('old',{resultLinks:[{kind:'pull_request',url:'https://github.com/acme/repo/pull/1'}]}),now),inventoryScope:'history'}]; const calls=[]; @@ -100,7 +102,7 @@ test('historical inventory does not consume live repository and GitHub polling', resolveRepos:async a=>{calls.push(['repos',a.map(agent=>agent.id)]);return a;}, enrich:async a=>{calls.push(['github',a.map(agent=>agent.id)]);return a;},now:()=>now}); await feed.scan(); - assert.deepEqual(calls,[['repos',['current']],['github',['current']]]); + assert.deepEqual(calls,[]); assert.equal(feed.snapshot().agents.length,2); assert.equal(feed.snapshot().agents.find(a=>a.id==='old').pr.url,'https://github.com/acme/repo/pull/1'); }); @@ -209,3 +211,49 @@ test('canonical result links reject credentials and malformed URLs before public assert.deepEqual(cottage.artifacts,[{url:'https://example.com/report',title:'Report'}]); assert.doesNotMatch(JSON.stringify(cottage),/secret/); }); + + +test('canonical lifecycle supersedes old development receipts and keeps delivery states stopped',()=>{ + const url='https://github.com/verygoodplugins/autohub/pull/1757'; + const row=task('1757',{status:'completed',displayResult:'Merged #1757',resultSummary:'Did not open a PR', + pr:{number:1757,url,state:'merged',headSha:'abc',checks:{status:'passing',total:1,entries:[{name:'CI',status:'SUCCESS',conclusion:'SUCCESS'}]},labels:['babysit:ready'],source:'github',checkedAt:new Date(now).toISOString()}, + finalization:{status:'merged'},cleanup:{status:'deferred',reason:'Live process'},version:4, + context:{pr:{state:'open',url},finalization:{status:'blocked'}}}); + const cottage=toInventoryCottage(row,now); + assert.equal(cottage.pr.state,'merged');assert.equal(cottage.result,'Merged #1757'); + assert.equal(prCi(cottage.pr).state,'passing');assert.equal(prCi(cottage.pr).total,1); + assert.equal(cottage.finalization.status,'merged');assert.equal(cottage.cleanup.reason,'Live process'); + assert.equal(cottage.executionStatus,'completed');assert.equal(cottage.version,4); + assert.equal(toInventoryCottage({...row,status:'ready_for_merge'},now).status,'idle'); + assert.equal(toInventoryCottage({...row,status:'blocked'},now).status,'blocked'); + assert.equal(toInventoryCottage({...row,status:'failed'},now).status,'offline'); +}); + + +test('Hub delivery authority wins over raw labels and preserves stale verified readiness',()=>{ + const pr={number:1757,url:'https://github.com/verygoodplugins/autohub/pull/1757',state:'open',headSha:'new',labels:['babysit:ready'],source:'github',checkedAt:new Date(now).toISOString()}; + const blocked=toInventoryCottage(task('head-changed',{status:'blocked',pr,finalization:{status:'blocked',blocker:'Head changed; review required'}}),now); + assert.equal(blocked.pr.stage,'blocked');assert.equal(blocked.pr.reviewState,'blocked'); + assert.deepEqual(blocked.pr.labels,['babysit:ready'],'raw evidence remains visible'); + assert.equal(blocked.pr.reason,'Head changed; review required'); + const stale=toInventoryCottage(task('ready',{status:'ready_for_merge',pr:{...pr,stale:true,checkedAt:new Date(now-3600000).toISOString()},finalization:{status:'ready'}}),now); + assert.equal(stale.pr.stage,'ready');assert.equal(stale.pr.stale,true); + assert.equal(stale.status,'idle'); + const renormalized=normalizePr(stale.pr,now); + assert.equal(renormalized.stage,'ready','browser normalization preserves Hub authority'); +}); + +test('merged Hub tasks preserve execution duration through browser normalization',()=>{ + const cottage=toInventoryCottage(task('merged',{status:'completed',startedAt:new Date(now-169*60000).toISOString(),completedAt:new Date(now).toISOString(),durationMs:63*60000}),now); + const browser=normalizeCottage(cottage,0,{town:x=>x,model:x=>x,occupancy:()=> 'recent',now}); + assert.equal(elapsedMs(browser),63*60000); +}); + +test('canonical Hub freshness remains authoritative after local PR cache TTL',()=>{ + const pr={number:1757,url:'https://github.com/verygoodplugins/autohub/pull/1757',state:'merged',stale:false,source:'github',checkedAt:new Date(now-86400000).toISOString()}; + const cottage=toInventoryCottage(task('merged',{status:'completed',pr}),now); + assert.equal(cottage.pr.stale,false); + assert.equal(normalizePr(cottage.pr,now+86400000).stale,false,'browser must not invent a stale merged observation'); + assert.equal(normalizePr({...cottage.pr,stale:true},now).stale,true,'Hub outage evidence remains visible'); + assert.equal(normalizePr(pr,now).stale,true,'standalone observations retain their TTL'); +}); diff --git a/test/observatory.test.mjs b/test/observatory.test.mjs index 918afb9..d719376 100644 --- a/test/observatory.test.mjs +++ b/test/observatory.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {activityJournalPresentation,activityCacheFor,applyActivityPage,returnToLatestActivity,activityPagingButtons,isPracticeDemo,button,handoffAction,appendInlineHandoffs,isApprenticeArrivalActive,apprenticeArrivalPosition,apprenticeResidentPosition,apprenticeResidentTarget,prSnapshotHtml,cottageSelection,inspectorPrCounts} from '../src/observatory.mjs'; +import {activityJournalPresentation,activityCacheFor,applyActivityPage,returnToLatestActivity,activityPagingButtons,isPracticeDemo,button,handoffAction,appendInlineHandoffs,isApprenticeArrivalActive,apprenticeArrivalPosition,apprenticeResidentPosition,apprenticeResidentTarget,prSnapshotHtml,lifecycleHtml,worktreeActionsHtml,cottageSelection,inspectorPrCounts} from '../src/observatory.mjs'; const pending = { id: 'practice-question', prompt: 'Which scope should I use?' }; @@ -184,3 +184,19 @@ test('latest journal action survives a live source cursor reset while browsing h assert.equal(cache.cursor,'new-source');assert.equal(cache.hasMore,true); assert.doesNotMatch(activityPagingButtons(cache),/data-action="latest"/); }); + + +test('delivery lifecycle shows readiness and cleanup without a success claim for failures',()=>{ + assert.match(lifecycleHtml({executionStatus:'ready_for_merge'}),/Ready for merge/); + const html=lifecycleHtml({executionStatus:'blocked',finalization:{blocker:'Review '},cleanup:{status:'deferred',reason:'Live owner'}}); + assert.match(html,/Review <missing>/);assert.match(html,/deferred · Live owner/); + assert.match(lifecycleHtml({executionStatus:'failed'}),/Failed/); + assert.doesNotMatch(lifecycleHtml({executionStatus:'failed'}),/Complete/); +}); + +test('removed worktrees retain historical copy but offer no invalid launch',()=>{ + const cottage={worktreePath:'/tmp/worktree',cleanup:{status:'removed'}}; + assert.doesNotMatch(worktreeActionsHtml(cottage),/Open worktree|cursor:/); + assert.match(worktreeActionsHtml(cottage),/Copy worktree path/); + assert.match(worktreeActionsHtml({...cottage,cleanup:{status:'deferred'}}),/Open worktree/); +});