@taicho-ai/framework 0.1.0
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/README.md +4 -0
- package/package.json +48 -0
- package/src/coaching/patterns.ts +103 -0
- package/src/coaching/proposal.ts +29 -0
- package/src/coaching/retrieval.ts +27 -0
- package/src/coaching/supersede.ts +14 -0
- package/src/coaching/teach.ts +74 -0
- package/src/core/agent-status.ts +79 -0
- package/src/core/auth/constants.ts +53 -0
- package/src/core/auth/login.ts +110 -0
- package/src/core/auth/pkce.ts +18 -0
- package/src/core/auth/profile.ts +38 -0
- package/src/core/auth/refresh.ts +57 -0
- package/src/core/auth/status.ts +26 -0
- package/src/core/command-guard.ts +126 -0
- package/src/core/conversation-artifacts.ts +40 -0
- package/src/core/conversation-replay.ts +160 -0
- package/src/core/costs.ts +97 -0
- package/src/core/discovery.ts +22 -0
- package/src/core/draft.ts +6 -0
- package/src/core/e2e-model.ts +315 -0
- package/src/core/embed.ts +221 -0
- package/src/core/events.ts +133 -0
- package/src/core/firecrawl.ts +25 -0
- package/src/core/headless.ts +260 -0
- package/src/core/instrument.ts +88 -0
- package/src/core/logger.ts +143 -0
- package/src/core/mcp/adapter.ts +34 -0
- package/src/core/mcp/manager.ts +145 -0
- package/src/core/mcp/oauth.ts +119 -0
- package/src/core/memory.ts +13 -0
- package/src/core/mock-model.ts +70 -0
- package/src/core/model.ts +91 -0
- package/src/core/pricing.ts +27 -0
- package/src/core/providers/openai-codex.ts +67 -0
- package/src/core/registry.ts +67 -0
- package/src/core/run.ts +909 -0
- package/src/core/schedule-cli.ts +55 -0
- package/src/core/scheduler.ts +356 -0
- package/src/core/tasks.ts +108 -0
- package/src/core/team-cli.ts +118 -0
- package/src/core/team-routing.ts +58 -0
- package/src/core/tools.ts +1104 -0
- package/src/core/turn-audit.ts +100 -0
- package/src/core/verification.ts +102 -0
- package/src/core/workflow-run.ts +119 -0
- package/src/index.ts +8 -0
- package/src/knowledge/retrieval.ts +73 -0
- package/src/knowledge/sync.ts +28 -0
- package/src/skills/retrieval.ts +22 -0
- package/src/store/annotations.ts +107 -0
- package/src/store/artifacts.ts +338 -0
- package/src/store/config.ts +187 -0
- package/src/store/conversation.ts +107 -0
- package/src/store/db.ts +34 -0
- package/src/store/files.ts +51 -0
- package/src/store/knowledge.ts +201 -0
- package/src/store/mcp-store.ts +65 -0
- package/src/store/migrate.ts +278 -0
- package/src/store/plans.ts +260 -0
- package/src/store/policy.ts +66 -0
- package/src/store/prefs.ts +64 -0
- package/src/store/roster.ts +308 -0
- package/src/store/run-transcript.ts +103 -0
- package/src/store/schedules.ts +94 -0
- package/src/store/seed-skills.ts +75 -0
- package/src/store/skills.ts +89 -0
- package/src/store/sources.ts +58 -0
- package/src/store/spend-ledger.ts +114 -0
- package/src/store/task-state.ts +267 -0
- package/src/store/teams.ts +227 -0
- package/src/store/thread.ts +72 -0
- package/src/store/trace.ts +98 -0
- package/src/store/vectors.ts +26 -0
- package/src/store/workflows.ts +144 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Skill store: one file per skill at skills/<id>.md (YAML frontmatter = the skill minus `body`,
|
|
2
|
+
* body = the procedure). Files are canon; the `skills` table is a rebuildable index. Mirrors
|
|
3
|
+
* store/knowledge.ts + store/policy.ts. */
|
|
4
|
+
import { YAML } from "bun";
|
|
5
|
+
import type { Database } from "bun:sqlite";
|
|
6
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { Skill } from "@taicho-ai/contracts/skill";
|
|
9
|
+
import { paths } from "./files";
|
|
10
|
+
import { log } from "../core/logger";
|
|
11
|
+
|
|
12
|
+
const FRONTMATTER = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/;
|
|
13
|
+
|
|
14
|
+
export function mkSkillId(): string {
|
|
15
|
+
return "skill_" + Math.random().toString(36).slice(2, 10);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function serializeSkill(s: Skill): string {
|
|
19
|
+
const { body, ...meta } = s;
|
|
20
|
+
return `---\n${YAML.stringify(meta, null, 2)}\n---\n${body}\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseSkill(text: string): Skill {
|
|
24
|
+
const m = FRONTMATTER.exec(text);
|
|
25
|
+
if (!m) throw new Error("skill file missing YAML frontmatter");
|
|
26
|
+
const meta = YAML.parse(m[1]) as Record<string, unknown>;
|
|
27
|
+
return Skill.parse({ ...meta, body: m[2].trim() });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeSkill(ws: string, db: Database, skill: Skill): void {
|
|
31
|
+
mkdirSync(paths.skillsDir(ws), { recursive: true });
|
|
32
|
+
writeFileSync(paths.skillFile(ws, skill.id), serializeSkill(skill));
|
|
33
|
+
indexSkill(db, skill);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readSkill(ws: string, id: string): Skill | null {
|
|
37
|
+
const f = paths.skillFile(ws, id);
|
|
38
|
+
if (!existsSync(f)) return null;
|
|
39
|
+
try { return parseSkill(readFileSync(f, "utf8")); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listSkills(ws: string): Skill[] {
|
|
43
|
+
const dir = paths.skillsDir(ws);
|
|
44
|
+
if (!existsSync(dir)) return [];
|
|
45
|
+
const out: Skill[] = [];
|
|
46
|
+
for (const f of readdirSync(dir)) {
|
|
47
|
+
if (!f.endsWith(".md")) continue;
|
|
48
|
+
try { out.push(parseSkill(readFileSync(join(dir, f), "utf8"))); }
|
|
49
|
+
catch (e) { log.warn(`skipping skill ${f}`, e); }
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function deleteSkill(ws: string, db: Database, id: string): boolean {
|
|
55
|
+
const f = paths.skillFile(ws, id);
|
|
56
|
+
const existed = existsSync(f);
|
|
57
|
+
if (existed) rmSync(f);
|
|
58
|
+
db.query("DELETE FROM skills WHERE id = ?").run(id);
|
|
59
|
+
return existed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function indexSkill(db: Database, s: Skill): void {
|
|
63
|
+
db.query(
|
|
64
|
+
`INSERT INTO skills (id, name, description, tags, status, body, updated)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, unixepoch())
|
|
66
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
67
|
+
name=excluded.name, description=excluded.description, tags=excluded.tags,
|
|
68
|
+
status=excluded.status, body=excluded.body, updated=unixepoch()`,
|
|
69
|
+
).run(s.id, s.name, s.description, JSON.stringify(s.tags), s.status, s.body);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SkillRow { id: string; name: string; description: string; tags: string[]; status: string; body: string }
|
|
73
|
+
|
|
74
|
+
export function getActiveSkills(db: Database): SkillRow[] {
|
|
75
|
+
const rows = db.query("SELECT id, name, description, tags, status, body FROM skills WHERE status = 'active' ORDER BY id")
|
|
76
|
+
.all() as { id: string; name: string; description: string; tags: string | null; status: string; body: string }[];
|
|
77
|
+
return rows.map((r) => ({ ...r, tags: r.tags ? (JSON.parse(r.tags) as string[]) : [] }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Rebuild the skills index from the canonical files (proves files-are-canon). */
|
|
81
|
+
export function reindexSkills(ws: string, db: Database): void {
|
|
82
|
+
db.exec("DELETE FROM skills");
|
|
83
|
+
const seen = new Map<string, string>(); // name -> id, to warn on duplicate names
|
|
84
|
+
for (const s of listSkills(ws)) {
|
|
85
|
+
if (seen.has(s.name)) log.warn(`duplicate skill name "${s.name}" (${seen.get(s.name)} and ${s.id}); use_skill resolves the first match`);
|
|
86
|
+
seen.set(s.name, s.id);
|
|
87
|
+
indexSkill(db, s);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Source-document discovery + hash tracking. Files in kb/sources/ are admin-authored inputs; each
|
|
2
|
+
* is hashed so the librarian re-extracts only what changed. Mirrors store/knowledge.ts (files canon,
|
|
3
|
+
* kb_sources = derived index of last-synced hashes). */
|
|
4
|
+
import type { Database } from "bun:sqlite";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { paths } from "./files";
|
|
9
|
+
import { log } from "../core/logger";
|
|
10
|
+
|
|
11
|
+
const SOURCE_EXT = /\.(md|txt)$/i;
|
|
12
|
+
|
|
13
|
+
export function hashContent(text: string): string {
|
|
14
|
+
return createHash("sha256").update(text).digest("hex").slice(0, 12);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiscoveredSource { path: string; content: string; hash: string }
|
|
18
|
+
|
|
19
|
+
export function listSourceFiles(ws: string): DiscoveredSource[] {
|
|
20
|
+
const dir = paths.kbSourceDir(ws);
|
|
21
|
+
if (!existsSync(dir)) return [];
|
|
22
|
+
const out: DiscoveredSource[] = [];
|
|
23
|
+
for (const name of readdirSync(dir)) {
|
|
24
|
+
if (!SOURCE_EXT.test(name)) continue;
|
|
25
|
+
try {
|
|
26
|
+
const content = readFileSync(join(dir, name), "utf8");
|
|
27
|
+
out.push({ path: `sources/${name}`, content, hash: hashContent(content) });
|
|
28
|
+
} catch (e) { log.warn(`skipping source ${name}`, e); }
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readTrackedSources(db: Database): Map<string, string> {
|
|
34
|
+
const rows = db.query("SELECT path, hash FROM kb_sources").all() as { path: string; hash: string }[];
|
|
35
|
+
return new Map(rows.map((r) => [r.path, r.hash]));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function upsertSourceHash(db: Database, path: string, hash: string): void {
|
|
39
|
+
db.query(
|
|
40
|
+
"INSERT INTO kb_sources (path, hash, updated) VALUES (?, ?, unixepoch()) " +
|
|
41
|
+
"ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, updated = unixepoch()",
|
|
42
|
+
).run(path, hash);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function deleteSourceHash(db: Database, path: string): void {
|
|
46
|
+
db.query("DELETE FROM kb_sources WHERE path = ?").run(path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SourceDiff { changed: DiscoveredSource[]; deleted: string[] }
|
|
50
|
+
|
|
51
|
+
export function diffSources(ws: string, db: Database): SourceDiff {
|
|
52
|
+
const tracked = readTrackedSources(db);
|
|
53
|
+
const onDisk = listSourceFiles(ws);
|
|
54
|
+
const seen = new Set(onDisk.map((s) => s.path));
|
|
55
|
+
const changed = onDisk.filter((s) => tracked.get(s.path) !== s.hash);
|
|
56
|
+
const deleted = [...tracked.keys()].filter((p) => !seen.has(p));
|
|
57
|
+
return { changed, deleted };
|
|
58
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Plan 09 + Plan 19: rolling spend ceilings, at two scopes. The agent loop is the one meter (tokens +
|
|
2
|
+
* advisory USD); this is the durable rolling counter it reads/commits against so ceilings SPAN SESSIONS.
|
|
3
|
+
*
|
|
4
|
+
* Counters are keyed by scope × UTC day (YYYY-MM-DD) / ISO week (YYYY-Www): when the day/week rolls over
|
|
5
|
+
* the key changes and the new period naturally reads 0 — no explicit reset. Files/traces stay canon for
|
|
6
|
+
* reporting (`/costs`); this table is a fast, purpose-built enforcement counter, not a second ledger of
|
|
7
|
+
* record. USD is only ever accumulated for PRICED runs — a subscription/unpriced call commits 0 USD (its
|
|
8
|
+
* tokens still count), so a USD ceiling never fabricates spend it cannot measure.
|
|
9
|
+
*
|
|
10
|
+
* A run on a team is metered against BOTH scopes in one transaction: the team's ceiling and the squad's.
|
|
11
|
+
* A team ceiling can therefore stop a run the squad ceiling would happily have allowed. */
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
import { DEFAULT_TEAM_ID } from "@taicho-ai/contracts/team";
|
|
14
|
+
import {
|
|
15
|
+
SQUAD_SCOPE, teamScope, hasCeilings,
|
|
16
|
+
type SpendScope, type SpendCeilings, type SpendTotals, type SpendLedger,
|
|
17
|
+
} from "@taicho-ai/agent";
|
|
18
|
+
export {
|
|
19
|
+
SQUAD_SCOPE, teamScope, hasCeilings, ceilingHit, exhaustionMessage,
|
|
20
|
+
type SpendScope, type SpendCeilings, type SpendTotals, type SpendLedger,
|
|
21
|
+
} from "@taicho-ai/agent";
|
|
22
|
+
|
|
23
|
+
/** The scopes a run by an agent on these teams is metered against (Plan 22: an agent may be on several).
|
|
24
|
+
* Every explicit team the agent belongs to gets its own ceiling, plus the always-present squad. The
|
|
25
|
+
* implicit `default` team is dropped — it IS the squad, so metering it separately would double-count.
|
|
26
|
+
* Order matters only for which exhaustion message the captain sees first, and the narrower (team) ones
|
|
27
|
+
* come before the squad because they are more actionable. */
|
|
28
|
+
export function scopesFor(teams?: string[] | string | null): SpendScope[] {
|
|
29
|
+
const list = teams == null ? [] : Array.isArray(teams) ? teams : [teams];
|
|
30
|
+
const explicit = [...new Set(list.filter((t) => t && t !== DEFAULT_TEAM_ID))];
|
|
31
|
+
return [...explicit.map(teamScope), SQUAD_SCOPE];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every ceiling this session enforces: the squad's, and each configured team's. */
|
|
35
|
+
export interface CeilingConfig {
|
|
36
|
+
squad?: SpendCeilings;
|
|
37
|
+
teams?: Record<string, SpendCeilings | undefined>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The seam the loop enforces against: read a scope's running total, commit a call's spend to every
|
|
41
|
+
* scope it belongs to. Bound to the DB + configured ceilings so the loop stays provider-agnostic. */
|
|
42
|
+
/** True when at least one ceiling is configured — callers skip building a ledger (and its per-call
|
|
43
|
+
* DB reads) entirely when there's nothing to enforce, preserving pre-Plan-09 behavior. */
|
|
44
|
+
/** True when the squad OR any team has a ceiling. With none, the whole subsystem stays off. */
|
|
45
|
+
export function hasAnyCeilings(c: CeilingConfig): boolean {
|
|
46
|
+
return hasCeilings(c.squad) || Object.values(c.teams ?? {}).some((t) => hasCeilings(t));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Which ceiling (if any) the current running total has already reached — a human message, else null.
|
|
50
|
+
* Uses `>=` so it refuses the NEXT call once a limit is met, mirroring the per-run cap check. */
|
|
51
|
+
/** UTC calendar day key — matches trace.ts's dateStamp so /runs and the ledger agree on "today". */
|
|
52
|
+
export function dayKey(d: Date): string {
|
|
53
|
+
return d.toISOString().slice(0, 10);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** ISO-8601 week key (YYYY-Www). The week's year is decided by its Thursday, so late-December /
|
|
57
|
+
* early-January days land in the correct ISO year — the standard algorithm. */
|
|
58
|
+
export function isoWeekKey(date: Date): string {
|
|
59
|
+
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
60
|
+
const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0 … Sun=6
|
|
61
|
+
d.setUTCDate(d.getUTCDate() - dayNum + 3); // move to this week's Thursday
|
|
62
|
+
const thursday = d.getTime();
|
|
63
|
+
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
|
|
64
|
+
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
|
65
|
+
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
|
66
|
+
const week = 1 + Math.round((thursday - firstThursday.getTime()) / (7 * 86_400_000));
|
|
67
|
+
return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readPeriod(db: Database, scope: SpendScope, kind: "day" | "week", key: string): { tokens: number; cost: number } {
|
|
71
|
+
const r = db
|
|
72
|
+
.query("SELECT tokens, cost_usd FROM squad_spend WHERE scope = ? AND period_kind = ? AND period_key = ?")
|
|
73
|
+
.get(scope, kind, key) as { tokens: number; cost_usd: number } | null;
|
|
74
|
+
return { tokens: r?.tokens ?? 0, cost: r?.cost_usd ?? 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read a scope's current day + week running totals without building a full ledger (used by /costs'
|
|
78
|
+
* header and tests). Reflects only spend committed while a ceiling was configured. */
|
|
79
|
+
export function readSpendTotals(db: Database, now: () => Date = () => new Date(), scope: SpendScope = SQUAD_SCOPE): SpendTotals {
|
|
80
|
+
const d = now();
|
|
81
|
+
const day = readPeriod(db, scope, "day", dayKey(d));
|
|
82
|
+
const week = readPeriod(db, scope, "week", isoWeekKey(d));
|
|
83
|
+
return { dayTokens: day.tokens, weekTokens: week.tokens, dayCostUsd: day.cost, weekCostUsd: week.cost };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A DB-backed spend ledger. `now` is injectable so tests can cross day/week boundaries deterministically. */
|
|
87
|
+
export function makeSpendLedger(db: Database, config: CeilingConfig, now: () => Date = () => new Date()): SpendLedger {
|
|
88
|
+
const upsert = db.query(
|
|
89
|
+
`INSERT INTO squad_spend (scope, period_kind, period_key, tokens, cost_usd, updated)
|
|
90
|
+
VALUES (?, ?, ?, ?, ?, unixepoch())
|
|
91
|
+
ON CONFLICT(scope, period_kind, period_key) DO UPDATE SET
|
|
92
|
+
tokens = tokens + excluded.tokens,
|
|
93
|
+
cost_usd = cost_usd + excluded.cost_usd,
|
|
94
|
+
updated = unixepoch()`,
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
ceilings: (scope) => {
|
|
98
|
+
const c = scope === SQUAD_SCOPE ? config.squad : config.teams?.[scope.slice("team:".length)];
|
|
99
|
+
return hasCeilings(c) ? c : undefined;
|
|
100
|
+
},
|
|
101
|
+
current: (scope) => readSpendTotals(db, now, scope),
|
|
102
|
+
add: (scopes, { tokens, costUsd }) => {
|
|
103
|
+
if ((tokens === 0 && costUsd === 0) || !scopes.length) return;
|
|
104
|
+
const d = now();
|
|
105
|
+
// One transaction across every scope: a crash must not leave the team charged and the squad not.
|
|
106
|
+
db.transaction(() => {
|
|
107
|
+
for (const scope of scopes) {
|
|
108
|
+
upsert.run(scope, "day", dayKey(d), tokens, costUsd);
|
|
109
|
+
upsert.run(scope, "week", isoWeekKey(d), tokens, costUsd);
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Task state separates "the model replied" from "the requested work is actually done".
|
|
2
|
+
*
|
|
3
|
+
* Plan 04 promotes this from a write-only per-turn audit record into a persistent TASK QUEUE:
|
|
4
|
+
* a task can outlive the turn that created it (a background `dispatch_task`), survives a restart
|
|
5
|
+
* (files under tasks/ are canon; a rebuildable SQLite `tasks` table is the query index), and is
|
|
6
|
+
* listable/cancellable via `/tasks`. A synchronous chat turn is just a task the captain watches. */
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, renameSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { Database } from "bun:sqlite";
|
|
10
|
+
import { paths } from "./files";
|
|
11
|
+
import type { RunTrace, VerificationVerdict } from "@taicho-ai/contracts/trace";
|
|
12
|
+
|
|
13
|
+
export type TaskStepStatus = "not_started" | "running" | "completed" | "failed" | "blocked" | "interrupted" | "verified";
|
|
14
|
+
/** Task lifecycle. `queued` (dispatched, awaiting a concurrency slot) and `cancelled` are Plan 04
|
|
15
|
+
* additions for background tasks; the rest are shared with the watched-turn (chat) path. */
|
|
16
|
+
export type TaskStatus =
|
|
17
|
+
| "requested" | "queued" | "running" | "completed" | "partial"
|
|
18
|
+
| "failed" | "blocked" | "interrupted" | "cancelled";
|
|
19
|
+
|
|
20
|
+
/** A criteria→verdict record surfaced from a delegation's independent check (Plan 06). Replaces the
|
|
21
|
+
* never-populated `verifiedClaims`: this field is populated from trace.verification (root + children). */
|
|
22
|
+
export interface TaskVerification {
|
|
23
|
+
criteria: string;
|
|
24
|
+
verdict: VerificationVerdict;
|
|
25
|
+
runId: string; // the checked child run
|
|
26
|
+
retried: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type TaskKind = "chat" | "background";
|
|
30
|
+
|
|
31
|
+
/** The canonical set of TERMINAL task statuses — a settled task whose outcome must not be overwritten.
|
|
32
|
+
* Single source of truth so the cancel guard (cancelTaskState) and the await guard (App's awaitTask)
|
|
33
|
+
* can never drift: cancelling an already-`blocked`/`interrupted`/`partial` task must be a no-op, not
|
|
34
|
+
* an outcome-erasing overwrite. Non-terminal = requested | queued | running. */
|
|
35
|
+
export const TERMINAL_TASK_STATUS = new Set<TaskStatus>([
|
|
36
|
+
"completed", "partial", "failed", "blocked", "interrupted", "cancelled",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export interface TaskState {
|
|
40
|
+
taskId: string;
|
|
41
|
+
title: string;
|
|
42
|
+
status: TaskStatus;
|
|
43
|
+
created: string;
|
|
44
|
+
updated: string;
|
|
45
|
+
rootRunId: string;
|
|
46
|
+
userTurnId?: string;
|
|
47
|
+
kind?: TaskKind; // "chat" (a turn the captain watches) | "background" (dispatch_task); default chat
|
|
48
|
+
agent?: string; // the agent this task runs on (target of a dispatch)
|
|
49
|
+
goal?: string; // the dispatched goal (background tasks); chat tasks use `title`
|
|
50
|
+
resultRef?: string; // hand-off BY REFERENCE: an artifact handle (id@vN) or the root run id
|
|
51
|
+
summary?: string; // short settle summary (truncated); check_task returns this, never a payload
|
|
52
|
+
steps: { name: string; status: TaskStepStatus; runId?: string; details?: string }[];
|
|
53
|
+
verifications: TaskVerification[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SUMMARY_CAP = 500;
|
|
57
|
+
|
|
58
|
+
function taskFile(ws: string, taskId: string): string {
|
|
59
|
+
return join(paths.taskDir(ws), `${taskId}.json`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function taskIdForRun(runId: string): string {
|
|
63
|
+
return `task_${runId.replace("/", "_")}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A fresh id for a background task — created before its run reserves a runId (dispatch returns the
|
|
67
|
+
* taskId immediately), so it can't derive from the runId the way a watched-turn task does. */
|
|
68
|
+
export function mkTaskId(): string {
|
|
69
|
+
return `task_bg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeTask(ws: string, task: TaskState, db?: Database): void {
|
|
73
|
+
mkdirSync(paths.taskDir(ws), { recursive: true });
|
|
74
|
+
// Plan 20: temp + rename (atomic on POSIX) — a crash mid-write used to truncate the .json, which
|
|
75
|
+
// reindexTasks then silently skipped (the record vanished from the index). The temp suffix isn't
|
|
76
|
+
// .json, so a leftover from a crashed write is ignored by the readdir filters. Mirrors schedules.ts.
|
|
77
|
+
const dest = taskFile(ws, task.taskId);
|
|
78
|
+
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(task, null, 2));
|
|
80
|
+
renameSync(tmp, dest);
|
|
81
|
+
if (db) indexTask(db, task);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── SQLite index (rebuildable from files; the query surface for /tasks) ──────────────────────────
|
|
85
|
+
|
|
86
|
+
export function indexTask(db: Database, task: TaskState): void {
|
|
87
|
+
db.query(
|
|
88
|
+
`INSERT INTO tasks (id, agent, goal, status, kind, root_run_id, result_ref, summary, created, updated)
|
|
89
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
90
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
91
|
+
agent=excluded.agent, goal=excluded.goal, status=excluded.status, kind=excluded.kind,
|
|
92
|
+
root_run_id=excluded.root_run_id, result_ref=excluded.result_ref, summary=excluded.summary,
|
|
93
|
+
updated=excluded.updated`,
|
|
94
|
+
).run(
|
|
95
|
+
task.taskId, task.agent ?? null, task.goal ?? task.title, task.status, task.kind ?? "chat",
|
|
96
|
+
task.rootRunId || null, task.resultRef ?? null, task.summary ?? null, task.created, task.updated,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface TaskIndexRow {
|
|
101
|
+
id: string; agent: string | null; goal: string | null; status: TaskStatus;
|
|
102
|
+
kind: TaskKind; root_run_id: string | null; result_ref: string | null;
|
|
103
|
+
summary: string | null; created: string | null; updated: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** List indexed tasks, newest-updated first. `activeOrBackground` (the `/tasks` default view) hides
|
|
107
|
+
* the noise of completed watched turns: only background tasks + any non-terminal task show. */
|
|
108
|
+
export function listTaskIndex(db: Database, opts: { activeOrBackground?: boolean } = {}): TaskIndexRow[] {
|
|
109
|
+
const rows = opts.activeOrBackground
|
|
110
|
+
? db.query<TaskIndexRow, []>(
|
|
111
|
+
`SELECT * FROM tasks WHERE kind = 'background' OR status IN ('queued','running','interrupted','requested')
|
|
112
|
+
ORDER BY updated DESC`,
|
|
113
|
+
).all()
|
|
114
|
+
: db.query<TaskIndexRow, []>(`SELECT * FROM tasks ORDER BY updated DESC`).all();
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Rebuild the tasks index from the canonical tasks/*.json files (files are truth; DB is a cache). */
|
|
119
|
+
export function reindexTasks(ws: string, db: Database): number {
|
|
120
|
+
db.query("DELETE FROM tasks").run();
|
|
121
|
+
const dir = paths.taskDir(ws);
|
|
122
|
+
if (!existsSync(dir)) return 0;
|
|
123
|
+
let n = 0;
|
|
124
|
+
for (const f of readdirSync(dir)) {
|
|
125
|
+
if (!f.endsWith(".json")) continue;
|
|
126
|
+
try {
|
|
127
|
+
const task = JSON.parse(readFileSync(join(dir, f), "utf8")) as TaskState;
|
|
128
|
+
indexTask(db, task);
|
|
129
|
+
n++;
|
|
130
|
+
} catch { /* skip an unparseable/partial task file */ }
|
|
131
|
+
}
|
|
132
|
+
return n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── lifecycle ────────────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
export function createTaskState(ws: string, input: { runId: string; title: string; userTurnId?: string }, db?: Database): TaskState {
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
const task: TaskState = {
|
|
140
|
+
taskId: taskIdForRun(input.runId),
|
|
141
|
+
title: input.title,
|
|
142
|
+
status: "running",
|
|
143
|
+
created: now,
|
|
144
|
+
updated: now,
|
|
145
|
+
rootRunId: input.runId,
|
|
146
|
+
userTurnId: input.userTurnId,
|
|
147
|
+
kind: "chat",
|
|
148
|
+
agent: input.runId.split("/")[0],
|
|
149
|
+
goal: input.title,
|
|
150
|
+
steps: [{ name: "root_run", status: "running", runId: input.runId }],
|
|
151
|
+
verifications: [],
|
|
152
|
+
};
|
|
153
|
+
writeTask(ws, task, db);
|
|
154
|
+
return task;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Create a persistent BACKGROUND task record (Plan 04) in status `queued`. rootRunId is unknown
|
|
158
|
+
* until the detached run reserves it — patched in later via setTaskFields. */
|
|
159
|
+
export function createBackgroundTask(ws: string, db: Database, input: { taskId: string; agent: string; goal: string }): TaskState {
|
|
160
|
+
const now = new Date().toISOString();
|
|
161
|
+
const task: TaskState = {
|
|
162
|
+
taskId: input.taskId,
|
|
163
|
+
title: input.goal,
|
|
164
|
+
status: "queued",
|
|
165
|
+
created: now,
|
|
166
|
+
updated: now,
|
|
167
|
+
rootRunId: "",
|
|
168
|
+
kind: "background",
|
|
169
|
+
agent: input.agent,
|
|
170
|
+
goal: input.goal,
|
|
171
|
+
steps: [{ name: "root_run", status: "not_started" }],
|
|
172
|
+
verifications: [],
|
|
173
|
+
};
|
|
174
|
+
writeTask(ws, task, db);
|
|
175
|
+
return task;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function readTaskState(ws: string, taskId: string): TaskState | null {
|
|
179
|
+
const f = taskFile(ws, taskId);
|
|
180
|
+
if (!existsSync(f)) return null;
|
|
181
|
+
return JSON.parse(readFileSync(f, "utf8")) as TaskState;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Patch a task's mutable fields (status/rootRunId/result/summary) on the file AND the index. */
|
|
185
|
+
export function setTaskFields(
|
|
186
|
+
ws: string,
|
|
187
|
+
db: Database,
|
|
188
|
+
taskId: string,
|
|
189
|
+
patch: Partial<Pick<TaskState, "status" | "rootRunId" | "resultRef" | "summary">> & { stepStatus?: TaskStepStatus },
|
|
190
|
+
): TaskState | null {
|
|
191
|
+
const task = readTaskState(ws, taskId);
|
|
192
|
+
if (!task) return null;
|
|
193
|
+
if (patch.status) task.status = patch.status;
|
|
194
|
+
if (patch.rootRunId !== undefined) {
|
|
195
|
+
task.rootRunId = patch.rootRunId;
|
|
196
|
+
if (task.steps[0]) task.steps[0].runId = patch.rootRunId;
|
|
197
|
+
}
|
|
198
|
+
if (patch.resultRef !== undefined) task.resultRef = patch.resultRef;
|
|
199
|
+
if (patch.summary !== undefined) task.summary = patch.summary.slice(0, SUMMARY_CAP);
|
|
200
|
+
if (patch.stepStatus && task.steps[0]) task.steps[0].status = patch.stepStatus;
|
|
201
|
+
task.updated = new Date().toISOString();
|
|
202
|
+
writeTask(ws, task, db);
|
|
203
|
+
return task;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Mark a task cancelled (the caller aborts the live run separately). No-op if already terminal —
|
|
207
|
+
* cancelling a settled task (completed/partial/failed/blocked/interrupted/cancelled) must NOT erase
|
|
208
|
+
* its recorded outcome. Guard aligned with TERMINAL_TASK_STATUS (was too narrow, dropping
|
|
209
|
+
* blocked/interrupted/partial). */
|
|
210
|
+
export function cancelTaskState(ws: string, db: Database, taskId: string): TaskState | null {
|
|
211
|
+
const task = readTaskState(ws, taskId);
|
|
212
|
+
if (!task) return null;
|
|
213
|
+
if (TERMINAL_TASK_STATUS.has(task.status)) return task;
|
|
214
|
+
task.status = "cancelled";
|
|
215
|
+
task.updated = new Date().toISOString();
|
|
216
|
+
writeTask(ws, task, db);
|
|
217
|
+
return task;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Boot reconciliation (Plan 04 Phase 5): a task left `running`/`queued` means the process died
|
|
221
|
+
* mid-flight — mark it `interrupted`. Returns the reconciled tasks so the captain can be told
|
|
222
|
+
* (report-and-ask; auto-resume is deferred per the closed Phase 0 decision). */
|
|
223
|
+
export function reconcileTasks(ws: string, db: Database): TaskState[] {
|
|
224
|
+
const dir = paths.taskDir(ws);
|
|
225
|
+
if (!existsSync(dir)) return [];
|
|
226
|
+
const reconciled: TaskState[] = [];
|
|
227
|
+
for (const f of readdirSync(dir)) {
|
|
228
|
+
if (!f.endsWith(".json")) continue;
|
|
229
|
+
let task: TaskState;
|
|
230
|
+
try { task = JSON.parse(readFileSync(join(dir, f), "utf8")) as TaskState; }
|
|
231
|
+
catch { continue; }
|
|
232
|
+
if (task.status === "running" || task.status === "queued") {
|
|
233
|
+
task.status = "interrupted";
|
|
234
|
+
if (task.steps[0] && task.steps[0].status === "running") task.steps[0].status = "interrupted";
|
|
235
|
+
task.updated = new Date().toISOString();
|
|
236
|
+
writeTask(ws, task, db);
|
|
237
|
+
reconciled.push(task);
|
|
238
|
+
} else {
|
|
239
|
+
indexTask(db, task); // keep the index consistent with every file, reconciled or not
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return reconciled;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function updateTaskFromTrace(ws: string, taskId: string, trace: RunTrace, children: RunTrace[] = [], db?: Database): TaskState | null {
|
|
246
|
+
const task = readTaskState(ws, taskId);
|
|
247
|
+
if (!task) return null;
|
|
248
|
+
const outcomeToStep = (outcome: RunTrace["outcome"]): TaskStepStatus => outcome === "completed" ? "completed" : outcome;
|
|
249
|
+
task.updated = new Date().toISOString();
|
|
250
|
+
task.rootRunId = task.rootRunId || trace.id;
|
|
251
|
+
task.steps = task.steps.filter((s) => s.name === "root_run");
|
|
252
|
+
task.steps[0] = { name: "root_run", status: outcomeToStep(trace.outcome), runId: trace.id };
|
|
253
|
+
for (const child of children) {
|
|
254
|
+
task.steps.push({ name: `child:${child.agent}`, status: outcomeToStep(child.outcome), runId: child.id, details: child.task });
|
|
255
|
+
}
|
|
256
|
+
// Populate the verification record from the checks this run (and its children) actually ran.
|
|
257
|
+
task.verifications = [trace, ...children].flatMap((t) =>
|
|
258
|
+
(t.verification ?? []).map((v) => ({ criteria: v.criteria, verdict: v.verdict, runId: v.runId, retried: v.retried })),
|
|
259
|
+
);
|
|
260
|
+
if (trace.outcome === "completed" && children.every((c) => c.outcome === "completed")) task.status = "completed";
|
|
261
|
+
else if (trace.outcome === "blocked" || children.some((c) => c.outcome === "blocked")) task.status = "blocked";
|
|
262
|
+
else if (trace.outcome === "interrupted" || children.some((c) => c.outcome === "interrupted")) task.status = "interrupted";
|
|
263
|
+
else if (trace.outcome === "failed" || children.some((c) => c.outcome === "failed")) task.status = "failed";
|
|
264
|
+
else task.status = "partial";
|
|
265
|
+
writeTask(ws, task, db);
|
|
266
|
+
return task;
|
|
267
|
+
}
|