u-foo 3.0.0 → 3.0.1
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/package.json +1 -1
- package/src/code/agent.js +17 -3
- package/src/code/context/executionSegment.js +5 -0
- package/src/code/context/planMode.js +8 -1
- package/src/code/index.js +2 -0
- package/src/code/nativeRunner.js +143 -207
- package/src/code/protocol/controlPlane.js +93 -0
- package/src/code/protocol/faultHarness.js +90 -0
- package/src/code/protocol/index.js +20 -0
- package/src/code/protocol/loopEvents.js +102 -0
- package/src/code/protocol/materialize.js +107 -0
- package/src/code/protocol/messageFixtures.js +116 -0
- package/src/code/protocol/ownership.js +147 -0
- package/src/code/protocol/protocolValidator.js +165 -0
- package/src/code/protocol/suspension.js +173 -0
- package/src/code/protocol/toolCallLedger.js +222 -0
- package/src/code/protocol/transitions.js +97 -0
- package/src/code/providers/anthropicMessagesTransport.js +93 -0
- package/src/code/providers/index.js +7 -0
- package/src/code/providers/openaiChatTransport.js +98 -0
- package/src/code/providers/transportContract.js +46 -0
- package/src/code/repl.js +8 -21
- package/src/code/runtime/taskLoop.js +13 -2
- package/src/code/runtime/taskRun.js +162 -1
- package/src/code/runtime/workspaceLease.js +41 -0
- package/src/code/sessionStore.js +1 -0
- package/src/code/taskRoute.js +73 -0
- package/src/ui/ink/UcodeApp.js +10 -27
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { assertTransport } = require("./transportContract");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Anthropic Messages API transport adapter.
|
|
7
|
+
* @param {{
|
|
8
|
+
* resolveUrl: Function,
|
|
9
|
+
* runTurn: Function,
|
|
10
|
+
* toJsonString: Function,
|
|
11
|
+
* clipText: Function,
|
|
12
|
+
* }} deps
|
|
13
|
+
*/
|
|
14
|
+
function createAnthropicMessagesTransport(deps = {}) {
|
|
15
|
+
const {
|
|
16
|
+
resolveUrl,
|
|
17
|
+
runTurn,
|
|
18
|
+
toJsonString,
|
|
19
|
+
clipText,
|
|
20
|
+
} = deps;
|
|
21
|
+
|
|
22
|
+
const transport = {
|
|
23
|
+
name: "anthropic-messages",
|
|
24
|
+
resolveUrl,
|
|
25
|
+
prepareMessages({ messages, prompt }) {
|
|
26
|
+
messages.push({
|
|
27
|
+
role: "user",
|
|
28
|
+
content: String(prompt || ""),
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
runTurn,
|
|
32
|
+
getToolCalls(turnResult) {
|
|
33
|
+
return Array.isArray(turnResult.toolCalls) ? turnResult.toolCalls : [];
|
|
34
|
+
},
|
|
35
|
+
appendFinalAssistantMessage({ messages, turnResult }) {
|
|
36
|
+
const assistantContent = Array.isArray(turnResult.assistantContent)
|
|
37
|
+
? turnResult.assistantContent
|
|
38
|
+
: [];
|
|
39
|
+
if (assistantContent.length > 0) {
|
|
40
|
+
messages.push({
|
|
41
|
+
role: "assistant",
|
|
42
|
+
content: assistantContent,
|
|
43
|
+
});
|
|
44
|
+
} else if (String(turnResult.text || "").trim()) {
|
|
45
|
+
messages.push({
|
|
46
|
+
role: "assistant",
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: String(turnResult.text || ""),
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
prepareToolCalls({ messages, turnResult, toolCalls }) {
|
|
57
|
+
const assistantContent = Array.isArray(turnResult.assistantContent)
|
|
58
|
+
? turnResult.assistantContent
|
|
59
|
+
: [];
|
|
60
|
+
|
|
61
|
+
messages.push({
|
|
62
|
+
role: "assistant",
|
|
63
|
+
content: assistantContent,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return toolCalls.map((call) => ({
|
|
67
|
+
name: call.name,
|
|
68
|
+
args: call.args,
|
|
69
|
+
source: call,
|
|
70
|
+
}));
|
|
71
|
+
},
|
|
72
|
+
appendToolResult({ collected, call, toolResult }) {
|
|
73
|
+
collected.push({
|
|
74
|
+
type: "tool_result",
|
|
75
|
+
tool_use_id: String(call.source.id || ""),
|
|
76
|
+
content: clipText(toJsonString(toolResult), 12000),
|
|
77
|
+
is_error: Boolean(!toolResult || toolResult.ok === false),
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
flushToolResults({ messages, collected }) {
|
|
81
|
+
messages.push({
|
|
82
|
+
role: "user",
|
|
83
|
+
content: collected,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return assertTransport(transport, "anthropic-messages");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
createAnthropicMessagesTransport,
|
|
93
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { randomUUID } = require("crypto");
|
|
4
|
+
const { assertTransport } = require("./transportContract");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* OpenAI-compatible chat-completions transport adapter.
|
|
8
|
+
* @param {{
|
|
9
|
+
* resolveUrl: Function,
|
|
10
|
+
* runTurn: Function,
|
|
11
|
+
* normalizeToolName: Function,
|
|
12
|
+
* normalizeToolCallArgs: Function,
|
|
13
|
+
* toJsonString: Function,
|
|
14
|
+
* clipText: Function,
|
|
15
|
+
* }} deps
|
|
16
|
+
*/
|
|
17
|
+
function createOpenAiChatTransport(deps = {}) {
|
|
18
|
+
const {
|
|
19
|
+
resolveUrl,
|
|
20
|
+
runTurn,
|
|
21
|
+
normalizeToolName,
|
|
22
|
+
normalizeToolCallArgs,
|
|
23
|
+
toJsonString,
|
|
24
|
+
clipText,
|
|
25
|
+
} = deps;
|
|
26
|
+
|
|
27
|
+
const transport = {
|
|
28
|
+
name: "openai-chat",
|
|
29
|
+
resolveUrl,
|
|
30
|
+
prepareMessages({ messages, systemPrompt, prompt }) {
|
|
31
|
+
const systemText = String(systemPrompt || "").trim();
|
|
32
|
+
const hasSystem = messages.some((entry) => String(entry.role || "").trim() === "system");
|
|
33
|
+
if (systemText && !hasSystem) {
|
|
34
|
+
messages.unshift({ role: "system", content: systemText });
|
|
35
|
+
}
|
|
36
|
+
messages.push({ role: "user", content: String(prompt || "") });
|
|
37
|
+
},
|
|
38
|
+
runTurn,
|
|
39
|
+
getToolCalls(turnResult) {
|
|
40
|
+
return Array.isArray(turnResult.toolCalls)
|
|
41
|
+
? turnResult.toolCalls.filter((call) => call && call.function && typeof call.function === "object")
|
|
42
|
+
: [];
|
|
43
|
+
},
|
|
44
|
+
appendFinalAssistantMessage({ messages, turnResult }) {
|
|
45
|
+
const text = String(turnResult.text || "").trim();
|
|
46
|
+
if (text) {
|
|
47
|
+
messages.push({
|
|
48
|
+
role: "assistant",
|
|
49
|
+
content: text,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
prepareToolCalls({ messages, toolCalls }) {
|
|
54
|
+
const assistantToolCalls = [];
|
|
55
|
+
for (const call of toolCalls) {
|
|
56
|
+
const callId = String(call.id || `call_${randomUUID()}`);
|
|
57
|
+
const name = normalizeToolName(call.function.name || "");
|
|
58
|
+
const args = normalizeToolCallArgs(call.function.arguments || "");
|
|
59
|
+
|
|
60
|
+
assistantToolCalls.push({
|
|
61
|
+
id: callId,
|
|
62
|
+
type: "function",
|
|
63
|
+
function: {
|
|
64
|
+
name: name || String(call.function.name || ""),
|
|
65
|
+
arguments: toJsonString(args),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (assistantToolCalls.length === 0) return null;
|
|
71
|
+
|
|
72
|
+
messages.push({
|
|
73
|
+
role: "assistant",
|
|
74
|
+
content: null,
|
|
75
|
+
tool_calls: assistantToolCalls,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return assistantToolCalls.map((toolCall) => ({
|
|
79
|
+
name: toolCall.function.name,
|
|
80
|
+
args: normalizeToolCallArgs(toolCall.function.arguments),
|
|
81
|
+
source: toolCall,
|
|
82
|
+
}));
|
|
83
|
+
},
|
|
84
|
+
appendToolResult({ messages, call, toolResult }) {
|
|
85
|
+
messages.push({
|
|
86
|
+
role: "tool",
|
|
87
|
+
tool_call_id: call.source.id,
|
|
88
|
+
content: clipText(toJsonString(toolResult), 12000),
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return assertTransport(transport, "openai-chat");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = {
|
|
97
|
+
createOpenAiChatTransport,
|
|
98
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transport contract for native Agent Loop Provider adapters.
|
|
5
|
+
*
|
|
6
|
+
* Transports own wire-format conversion and turn execution only.
|
|
7
|
+
* They must not decide Plan Mode, write leases, or tool batch policy.
|
|
8
|
+
*
|
|
9
|
+
* Required methods:
|
|
10
|
+
* - resolveUrl(baseUrl) → string
|
|
11
|
+
* - prepareMessages({ messages, systemPrompt?, prompt })
|
|
12
|
+
* - runTurn(params) → Promise<turnResult>
|
|
13
|
+
* - getToolCalls(turnResult) → array
|
|
14
|
+
* - appendFinalAssistantMessage({ messages, turnResult })
|
|
15
|
+
* - prepareToolCalls({ messages, turnResult?, toolCalls }) → pendingCalls|null
|
|
16
|
+
* - appendToolResult({ messages?, collected?, call, toolResult })
|
|
17
|
+
* - flushToolResults?({ messages, collected }) // Anthropic-style batch
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const TRANSPORT_NAMES = Object.freeze(["openai-chat", "anthropic-messages"]);
|
|
21
|
+
|
|
22
|
+
function assertTransport(transport = null, name = "") {
|
|
23
|
+
if (!transport || typeof transport !== "object") {
|
|
24
|
+
throw new Error(`missing transport${name ? `: ${name}` : ""}`);
|
|
25
|
+
}
|
|
26
|
+
const required = [
|
|
27
|
+
"resolveUrl",
|
|
28
|
+
"prepareMessages",
|
|
29
|
+
"runTurn",
|
|
30
|
+
"getToolCalls",
|
|
31
|
+
"appendFinalAssistantMessage",
|
|
32
|
+
"prepareToolCalls",
|
|
33
|
+
"appendToolResult",
|
|
34
|
+
];
|
|
35
|
+
for (const key of required) {
|
|
36
|
+
if (typeof transport[key] !== "function") {
|
|
37
|
+
throw new Error(`transport ${name || "?"} missing ${key}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return transport;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
TRANSPORT_NAMES,
|
|
45
|
+
assertTransport,
|
|
46
|
+
};
|
package/src/code/repl.js
CHANGED
|
@@ -283,6 +283,7 @@ async function runUcodeCoreAgent({
|
|
|
283
283
|
resolveUcodeProviderModel,
|
|
284
284
|
runNaturalLanguageTask,
|
|
285
285
|
resumeAfterUserInteraction,
|
|
286
|
+
submitUserInteractionAnswer,
|
|
286
287
|
} = require("./agent");
|
|
287
288
|
const resolvedWorkspaceRoot = resolveUfooProjectRoot(workspaceRoot);
|
|
288
289
|
const resolvedUcode = resolveUcodeProviderModel({
|
|
@@ -592,23 +593,12 @@ async function runUcodeCoreAgent({
|
|
|
592
593
|
|
|
593
594
|
// Pending approval/choice/chat takes priority over nudge / new NL.
|
|
594
595
|
try {
|
|
595
|
-
const {
|
|
596
|
-
hasPendingUserInteraction,
|
|
597
|
-
parseUserInteractionInput,
|
|
598
|
-
getPendingUserInteraction,
|
|
599
|
-
} = require("./context/userInteraction");
|
|
596
|
+
const { hasPendingUserInteraction } = require("./context/userInteraction");
|
|
600
597
|
if (
|
|
601
598
|
trimmed
|
|
602
599
|
&& state.executionState
|
|
603
600
|
&& hasPendingUserInteraction(state.executionState)
|
|
604
601
|
) {
|
|
605
|
-
const pending = getPendingUserInteraction(state.executionState);
|
|
606
|
-
const parsed = parseUserInteractionInput(pending, trimmed);
|
|
607
|
-
if (!parsed.ok) {
|
|
608
|
-
stdout.write(`${parsed.error || "Invalid reply"}\n`);
|
|
609
|
-
printPrompt(stdout);
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
602
|
chain = chain.then(async () => {
|
|
613
603
|
let streamBuffer = null;
|
|
614
604
|
let streamedVisible = false;
|
|
@@ -622,7 +612,7 @@ async function runUcodeCoreAgent({
|
|
|
622
612
|
taskInFlight = true;
|
|
623
613
|
let resumeResult;
|
|
624
614
|
try {
|
|
625
|
-
resumeResult = await
|
|
615
|
+
resumeResult = await submitUserInteractionAnswer(trimmed, state, {
|
|
626
616
|
onDelta: state.jsonOutput
|
|
627
617
|
? null
|
|
628
618
|
: async (delta) => {
|
|
@@ -649,15 +639,12 @@ async function runUcodeCoreAgent({
|
|
|
649
639
|
if (streamed && streamedVisible && resumeResult && resumeResult.streamLastChar !== "\n") {
|
|
650
640
|
stdout.write("\n");
|
|
651
641
|
}
|
|
652
|
-
if (resumeResult
|
|
653
|
-
stdout.write("Still waiting for your reply.\n");
|
|
654
|
-
} else if (!resumeResult || resumeResult.ok === false) {
|
|
642
|
+
if (!resumeResult || resumeResult.ok === false) {
|
|
655
643
|
stdout.write(`Error: ${(resumeResult && resumeResult.error) || "resume failed"}\n`);
|
|
656
|
-
} else {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
644
|
+
} else if (resumeResult.shouldEchoSummary && resumeResult.echoSummaryText) {
|
|
645
|
+
stdout.write(`${resumeResult.echoSummaryText}\n`);
|
|
646
|
+
} else if (resumeResult.waitingUserInteraction) {
|
|
647
|
+
stdout.write("Still waiting for your reply.\n");
|
|
661
648
|
}
|
|
662
649
|
const persisted = persistSessionState(state);
|
|
663
650
|
if (!state.jsonOutput && (!persisted || persisted.ok === false)) {
|
|
@@ -357,17 +357,28 @@ function processTaskRun(executionState = null, taskRunId = "", options = {}) {
|
|
|
357
357
|
|
|
358
358
|
/**
|
|
359
359
|
* Resume queued/running TaskRuns after process restart (executionState restored).
|
|
360
|
+
* Requeues interrupted mid-tool/model runs, releases stale leases, then advances.
|
|
360
361
|
*/
|
|
361
362
|
function resumePersistedTaskRuns(executionState = null, options = {}) {
|
|
362
363
|
const state = ensureGraphs(executionState);
|
|
364
|
+
const {
|
|
365
|
+
recoverTaskRunsAfterRestart,
|
|
366
|
+
} = require("./taskRun");
|
|
367
|
+
const { releaseStaleWriteLeases } = require("./workspaceLease");
|
|
368
|
+
const recovery = recoverTaskRunsAfterRestart(state);
|
|
369
|
+
const leases = releaseStaleWriteLeases(state, {
|
|
370
|
+
maxAgeMs: options.leaseStaleMs,
|
|
371
|
+
});
|
|
363
372
|
const byId = state.taskRuns && state.taskRuns.byId ? state.taskRuns.byId : {};
|
|
364
373
|
const results = [];
|
|
365
374
|
for (const run of Object.values(byId)) {
|
|
366
375
|
if (!run) continue;
|
|
367
|
-
if (run.status !== "queued" && run.status !== "running")
|
|
376
|
+
if (run.status !== "queued" && run.status !== "running" && run.status !== "cancelling") {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
368
379
|
results.push(processTaskRun(state, run.id, options));
|
|
369
380
|
}
|
|
370
|
-
return results;
|
|
381
|
+
return { recovery, leases, results };
|
|
371
382
|
}
|
|
372
383
|
|
|
373
384
|
module.exports = {
|
|
@@ -4,6 +4,15 @@ const { randomUUID } = require("crypto");
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* TaskRun registry — parent Task node identity vs runnable attempt.
|
|
7
|
+
*
|
|
8
|
+
* Scheduler owner: TaskLoop (`processTaskRun` / `resumePersistedTaskRuns`).
|
|
9
|
+
* Agent Loop may only issue control commands (start/cancel/complete) via CAS.
|
|
10
|
+
*
|
|
11
|
+
* Restart rules:
|
|
12
|
+
* - queued → remain queued (scheduler resumes)
|
|
13
|
+
* - running + phase waiting_model|executing_tools|planning → requeue to queued
|
|
14
|
+
* - cancelling → stay cancelling until cancel completes
|
|
15
|
+
* - terminal → never transition backward
|
|
7
16
|
*/
|
|
8
17
|
|
|
9
18
|
const TASK_RUN_STATUSES = Object.freeze([
|
|
@@ -25,6 +34,31 @@ const TASK_RUN_PHASES = Object.freeze([
|
|
|
25
34
|
|
|
26
35
|
const TERMINAL_TASK_RUN = new Set(["succeeded", "failed", "cancelled"]);
|
|
27
36
|
|
|
37
|
+
/** Keep in sync with protocol/transitions.TASK_RUN_TRANSITIONS. */
|
|
38
|
+
const TASK_RUN_TRANSITIONS = Object.freeze({
|
|
39
|
+
queued: Object.freeze(["running", "cancelled"]),
|
|
40
|
+
running: Object.freeze(["succeeded", "failed", "cancelling"]),
|
|
41
|
+
cancelling: Object.freeze(["cancelled", "failed"]),
|
|
42
|
+
succeeded: Object.freeze([]),
|
|
43
|
+
failed: Object.freeze([]),
|
|
44
|
+
cancelled: Object.freeze([]),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Default write-lease / heartbeat staleness (ms). */
|
|
48
|
+
const DEFAULT_LEASE_STALE_MS = 30 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/** Extra recovery edge used only by recoverTaskRunsAfterRestart. */
|
|
51
|
+
const RECOVERY_TRANSITIONS = Object.freeze({
|
|
52
|
+
running: Object.freeze(["queued"]),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function isAllowedTaskRunTransition(fromStatus = "", toStatus = "") {
|
|
56
|
+
const from = String(fromStatus || "").trim();
|
|
57
|
+
const to = String(toStatus || "").trim();
|
|
58
|
+
const allowed = TASK_RUN_TRANSITIONS[from];
|
|
59
|
+
if (!allowed) return false;
|
|
60
|
+
return allowed.includes(to);
|
|
61
|
+
}
|
|
28
62
|
function createTaskRunId() {
|
|
29
63
|
return `trun_${Date.now().toString(36)}_${randomUUID().slice(0, 6)}`;
|
|
30
64
|
}
|
|
@@ -33,6 +67,7 @@ function emptyTaskRunStore() {
|
|
|
33
67
|
return {
|
|
34
68
|
byId: {},
|
|
35
69
|
commandLog: {},
|
|
70
|
+
wakeupLog: {},
|
|
36
71
|
};
|
|
37
72
|
}
|
|
38
73
|
|
|
@@ -47,6 +82,9 @@ function ensureTaskRunStore(executionState = null) {
|
|
|
47
82
|
if (!state.taskRuns.commandLog || typeof state.taskRuns.commandLog !== "object") {
|
|
48
83
|
state.taskRuns.commandLog = {};
|
|
49
84
|
}
|
|
85
|
+
if (!state.taskRuns.wakeupLog || typeof state.taskRuns.wakeupLog !== "object") {
|
|
86
|
+
state.taskRuns.wakeupLog = {};
|
|
87
|
+
}
|
|
50
88
|
return state.taskRuns;
|
|
51
89
|
}
|
|
52
90
|
|
|
@@ -73,6 +111,8 @@ function createTaskRun({
|
|
|
73
111
|
createdAt: now,
|
|
74
112
|
startedAt: "",
|
|
75
113
|
completedAt: "",
|
|
114
|
+
heartbeatAt: "",
|
|
115
|
+
lastWakeupId: "",
|
|
76
116
|
};
|
|
77
117
|
}
|
|
78
118
|
|
|
@@ -113,8 +153,26 @@ function isTerminalTaskRun(run = null) {
|
|
|
113
153
|
return Boolean(run && TERMINAL_TASK_RUN.has(String(run.status || "")));
|
|
114
154
|
}
|
|
115
155
|
|
|
156
|
+
function touchTaskRunHeartbeat(executionState = null, taskRunId = "") {
|
|
157
|
+
const run = getTaskRun(executionState, taskRunId);
|
|
158
|
+
if (!run) return null;
|
|
159
|
+
run.heartbeatAt = new Date().toISOString();
|
|
160
|
+
putTaskRun(executionState, run);
|
|
161
|
+
return run;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isTransitionAllowed(fromStatus, toStatus, { allowRecovery = false } = {}) {
|
|
165
|
+
if (fromStatus === toStatus) return true;
|
|
166
|
+
if (isAllowedTaskRunTransition(fromStatus, toStatus)) return true;
|
|
167
|
+
if (allowRecovery) {
|
|
168
|
+
const extra = RECOVERY_TRANSITIONS[fromStatus] || [];
|
|
169
|
+
return extra.includes(toStatus);
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
116
174
|
/**
|
|
117
|
-
* Compare-and-set status transition.
|
|
175
|
+
* Compare-and-set status transition. Enforces allowed edges; terminal is final.
|
|
118
176
|
*/
|
|
119
177
|
function casTaskRunStatus(executionState = null, taskRunId = "", {
|
|
120
178
|
expectedStatus = "",
|
|
@@ -123,6 +181,7 @@ function casTaskRunStatus(executionState = null, taskRunId = "", {
|
|
|
123
181
|
result = null,
|
|
124
182
|
error = null,
|
|
125
183
|
changedFiles = null,
|
|
184
|
+
allowRecovery = false,
|
|
126
185
|
} = {}) {
|
|
127
186
|
const run = getTaskRun(executionState, taskRunId);
|
|
128
187
|
if (!run) return { ok: false, code: "TASK_RUN_NOT_FOUND", run: null };
|
|
@@ -135,10 +194,28 @@ function casTaskRunStatus(executionState = null, taskRunId = "", {
|
|
|
135
194
|
currentStatus: run.status,
|
|
136
195
|
};
|
|
137
196
|
}
|
|
197
|
+
if (TERMINAL_TASK_RUN.has(run.status)) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
code: "TASK_ALREADY_TERMINAL",
|
|
201
|
+
run,
|
|
202
|
+
currentStatus: run.status,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
138
205
|
const next = String(nextStatus || "").trim();
|
|
139
206
|
if (!TASK_RUN_STATUSES.includes(next)) {
|
|
140
207
|
return { ok: false, code: "INVALID_TASK_STATUS", run };
|
|
141
208
|
}
|
|
209
|
+
if (!isTransitionAllowed(run.status, next, { allowRecovery })) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
code: "TASK_TRANSITION_FORBIDDEN",
|
|
213
|
+
run,
|
|
214
|
+
currentStatus: run.status,
|
|
215
|
+
nextStatus: next,
|
|
216
|
+
allowed: (TASK_RUN_TRANSITIONS[run.status] || []).slice(),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
142
219
|
run.status = next;
|
|
143
220
|
if (phase && TASK_RUN_PHASES.includes(phase)) run.phase = phase;
|
|
144
221
|
if (result !== null) run.result = result;
|
|
@@ -150,6 +227,7 @@ function casTaskRunStatus(executionState = null, taskRunId = "", {
|
|
|
150
227
|
run.phase = "finalizing";
|
|
151
228
|
}
|
|
152
229
|
if (next === "cancelling") run.cancelRequested = true;
|
|
230
|
+
run.heartbeatAt = new Date().toISOString();
|
|
153
231
|
putTaskRun(executionState, run);
|
|
154
232
|
return { ok: true, run };
|
|
155
233
|
}
|
|
@@ -168,10 +246,89 @@ function getCachedControlCommand(executionState = null, commandId = "") {
|
|
|
168
246
|
return store.commandLog[id] ? JSON.parse(JSON.stringify(store.commandLog[id])) : null;
|
|
169
247
|
}
|
|
170
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Deduplicate wakeups by wakeupId. Second delivery returns cached result.
|
|
251
|
+
*/
|
|
252
|
+
function beginWakeup(executionState = null, wakeupId = "", meta = {}) {
|
|
253
|
+
const id = String(wakeupId || "").trim();
|
|
254
|
+
if (!id) return { ok: true, fresh: true };
|
|
255
|
+
const store = ensureTaskRunStore(executionState);
|
|
256
|
+
const existing = store.wakeupLog[id];
|
|
257
|
+
if (existing && existing.status === "completed") {
|
|
258
|
+
return {
|
|
259
|
+
ok: true,
|
|
260
|
+
fresh: false,
|
|
261
|
+
idempotentReplay: true,
|
|
262
|
+
result: existing.result ? JSON.parse(JSON.stringify(existing.result)) : existing,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (existing && existing.status === "started") {
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
fresh: false,
|
|
269
|
+
idempotentReplay: true,
|
|
270
|
+
result: { status: "in_flight", wakeupId: id },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
store.wakeupLog[id] = {
|
|
274
|
+
status: "started",
|
|
275
|
+
startedAt: new Date().toISOString(),
|
|
276
|
+
...meta,
|
|
277
|
+
};
|
|
278
|
+
return { ok: true, fresh: true };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function completeWakeup(executionState = null, wakeupId = "", result = {}) {
|
|
282
|
+
const id = String(wakeupId || "").trim();
|
|
283
|
+
if (!id) return;
|
|
284
|
+
const store = ensureTaskRunStore(executionState);
|
|
285
|
+
store.wakeupLog[id] = {
|
|
286
|
+
...(store.wakeupLog[id] || {}),
|
|
287
|
+
status: "completed",
|
|
288
|
+
completedAt: new Date().toISOString(),
|
|
289
|
+
result: JSON.parse(JSON.stringify(result || {})),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* After process restart: requeue interrupted running runs; leave cancelling alone.
|
|
295
|
+
* Does not execute tools — caller should invoke processTaskRun separately.
|
|
296
|
+
*/
|
|
297
|
+
function recoverTaskRunsAfterRestart(executionState = null) {
|
|
298
|
+
const store = ensureTaskRunStore(executionState);
|
|
299
|
+
const recovered = [];
|
|
300
|
+
for (const run of Object.values(store.byId)) {
|
|
301
|
+
if (!run) continue;
|
|
302
|
+
if (run.status === "running") {
|
|
303
|
+
const phase = String(run.phase || "");
|
|
304
|
+
if (phase === "waiting_model" || phase === "executing_tools" || phase === "planning") {
|
|
305
|
+
const cas = casTaskRunStatus(executionState, run.id, {
|
|
306
|
+
expectedStatus: "running",
|
|
307
|
+
nextStatus: "queued",
|
|
308
|
+
phase: "initializing",
|
|
309
|
+
allowRecovery: true,
|
|
310
|
+
});
|
|
311
|
+
recovered.push({
|
|
312
|
+
taskRunId: run.id,
|
|
313
|
+
action: cas.ok ? "requeued" : "skip",
|
|
314
|
+
code: cas.ok ? "" : cas.code,
|
|
315
|
+
});
|
|
316
|
+
} else {
|
|
317
|
+
recovered.push({ taskRunId: run.id, action: "resume_running" });
|
|
318
|
+
}
|
|
319
|
+
} else if (run.status === "queued" || run.status === "cancelling") {
|
|
320
|
+
recovered.push({ taskRunId: run.id, action: `resume_${run.status}` });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return recovered;
|
|
324
|
+
}
|
|
325
|
+
|
|
171
326
|
module.exports = {
|
|
172
327
|
TASK_RUN_STATUSES,
|
|
173
328
|
TASK_RUN_PHASES,
|
|
174
329
|
TERMINAL_TASK_RUN,
|
|
330
|
+
TASK_RUN_TRANSITIONS,
|
|
331
|
+
DEFAULT_LEASE_STALE_MS,
|
|
175
332
|
createTaskRunId,
|
|
176
333
|
emptyTaskRunStore,
|
|
177
334
|
ensureTaskRunStore,
|
|
@@ -181,7 +338,11 @@ module.exports = {
|
|
|
181
338
|
findActiveTaskRunForNode,
|
|
182
339
|
listActiveWritingTaskRuns,
|
|
183
340
|
isTerminalTaskRun,
|
|
341
|
+
touchTaskRunHeartbeat,
|
|
184
342
|
casTaskRunStatus,
|
|
185
343
|
cacheControlCommand,
|
|
186
344
|
getCachedControlCommand,
|
|
345
|
+
beginWakeup,
|
|
346
|
+
completeWakeup,
|
|
347
|
+
recoverTaskRunsAfterRestart,
|
|
187
348
|
};
|
|
@@ -190,6 +190,46 @@ function hasActiveWriteLease(executionState = null) {
|
|
|
190
190
|
return countWriteLeases(executionState) > 0;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Release leases whose TaskRun is missing/terminal, or whose acquiredAt is older
|
|
195
|
+
* than maxAgeMs without an active non-terminal run heartbeat.
|
|
196
|
+
*/
|
|
197
|
+
function releaseStaleWriteLeases(executionState = null, {
|
|
198
|
+
maxAgeMs = require("./taskRun").DEFAULT_LEASE_STALE_MS,
|
|
199
|
+
nowMs = Date.now(),
|
|
200
|
+
} = {}) {
|
|
201
|
+
const {
|
|
202
|
+
getTaskRun,
|
|
203
|
+
isTerminalTaskRun,
|
|
204
|
+
} = require("./taskRun");
|
|
205
|
+
const lease = ensureWorkspaceLease(executionState);
|
|
206
|
+
const holders = normalizeHolders(lease);
|
|
207
|
+
const released = [];
|
|
208
|
+
const kept = [];
|
|
209
|
+
const maxAge = Number.isFinite(maxAgeMs) ? Math.max(0, maxAgeMs) : 0;
|
|
210
|
+
|
|
211
|
+
for (const holder of holders) {
|
|
212
|
+
const run = getTaskRun(executionState, holder.taskRunId);
|
|
213
|
+
if (!run || isTerminalTaskRun(run)) {
|
|
214
|
+
released.push({ taskRunId: holder.taskRunId, reason: "terminal_or_missing" });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const acquired = Date.parse(String(holder.acquiredAt || ""));
|
|
218
|
+
if (maxAge > 0 && Number.isFinite(acquired) && (nowMs - acquired) > maxAge) {
|
|
219
|
+
const beat = Date.parse(String(run.heartbeatAt || run.startedAt || ""));
|
|
220
|
+
if (!Number.isFinite(beat) || (nowMs - beat) > maxAge) {
|
|
221
|
+
released.push({ taskRunId: holder.taskRunId, reason: "stale_heartbeat" });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
kept.push(holder);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
lease.holders = kept;
|
|
229
|
+
if (kept.length === 0) lease.acquiredAt = "";
|
|
230
|
+
return { ok: true, released, kept: kept.slice(), lease };
|
|
231
|
+
}
|
|
232
|
+
|
|
193
233
|
module.exports = {
|
|
194
234
|
WRITE_TOOLS,
|
|
195
235
|
MAX_CONCURRENT_WRITE_LEASES,
|
|
@@ -205,4 +245,5 @@ module.exports = {
|
|
|
205
245
|
clearWorkspaceLease,
|
|
206
246
|
checkWriteAllowed,
|
|
207
247
|
hasActiveWriteLease,
|
|
248
|
+
releaseStaleWriteLeases,
|
|
208
249
|
};
|
package/src/code/sessionStore.js
CHANGED
|
@@ -68,6 +68,7 @@ function normalizeContextPolicy(value = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function buildSessionSnapshot(input = {}) {
|
|
71
|
+
// Durable session fields vs projections: src/code/protocol/ownership.js
|
|
71
72
|
const source = input && typeof input === "object" ? input : {};
|
|
72
73
|
const sessionId = resolveSessionId(source.sessionId);
|
|
73
74
|
const createdAt = String(source.createdAt || "").trim() || toIsoNow();
|