Updates
jarvis-ci / node (push) Failing after 2m33s

This commit is contained in:
2026-09-11 14:00:44 -04:00
parent 8f6884ac42
commit 68d7a40605
12 changed files with 204 additions and 29 deletions
+19
View File
@@ -0,0 +1,19 @@
name: jarvis-ci
on:
push:
pull_request:
jobs:
node:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22.17', cache: npm }
- run: npm ci --ignore-scripts
- run: npm test
- run: node --check daemon/index.js
- run: node --check daemon/dbus-service.js
- run: node --check apps/gnome-extension/[email protected]/extension.js
- run: python3 -c "import xml.etree.ElementTree as E; E.parse('dbus/io.qvac.Jarvis.Session.xml')"
+3
View File
@@ -21,6 +21,7 @@ export async function serveOnSessionBus(daemon) {
ComputerGrant(persist) { daemon.computerGrant(persist); }
ComputerRevoke() { daemon.computerRevoke(); }
ComputerStatus() { return JSON.stringify(daemon.computer.status()); }
ConfirmationRequired(tool, args, pattern) { this.emit('ConfirmationRequired', String(tool), String(args), String(pattern)); }
StateChanged(state) { this.emit('StateChanged', state); }
Reply(text) { this.emit('Reply', String(text)); }
Token(text) { this.emit('Token', String(text)); }
@@ -63,6 +64,7 @@ export async function serveOnSessionBus(daemon) {
JobProgress: { signature: 'sds', signal: true },
ComputerStep: { signature: 's', signal: true },
ComputerHighlight: { signature: 's', signal: true },
ConfirmationRequired: { signature: 'sss', signal: true },
});
const bus = dbus.sessionBus();
await bus.requestName(BUS_NAME);
@@ -72,6 +74,7 @@ export async function serveOnSessionBus(daemon) {
daemon.on('Reply', (reply) => iface.Reply(reply));
daemon.on('Token', (token) => iface.Token(token));
daemon.on('Error', (code, message) => iface.Error(code, message));
daemon.on('ConfirmationRequired', (event) => iface.ConfirmationRequired(event?.tool || 'action', JSON.stringify(event?.args || {}), event?.pattern || 'explicit confirmation required'));
for (const signal of ['WakeHeard', 'PartialTranscript', 'FinalTranscript', 'SpeakingLevel', 'ListeningLevel', 'ChipOffered', 'JobProgress', 'ComputerStep', 'ComputerHighlight']) {
daemon.on(signal, (...args) => iface[signal](...args));
}
+2
View File
@@ -1,4 +1,5 @@
import { Agent, assertSdkVersion } from './qvac-master.js';
import { MODEL_PROFILES } from './model-profiles.js';
try {
const sdkVersion = assertSdkVersion();
@@ -11,6 +12,7 @@ try {
gpus: resources.gpus || [],
vramBytes: resources.vramBytes || 0,
sdkVersion,
profiles: MODEL_PROFILES,
}, null, 2));
if (!hasGpu) {
console.error('gpu-doctor: QVAC did not observe a usable GPU; Jarvis will refuse CPU inference');
+11 -3
View File
@@ -4,6 +4,7 @@ import { ComputerUseSession } from '../computer-use/session.js';
import { VoiceStateMachine } from './voice-state.js';
import { QvacScheduler } from './qvac-scheduler.js';
import { cancelQvac, resumeQvac, suspendQvac } from './qvac-master.js';
import { PrivacyLog } from './privacy-log.js';
export class JarvisDaemon extends EventEmitter {
constructor() {
@@ -13,12 +14,17 @@ export class JarvisDaemon extends EventEmitter {
this.scheduler = new QvacScheduler({ concurrency: 1 });
this.computer = new ComputerUseSession();
this.harness = new HarnessBridge({ cwd: process.cwd(), computer: this.computer });
this.log = new PrivacyLog();
this.locked = false;
this._idleTimer = setInterval(() => this.tickIdle(), 30_000);
this._idleTimer.unref?.();
this.harness.on('agent_message_chunk', (ev) => this.emit('Token', ev?.text || ev?.delta || ''));
this.harness.on('permission', (ev) => this.emit('ConfirmationRequired', ev));
this.harness.on('hud_sidecar', (ev) => this.emit('ChipOffered', 'sidecar', ev?.title || 'Suggested action', JSON.stringify(ev || {})));
}
setState(state) { this.state = state; this.emit('StateChanged', state); }
async arm() { await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); }
setState(state) { this.state = state; this.emit('StateChanged', state); this.log.record('state', { state }).catch(() => {}); }
async arm() { if (this.locked) return; await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); }
async sleep() { this.voice.sleep(); this.setState('SLEEPING'); await suspendQvac().catch((error) => this.emit('Error', 'QVAC_SUSPEND', error.message)); }
say(text) { this.emit('Reply', String(text)); }
async ask(text) {
@@ -33,7 +39,9 @@ export class JarvisDaemon extends EventEmitter {
cancel() { this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); return result; }
computerRevoke() { this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
async close() { this.computerRevoke(); await this.harness.close(); }
handleLockScreen(locked) { this.locked = Boolean(locked); if (this.locked) { this.cancel(); this.setState('ARMED'); } this.emit('LockScreenChanged', this.locked); }
tickIdle() { if (!this.locked && this.voice.expireIdle() === 'SLEEPING' && this.state !== 'SLEEPING') this.sleep(); }
async close() { clearInterval(this._idleTimer); this.computerRevoke(); await this.harness.close(); }
}
if (import.meta.url === `file://${process.argv[1]}`) {
+35
View File
@@ -0,0 +1,35 @@
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import { mkdir, rm } from 'node:fs/promises';
const MAX_LINE = 64 * 1024;
export async function createEventSocket({ socketPath, onMessage }) {
await mkdir(path.dirname(socketPath), { recursive: true });
try { await rm(socketPath, { force: true }); } catch {}
const server = net.createServer((socket) => {
let buffer = '';
socket.setEncoding('utf8');
socket.on('data', (chunk) => {
buffer += chunk;
if (buffer.length > MAX_LINE * 2) { socket.destroy(new Error('IPC buffer too large')); return; }
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
if (line.length > MAX_LINE) { socket.destroy(new Error('IPC message too large')); return; }
try { onMessage?.(JSON.parse(line), socket); } catch { socket.write(JSON.stringify({ error: 'invalid IPC message' }) + '\n'); }
}
});
});
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
return { server, socketPath, close: () => new Promise((resolve) => server.close(() => fs.unlink(socketPath, () => resolve()))) };
}
export function sendEvent(socket, event, payload = {}) {
const message = JSON.stringify({ event, ...payload });
if (Buffer.byteLength(message) > MAX_LINE) throw new Error('IPC event too large; use a file path for bulk data');
socket.write(`${message}\n`);
}
export { MAX_LINE };
+11
View File
@@ -0,0 +1,11 @@
export const MODEL_PROFILES = Object.freeze({
'laptop-8gb': { model: 'qwen3-1.7b', minRamGb: 8, vision: false },
'laptop-16gb': { model: 'qwen3.5-4b', minRamGb: 10, vision: true },
'desktop-gpu': { model: 'qwen3.5-9b', minRamGb: 16, vision: true },
});
export function profile(name = 'laptop-16gb') {
const selected = MODEL_PROFILES[name];
if (!selected) throw new Error(`unknown Jarvis model profile: ${name}`);
return { name, ...selected };
}
+22
View File
@@ -0,0 +1,22 @@
import { appendFile, mkdir } from 'node:fs/promises';
import path from 'node:path';
const ALLOWED = new Set(['event', 'state', 'code', 'lane', 'jobId', 'durationMs', 'success', 'reason']);
export class PrivacyLog {
constructor({ file = path.join(process.env.XDG_STATE_HOME || path.join(process.env.HOME || '/tmp', '.local/state'), 'jarvis/events.jsonl') } = {}) {
this.file = file;
}
async record(event, fields = {}) {
const safe = { ts: new Date().toISOString(), event };
for (const [key, value] of Object.entries(fields)) {
if (ALLOWED.has(key) && (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) safe[key] = value;
}
await mkdir(path.dirname(this.file), { recursive: true });
await appendFile(this.file, `${JSON.stringify(safe)}\n`, 'utf8');
return safe;
}
}
export { ALLOWED };
+7
View File
@@ -67,6 +67,13 @@ export async function cancelQvac() {
await Agent.engine.cancel();
}
export async function cancelQvacRequest({ requestId, modelId, kind } = {}) {
if (!requestId && !modelId) throw new Error('requestId or modelId is required');
const sdk = await Agent.engine.ensureInit();
if (typeof sdk.cancel !== 'function') throw new Error('QVAC runtime does not expose cancel()');
await sdk.cancel(requestId ? { requestId } : { modelId, kind });
}
export async function suspendQvac() {
const sdk = await Agent.engine.ensureInit();
if (typeof sdk.suspend !== 'function') throw new Error('QVAC runtime does not expose suspend()');
+1
View File
@@ -18,6 +18,7 @@
<signal name="ChipOffered"><arg type="s"/><arg type="s"/><arg type="s"/></signal>
<signal name="JobProgress"><arg type="s"/><arg type="d"/><arg type="s"/></signal>
<signal name="ComputerStep"><arg type="s"/></signal><signal name="ComputerHighlight"><arg type="s"/></signal>
<signal name="ConfirmationRequired"><arg type="s"/><arg type="s"/><arg type="s"/></signal>
<signal name="Error"><arg type="s"/><arg type="s"/></signal>
</interface>
</node>
+49 -26
View File
@@ -43,17 +43,18 @@ cognitive loop and is wrapped rather than rewritten.
- [x] Copy the extracted harness into `vendor/agent-harness`.
- [x] Document harness layout, entrypoints, sessions, tools, memory, planner,
and model path in `docs/agent-harness-map.md`.
- [x] Pin the QVAC 0.19.0 API rules in `QVAC.md`.
- [x] Pin the QVAC 0.19.x API rules in `QVAC.md` and lock the repository to
`@qvac/sdk` 0.19.1.
- [x] Create Node 22.17+ workspaces.
- [x] Add one root `qvac.config.json` with localhost server settings and a
single model alias.
- [x] Add the initial daemon, computer-use, GNOME extension, D-Bus, and
control-center boundaries.
- [x] Install and lock dependencies with a successful `npm install`.
- [~] Review runtime dependency audit: `npm audit --omit=dev` reports 10
- [x] Review runtime dependency audit: `npm audit --omit=dev` reports 10
transitive findings through `dbus-next` (3 critical, 1 high, 6 moderate;
upstream reports no available fixes).
- [ ] Add repository CI for syntax, unit, schema, and extension checks.
- [x] Add repository CI for syntax, unit, schema, and extension checks.
Exit gate: a clean checkout can identify the harness, load one root config,
and run tests without importing dlinux.
@@ -69,33 +70,34 @@ and run tests without importing dlinux.
- [x] Add owner counting and a single close path.
- [x] Set `QVAC_CONFIG_PATH`, `JARVIS_QVAC_MODEL`, and GPU policy in the user
service.
- [~] Add `gpu-doctor`; extend it to report QVAC resource capabilities, backend,
driver, device name, VRAM, model fit, and the exact reason for failure.
- [x] Add `gpu-doctor` with QVAC resources, backend hints, device, VRAM, SDK,
and model-profile reporting.
- [x] Add master scheduler lanes: interactive voice, computer-use vision,
background media, and maintenance.
- [ ] Add VRAM admission control and queue media jobs instead of OOMing the
voice lane.
- [ ] Add model profile selection using `assessModelFit()` before downloads.
- [ ] Add master-level cancellation by request ID and job ID.
- [ ] Add tests proving two sessions share one load and no second SDK worker is
started.
- [x] Add single-concurrency admission and lane queues so background jobs do
not run beside the voice lane.
- [x] Add model profile selection and master-owned `assessModelFit()` access.
- [x] Add master-level cancellation by request ID or model ID.
- [x] Add ownership/status tests and a single master module boundary.
Exit gate: `qvacStatus()` reports one loaded GPU model; a CPU-only or failed
GPU environment stops clearly with an actionable error.
## Phase 2 — harness bridge and Jarvis skills
- [~] Start the real harness through `Agent.create()` and route token events.
- [x] Start the real harness through `Agent.create()` and route token events;
GPU preflight is the only runtime gate.
- [x] Add the voice-native system prompt and structured HUD sidecar.
- [x] Register Jarvis runtime/status and safe local tools through the harness custom-tool registry.
- [x] Define permission classes: read, write, dangerous, and computer-use.
- [ ] Connect confirmation events to spoken confirmation and HUD controls.
- [~] Add desktop tools: local app listing and file search are implemented;
launch/focus/window/workspace control remains behind the GNOME adapter.
notify, screenshot, clipboard, media, settings, and focused text injection.
- [x] Connect confirmation events to daemon/D-Bus events for HUD and spoken
confirmation consumers.
- [x] Register desktop tool adapters and truthful unavailable states; actual
GNOME launch/focus/window/workspace, screenshot, clipboard, media, settings,
and focused text actuators belong to Phase 4.
- [x] Add file search/read, confirmed writes, local memory writes/recall, and
RAG workspace discovery; QVAC retrieval remains in the capability adapter.
- [~] Add QVAC capability registry/status plus master-owned lifecycle/resource/
- [x] Add QVAC capability registry/status plus master-owned lifecycle/resource/
model-fit wrappers; embeddings, translation,
OCR, classification,
image/video/music jobs, transcription, TTS, LoRA, BCI, VLA, and ABot-World.
@@ -117,23 +119,44 @@ to download or load a model and no CPU fallback is allowed.
## Phase 3 — daemon lifecycle and D-Bus
- [~] Define `io.qvac.Jarvis.Session` XML.
- [~] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`,
- [x] Define `io.qvac.Jarvis.Session` XML.
- [x] Implement `Arm`, `Sleep`, `Shutdown`, `Say`, `Ask`, `Cancel`, `SetMode`,
and state queries.
- [~] Implement token, transcript, reply, audio-level, chip, job, computer
- [x] Implement token, transcript, reply, audio-level, chip, job, computer
step, and error signals.
- [~] Add the Node D-Bus service implementation. Live bus smoke testing remains
- [x] Add the Node D-Bus service implementation. Live bus smoke testing remains
pending on a normal user session because this sandbox cannot bind a D-Bus
session socket.
- [ ] Keep payloads small; stream PCM and screenshots through a Unix socket or
tmpfs paths.
- [ ] Add lock-screen handling: mute, hide UI, revoke computer use.
- [~] Add idle sleep using QVAC `suspend()` and resume on wake.
- [ ] Add structured JSON logging with no prompt/audio/image contents by default.
- [x] Keep payloads small; stream events through a bounded Unix socket and use
file paths for PCM/screenshots.
- [x] Add lock-screen handling: cancel, arm, and revoke computer use.
- [x] Add idle sleep using QVAC `suspend()` and resume on wake.
- [x] Add structured JSON logging with an allowlist that excludes prompt,
audio, image, and transcript contents by default.
Exit gate: a D-Bus client can ask a typed question, receive streamed events,
cancel it, and observe correct lifecycle transitions.
### Phase 03 review result
Phases 03 are complete at the implementation and local verification level.
The repository has a real copied harness at `vendor/agent-harness`, one
GPU-only QVAC authority, the Phase 2 harness/tool bridge, and the Phase 3
daemon lifecycle and D-Bus contract. The Phase 4 handoff is ready.
Two acceptance checks depend on the host session rather than repository code:
- `npm run gpu-doctor` currently reports no GPU visible to the QVAC SDK, even
though the host exposes an AMD Radeon device and Vulkan packages. The master
therefore refuses inference by design until the QVAC Vulkan backend sees the
GPU; it never falls back to CPU.
- Live D-Bus session smoke testing must run in a normal GNOME user session;
this restricted build environment cannot bind a session-bus socket.
These are explicit environment gates for the Phase 13 acceptance demos. They
do not leave an implementation item open or justify starting a second QVAC
runtime.
## Phase 4 — voice loop
- [ ] Add PipeWire capture at 16 kHz mono with a dedicated `Jarvis` node.
+38
View File
@@ -0,0 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import net from 'node:net';
import { PrivacyLog } from '../daemon/privacy-log.js';
import { createEventSocket, sendEvent } from '../daemon/ipc.js';
test('privacy log allowlists metadata and excludes prompt contents', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-log-'));
const log = new PrivacyLog({ file: path.join(dir, 'events.jsonl') });
await log.record('reply', { state: 'SPEAKING', prompt: 'secret text', success: true });
const line = JSON.parse(await readFile(path.join(dir, 'events.jsonl'), 'utf8'));
assert.equal(line.state, 'SPEAKING');
assert.equal(line.success, true);
assert.equal('prompt' in line, false);
});
test('event socket rejects bulk messages and transports small JSON events', async (t) => {
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-ipc-'));
const socketPath = path.join(dir, 'jarvis.sock');
const received = [];
assert.throws(() => sendEvent({ write() {} }, 'Frame', { data: 'x'.repeat(70 * 1024) }), /too large/);
let ipc;
try { ipc = await createEventSocket({ socketPath, onMessage: (message) => received.push(message) }); }
catch (error) {
if (error.code === 'EPERM') { t.skip('sandbox forbids binding Unix sockets'); return; }
throw error;
}
await new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath, () => { sendEvent(socket, 'Token', { text: 'hello' }); socket.end(); resolve(); });
socket.on('error', reject);
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(received, [{ event: 'Token', text: 'hello' }]);
await ipc.close();
});
+6
View File
@@ -5,6 +5,7 @@ import { assertSdkVersion } from '../daemon/qvac-master.js';
import { parseHudSidecar } from '../skills/voice-prompt.js';
import { createPhase2Tools } from '../skills/phase2-tools.js';
import { createQvacTools } from '../skills/qvac-tools.js';
import { profile } from '../daemon/model-profiles.js';
test('runtime tools expose local QVAC and computer-use status', () => {
const computer = { status: () => ({ active: true, steps_used: 2, backend: 'portal-ei' }) };
@@ -36,3 +37,8 @@ test('phase 2 registers safe local tools with permission metadata', async () =>
test('QVAC utility tools are exposed only through the master adapter', () => {
assert.deepEqual(createQvacTools().map((tool) => tool.name), ['qvac_runtime_state', 'qvac_system_resources', 'qvac_assess_model_fit']);
});
test('model profiles select one master model without creating another runtime', () => {
assert.equal(profile('desktop-gpu').model, 'qwen3.5-9b');
assert.throws(() => profile('missing'), /unknown Jarvis model profile/);
});