@guanlin4924/devflow-pi 0.1.35 → 0.1.37

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 CHANGED
@@ -11,7 +11,7 @@ Devflow Pi 是一个面向项目研发交付的独立 OMP 产品入口,内置
11
11
  安装与启动:
12
12
 
13
13
  ```bash
14
- npm install -g @guanlin4924/devflow-pi@0.1.35
14
+ npm install -g @guanlin4924/devflow-pi@0.1.37
15
15
  cd /path/to/your-project
16
16
  devflow-pi init
17
17
  devflow-pi doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -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
- const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
1349
+ let rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
1350
1350
  const currentSessionId = ctx.sessionManager.getSessionId();
1351
- if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && rollover.source_session_id === currentSessionId && typeof rollover.id === "string") {
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
- if (workflow.result.ok && workflow.snapshot) {
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
  }
@@ -1444,6 +1468,30 @@ export default function devflowCore(pi: ExtensionAPI) {
1444
1468
  return { handled: true };
1445
1469
  }
1446
1470
  }
1471
+ // A step transition can succeed before the runtime manages to prepare the
1472
+ // fresh-session handoff. Keep approval useful in this recovery state, but
1473
+ // retry only the handoff; never repeat the already-persisted transition.
1474
+ if (controlState.phase === "blocked" && controlState.label === "会话轮换准备失败" && controlAction === "approve") {
1475
+ const inspected = await inspectWorkflow(ctx.cwd);
1476
+ if (!inspected.result.ok || !inspected.snapshot) {
1477
+ ctx.ui.notify(`会话交接重试失败:${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
1478
+ return { handled: true };
1479
+ }
1480
+ if (inspected.snapshot.action === "workflow_complete") {
1481
+ setControlState(ctx, initialControlState());
1482
+ ctx.ui.notify("工作流已完成。", "info");
1483
+ return { handled: true };
1484
+ }
1485
+ setControlState(ctx, waitingUserState("正在重试干净会话交接"));
1486
+ try {
1487
+ await requestControlledRollover(ctx, inspected.snapshot);
1488
+ ctx.ui.notify("已重新登记会话交接;当前回合结束后将自动切换到下一节点。", "info");
1489
+ } catch (error) {
1490
+ setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "会话轮换准备失败" });
1491
+ ctx.ui.notify(`会话交接仍未完成:${String(error)}`, "error");
1492
+ }
1493
+ return { handled: true };
1494
+ }
1447
1495
  if (controlState.phase === "awaiting-approval" || controlState.phase === "waiting-user") {
1448
1496
  if (controlAction === "reject") {
1449
1497
  ctx.ui.notify("已保留当前步骤和产物。请直接说明需要修改的内容;未执行任何推进。", "info");
@@ -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
- options = { json: false, write: false }
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,38 @@ 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 && %w[prepared claimed].include?(rollover["status"])
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
+ if expected_state_revision.is_a?(Integer) && expected_state_revision < revision - 1
102
+ # The state has moved beyond the revision captured by this prepared
103
+ # handoff. It is an orphan from an interrupted/retried runtime turn,
104
+ # not an active lock. Verify its immutable evidence, preserve the
105
+ # handoff file on disk, and replace the STATE pointer with a handoff
106
+ # for the current revision.
107
+ existing_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
108
+ unless File.exist?(existing_path) && Digest::SHA256.hexdigest(File.binread(existing_path)) == rollover["handoff_sha256"]
109
+ result = fail_result("handoff_integrity_failure", "rollover" => rollover)
110
+ throw :transition_done, result
111
+ end
112
+ rollover = nil
113
+ else
114
+ result = fail_result("rollover_in_progress", "rollover" => rollover)
115
+ throw :transition_done, result
116
+ end
117
+ elsif rollover && rollover["status"] == "claimed"
81
118
  result = fail_result("rollover_in_progress", "rollover" => rollover)
82
119
  throw :transition_done, result
83
120
  end
@@ -117,6 +154,15 @@ result = catch(:transition_done) do
117
154
  end
118
155
  result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
119
156
  when "claim"
157
+ if rollover.is_a?(Hash) && %w[claimed consumed].include?(rollover["status"]) && rollover["target_session_id"] == options[:target_session_id]
158
+ path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
159
+ if File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
160
+ result = { "ok" => true, "written" => false, "reused" => true, "rollover" => rollover, "revision" => revision }
161
+ throw :transition_done, result
162
+ end
163
+ result = fail_result("handoff_integrity_failure", "rollover" => rollover)
164
+ throw :transition_done, result
165
+ end
120
166
  unless rollover.is_a?(Hash) && rollover["status"] == "prepared"
121
167
  result = fail_result("rollover_not_prepared", "rollover" => rollover)
122
168
  throw :transition_done, result
@@ -131,11 +177,51 @@ result = catch(:transition_done) do
131
177
  throw :transition_done, result
132
178
  end
133
179
  if options[:write]
134
- rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now
180
+ 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])
181
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
182
+ end
183
+ result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
184
+ when "renew"
185
+ unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
186
+ result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
187
+ throw :transition_done, result
188
+ end
189
+ expiry = rollover["lease_expires_at"]
190
+ if expiry.is_a?(String) && Time.parse(expiry) <= Time.now.utc
191
+ result = fail_result("rollover_lease_expired", "rollover" => rollover)
192
+ throw :transition_done, result
193
+ end
194
+ if options[:write]
195
+ rollover["lease_expires_at"] = lease_expiry(options[:lease_seconds])
196
+ rollover["last_renewed_at"] = iso_now
197
+ state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
198
+ end
199
+ result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
200
+ when "recover"
201
+ unless rollover.is_a?(Hash) && rollover["status"] == "claimed"
202
+ result = fail_result("rollover_not_claimed", "rollover" => rollover)
203
+ throw :transition_done, result
204
+ end
205
+ expiry = rollover["lease_expires_at"]
206
+ if !expiry.is_a?(String) || Time.parse(expiry) > Time.now.utc
207
+ result = fail_result("rollover_lease_active", "rollover" => rollover)
208
+ throw :transition_done, result
209
+ end
210
+ path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
211
+ unless File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
212
+ result = fail_result("handoff_integrity_failure", "rollover" => rollover)
213
+ throw :transition_done, result
214
+ end
215
+ if options[:write]
216
+ rollover["status"] = "prepared"; rollover["target_session_id"] = nil; rollover["claimed_at"] = nil; rollover.delete("lease_expires_at"); rollover.delete("last_renewed_at")
135
217
  state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
136
218
  end
137
219
  result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
138
220
  when "consume"
221
+ if rollover.is_a?(Hash) && rollover["status"] == "consumed" && rollover["target_session_id"] == options[:target_session_id]
222
+ result = { "ok" => true, "written" => false, "reused" => true, "rollover" => rollover, "revision" => revision }
223
+ throw :transition_done, result
224
+ end
139
225
  unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
140
226
  result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
141
227
  throw :transition_done, result
@@ -132,7 +132,7 @@ module WorkflowStateValidator
132
132
  return
133
133
  end
134
134
  status = rollover["status"]
135
- unless %w[prepared claimed consumed failed].include?(status)
135
+ unless %w[prepared claimed consumed superseded failed].include?(status)
136
136
  issues << issue("error", "invalid_rollover_status", "STATE.rollover.status is invalid", status: status)
137
137
  return
138
138
  end
package/src/constants.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.35";
2
+ export const PRODUCT_VERSION = "0.1.37";
3
3
  export const PROJECT_SCHEMA_VERSION = 1;
4
4
  export const OMP_RUNTIME_VERSION = "17.2.7";
5
5
 
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
- request = JSON.parse(await readFile(requestPath, "utf8")) as typeof request;
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
- await rm(requestPath, { force: true });
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);