@yeaft/webchat-agent 1.0.202 → 1.0.204
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/engine.js +27 -5
- package/yeaft/memory/ams.js +12 -5
- package/yeaft/memory/prompt-cleanup.js +142 -0
- package/yeaft/work-center/runner.js +15 -0
- package/yeaft/work-center/store.js +110 -10
- package/yeaft/work-center/watcher.js +11 -0
- package/yeaft/work-center/workflow.js +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.204"}
|
package/package.json
CHANGED
package/yeaft/engine.js
CHANGED
|
@@ -32,7 +32,7 @@ import { archiveTurn } from './archive/turn-archive.js';
|
|
|
32
32
|
import { archiveToolResults } from './archive/tool-results.js';
|
|
33
33
|
import { readSummary as readScopeSummary } from './memory/store.js';
|
|
34
34
|
import { runAdjust } from './memory/adjust.js';
|
|
35
|
-
import { cleanMemoryPromptText } from './memory/prompt-cleanup.js';
|
|
35
|
+
import { cleanMemoryPromptText, isMemoryPromptRelevant } from './memory/prompt-cleanup.js';
|
|
36
36
|
import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
|
|
37
37
|
import { runStopHooks } from './stop-hooks.js';
|
|
38
38
|
import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
|
|
@@ -388,6 +388,20 @@ export function shouldAllowGroupReflection({
|
|
|
388
388
|
* }} args
|
|
389
389
|
* @returns {Array<{scope: string, summary: string}>}
|
|
390
390
|
*/
|
|
391
|
+
export function selectResidentTopicScopes(topicScopes, recallEntries, userMsg = '') {
|
|
392
|
+
const recalledTopicScopes = new Set((recallEntries || [])
|
|
393
|
+
.map(entry => entry?.scope)
|
|
394
|
+
.filter(scope => typeof scope === 'string' && /^sessions\/[^/]+\/topic\//.test(scope)));
|
|
395
|
+
return (Array.isArray(topicScopes) ? topicScopes : [])
|
|
396
|
+
.filter(scope => recalledTopicScopes.has(scope) || isTopicScopeRelevant(scope, userMsg));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isTopicScopeRelevant(scope, userMsg) {
|
|
400
|
+
if (!userMsg || typeof scope !== 'string') return false;
|
|
401
|
+
const label = scope.replace(/^sessions\/[^/]+\/topic\//, '').replace(/[/-]+/g, ' ');
|
|
402
|
+
return isMemoryPromptRelevant(label, userMsg);
|
|
403
|
+
}
|
|
404
|
+
|
|
391
405
|
export function buildResidentEntries(args) {
|
|
392
406
|
const summaries = (args && args.summaries) || {};
|
|
393
407
|
const out = [];
|
|
@@ -873,6 +887,7 @@ export class Engine {
|
|
|
873
887
|
* ownVpId?: string|null,
|
|
874
888
|
* summaries: { user?: string, session?: string, vp?: string },
|
|
875
889
|
* recallEntries: object[],
|
|
890
|
+
* userMsg?: string,
|
|
876
891
|
* }} args
|
|
877
892
|
* @returns {{
|
|
878
893
|
* ams: import('./memory/ams.js').ActiveMemorySet,
|
|
@@ -912,7 +927,7 @@ export class Engine {
|
|
|
912
927
|
ams.setOnDemand(segs);
|
|
913
928
|
|
|
914
929
|
// (c) Snapshot — render the AMS layers as a single prompt block.
|
|
915
|
-
const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en');
|
|
930
|
+
const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en', args.userMsg || '');
|
|
916
931
|
|
|
917
932
|
const scopes = buildRelevantScopes({
|
|
918
933
|
sessionId: args.sessionId,
|
|
@@ -929,10 +944,11 @@ export class Engine {
|
|
|
929
944
|
*
|
|
930
945
|
* @param {import('./memory/ams.js').ActiveMemorySet} ams
|
|
931
946
|
* @param {string} [language]
|
|
947
|
+
* @param {string} [userMsg]
|
|
932
948
|
* @returns {string}
|
|
933
949
|
*/
|
|
934
|
-
#renderAmsSnapshot(ams, language = 'en') {
|
|
935
|
-
const snap = ams.snapshot();
|
|
950
|
+
#renderAmsSnapshot(ams, language = 'en', userMsg = '') {
|
|
951
|
+
const snap = ams.snapshot({ userMsg });
|
|
936
952
|
if (!snap) return '';
|
|
937
953
|
const parts = [];
|
|
938
954
|
if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
|
|
@@ -1906,6 +1922,11 @@ export class Engine {
|
|
|
1906
1922
|
if (recallEntryCount > 0) {
|
|
1907
1923
|
yield { type: 'recall', entryCount: recallEntryCount, cached: false, threadId };
|
|
1908
1924
|
}
|
|
1925
|
+
const topicScopesForResident = selectResidentTopicScopes(
|
|
1926
|
+
topicScopesForMemory,
|
|
1927
|
+
recallResult?.entries || [],
|
|
1928
|
+
prompt,
|
|
1929
|
+
);
|
|
1909
1930
|
|
|
1910
1931
|
// Layer-A summaries — same scopes AMS Resident will surface, loaded
|
|
1911
1932
|
// here so we can pass them into #prepareAms. (Rolling per-scope
|
|
@@ -1916,7 +1937,7 @@ export class Engine {
|
|
|
1916
1937
|
? vpPersona.vpId
|
|
1917
1938
|
: (typeof senderVpId === 'string' ? senderVpId : undefined),
|
|
1918
1939
|
language: this.#config.language || 'en',
|
|
1919
|
-
topicScopes:
|
|
1940
|
+
topicScopes: topicScopesForResident,
|
|
1920
1941
|
});
|
|
1921
1942
|
|
|
1922
1943
|
// ─── AMS: populate + snapshot ───────────────────────────────
|
|
@@ -1935,6 +1956,7 @@ export class Engine {
|
|
|
1935
1956
|
ownVpId: ownVpIdForAms,
|
|
1936
1957
|
summaries,
|
|
1937
1958
|
recallEntries: recallResult ? (recallResult.entries || []) : [],
|
|
1959
|
+
userMsg: prompt,
|
|
1938
1960
|
});
|
|
1939
1961
|
if (amsContext && amsContext.snapshotBlock) {
|
|
1940
1962
|
memoryInjection = amsContext.snapshotBlock;
|
package/yeaft/memory/ams.js
CHANGED
|
@@ -17,7 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { approxTokens } from './budget.js';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
cleanMemoryPromptText,
|
|
22
|
+
filterMemoryPromptTextForPrompt,
|
|
23
|
+
isDuplicateMemoryText,
|
|
24
|
+
rememberMemoryText,
|
|
25
|
+
} from './prompt-cleanup.js';
|
|
21
26
|
import { isVpForeign } from './store.js';
|
|
22
27
|
|
|
23
28
|
const RECENT_DEFAULT_CAPACITY = 64;
|
|
@@ -144,16 +149,18 @@ export class ActiveMemorySet {
|
|
|
144
149
|
* system prompt. Each layer is greedily packed within its budget;
|
|
145
150
|
* overflow is dropped from this turn but not from disk.
|
|
146
151
|
*
|
|
152
|
+
* @param {{ userMsg?: string }} [opts]
|
|
147
153
|
* @returns {AmsSnapshot}
|
|
148
154
|
*/
|
|
149
|
-
snapshot() {
|
|
155
|
+
snapshot(opts = {}) {
|
|
156
|
+
const userMsg = typeof opts.userMsg === 'string' ? opts.userMsg : '';
|
|
150
157
|
const seenPromptText = new Set();
|
|
151
158
|
|
|
152
159
|
// Resident: pack scopes by priority order (caller provides via insert
|
|
153
160
|
// order — current group's own vp first, then user, etc.).
|
|
154
161
|
const { picked: resPicked, cost: resCost } = pickMemoryItems({
|
|
155
162
|
items: [...this._resident.entries()].map(([scope, summary]) => ({
|
|
156
|
-
scope, summary:
|
|
163
|
+
scope, summary: filterMemoryPromptTextForPrompt(summary, userMsg),
|
|
157
164
|
})),
|
|
158
165
|
budget: this.budget.resident,
|
|
159
166
|
seen: seenPromptText,
|
|
@@ -165,7 +172,7 @@ export class ActiveMemorySet {
|
|
|
165
172
|
const { picked: recPicked, cost: recCost } = pickMemoryItems({
|
|
166
173
|
items: [...this._recent.values()]
|
|
167
174
|
.reverse()
|
|
168
|
-
.map(e => ({ ...e.seg, body:
|
|
175
|
+
.map(e => ({ ...e.seg, body: filterMemoryPromptTextForPrompt(e.seg?.body, userMsg) })),
|
|
169
176
|
budget: this.budget.recent,
|
|
170
177
|
seen: seenPromptText,
|
|
171
178
|
textOf: seg => seg.body,
|
|
@@ -175,7 +182,7 @@ export class ActiveMemorySet {
|
|
|
175
182
|
// OnDemand: insertion order from caller (already FTS-ranked).
|
|
176
183
|
const { picked: odPicked, cost: odCost } = pickMemoryItems({
|
|
177
184
|
items: [...this._onDemand.values()]
|
|
178
|
-
.map(seg => ({ ...seg, body:
|
|
185
|
+
.map(seg => ({ ...seg, body: filterMemoryPromptTextForPrompt(seg?.body, userMsg) })),
|
|
179
186
|
budget: this.budget.onDemand,
|
|
180
187
|
seen: seenPromptText,
|
|
181
188
|
textOf: seg => seg.body,
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
const DREAM_STATE_BLOCK_RE = /<!--\s*dream-state\s*-->[\s\S]*?<!--\s*\/dream-state\s*-->/gi;
|
|
10
10
|
|
|
11
|
+
const TRANSIENT_MEMORY_RE = /\b(work\s*item|current\s+(?:state|work|task)|in[_ -]?progress|todo|next\s+step|blocker|blocked|pr\s*#?\d+|pull\s+request|review|merge\s+commit|release\s+tag|tag\s+v\d|v\d+\.\d+\.\d+)\b|(?:工作项|当前(?:状态|任务|工作)|正在|待办|下一步|阻塞|评审|合并|发布|标签|已推|已合并|已完成)/i;
|
|
12
|
+
const ASCII_WORD_RE = /[a-z0-9_]{3,}/gi;
|
|
13
|
+
const CJK_RUN_RE = /[\u4e00-\u9fff]{2,}/g;
|
|
14
|
+
const MARKDOWN_LIST_ITEM_RE = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
|
|
15
|
+
const COMMON_INTENT_TOKENS = new Set([
|
|
16
|
+
'current', 'state', 'work', 'task', 'item', 'items', 'todo', 'todos',
|
|
17
|
+
'next', 'step', 'steps', 'done', 'completed', 'finish', 'finished',
|
|
18
|
+
'merge', 'merged', 'review', 'reviewed', 'release', 'tag', 'tags',
|
|
19
|
+
'pr', 'pull', 'request', 'issue', 'fix', 'feat', 'test', 'tests',
|
|
20
|
+
'dream', 'memory', 'session', 'topic', 'status', 'blocker', 'blocked',
|
|
21
|
+
'当前', '状态', '任务', '工作', '工作项', '待办', '下一步', '完成', '已完成',
|
|
22
|
+
'合并', '评审', '发布', '标签', '记忆', '主题', '阻塞', '正在',
|
|
23
|
+
]);
|
|
24
|
+
|
|
11
25
|
/**
|
|
12
26
|
* Remove Dream scheduler metadata blocks from memory text.
|
|
13
27
|
*
|
|
@@ -31,6 +45,134 @@ export function cleanMemoryPromptText(text) {
|
|
|
31
45
|
.trim();
|
|
32
46
|
}
|
|
33
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Whether a memory item describes short-lived execution state rather than a
|
|
50
|
+
* stable preference / project fact. Transient memory is still valid on disk, but
|
|
51
|
+
* should only enter the prompt when it is related to the current user turn.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} text
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function isTransientMemoryText(text) {
|
|
57
|
+
return TRANSIENT_MEMORY_RE.test(cleanMemoryPromptText(text));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Conservative prompt relevance gate for transient Dream memories. Stable
|
|
62
|
+
* memories are handled by the caller; this function only answers whether a
|
|
63
|
+
* known-transient item has lexical overlap with the current user request.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} memoryText
|
|
66
|
+
* @param {string} userText
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isTransientMemoryRelevant(memoryText, userText) {
|
|
70
|
+
return isMemoryPromptRelevant(memoryText, userText);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Conservative lexical relevance check used only at prompt assembly time. It is
|
|
75
|
+
* deliberately not semantic clustering: if there is no concrete token overlap,
|
|
76
|
+
* the memory should win again through FTS when the user asks for it explicitly.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} memoryText
|
|
79
|
+
* @param {string} userText
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function isMemoryPromptRelevant(memoryText, userText) {
|
|
83
|
+
const memoryTokens = promptRelevanceTokens(memoryText);
|
|
84
|
+
const userTokens = promptRelevanceTokens(userText);
|
|
85
|
+
if (memoryTokens.size === 0 || userTokens.size === 0) return false;
|
|
86
|
+
for (const token of memoryTokens) {
|
|
87
|
+
if (userTokens.has(token)) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Drop irrelevant transient paragraphs while preserving stable memory text. This
|
|
94
|
+
* keeps disk memory intact; it only trims what enters the current prompt.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} text
|
|
97
|
+
* @param {string} userText
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function filterMemoryPromptTextForPrompt(text, userText) {
|
|
101
|
+
const cleaned = cleanMemoryPromptText(text);
|
|
102
|
+
if (!cleaned || !userText) return cleaned;
|
|
103
|
+
const kept = [];
|
|
104
|
+
for (const chunk of splitMemoryPromptChunks(cleaned)) {
|
|
105
|
+
if (!isTransientMemoryText(chunk) || isTransientMemoryRelevant(chunk, userText)) {
|
|
106
|
+
kept.push(chunk);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return joinMemoryPromptChunks(kept).trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function splitMemoryPromptChunks(text) {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
for (const block of String(text || '').split(/\n{2,}/)) {
|
|
115
|
+
const trimmed = block.trim();
|
|
116
|
+
if (!trimmed) continue;
|
|
117
|
+
chunks.push(...splitListBlock(trimmed));
|
|
118
|
+
}
|
|
119
|
+
return chunks;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function splitListBlock(block) {
|
|
123
|
+
const lines = block.split('\n');
|
|
124
|
+
if (!lines.some(line => MARKDOWN_LIST_ITEM_RE.test(line))) return [block];
|
|
125
|
+
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let current = [];
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
if (MARKDOWN_LIST_ITEM_RE.test(line) && current.length > 0) {
|
|
130
|
+
chunks.push(current.join('\n').trim());
|
|
131
|
+
current = [];
|
|
132
|
+
}
|
|
133
|
+
current.push(line);
|
|
134
|
+
}
|
|
135
|
+
if (current.length > 0) chunks.push(current.join('\n').trim());
|
|
136
|
+
return chunks.filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function joinMemoryPromptChunks(chunks) {
|
|
140
|
+
const out = [];
|
|
141
|
+
let previousWasList = false;
|
|
142
|
+
for (const chunk of chunks) {
|
|
143
|
+
const isList = MARKDOWN_LIST_ITEM_RE.test(chunk);
|
|
144
|
+
if (out.length > 0 && !(previousWasList && isList)) out.push('');
|
|
145
|
+
out.push(chunk);
|
|
146
|
+
previousWasList = isList;
|
|
147
|
+
}
|
|
148
|
+
return out.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} text
|
|
153
|
+
* @returns {Set<string>}
|
|
154
|
+
*/
|
|
155
|
+
export function promptRelevanceTokens(text) {
|
|
156
|
+
const cleaned = cleanMemoryPromptText(text).toLowerCase();
|
|
157
|
+
const out = new Set();
|
|
158
|
+
for (const match of cleaned.matchAll(ASCII_WORD_RE)) {
|
|
159
|
+
const token = match[0];
|
|
160
|
+
if (!COMMON_INTENT_TOKENS.has(token)) out.add(token);
|
|
161
|
+
}
|
|
162
|
+
for (const match of cleaned.matchAll(CJK_RUN_RE)) {
|
|
163
|
+
for (const token of cjkBigrams(match[0])) {
|
|
164
|
+
if (!COMMON_INTENT_TOKENS.has(token)) out.add(token);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function cjkBigrams(text) {
|
|
171
|
+
const out = [];
|
|
172
|
+
for (let i = 0; i < text.length - 1; i += 1) out.push(text.slice(i, i + 2));
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
34
176
|
/**
|
|
35
177
|
* Normalized key for conservative prompt dedupe. This is intentionally simple:
|
|
36
178
|
* exact semantic clustering belongs in Dream; prompt assembly only removes
|
|
@@ -756,6 +756,21 @@ export class WorkItemRunner {
|
|
|
756
756
|
};
|
|
757
757
|
}
|
|
758
758
|
const dependencies = this.store.listActionDependencies(workItem.id, action.dependsOnStageIds || []);
|
|
759
|
+
if (dependencies.length > 0 && dependencies.every(dependency => (
|
|
760
|
+
dependency.workspaceMode === 'shared' && !dependency.workspace?.isolated
|
|
761
|
+
))) {
|
|
762
|
+
const persistedAction = this.store.setActionWorkspaceForRun(
|
|
763
|
+
action.id,
|
|
764
|
+
run.id,
|
|
765
|
+
ownerBootId,
|
|
766
|
+
run.leaseEpoch,
|
|
767
|
+
action.generation,
|
|
768
|
+
null,
|
|
769
|
+
'shared',
|
|
770
|
+
);
|
|
771
|
+
if (!persistedAction) throw integrationFenceError('Work Center integration fallback lost its Run lease');
|
|
772
|
+
return { ...claim, action: persistedAction };
|
|
773
|
+
}
|
|
759
774
|
const integration = prepareActionIntegration({
|
|
760
775
|
workDir: workItem.workspaceKey || workItem.workDir,
|
|
761
776
|
dependencies,
|
|
@@ -933,6 +933,21 @@ export class WorkItemStore {
|
|
|
933
933
|
const nextWorkspaceMode = workspaceMode || action.workspaceMode;
|
|
934
934
|
const specChanged = nextWorkspaceMode !== action.workspaceMode;
|
|
935
935
|
const nextAction = { ...action, workspaceMode: nextWorkspaceMode };
|
|
936
|
+
const now = this.now();
|
|
937
|
+
if (action.workspaceMode === 'isolated-write' && nextWorkspaceMode === 'shared') {
|
|
938
|
+
const workItem = this.getWorkItem(action.workItemId);
|
|
939
|
+
const workspaceConflict = workItem?.workspaceKey
|
|
940
|
+
? this.db.prepare(`SELECT 1 FROM actions running
|
|
941
|
+
JOIN work_items running_item ON running_item.id = running.work_item_id
|
|
942
|
+
WHERE running.id != ? AND running.status = 'running'
|
|
943
|
+
AND running_item.workspace_key = ? LIMIT 1`).get(action.id, workItem.workspaceKey)
|
|
944
|
+
: null;
|
|
945
|
+
if (workspaceConflict) {
|
|
946
|
+
const error = new Error('Work Center cannot fall back to shared while the workspace has another running Action');
|
|
947
|
+
error.workItemPrepareDeferred = true;
|
|
948
|
+
throw error;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
936
951
|
const changed = this.db.prepare(`UPDATE actions SET workspace = ?, workspace_mode = ?,
|
|
937
952
|
generation = generation + ?, spec_hash = ?, result_run_id = CASE WHEN ? = 1 THEN NULL ELSE result_run_id END,
|
|
938
953
|
updated_at = ? WHERE id = ? AND status = 'running' AND current_run_id = ?
|
|
@@ -942,13 +957,90 @@ export class WorkItemStore {
|
|
|
942
957
|
specChanged ? 1 : 0,
|
|
943
958
|
specChanged ? actionSpecHash(nextAction) : action.specHash,
|
|
944
959
|
specChanged ? 1 : 0,
|
|
945
|
-
|
|
960
|
+
now,
|
|
946
961
|
actionId,
|
|
947
962
|
runId,
|
|
948
963
|
leaseEpoch,
|
|
949
964
|
expectedGeneration,
|
|
950
965
|
);
|
|
951
|
-
|
|
966
|
+
if (Number(changed.changes) !== 1) return null;
|
|
967
|
+
|
|
968
|
+
if (action.workspaceMode === 'isolated-write' && nextWorkspaceMode === 'shared') {
|
|
969
|
+
const pendingRows = this.db.prepare(`SELECT * FROM actions
|
|
970
|
+
WHERE work_item_id = ? AND id != ? AND workspace_mode IN ('isolated-write', 'integrate')
|
|
971
|
+
AND status = 'ready' AND current_run_id IS NULL`).all(action.workItemId, action.id);
|
|
972
|
+
for (const row of pendingRows) {
|
|
973
|
+
const pending = mapAction(row);
|
|
974
|
+
const fallback = { ...pending, workspaceMode: 'shared', workspace: null };
|
|
975
|
+
const repaired = this.db.prepare(`UPDATE actions SET workspace = NULL, workspace_mode = 'shared',
|
|
976
|
+
generation = generation + 1, spec_hash = ?, result_run_id = NULL, updated_at = ?
|
|
977
|
+
WHERE id = ? AND status = 'ready' AND current_run_id IS NULL
|
|
978
|
+
AND generation = ? AND workspace_mode = ?`).run(
|
|
979
|
+
actionSpecHash(fallback),
|
|
980
|
+
now,
|
|
981
|
+
pending.id,
|
|
982
|
+
pending.generation,
|
|
983
|
+
pending.workspaceMode,
|
|
984
|
+
);
|
|
985
|
+
if (Number(repaired.changes) !== 1) {
|
|
986
|
+
throw new Error('Work Center could not serialize the pending Action graph after workspace fallback');
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return this.getAction(actionId);
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#graphWorkItemState(workItemId) {
|
|
995
|
+
const remaining = this.db.prepare(`SELECT id, status FROM actions WHERE work_item_id = ?
|
|
996
|
+
AND status IN ('ready', 'running', 'waiting', 'failed') ORDER BY sequence`).all(workItemId);
|
|
997
|
+
const blocked = remaining.find(candidate => candidate.status === 'waiting' || candidate.status === 'failed');
|
|
998
|
+
const runnable = remaining.find(candidate => candidate.status === 'ready' || candidate.status === 'running');
|
|
999
|
+
return {
|
|
1000
|
+
status: blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
|
|
1001
|
+
: runnable ? (remaining.some(candidate => candidate.status === 'running') ? 'running' : 'ready')
|
|
1002
|
+
: 'done',
|
|
1003
|
+
currentActionId: blocked?.id || runnable?.id || null,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
deferRun(runId, ownerBootId, leaseEpoch, reason = 'Work Center resource is temporarily busy') {
|
|
1008
|
+
return withTransaction(this.db, () => {
|
|
1009
|
+
const active = this.#activeRunRow(runId, ownerBootId, leaseEpoch, true);
|
|
1010
|
+
if (!active) return null;
|
|
1011
|
+
const action = this.getAction(active.action_id);
|
|
1012
|
+
const workItem = this.getWorkItem(active.work_item_id);
|
|
1013
|
+
const now = this.now();
|
|
1014
|
+
const changedRun = this.db.prepare(`UPDATE runs SET status = 'interrupted', ended_at = ?, error = ?,
|
|
1015
|
+
failure_kind = 'resource_deferred', failure_code = 'workspace_busy'
|
|
1016
|
+
WHERE id = ? AND owner_boot_id = ? AND lease_epoch = ? AND status = 'running'`).run(
|
|
1017
|
+
now, reason, runId, ownerBootId, leaseEpoch,
|
|
1018
|
+
);
|
|
1019
|
+
if (Number(changedRun.changes) !== 1) return null;
|
|
1020
|
+
const changedAction = this.db.prepare(`UPDATE actions SET status = 'ready', attempt = MAX(attempt - 1, 0),
|
|
1021
|
+
current_run_id = NULL, updated_at = ? WHERE id = ? AND status = 'running'
|
|
1022
|
+
AND current_run_id = ? AND lease_epoch = ? AND generation = ?`).run(
|
|
1023
|
+
now, action.id, runId, leaseEpoch, action.generation,
|
|
1024
|
+
);
|
|
1025
|
+
if (Number(changedAction.changes) !== 1) {
|
|
1026
|
+
throw new Error('Work Center deferred Run lost the current Action fence');
|
|
1027
|
+
}
|
|
1028
|
+
const graphMode = isGraphWorkItem(workItem);
|
|
1029
|
+
const graphState = graphMode ? this.#graphWorkItemState(workItem.id) : null;
|
|
1030
|
+
const changedWorkItem = graphMode
|
|
1031
|
+
? this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?, current_run_id = NULL,
|
|
1032
|
+
updated_at = ? WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention')`).run(
|
|
1033
|
+
graphState.status, graphState.currentActionId, now, workItem.id,
|
|
1034
|
+
)
|
|
1035
|
+
: this.db.prepare(`UPDATE work_items SET status = 'ready', current_run_id = NULL, updated_at = ?
|
|
1036
|
+
WHERE id = ? AND status = 'running' AND current_action_id = ? AND current_run_id = ?`).run(
|
|
1037
|
+
now, workItem.id, action.id, runId,
|
|
1038
|
+
);
|
|
1039
|
+
if (Number(changedWorkItem.changes) !== 1) {
|
|
1040
|
+
throw new Error('Work Center deferred Run lost the WorkItem fence');
|
|
1041
|
+
}
|
|
1042
|
+
this.appendEvent(workItem.id, 'run.deferred', { reason }, { actionId: action.id, runId });
|
|
1043
|
+
return this.getWorkItemDetail(workItem.id);
|
|
952
1044
|
});
|
|
953
1045
|
}
|
|
954
1046
|
|
|
@@ -1444,6 +1536,19 @@ export class WorkItemStore {
|
|
|
1444
1536
|
OR (running.work_item_id != a.work_item_id
|
|
1445
1537
|
AND a.workspace_mode != 'read' AND running.workspace_mode != 'read'))
|
|
1446
1538
|
)
|
|
1539
|
+
AND NOT EXISTS (
|
|
1540
|
+
SELECT 1 FROM runs deferred
|
|
1541
|
+
WHERE deferred.action_id = a.id
|
|
1542
|
+
AND deferred.failure_kind = 'resource_deferred'
|
|
1543
|
+
AND deferred.failure_code = 'workspace_busy'
|
|
1544
|
+
AND EXISTS (
|
|
1545
|
+
SELECT 1 FROM actions blocker
|
|
1546
|
+
JOIN work_items blocker_item ON blocker_item.id = blocker.work_item_id
|
|
1547
|
+
WHERE blocker.status = 'running' AND blocker.id != a.id
|
|
1548
|
+
AND blocker_item.workspace_key != ''
|
|
1549
|
+
AND blocker_item.workspace_key = w.workspace_key
|
|
1550
|
+
)
|
|
1551
|
+
)
|
|
1447
1552
|
ORDER BY a.updated_at ASC, a.sequence ASC LIMIT 1`).get();
|
|
1448
1553
|
if (!row) return null;
|
|
1449
1554
|
const now = this.now();
|
|
@@ -1858,14 +1963,9 @@ export class WorkItemStore {
|
|
|
1858
1963
|
let currentActionId = nextAction?.id ?? (transition.keepCurrentAction ? action.id : null);
|
|
1859
1964
|
let changedWorkItem;
|
|
1860
1965
|
if (transition.graphAdvance) {
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
const runnable = remaining.find(candidate => candidate.status === 'ready' || candidate.status === 'running');
|
|
1865
|
-
workItemStatus = blocked ? (blocked.status === 'waiting' ? 'waiting' : 'needs_attention')
|
|
1866
|
-
: runnable ? (remaining.some(candidate => candidate.status === 'running') ? 'running' : 'ready')
|
|
1867
|
-
: 'done';
|
|
1868
|
-
currentActionId = blocked?.id || runnable?.id || null;
|
|
1966
|
+
const graphState = this.#graphWorkItemState(workItem.id);
|
|
1967
|
+
workItemStatus = graphState.status;
|
|
1968
|
+
currentActionId = graphState.currentActionId;
|
|
1869
1969
|
changedWorkItem = this.db.prepare(`UPDATE work_items SET status = ?, current_action_id = ?,
|
|
1870
1970
|
current_run_id = NULL, ledger_revision = ledger_revision + ?, updated_at = ?
|
|
1871
1971
|
WHERE id = ? AND status IN ('ready', 'running', 'waiting', 'needs_attention')
|
|
@@ -143,6 +143,17 @@ export class WorkItemWatcher {
|
|
|
143
143
|
);
|
|
144
144
|
break;
|
|
145
145
|
}
|
|
146
|
+
if (error?.workItemPrepareDeferred) {
|
|
147
|
+
const detail = this.store.deferRun(
|
|
148
|
+
claim.run.id,
|
|
149
|
+
this.ownerBootId,
|
|
150
|
+
claim.run.leaseEpoch,
|
|
151
|
+
error.message,
|
|
152
|
+
);
|
|
153
|
+
if (!detail) throw new Error('Work Center deferred preparation lost its Run lease');
|
|
154
|
+
this.onEvent({ type: 'run.deferred', workItem: detail });
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
146
157
|
this.controller.submit(claim.run.id, this.ownerBootId, claim.run.leaseEpoch, {
|
|
147
158
|
outcome: error?.workItemPrepareRetryable ? 'retryable' : 'failed',
|
|
148
159
|
response: '', summary: '', evidence: [],
|
|
@@ -373,7 +373,7 @@ export function resolvePlanningWorkflowSnapshot(settings, requestedWorkItemType
|
|
|
373
373
|
const typeInstruction = requestedType
|
|
374
374
|
? `The user explicitly selected workItemType "${requestedType}". Keep that exact type.`
|
|
375
375
|
: 'Infer one specific workItemType from the contract.';
|
|
376
|
-
const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReference workflow catalog:\n${catalog || '(none)'}\nUse the catalog only to understand established task categories and sequencing patterns. Always submit the smallest reliable graph of 1 to 8 task-specific Actions; never omit Actions or copy template brief text. Split independent
|
|
376
|
+
const triageInstruction = `${normalized.actionInstructions.triage}\n\n${typeInstruction}\nReference workflow catalog:\n${catalog || '(none)'}\nUse the catalog only to understand established task categories and sequencing patterns. Always submit the smallest reliable graph of 1 to 8 task-specific Actions; never omit Actions or copy template brief text. The scheduler can run up to ${normalized.maxConcurrentActions} Actions concurrently. Before submitting, compare each pair of Actions and add a dependency only when one consumes a concrete result or side effect of the other; ordering by narrative, phase name, or list position is not a dependency. Split independent analysis, verification, and repository changes into sibling Actions so the scheduler can use that concurrency. Use workspaceMode read only for Actions guaranteed not to mutate files, Git state, services, or external systems; use isolated-write for independent Git changes, integrate for an integrate Action that combines isolated-write dependencies, and shared for serial side effects. If any Action uses isolated-write, add exactly one Action with type integrate and workspaceMode integrate; it must depend directly on every isolated-write Action, and all later Actions must depend on the integration result rather than an isolated-write Action. Non-Git or dirty workspaces are serialized automatically; do not fake parallelism by marking a mutating Action as read. Every generated Action must state objective, approach, expectedOutcome, capability, dependencies, and workspaceMode. The objective, approach, and expectedOutcome must be specific to this WorkItem and that Action: describe the concrete work, the repository-aware execution method, and the verifiable result that will guide the executor. Generic Action-type boilerplate is invalid. Add only Actions required by this task. Do not copy a generic workflow.`;
|
|
377
377
|
return normalizeWorkflowDefinition({
|
|
378
378
|
id: 'ai-planned',
|
|
379
379
|
name: 'AI planned',
|