aboutsummaryrefslogtreecommitdiff
path: root/modules/core/core-bundle.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 19:01:46 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 19:01:46 +0200
commitb0f90494fa00e787f8e9d34368febf2e7e26e8a6 (patch)
tree29a770634d03275de0b66361ed6e6546e612380d /modules/core/core-bundle.js
parentf55e3055a4b85cf3920f5df1e3add3696153169c (diff)
feat(newos4): on-demand worker labels and a server-canonical predictor store (v4.6.70)
Two decisions recorded by the operator on 2026-10-23, implemented. 6 — spawn-on-demand is the design, so stop reporting it as a defect: - OrchestratorModule counts delegations inside delegateTask() - status prints "Mode: on-demand — N/M worker(s) available, X delegation(s) this session" and drops the "spawning is not dispatching" warning - per-agent lines read "available, no delegations yet (spawned ...)" / "N delegation(s), last ..." 10 — per-user, server file canonical, browser VFS a TTL'd read-through cache: - store = the user's own WebDAV space (<space>/_data/predictor.json), so identity comes from credentials the app already has; no new endpoint, no unauthenticated write surface - 5 min TTL: cache wins while fresh, server wins when it differs; a banner naming both counts prints only on divergence - write-through on save (debounced 5 s) with a MKCOL retry when the collection does not exist yet; store mode is printed by predictor-status - the cache write is guarded and verified — a missing /system/metrics made writeFile fail silently, which is how the first version lost a pull Verified live on v4.6.70 with a stubbed transport: pull applies 5 pts/5 paired and writes a stamped cache, the divergence banner fires once and not on the second (equal) pull, write-through PUT is issued, status reads "Store: server". A real WebDAV round-trip still needs the user's WebDAV config in-app; the agando space has no _data collection yet, so GET returns 404 -> "server (empty)".
Diffstat (limited to 'modules/core/core-bundle.js')
-rw-r--r--modules/core/core-bundle.js125
1 files changed, 119 insertions, 6 deletions
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index e4cd832..303c151 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -15533,6 +15533,9 @@ class OrchestratorModule {
this._a2aPollTimer = null;
this._heartbeatInterval = null;
this._a2aMessages = {};
+ // v4.6.68: on-demand is the design (decision 2026-10-23). Delegations — not
+ // "agents ever scheduled" — is the number that means something.
+ this._delegations = 0;
this._restartBackoffs = {};
this._eventBus = {};
this._idleSweepInterval = null;
@@ -16585,6 +16588,7 @@ class OrchestratorModule {
async delegateTask(task, opts = {}) {
if (!task) return this.terminal.print('Usage: orchestrate <task description>', 'error');
+ this._delegations = (this._delegations || 0) + 1;
// Parallel fan-out: decompose and run sub-tasks concurrently
if (opts.parallel) {
@@ -16931,7 +16935,7 @@ class OrchestratorModule {
// v4.6.63: say what the state MEANS. A parked agent with 0 assignments and a parked
// agent between tasks looked identical from outside (packet §6).
const agentsArr = Object.values(this._agents);
- const everScheduled = agentsArr.filter(a => (a.tasksAssigned || 0) > 0).length;
+ const available = agentsArr.filter(a => a.status === 'idle' || a.status === 'sleeping').length;
const describe = (a) => {
const n = a.tasksAssigned || 0;
const since = a.createdAt ? String(a.createdAt).slice(0, 16).replace('T', ' ') : '?';
@@ -16944,19 +16948,21 @@ class OrchestratorModule {
}
if (n === 0) {
return (a.status === 'sleeping' ? 'parked' : a.status) +
- ` — NEVER SCHEDULED (0 tasks since ${since})`;
+ ` — available, no delegations yet (spawned ${since})`;
}
return (a.status === 'sleeping' ? 'parked' : a.status) +
- ` — waiting for work (${n} task(s), last ${last || '?'})`;
+ ` — ${n} delegation(s), last ${last || '?'}`;
};
agentsArr.forEach(a => {
const modelInfo = a.model ? ` [model: ${a.model}]` : '';
const backoff = a.nextRestartAt && a.nextRestartAt > Date.now() ? ` [restart in ${Math.round((a.nextRestartAt - Date.now()) / 1000)}s]` : '';
this.terminal.print(` ${a.id} [${a.role}] — ${describe(a)}${modelInfo}${backoff}`, 'info');
});
- this.terminal.print(` Dispatch: ${everScheduled}/${total} agent(s) have ever been given a task`, everScheduled === 0 && total > 0 ? 'warning' : 'info');
- if (everScheduled === 0 && total > 0) {
- this.terminal.print(' ⚠️ No agent has ever been scheduled — spawning is not dispatching. Use orchestrate <task> or orchestrator-spawn <role> <task>.', 'warning');
+ // v4.6.68: on-demand by design — a worker with nothing to do is healthy, not broken.
+ // The number to watch is how many delegations the AI actually made.
+ this.terminal.print(` Mode: on-demand — ${available}/${total} worker(s) available, ${this._delegations || 0} delegation(s) this session`, 'info');
+ if (total > 0 && (this._delegations || 0) === 0) {
+ this.terminal.print(' Workers wait for delegate_task — spawn-on-demand, no background dispatch queue by design.', 'text-muted');
}
}
@@ -18070,6 +18076,9 @@ class PredictorModule {
init(terminal) {
this.terminal = terminal;
this._loadFromVFS();
+ this._storeMode = 'cache';
+ // read-through after boot: a stale cache revalidates against the canonical file
+ setTimeout(() => { this._pullFromServer().catch(function () {}); }, 1500);
this._loaded = true;
this.terminal.print('📊 Predictor loaded — analyzing task patterns', 'info');
}
@@ -18318,6 +18327,7 @@ class PredictorModule {
this.terminal.print(` Awaiting outcome: ${this._pending.length}`, 'info');
this.terminal.print(` Unpaired runs: ${unpaired}`, 'info');
this.terminal.print(` Unscored pairs: ${unscored} (aborted run or floored prediction)`, 'info');
+ this.terminal.print(` Store: ${this._storeMode || 'cache'} — server file canonical, browser copy is a ${Math.round(this._STORE_TTL_MS / 60000)} min read-through cache`, 'info');
this.terminal.print(` Confidence threshold: ${this._CONFIDENCE_THRESHOLD}`, 'info');
this.terminal.print(` Min data points: ${this._MIN_DATA_POINTS}`, 'info');
this.terminal.print(` Pair TTL: ${Math.round(this._PAIR_TTL_MS / 60000)}m`, 'info');
@@ -18355,6 +18365,46 @@ class PredictorModule {
}
// ─── Persistence ───
+ // v4.6.68 (decision 2026-10-23): the SERVER file is canonical, the browser copy is a
+ // TTL'd read-through cache written through on save. Identity is the user's own WebDAV
+ // space — no new endpoint, no new credential, nothing unauthenticated added.
+ _STORE_TTL_MS = 300000;
+
+ _wdConfig() {
+ try {
+ const raw = localStorage.getItem('webx11_webdav_config');
+ if (!raw) return null;
+ const c = JSON.parse(raw);
+ if (!c || !c.url || String(c.url).indexOf('/spaces/') === -1) return null;
+ return {
+ base: String(c.url).replace(/\/+$/, ''),
+ user: c.user || c.username || '',
+ pass: c.pass || c.password || ''
+ };
+ } catch (e) { return null; }
+ }
+ _storeUrl(cfg) { return cfg.base + '/_data/predictor.json'; }
+ // The VFS write fails silently when the parent directory is missing (writeFile does not
+ // create parents), so the cache is written through one guarded path that proves itself.
+ _writeCache(obj) {
+ const fs = this.terminal.fs;
+ if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics');
+ fs.fsWriteJSON('/system/metrics/predictor.json', obj);
+ if (!fs.readFile('/system/metrics/predictor.json')) {
+ this._storeMode = 'cache-only (cache write failed)';
+ if (!this._writeWarned) {
+ this._writeWarned = true;
+ this.terminal.print('📊 Predictor: could not write its cache file — /system/metrics is not writable in this VFS', 'error');
+ }
+ return false;
+ }
+ return true;
+ }
+ _storeHeaders(cfg) {
+ const h = { 'Content-Type': 'application/json' };
+ if (cfg.user) h.Authorization = 'Basic ' + btoa(cfg.user + ':' + cfg.pass);
+ return h;
+ }
_loadFromVFS() {
try {
@@ -18396,6 +18446,8 @@ class PredictorModule {
if (!fs.readDir('/system/metrics')) fs.mkdirRecursive('/system/metrics');
fs.fsWriteJSON('/system/metrics/predictor.json', {
v: this._SCHEMA,
+ cachedAt: Date.now(),
+ storeMode: 'cache',
dataPoints: this._dataPoints.slice(-500),
predictions: this._predictions,
accuracy: this._accuracy,
@@ -18408,11 +18460,72 @@ class PredictorModule {
},
pending: this._pending.slice(-50)
});
+ this._debouncePush();
} catch (e) {
console.error('[Predictor] Save error:', e.message);
}
}
+ _debouncePush() {
+ clearTimeout(this._pushTimer);
+ this._pushTimer = setTimeout(() => this._pushToServer(), 5000);
+ }
+
+ async _pushToServer() {
+ const cfg = this._wdConfig();
+ if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; }
+ try {
+ const body = this.terminal.fs.readFile('/system/metrics/predictor.json') || '{}';
+ const put = () => fetch(this._storeUrl(cfg), { method: 'PUT', headers: this._storeHeaders(cfg), body: body });
+ let resp = await put();
+ // first push into a fresh space: the collection has to exist before the file can
+ if (resp.status === 409 || resp.status === 404) {
+ try { await fetch(cfg.base + '/_data', { method: 'MKCOL', headers: this._storeHeaders(cfg) }); } catch (e) {}
+ resp = await put();
+ }
+ if (resp.ok) { this._storeMode = 'server'; this._serverStamp = Date.now(); return true; }
+ this._storeMode = 'cache-only (PUT ' + resp.status + ')';
+ return false;
+ } catch (e) {
+ this._storeMode = 'cache-only (offline)';
+ return false;
+ }
+ }
+
+ // Read-through: the cache wins while fresh; the server wins when the two differ.
+ async _pullFromServer(force) {
+ const cfg = this._wdConfig();
+ if (!cfg) { this._storeMode = 'cache-only (no WebDAV config)'; return false; }
+ if (!force && this._cacheStamp && (Date.now() - this._cacheStamp) < this._STORE_TTL_MS) return false;
+ try {
+ const resp = await fetch(this._storeUrl(cfg), { headers: this._storeHeaders(cfg) });
+ if (resp.status === 404) { this._storeMode = 'server (empty)'; return false; }
+ if (!resp.ok) { this._storeMode = 'cache-only (GET ' + resp.status + ')'; return false; }
+ const remote = await resp.json();
+ if (!remote || typeof remote !== 'object') return false;
+ const localPts = (this._dataPoints || []).length;
+ const localPaired = (this._accuracy || {}).total || 0;
+ const remotePts = (remote.dataPoints || []).length;
+ const remotePaired = (remote.accuracy || {}).total || 0;
+ const diverge = remotePts !== localPts || remotePaired !== localPaired;
+ remote.cachedAt = Date.now();
+ remote.storeMode = 'server';
+ if (!this._writeCache(remote)) return false;
+ this._cacheStamp = Date.now();
+ this._serverStamp = Date.now();
+ this._loadFromVFS();
+ this._storeMode = 'server';
+ if (diverge && !this._divergenceNoted) {
+ this._divergenceNoted = true;
+ this.terminal.print(`📊 Predictor store: server copy differs from the local cache (server ${remotePts} pts / ${remotePaired} paired, cache ${localPts} / ${localPaired}) — server wins`, 'warning');
+ }
+ return true;
+ } catch (e) {
+ this._storeMode = 'cache-only (offline)';
+ return false;
+ }
+ }
+
_debounceSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this._saveNow(), 3000);