mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6604c72a94 | ||
|
|
63a96d2508 | ||
|
|
a84980b92c | ||
|
|
a279981029 |
No files matched your search
+2
-2
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
|
||||
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
|
||||
|
||||
## Released in 0.7.12
|
||||
## Released in 0.7.13
|
||||
|
||||
[0.7.12 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.12): OpenRig 0.7.12 is a patch for people running their own agent teams. When you answer your team, by typing, clicking or qualifying an answer, the answer now goes to an agent that reads it with the question and decides. When no receiving agent is available, your answer waits. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
[0.7.13 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.13): OpenRig 0.7.13 is a patch for people running their own agent teams. When you answer a runner's permission request, you now get a note back saying who decides it, and the runner wakes on the decision. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -1278,7 +1278,8 @@ reaching them is ordinary work rather than an escalation.
|
||||
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
|
||||
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
|
||||
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary"`), and the bridge, with its reach to
|
||||
--binding "relay:<connectorRef>:file:<absolute env file>:primary:handle=<alias>"`). The handle is how
|
||||
their replies are matched to them: a binding without one is outbound-only. The bridge, with its reach to
|
||||
any messaging platform, is yours (`docs/reference/gateway-relay.md`).
|
||||
- **`rig gateway channel add <id> --display-name <name> --binding
|
||||
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
|
||||
@@ -1597,8 +1598,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
|
||||
without that storage), so don't copy them by hand; read the `plugin_skill_*` warnings if one
|
||||
is missing. TDD is no longer a default skill.
|
||||
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry refreshes that skill from its source when
|
||||
a Claude or Codex seat is restored, keeping its conversation; `keep_saved`, the default, keeps the saved copy, but a
|
||||
skill whose source changed after `rig down` comes back missing, named in the restore's warnings.
|
||||
a Claude or Codex seat is restored, keeping its conversation, while the seat's copy is still what OpenRig wrote: an
|
||||
edited, unrecorded or linked copy is kept and named, and a source that can't be read leaves the saved copy.
|
||||
`keep_saved`, the default, keeps the saved copy, but a missing file comes back only from a source that still has the
|
||||
bytes OpenRig wrote: a file whose source changed after `rig down` stays missing, named in the restore's warnings,
|
||||
even when the rest of the skill (its `SKILL.md`, say) is unchanged.
|
||||
- **`rig startup-proof submit`** — answer an explicitly selected authenticated startup challenge.
|
||||
Startup adds no proof exercise by default. Declare `startup_proof` with `authenticated` or
|
||||
`none` in the applicable startup layers; see `docs/reference/rig-spec.md#startup-block/startup-proof-selection`.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
|
||||
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
|
||||
import { networkInstallationsSchema } from "./migrations/163_network_installations.js";
|
||||
import { queueItemsReplyToIndexSchema } from "./migrations/164_queue_items_reply_to_index.js";
|
||||
import { queueListReadIndexesSchema } from "./migrations/166_queue_list_read_indexes.js";
|
||||
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
|
||||
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
|
||||
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
|
||||
@@ -331,4 +332,5 @@ export const ALL_MIGRATIONS: Migration[] = [
|
||||
sessionAddressReadIndexesSchema,
|
||||
networkInstallationsSchema,
|
||||
queueItemsReplyToIndexSchema,
|
||||
queueListReadIndexesSchema,
|
||||
];
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/** Bound queue selection by its existing order, and notification reads by their eligible history. */
|
||||
export const queueListReadIndexesSchema: Migration = {
|
||||
name: "166_queue_list_read_indexes.sql",
|
||||
sql: `
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_created
|
||||
ON queue_items(ts_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_active_created
|
||||
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_items_active_updated
|
||||
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_updated DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_owner_notification_qitem
|
||||
ON queue_transitions(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_transitions_archive_owner_notification_qitem
|
||||
ON queue_transitions_archive(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
|
||||
`,
|
||||
};
|
||||
@@ -42,7 +42,9 @@ export function freshRecoveryId(db: Database.Database, qitemId: string, overdueD
|
||||
* idx_queue_items_recovery_tags' WHERE clause (migration 099) byte for byte, which is what lets the
|
||||
* planner read that small index instead of scanning and parsing every row. */
|
||||
const RECOVERY_TAG_ROWS = `(tags LIKE '%"recovery-for:%' OR tags LIKE '%\\%')`;
|
||||
const RECOVERY_ORDER = "ORDER BY CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
|
||||
// Unary + preserves the numeric CASE order but prevents a prefix match on the full-history listing
|
||||
// index. Both standalone lookups and the batch need the small recovery-tag index instead.
|
||||
const RECOVERY_ORDER = "ORDER BY +CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
|
||||
/** A listing's recovery index, built by SQLite's own json_each (the same values, and the same truthy excludeTag
|
||||
* test, as the per-row query): for each string tag value, the rows that carry it in RECOVERY_ORDER, and each row's
|
||||
* string values. Built lazily, on the first lookup inside `withRecoveryIndex`, so a listing that never asks (a
|
||||
|
||||
@@ -361,6 +361,22 @@ export class WorkflowValidator {
|
||||
});
|
||||
}
|
||||
|
||||
// #3087: validate never resolves seats, so a harness pin is only
|
||||
// reconciled when a run resolves the step's owner. Say so, so a
|
||||
// clean result is not read as "every pin is satisfiable".
|
||||
spec.steps.forEach((step, idx) => {
|
||||
if (!step.harness) return;
|
||||
const binding = spec.target?.rig
|
||||
? `rig "${spec.target.rig}"`
|
||||
: "no rig is bound";
|
||||
issues.push({
|
||||
code: "harness_pin_unchecked",
|
||||
message: `step "${step.id}" pins harness "${step.harness}", but validate does not check harness pins against any seats (${binding}). The pin is checked when a run resolves the step's owner, and the run fails with harness_pin_unsatisfied if no preferred_target of role "${step.actor_role}" runs ${step.harness}.`,
|
||||
field: `workflow.steps[${idx}].harness`,
|
||||
severity: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF2 FR-5: gate target semantics — a HUMAN-seat target
|
||||
// requires summary + evidence_ref (the shipped human-route write
|
||||
// path enforces them at create; failing HERE is the fail-at-author-
|
||||
|
||||
@@ -188,6 +188,7 @@ export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, even
|
||||
* its exclusion); the guard's redundant-exclusion check then keeps the two in sync.
|
||||
*/
|
||||
export const migrationsForFullTestDbExclusions: Record<string, string> = {
|
||||
"166_queue_list_read_indexes.sql": "read indexes depend on owner-notification columns/archive omitted here (054/076); startup and queue-list-order-indexes.test.ts exercise ALL_MIGRATIONS.",
|
||||
"156_queue_erasure_link_indexes.sql": "indexes outbox_entries, inbox_entries and the stream tables (all excluded from the core edge) for queue erasure's lookups; erasure tests migrate the canonical full schema.",
|
||||
"082_archive_identity_provenance.sql": "archive extension; 054 is excluded from this core fixture. Archive identity regressions use ALL_MIGRATIONS.",
|
||||
"026_inbox_entries.sql": "inbox subsystem table — not on the shared core edge (inbox suites migrate it inline).",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { migrate } from "../src/db/migrate.js";
|
||||
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { QueueRepository, type QueueListOptions } from "../src/domain/queue-repository.js";
|
||||
import { RECOVERY_INDEX_SQL, findQueueRecovery } from "../src/domain/queue-recovery.js";
|
||||
import { QueueTransitionLog } from "../src/domain/queue-transition-log.js";
|
||||
import { runStuckSweep } from "../src/domain/queue-stuck-sweep.js";
|
||||
import { OutboxHandler } from "../src/domain/outbox-handler.js";
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
|
||||
describe("bounded queue lists use their ordering indexes", () => {
|
||||
let db: Database.Database;
|
||||
let repo: QueueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(":memory:");
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => ({ ok: true, entities: [] }) });
|
||||
const insert = db.prepare(`INSERT INTO queue_items
|
||||
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, tags)
|
||||
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, ?, ?)`);
|
||||
db.transaction(() => {
|
||||
for (let i = 0; i < 240; i++) {
|
||||
// Equal timestamps across states exercise the existing scan's tie order too.
|
||||
const ts = new Date(Date.UTC(2026, 10, 12, 0, 0, Math.floor(i / 3))).toISOString();
|
||||
const tags = i % 7 === 0 ? ["first", "second"] : i % 2 ? ["first"] : ["second"];
|
||||
insert.run(`q-${i}`, ts, ts, ["pending", "done", "blocked"][i % 3], "body".repeat(256), JSON.stringify(tags));
|
||||
}
|
||||
})();
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); db.close(); });
|
||||
|
||||
function selection(options: QueueListOptions) {
|
||||
const prepare = db.prepare.bind(db);
|
||||
let selected: { sql: string; args: unknown[] } | undefined;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
const statement = prepare(sql);
|
||||
if (/^SELECT (?:\*|qitem_id,).* FROM queue_items\s+(?:WHERE\b|ORDER BY\b)/s.test(sql)) {
|
||||
const all = statement.all.bind(statement);
|
||||
statement.all = (...args: unknown[]) => { selected = { sql, args }; return all(...args); };
|
||||
}
|
||||
return statement;
|
||||
});
|
||||
let ids: string[];
|
||||
try { ids = repo.list(options).map((item) => item.qitemId); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(selected).toBeDefined();
|
||||
const { sql, args } = selected!;
|
||||
const legacy = prepare(sql.replace("FROM queue_items", "FROM queue_items NOT INDEXED"))
|
||||
.all(...args) as Array<{ qitem_id: string }>;
|
||||
expect(ids).toEqual(legacy.map((row) => row.qitem_id));
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...args) as Array<{ detail: string }>;
|
||||
return { ids, details: plan.map((row) => row.detail).join("\n") };
|
||||
}
|
||||
|
||||
it.each([false, true])("does not scan and sort all rows for a bounded list (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps active rows first without sorting all rig history (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ rig: "r", compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps scoped update-time ordering indexed (compact=%s)", (compact) => {
|
||||
const { ids, details } = selection({ rig: "r", sortByUpdated: true, compact, limit: 6 });
|
||||
expect(ids).toHaveLength(6);
|
||||
expect(details).toContain("USING INDEX");
|
||||
expect(details).not.toContain("TEMP B-TREE");
|
||||
});
|
||||
|
||||
it("looks for owner notifications without reading ordinary live or archived transitions", () => {
|
||||
const log = new QueueTransitionLog(db);
|
||||
const prepare = db.prepare.bind(db);
|
||||
let query = "";
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
if (sql.includes("ROW_NUMBER()") && sql.includes("owner_notification_level IS NOT NULL")) query = sql;
|
||||
return prepare(sql);
|
||||
});
|
||||
try { expect(log.ownerNotificationsForQitems(["q-0"])).toEqual(new Map()); }
|
||||
finally { spy.mockRestore(); }
|
||||
expect(query).not.toBe("");
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${query}`).all(JSON.stringify(["q-0"])) as Array<{ detail: string }>;
|
||||
const details = plan.map((row) => row.detail).join("\n");
|
||||
expect(details).toContain("idx_queue_transitions_owner_notification_qitem");
|
||||
expect(details).toContain("idx_queue_transitions_archive_owner_notification_qitem");
|
||||
});
|
||||
|
||||
it("still applies both exact tags before the bound, including zero and unbounded limits", () => {
|
||||
for (const limit of [0, 1, 6, -1]) {
|
||||
const { ids } = selection({ rig: "r", tag: ["first", "second"], limit });
|
||||
expect(ids.every((id) => Number(id.slice(2)) % 7 === 0)).toBe(true);
|
||||
expect(ids).toHaveLength(limit < 0 ? 35 : Math.min(limit, 35));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps recovery lookup on its small partial index when listing indexes exist", () => {
|
||||
const plan = db.prepare(`EXPLAIN QUERY PLAN ${RECOVERY_INDEX_SQL}`).all() as Array<{ detail: string }>;
|
||||
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
|
||||
});
|
||||
|
||||
async function pointPlan(prefix: string, run: () => unknown | Promise<unknown>) {
|
||||
const prepare = db.prepare.bind(db);
|
||||
let selected: { sql: string; args: unknown[] } | undefined;
|
||||
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||
const statement = prepare(sql);
|
||||
if (sql.startsWith(prefix)) {
|
||||
const get = statement.get.bind(statement);
|
||||
statement.get = (...args: unknown[]) => { selected = { sql, args }; return get(...args); };
|
||||
}
|
||||
return statement;
|
||||
});
|
||||
try { await run(); } finally { spy.mockRestore(); }
|
||||
expect(selected).toBeDefined();
|
||||
const plan = prepare(`EXPLAIN QUERY PLAN ${selected!.sql}`).all(...selected!.args) as Array<{ detail: string }>;
|
||||
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
|
||||
}
|
||||
|
||||
it("keeps standalone recovery hits and misses on the recovery partial index", async () => {
|
||||
db.prepare("UPDATE queue_items SET tags = ? WHERE qitem_id = 'q-0'").run(JSON.stringify(["recovery-for:subject"]));
|
||||
for (const subject of ["subject", "no-match"]) {
|
||||
for (const exclude of [undefined, "excluded"]) {
|
||||
await pointPlan("SELECT qitem_id, state, ts_updated FROM queue_items", () => {
|
||||
expect(findQueueRecovery(db, subject, exclude)?.qitemId ?? null).toBe(subject === "subject" ? "q-0" : null);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function sweepForPlan(state: "pending" | "in-progress") {
|
||||
db.exec("DELETE FROM queue_items");
|
||||
repo.attachOutbox(new OutboxHandler(db));
|
||||
if (state === "pending") {
|
||||
const rigs = new RigRepository(db);
|
||||
const rig = rigs.createRig("r");
|
||||
const node = rigs.addNode(rig.id, "worker", { runtime: "claude-code" });
|
||||
db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES ('plan-session',?,'worker@r','running')").run(node.id);
|
||||
}
|
||||
const past = new Date(Date.now() - 120 * 60_000).toISOString();
|
||||
const insert = db.prepare(`INSERT INTO queue_items
|
||||
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, claimed_at, closure_required_at)
|
||||
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, 'work', ?, ?)`);
|
||||
for (let i = 0; i < (state === "pending" ? 3 : 1); i++) {
|
||||
insert.run(`candidate-${i}`, past, past, state, state === "in-progress" ? past : null, state === "in-progress" ? past : null);
|
||||
}
|
||||
return runStuckSweep({ db, queueRepo: repo, resolveOrchestrator: () => null, resolveRecoveryAgentSeat: () => null,
|
||||
resolveOperatorSeat: () => null, isRegisteredHost: () => false, unclaimedAgeMinutes: 60, log: () => {} });
|
||||
}
|
||||
|
||||
it("keeps the per-row finding lookup on the recovery partial index", async () => {
|
||||
await pointPlan("SELECT qitem_id, source_session, state, ts_updated FROM queue_items", () => sweepForPlan("in-progress"));
|
||||
});
|
||||
|
||||
it("keeps the latest backlog finding lookup on the recovery partial index", async () => {
|
||||
await pointPlan("SELECT qitem_id, state, ts_updated, summary, body, destination_session, tags FROM queue_items", () => sweepForPlan("pending"));
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,8 @@ describe("Slack sweep selection", () => {
|
||||
it("keeps a legacy schema without owner classification quiet", async () => {
|
||||
row("legacy");
|
||||
for (const table of ["queue_transitions", "queue_transitions_archive"]) {
|
||||
// The simulated pre-classification schema also predates its partial index.
|
||||
db.exec(`DROP INDEX idx_${table}_owner_notification_qitem`);
|
||||
db.exec(`ALTER TABLE ${table} DROP COLUMN owner_notification_level; ALTER TABLE ${table} DROP COLUMN owner_notification_kind`);
|
||||
}
|
||||
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => registry });
|
||||
|
||||
@@ -577,6 +577,23 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
|
||||
).rejects.toMatchObject({ code: "harness_pin_unsatisfied" });
|
||||
});
|
||||
|
||||
it("validate warns that a harness pin is unchecked instead of reporting a clean result (#3087)", () => {
|
||||
const spec = parseWorkflowSpec(HARNESS_SPEC, "harness.yaml");
|
||||
const result = new WorkflowValidator().validate(spec);
|
||||
expect(result.ok).toBe(true);
|
||||
const unchecked = result.issues.filter((i) => i.code === "harness_pin_unchecked");
|
||||
expect(unchecked).toHaveLength(1);
|
||||
expect(unchecked[0]).toMatchObject({ severity: "warning", field: "workflow.steps[0].harness" });
|
||||
expect(unchecked[0]!.message).toContain('"codex"');
|
||||
expect(unchecked[0]!.message).toContain("no rig is bound");
|
||||
});
|
||||
|
||||
it("validate adds no pin warning to a spec without harness pins", () => {
|
||||
const spec = parseWorkflowSpec(LINEAR_SPEC, "linear.yaml");
|
||||
const result = new WorkflowValidator().validate(spec);
|
||||
expect(result.issues.some((i) => i.code === "harness_pin_unchecked")).toBe(false);
|
||||
});
|
||||
|
||||
it("steps with no pin resolve preferred_targets[0] unchanged (zero regression)", async () => {
|
||||
const specPath = writeSpec(tmp, "linear.yaml", LINEAR_SPEC);
|
||||
const result = await runtime.instantiate({
|
||||
|
||||
Reference in new issue
Block a user