@guanlin4924/devflow-pi 0.1.35 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +28 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +12 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +75 -3
- package/src/constants.ts +1 -1
- package/src/runtime.ts +38 -2
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1346,11 +1346,25 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1346
1346
|
// transition, and only the already-recorded target may consume a claim.
|
|
1347
1347
|
const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
|
|
1348
1348
|
const rolloverState = parseJsonResult(rolloverResult);
|
|
1349
|
-
|
|
1349
|
+
let rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
|
|
1350
1350
|
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1351
|
-
if (
|
|
1351
|
+
if (
|
|
1352
|
+
rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "claimed" &&
|
|
1353
|
+
rollover.target_session_id !== currentSessionId && typeof rollover.lease_expires_at === "string" &&
|
|
1354
|
+
Date.parse(rollover.lease_expires_at) <= Date.now()
|
|
1355
|
+
) {
|
|
1356
|
+
const recovered = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1357
|
+
"recover", "--write", "--json", "--expected-revision", String(rolloverState.revision),
|
|
1358
|
+
]);
|
|
1359
|
+
if (recovered.ok) {
|
|
1360
|
+
const refreshed = parseJsonResult(await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]));
|
|
1361
|
+
rollover = refreshed?.rollover as Record<string, unknown> | undefined;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && typeof rollover.id === "string") {
|
|
1352
1365
|
const workflow = await inspectWorkflow(ctx.cwd);
|
|
1353
|
-
|
|
1366
|
+
const expectedStateRevision = typeof rollover.expected_state_revision === "number" ? rollover.expected_state_revision : undefined;
|
|
1367
|
+
if (workflow.result.ok && workflow.snapshot && expectedStateRevision !== undefined && expectedStateRevision + 1 === rolloverState.revision) {
|
|
1354
1368
|
pendingRollover = { snapshot: workflow.snapshot, preparedRevision: rolloverState.revision, rolloverId: rollover.id };
|
|
1355
1369
|
await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, currentSessionId);
|
|
1356
1370
|
ctx.ui.notify("已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
|
|
@@ -1380,10 +1394,15 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1380
1394
|
const pending = pendingRollover;
|
|
1381
1395
|
const targetSessionId = ctx.sessionManager.getSessionId();
|
|
1382
1396
|
const claimed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1383
|
-
"claim", "--write", "--json", "--expected-revision", String(pending.preparedRevision), "--target-session-id", targetSessionId,
|
|
1397
|
+
"claim", "--write", "--json", "--expected-revision", String(pending.preparedRevision), "--target-session-id", targetSessionId, "--lease-seconds", "60",
|
|
1384
1398
|
]);
|
|
1385
1399
|
const claimedPayload = parseJsonResult(claimed);
|
|
1386
1400
|
if (!claimed.ok || !claimedPayload || typeof claimedPayload.revision !== "number") {
|
|
1401
|
+
if (claimedPayload?.reason === "rollover_lease_active" || claimedPayload?.reason === "handoff_integrity_failure") {
|
|
1402
|
+
pendingRollover = undefined;
|
|
1403
|
+
} else {
|
|
1404
|
+
await enqueueControlledRolloverRequest(ctx.cwd, pending.rolloverId, targetSessionId);
|
|
1405
|
+
}
|
|
1387
1406
|
ctx.ui.notify(`Devflow 会话交接认领失败;工作流保持冻结:${claimed.stderr || claimed.stdout}`, "error");
|
|
1388
1407
|
return;
|
|
1389
1408
|
}
|
|
@@ -1391,6 +1410,11 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1391
1410
|
"consume", "--write", "--json", "--expected-revision", String(claimedPayload.revision), "--target-session-id", targetSessionId,
|
|
1392
1411
|
]);
|
|
1393
1412
|
if (!consumed.ok) {
|
|
1413
|
+
if (parseJsonResult(consumed)?.reason === "rollover_transition_failed") {
|
|
1414
|
+
await enqueueControlledRolloverRequest(ctx.cwd, pending.rolloverId, targetSessionId);
|
|
1415
|
+
} else {
|
|
1416
|
+
pendingRollover = undefined;
|
|
1417
|
+
}
|
|
1394
1418
|
ctx.ui.notify(`Devflow 会话交接消费失败;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
|
|
1395
1419
|
return;
|
|
1396
1420
|
}
|
|
@@ -508,14 +508,6 @@ result =
|
|
|
508
508
|
next_intent_requested = false
|
|
509
509
|
current_revision = Integer(state.fetch("revision", 0))
|
|
510
510
|
|
|
511
|
-
if state.dig("rollover", "status") && %w[prepared claimed].include?(state.dig("rollover", "status"))
|
|
512
|
-
result = fail_result(
|
|
513
|
-
"session_rollover_in_progress",
|
|
514
|
-
"rollover" => state["rollover"],
|
|
515
|
-
"message" => "Workflow transitions are frozen until the controlled session handoff is consumed or explicitly recovered."
|
|
516
|
-
)
|
|
517
|
-
end
|
|
518
|
-
|
|
519
511
|
if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
|
|
520
512
|
result = fail_result(
|
|
521
513
|
"stale_revision",
|
|
@@ -556,6 +548,18 @@ result =
|
|
|
556
548
|
end
|
|
557
549
|
end
|
|
558
550
|
|
|
551
|
+
# Cancellation is the explicit, user-confirmed escape hatch for an
|
|
552
|
+
# interrupted handoff. It must run before the normal rollover freeze;
|
|
553
|
+
# otherwise a prepared/claimed handoff makes the feature impossible to
|
|
554
|
+
# abandon and permanently occupies the project.
|
|
555
|
+
if result.nil? && state.dig("rollover", "status") && %w[prepared claimed].include?(state.dig("rollover", "status"))
|
|
556
|
+
result = fail_result(
|
|
557
|
+
"session_rollover_in_progress",
|
|
558
|
+
"rollover" => state["rollover"],
|
|
559
|
+
"message" => "Workflow transitions are frozen until the controlled session handoff is consumed or explicitly recovered."
|
|
560
|
+
)
|
|
561
|
+
end
|
|
562
|
+
|
|
559
563
|
if result.nil? && options[:complete_step]
|
|
560
564
|
step_id = options[:complete_step]
|
|
561
565
|
step = step_by_id[step_id]
|
|
@@ -14,7 +14,8 @@ require "time"
|
|
|
14
14
|
require "yaml"
|
|
15
15
|
require_relative "control_io"
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
DEFAULT_LEASE_SECONDS = 60
|
|
18
|
+
options = { json: false, write: false, lease_seconds: DEFAULT_LEASE_SECONDS }
|
|
18
19
|
parser = OptionParser.new do |opts|
|
|
19
20
|
opts.banner = "Usage: session_rollover.rb prepare|claim|consume|inspect [options]"
|
|
20
21
|
opts.on("--write", "Apply the requested transition") { options[:write] = true }
|
|
@@ -23,6 +24,7 @@ parser = OptionParser.new do |opts|
|
|
|
23
24
|
opts.on("--source-session-id ID", "Source OMP session id") { |v| options[:source_session_id] = v }
|
|
24
25
|
opts.on("--target-session-id ID", "Target OMP session id") { |v| options[:target_session_id] = v }
|
|
25
26
|
opts.on("--reason TEXT", "Auditable rollover reason") { |v| options[:reason] = v }
|
|
27
|
+
opts.on("--lease-seconds N", Integer, "Claim lease duration") { |v| options[:lease_seconds] = v }
|
|
26
28
|
end
|
|
27
29
|
action = ARGV.shift
|
|
28
30
|
parser.parse!
|
|
@@ -52,6 +54,10 @@ def iso_now
|
|
|
52
54
|
Time.now.utc.iso8601(3)
|
|
53
55
|
end
|
|
54
56
|
|
|
57
|
+
def lease_expiry(seconds)
|
|
58
|
+
[[seconds.to_i, 5].max, 3600].min.then { |duration| (Time.now.utc + duration).iso8601(3) }
|
|
59
|
+
end
|
|
60
|
+
|
|
55
61
|
root = Dir.pwd
|
|
56
62
|
state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
|
|
57
63
|
result = catch(:transition_done) do
|
|
@@ -77,7 +83,24 @@ result = catch(:transition_done) do
|
|
|
77
83
|
result = fail_result("invalid_source_session_id")
|
|
78
84
|
throw :transition_done, result
|
|
79
85
|
end
|
|
80
|
-
if rollover &&
|
|
86
|
+
if rollover && rollover["status"] == "prepared"
|
|
87
|
+
# Preparing a handoff is retried after an interrupted runtime turn. The
|
|
88
|
+
# existing prepared handoff is the durable idempotency key; reusing it
|
|
89
|
+
# is safe only when it belongs to this exact STATE revision and its
|
|
90
|
+
# content hash still verifies.
|
|
91
|
+
expected_state_revision = rollover["expected_state_revision"]
|
|
92
|
+
if expected_state_revision == revision || (expected_state_revision.is_a?(Integer) && expected_state_revision + 1 == revision)
|
|
93
|
+
existing_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
|
|
94
|
+
if File.exist?(existing_path) && Digest::SHA256.hexdigest(File.binread(existing_path)) == rollover["handoff_sha256"]
|
|
95
|
+
result = { "ok" => true, "written" => false, "reused" => true, "rollover" => rollover, "revision" => revision }
|
|
96
|
+
throw :transition_done, result
|
|
97
|
+
end
|
|
98
|
+
result = fail_result("handoff_integrity_failure", "rollover" => rollover)
|
|
99
|
+
throw :transition_done, result
|
|
100
|
+
end
|
|
101
|
+
result = fail_result("rollover_in_progress", "rollover" => rollover)
|
|
102
|
+
throw :transition_done, result
|
|
103
|
+
elsif rollover && rollover["status"] == "claimed"
|
|
81
104
|
result = fail_result("rollover_in_progress", "rollover" => rollover)
|
|
82
105
|
throw :transition_done, result
|
|
83
106
|
end
|
|
@@ -117,6 +140,15 @@ result = catch(:transition_done) do
|
|
|
117
140
|
end
|
|
118
141
|
result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
|
|
119
142
|
when "claim"
|
|
143
|
+
if rollover.is_a?(Hash) && %w[claimed consumed].include?(rollover["status"]) && rollover["target_session_id"] == options[:target_session_id]
|
|
144
|
+
path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
|
|
145
|
+
if File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
|
|
146
|
+
result = { "ok" => true, "written" => false, "reused" => true, "rollover" => rollover, "revision" => revision }
|
|
147
|
+
throw :transition_done, result
|
|
148
|
+
end
|
|
149
|
+
result = fail_result("handoff_integrity_failure", "rollover" => rollover)
|
|
150
|
+
throw :transition_done, result
|
|
151
|
+
end
|
|
120
152
|
unless rollover.is_a?(Hash) && rollover["status"] == "prepared"
|
|
121
153
|
result = fail_result("rollover_not_prepared", "rollover" => rollover)
|
|
122
154
|
throw :transition_done, result
|
|
@@ -131,11 +163,51 @@ result = catch(:transition_done) do
|
|
|
131
163
|
throw :transition_done, result
|
|
132
164
|
end
|
|
133
165
|
if options[:write]
|
|
134
|
-
rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now
|
|
166
|
+
rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now; rollover["lease_expires_at"] = lease_expiry(options[:lease_seconds])
|
|
167
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
168
|
+
end
|
|
169
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
170
|
+
when "renew"
|
|
171
|
+
unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
|
|
172
|
+
result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
|
|
173
|
+
throw :transition_done, result
|
|
174
|
+
end
|
|
175
|
+
expiry = rollover["lease_expires_at"]
|
|
176
|
+
if expiry.is_a?(String) && Time.parse(expiry) <= Time.now.utc
|
|
177
|
+
result = fail_result("rollover_lease_expired", "rollover" => rollover)
|
|
178
|
+
throw :transition_done, result
|
|
179
|
+
end
|
|
180
|
+
if options[:write]
|
|
181
|
+
rollover["lease_expires_at"] = lease_expiry(options[:lease_seconds])
|
|
182
|
+
rollover["last_renewed_at"] = iso_now
|
|
183
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
184
|
+
end
|
|
185
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
186
|
+
when "recover"
|
|
187
|
+
unless rollover.is_a?(Hash) && rollover["status"] == "claimed"
|
|
188
|
+
result = fail_result("rollover_not_claimed", "rollover" => rollover)
|
|
189
|
+
throw :transition_done, result
|
|
190
|
+
end
|
|
191
|
+
expiry = rollover["lease_expires_at"]
|
|
192
|
+
if !expiry.is_a?(String) || Time.parse(expiry) > Time.now.utc
|
|
193
|
+
result = fail_result("rollover_lease_active", "rollover" => rollover)
|
|
194
|
+
throw :transition_done, result
|
|
195
|
+
end
|
|
196
|
+
path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
|
|
197
|
+
unless File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
|
|
198
|
+
result = fail_result("handoff_integrity_failure", "rollover" => rollover)
|
|
199
|
+
throw :transition_done, result
|
|
200
|
+
end
|
|
201
|
+
if options[:write]
|
|
202
|
+
rollover["status"] = "prepared"; rollover["target_session_id"] = nil; rollover["claimed_at"] = nil; rollover.delete("lease_expires_at"); rollover.delete("last_renewed_at")
|
|
135
203
|
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
136
204
|
end
|
|
137
205
|
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
138
206
|
when "consume"
|
|
207
|
+
if rollover.is_a?(Hash) && rollover["status"] == "consumed" && rollover["target_session_id"] == options[:target_session_id]
|
|
208
|
+
result = { "ok" => true, "written" => false, "reused" => true, "rollover" => rollover, "revision" => revision }
|
|
209
|
+
throw :transition_done, result
|
|
210
|
+
end
|
|
139
211
|
unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
|
|
140
212
|
result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
|
|
141
213
|
throw :transition_done, result
|
package/src/constants.ts
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -186,11 +186,17 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
|
|
|
186
186
|
// authority remains STATE.rollover, which the extension verifies and claims
|
|
187
187
|
// during session_switch before it starts the next workflow turn.
|
|
188
188
|
const consumeControlledRollover = async (): Promise<void> => {
|
|
189
|
+
if (rolloverConsumptionInFlight) return;
|
|
190
|
+
rolloverConsumptionInFlight = true;
|
|
191
|
+
try {
|
|
189
192
|
const requestPath = path.join(project.root, ".planning", "rollover-request.json");
|
|
190
193
|
let request: { schema_version?: number; rollover_id?: string; source_session_id?: string };
|
|
194
|
+
let originalRequest: string;
|
|
191
195
|
try {
|
|
192
|
-
|
|
196
|
+
originalRequest = await readFile(requestPath, "utf8");
|
|
197
|
+
request = JSON.parse(originalRequest) as typeof request;
|
|
193
198
|
} catch {
|
|
199
|
+
rolloverConsumptionInFlight = false;
|
|
194
200
|
return;
|
|
195
201
|
}
|
|
196
202
|
if (
|
|
@@ -201,6 +207,7 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
|
|
|
201
207
|
) {
|
|
202
208
|
await rm(requestPath, { force: true });
|
|
203
209
|
mode.showWarning("已忽略无效或陈旧的 Devflow 会话轮换请求;STATE 未被修改。");
|
|
210
|
+
rolloverConsumptionInFlight = false;
|
|
204
211
|
return;
|
|
205
212
|
}
|
|
206
213
|
// Session creation can briefly be unavailable while the interactive mode
|
|
@@ -213,10 +220,39 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
|
|
|
213
220
|
}
|
|
214
221
|
if (!switched) {
|
|
215
222
|
mode.showWarning("Devflow 会话轮换被运行时取消;当前会话保持不变。请检查控制面状态后重试。");
|
|
223
|
+
scheduleControlledRolloverRetry();
|
|
224
|
+
rolloverConsumptionInFlight = false;
|
|
216
225
|
return;
|
|
217
226
|
}
|
|
218
|
-
|
|
227
|
+
let requestAfterSwitch = "";
|
|
228
|
+
try { requestAfterSwitch = await readFile(requestPath, "utf8"); } catch { /* success path normally has no replacement request */ }
|
|
229
|
+
if (!requestAfterSwitch || requestAfterSwitch === originalRequest) {
|
|
230
|
+
await rm(requestPath, { force: true });
|
|
231
|
+
} else {
|
|
232
|
+
scheduleControlledRolloverRetry();
|
|
233
|
+
}
|
|
234
|
+
rolloverConsumptionInFlight = false;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
rolloverConsumptionInFlight = false;
|
|
237
|
+
mode.showWarning(`Devflow 会话轮换运行时异常,将自动重试:${String(error)}`);
|
|
238
|
+
scheduleControlledRolloverRetry();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
let rolloverConsumptionInFlight = false;
|
|
242
|
+
let rolloverRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
243
|
+
let rolloverRetryAttempt = 0;
|
|
244
|
+
const scheduleControlledRolloverRetry = () => {
|
|
245
|
+
if (rolloverRetryTimer) return;
|
|
246
|
+
const delay = Math.min(30_000, 1_000 * 2 ** Math.min(rolloverRetryAttempt, 5));
|
|
247
|
+
rolloverRetryAttempt += 1;
|
|
248
|
+
rolloverRetryTimer = setTimeout(() => {
|
|
249
|
+
rolloverRetryTimer = undefined;
|
|
250
|
+
void consumeControlledRollover();
|
|
251
|
+
}, delay);
|
|
219
252
|
};
|
|
253
|
+
// A prepared handoff is a durable runtime request, not something that
|
|
254
|
+
// should wait for the user to type another command after a restart.
|
|
255
|
+
await consumeControlledRollover();
|
|
220
256
|
while (true) {
|
|
221
257
|
const input = await mode.getUserInput();
|
|
222
258
|
await omp.submitInteractiveInput(mode, runtime.session, input);
|