@guanlin4924/devflow-pi 0.1.34 → 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 CHANGED
@@ -11,7 +11,7 @@ Devflow Pi 是一个面向项目研发交付的独立 OMP 产品入口,内置
11
11
  安装与启动:
12
12
 
13
13
  ```bash
14
- npm install -g @guanlin4924/devflow-pi@0.1.34
14
+ npm install -g @guanlin4924/devflow-pi@0.1.36
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.34",
3
+ "version": "0.1.36",
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
  }
@@ -2722,9 +2746,9 @@ export default function devflowCore(pi: ExtensionAPI) {
2722
2746
  ctx.ui.notify("Devflow workflow is already complete.", "info");
2723
2747
  return;
2724
2748
  }
2725
- // Re-arm a durable rollover left by an interrupted runtime. The
2726
- // original source session is the only session allowed to resume a
2727
- // prepared handoff; the runtime will claim it after the next command.
2749
+ // Re-arm a durable rollover left by an interrupted runtime. An
2750
+ // explicit resume is the user's authorization for a fresh session to
2751
+ // adopt the same project's integrity-checked prepared handoff.
2728
2752
  const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
2729
2753
  const rolloverState = parseJsonResult(rolloverResult);
2730
2754
  const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
@@ -2733,7 +2757,6 @@ export default function devflowCore(pi: ExtensionAPI) {
2733
2757
  rolloverState &&
2734
2758
  typeof rolloverState.revision === "number" &&
2735
2759
  rollover?.status === "prepared" &&
2736
- rollover.source_session_id === currentSessionId &&
2737
2760
  typeof rollover.id === "string"
2738
2761
  ) {
2739
2762
  pendingRollover = {
@@ -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,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 && %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
+ 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
@@ -1,5 +1,5 @@
1
1
  export const PRODUCT_NAME = "Devflow Pi";
2
- export const PRODUCT_VERSION = "0.1.34";
2
+ export const PRODUCT_VERSION = "0.1.36";
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);