Compare commits

..
9 changed files with 284 additions and 70 deletions
+10 -11
View File
@@ -3,23 +3,14 @@ name: Android On-Demand
run-name: Android ${{ inputs.preset }} · ${{ inputs.head_sha }}
on:
workflow_dispatch:
workflow_call:
inputs:
head_sha:
description: Exact pushed commit SHA to verify
required: true
type: string
preset:
description: Android verification lane
required: true
default: focused
type: choice
options:
- focused
- lint
- assemble-debug
- release-smoke
- all-final
type: string
permissions:
contents: read
@@ -38,11 +29,19 @@ jobs:
shell: bash
env:
REQUESTED_SHA: ${{ inputs.head_sha }}
REQUESTED_PRESET: ${{ inputs.preset }}
run: |
if [[ ! "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "head_sha must be a full lowercase 40-character commit SHA" >&2
exit 2
fi
case "$REQUESTED_PRESET" in
focused|lint|assemble-debug|release-smoke|all-final) ;;
*)
echo "unsupported Android preset: $REQUESTED_PRESET" >&2
exit 2
;;
esac
- name: Checkout exact commit
uses: actions/checkout@v7
+30 -9
View File
@@ -20,13 +20,25 @@ on:
description: "Exact candidate commit to check"
required: true
type: string
android_preset:
description: "Optional Android-only compute lane"
required: false
default: auto
type: choice
options:
- auto
- focused
- lint
- assemble-debug
- release-smoke
- all-final
permissions:
contents: read
pull-requests: read
concurrency:
group: ci-required-${{ github.ref }}
group: ci-required-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.head_sha, inputs.android_preset) || github.ref }}
cancel-in-progress: true
jobs:
@@ -113,33 +125,41 @@ jobs:
android:
needs: changes
if: needs.changes.outputs.android == 'true'
if: needs.changes.outputs.android == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
uses: ./.github/workflows/ci-android.yml
android_on_demand:
needs: changes
if: github.event_name == 'workflow_dispatch' && inputs.android_preset != 'auto'
uses: ./.github/workflows/android-on-demand.yml
with:
head_sha: ${{ inputs.head_sha }}
preset: ${{ inputs.android_preset }}
desktop:
needs: changes
if: needs.changes.outputs.desktop == 'true'
if: needs.changes.outputs.desktop == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
uses: ./.github/workflows/ci-desktop.yml
plugin:
needs: changes
if: needs.changes.outputs.plugin == 'true'
if: needs.changes.outputs.plugin == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
uses: ./.github/workflows/ci-plugin.yml
dashboard:
needs: changes
if: needs.changes.outputs.dashboard == 'true'
if: needs.changes.outputs.dashboard == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
uses: ./.github/workflows/ci-dashboard.yml
contract:
needs: changes
if: needs.changes.outputs.contract == 'true'
if: needs.changes.outputs.contract == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
uses: ./.github/workflows/ci-contract.yml
docs:
name: Build public docs
needs: changes
if: needs.changes.outputs.docs == 'true'
if: needs.changes.outputs.docs == 'true' && (github.event_name != 'workflow_dispatch' || inputs.android_preset == 'auto')
runs-on: ubuntu-latest
defaults:
run:
@@ -161,11 +181,12 @@ jobs:
guard:
name: Required checks
if: always()
needs: [changes, android, desktop, plugin, dashboard, contract, docs]
needs: [changes, android, android_on_demand, desktop, plugin, dashboard, contract, docs]
runs-on: ubuntu-latest
env:
CHANGES_RESULT: ${{ needs.changes.result }}
ANDROID_RESULT: ${{ needs.android.result }}
ANDROID_ON_DEMAND_RESULT: ${{ needs.android_on_demand.result }}
DESKTOP_RESULT: ${{ needs.desktop.result }}
PLUGIN_RESULT: ${{ needs.plugin.result }}
DASHBOARD_RESULT: ${{ needs.dashboard.result }}
@@ -176,7 +197,7 @@ jobs:
shell: bash
run: |
failed=0
for check in CHANGES ANDROID DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
for check in CHANGES ANDROID ANDROID_ON_DEMAND DESKTOP PLUGIN DASHBOARD CONTRACT DOCS; do
result_var="${check}_RESULT"
result="${!result_var}"
echo "$check: $result"
+5 -4
View File
@@ -270,9 +270,10 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
## Testing
- **Android cloud verification (preferred for pushed work):** dispatch
`.github/workflows/android-on-demand.yml` against the exact pushed SHA with
`focused`, `lint`, `assemble-debug`, `release-smoke`, or `all-final`. Check for
- **Android cloud verification (preferred for pushed work):** dispatch the
registered `Required checks` workflow with an exact base/head SHA pair and
`android_preset` set to `focused`, `lint`, `assemble-debug`, `release-smoke`,
or `all-final`. It calls the reusable Android workflow from `dev`. Check for
an existing run before dispatching the same SHA/preset again. The four
`all-final` compute jobs use isolated runners and may execute concurrently.
- **Full local Android gate (optional):** `scripts\dev.bat prepush` on Windows
@@ -287,7 +288,7 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
device lane is scheduled automatically.
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched. `android-on-demand.yml` is the trusted manual compute lane for an exact pushed commit; it does not replace required PR checks.
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched. The registered `ci-required.yml` dispatcher calls `android-on-demand.yml` as the trusted manual compute lane for an exact pushed commit; it does not replace required PR checks.
Superseded Android runs on `dev` and PR refs are canceled automatically; `main`
runs are never canceled because each release-branch commit must complete its
independent validation.
@@ -2436,14 +2436,7 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
java.util.IdentityHashMap<Throwable, Boolean>(),
)
while (current != null && seen.add(current)) {
if (
current is DashboardHttpException &&
current.statusCode == 401 &&
(
current.message.orEmpty().contains("no_cookie", ignoreCase = true) ||
current.message.orEmpty().contains("unauthenticated", ignoreCase = true)
)
) {
if (current is DashboardHttpException && current.statusCode == 401) {
return true
}
current = current.cause
@@ -209,6 +209,7 @@ internal class HermesRuntimeBinder(
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
connection.loadProfileScopedMessages(profileName, sessionId, mode)
}
chat.setDashboardSignInRequiredHandler(connection::probeNow)
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
chat.profileSessionDeleter = connection::deleteSession
chat.profileSessionRenamer = connection::renameSession
@@ -3849,6 +3849,12 @@ class ChatViewModel : ViewModel() {
profileSessionPageLister = lister
}
private var dashboardSignInRequiredHandler: (() -> Unit)? = null
fun setDashboardSignInRequiredHandler(handler: () -> Unit) {
dashboardSignInRequiredHandler = handler
}
/**
* Deletes a session scoped to the active profile on gateway connections
* (dashboard `DELETE /api/sessions/{id}?profile=`). The write twin of
@@ -4237,7 +4243,19 @@ class ChatViewModel : ViewModel() {
}
private fun publishHistoryLoadFailure(sessionId: String, error: Throwable) {
if (error.isDashboardSignInRequiredFailure()) return
if (error.isDashboardSignInRequiredFailure()) {
dashboardSignInRequiredHandler?.invoke()
DiagnosticsLog.record(
category = DiagnosticCategory.Auth,
severity = DiagnosticSeverity.Warning,
title = "Dashboard sign-in required for chat history",
detail = "stored_session=$sessionId; dashboard_auth=required",
operation = "load chat history",
endpointRole = "gateway",
suggestion = "Sign in to Dashboard on the active route, then retry this conversation.",
)
return
}
val rawError = error.message?.takeIf { it.isNotBlank() }
?: "The active profile's conversation history could not be reached."
_chatFailure.value = ChatFailureNotice(
@@ -7824,13 +7842,28 @@ class ChatViewModel : ViewModel() {
if (!queuedSuccessorPending.get()) {
val expectedSessionId = checkpoint.sessionId
viewModelScope.launch {
val history = loadSessionHistory(expectedSessionId)
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
handler.loadMessageHistory(history)
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
try {
val history = loadSessionHistory(expectedSessionId)
if (
handler.currentSessionId.value == expectedSessionId &&
history.isNotEmpty()
) {
handler.loadMessageHistory(history)
clearMatchingHistoryLoadFailure(expectedSessionId)
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == expectedSessionId) {
publishHistoryLoadFailure(expectedSessionId, e)
}
} finally {
if (handler.currentSessionId.value == expectedSessionId) {
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
}
drainQueue()
}
drainQueue()
}
}
}
@@ -9712,34 +9745,44 @@ class ChatViewModel : ViewModel() {
)
}
viewModelScope.launch {
if (!turnErrored && !gatewayHistoryReconcileRequired) {
// Profile-aware read: a gateway turn on a non-default profile
// persists into THAT profile's own state.db, so the bare
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
// → a silent wipe of the just-finished turn. loadSessionHistory
// prefers the `?profile=` dashboard loader on gateway connections.
val serverMessages = loadSessionHistory(sid)
val missingPersistedToolActivity =
completedTransport == "gateway" &&
handler.hasMissingPersistedToolActivity(serverMessages)
if (shouldReloadHistoryAfterSuccessfulTurn(
actualTransport = completedTransport,
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
missingPersistedToolActivity = missingPersistedToolActivity,
)
) {
handler.loadMessageHistory(serverMessages)
try {
if (!turnErrored && !gatewayHistoryReconcileRequired) {
// Profile-aware read: a gateway turn on a non-default profile
// persists into THAT profile's own state.db, so the bare
// api_server `/api/sessions/{id}/messages` 404s → emptyList()
// → a silent wipe of the just-finished turn. loadSessionHistory
// prefers the `?profile=` dashboard loader on gateway connections.
val serverMessages = loadSessionHistory(sid)
val missingPersistedToolActivity =
completedTransport == "gateway" &&
handler.hasMissingPersistedToolActivity(serverMessages)
if (shouldReloadHistoryAfterSuccessfulTurn(
actualTransport = completedTransport,
gatewayReconcileRequired = gatewayHistoryReconcileRequired,
missingPersistedToolActivity = missingPersistedToolActivity,
)
) {
handler.loadMessageHistory(serverMessages)
clearMatchingHistoryLoadFailure(sid)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == sid) {
publishHistoryLoadFailure(sid, e)
}
} finally {
// Re-sync the drawer now that the turn is persisted server-side.
// The only other auto-refresh fires ~160ms after session creation
// (RelayApp) — mid-stream, BEFORE the new session's first message
// is persisted, so a brand-new chat would otherwise stay missing
// from the drawer (carried only by the optimistic row) until a
// manual reload. By message.complete the dashboard list includes it.
refreshSessions()
scheduleTitleReconcile(sid)
drainQueue()
}
// Re-sync the drawer now that the turn is persisted server-side.
// The only other auto-refresh fires ~160ms after session creation
// (RelayApp) — mid-stream, BEFORE the new session's first message
// is persisted, so a brand-new chat would otherwise stay missing
// from the drawer (carried only by the optimistic row) until a
// manual reload. By message.complete the dashboard list includes it.
refreshSessions()
scheduleTitleReconcile(sid)
drainQueue()
}
Unit
} else {
@@ -562,17 +562,24 @@ class DashboardApiClientTest {
}
@Test
fun signInRequiredClassifierAcceptsNoCookieButRejectsForbidden() {
fun signInRequiredClassifierAcceptsEveryUnauthorizedShapeButRejectsForbidden() {
val noCookie = DashboardHttpException(
401,
"Session failed - HTTP 401: {\"reason\":\"no_cookie\",\"detail\":\"Unauthorized\"}",
)
val expired = DashboardHttpException(
401,
"Session failed - HTTP 401: {\"reason\":\"session_expired\",\"detail\":\"invalid_or_expired_session\"}",
)
val generic = DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized")
val forbidden = DashboardHttpException(
403,
"Session failed - HTTP 403: forbidden",
)
assertTrue(noCookie.isDashboardSignInRequiredFailure())
assertTrue(expired.isDashboardSignInRequiredFailure())
assertTrue(generic.isDashboardSignInRequiredFailure())
assertFalse(forbidden.isDashboardSignInRequiredFailure())
}
@@ -238,6 +238,63 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("gateway", diagnostic.endpointRole)
}
@Test
fun normalCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
DiagnosticsLog.clear()
apiMessageRequestCount.set(0)
val owner = Profile(name = "owner", model = "model-a", description = "Owner")
val requestedProfiles = mutableListOf<String?>()
val sessionRefreshes = AtomicInteger(0)
val signInRequests = AtomicInteger(0)
viewModel.setSelectedProfileProvider { owner }
viewModel.setSessionProfileNameProvider { owner.name }
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
requestedProfiles += profileName
assertEquals(STORED_SESSION_ID, sessionId)
Result.failure(
DashboardHttpException(401, "Session failed - HTTP 401: Unauthorized"),
)
}
viewModel.setProfileSessionLister { profileName ->
assertEquals(owner.name, profileName)
sessionRefreshes.incrementAndGet()
Result.success(emptyList())
}
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
viewModel.sendMessage("Keep this local prompt")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", "Keep this local answer") },
"live-resumed",
),
)
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Keep this local answer") },
"live-resumed",
),
)
awaitCondition {
!handler.isStreaming.value &&
signInRequests.get() == 1 &&
sessionRefreshes.get() >= 1
}
assertTrue(handler.messages.value.any { it.content == "Keep this local prompt" })
assertTrue(handler.messages.value.any { it.content == "Keep this local answer" })
assertEquals(listOf(owner.name), requestedProfiles)
assertEquals(0, apiMessageRequestCount.get())
assertFalse(viewModel.steerableTurn.value)
assertNull(viewModel.chatFailure.value)
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
assertTrue(diagnostic.suggestion.orEmpty().contains("Sign in to Dashboard"))
}
@Test
fun missingRequiredProfileHistoryLoaderFailsClosedWithoutApiRead() {
DiagnosticsLog.clear()
@@ -2746,6 +2803,92 @@ class ChatViewModelGatewayInboundTurnTest {
)
}
@Test
fun recoveredCompletionUnauthorizedHistoryPreservesTranscriptAndRunsCleanup() {
DiagnosticsLog.clear()
apiMessageRequestCount.set(0)
val checkpointStore = MemoryCheckpointStore(
ChatTurnCheckpoint(
contextKey = PROFILE_CONTEXT,
sessionId = STORED_SESSION_ID,
liveSessionId = "live-resumed",
transport = "gateway",
user = ChatTurnUserCheckpoint("prior-user", "Recovered prompt", 1L),
assistant = ChatTurnAssistantCheckpoint(
id = "prior-assistant",
content = "Recovered partial",
timestamp = 2L,
),
priorUserMessageCount = 0,
baselineAssistantCount = 0,
startedAt = 2L,
updatedAt = System.currentTimeMillis(),
),
)
val requestedProfiles = mutableListOf<String?>()
val sessionRefreshes = AtomicInteger(0)
val signInRequests = AtomicInteger(0)
var failHistory = false
viewModel.setProfileMessageLoaderWithMode { profileName, sessionId, _ ->
requestedProfiles += profileName
assertEquals(STORED_SESSION_ID, sessionId)
if (failHistory) {
Result.failure(
DashboardHttpException(
401,
"Session failed - HTTP 401: {\"reason\":\"session_expired\"}",
),
)
} else {
Result.success(emptyList())
}
}
viewModel.setProfileSessionLister { profileName ->
assertNull(profileName)
sessionRefreshes.incrementAndGet()
Result.success(emptyList())
}
viewModel.setDashboardSignInRequiredHandler { signInRequests.incrementAndGet() }
gatewayHarness.recoveryRunning = true
gatewayHarness.recoveryAssistant = "Recovered partial"
viewModel.setChatTurnCheckpointStore(checkpointStore)
handler.setSessionId(null)
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
viewModel.prewarmGateway()
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.messages.value.any { it.id == "prior-assistant" && it.isStreaming }
}
failHistory = true
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Recovered answer") },
"live-resumed",
),
)
awaitCondition {
!handler.isStreaming.value &&
signInRequests.get() == 1 &&
sessionRefreshes.get() >= 1
}
assertTrue(handler.messages.value.any {
it.id == "prior-assistant" &&
it.content.contains("Recovered answer") &&
!it.isStreaming
})
assertTrue(requestedProfiles.isNotEmpty())
assertTrue(requestedProfiles.all { it == null })
assertEquals(0, apiMessageRequestCount.get())
assertFalse(viewModel.steerableTurn.value)
awaitCondition { checkpointStore.checkpoint == null }
assertNull(viewModel.chatFailure.value)
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Auth), 1).single()
assertEquals("Dashboard sign-in required for chat history", diagnostic.title)
}
@Test
fun lateCanceledCompletionDrainsBeforeImmediateNextTurn() {
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
+10 -4
View File
@@ -26,17 +26,23 @@ Check recent runs before dispatching so another task does not duplicate the
same SHA and preset:
```powershell
gh run list --workflow android-on-demand.yml --event workflow_dispatch --limit 20
$base = git merge-base origin/dev HEAD
$sha = git rev-parse HEAD
gh workflow run android-on-demand.yml --ref dev -f head_sha=$sha -f preset=focused
gh run list --workflow android-on-demand.yml --event workflow_dispatch --limit 5
gh run list --workflow ci-required.yml --event workflow_dispatch --limit 20
gh workflow run ci-required.yml --ref dev `
-f base_sha=$base `
-f head_sha=$sha `
-f android_preset=focused
gh run list --workflow ci-required.yml --event workflow_dispatch --limit 5
```
The SHA must already exist on GitHub. Do not push solely to obtain cloud compute
without push authorization. On-demand jobs read shared Gradle cache state but
do not write it, so task commits cannot replace the cache populated by trusted
`dev`/`main` CI. The on-demand result supplements rather than replaces required
PR checks.
PR checks. `Required checks` is the registered dispatcher because GitHub only
registers manual workflow entry points from the default branch; it calls the
Android workflow from the selected `dev` ref.
## Local use