diff options
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 121 | ||||
| -rw-r--r-- | modules/core/module-billing.js | 33 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-orchestrator.js | 88 | ||||
| -rw-r--r-- | modules/ext/media.js | 6 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 20 | ||||
| -rw-r--r-- | modules/test/test-orchestrator-parallel-harvest.js | 132 |
8 files changed, 331 insertions, 77 deletions
@@ -1349,13 +1349,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.146"></script> +<script src="modules/core/core-bundle.js?v=v4.6.150"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.146"></script> +<script src="modules/core/module-loader.js?v=v4.6.150"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.146"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.150"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js index 8b6e4cb..c37d13d 100644 --- a/modules/core/core-bundle.js +++ b/modules/core/core-bundle.js @@ -7773,8 +7773,10 @@ class OllamaModule { // Billing / pricing surface for newOS4 (2026-10-28) // // Why: the terminal could show a model's name but never what it costs. Prices and the anonymous -// message allowance are public data, so they need no login — the account BALANCE does, and newOS4 -// has no session layer, so that is reported honestly as "not signed in" rather than guessed. +// message allowance are public data, so they need no login — the account BALANCE does. +// 2026-11-03: newOS4 DOES have a session — the browser sends the HttpOnly cookie and +// parse_bearer_token() reads it — so the balance below is real when logged in. The old comment +// claimed otherwise and made the 401 look expected. // // Commands: cost [on|off], usage class BillingModule { @@ -7828,7 +7830,9 @@ class BillingModule { } async _loadBalance() { - // Only answers when a session exists; newOS4 currently has none, so this is expected to 401. + // Cookie auth is what makes this work: the browser sends webx11_session_token and + // parse_bearer_token() falls back to it. If this 401s, the user is logged out — say so, + // do not assume newOS4 cannot authenticate (that comment was wrong until 2026-11-03). try { const r = await fetch('/api/billing/subscription', { credentials: 'include' }); if (!r.ok) return null; @@ -7839,6 +7843,16 @@ class BillingModule { } } + async _loadUsage(days) { + try { + const r = await fetch('/api/billing/usage?days=' + (days || 1), { credentials: 'include' }); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + // --- session spend, measured where the tokens actually are ---------------- _hookUsage() { const om = this.terminal && this.terminal.ollamaManager; @@ -7881,8 +7895,10 @@ class BillingModule { } } + // On by default since 2026-11-03: "the terminal shows tokens but not cost" was the default, + // not a bug — the flag was opt-in and nobody knew the command existed. `cost off` opts out. _costLineOn() { - try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; } + try { return localStorage.getItem('webx11_cost_line') !== '0'; } catch (e) { return true; } } // --- the command ---------------------------------------------------------- @@ -7910,13 +7926,20 @@ class BillingModule { p('📊 Free allowance: unavailable (billing API did not answer)', 'warning'); } - p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info'); + p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — log in at /spaces/dashboard.html to see your balance'}`, 'info'); if (bal && bal.plan_allowance) { const pa = bal.plan_allowance; p(`🎁 Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} ${pa.period === 'day' ? 'today' : 'this month'} — $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info'); } + const u = await this._loadUsage(1); + if (u && u.totals) { + p(`📅 Today (all newOS4 sessions): ${u.totals.requests} call(s), ` + + `${((u.totals.prompt_tokens + u.totals.completion_tokens) / 1000).toFixed(1)}k tokens, ` + + `≈ $${Number(u.totals.retail_usd).toFixed(2)} retail`, 'info'); + } + if (s && s.msgs) { p(`🧮 This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out ≈ $${s.cost.toFixed(4)} retail`, 'info'); Object.entries(s.byModel) @@ -17464,6 +17487,18 @@ class OrchestratorModule { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); this._delegations = (this._delegations || 0) + 1; + // Parallel fan-out bookkeeping. A sub-task carries its own id, and the batch that + // spawned it hands in a sink, so the harvest below matches results by identity — + // never by a task string that a later delegation on the same agent can overwrite. + const taskId = opts.taskId || null; + const resultSink = opts.resultSink || null; + const recordResult = (status, payload) => { + if (!taskId || !resultSink || typeof resultSink.set !== 'function') return; + resultSink.set(taskId, Object.assign({ + taskId, task, status, at: new Date().toISOString() + }, payload || {})); + }; + // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { const parts = this._decomposeGoal(task); @@ -17484,38 +17519,45 @@ class OrchestratorModule { } } - const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal))); - const successes = results.filter(r => r.status === 'fulfilled').length; - const failures = results.filter(r => r.status === 'rejected').length; + const runId = 'pb' + Date.now().toString(36) + '-' + (this._parallelSeq = (this._parallelSeq || 0) + 1); + const resultSink = new Map(); + const results = await Promise.allSettled(parts.map((sub, i) => + this.delegateTask(sub.goal, { taskId: runId + ':' + i, resultSink }))); - // Result synthesis: collect outbox results from each sub-task's agent + // Result synthesis: one row per sub-task, looked up by the id handed to it. const summaries = []; for (let i = 0; i < parts.length; i++) { const sub = parts[i]; const r = results[i]; - if (r.status === 'fulfilled') { - // Find the agent that handled this sub-task - const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); - if (!agent) { - // "ok / completed (agent recycled)" told the caller a task succeeded - // when we could not even find its agent. - summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' }); - continue; - } - const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); - const body = outbox && typeof outbox.result === 'string' ? outbox.result : ''; - const snippet = body.substring(0, 200); + const subTaskId = runId + ':' + i; + const rec = resultSink.get(subTaskId); + if (rec) { summaries.push({ - task: sub.goal, agent: agent.id, - status: body ? 'ok' : 'failed', - result: body || null, - error: body ? undefined : (outbox && outbox.error) || 'no result in outbox' + task: sub.goal, + taskId: subTaskId, + agent: rec.agentId, + status: rec.status === 'ok' ? 'ok' : 'failed', + result: rec.status === 'ok' ? (rec.result || null) : null, + error: rec.status === 'ok' ? undefined : (rec.error || 'no result recorded') }); - } else { - summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); + continue; } + // The call died before it could report itself (a throw from a path that never + // reached a recordResult call): the promise state is the only evidence left. + summaries.push({ + task: sub.goal, + taskId: subTaskId, + status: 'failed', + error: (r.status === 'rejected' && ((r.reason && r.reason.message) || String(r.reason))) || 'no result recorded' + }); } + // The tally IS the summaries — never the promise states. allSettled counts + // resolutions, and a spawn refusal returns terminal.print() (undefined), which + // FULFILS: that is how "3/3 succeeded" printed over three failed rows. + const successes = summaries.filter(s => s.status === 'ok').length; + const failures = summaries.length - successes; + // Store merged result to VFS for persistence const merged = { type: 'parallel_result', @@ -17537,7 +17579,8 @@ class OrchestratorModule { const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : ''; this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info'); } - this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header'); + // The header must agree with the rows above it: ✅ only when nothing failed. + this.terminal.print((failures === 0 ? '✅' : '❌') + ' Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), failures === 0 ? 'header' : 'error'); return merged; } // Single sub-task — fall through to normal path @@ -17556,8 +17599,10 @@ class OrchestratorModule { // Auto-spawn with role-derived capabilities if (!agent) { if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { - // Try finding any non-failed agent to reuse - const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping'); + // Reuse an agent that is NOT running. A BUSY agent must never be handed a + // second task: concurrent sub-tasks then overwrite currentTask/leaseId and + // erase each other's results (reproduced 4/4 — Defect 2b). + const reuse = Object.values(this._agents).find(a => a.status !== 'busy' && a.status !== 'failed'); if (reuse) { reuse.status = 'busy'; agent = reuse; @@ -17567,11 +17612,18 @@ class OrchestratorModule { const role = this._deriveRole(task); const caps = this._deriveCapabilities(task); agent = this.spawnAgent(role, caps); - if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error'); + if (!agent) { + const msg = 'no idle agent — ' + Object.keys(this._agents).length + '/' + this._SPAWN_LIMIT + ' agents busy, refusing to share one'; + recordResult('failed', { error: msg }); + return this.terminal.print('❌ ' + msg, 'error'); + } agent.status = 'busy'; // claim immediately after spawn } } - if (!agent) return this.terminal.print('❌ No available agents', 'error'); + if (!agent) { + recordResult('failed', { error: 'no agent available' }); + return this.terminal.print('❌ No available agents', 'error'); + } // A2A Contract handshake const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; @@ -17641,6 +17693,9 @@ class OrchestratorModule { // Return result for callers that await delegateTask outboxResult = _answer; // '' when the worker returned nothing; callers see that + recordResult(_answer ? 'ok' : 'failed', _answer + ? { result: _answer, agentId: agent.id } + : { agentId: agent.id, error: 'agent produced no answer' }); // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { @@ -17652,8 +17707,10 @@ class OrchestratorModule { } else { this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info'); outboxResult = 'queued — AI module not loaded, nothing executed'; + recordResult('failed', { agentId: agent.id, error: 'AI module not loaded — nothing executed' }); } } catch (e) { + recordResult('failed', { agentId: agent.id, error: e.message || String(e) }); this._handleAgentFailure(agent.id, e.message); } diff --git a/modules/core/module-billing.js b/modules/core/module-billing.js index 87f8dc3..4ccf196 100644 --- a/modules/core/module-billing.js +++ b/modules/core/module-billing.js @@ -1,8 +1,10 @@ // Billing / pricing surface for newOS4 (2026-10-28) // // Why: the terminal could show a model's name but never what it costs. Prices and the anonymous -// message allowance are public data, so they need no login — the account BALANCE does, and newOS4 -// has no session layer, so that is reported honestly as "not signed in" rather than guessed. +// message allowance are public data, so they need no login — the account BALANCE does. +// 2026-11-03: newOS4 DOES have a session — the browser sends the HttpOnly cookie and +// parse_bearer_token() reads it — so the balance below is real when logged in. The old comment +// claimed otherwise and made the 401 look expected. // // Commands: cost [on|off], usage class BillingModule { @@ -56,7 +58,9 @@ class BillingModule { } async _loadBalance() { - // Only answers when a session exists; newOS4 currently has none, so this is expected to 401. + // Cookie auth is what makes this work: the browser sends webx11_session_token and + // parse_bearer_token() falls back to it. If this 401s, the user is logged out — say so, + // do not assume newOS4 cannot authenticate (that comment was wrong until 2026-11-03). try { const r = await fetch('/api/billing/subscription', { credentials: 'include' }); if (!r.ok) return null; @@ -67,6 +71,16 @@ class BillingModule { } } + async _loadUsage(days) { + try { + const r = await fetch('/api/billing/usage?days=' + (days || 1), { credentials: 'include' }); + if (!r.ok) return null; + return await r.json(); + } catch (e) { + return null; + } + } + // --- session spend, measured where the tokens actually are ---------------- _hookUsage() { const om = this.terminal && this.terminal.ollamaManager; @@ -109,8 +123,10 @@ class BillingModule { } } + // On by default since 2026-11-03: "the terminal shows tokens but not cost" was the default, + // not a bug — the flag was opt-in and nobody knew the command existed. `cost off` opts out. _costLineOn() { - try { return localStorage.getItem('webx11_cost_line') === '1'; } catch (e) { return false; } + try { return localStorage.getItem('webx11_cost_line') !== '0'; } catch (e) { return true; } } // --- the command ---------------------------------------------------------- @@ -138,13 +154,20 @@ class BillingModule { p('📊 Free allowance: unavailable (billing API did not answer)', 'warning'); } - p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — newOS4 sends no session token, so a balance cannot be shown here yet'}`, 'info'); + p(`💰 Balance: ${bal && bal.balance_usd != null ? '$' + Number(bal.balance_usd).toFixed(2) : 'not signed in — log in at /spaces/dashboard.html to see your balance'}`, 'info'); if (bal && bal.plan_allowance) { const pa = bal.plan_allowance; p(`🎁 Included usage (${pa.plan}): $${Number(pa.used_usd).toFixed(2)} of $${Number(pa.limit_usd).toFixed(2)} ${pa.period === 'day' ? 'today' : 'this month'} — $${Number(pa.remaining_usd).toFixed(2)} left, then your balance pays`, 'info'); } + const u = await this._loadUsage(1); + if (u && u.totals) { + p(`📅 Today (all newOS4 sessions): ${u.totals.requests} call(s), ` + + `${((u.totals.prompt_tokens + u.totals.completion_tokens) / 1000).toFixed(1)}k tokens, ` + + `≈ $${Number(u.totals.retail_usd).toFixed(2)} retail`, 'info'); + } + if (s && s.msgs) { p(`🧮 This session: ${s.msgs} call(s), ${(s.prompt / 1000).toFixed(1)}k in / ${(s.completion / 1000).toFixed(1)}k out ≈ $${s.cost.toFixed(4)} retail`, 'info'); Object.entries(s.byModel) diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index c9162d0..1005463 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.146'; + const LATEST_VER = 'v4.6.150'; const CACHE = '?v=' + LATEST_VER; // Exposed so ext modules can register their own URL with the same cache-busting version // instead of an unversioned path the browser may already have cached (shell.js did). diff --git a/modules/core/module-orchestrator.js b/modules/core/module-orchestrator.js index 6b8ee18..05d7392 100644 --- a/modules/core/module-orchestrator.js +++ b/modules/core/module-orchestrator.js @@ -1084,6 +1084,18 @@ class OrchestratorModule { if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error'); this._delegations = (this._delegations || 0) + 1; + // Parallel fan-out bookkeeping. A sub-task carries its own id, and the batch that + // spawned it hands in a sink, so the harvest below matches results by identity — + // never by a task string that a later delegation on the same agent can overwrite. + const taskId = opts.taskId || null; + const resultSink = opts.resultSink || null; + const recordResult = (status, payload) => { + if (!taskId || !resultSink || typeof resultSink.set !== 'function') return; + resultSink.set(taskId, Object.assign({ + taskId, task, status, at: new Date().toISOString() + }, payload || {})); + }; + // Parallel fan-out: decompose and run sub-tasks concurrently if (opts.parallel) { const parts = this._decomposeGoal(task); @@ -1104,38 +1116,45 @@ class OrchestratorModule { } } - const results = await Promise.allSettled(parts.map(sub => this.delegateTask(sub.goal))); - const successes = results.filter(r => r.status === 'fulfilled').length; - const failures = results.filter(r => r.status === 'rejected').length; + const runId = 'pb' + Date.now().toString(36) + '-' + (this._parallelSeq = (this._parallelSeq || 0) + 1); + const resultSink = new Map(); + const results = await Promise.allSettled(parts.map((sub, i) => + this.delegateTask(sub.goal, { taskId: runId + ':' + i, resultSink }))); - // Result synthesis: collect outbox results from each sub-task's agent + // Result synthesis: one row per sub-task, looked up by the id handed to it. const summaries = []; for (let i = 0; i < parts.length; i++) { const sub = parts[i]; const r = results[i]; - if (r.status === 'fulfilled') { - // Find the agent that handled this sub-task - const agent = Object.values(this._agents).find(a => a.currentTask === sub.goal || (a.lastTask === sub.goal)); - if (!agent) { - // "ok / completed (agent recycled)" told the caller a task succeeded - // when we could not even find its agent. - summaries.push({ task: sub.goal, status: 'failed', error: 'agent not found — result unverifiable' }); - continue; - } - const outbox = this.terminal.fs.fsReadJSON(`/agents/${agent.id}/outbox.json`); - const body = outbox && typeof outbox.result === 'string' ? outbox.result : ''; - const snippet = body.substring(0, 200); + const subTaskId = runId + ':' + i; + const rec = resultSink.get(subTaskId); + if (rec) { summaries.push({ - task: sub.goal, agent: agent.id, - status: body ? 'ok' : 'failed', - result: body || null, - error: body ? undefined : (outbox && outbox.error) || 'no result in outbox' + task: sub.goal, + taskId: subTaskId, + agent: rec.agentId, + status: rec.status === 'ok' ? 'ok' : 'failed', + result: rec.status === 'ok' ? (rec.result || null) : null, + error: rec.status === 'ok' ? undefined : (rec.error || 'no result recorded') }); - } else { - summaries.push({ task: sub.goal, status: 'failed', error: r.reason?.message || 'unknown error' }); + continue; } + // The call died before it could report itself (a throw from a path that never + // reached a recordResult call): the promise state is the only evidence left. + summaries.push({ + task: sub.goal, + taskId: subTaskId, + status: 'failed', + error: (r.status === 'rejected' && ((r.reason && r.reason.message) || String(r.reason))) || 'no result recorded' + }); } + // The tally IS the summaries — never the promise states. allSettled counts + // resolutions, and a spawn refusal returns terminal.print() (undefined), which + // FULFILS: that is how "3/3 succeeded" printed over three failed rows. + const successes = summaries.filter(s => s.status === 'ok').length; + const failures = summaries.length - successes; + // Store merged result to VFS for persistence const merged = { type: 'parallel_result', @@ -1157,7 +1176,8 @@ class OrchestratorModule { const snippet = s.result ? s.result.replace(/\n/g, ' ').substring(0, 60) : ''; this.terminal.print(' ' + icon + ' ' + s.task.substring(0, 35) + agentTag + ' → ' + snippet, 'info'); } - this.terminal.print('✅ Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), 'header'); + // The header must agree with the rows above it: ✅ only when nothing failed. + this.terminal.print((failures === 0 ? '✅' : '❌') + ' Parallel batch: ' + successes + '/' + parts.length + ' succeeded' + (failures > 0 ? ', ' + failures + ' failed' : ''), failures === 0 ? 'header' : 'error'); return merged; } // Single sub-task — fall through to normal path @@ -1176,8 +1196,10 @@ class OrchestratorModule { // Auto-spawn with role-derived capabilities if (!agent) { if (Object.keys(this._agents).length >= this._SPAWN_LIMIT) { - // Try finding any non-failed agent to reuse - const reuse = Object.values(this._agents).find(a => a.status !== 'failed' && a.status !== 'sleeping'); + // Reuse an agent that is NOT running. A BUSY agent must never be handed a + // second task: concurrent sub-tasks then overwrite currentTask/leaseId and + // erase each other's results (reproduced 4/4 — Defect 2b). + const reuse = Object.values(this._agents).find(a => a.status !== 'busy' && a.status !== 'failed'); if (reuse) { reuse.status = 'busy'; agent = reuse; @@ -1187,11 +1209,18 @@ class OrchestratorModule { const role = this._deriveRole(task); const caps = this._deriveCapabilities(task); agent = this.spawnAgent(role, caps); - if (!agent) return this.terminal.print('❌ No available agents — spawn limit reached', 'error'); + if (!agent) { + const msg = 'no idle agent — ' + Object.keys(this._agents).length + '/' + this._SPAWN_LIMIT + ' agents busy, refusing to share one'; + recordResult('failed', { error: msg }); + return this.terminal.print('❌ ' + msg, 'error'); + } agent.status = 'busy'; // claim immediately after spawn } } - if (!agent) return this.terminal.print('❌ No available agents', 'error'); + if (!agent) { + recordResult('failed', { error: 'no agent available' }); + return this.terminal.print('❌ No available agents', 'error'); + } // A2A Contract handshake const registry = this.terminal.moduleManager.modules['AgentRegistryModule']; @@ -1261,6 +1290,9 @@ class OrchestratorModule { // Return result for callers that await delegateTask outboxResult = _answer; // '' when the worker returned nothing; callers see that + recordResult(_answer ? 'ok' : 'failed', _answer + ? { result: _answer, agentId: agent.id } + : { agentId: agent.id, error: 'agent produced no answer' }); // Check restart policy — if always, set back to idle for next task if (agent.restartPolicy === 'always') { @@ -1272,8 +1304,10 @@ class OrchestratorModule { } else { this.terminal.print(`ℹ️ AI module not available — task queued for ${agent.id}`, 'info'); outboxResult = 'queued — AI module not loaded, nothing executed'; + recordResult('failed', { agentId: agent.id, error: 'AI module not loaded — nothing executed' }); } } catch (e) { + recordResult('failed', { agentId: agent.id, error: e.message || String(e) }); this._handleAgentFailure(agent.id, e.message); } diff --git a/modules/ext/media.js b/modules/ext/media.js index 5723e27..bdc50e2 100644 --- a/modules/ext/media.js +++ b/modules/ext/media.js @@ -1,13 +1,13 @@ // newOS4 Media Analysis Module (v4.5.79) // Commands: analyze <path> [--flags] // Uses gemini-3-flash-preview:cloud for vision (images, video frames) -// Uses deepseek-v4-flash:cloud for text analysis (audio features, summaries) +// Uses deepseek-v4.1-flash:cloud for text analysis (audio features, summaries) class MediaModule { constructor() { this.commands = {}; this.terminal = null; this._visionModel = 'gemini-3-flash-preview:cloud'; - this._textModel = 'deepseek-v4-flash:cloud'; + this._textModel = 'deepseek-v4.1-flash:cloud'; this._maxFrames = 10; // max video frames to analyze this._frameInterval = 5; // seconds between video frames } @@ -118,7 +118,7 @@ class MediaModule { } // Send features to text model for AI interpretation - this.terminal.print('\n🤖 AI interpretation (deepseek-v4-flash)...', 'info'); + this.terminal.print('\n🤖 AI interpretation (deepseek-v4.1-flash)...', 'info'); const prompt = `Analyze these audio features and describe what the audio likely sounds like. Consider the genre, mood, and characteristics: Duration: ${features.duration.toFixed(1)}s diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index a97a8bc..fa42b96 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -976,12 +976,20 @@ class WebDAVModule { }; const mount = document.getElementById('wdav-tg-mount'); // A Telegram widget cannot log anyone in inside an Android WebView or an in-app - // browser: it needs a popup that can postMessage back to the opener, and those hosts - // hand the link to the system browser, which has no opener. Measured 2026-11-02 (the - // operator's phone UA ends "...; wv)"): every attempt landed in Chrome and died there. - // In that case skip the widget entirely and start the code flow, which needs no popup. - const _tgWV = /;\s*wv\)/.test(navigator.userAgent || '') || - /\bTelegram\b/i.test(navigator.userAgent || ''); + // browser: tapping it hands the whole tab to oauth.telegram.org and must come back to + // this page with #tgAuthResult. Those hosts either hand the navigation to the system + // browser (no way back) or never return. Measured 2026-11-03, operator's phone. + // Fallback only: modules/wx11-tg-webview.js is the source of truth when the host page + // loaded it (the dashboard does). This inline copy missed "Version/4.0" WebViews — the + // operator's own phone — until 2026-11-03, and the Meta/Line classes until the review + // that followed. Probe: /tg-widget-test.html. + const _ua = navigator.userAgent || ''; + const _tgWV = (window.WX11TG && window.WX11TG.isWebView) + ? window.WX11TG.isWebView() + : (/;\s*wv\)/.test(_ua) || + (/\bVersion\/4\.0\b/.test(_ua) && /Android/i.test(_ua) && !/\bSamsungBrowser\b/.test(_ua)) || + /\bTelegram\b/i.test(_ua) || + /\bFBAN\b|\bFBAV\b|\bInstagram\b|\bLine\//i.test(_ua)); const tw = document.createElement('script'); tw.src = 'https://telegram.org/js/telegram-widget.js?22'; tw.setAttribute('data-telegram-login', 'roducbot'); diff --git a/modules/test/test-orchestrator-parallel-harvest.js b/modules/test/test-orchestrator-parallel-harvest.js new file mode 100644 index 0000000..4381699 --- /dev/null +++ b/modules/test/test-orchestrator-parallel-harvest.js @@ -0,0 +1,132 @@ +// node modules/test/test-orchestrator-parallel-harvest.js +// Defect 2b (2026-11-02/03): orchestrate-parallel reported success it could not prove. +// +// Three separate lies, all in one block: +// 1. The harvest re-found each sub-task's agent by task STRING (`currentTask === sub.goal || +// lastTask === sub.goal`). An agent that had already moved on to another sub-task no longer +// carried the old goal, so a task that had demonstrably finished was dropped as +// "agent not found — result unverifiable". +// 2. The "N/N succeeded" header counted Promise.allSettled resolutions, not outcomes — a spawn +// refusal returns terminal.print() (undefined), which FULFILS, so a batch where nothing ran +// printed green. +// 3. At pool cap, delegateTask stole a BUSY agent, so two sub-tasks overwrote +// currentTask/leaseId and erased each other's results. +// +// This file pins the contract: results are harvested by sub-task id, the tally comes from the +// summaries, and a sub-task that could not get an agent is a failure, never a green row. +const fs = require('fs'), path = require('path'); +const ROOT = path.join(__dirname, '..', 'core'); +// Point ORCH_SRC at modules/core/core-bundle.js to run these assertions against the DEPLOYED +// artifact instead of the source file — a green source test says nothing about the bundle. +const SRC_FILE = process.env.ORCH_SRC ? path.resolve(process.env.ORCH_SRC) : path.join(ROOT, 'module-orchestrator.js'); + +const store = {}; +global.localStorage = { getItem: k => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); }, removeItem: k => { delete store[k]; } }; +global.window = global; +global.addEventListener = () => {}; + +const Orch = new Function(fs.readFileSync(SRC_FILE, 'utf8') + '\nreturn OrchestratorModule;')(); + +let fails = 0, passes = 0; +const ok = (n, c, d) => { if (c) { passes++; return; } fails++; console.log('FAIL ' + n + (d ? ' — ' + d : '')); }; + +// A VFS stub that behaves like the real one where it matters: paths normalise, and +// fsReadJSON returns null (never throws) for a missing/corrupt file. +function mkEnv(opts) { + const prints = []; + const files = new Map(); + const norm = p => String(p).replace(/^\/+/, ''); + const term = { + prints, + print(msg, type) { prints.push({ msg: String(msg), type: type || '' }); }, + fs: { + readFile: p => (files.has(norm(p)) ? files.get(norm(p)) : null), + writeFile: (p, c) => { files.set(norm(p), c); return true; }, + readDir: () => true, + mkdir: () => true, + rm: () => true, + fsReadJSON: p => { try { const raw = files.get(norm(p)); return raw === undefined ? null : JSON.parse(raw); } catch (e) { return null; } }, + }, + moduleManager: { modules: {}, _setInterval: () => 0, _setTimeout: () => 0 }, + ollamaManager: null, + }; + const ai = { _sessions: new Map(), chat: opts.chat, terminal: term }; + term.moduleManager.modules['WebX11AIAssistantModule'] = ai; + + const orch = Object.create(Orch.prototype); + orch.terminal = term; + orch.commands = {}; + orch._agents = {}; + orch._a2aMessages = {}; + orch._eventBus = {}; + orch._restartBackoffs = {}; + orch._delegations = 0; + orch._SPAWN_LIMIT = opts.spawnLimit || 5; + orch._RESTART_POLICY = 'none'; + return { orch, term, files, prints }; +} + +const GOAL = 'read alpha log and write beta report and check gamma status'; +const tick = (ms) => new Promise(r => setTimeout(r, ms || 5)); + +(async () => { + // ── T1: three independent sub-tasks are harvested by identity, not by task string ── + { + const { orch, files, prints } = mkEnv({ + chat: async (msgs) => { await tick(); return { content: 'ANSWER[' + msgs[0] + ']', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const decomposed = prints.some(p => /Decomposed into 3 sub-tasks/.test(p.msg)); + ok('T1a the goal decomposes into 3 sub-tasks (harness precondition)', decomposed, prints.map(p => p.msg).join(' | ')); + ok('T1b all three sub-tasks are harvested', merged.summaries.length === 3, 'summaries=' + merged.summaries.length); + ok('T1c every sub-task carries its own answer', merged.summaries.every(s => s.status === 'ok' && /^ANSWER\[\[DELEGATED\]/.test(s.result || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.result]))); + ok('T1d no sub-task is discarded as unverifiable', !merged.summaries.some(s => /agent not found/.test(s.error || '')), JSON.stringify(merged.summaries.map(s => s.error))); + ok('T1e successes come from the summaries', merged.successes === 3 && merged.failures === 0, 'successes=' + merged.successes + ' failures=' + merged.failures); + const onDisk = JSON.parse(files.get('orchestrator/parallel-results.json')); + ok('T1f the persisted payload agrees with the returned tally', onDisk.successes === merged.successes && onDisk.summaries.length === 3, JSON.stringify({ onDisk: onDisk.successes, merged: merged.successes })); + ok('T1g each summary is keyed by its own sub-task id', new Set(merged.summaries.map(s => s.taskId)).size === 3, JSON.stringify(merged.summaries.map(s => s.taskId))); + } + + // ── T2: at pool cap a sub-task is REFUSED, never run on a busy agent ── + // Old behaviour: all three ran on the one agent, clobbering each other, and the header + // still read "3/3 succeeded" while two summaries said failed. + { + const { orch, prints } = mkEnv({ + spawnLimit: 1, + chat: async () => { await tick(10); return { content: 'single-agent answer', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const oks = merged.summaries.filter(s => s.status === 'ok').length; + ok('T2a only the one agent that was free produced a result', oks === 1 && merged.successes === 1, 'ok=' + oks + ' successes=' + merged.successes); + ok('T2b the refused sub-tasks are reported as failures with a reason', merged.failures === 2 && merged.summaries.filter(s => s.status === 'failed').every(s => /idle|busy|limit/i.test(s.error || '')), JSON.stringify(merged.summaries.map(s => [s.status, s.error]))); + ok('T2c the tally never exceeds the green rows', !merged.summaries.some(s => s.status !== 'ok') || merged.successes < merged.total, 'successes=' + merged.successes + '/' + merged.total); + const header = prints.map(p => p.msg).find(m => /Parallel batch:/.test(m)) || ''; + ok('T2d the header reports the real outcome', /1\/3 succeeded, 2 failed/.test(header), header); + ok('T2e a batch with failures is not printed as a green header', !/^✅/.test(header), header); + } + + // ── T3: a worker that answers nothing is a failure, not a success ── + { + const { orch } = mkEnv({ + chat: async (msgs) => { await tick(); return { content: /beta/.test(msgs[0]) ? '' : 'ok answer', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const silent = merged.summaries.filter(s => /beta/.test(s.task)); + ok('T3a the silent worker is a failed row', silent.length === 1 && silent[0].status === 'failed', JSON.stringify(silent)); + ok('T3b successes count the real answers only', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures); + } + + // ── T4: a thrown sub-task is not swallowed, and does not inflate the tally ── + { + const { orch } = mkEnv({ + chat: async (msgs) => { await tick(); if (/gamma/.test(msgs[0])) throw new Error('worker exploded'); return { content: 'fine', toolTurns: 0 }; }, + }); + const merged = await orch.delegateTask(GOAL, { parallel: true }); + const bad = merged.summaries.filter(s => /gamma/.test(s.task)); + ok('T4a the throwing sub-task is a failed row with its error', bad.length === 1 && bad[0].status === 'failed' && /worker exploded/.test(bad[0].error || ''), JSON.stringify(bad)); + ok('T4b the tally reflects 2 real answers out of 3', merged.successes === 2 && merged.failures === 1, 'successes=' + merged.successes + ' failures=' + merged.failures); + } + + console.log('test-orchestrator-parallel-harvest: ' + passes + ' passed, ' + fails + ' failed'); + process.exit(fails ? 1 : 0); +})(); |