@ouro.bot/cli 0.1.0-alpha.825 → 0.1.0-alpha.827
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.json +12 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +55 -6
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/awaiting/await-runtime-state.js +11 -0
- package/dist/heart/daemon/daemon-entry.js +10 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.827",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Awaits advance their own cadence on dispatch, so a filed watch no longer fires forever without recording a check."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.826",
|
|
12
|
+
"changes": [
|
|
13
|
+
"Sanctuary: a download sitting at zero bytes is reported as stalled rather than as progress, with a blocklist-and-research repair."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.825",
|
|
6
18
|
"changes": [
|
|
@@ -73,6 +73,10 @@ class ServiceError extends Error {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
const seerr = (path, opts = {}) => req(SEERR.url, `/api/v1${path}`, { key: SEERR.apiKey, ...opts })
|
|
76
|
+
// Long enough that an ordinary slow start is not called dead, short enough that
|
|
77
|
+
// nobody waits a day for a release that was never going to arrive.
|
|
78
|
+
const STALL_AFTER_HOURS = 6
|
|
79
|
+
|
|
76
80
|
const sonarr = (path, opts = {}) => req(SONARR.url, `/api/v3${path}`, { key: SONARR.apiKey, ...opts })
|
|
77
81
|
const radarr = (path, opts = {}) => req(RADARR.url, `/api/v3${path}`, { key: RADARR.apiKey, ...opts })
|
|
78
82
|
const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key: PROWLARR.apiKey, ...opts })
|
|
@@ -427,14 +431,35 @@ async function mediaRequestStatus(a) {
|
|
|
427
431
|
const byDownload = new Map()
|
|
428
432
|
for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
|
|
429
433
|
const downloads = [...byDownload.values()]
|
|
434
|
+
const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
|
|
435
|
+
const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
|
|
436
|
+
// A torrent that has not moved a byte since it was grabbed is not slow, it is
|
|
437
|
+
// dead: no seeders, or a release the client cannot fetch. Radarr goes on
|
|
438
|
+
// reporting trackedDownloadStatus "ok" for these indefinitely, so a queue row
|
|
439
|
+
// on its own reads as healthy and any answer built from it reassures instead
|
|
440
|
+
// of acting. Measuring progress against age is what separates the two.
|
|
441
|
+
const now = Date.now()
|
|
442
|
+
const stalledItems = downloads
|
|
443
|
+
.filter((r) => (r.size ?? 0) > 0 && (r.sizeleft ?? 0) >= (r.size ?? 0) && r.added
|
|
444
|
+
&& (now - Date.parse(r.added)) / 3_600_000 >= STALL_AFTER_HOURS)
|
|
445
|
+
.map((r) => ({
|
|
446
|
+
title: r.title ?? null,
|
|
447
|
+
added_at: r.added ?? null,
|
|
448
|
+
age_hours: Math.round((now - Date.parse(r.added)) / 3_600_000),
|
|
449
|
+
size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
|
|
450
|
+
queue_id: r.id ?? null,
|
|
451
|
+
}))
|
|
430
452
|
result.download = {
|
|
431
453
|
active_downloads: downloads.length,
|
|
432
454
|
active_items: queueRecs.length,
|
|
433
455
|
states: [...new Set(downloads.map((r) => r.status))],
|
|
434
456
|
tracked_states: [...new Set(downloads.map((r) => r.trackedDownloadState).filter(Boolean))],
|
|
435
457
|
errors: [...new Set(downloads.map((r) => r.errorMessage).filter(Boolean))],
|
|
436
|
-
size_left_bytes:
|
|
437
|
-
size_total_bytes:
|
|
458
|
+
size_left_bytes: sizeLeft,
|
|
459
|
+
size_total_bytes: sizeTotal,
|
|
460
|
+
percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
|
|
461
|
+
stalled: stalledItems.length > 0,
|
|
462
|
+
stalled_items: stalledItems,
|
|
438
463
|
}
|
|
439
464
|
|
|
440
465
|
const chain = await chainHealth()
|
|
@@ -453,6 +478,15 @@ function diagnose({ shelf, result, chain, entity, kind }) {
|
|
|
453
478
|
return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
|
|
454
479
|
summary: "On the shelf and complete. Nothing is stuck." }
|
|
455
480
|
}
|
|
481
|
+
if (result.download.stalled) {
|
|
482
|
+
const items = result.download.stalled_items
|
|
483
|
+
const oldest = Math.max(...items.map((i) => i.age_hours))
|
|
484
|
+
return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
|
|
485
|
+
likely_fix: "blocklist_and_research", human_action_required: false,
|
|
486
|
+
percent_complete: result.download.percent_complete,
|
|
487
|
+
detail: items,
|
|
488
|
+
summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded a single byte in ${oldest} hours, which means no seeders rather than a quiet queue. Waiting will not fix it; blocklist the release and search again for a different one.` }
|
|
489
|
+
}
|
|
456
490
|
if (result.download.active_downloads > 0) {
|
|
457
491
|
const left = result.download.size_left_bytes
|
|
458
492
|
const total = result.download.size_total_bytes
|
|
@@ -528,9 +562,23 @@ async function mediaDiagnoseAndFix(a) {
|
|
|
528
562
|
? (action === "force_import_scan" ? { name: "RescanSeries", seriesId: svcId } : { name: "SeriesSearch", seriesId: svcId })
|
|
529
563
|
: (action === "force_import_scan" ? { name: "RescanMovie", movieIds: [svcId] } : { name: "MoviesSearch", movieIds: [svcId] })
|
|
530
564
|
commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
|
|
565
|
+
} else if (action === "blocklist_and_research") {
|
|
566
|
+
// Removing with blocklist=true is what stops the same dead release being
|
|
567
|
+
// grabbed straight back. The search that follows is then free to pick a
|
|
568
|
+
// different one.
|
|
569
|
+
const stalled = before.download.stalled_items ?? []
|
|
570
|
+
if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
|
|
571
|
+
human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
|
|
572
|
+
for (const item of stalled) {
|
|
573
|
+
if (item.queue_id === null) continue
|
|
574
|
+
const client = kind === "series" ? sonarr : radarr
|
|
575
|
+
await client(`/queue/${item.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
|
|
576
|
+
}
|
|
577
|
+
const cmd = kind === "series" ? { name: "SeriesSearch", seriesId: svcId } : { name: "MoviesSearch", movieIds: [svcId] }
|
|
578
|
+
commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
|
|
531
579
|
} else {
|
|
532
580
|
return { action, result: "unsupported_action", before, after: null, human_action_required: true,
|
|
533
|
-
human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, report_only.` }
|
|
581
|
+
human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, blocklist_and_research, report_only.` }
|
|
534
582
|
}
|
|
535
583
|
|
|
536
584
|
await new Promise((r) => setTimeout(r, 12_000))
|
|
@@ -553,6 +601,7 @@ function describeAction(action) {
|
|
|
553
601
|
rescan: "Trigger a fresh indexer search and grab the best acceptable release.",
|
|
554
602
|
enable_monitoring: "Mark it monitored, then search.",
|
|
555
603
|
force_import_scan: "Re-scan the disk so an already-downloaded file gets imported.",
|
|
604
|
+
blocklist_and_research: "Blocklist the stalled release so it cannot be grabbed again, then search for a different one.",
|
|
556
605
|
report_only: "Report only; change nothing.",
|
|
557
606
|
}[action] ?? "Unknown action."
|
|
558
607
|
}
|
|
@@ -625,7 +674,7 @@ const TOOLS = [
|
|
|
625
674
|
},
|
|
626
675
|
{
|
|
627
676
|
name: "media_request_status",
|
|
628
|
-
description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. Never infer the cause yourself — read diagnosis.summary.",
|
|
677
|
+
description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. A queue row is not proof of progress: a release sitting at zero bytes comes back as stalled, not as downloading. Never infer the cause yourself — read diagnosis.summary.",
|
|
629
678
|
inputSchema: { type: "object", properties: {
|
|
630
679
|
request_id: { type: "string", description: "e.g. jellyseerr:42" },
|
|
631
680
|
tmdb_id: { type: "number" },
|
|
@@ -634,10 +683,10 @@ const TOOLS = [
|
|
|
634
683
|
},
|
|
635
684
|
{
|
|
636
685
|
name: "media_diagnose_and_fix",
|
|
637
|
-
description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
|
|
686
|
+
description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified, including 'blocklist_and_research' for a release that has stalled at zero bytes. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
|
|
638
687
|
inputSchema: { type: "object", properties: {
|
|
639
688
|
request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
|
|
640
|
-
action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "report_only"] },
|
|
689
|
+
action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "blocklist_and_research", "report_only"] },
|
|
641
690
|
dry_run: { type: "boolean" },
|
|
642
691
|
} },
|
|
643
692
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.827</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.readAwaitRuntimeState = readAwaitRuntimeState;
|
|
37
37
|
exports.applyAwaitRuntimeState = applyAwaitRuntimeState;
|
|
38
38
|
exports.writeAwaitRuntimeState = writeAwaitRuntimeState;
|
|
39
|
+
exports.recordAwaitDispatch = recordAwaitDispatch;
|
|
39
40
|
exports.recordAwaitCheck = recordAwaitCheck;
|
|
40
41
|
const path = __importStar(require("path"));
|
|
41
42
|
const json_store_1 = require("../../arc/json-store");
|
|
@@ -89,6 +90,16 @@ function writeAwaitRuntimeState(agentRoot, name, partial) {
|
|
|
89
90
|
meta: { agentRoot, name, last_checked: merged.last_checked, checked_count: merged.checked_count },
|
|
90
91
|
});
|
|
91
92
|
}
|
|
93
|
+
// The scheduler owns cadence, not the model. `recordAwaitCheck` is written only
|
|
94
|
+
// when a woken turn calls `resolve_await` with verdict "no", so an await whose
|
|
95
|
+
// turn answers in chat, errors, or simply never calls the tool leaves
|
|
96
|
+
// `last_checked` null forever: it reads as "never checked" on every
|
|
97
|
+
// reconciliation, re-fires, wakes the private runtime again and records
|
|
98
|
+
// nothing. Recording the dispatch itself keeps the cadence honest whatever the
|
|
99
|
+
// turn does; the observation from `resolve_await` still layers on top.
|
|
100
|
+
function recordAwaitDispatch(agentRoot, name, now) {
|
|
101
|
+
writeAwaitRuntimeState(agentRoot, name, { last_checked: now });
|
|
102
|
+
}
|
|
92
103
|
function recordAwaitCheck(agentRoot, name, observation, now) {
|
|
93
104
|
const existing = readAwaitRuntimeState(agentRoot, name);
|
|
94
105
|
const nextCount = (existing?.checked_count ?? 0) + 1;
|
|
@@ -55,6 +55,7 @@ const habit_scheduler_1 = require("../habits/habit-scheduler");
|
|
|
55
55
|
const habit_migration_1 = require("../habits/habit-migration");
|
|
56
56
|
const await_scheduler_1 = require("../awaiting/await-scheduler");
|
|
57
57
|
const await_expiry_1 = require("../awaiting/await-expiry");
|
|
58
|
+
const await_runtime_state_1 = require("../awaiting/await-runtime-state");
|
|
58
59
|
const os_cron_deps_1 = require("./os-cron-deps");
|
|
59
60
|
const os_cron_1 = require("./os-cron");
|
|
60
61
|
const container_runtime_1 = require("./container-runtime");
|
|
@@ -801,7 +802,15 @@ void (0, daemon_bootstrap_startup_1.startDaemonAfterContainerCredentialBootstrap
|
|
|
801
802
|
agent,
|
|
802
803
|
awaitName,
|
|
803
804
|
triggerSource: "await-scheduler",
|
|
804
|
-
})).
|
|
805
|
+
})).then(() => {
|
|
806
|
+
// Record the dispatch here so the cadence advances whatever the woken
|
|
807
|
+
// turn does. Without this the only writer of `last_checked` is the
|
|
808
|
+
// agent calling `resolve_await`, so a turn that does anything else
|
|
809
|
+
// leaves the await permanently "never checked" and it re-fires on
|
|
810
|
+
// every reconciliation. Recorded only on a successful wake: a failed
|
|
811
|
+
// dispatch is not a check.
|
|
812
|
+
(0, await_runtime_state_1.recordAwaitDispatch)(bundleRoot, awaitName, new Date().toISOString());
|
|
813
|
+
}).catch((error) => {
|
|
805
814
|
emitAwaitPrivateWakeDispatchError({
|
|
806
815
|
agent,
|
|
807
816
|
awaitName,
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.827",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.827",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|