Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
a8a9551
chore(deps): update tinyagents subproject commit
senamakel Aug 21, 2026
2a53366
refactor(threads): delegate task-run logic to tinyagents crate
senamakel Aug 21, 2026
a760bf9
fix(agent): await async run operations in task dispatcher and todos
senamakel Aug 21, 2026
9c8e79b
feat(runtime): migrate legacy task run ledgers alongside task boards
senamakel Aug 21, 2026
c1cdfaa
refactor(prompt): delegate task prompt construction to tinyagents crate
senamakel Aug 21, 2026
0688031
refactor(poller): delegate card selection and backoff to shared dispa…
senamakel Aug 21, 2026
d32ac16
feat(poller): reimplement approval gate as a local function
senamakel Aug 21, 2026
5924207
refactor(task-dispatcher): delegate active-run tracking to the crate …
senamakel Aug 21, 2026
11ee306
fix(dispatch): rename parameter for clarity
senamakel Aug 21, 2026
898a7a7
refactor(goals): delegate budget accounting and stop-hook logic to th…
senamakel Aug 21, 2026
e705b34
chore(tests): remove unused import in test module
senamakel Aug 21, 2026
5b40082
fix(tests): update reclaim limit in test to match new tolerance
senamakel Aug 21, 2026
926004c
docs(threads/todos): clarify runs module responsibilities and legacy …
senamakel Aug 21, 2026
47fc550
chore: reformat code for consistent style
senamakel Aug 21, 2026
4fb999e
chore(deps): bump vendored tinyagents to the task-runtime port
senamakel Aug 21, 2026
36d80f6
Merge remote-tracking branch 'origin/main' into tinyagents-task-runtime
senamakel Aug 21, 2026
19598dc
chore(deps): update vendor submodules for tinyhumans-sdk and tinymemory
senamakel Aug 21, 2026
97f1dd2
chore(deps): keep main's tinyhumans-sdk and tinymemory gitlinks
senamakel Aug 21, 2026
9cba8bc
chore(deps): update vendor submodules tinyhumans-sdk and tinymemory
senamakel Aug 21, 2026
4283c15
chore(deps): keep main's tinyhumans-sdk and tinymemory gitlinks
senamakel Aug 21, 2026
14ee4d4
chore(deps): update tinyagents to 2.1.1 and downgrade two Windows dep…
senamakel Aug 21, 2026
23a3b29
chore(deps): restore main's Cargo.lock, keeping only the tinyagents 2…
senamakel Aug 21, 2026
2fa8184
fix: reject non-ASCII and malformed hex in legacy thread ID parsing
senamakel Aug 21, 2026
07ff512
test(todos): add tests for malformed legacy file name stems
senamakel Aug 21, 2026
fa97e63
chore(todos): remove stale doc comment on thread id decoder
senamakel Aug 21, 2026
ce6bd6c
fix(todos): handle store write failures in legacy task migration
senamakel Aug 21, 2026
45f2287
fix(threads/todos): reject uppercase hex in legacy thread IDs
senamakel Aug 22, 2026
84ada81
fix(todos): remove unnecessary Option wrapper in hex decoding
senamakel Aug 22, 2026
96e1cc6
chore(deps): update Cargo.lock with dependency bumps
senamakel Aug 22, 2026
0fec183
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/5669
senamakel Aug 22, 2026
6252c51
test(raw_coverage): add missing tool_specs field to test fixtures
senamakel Aug 22, 2026
dfad6c2
fix(tests): correct delegation tool description assertion in e2e test
senamakel Aug 22, 2026
4525c6c
docs: update broken links in localized README files
senamakel Aug 22, 2026
c4dea6e
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/5669
senamakel Aug 22, 2026
f4469a7
fix(tests): tighten assertion for research tool description
senamakel Aug 22, 2026
5fff8e3
Merge remote-tracking branch 'upstream/main' into pr/5669
senamakel Aug 22, 2026
9834fb9
Merge upstream/main into pr/5669
senamakel Aug 22, 2026
790b920
test(raw_coverage): add e2e test for agent archivist debug round 21
senamakel Aug 22, 2026
083bace
test(raw_coverage): add round 21 agent archivist debug e2e test
senamakel Aug 22, 2026
3d29385
fix(tests): update raw coverage e2e test to match new inference agent…
senamakel Aug 22, 2026
fc35195
fix(tests): update raw coverage e2e test to match new inference agent…
senamakel Aug 22, 2026
a9fc1ba
fix(threads): handle empty todo list in run execution
senamakel Aug 22, 2026
7ce425d
fix(threads): handle missing run in todo list
senamakel Aug 22, 2026
5f62217
fix(threads): handle missing run in todo list
senamakel Aug 22, 2026
e39d138
fix(threads): handle missing run in todo completion
senamakel Aug 22, 2026
750fd80
fix(threads): handle missing run in todo completion
senamakel Aug 22, 2026
0699577
fix(threads): handle missing run in todo completion
senamakel Aug 22, 2026
ebf0971
fix(tests): add raw coverage e2e test for inference agent
senamakel Aug 22, 2026
e81f735
fix(tests): correct raw coverage test for agent round 26
senamakel Aug 22, 2026
838fc3b
merge: resolve conflicts with upstream/main
senamakel Aug 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 102 additions & 70 deletions app/src-tauri/Cargo.lock

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions src/core/runtime/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,8 @@ async fn run_legacy_migrations(config: &Config) {
// Idempotent copy of any task boards left in the retired
// `{workspace}/agent_task_boards/*.json` file-JSON tree into the crate
// `graph.todos` store, which is now authoritative. Idempotent and returns
// fast on an empty/absent legacy dir (the `*.runs.json` ledger stays local).
// As above, each core boot must inspect its own workspace.
// fast on an empty/absent legacy dir. As above, each core boot must inspect
// its own workspace.
match crate::openhuman::agent::tinyagents::todos::migrate_legacy_task_boards(
&config.workspace_dir,
)
Expand All @@ -393,6 +393,26 @@ async fn run_legacy_migrations(config: &Config) {
Ok(_) => {}
Err(e) => log::warn!("[todos] legacy→crate task-board migration failed: {e}"),
}

// The `*.runs.json` claim/heartbeat ledgers that sat beside those boards
// move with them: run records now live in the crate `graph.todos.runs`
// store, so a board and its run log cannot drift apart across a restart.
// Left behind, an in-flight claim would be invisible to the reclaim sweep
// and its card would stay wedged at `in_progress` forever.
match crate::openhuman::threads::todos::runs::migrate_legacy_task_runs(&config.workspace_dir)
.await
{
Ok(report) if report.total > 0 => {
log::info!(
"[todos] legacy→crate run-ledger migration: total={} copied={} skipped={}",
report.total,
report.copied,
report.skipped
);
}
Ok(_) => {}
Err(e) => log::warn!("[todos] legacy→crate run-ledger migration failed: {e}"),
}
}

/// Auto-connect Socket.IO to the backend when enabled by the service selection.
Expand Down
6 changes: 3 additions & 3 deletions src/openhuman/agent/task_dispatcher/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ pub async fn dispatch_card(
"[task_dispatcher] card claimed (→in_progress), spawning autonomous run"
);

if let Err(e) = runs::create_run(&location, &run_id, &card_id, &executor.label) {
if let Err(e) = runs::create_run(&location, &run_id, &card_id, &executor.label).await {
tracing::warn!(
run_id = %run_id,
card_id = %card_id,
Expand Down Expand Up @@ -182,8 +182,8 @@ pub async fn dispatch_card(
tid,
ActiveRun {
abort: join.abort_handle(),
hb_cancel: hb_cancel_tx,
location: reg_location,
heartbeat_cancel: hb_cancel_tx,
context: reg_location,
card_id: reg_card_id,
run_id: reg_run_id,
},
Expand Down
3 changes: 2 additions & 1 deletion src/openhuman/agent/task_dispatcher/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,8 @@ pub(super) async fn write_back(
Vec::new(),
),
};
if let Err(e) = runs::complete_run(location, run_id, run_outcome, run_error, run_evidence) {
if let Err(e) = runs::complete_run(location, run_id, run_outcome, run_error, run_evidence).await
{
tracing::warn!(
run_id = %run_id,
error = %e,
Expand Down
93 changes: 31 additions & 62 deletions src/openhuman/agent/task_dispatcher/poller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
use std::sync::OnceLock;
use std::time::Duration;

use crate::openhuman::agent::task_board::{TaskApprovalMode, TaskBoardCard, TaskCardStatus};
use tinyagents::graph::todos::dispatch::select;

use crate::openhuman::agent::task_board::{TaskApprovalMode, TaskBoardCard};
use crate::openhuman::config::Config;
use crate::openhuman::threads::todos::ops::{self, BoardLocation, USER_TASKS_THREAD_ID};
use crate::openhuman::threads::todos::runs::{self, RunLimits};
Expand All @@ -30,27 +32,22 @@ const POLLER_MAX_BACKOFF_SECONDS: u64 = 15 * 60;
/// immediately slow down.
const POLLER_IDLE_GRACE_TICKS: u32 = 2;

/// The backoff curve itself lives in the crate
/// ([`select::PollCadence`](tinyagents::graph::todos::dispatch::select::PollCadence));
/// this is OpenHuman's tuning of it (issue #4090).
const POLLER_CADENCE: select::PollCadence = select::PollCadence {
base: Duration::from_secs(POLLER_TICK_SECONDS),
max_backoff: Duration::from_secs(POLLER_MAX_BACKOFF_SECONDS),
grace_ticks: POLLER_IDLE_GRACE_TICKS,
};

static POLLER_STARTED: OnceLock<()> = OnceLock::new();

/// Compute the next sleep before a poll tick given how many consecutive idle
/// ticks have elapsed (issue #4090). Pure + deterministic so the backoff curve
/// is unit-testable without the real timer.
///
/// - Fresh work (`idle_ticks == 0`) or within the grace window → base cadence.
/// - Beyond the grace window → exponential backoff (double per extra idle tick)
/// saturating at [`POLLER_MAX_BACKOFF_SECONDS`].
/// How long to sleep before the next poll tick, given how many consecutive idle
/// ticks have elapsed: base cadence through the grace window, then doubling up
/// to the ceiling.
fn next_poll_delay(idle_ticks: u32) -> Duration {
let over = idle_ticks.saturating_sub(POLLER_IDLE_GRACE_TICKS);
if over == 0 {
return Duration::from_secs(POLLER_TICK_SECONDS);
}
// Double per idle tick past the grace window, saturating at the cap. Clamp
// the shift so a long idle streak can't overflow the multiply.
let factor = 1u64.checked_shl(over.min(20)).unwrap_or(u64::MAX);
let secs = POLLER_TICK_SECONDS
.saturating_mul(factor)
.min(POLLER_MAX_BACKOFF_SECONDS);
Duration::from_secs(secs)
POLLER_CADENCE.next_delay(idle_ticks)
}

/// Spawn the board poller. Idempotent — only the first call installs the loop.
Expand Down Expand Up @@ -200,11 +197,7 @@ async fn poll_board(location: &BoardLocation, agent_assigned_only: bool) -> Resu

// `enforce_single_in_progress` caps the board at one running card, so if
// one is already in progress there's nothing for this tick to claim.
if snapshot
.cards
.iter()
.any(|c| c.status == TaskCardStatus::InProgress)
{
if select::has_card_in_progress(&snapshot.cards) {
return Ok(false);
}

Expand All @@ -230,61 +223,37 @@ async fn poll_board(location: &BoardLocation, agent_assigned_only: bool) -> Resu
/// When `agent_assigned_only` is set, cards without an `assigned_agent` are
/// excluded — used on the `user-tasks` board so the poller runs only
/// agent-generated tasks and never picks up a human's manually-created card.
///
/// The selection policy itself is
/// [`select::pick_next_card`](tinyagents::graph::todos::dispatch::select::pick_next_card).
pub(super) fn pick_next_todo(
cards: &[TaskBoardCard],
agent_assigned_only: bool,
) -> Option<TaskBoardCard> {
cards
.iter()
.filter(|c| matches!(c.status, TaskCardStatus::Todo | TaskCardStatus::Ready))
.filter(|c| {
!agent_assigned_only
|| c.assigned_agent
.as_deref()
.map(|a| !a.trim().is_empty())
.unwrap_or(false)
})
.max_by(|a, b| {
card_urgency(a)
.partial_cmp(&card_urgency(b))
.unwrap_or(std::cmp::Ordering::Equal)
// On equal urgency, prefer the lower `order` (earlier card):
// reversing the order comparison makes it the "greater" pick.
.then(b.order.cmp(&a.order))
})
.cloned()
select::pick_next_card(cards, agent_assigned_only)
}

/// Whether a card must be parked at `awaiting_approval` before it can run.
///
/// Per-card `approval_mode` is authoritative when set; the global
/// `require_task_plan_approval` setting is only the fallback for cards with no
/// explicit preference:
/// - `Required` → always park, **even when the global default is off**. The
/// interactive plan-review gate (WebChat turns, see
/// [`crate::openhuman::agent::tools::todo`]) stamps `Required`, and that
/// review must hold regardless of the global switch — otherwise an
/// interactive plan would execute before the user ever sees the review card.
/// - `NotRequired` → never park (already cleared human review, e.g. approved
/// out of the `task-sources` inbox onto `user-tasks`).
/// - unset → fall back to the global default.
/// explicit preference. In particular `Required` parks the card **even when the
/// global default is off**: the interactive plan-review gate (WebChat turns,
/// see [`crate::openhuman::agent::tools::todo`]) stamps `Required`, and that
/// review must hold regardless of the global switch — otherwise an interactive
/// plan would execute before the user ever saw the review card.
///
/// The rule itself is
/// [`select::requires_plan_approval`](tinyagents::graph::todos::dispatch::select::requires_plan_approval).
pub(super) fn requires_plan_approval(
global_required: bool,
approval_mode: Option<&TaskApprovalMode>,
) -> bool {
match approval_mode {
Some(TaskApprovalMode::Required) => true,
Some(TaskApprovalMode::NotRequired) => false,
None => global_required,
}
select::requires_plan_approval(global_required, approval_mode)
}

pub(super) fn card_urgency(card: &TaskBoardCard) -> f64 {
card.source_metadata
.as_ref()
.and_then(|m| m.get("urgency"))
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0)
select::card_urgency(card)
}

#[cfg(test)]
Expand Down
133 changes: 25 additions & 108 deletions src/openhuman/agent/task_dispatcher/prompt.rs
Original file line number Diff line number Diff line change
@@ -1,121 +1,38 @@
//! Task prompt construction helpers.
//! Task prompt construction — a thin binding of
//! [`tinyagents::graph::todos::dispatch::prompt`] to OpenHuman's tool names.
//!
//! Builds the goal prompt handed to autonomous runs from a [`TaskBoardCard`],
//! and the live-progress instruction that keeps the card current while the
//! run works.
//! The crate owns the rendering (objective, plan, acceptance criteria, source
//! provenance, and the "block rather than guess" progress addendum). All this
//! module supplies is which tools the generated text should point the model at:
//! `memory_recall` for the ingested activity of a card's originating item, and
//! `update_task` for the card write-back.

use crate::openhuman::agent::task_board::TaskBoardCard;

/// Render a card into the goal prompt handed to the autonomous run.
///
/// The card's `content`/title is the display form; the prompt leads with the
/// clean `objective`, then any `plan` steps and `acceptance_criteria`, and a
/// pointer to the originating source so the agent can pull related context from
/// memory via its `memory_recall` tool (the GitHub/Notion/… activity for this
/// item is ingested into the summary tree by the memory-sources domain).
pub fn build_task_prompt(card: &TaskBoardCard) -> String {
let mut lines: Vec<String> = Vec::new();
use std::sync::LazyLock;

let objective = card
.objective
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| card.title.trim());
lines.push(format!(
"You are autonomously executing one task to completion. Objective:\n{objective}"
));
use tinyagents::graph::todos::dispatch::prompt as crate_prompt;
use tinyagents::graph::todos::dispatch::TaskPromptTools;

if !card.plan.is_empty() {
lines.push("\nPlan:".to_string());
for (i, step) in card.plan.iter().enumerate() {
lines.push(format!("{}. {}", i + 1, step.trim()));
}
}

if !card.acceptance_criteria.is_empty() {
lines.push("\nAcceptance criteria (the task is done only when all hold):".to_string());
for c in &card.acceptance_criteria {
lines.push(format!("- {}", c.trim()));
}
}

if let Some(meta) = &card.source_metadata {
let provider = meta.get("provider").and_then(|v| v.as_str());
let repo = meta.get("repo").and_then(|v| v.as_str());
let external_id = meta.get("external_id").and_then(|v| v.as_str());
let url = meta.get("url").and_then(|v| v.as_str());
let mut origin = String::new();
if let Some(p) = provider {
origin.push_str(p);
}
if let Some(r) = repo {
origin.push_str(&format!(" {r}"));
}
if let Some(id) = external_id {
origin.push_str(&format!("#{id}"));
}
// Gate on a known provider so the origin string is always meaningful
// (an id-only card would render "#123" with a leading space).
if provider.is_some() {
lines.push(format!(
"\nThis task originates from {}. Its activity has been ingested into memory — use \
your memory_recall tool to pull related context (prior discussion, linked items) \
before and while you work.",
origin.trim()
));
}
if let Some(u) = url {
lines.push(format!("Source link: {u}"));
}
// G9b — agent-driven external write-back. When the upstream item is
// addressable (provider + id), instruct the agent to close the loop on
// the source itself via its integration tools. Runs under the
// connection's existing write scope (no extra approval gate); if it
// can't, it reports that instead of failing.
if provider.is_some() && external_id.is_some() {
lines.push(format!(
"\nWhen the task is complete, record the outcome on the upstream source ({}): use \
your integration tools to add a comment summarising the resolution and, if the \
work fully addresses it, close/resolve the item. If you lack the permission or \
connection to do so, say so in your final summary instead of guessing.",
origin.trim()
));
}
}
use crate::openhuman::agent::task_board::TaskBoardCard;

lines.push(
"\nWork the task to completion. Do not pick up unrelated work. When finished, your final \
message should summarise what you did and the evidence (commits, PRs, results)."
.to_string(),
);
/// OpenHuman's tool names for the two tools a task prompt references.
static TOOLS: LazyLock<TaskPromptTools> = LazyLock::new(|| TaskPromptTools {
memory_recall: Some("memory_recall".to_string()),
update_task: "update_task".to_string(),
});

lines.join("\n")
/// Render a card into the goal prompt handed to the autonomous run.
pub fn build_task_prompt(card: &TaskBoardCard) -> String {
crate_prompt::build_task_prompt(card, &TOOLS)
}

/// Instruction appended to the run prompt so the autonomous turn keeps its own
/// task card current via the `update_task` tool while it works.
///
/// The card is already `in_progress` (the dispatcher claimed it before
/// spawning the run), addressed by the exact card id + board the run owns
/// (without the explicit `threadId` the tool defaults to the `task-sources`
/// board and would miss a `user-tasks` card). Two things this asks for:
/// 1. *progress* updates (notes/evidence) as the run works, and
/// 2. an explicit `status: blocked` + `blocker` when the run needs a
/// decision/information from the user or cannot proceed — which
/// [`write_back`] now preserves rather than force-completing, so the task
/// pauses for the user instead of being silently marked done.
/// The card is addressed by exact id **and** board: without the explicit
/// `threadId` the tool defaults to the `task-sources` board and would miss a
/// `user-tasks` card. A run that blocks itself is preserved as blocked by
/// [`write_back`](super::executor::write_back) rather than force-completed, so
/// the task pauses for the user instead of being silently marked done.
pub(super) fn build_progress_instruction(card_id: &str, thread_id: &str) -> String {
format!(
"\n\nThis task is tracked as card `{card_id}` on the `{thread_id}` board. As you work, \
call the `update_task` tool (id `{card_id}`, threadId `{thread_id}`) to keep the card \
current — append `notes`/`evidence` as you make progress.\n\nIf you need a decision or \
information from the user, or you genuinely cannot proceed (missing access, ambiguous \
requirement, an action that needs the user's confirmation), call `update_task` with \
`status: blocked` and a `blocker` that states exactly what you need from the user. The \
task will stay paused in that blocked state until the user responds — do NOT guess, \
fabricate, or take a risky irreversible action just to avoid blocking. If instead you \
finish the work, end with a summary of what you did and the evidence; completion is \
recorded automatically."
)
crate_prompt::build_progress_instruction(card_id, thread_id, &TOOLS)
}
Loading
Loading