astrocode-workflow 0.1.55 → 0.1.57
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/dist/hooks/inject-provider.js +58 -3
- package/dist/state/schema.d.ts +1 -1
- package/dist/state/schema.js +3 -0
- package/dist/tools/index.js +3 -1
- package/dist/tools/injects.d.ts +5 -0
- package/dist/tools/injects.js +129 -7
- package/dist/tools/workflow.d.ts +3 -0
- package/dist/tools/workflow.js +47 -7
- package/dist/workflow/context.d.ts +8 -0
- package/dist/workflow/context.js +48 -2
- package/package.json +1 -1
- package/src/hooks/inject-provider.ts +70 -3
- package/src/state/schema.ts +3 -0
- package/src/tools/index.ts +3 -1
- package/src/tools/injects.ts +147 -11
- package/src/tools/workflow.ts +55 -13
- package/src/workflow/context.ts +56 -2
- package/src/workflow/directives.ts +3 -2
|
@@ -16,24 +16,76 @@ export function createInjectProvider(opts) {
|
|
|
16
16
|
function markInjected(injectId, nowMs) {
|
|
17
17
|
injectedCache.set(injectId, nowMs);
|
|
18
18
|
}
|
|
19
|
+
function getInjectionDiagnostics(nowIso, scopeAllowlist, typeAllowlist) {
|
|
20
|
+
// Get ALL injects to analyze filtering
|
|
21
|
+
const allInjects = db.prepare("SELECT * FROM injects").all();
|
|
22
|
+
let total = allInjects.length;
|
|
23
|
+
let selected = 0;
|
|
24
|
+
let skippedExpired = 0;
|
|
25
|
+
let skippedScope = 0;
|
|
26
|
+
let skippedType = 0;
|
|
27
|
+
let eligibleIds = [];
|
|
28
|
+
for (const inject of allInjects) {
|
|
29
|
+
// Check expiration
|
|
30
|
+
if (inject.expires_at && inject.expires_at <= nowIso) {
|
|
31
|
+
skippedExpired++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Check scope
|
|
35
|
+
if (!scopeAllowlist.includes(inject.scope)) {
|
|
36
|
+
skippedScope++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Check type
|
|
40
|
+
if (!typeAllowlist.includes(inject.type)) {
|
|
41
|
+
skippedType++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// This inject is eligible
|
|
45
|
+
selected++;
|
|
46
|
+
eligibleIds.push(inject.inject_id);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
now: nowIso,
|
|
50
|
+
scopes_considered: scopeAllowlist,
|
|
51
|
+
types_considered: typeAllowlist,
|
|
52
|
+
total_injects: total,
|
|
53
|
+
selected_eligible: selected,
|
|
54
|
+
skipped: {
|
|
55
|
+
expired: skippedExpired,
|
|
56
|
+
scope: skippedScope,
|
|
57
|
+
type: skippedType,
|
|
58
|
+
},
|
|
59
|
+
eligible_ids: eligibleIds,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
19
62
|
async function injectEligibleInjects(sessionId) {
|
|
20
63
|
const now = nowISO();
|
|
21
64
|
const nowMs = Date.now();
|
|
22
|
-
// Get
|
|
65
|
+
// Get allowlists from config or defaults
|
|
23
66
|
const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
|
|
24
67
|
const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
|
|
68
|
+
// Get diagnostic data
|
|
69
|
+
const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
|
|
25
70
|
const eligibleInjects = selectEligibleInjects(db, {
|
|
26
71
|
nowIso: now,
|
|
27
72
|
scopeAllowlist,
|
|
28
73
|
typeAllowlist,
|
|
29
74
|
limit: config.inject?.max_per_turn ?? 5,
|
|
30
75
|
});
|
|
31
|
-
|
|
76
|
+
let injected = 0;
|
|
77
|
+
let skippedDeduped = 0;
|
|
78
|
+
if (eligibleInjects.length === 0) {
|
|
79
|
+
// Log when no injects are eligible
|
|
80
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
|
|
32
81
|
return;
|
|
82
|
+
}
|
|
33
83
|
// Inject each eligible inject, skipping recently injected ones
|
|
34
84
|
for (const inject of eligibleInjects) {
|
|
35
|
-
if (shouldSkipInject(inject.inject_id, nowMs))
|
|
85
|
+
if (shouldSkipInject(inject.inject_id, nowMs)) {
|
|
86
|
+
skippedDeduped++;
|
|
36
87
|
continue;
|
|
88
|
+
}
|
|
37
89
|
// Format as injection message
|
|
38
90
|
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
39
91
|
await injectChatPrompt({
|
|
@@ -42,8 +94,11 @@ export function createInjectProvider(opts) {
|
|
|
42
94
|
text: formattedText,
|
|
43
95
|
agent: "Astrocode"
|
|
44
96
|
});
|
|
97
|
+
injected++;
|
|
45
98
|
markInjected(inject.inject_id, nowMs);
|
|
46
99
|
}
|
|
100
|
+
// Log diagnostic summary
|
|
101
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
|
|
47
102
|
}
|
|
48
103
|
// Public hook handlers
|
|
49
104
|
return {
|
package/dist/state/schema.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const SCHEMA_VERSION = 2;
|
|
2
|
-
export declare const SCHEMA_SQL = "\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS repo_state (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n spec_hash_before TEXT,\n spec_hash_after TEXT,\n last_run_id TEXT,\n last_story_key TEXT,\n last_event_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS epics (\n epic_key TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'active',\n priority INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_drafts (\n draft_id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_keyseq (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n next_story_num INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS stories (\n story_key TEXT PRIMARY KEY,\n epic_key TEXT,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'queued', -- queued|approved|in_progress|done|blocked|archived\n priority INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n locked_by_run_id TEXT,\n locked_at TEXT,\n in_progress INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (epic_key) REFERENCES epics(epic_key)\n);\n\nCREATE TABLE IF NOT EXISTS runs (\n run_id TEXT PRIMARY KEY,\n story_key TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'created', -- created|running|completed|failed|aborted\n pipeline_stages_json TEXT NOT NULL DEFAULT '[]',\n current_stage_key TEXT,\n created_at TEXT NOT NULL,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n error_text TEXT,\n FOREIGN KEY (story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS stage_runs (\n stage_run_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n stage_index INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|skipped\n created_at TEXT NOT NULL,\n subagent_type TEXT,\n subagent_session_id TEXT,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n baton_path TEXT,\n summary_md TEXT,\n output_json TEXT,\n error_text TEXT,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS artifacts (\n artifact_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL, -- plan|baton|evidence|diff|log|summary|commit|tool_output|snapshot\n path TEXT NOT NULL,\n sha256 TEXT,\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS tool_runs (\n tool_run_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n tool_name TEXT NOT NULL,\n args_json TEXT NOT NULL DEFAULT '{}',\n output_summary TEXT NOT NULL DEFAULT '',\n output_artifact_id TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS events (\n event_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL,\n body_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS injects (\n inject_id TEXT PRIMARY KEY,\n type TEXT NOT NULL DEFAULT 'note',\n title TEXT NOT NULL,\n body_md TEXT NOT NULL,\n tags_json TEXT NOT NULL DEFAULT '[]',\n scope TEXT NOT NULL DEFAULT 'repo', -- repo|run:<id>|story:<key>|global\n source TEXT NOT NULL DEFAULT 'user', -- user|tool|agent|import\n priority INTEGER NOT NULL DEFAULT 50,\n expires_at TEXT,\n sha256 TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS running_batches (\n batch_id TEXT PRIMARY KEY,\n run_id TEXT,\n session_id TEXT,\n status TEXT NOT NULL DEFAULT 'running', -- running|completed|failed|aborted\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_metrics (\n metric_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n name TEXT NOT NULL,\n value_num REAL,\n value_text TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS template_intents (\n intent_key TEXT PRIMARY KEY,\n body_md TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- vNext tables\n\nCREATE TABLE IF NOT EXISTS story_relations (\n parent_story_key TEXT NOT NULL,\n child_story_key TEXT NOT NULL,\n relation_type TEXT NOT NULL DEFAULT 'split',\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n PRIMARY KEY (parent_story_key, child_story_key),\n FOREIGN KEY (parent_story_key) REFERENCES stories(story_key),\n FOREIGN KEY (child_story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS continuations (\n continuation_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n run_id TEXT,\n directive_hash TEXT NOT NULL,\n kind TEXT NOT NULL, -- continue|stage|blocked|repair\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_continuations_session_created ON continuations(session_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_continuations_run_created ON continuations(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS context_snapshots (\n snapshot_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n summary_md TEXT NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_context_snapshots_run_created ON context_snapshots(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS agent_sessions (\n session_id TEXT PRIMARY KEY,\n parent_session_id TEXT,\n agent_name TEXT NOT NULL,\n run_id TEXT,\n stage_key TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- Indexes\n\nCREATE INDEX IF NOT EXISTS idx_stories_state ON stories(state);\nCREATE INDEX IF NOT EXISTS idx_runs_story ON runs(story_key);\nCREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);\nCREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id, stage_index);\nCREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_key, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);\n\n-- Stronger invariants (SQLite partial indexes)\n-- Only one run may be 'running' at a time (single-repo harness by default).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_running_run\n ON runs(status)\n WHERE status = 'running';\n\n-- Only one story may be in_progress=1 at a time (pairs with single running run).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_in_progress_story\n ON stories(in_progress)\n WHERE in_progress = 1;\n\n";
|
|
2
|
+
export declare const SCHEMA_SQL = "\nPRAGMA foreign_keys = ON;\n\nCREATE TABLE IF NOT EXISTS repo_state (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n schema_version INTEGER NOT NULL,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n spec_hash_before TEXT,\n spec_hash_after TEXT,\n last_run_id TEXT,\n last_story_key TEXT,\n last_event_at TEXT\n);\n\nCREATE TABLE IF NOT EXISTS settings (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS epics (\n epic_key TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'active',\n priority INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_drafts (\n draft_id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS story_keyseq (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n next_story_num INTEGER NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS stories (\n story_key TEXT PRIMARY KEY,\n epic_key TEXT,\n title TEXT NOT NULL,\n body_md TEXT NOT NULL DEFAULT '',\n state TEXT NOT NULL DEFAULT 'queued', -- queued|approved|in_progress|done|blocked|archived\n priority INTEGER NOT NULL DEFAULT 0,\n approved_at TEXT,\n locked_by_run_id TEXT,\n locked_at TEXT,\n in_progress INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (epic_key) REFERENCES epics(epic_key)\n);\n\nCREATE TABLE IF NOT EXISTS runs (\n run_id TEXT PRIMARY KEY,\n story_key TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'created', -- created|running|completed|failed|aborted\n pipeline_stages_json TEXT NOT NULL DEFAULT '[]',\n current_stage_key TEXT,\n created_at TEXT NOT NULL,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n error_text TEXT,\n FOREIGN KEY (story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS stage_runs (\n stage_run_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n stage_index INTEGER NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|skipped\n created_at TEXT NOT NULL,\n subagent_type TEXT,\n subagent_session_id TEXT,\n started_at TEXT,\n completed_at TEXT,\n updated_at TEXT NOT NULL,\n baton_path TEXT,\n summary_md TEXT,\n output_json TEXT,\n error_text TEXT,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS artifacts (\n artifact_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL, -- plan|baton|evidence|diff|log|summary|commit|tool_output|snapshot\n path TEXT NOT NULL,\n sha256 TEXT,\n meta_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS tool_runs (\n tool_run_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n tool_name TEXT NOT NULL,\n args_json TEXT NOT NULL DEFAULT '{}',\n output_summary TEXT NOT NULL DEFAULT '',\n output_artifact_id TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS events (\n event_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n type TEXT NOT NULL,\n body_json TEXT NOT NULL DEFAULT '{}',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS injects (\n inject_id TEXT PRIMARY KEY,\n type TEXT NOT NULL DEFAULT 'note',\n title TEXT NOT NULL,\n body_md TEXT NOT NULL,\n tags_json TEXT NOT NULL DEFAULT '[]',\n scope TEXT NOT NULL DEFAULT 'repo', -- repo|run:<id>|story:<key>|global\n source TEXT NOT NULL DEFAULT 'user', -- user|tool|agent|import\n priority INTEGER NOT NULL DEFAULT 50,\n expires_at TEXT,\n sha256 TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\nCREATE TABLE IF NOT EXISTS running_batches (\n batch_id TEXT PRIMARY KEY,\n run_id TEXT,\n session_id TEXT,\n status TEXT NOT NULL DEFAULT 'running', -- running|completed|failed|aborted\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS workflow_metrics (\n metric_id TEXT PRIMARY KEY,\n run_id TEXT,\n stage_key TEXT,\n name TEXT NOT NULL,\n value_num REAL,\n value_text TEXT,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE TABLE IF NOT EXISTS template_intents (\n intent_key TEXT PRIMARY KEY,\n body_md TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- vNext tables\n\nCREATE TABLE IF NOT EXISTS story_relations (\n parent_story_key TEXT NOT NULL,\n child_story_key TEXT NOT NULL,\n relation_type TEXT NOT NULL DEFAULT 'split',\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n PRIMARY KEY (parent_story_key, child_story_key),\n FOREIGN KEY (parent_story_key) REFERENCES stories(story_key),\n FOREIGN KEY (child_story_key) REFERENCES stories(story_key)\n);\n\nCREATE TABLE IF NOT EXISTS continuations (\n continuation_id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n run_id TEXT,\n directive_hash TEXT NOT NULL,\n kind TEXT NOT NULL, -- continue|stage|blocked|repair\n reason TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_continuations_session_created ON continuations(session_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_continuations_run_created ON continuations(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS context_snapshots (\n snapshot_id TEXT PRIMARY KEY,\n run_id TEXT NOT NULL,\n stage_key TEXT NOT NULL,\n summary_md TEXT NOT NULL,\n created_at TEXT NOT NULL,\n FOREIGN KEY (run_id) REFERENCES runs(run_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_context_snapshots_run_created ON context_snapshots(run_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS agent_sessions (\n session_id TEXT PRIMARY KEY,\n parent_session_id TEXT,\n agent_name TEXT NOT NULL,\n run_id TEXT,\n stage_key TEXT,\n status TEXT NOT NULL DEFAULT 'active',\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n\n-- Indexes\n\nCREATE INDEX IF NOT EXISTS idx_stories_state ON stories(state);\nCREATE INDEX IF NOT EXISTS idx_runs_story ON runs(story_key);\nCREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);\nCREATE INDEX IF NOT EXISTS idx_stage_runs_run ON stage_runs(run_id, stage_index);\nCREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_key, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);\nCREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;\n\n-- Stronger invariants (SQLite partial indexes)\n-- Only one run may be 'running' at a time (single-repo harness by default).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_running_run\n ON runs(status)\n WHERE status = 'running';\n\n-- Only one story may be in_progress=1 at a time (pairs with single running run).\nCREATE UNIQUE INDEX IF NOT EXISTS uniq_single_in_progress_story\n ON stories(in_progress)\n WHERE in_progress = 1;\n\n";
|
package/dist/state/schema.js
CHANGED
|
@@ -233,6 +233,9 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_ke
|
|
|
233
233
|
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);
|
|
234
234
|
CREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);
|
|
235
235
|
CREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);
|
|
236
|
+
CREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);
|
|
237
|
+
CREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;
|
|
236
239
|
|
|
237
240
|
-- Stronger invariants (SQLite partial indexes)
|
|
238
241
|
-- Only one run may be 'running' at a time (single-repo harness by default).
|
package/dist/tools/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createAstroRunGetTool, createAstroRunAbortTool } from "./run";
|
|
|
6
6
|
import { createAstroWorkflowProceedTool } from "./workflow";
|
|
7
7
|
import { createAstroStageStartTool, createAstroStageCompleteTool, createAstroStageFailTool, createAstroStageResetTool } from "./stage";
|
|
8
8
|
import { createAstroArtifactPutTool, createAstroArtifactListTool, createAstroArtifactGetTool } from "./artifacts";
|
|
9
|
-
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool } from "./injects";
|
|
9
|
+
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool, createAstroInjectEligibleTool, createAstroInjectDebugDueTool } from "./injects";
|
|
10
10
|
import { createAstroRepairTool } from "./repair";
|
|
11
11
|
export function createAstroTools(opts) {
|
|
12
12
|
const { ctx, config, db, agents } = opts;
|
|
@@ -39,6 +39,8 @@ export function createAstroTools(opts) {
|
|
|
39
39
|
tools.astro_inject_list = createAstroInjectListTool({ ctx, config, db });
|
|
40
40
|
tools.astro_inject_search = createAstroInjectSearchTool({ ctx, config, db });
|
|
41
41
|
tools.astro_inject_get = createAstroInjectGetTool({ ctx, config, db });
|
|
42
|
+
tools.astro_inject_eligible = createAstroInjectEligibleTool({ ctx, config, db });
|
|
43
|
+
tools.astro_inject_debug_due = createAstroInjectDebugDueTool({ ctx, config, db });
|
|
42
44
|
tools.astro_repair = createAstroRepairTool({ ctx, config, db });
|
|
43
45
|
}
|
|
44
46
|
else {
|
package/dist/tools/injects.d.ts
CHANGED
|
@@ -46,3 +46,8 @@ export declare function createAstroInjectEligibleTool(opts: {
|
|
|
46
46
|
config: AstrocodeConfig;
|
|
47
47
|
db: SqliteDb;
|
|
48
48
|
}): ToolDefinition;
|
|
49
|
+
export declare function createAstroInjectDebugDueTool(opts: {
|
|
50
|
+
ctx: any;
|
|
51
|
+
config: AstrocodeConfig;
|
|
52
|
+
db: SqliteDb;
|
|
53
|
+
}): ToolDefinition;
|
package/dist/tools/injects.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import { withTx } from "../state/db";
|
|
2
3
|
import { nowISO } from "../shared/time";
|
|
3
4
|
import { sha256Hex } from "../shared/hash";
|
|
5
|
+
const VALID_INJECT_TYPES = ['note', 'policy', 'reminder', 'debug'];
|
|
6
|
+
function validateInjectType(type) {
|
|
7
|
+
if (!VALID_INJECT_TYPES.includes(type)) {
|
|
8
|
+
throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(', ')}`);
|
|
9
|
+
}
|
|
10
|
+
return type;
|
|
11
|
+
}
|
|
12
|
+
function validateTimestamp(timestamp) {
|
|
13
|
+
if (!timestamp)
|
|
14
|
+
return null;
|
|
15
|
+
// Check if it's a valid ISO 8601 timestamp with Z suffix
|
|
16
|
+
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
17
|
+
if (!isoRegex.test(timestamp)) {
|
|
18
|
+
throw new Error(`Invalid timestamp format. Expected ISO 8601 UTC with Z suffix (e.g., "2026-01-23T13:05:19.000Z"), got: "${timestamp}"`);
|
|
19
|
+
}
|
|
20
|
+
// Additional validation: ensure it's parseable and represents a valid date
|
|
21
|
+
const parsed = new Date(timestamp);
|
|
22
|
+
if (isNaN(parsed.getTime())) {
|
|
23
|
+
throw new Error(`Invalid timestamp: "${timestamp}" does not represent a valid date`);
|
|
24
|
+
}
|
|
25
|
+
return timestamp;
|
|
26
|
+
}
|
|
4
27
|
function newInjectId() {
|
|
5
28
|
return `inj_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
6
29
|
}
|
|
@@ -23,13 +46,33 @@ export function createAstroInjectPutTool(opts) {
|
|
|
23
46
|
const id = inject_id ?? newInjectId();
|
|
24
47
|
const now = nowISO();
|
|
25
48
|
const sha = sha256Hex(body_md);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
49
|
+
// Validate inputs
|
|
50
|
+
const validatedType = validateInjectType(type);
|
|
51
|
+
const validatedExpiresAt = validateTimestamp(expires_at);
|
|
52
|
+
return withTx(db, () => {
|
|
53
|
+
const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id);
|
|
54
|
+
if (existing) {
|
|
55
|
+
// Use INSERT ... ON CONFLICT for atomic updates
|
|
56
|
+
db.prepare(`
|
|
57
|
+
INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at)
|
|
58
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
59
|
+
ON CONFLICT(inject_id) DO UPDATE SET
|
|
60
|
+
type=excluded.type,
|
|
61
|
+
title=excluded.title,
|
|
62
|
+
body_md=excluded.body_md,
|
|
63
|
+
tags_json=excluded.tags_json,
|
|
64
|
+
scope=excluded.scope,
|
|
65
|
+
source=excluded.source,
|
|
66
|
+
priority=excluded.priority,
|
|
67
|
+
expires_at=excluded.expires_at,
|
|
68
|
+
sha256=excluded.sha256,
|
|
69
|
+
updated_at=excluded.updated_at
|
|
70
|
+
`).run(id, type, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
|
|
71
|
+
return `✅ Updated inject ${id}: ${title}`;
|
|
72
|
+
}
|
|
73
|
+
db.prepare("INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(id, validatedType, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
|
|
74
|
+
return `✅ Created inject ${id}: ${title}`;
|
|
75
|
+
});
|
|
33
76
|
},
|
|
34
77
|
});
|
|
35
78
|
}
|
|
@@ -137,3 +180,82 @@ export function createAstroInjectEligibleTool(opts) {
|
|
|
137
180
|
},
|
|
138
181
|
});
|
|
139
182
|
}
|
|
183
|
+
export function createAstroInjectDebugDueTool(opts) {
|
|
184
|
+
const { db } = opts;
|
|
185
|
+
return tool({
|
|
186
|
+
description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
|
|
187
|
+
args: {
|
|
188
|
+
scopes_json: tool.schema.string().default('["repo","global"]'),
|
|
189
|
+
types_json: tool.schema.string().default('["note","policy"]'),
|
|
190
|
+
},
|
|
191
|
+
execute: async ({ scopes_json, types_json }) => {
|
|
192
|
+
const now = nowISO();
|
|
193
|
+
const scopes = JSON.parse(scopes_json);
|
|
194
|
+
const types = JSON.parse(types_json);
|
|
195
|
+
// Get ALL injects to analyze filtering
|
|
196
|
+
const allInjects = db.prepare("SELECT * FROM injects").all();
|
|
197
|
+
let total = allInjects.length;
|
|
198
|
+
let selected = 0;
|
|
199
|
+
let skippedExpired = 0;
|
|
200
|
+
let skippedScope = 0;
|
|
201
|
+
let skippedType = 0;
|
|
202
|
+
const excludedReasons = [];
|
|
203
|
+
const selectedInjects = [];
|
|
204
|
+
for (const inject of allInjects) {
|
|
205
|
+
const reasons = [];
|
|
206
|
+
// Check expiration
|
|
207
|
+
if (inject.expires_at && inject.expires_at <= now) {
|
|
208
|
+
reasons.push("expired");
|
|
209
|
+
skippedExpired++;
|
|
210
|
+
}
|
|
211
|
+
// Check scope
|
|
212
|
+
if (!scopes.includes(inject.scope)) {
|
|
213
|
+
reasons.push("scope");
|
|
214
|
+
skippedScope++;
|
|
215
|
+
}
|
|
216
|
+
// Check type
|
|
217
|
+
if (!types.includes(inject.type)) {
|
|
218
|
+
reasons.push("type");
|
|
219
|
+
skippedType++;
|
|
220
|
+
}
|
|
221
|
+
if (reasons.length > 0) {
|
|
222
|
+
excludedReasons.push({
|
|
223
|
+
inject_id: inject.inject_id,
|
|
224
|
+
title: inject.title,
|
|
225
|
+
reasons: reasons,
|
|
226
|
+
scope: inject.scope,
|
|
227
|
+
type: inject.type,
|
|
228
|
+
expires_at: inject.expires_at,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
selected++;
|
|
233
|
+
selectedInjects.push({
|
|
234
|
+
inject_id: inject.inject_id,
|
|
235
|
+
title: inject.title,
|
|
236
|
+
scope: inject.scope,
|
|
237
|
+
type: inject.type,
|
|
238
|
+
expires_at: inject.expires_at,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return JSON.stringify({
|
|
243
|
+
now,
|
|
244
|
+
scopes_considered: scopes,
|
|
245
|
+
types_considered: types,
|
|
246
|
+
summary: {
|
|
247
|
+
total_injects: total,
|
|
248
|
+
selected_eligible: selected,
|
|
249
|
+
excluded_total: total - selected,
|
|
250
|
+
skipped_breakdown: {
|
|
251
|
+
expired: skippedExpired,
|
|
252
|
+
scope: skippedScope,
|
|
253
|
+
type: skippedType,
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
selected_injects: selectedInjects,
|
|
257
|
+
excluded_injects: excludedReasons,
|
|
258
|
+
}, null, 2);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
package/dist/tools/workflow.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type ToolDefinition } from "@opencode-ai/plugin/tool";
|
|
2
2
|
import type { AstrocodeConfig } from "../config/schema";
|
|
3
3
|
import type { SqliteDb } from "../state/db";
|
|
4
|
+
import type { StageKey } from "../state/types";
|
|
5
|
+
export declare const STAGE_TO_AGENT_MAP: Record<string, string>;
|
|
6
|
+
export declare function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string;
|
|
4
7
|
import { AgentConfig } from "@opencode-ai/sdk";
|
|
5
8
|
export declare function createAstroWorkflowProceedTool(opts: {
|
|
6
9
|
ctx: any;
|
package/dist/tools/workflow.js
CHANGED
|
@@ -9,7 +9,7 @@ import { newEventId } from "../state/ids";
|
|
|
9
9
|
import { debug } from "../shared/log";
|
|
10
10
|
import { createToastManager } from "../ui/toasts";
|
|
11
11
|
// Agent name mapping for case-sensitive resolution
|
|
12
|
-
const STAGE_TO_AGENT_MAP = {
|
|
12
|
+
export const STAGE_TO_AGENT_MAP = {
|
|
13
13
|
frame: "Frame",
|
|
14
14
|
plan: "Plan",
|
|
15
15
|
spec: "Spec",
|
|
@@ -18,13 +18,39 @@ const STAGE_TO_AGENT_MAP = {
|
|
|
18
18
|
verify: "Verify",
|
|
19
19
|
close: "Close"
|
|
20
20
|
};
|
|
21
|
-
function resolveAgentName(stageKey, config) {
|
|
21
|
+
export function resolveAgentName(stageKey, config, agents, warnings) {
|
|
22
22
|
// Use configurable agent names from config, fallback to hardcoded map, then General
|
|
23
23
|
const agentNames = config.agents?.stage_agent_names;
|
|
24
|
+
let candidate;
|
|
24
25
|
if (agentNames && agentNames[stageKey]) {
|
|
25
|
-
|
|
26
|
+
candidate = agentNames[stageKey];
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
else {
|
|
29
|
+
candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
|
|
30
|
+
}
|
|
31
|
+
// Validate that the agent actually exists in the registry
|
|
32
|
+
if (agents && !agents[candidate]) {
|
|
33
|
+
const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
|
|
34
|
+
if (warnings) {
|
|
35
|
+
warnings.push(warning);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
console.warn(`[Astrocode] ${warning}`);
|
|
39
|
+
}
|
|
40
|
+
candidate = "General";
|
|
41
|
+
}
|
|
42
|
+
// Final guard: ensure General exists, fallback to built-in "general" if not
|
|
43
|
+
if (agents && !agents[candidate]) {
|
|
44
|
+
const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
|
|
45
|
+
if (warnings) {
|
|
46
|
+
warnings.push(finalWarning);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.warn(`[Astrocode] ${finalWarning}`);
|
|
50
|
+
}
|
|
51
|
+
return "general"; // built-in, guaranteed by OpenCode
|
|
52
|
+
}
|
|
53
|
+
return candidate;
|
|
28
54
|
}
|
|
29
55
|
function stageGoal(stage, cfg) {
|
|
30
56
|
switch (stage) {
|
|
@@ -99,6 +125,7 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
99
125
|
const sessionId = ctx.sessionID;
|
|
100
126
|
const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
|
|
101
127
|
const actions = [];
|
|
128
|
+
const warnings = [];
|
|
102
129
|
const startedAt = nowISO();
|
|
103
130
|
for (let i = 0; i < steps; i++) {
|
|
104
131
|
const next = decideNextAction(db, config);
|
|
@@ -172,7 +199,7 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
172
199
|
const run = db.prepare("SELECT * FROM runs WHERE run_id=?").get(active.run_id);
|
|
173
200
|
const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key);
|
|
174
201
|
// Mark stage started + set subagent_type to the stage agent.
|
|
175
|
-
let agentName = resolveAgentName(next.stage_key, config);
|
|
202
|
+
let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
|
|
176
203
|
// Validate agent availability with fallback chain
|
|
177
204
|
const systemConfig = config;
|
|
178
205
|
// Check both the system config agent map (if present) OR the local agents map passed to the tool
|
|
@@ -193,15 +220,23 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
193
220
|
return false;
|
|
194
221
|
};
|
|
195
222
|
if (!agentExists(agentName)) {
|
|
223
|
+
const originalAgent = agentName;
|
|
196
224
|
console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
|
|
197
|
-
//
|
|
225
|
+
// First fallback: orchestrator
|
|
198
226
|
agentName = config.agents?.orchestrator_name || "Astro";
|
|
199
227
|
if (!agentExists(agentName)) {
|
|
200
|
-
|
|
228
|
+
console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
|
|
229
|
+
// Final fallback: General (guaranteed to exist)
|
|
230
|
+
agentName = "General";
|
|
231
|
+
if (!agentExists(agentName)) {
|
|
232
|
+
throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
|
|
233
|
+
}
|
|
201
234
|
}
|
|
202
235
|
}
|
|
203
236
|
withTx(db, () => {
|
|
204
237
|
startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
|
|
238
|
+
// Log delegation observability
|
|
239
|
+
console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
|
|
205
240
|
});
|
|
206
241
|
if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
|
|
207
242
|
await toasts.show({ title: "Astrocode", message: `Stage started: ${next.stage_key}`, variant: "info" });
|
|
@@ -291,6 +326,11 @@ export function createAstroWorkflowProceedTool(opts) {
|
|
|
291
326
|
lines.push(``, `## Actions`);
|
|
292
327
|
for (const a of actions)
|
|
293
328
|
lines.push(`- ${a}`);
|
|
329
|
+
if (warnings.length > 0) {
|
|
330
|
+
lines.push(``, `## Warnings`);
|
|
331
|
+
for (const w of warnings)
|
|
332
|
+
lines.push(`⚠️ ${w}`);
|
|
333
|
+
}
|
|
294
334
|
return lines.join("\n").trim();
|
|
295
335
|
},
|
|
296
336
|
});
|
|
@@ -4,6 +4,14 @@ import type { RunRow, StageRunRow, StoryRow } from "../state/types";
|
|
|
4
4
|
export declare function getRun(db: SqliteDb, runId: string): RunRow | null;
|
|
5
5
|
export declare function getStory(db: SqliteDb, storyKey: string): StoryRow | null;
|
|
6
6
|
export declare function listStageRuns(db: SqliteDb, runId: string): StageRunRow[];
|
|
7
|
+
/**
|
|
8
|
+
* Check if a context snapshot is stale by comparing DB timestamps
|
|
9
|
+
*/
|
|
10
|
+
export declare function isContextSnapshotStale(snapshotText: string, db: SqliteDb, maxAgeSeconds?: number): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Add staleness indicator to context snapshot if needed
|
|
13
|
+
*/
|
|
14
|
+
export declare function addStalenessIndicator(snapshotText: string, db: SqliteDb, maxAgeSeconds?: number): string;
|
|
7
15
|
export declare function buildContextSnapshot(opts: {
|
|
8
16
|
db: SqliteDb;
|
|
9
17
|
config: AstrocodeConfig;
|
package/dist/workflow/context.js
CHANGED
|
@@ -27,6 +27,49 @@ function statusIcon(status) {
|
|
|
27
27
|
return "⬜";
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Check if a context snapshot is stale by comparing DB timestamps
|
|
32
|
+
*/
|
|
33
|
+
export function isContextSnapshotStale(snapshotText, db, maxAgeSeconds = 300) {
|
|
34
|
+
// Extract run_id from snapshot
|
|
35
|
+
const runIdMatch = snapshotText.match(/Run: `([^`]+)`/);
|
|
36
|
+
if (!runIdMatch)
|
|
37
|
+
return true; // Can't validate without run_id
|
|
38
|
+
const runId = runIdMatch[1];
|
|
39
|
+
// Extract snapshot's claimed updated_at
|
|
40
|
+
const snapshotUpdatedMatch = snapshotText.match(/updated: ([^\)]+)\)/);
|
|
41
|
+
if (!snapshotUpdatedMatch)
|
|
42
|
+
return true; // Fallback to age-based check
|
|
43
|
+
try {
|
|
44
|
+
const snapshotUpdatedAt = snapshotUpdatedMatch[1];
|
|
45
|
+
const currentRun = db.prepare("SELECT updated_at FROM runs WHERE run_id = ?").get(runId);
|
|
46
|
+
if (!currentRun?.updated_at)
|
|
47
|
+
return true; // Run doesn't exist
|
|
48
|
+
// Compare timestamps - if DB is newer than snapshot claims, snapshot is stale
|
|
49
|
+
const snapshotTime = new Date(snapshotUpdatedAt).getTime();
|
|
50
|
+
const currentTime = new Date(currentRun.updated_at).getTime();
|
|
51
|
+
return currentTime > snapshotTime;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Fallback to age-based staleness if parsing fails
|
|
55
|
+
const timestampMatch = snapshotText.match(/generated: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
|
|
56
|
+
if (!timestampMatch)
|
|
57
|
+
return false;
|
|
58
|
+
const generatedAt = new Date(timestampMatch[1]);
|
|
59
|
+
const now = new Date();
|
|
60
|
+
const ageSeconds = (now.getTime() - generatedAt.getTime()) / 1000;
|
|
61
|
+
return ageSeconds > maxAgeSeconds;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Add staleness indicator to context snapshot if needed
|
|
66
|
+
*/
|
|
67
|
+
export function addStalenessIndicator(snapshotText, db, maxAgeSeconds = 300) {
|
|
68
|
+
if (isContextSnapshotStale(snapshotText, db, maxAgeSeconds)) {
|
|
69
|
+
return snapshotText.replace(/# Astrocode Context \(generated: ([^\)]+)\)/, "# Astrocode Context (generated: $1) ⚠️ STALE - DB state has changed");
|
|
70
|
+
}
|
|
71
|
+
return snapshotText;
|
|
72
|
+
}
|
|
30
73
|
export function buildContextSnapshot(opts) {
|
|
31
74
|
const { db, config, run_id, next_action } = opts;
|
|
32
75
|
const run = getRun(db, run_id);
|
|
@@ -35,8 +78,11 @@ export function buildContextSnapshot(opts) {
|
|
|
35
78
|
const story = getStory(db, run.story_key);
|
|
36
79
|
const stageRuns = listStageRuns(db, run_id);
|
|
37
80
|
const lines = [];
|
|
38
|
-
|
|
39
|
-
|
|
81
|
+
// Add timestamps for staleness checking
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const timestamp = now.toISOString();
|
|
84
|
+
lines.push(`# Astrocode Context (generated: ${timestamp})`);
|
|
85
|
+
lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}** (updated: ${run.updated_at})`);
|
|
40
86
|
if (run.current_stage_key)
|
|
41
87
|
lines.push(`- Current stage: \`${run.current_stage_key}\``);
|
|
42
88
|
if (next_action)
|
package/package.json
CHANGED
|
@@ -32,14 +32,67 @@ export function createInjectProvider(opts: {
|
|
|
32
32
|
injectedCache.set(injectId, nowMs);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function getInjectionDiagnostics(nowIso: string, scopeAllowlist: string[], typeAllowlist: string[]): any {
|
|
36
|
+
// Get ALL injects to analyze filtering
|
|
37
|
+
const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
|
|
38
|
+
|
|
39
|
+
let total = allInjects.length;
|
|
40
|
+
let selected = 0;
|
|
41
|
+
let skippedExpired = 0;
|
|
42
|
+
let skippedScope = 0;
|
|
43
|
+
let skippedType = 0;
|
|
44
|
+
let eligibleIds: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (const inject of allInjects) {
|
|
47
|
+
// Check expiration
|
|
48
|
+
if (inject.expires_at && inject.expires_at <= nowIso) {
|
|
49
|
+
skippedExpired++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check scope
|
|
54
|
+
if (!scopeAllowlist.includes(inject.scope)) {
|
|
55
|
+
skippedScope++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check type
|
|
60
|
+
if (!typeAllowlist.includes(inject.type)) {
|
|
61
|
+
skippedType++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// This inject is eligible
|
|
66
|
+
selected++;
|
|
67
|
+
eligibleIds.push(inject.inject_id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
now: nowIso,
|
|
72
|
+
scopes_considered: scopeAllowlist,
|
|
73
|
+
types_considered: typeAllowlist,
|
|
74
|
+
total_injects: total,
|
|
75
|
+
selected_eligible: selected,
|
|
76
|
+
skipped: {
|
|
77
|
+
expired: skippedExpired,
|
|
78
|
+
scope: skippedScope,
|
|
79
|
+
type: skippedType,
|
|
80
|
+
},
|
|
81
|
+
eligible_ids: eligibleIds,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
35
85
|
async function injectEligibleInjects(sessionId: string) {
|
|
36
86
|
const now = nowISO();
|
|
37
87
|
const nowMs = Date.now();
|
|
38
88
|
|
|
39
|
-
// Get
|
|
89
|
+
// Get allowlists from config or defaults
|
|
40
90
|
const scopeAllowlist = config.inject?.scope_allowlist ?? ["repo", "global"];
|
|
41
91
|
const typeAllowlist = config.inject?.type_allowlist ?? ["note", "policy"];
|
|
42
92
|
|
|
93
|
+
// Get diagnostic data
|
|
94
|
+
const diagnostics = getInjectionDiagnostics(now, scopeAllowlist, typeAllowlist);
|
|
95
|
+
|
|
43
96
|
const eligibleInjects = selectEligibleInjects(db, {
|
|
44
97
|
nowIso: now,
|
|
45
98
|
scopeAllowlist,
|
|
@@ -47,11 +100,21 @@ export function createInjectProvider(opts: {
|
|
|
47
100
|
limit: config.inject?.max_per_turn ?? 5,
|
|
48
101
|
});
|
|
49
102
|
|
|
50
|
-
|
|
103
|
+
let injected = 0;
|
|
104
|
+
let skippedDeduped = 0;
|
|
105
|
+
|
|
106
|
+
if (eligibleInjects.length === 0) {
|
|
107
|
+
// Log when no injects are eligible
|
|
108
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=0 skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:0}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
51
111
|
|
|
52
112
|
// Inject each eligible inject, skipping recently injected ones
|
|
53
113
|
for (const inject of eligibleInjects) {
|
|
54
|
-
if (shouldSkipInject(inject.inject_id, nowMs))
|
|
114
|
+
if (shouldSkipInject(inject.inject_id, nowMs)) {
|
|
115
|
+
skippedDeduped++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
55
118
|
|
|
56
119
|
// Format as injection message
|
|
57
120
|
const formattedText = `[Inject: ${inject.title}]\n\n${inject.body_md}`;
|
|
@@ -63,8 +126,12 @@ export function createInjectProvider(opts: {
|
|
|
63
126
|
agent: "Astrocode"
|
|
64
127
|
});
|
|
65
128
|
|
|
129
|
+
injected++;
|
|
66
130
|
markInjected(inject.inject_id, nowMs);
|
|
67
131
|
}
|
|
132
|
+
|
|
133
|
+
// Log diagnostic summary
|
|
134
|
+
console.log(`[Astrocode:inject] ${now} selected=${diagnostics.selected_eligible} injected=${injected} skipped={expired:${diagnostics.skipped.expired} scope:${diagnostics.skipped.scope} type:${diagnostics.skipped.type} deduped:${skippedDeduped}}`);
|
|
68
135
|
}
|
|
69
136
|
|
|
70
137
|
// Public hook handlers
|
package/src/state/schema.ts
CHANGED
|
@@ -235,6 +235,9 @@ CREATE INDEX IF NOT EXISTS idx_artifacts_run_stage ON artifacts(run_id, stage_ke
|
|
|
235
235
|
CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, created_at DESC);
|
|
236
236
|
CREATE INDEX IF NOT EXISTS idx_tool_runs_run ON tool_runs(run_id, created_at DESC);
|
|
237
237
|
CREATE INDEX IF NOT EXISTS idx_injects_scope_priority ON injects(scope, priority DESC, created_at DESC);
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_injects_scope_type_priority_updated ON injects(scope, type, priority DESC, updated_at DESC);
|
|
239
|
+
CREATE INDEX IF NOT EXISTS idx_injects_expires ON injects(expires_at) WHERE expires_at IS NOT NULL;
|
|
240
|
+
CREATE INDEX IF NOT EXISTS idx_injects_sha256 ON injects(sha256) WHERE sha256 IS NOT NULL;
|
|
238
241
|
|
|
239
242
|
-- Stronger invariants (SQLite partial indexes)
|
|
240
243
|
-- Only one run may be 'running' at a time (single-repo harness by default).
|
package/src/tools/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { createAstroRunGetTool, createAstroRunAbortTool } from "./run";
|
|
|
10
10
|
import { createAstroWorkflowProceedTool } from "./workflow";
|
|
11
11
|
import { createAstroStageStartTool, createAstroStageCompleteTool, createAstroStageFailTool, createAstroStageResetTool } from "./stage";
|
|
12
12
|
import { createAstroArtifactPutTool, createAstroArtifactListTool, createAstroArtifactGetTool } from "./artifacts";
|
|
13
|
-
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool } from "./injects";
|
|
13
|
+
import { createAstroInjectPutTool, createAstroInjectListTool, createAstroInjectSearchTool, createAstroInjectGetTool, createAstroInjectEligibleTool, createAstroInjectDebugDueTool } from "./injects";
|
|
14
14
|
import { createAstroRepairTool } from "./repair";
|
|
15
15
|
|
|
16
16
|
import { AgentConfig } from "@opencode-ai/sdk";
|
|
@@ -50,6 +50,8 @@ export function createAstroTools(opts: { ctx: any; config: AstrocodeConfig; db:
|
|
|
50
50
|
tools.astro_inject_list = createAstroInjectListTool({ ctx, config, db });
|
|
51
51
|
tools.astro_inject_search = createAstroInjectSearchTool({ ctx, config, db });
|
|
52
52
|
tools.astro_inject_get = createAstroInjectGetTool({ ctx, config, db });
|
|
53
|
+
tools.astro_inject_eligible = createAstroInjectEligibleTool({ ctx, config, db });
|
|
54
|
+
tools.astro_inject_debug_due = createAstroInjectDebugDueTool({ ctx, config, db });
|
|
53
55
|
tools.astro_repair = createAstroRepairTool({ ctx, config, db });
|
|
54
56
|
} else {
|
|
55
57
|
// Limited mode tools - provide helpful messages instead of failing
|
package/src/tools/injects.ts
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool";
|
|
2
2
|
import type { AstrocodeConfig } from "../config/schema";
|
|
3
3
|
import type { SqliteDb } from "../state/db";
|
|
4
|
+
import { withTx } from "../state/db";
|
|
4
5
|
import { nowISO } from "../shared/time";
|
|
5
6
|
import { sha256Hex } from "../shared/hash";
|
|
6
7
|
|
|
8
|
+
const VALID_INJECT_TYPES = ['note', 'policy', 'reminder', 'debug'] as const;
|
|
9
|
+
type InjectType = typeof VALID_INJECT_TYPES[number];
|
|
10
|
+
|
|
11
|
+
function validateInjectType(type: string): InjectType {
|
|
12
|
+
if (!VALID_INJECT_TYPES.includes(type as InjectType)) {
|
|
13
|
+
throw new Error(`Invalid inject type "${type}". Must be one of: ${VALID_INJECT_TYPES.join(', ')}`);
|
|
14
|
+
}
|
|
15
|
+
return type as InjectType;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateTimestamp(timestamp: string | null): string | null {
|
|
19
|
+
if (!timestamp) return null;
|
|
20
|
+
|
|
21
|
+
// Check if it's a valid ISO 8601 timestamp with Z suffix
|
|
22
|
+
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
|
23
|
+
if (!isoRegex.test(timestamp)) {
|
|
24
|
+
throw new Error(`Invalid timestamp format. Expected ISO 8601 UTC with Z suffix (e.g., "2026-01-23T13:05:19.000Z"), got: "${timestamp}"`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Additional validation: ensure it's parseable and represents a valid date
|
|
28
|
+
const parsed = new Date(timestamp);
|
|
29
|
+
if (isNaN(parsed.getTime())) {
|
|
30
|
+
throw new Error(`Invalid timestamp: "${timestamp}" does not represent a valid date`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return timestamp;
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
function newInjectId(): string {
|
|
8
37
|
return `inj_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
9
38
|
}
|
|
@@ -29,20 +58,39 @@ export function createAstroInjectPutTool(opts: { ctx: any; config: AstrocodeConf
|
|
|
29
58
|
const now = nowISO();
|
|
30
59
|
const sha = sha256Hex(body_md);
|
|
31
60
|
|
|
32
|
-
|
|
61
|
+
// Validate inputs
|
|
62
|
+
const validatedType = validateInjectType(type);
|
|
63
|
+
const validatedExpiresAt = validateTimestamp(expires_at);
|
|
33
64
|
|
|
34
|
-
|
|
35
|
-
db.prepare(
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
65
|
+
return withTx(db, () => {
|
|
66
|
+
const existing = db.prepare("SELECT inject_id FROM injects WHERE inject_id=?").get(id) as any;
|
|
67
|
+
|
|
68
|
+
if (existing) {
|
|
69
|
+
// Use INSERT ... ON CONFLICT for atomic updates
|
|
70
|
+
db.prepare(`
|
|
71
|
+
INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
73
|
+
ON CONFLICT(inject_id) DO UPDATE SET
|
|
74
|
+
type=excluded.type,
|
|
75
|
+
title=excluded.title,
|
|
76
|
+
body_md=excluded.body_md,
|
|
77
|
+
tags_json=excluded.tags_json,
|
|
78
|
+
scope=excluded.scope,
|
|
79
|
+
source=excluded.source,
|
|
80
|
+
priority=excluded.priority,
|
|
81
|
+
expires_at=excluded.expires_at,
|
|
82
|
+
sha256=excluded.sha256,
|
|
83
|
+
updated_at=excluded.updated_at
|
|
84
|
+
`).run(id, type, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
|
|
85
|
+
return `✅ Updated inject ${id}: ${title}`;
|
|
86
|
+
}
|
|
40
87
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
db.prepare(
|
|
89
|
+
"INSERT INTO injects (inject_id, type, title, body_md, tags_json, scope, source, priority, expires_at, sha256, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
90
|
+
).run(id, validatedType, title, body_md, tags_json, scope, source, priority, validatedExpiresAt, sha, now, now);
|
|
44
91
|
|
|
45
|
-
|
|
92
|
+
return `✅ Created inject ${id}: ${title}`;
|
|
93
|
+
});
|
|
46
94
|
},
|
|
47
95
|
});
|
|
48
96
|
}
|
|
@@ -174,3 +222,91 @@ export function createAstroInjectEligibleTool(opts: { ctx: any; config: Astrocod
|
|
|
174
222
|
},
|
|
175
223
|
});
|
|
176
224
|
}
|
|
225
|
+
|
|
226
|
+
export function createAstroInjectDebugDueTool(opts: { ctx: any; config: AstrocodeConfig; db: SqliteDb }): ToolDefinition {
|
|
227
|
+
const { db } = opts;
|
|
228
|
+
|
|
229
|
+
return tool({
|
|
230
|
+
description: "Debug: show comprehensive injection diagnostics - why injects were selected/skipped.",
|
|
231
|
+
args: {
|
|
232
|
+
scopes_json: tool.schema.string().default('["repo","global"]'),
|
|
233
|
+
types_json: tool.schema.string().default('["note","policy"]'),
|
|
234
|
+
},
|
|
235
|
+
execute: async ({ scopes_json, types_json }) => {
|
|
236
|
+
const now = nowISO();
|
|
237
|
+
const scopes = JSON.parse(scopes_json) as string[];
|
|
238
|
+
const types = JSON.parse(types_json) as string[];
|
|
239
|
+
|
|
240
|
+
// Get ALL injects to analyze filtering
|
|
241
|
+
const allInjects = db.prepare("SELECT * FROM injects").all() as any[];
|
|
242
|
+
|
|
243
|
+
let total = allInjects.length;
|
|
244
|
+
let selected = 0;
|
|
245
|
+
let skippedExpired = 0;
|
|
246
|
+
let skippedScope = 0;
|
|
247
|
+
let skippedType = 0;
|
|
248
|
+
const excludedReasons: any[] = [];
|
|
249
|
+
const selectedInjects: any[] = [];
|
|
250
|
+
|
|
251
|
+
for (const inject of allInjects) {
|
|
252
|
+
const reasons: string[] = [];
|
|
253
|
+
|
|
254
|
+
// Check expiration
|
|
255
|
+
if (inject.expires_at && inject.expires_at <= now) {
|
|
256
|
+
reasons.push("expired");
|
|
257
|
+
skippedExpired++;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Check scope
|
|
261
|
+
if (!scopes.includes(inject.scope)) {
|
|
262
|
+
reasons.push("scope");
|
|
263
|
+
skippedScope++;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check type
|
|
267
|
+
if (!types.includes(inject.type)) {
|
|
268
|
+
reasons.push("type");
|
|
269
|
+
skippedType++;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (reasons.length > 0) {
|
|
273
|
+
excludedReasons.push({
|
|
274
|
+
inject_id: inject.inject_id,
|
|
275
|
+
title: inject.title,
|
|
276
|
+
reasons: reasons,
|
|
277
|
+
scope: inject.scope,
|
|
278
|
+
type: inject.type,
|
|
279
|
+
expires_at: inject.expires_at,
|
|
280
|
+
});
|
|
281
|
+
} else {
|
|
282
|
+
selected++;
|
|
283
|
+
selectedInjects.push({
|
|
284
|
+
inject_id: inject.inject_id,
|
|
285
|
+
title: inject.title,
|
|
286
|
+
scope: inject.scope,
|
|
287
|
+
type: inject.type,
|
|
288
|
+
expires_at: inject.expires_at,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return JSON.stringify({
|
|
294
|
+
now,
|
|
295
|
+
scopes_considered: scopes,
|
|
296
|
+
types_considered: types,
|
|
297
|
+
summary: {
|
|
298
|
+
total_injects: total,
|
|
299
|
+
selected_eligible: selected,
|
|
300
|
+
excluded_total: total - selected,
|
|
301
|
+
skipped_breakdown: {
|
|
302
|
+
expired: skippedExpired,
|
|
303
|
+
scope: skippedScope,
|
|
304
|
+
type: skippedType,
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
selected_injects: selectedInjects,
|
|
308
|
+
excluded_injects: excludedReasons,
|
|
309
|
+
}, null, 2);
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
}
|
package/src/tools/workflow.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { debug } from "../shared/log";
|
|
|
13
13
|
import { createToastManager } from "../ui/toasts";
|
|
14
14
|
|
|
15
15
|
// Agent name mapping for case-sensitive resolution
|
|
16
|
-
const STAGE_TO_AGENT_MAP: Record<string, string> = {
|
|
16
|
+
export const STAGE_TO_AGENT_MAP: Record<string, string> = {
|
|
17
17
|
frame: "Frame",
|
|
18
18
|
plan: "Plan",
|
|
19
19
|
spec: "Spec",
|
|
@@ -23,13 +23,40 @@ const STAGE_TO_AGENT_MAP: Record<string, string> = {
|
|
|
23
23
|
close: "Close"
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig): string {
|
|
26
|
+
export function resolveAgentName(stageKey: StageKey, config: AstrocodeConfig, agents?: any, warnings?: string[]): string {
|
|
27
27
|
// Use configurable agent names from config, fallback to hardcoded map, then General
|
|
28
28
|
const agentNames = config.agents?.stage_agent_names;
|
|
29
|
+
let candidate: string;
|
|
30
|
+
|
|
29
31
|
if (agentNames && agentNames[stageKey]) {
|
|
30
|
-
|
|
32
|
+
candidate = agentNames[stageKey];
|
|
33
|
+
} else {
|
|
34
|
+
candidate = STAGE_TO_AGENT_MAP[stageKey] || "General";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Validate that the agent actually exists in the registry
|
|
38
|
+
if (agents && !agents[candidate]) {
|
|
39
|
+
const warning = `Agent "${candidate}" not found in registry for stage "${stageKey}". Falling back to General.`;
|
|
40
|
+
if (warnings) {
|
|
41
|
+
warnings.push(warning);
|
|
42
|
+
} else {
|
|
43
|
+
console.warn(`[Astrocode] ${warning}`);
|
|
44
|
+
}
|
|
45
|
+
candidate = "General";
|
|
31
46
|
}
|
|
32
|
-
|
|
47
|
+
|
|
48
|
+
// Final guard: ensure General exists, fallback to built-in "general" if not
|
|
49
|
+
if (agents && !agents[candidate]) {
|
|
50
|
+
const finalWarning = `Critical: General agent not found in registry. Falling back to built-in "general".`;
|
|
51
|
+
if (warnings) {
|
|
52
|
+
warnings.push(finalWarning);
|
|
53
|
+
} else {
|
|
54
|
+
console.warn(`[Astrocode] ${finalWarning}`);
|
|
55
|
+
}
|
|
56
|
+
return "general"; // built-in, guaranteed by OpenCode
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return candidate;
|
|
33
60
|
}
|
|
34
61
|
|
|
35
62
|
function stageGoal(stage: StageKey, cfg: AstrocodeConfig): string {
|
|
@@ -123,6 +150,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
123
150
|
const steps = Math.min(max_steps, config.workflow.loop_max_steps_hard_cap);
|
|
124
151
|
|
|
125
152
|
const actions: string[] = [];
|
|
153
|
+
const warnings: string[] = [];
|
|
126
154
|
const startedAt = nowISO();
|
|
127
155
|
|
|
128
156
|
for (let i = 0; i < steps; i++) {
|
|
@@ -212,7 +240,7 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
212
240
|
const story = db.prepare("SELECT * FROM stories WHERE story_key=?").get(run.story_key) as any;
|
|
213
241
|
|
|
214
242
|
// Mark stage started + set subagent_type to the stage agent.
|
|
215
|
-
let agentName = resolveAgentName(next.stage_key, config);
|
|
243
|
+
let agentName = resolveAgentName(next.stage_key, config, agents, warnings);
|
|
216
244
|
|
|
217
245
|
// Validate agent availability with fallback chain
|
|
218
246
|
const systemConfig = config as any;
|
|
@@ -234,17 +262,26 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
234
262
|
return false;
|
|
235
263
|
};
|
|
236
264
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
265
|
+
if (!agentExists(agentName)) {
|
|
266
|
+
const originalAgent = agentName;
|
|
267
|
+
console.warn(`[Astrocode] Agent ${agentName} not found in config. Falling back to orchestrator.`);
|
|
268
|
+
// First fallback: orchestrator
|
|
269
|
+
agentName = config.agents?.orchestrator_name || "Astro";
|
|
270
|
+
if (!agentExists(agentName)) {
|
|
271
|
+
console.warn(`[Astrocode] Orchestrator ${agentName} not available. Falling back to General.`);
|
|
272
|
+
// Final fallback: General (guaranteed to exist)
|
|
273
|
+
agentName = "General";
|
|
274
|
+
if (!agentExists(agentName)) {
|
|
275
|
+
throw new Error(`Critical: No agents available for delegation. Primary: ${originalAgent}, Orchestrator: ${config.agents?.orchestrator_name || "Astro"}, General: unavailable`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
245
279
|
|
|
246
280
|
withTx(db, () => {
|
|
247
281
|
startStage(db, active.run_id, next.stage_key, { subagent_type: agentName });
|
|
282
|
+
|
|
283
|
+
// Log delegation observability
|
|
284
|
+
console.log(`[Astrocode:delegate] run_id=${active.run_id} stage=${next.stage_key} agent=${agentName} fallback=${agentName !== resolveAgentName(next.stage_key, config, agents) ? 'yes' : 'no'}`);
|
|
248
285
|
});
|
|
249
286
|
|
|
250
287
|
if (config.ui.toasts.enabled && config.ui.toasts.show_stage_started) {
|
|
@@ -355,6 +392,11 @@ export function createAstroWorkflowProceedTool(opts: { ctx: any; config: Astroco
|
|
|
355
392
|
lines.push(``, `## Actions`);
|
|
356
393
|
for (const a of actions) lines.push(`- ${a}`);
|
|
357
394
|
|
|
395
|
+
if (warnings.length > 0) {
|
|
396
|
+
lines.push(``, `## Warnings`);
|
|
397
|
+
for (const w of warnings) lines.push(`⚠️ ${w}`);
|
|
398
|
+
}
|
|
399
|
+
|
|
358
400
|
return lines.join("\n").trim();
|
|
359
401
|
},
|
|
360
402
|
});
|
package/src/workflow/context.ts
CHANGED
|
@@ -35,6 +35,57 @@ function statusIcon(status: string): string {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Check if a context snapshot is stale by comparing DB timestamps
|
|
40
|
+
*/
|
|
41
|
+
export function isContextSnapshotStale(snapshotText: string, db: SqliteDb, maxAgeSeconds: number = 300): boolean {
|
|
42
|
+
// Extract run_id from snapshot
|
|
43
|
+
const runIdMatch = snapshotText.match(/Run: `([^`]+)`/);
|
|
44
|
+
if (!runIdMatch) return true; // Can't validate without run_id
|
|
45
|
+
|
|
46
|
+
const runId = runIdMatch[1];
|
|
47
|
+
|
|
48
|
+
// Extract snapshot's claimed updated_at
|
|
49
|
+
const snapshotUpdatedMatch = snapshotText.match(/updated: ([^\)]+)\)/);
|
|
50
|
+
if (!snapshotUpdatedMatch) return true; // Fallback to age-based check
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const snapshotUpdatedAt = snapshotUpdatedMatch[1];
|
|
54
|
+
const currentRun = db.prepare("SELECT updated_at FROM runs WHERE run_id = ?").get(runId) as { updated_at?: string };
|
|
55
|
+
|
|
56
|
+
if (!currentRun?.updated_at) return true; // Run doesn't exist
|
|
57
|
+
|
|
58
|
+
// Compare timestamps - if DB is newer than snapshot claims, snapshot is stale
|
|
59
|
+
const snapshotTime = new Date(snapshotUpdatedAt).getTime();
|
|
60
|
+
const currentTime = new Date(currentRun.updated_at).getTime();
|
|
61
|
+
|
|
62
|
+
return currentTime > snapshotTime;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// Fallback to age-based staleness if parsing fails
|
|
65
|
+
const timestampMatch = snapshotText.match(/generated: (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)/);
|
|
66
|
+
if (!timestampMatch) return false;
|
|
67
|
+
|
|
68
|
+
const generatedAt = new Date(timestampMatch[1]);
|
|
69
|
+
const now = new Date();
|
|
70
|
+
const ageSeconds = (now.getTime() - generatedAt.getTime()) / 1000;
|
|
71
|
+
|
|
72
|
+
return ageSeconds > maxAgeSeconds;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Add staleness indicator to context snapshot if needed
|
|
78
|
+
*/
|
|
79
|
+
export function addStalenessIndicator(snapshotText: string, db: SqliteDb, maxAgeSeconds: number = 300): string {
|
|
80
|
+
if (isContextSnapshotStale(snapshotText, db, maxAgeSeconds)) {
|
|
81
|
+
return snapshotText.replace(
|
|
82
|
+
/# Astrocode Context \(generated: ([^\)]+)\)/,
|
|
83
|
+
"# Astrocode Context (generated: $1) ⚠️ STALE - DB state has changed"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return snapshotText;
|
|
87
|
+
}
|
|
88
|
+
|
|
38
89
|
export function buildContextSnapshot(opts: {
|
|
39
90
|
db: SqliteDb;
|
|
40
91
|
config: AstrocodeConfig;
|
|
@@ -52,8 +103,11 @@ export function buildContextSnapshot(opts: {
|
|
|
52
103
|
|
|
53
104
|
const lines: string[] = [];
|
|
54
105
|
|
|
55
|
-
|
|
56
|
-
|
|
106
|
+
// Add timestamps for staleness checking
|
|
107
|
+
const now = new Date();
|
|
108
|
+
const timestamp = now.toISOString();
|
|
109
|
+
lines.push(`# Astrocode Context (generated: ${timestamp})`);
|
|
110
|
+
lines.push(`- Run: \`${run.run_id}\` Status: **${run.status}** (updated: ${run.updated_at})`);
|
|
57
111
|
if (run.current_stage_key) lines.push(`- Current stage: \`${run.current_stage_key}\``);
|
|
58
112
|
if (next_action) lines.push(`- Next action: ${next_action}`);
|
|
59
113
|
|
|
@@ -2,6 +2,7 @@ import type { AstrocodeConfig } from "../config/schema";
|
|
|
2
2
|
import { sha256Hex } from "../shared/hash";
|
|
3
3
|
import { clampChars, normalizeNewlines } from "../shared/text";
|
|
4
4
|
import type { StageKey } from "../state/types";
|
|
5
|
+
import { addStalenessIndicator } from "./context";
|
|
5
6
|
|
|
6
7
|
export type DirectiveKind = "continue" | "stage" | "blocked" | "repair";
|
|
7
8
|
|
|
@@ -77,8 +78,8 @@ export function buildBlockedDirective(opts: {
|
|
|
77
78
|
``,
|
|
78
79
|
`Question: ${question}`,
|
|
79
80
|
``,
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
`Context snapshot:`,
|
|
82
|
+
context_snapshot_md.trim(),
|
|
82
83
|
].join("\n")
|
|
83
84
|
).trim();
|
|
84
85
|
|