magic-agent 2.3.0 → 2.3.9

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.
Files changed (59) hide show
  1. package/CLIENT-CONTRACT.md +78 -13
  2. package/README.md +10 -4
  3. package/client-ui/pc/app.css +14 -0
  4. package/client-ui/pc/js/93-rail.js +3 -1
  5. package/client-ui/shared/conv/03-chat-message.js +20 -3
  6. package/client-ui/shared/conv/07-stream-delegation.js +15 -0
  7. package/client-ui/shared/conv/27-conv-ledger.js +1 -1
  8. package/client-ui/shared/conv/conv.css +17 -0
  9. package/client-ui/shared/conversation.js +209 -39
  10. package/client-ui/shared/transport-http.js +193 -38
  11. package/client-ui-mobile/index.html +733 -107
  12. package/client-ui-mobile/reqs.js +42 -29
  13. package/internal/agent/capability.go +8 -1
  14. package/internal/agent/claude.go +5 -3
  15. package/internal/agent/codebuddy.go +6 -2
  16. package/internal/agent/codex.go +7 -2
  17. package/internal/agent/dsh_sdk.go +8 -0
  18. package/internal/agent/dsh_sdk_test.go +17 -0
  19. package/internal/agent/engine.go +20 -0
  20. package/internal/agent/engine_test.go +52 -0
  21. package/internal/agent/llmengine.go +6 -0
  22. package/internal/agent/llmengine_test.go +7 -0
  23. package/internal/agent/llmhttp.go +6 -0
  24. package/internal/agent/llmhttp_test.go +27 -0
  25. package/internal/agent/llmmodels.go +13 -0
  26. package/internal/agent/permission.go +5 -0
  27. package/internal/cli/ask.go +32 -17
  28. package/internal/cli/requirements.go +2 -0
  29. package/internal/cli/sessionlog.go +5 -0
  30. package/internal/cli/sessionlog_user_test.go +49 -0
  31. package/internal/client/cors_test.go +32 -0
  32. package/internal/client/jobs.go +1160 -0
  33. package/internal/client/jobs_test.go +369 -0
  34. package/internal/client/requirements.go +27 -7
  35. package/internal/client/requirements_test.go +51 -0
  36. package/internal/client/service.go +259 -142
  37. package/internal/client/service_test.go +78 -0
  38. package/internal/requirements/consolidate.go +86 -24
  39. package/internal/requirements/requirements.go +9 -0
  40. package/internal/requirements/requirements_test.go +129 -0
  41. package/internal/requirements/store.go +11 -3
  42. package/internal/session/session.go +15 -1
  43. package/internal/session/workspace_lock.go +165 -0
  44. package/internal/session/workspace_lock_test.go +90 -0
  45. package/internal/suggest/suggest.go +101 -7
  46. package/internal/suggest/suggest_test.go +44 -0
  47. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  48. package/npm/apk/version.json +3 -3
  49. package/npm/dist/darwin-arm64/magic-agent +0 -0
  50. package/npm/dist/darwin-arm64/magic-client +0 -0
  51. package/npm/dist/darwin-x64/magic-agent +0 -0
  52. package/npm/dist/darwin-x64/magic-client +0 -0
  53. package/npm/dist/linux-arm64/magic-agent +0 -0
  54. package/npm/dist/linux-arm64/magic-client +0 -0
  55. package/npm/dist/linux-x64/magic-agent +0 -0
  56. package/npm/dist/linux-x64/magic-client +0 -0
  57. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  58. package/npm/dist/win32-x64/magic-client.exe +0 -0
  59. package/package.json +3 -2
@@ -128,7 +128,15 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
128
128
  if Root() == "" {
129
129
  return Result{Workspace: ws.ID, Dir: ws.Dir, Error: "无法确定需求存储目录"}, fmt.Errorf("requirements: no storage root")
130
130
  }
131
- res := Result{Workspace: ws.ID, Dir: ws.Dir}
131
+ moduleScope := SlugifyModule(opts.Module)
132
+ res := Result{Workspace: ws.ID, Dir: ws.Dir, Module: moduleScope}
133
+
134
+ // 模块级整理只更新已有模块,不接受一个不存在的名字(否则模型会把新需求
135
+ // 塞进一个凭空的目录,用户在清单里根本找不到它)。
136
+ if moduleScope != "" && !contains(listModules(ws.ID), moduleScope) {
137
+ res.Error = "模块不存在: " + opts.Module
138
+ return res, fmt.Errorf("requirements: module not found: %s", opts.Module)
139
+ }
132
140
 
133
141
  // 锁:同一工作区同时只允许一轮归纳(客户端防抖 + 手动「立即整理」可能撞上)。
134
142
  release, busy := acquireLock(ws.ID)
@@ -152,8 +160,9 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
152
160
  /* 首次「整理」:先按空间目录反推一版需求基线,再把旧会话记为已消费。
153
161
  判断依据是 state.BootstrapAt(不是文档数 / 不是 LastConsolidatedAt):
154
162
  自动归纳可能已经产出过几篇文档,但「有没有按目录初始化过」是另一件事。
155
- opts.Auto 的自动路径不初始化 —— 它每轮都可能跑,不能悄悄花几分钟读代码。 */
156
- if st.BootstrapAt == 0 && !opts.Auto {
163
+ opts.Auto 的自动路径不初始化 —— 它每轮都可能跑,不能悄悄花几分钟读代码。
164
+ 模块级整理也不初始化:目录基线是空间级的,模块只做增量。 */
165
+ if st.BootstrapAt == 0 && !opts.Auto && moduleScope == "" {
157
166
  boot, berr := bootstrapWorkspace(ctx, ws, opts)
158
167
  if berr != nil {
159
168
  res.Error = truncRunes(berr.Error(), 240)
@@ -206,8 +215,11 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
206
215
  if cursor > len(mem) {
207
216
  cursor = len(mem) // 记忆文件被外部截断 → 游标跟着收敛,不越界
208
217
  }
218
+ /* 模块级整理**不重放**(--requirement-all 是空间级回填),只看游标之后的
219
+ 新记忆;也不推进游标,否则别的模块的需求会被这一轮悄悄吞掉。 */
220
+ replay := opts.All && moduleScope == ""
209
221
  entries := mem[cursor:]
210
- if opts.All {
222
+ if replay {
211
223
  entries = mem
212
224
  }
213
225
  if len(entries) == 0 && !opts.Force {
@@ -218,7 +230,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
218
230
  }
219
231
 
220
232
  use := entries
221
- if opts.All {
233
+ if replay {
222
234
  if len(use) > MaxReplayEntries {
223
235
  use = use[len(use)-MaxReplayEntries:]
224
236
  }
@@ -241,7 +253,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
241
253
  return res, callErr
242
254
  }
243
255
 
244
- chunks := chunkEntries(use, opts.All)
256
+ chunks := chunkEntries(use, replay)
245
257
  merger := newDocMerger(ws.ID)
246
258
  byN := make(map[int]Entry, len(mem))
247
259
  for _, e := range mem {
@@ -256,7 +268,15 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
256
268
  // 每片重新读已有文档:上一片刚写的要点必须出现在这一片的「已有需求点」里,
257
269
  // 否则同一件事会被重复追加。
258
270
  docs := featureDocsOf(ws.ID)
259
- prompt := buildPrompt(wsName, docs, chunk, listModules(ws.ID), opts.All)
271
+ modules := listModules(ws.ID)
272
+ /* 模块级整理:只让模型看到这个模块的已有文档与模块名 ——
273
+ 否则它会把别的模块的新需求也一起写回来(那正是用户点的这一下
274
+ 「只整理一个模块」所不能接受的事)。 */
275
+ if moduleScope != "" {
276
+ docs = docsOfModule(docs, moduleScope)
277
+ modules = []string{moduleScope}
278
+ }
279
+ prompt := buildPrompt(wsName, docs, chunk, modules, replay, moduleScope)
260
280
  text, err := call(prompt, outSchema)
261
281
  res.Calls++
262
282
  if err != nil {
@@ -276,28 +296,34 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
276
296
 
277
297
  // 新模块的同义二审:归纳是逐轮独立的,模型容易把同一件事拆成近义模块
278
298
  //(「任务列表」/「对话列表」)。只有确认已有模块里没有同义项时才允许新建。
299
+ // 模块级整理不新建模块(作用域就是这个已有模块),因而也不做二审。
279
300
  alias := map[string]string{}
280
- existing := listModules(ws.ID)
281
- newMods := []string{}
282
- seenNew := map[string]bool{}
283
- for _, o := range outs {
284
- m := SlugifyModule(o.Module)
285
- if m == "" || seenNew[m] || contains(existing, m) {
286
- continue
301
+ if moduleScope == "" {
302
+ existing := listModules(ws.ID)
303
+ newMods := []string{}
304
+ seenNew := map[string]bool{}
305
+ for _, o := range outs {
306
+ m := SlugifyModule(o.Module)
307
+ if m == "" || seenNew[m] || contains(existing, m) {
308
+ continue
309
+ }
310
+ seenNew[m] = true
311
+ newMods = append(newMods, m)
312
+ }
313
+ if len(newMods) > 0 && len(existing) > 0 {
314
+ alias = resolveModuleAlias(call, newMods, existing)
287
315
  }
288
- seenNew[m] = true
289
- newMods = append(newMods, m)
290
- }
291
- if len(newMods) > 0 && len(existing) > 0 {
292
- alias = resolveModuleAlias(call, newMods, existing)
293
316
  }
294
317
 
295
318
  for _, o := range outs {
319
+ if moduleScope != "" && !outputInModule(o, moduleScope, merger) {
320
+ continue
321
+ }
296
322
  w := writeFeatureItem(ws.ID, o, &writeCtx{
297
323
  merger: merger,
298
324
  alias: alias,
299
325
  kind: "归纳",
300
- all: opts.All,
326
+ all: replay,
301
327
  sourcesOf: func(x featureOut) []string {
302
328
  out := make([]string, 0, len(x.Sources))
303
329
  for _, n := range x.Sources {
@@ -324,7 +350,7 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
324
350
  }
325
351
 
326
352
  consumed := cursor
327
- if !opts.All {
353
+ if !replay && moduleScope == "" {
328
354
  consumed = cursor + len(use)
329
355
  if consumed > len(mem) {
330
356
  consumed = len(mem)
@@ -339,11 +365,11 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
339
365
  }
340
366
  res.OK = true
341
367
  res.Consumed = 0
342
- if !opts.All {
368
+ if !replay && moduleScope == "" {
343
369
  res.Consumed = len(use)
344
370
  }
345
371
  res.Replayed = 0
346
- if opts.All {
372
+ if replay {
347
373
  res.Replayed = len(use)
348
374
  }
349
375
  res.Pending = len(mem) - consumed
@@ -435,12 +461,38 @@ func featureDocsOf(id string) []existsDoc {
435
461
  return out
436
462
  }
437
463
 
464
+ // docsOfModule 只留某个模块下的已有功能点(模块级整理的作用域)。
465
+ func docsOfModule(docs []existsDoc, module string) []existsDoc {
466
+ out := make([]existsDoc, 0, len(docs))
467
+ for _, d := range docs {
468
+ if SlugifyModule(d.Module) == module {
469
+ out = append(out, d)
470
+ }
471
+ }
472
+ return out
473
+ }
474
+
475
+ // outputInModule 一条模型增量是否属于本次模块级整理的作用域。
476
+ //
477
+ // 已有功能点按 name 匹配后以它**当前所属模块**为准(与 writeFeatureItem 同一口径:
478
+ // 文档一旦归类就不搬家);新功能点才看模型给的 module。
479
+ func outputInModule(o featureOut, scope string, merger *docMerger) bool {
480
+ name := strings.TrimSpace(truncRunes(strings.TrimSpace(o.Name), TitleMaxChars))
481
+ if name == "" {
482
+ return false
483
+ }
484
+ if hit, has := merger.match(name); has {
485
+ return SlugifyModule(hit.Module) == scope
486
+ }
487
+ return SlugifyModule(o.Module) == scope
488
+ }
489
+
438
490
  // buildPrompt 组归纳 prompt。
439
491
  //
440
492
  // 规则段是**产品口径**,不是技术参数:哪些话不能写进需求点(缺陷措辞)、
441
493
  // 什么样的模块必须复用、结论与问题的界限。这些是业务项目半年里一条条纠正出来的,
442
494
  // 换实现时原样保留,别按自己的口味重写。
443
- func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []string, replay bool) string {
495
+ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []string, replay bool, scope string) string {
444
496
  mods := make([]string, 0, len(modules))
445
497
  for _, m := range modules {
446
498
  if strings.TrimSpace(m) != "" {
@@ -531,9 +583,19 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
531
583
  "desc 可用记忆重新校核改写(这是唯一允许改写 desc 的场景)," +
532
584
  "需求点/结论里已有的一字不改照抄、缺的补上。"
533
585
  }
586
+ /* 模块级整理:模型只该动这一个模块。必须写清楚「别的记忆忽略」——
587
+ 否则它会把整段对话重新归类,用户点一个模块却把别的模块也改了。 */
588
+ if scope != "" {
589
+ rules[0] = "0. **本次只整理模块「" + scope + "」**:只输出属于这个模块的功能点" +
590
+ "(新功能点的 module 必须逐字写「" + scope + "」,已有功能点沿用它的 name)。" +
591
+ "不属于这个模块的记忆**一律忽略** —— 它们会在「整理整个空间」时处理。"
592
+ }
534
593
 
535
594
  var b strings.Builder
536
595
  b.WriteString("你是资深产品经理,负责把「对话记忆」归纳成**两级**功能点需求文档的**增量**。工作区:" + wsName + "\n\n")
596
+ if scope != "" {
597
+ b.WriteString("【模块级整理】本次作用域:模块「" + scope + "」。\n\n")
598
+ }
537
599
  b.WriteString("== 已有模块(大功能点 · 一级)==\n")
538
600
  if len(mods) == 0 {
539
601
  b.WriteString("(暂无模块,可新建)")
@@ -127,6 +127,13 @@ type Options struct {
127
127
  Engine string
128
128
  // Model 归纳用哪个模型(空 = 引擎默认)。
129
129
  Model string
130
+ // Module 只整理这个模块(空 = 整个空间)。
131
+ //
132
+ // 语义:只把「属于该模块」的增量写回该模块,**不推进空间游标** ——
133
+ // 其它模块的记忆仍留在 pending,等空间级「整理」或下一次自动归纳处理。
134
+ // 因此模块级整理是「只增不减、可重复跑」的,不会吞掉别的模块的需求。
135
+ // ⚠️ 它不会触发首次目录初始化(目录基线天然是空间级的)。
136
+ Module string
130
137
  // Timeout 单次调用超时(0 = 归纳 240s;首次目录初始化 9m)。
131
138
  Timeout time.Duration
132
139
  // Auto 自动归纳(插件的轮末防抖 / 每日兜底)。
@@ -141,6 +148,8 @@ type Result struct {
141
148
  // Workspace / Dir 归纳的是哪个工作区。
142
149
  Workspace string `json:"workspace,omitempty"`
143
150
  Dir string `json:"dir"`
151
+ // Module 这一轮只整理了哪个模块(空 = 整个空间)。
152
+ Module string `json:"module,omitempty"`
144
153
  // Noop 没有未归纳记忆、也没要求强制跑(等于什么都没做,不是错)。
145
154
  Noop bool `json:"noop,omitempty"`
146
155
  // Bootstrap 这一轮是否完成了首次初始化。
@@ -326,6 +326,27 @@ func TestWorkspacesGroupsAndSorts(t *testing.T) {
326
326
  }
327
327
  }
328
328
 
329
+ // TestResolveWorkspaceByID 按 ws id 也能解析(客户端手里可能只有 id、没有目录)。
330
+ //
331
+ // 老实现的 safeID 把前缀 `ws-` 里的 w/s 也按十六进制校验,任何 id 都过不了:
332
+ // `--consolidate-requirements ws-xxxx` 直接报「工作区不存在」。
333
+ func TestResolveWorkspaceByID(t *testing.T) {
334
+ root := testEnv(t)
335
+ proj := filepath.Join(root, "proj-id")
336
+ if err := os.MkdirAll(proj, 0o700); err != nil {
337
+ t.Fatal(err)
338
+ }
339
+ e := registerWorkspace(proj)
340
+ got, ok := resolveWorkspace(e.ID)
341
+ if !ok || got.ID != e.ID || got.Dir != proj {
342
+ t.Fatalf("按 id 应解析出 %s/%s,实际 %+v ok=%v", e.ID, proj, got, ok)
343
+ }
344
+ // 路径穿越仍要被挡住。
345
+ if safeID("ws-../../etc") || safeID("ws-") || safeID("x-ws-1") {
346
+ t.Error("非法 id 必须被拒绝")
347
+ }
348
+ }
349
+
329
350
  // TestConsolidatePipeline 走完整链路:摄入 → prompt → 模型输出 → 合并 → 游标前进。
330
351
  // 模型用假调用器(真实归纳的网络/CLI 由真机验收覆盖),验的是代码这一侧。
331
352
  func TestConsolidatePipeline(t *testing.T) {
@@ -411,6 +432,114 @@ func TestConsolidatePipeline(t *testing.T) {
411
432
  }
412
433
  }
413
434
 
435
+ // TestConsolidateModuleScope 模块级整理只动这个模块,且不推进空间游标。
436
+ //
437
+ // 用户 2026-10-23 报障:「点需求的一个模块的整理却触发了所有的整理」。
438
+ // 这里钉住三件事:
439
+ // - 模型只看到这个模块的已有文档(看不到别的模块,才不会把别的模块也改了);
440
+ // - 模型即使输出别的模块的增量,也不落盘;
441
+ // - 空间游标不动 —— 别的模块的需求留给空间级整理,不能被这一下吞掉。
442
+ func TestConsolidateModuleScope(t *testing.T) {
443
+ root := testEnv(t)
444
+ proj := filepath.Join(root, "proj-module")
445
+ if err := os.MkdirAll(proj, 0o700); err != nil {
446
+ t.Fatal(err)
447
+ }
448
+ writeSessionLog(t, session.Dir(), "s-mod", proj, "手机号登录要加验证码", "购物车要支持结算")
449
+
450
+ id := DirID(proj)
451
+ merger := newDocMerger(id)
452
+ wctx := &writeCtx{merger: merger, kind: "归纳"}
453
+ for _, o := range []featureOut{
454
+ {Module: "登录", Name: "手机号登录", Points: []string{"手机号可登录"}},
455
+ {Module: "支付", Name: "购物车结算", Points: []string{"购物车可结算"}},
456
+ } {
457
+ if w := writeFeatureItem(id, o, wctx); !w.ok {
458
+ t.Fatalf("预置文档失败: %+v", w)
459
+ }
460
+ }
461
+ // 已初始化 + 游标 0:模拟「有一批待整理记忆,已过了首次目录初始化」。
462
+ if err := writeState(id, state{BootstrapAt: time.Now().UnixMilli(), Sessions: map[string]memCursor{}}); err != nil {
463
+ t.Fatal(err)
464
+ }
465
+
466
+ old := newCaller
467
+ defer func() { newCaller = old }()
468
+ var prompts []string
469
+ newCaller = func(ctx context.Context, engineName, model string, timeout time.Duration) (llmCall, error) {
470
+ return func(prompt string, schema *agent.JSONSchema) (string, error) {
471
+ prompts = append(prompts, prompt)
472
+ return `{"features":[
473
+ {"module":"登录","name":"手机号登录","points":["支持验证码登录"],"sources":[1]},
474
+ {"module":"支付","name":"购物车结算","points":["支持优惠券"],"sources":[2]}
475
+ ]}`, nil
476
+ }, nil
477
+ }
478
+
479
+ res, err := Consolidate(context.Background(), proj, Options{Module: "登录"})
480
+ if err != nil {
481
+ t.Fatalf("模块级整理失败: %v", err)
482
+ }
483
+ if !res.OK || res.Module != "登录" {
484
+ t.Fatalf("结果应标明模块级整理: %+v", res)
485
+ }
486
+ if res.Consumed != 0 {
487
+ t.Errorf("模块级整理不该推进游标,实际 consumed=%d", res.Consumed)
488
+ }
489
+ if st := readState(id); st.Consumed != 0 {
490
+ t.Errorf("state 游标不该被模块级整理推进,实际 %d", st.Consumed)
491
+ }
492
+ if res.Updated != 1 || res.Created != 0 {
493
+ t.Errorf("只该更新登录模块的 1 篇,实际 %+v", res)
494
+ }
495
+ if len(prompts) != 1 {
496
+ t.Fatalf("应只调 1 次模型,实际 %d", len(prompts))
497
+ }
498
+ if !strings.Contains(prompts[0], "本次只整理模块「登录」") || !strings.Contains(prompts[0], "【模块级整理】") {
499
+ t.Errorf("prompt 应写明模块作用域:\n%s", prompts[0])
500
+ }
501
+ if strings.Contains(prompts[0], "购物车结算") || strings.Contains(prompts[0], "[模块 支付]") {
502
+ t.Errorf("prompt 不该带出别的模块的文档:\n%s", prompts[0])
503
+ }
504
+
505
+ moduleOf := func(name string) (string, Doc) {
506
+ for _, f := range listFeatureFiles(id) {
507
+ if f.Name == name {
508
+ return f.Module, ParseDoc(readFile(t, f.File))
509
+ }
510
+ }
511
+ t.Fatalf("找不到文档 %s", name)
512
+ return "", Doc{}
513
+ }
514
+ if mod, d := moduleOf("手机号登录"); mod != "登录" || !contains(d.Points, "支持验证码登录") {
515
+ t.Errorf("登录模块应收到本模块增量,实际[%s]%+v", mod, d.Points)
516
+ }
517
+ if _, d := moduleOf("购物车结算"); len(d.Points) != 1 || contains(d.Points, "支持优惠券") {
518
+ t.Errorf("别的模块不该被模块级整理改动,实际 %+v", d.Points)
519
+ }
520
+
521
+ // 空间级整理仍应消费这批记忆:两个模块都更新,游标前进。
522
+ res2, err := Consolidate(context.Background(), proj, Options{})
523
+ if err != nil {
524
+ t.Fatalf("空间级整理失败: %v", err)
525
+ }
526
+ if res2.Consumed != 2 || res2.Updated != 2 {
527
+ t.Errorf("空间级整理应消费 2 条并更新 2 篇,实际 %+v", res2)
528
+ }
529
+ if st := readState(id); st.Consumed != 2 {
530
+ t.Errorf("空间级整理后游标应为 2,实际 %d", st.Consumed)
531
+ }
532
+ if _, d := moduleOf("购物车结算"); !contains(d.Points, "支持优惠券") {
533
+ t.Errorf("空间级整理应补上支付模块的增量,实际 %+v", d.Points)
534
+ }
535
+
536
+ // 不存在的模块要明确报错,不能凭空建目录。
537
+ if _, err := Consolidate(context.Background(), proj, Options{Module: "不存在"}); err == nil ||
538
+ !strings.Contains(err.Error(), "module not found") {
539
+ t.Errorf("不存在的模块应报错,实际 %v", err)
540
+ }
541
+ }
542
+
414
543
  // TestConsolidateBootstrapsOnceThenIncremental 首次「整理」按目录反推,之后只处理新会话。
415
544
  //
416
545
  // 这是 2026-10-22 用户口径的代码化:
@@ -77,11 +77,19 @@ func registryFile() string { return filepath.Join(Root(), "workspaces.json") }
77
77
 
78
78
  // safeID 工作区 id 会拼进路径 → 只放行本包生成过的形状(挡目录穿越)。
79
79
  func safeID(id string) bool {
80
- if !strings.HasPrefix(id, "ws-") || len(id) > 40 {
80
+ const prefix = "ws-"
81
+ if !strings.HasPrefix(id, prefix) || len(id) > 40 {
81
82
  return false
82
83
  }
83
- for _, r := range id {
84
- if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || r == '-' {
84
+ // 前缀 `ws-` 本身含 w/s,不能拿去跑十六进制校验(老实现的循环把整个 id
85
+ // 都按 [0-9a-f-] 过滤,于是任何 ws id 都过不了 —— 按 id 整理 workspace
86
+ // 会报「工作区不存在」)。只校验前缀之后的部分。
87
+ rest := strings.TrimPrefix(id, prefix)
88
+ if rest == "" {
89
+ return false
90
+ }
91
+ for _, r := range rest {
92
+ if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') {
85
93
  continue
86
94
  }
87
95
  return false
@@ -325,6 +325,8 @@ type Handle struct {
325
325
  noPersist bool
326
326
  // lastTouch 上次 Touch 续期的时间(节流用,见 Touch)。
327
327
  lastTouch time.Time
328
+ // lock 工作空间串行锁(见 workspace_lock.go;未启用时为 nil)。
329
+ lock *WorkspaceLock
328
330
  }
329
331
 
330
332
  // RunID 本次运行的 id。
@@ -416,6 +418,7 @@ func (h *Handle) Finish(sessionID, state string) {
416
418
  if h == nil {
417
419
  return
418
420
  }
421
+ defer h.lock.Release() // 工作空间锁随本轮结束释放(未启用时是 nil,安全)
419
422
  if state == StateFailed {
420
423
  if cur, err := readRecord(h.path); err == nil && isTerminalOverride(cur.State) {
421
424
  state = cur.State
@@ -478,6 +481,17 @@ func Begin(o BeginOptions) (*Handle, error) {
478
481
  if !o.NoPersist {
479
482
  dropUnreadable(dir)
480
483
  }
484
+ /* 工作空间串行锁(选入式,见 workspace_lock.go):拿到锁之后才写 running 记录,
485
+ 别的进程看到的「在跑」就一定是已经可以开跑的。
486
+ ⚠️ 团队子任务(ParentRunID 非空)不参与:它们与主任务同属一个工作空间,
487
+ 再取一把锁就是自己锁自己。 */
488
+ var wl *WorkspaceLock
489
+ if strings.TrimSpace(o.ParentRunID) == "" {
490
+ var lerr error
491
+ if wl, lerr = acquireWorkspaceLock(o.Workspace); lerr != nil {
492
+ return nil, lerr
493
+ }
494
+ }
481
495
 
482
496
  now := time.Now()
483
497
  rec := Record{
@@ -503,7 +517,7 @@ func Begin(o BeginOptions) (*Handle, error) {
503
517
  }
504
518
  // path 照样算出来:追加入口的 socket 与它同目录同前缀(见 AppendSocketPath),
505
519
  // 句柄里留着它,SetAppendPath 之类也不用知道自己是不是在存档模式。
506
- h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist}
520
+ h := &Handle{rec: rec, path: filepath.Join(dir, rec.RunID+".json"), noPersist: o.NoPersist, lock: wl}
507
521
  h.write() // noPersist 时这里什么都不做
508
522
  // 「新起即停旧」:同一条会话上还活着的上一次运行先停掉(见 supersedeSiblings)。
509
523
  if !o.NoPersist && rec.SessionID != "" {
@@ -0,0 +1,165 @@
1
+ package session
2
+
3
+ import (
4
+ "crypto/sha1"
5
+ "encoding/hex"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "strconv"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/darren/magic-agent/internal/agent"
14
+ )
15
+
16
+ /* ── 工作空间串行锁(2026-10-23,多客户端串行的一部分)──
17
+
18
+ 为什么要锁在 core 而不是只靠 magic-client 的调度器:
19
+ · 同一台桌面上,除了 magic-client 起的轮次,还有人直接跑 `magic-agent -p`;
20
+ · 多台手机连的是同一个 magic-client,但**进程间**(多个 magic-client 实例)也可能并存。
21
+ 调度器只能管自己进程里的队列;一个落在文件系统上的锁才能让「谁先跑」对所有进程一致。
22
+
23
+ 为什么是选入式(MAGIC_AGENT_WORKSPACE_LOCK=1):
24
+ 默认开启会改变 CLI 的既有语义(两条命令并发跑本来是允许的),也会让团队模式的
25
+ 子任务与主任务互相等待。这里只在 magic-client spawn core 时注入该环境变量,
26
+ 团队子任务(ParentRunID 非空)自动跳过。
27
+
28
+ 锁的形态:<sessions dir>/locks/<sha1(workspace)>.lock,内容 `<pid>\n<unix nano>`。
29
+ · 用 O_CREATE|O_EXCL 原子创建;
30
+ · 持锁进程已不在 / 文件超过 staleAfter → 视为陈旧并回收;
31
+ · 进程退出没来得及删也不会永久堵住(下一步的持有者会按 pid 判死回收)。
32
+ */
33
+
34
+ // EnvWorkspaceLock 打开「工作空间串行锁」的环境变量(值为 "1" 时生效)。
35
+ const EnvWorkspaceLock = "MAGIC_AGENT_WORKSPACE_LOCK"
36
+
37
+ // EnvWorkspaceLockWait 等锁上限(Go duration;默认 10m)。
38
+ const EnvWorkspaceLockWait = "MAGIC_AGENT_WORKSPACE_LOCK_WAIT"
39
+
40
+ const (
41
+ workspaceLockPoll = 500 * time.Millisecond
42
+ workspaceLockStale = 6 * time.Hour
43
+ )
44
+
45
+ // WorkspaceLock 一把已持有的工作空间锁;Release 幂等。
46
+ type WorkspaceLock struct {
47
+ path string
48
+ held bool
49
+ }
50
+
51
+ func workspaceLockEnabled() bool {
52
+ return strings.TrimSpace(os.Getenv(EnvWorkspaceLock)) == "1"
53
+ }
54
+
55
+ func workspaceLockWait() time.Duration {
56
+ if v := strings.TrimSpace(os.Getenv(EnvWorkspaceLockWait)); v != "" {
57
+ if d, err := time.ParseDuration(v); err == nil && d > 0 {
58
+ return d
59
+ }
60
+ }
61
+ return 10 * time.Minute
62
+ }
63
+
64
+ // lockKey 归一工作空间:空 = 进程 cwd;返回绝对路径 + 哈希文件名。
65
+ func workspaceLockKey(workspace string) (key, file string) {
66
+ ws := strings.TrimSpace(workspace)
67
+ if ws == "" {
68
+ ws, _ = os.Getwd()
69
+ }
70
+ if abs, err := filepath.Abs(ws); err == nil {
71
+ ws = abs
72
+ }
73
+ ws = filepath.Clean(ws)
74
+ sum := sha1.Sum([]byte(ws))
75
+ return ws, hex.EncodeToString(sum[:])
76
+ }
77
+
78
+ /*
79
+ AcquireWorkspaceLock 取工作空间锁(阻塞,直到拿到或超时)。
80
+
81
+ o.NoPersist 没关系:锁在 sessions 目录,不依赖会话记录落盘。
82
+ 返回 nil 表示未启用(调用方无需处理)。
83
+ */
84
+ func acquireWorkspaceLock(workspace string) (*WorkspaceLock, error) {
85
+ if !workspaceLockEnabled() {
86
+ return nil, nil
87
+ }
88
+ dir := Dir()
89
+ if dir == "" {
90
+ return nil, fmt.Errorf("cannot resolve sessions dir")
91
+ }
92
+ lockDir := filepath.Join(dir, "locks")
93
+ if err := os.MkdirAll(lockDir, 0o700); err != nil {
94
+ return nil, err
95
+ }
96
+ ws, name := workspaceLockKey(workspace)
97
+ path := filepath.Join(lockDir, name+".lock")
98
+ deadline := time.Now().Add(workspaceLockWait())
99
+ announced := false
100
+ for {
101
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
102
+ if err == nil {
103
+ _, _ = fmt.Fprintf(f, "%d\n%d\n", os.Getpid(), time.Now().UnixNano())
104
+ _ = f.Close()
105
+ return &WorkspaceLock{path: path, held: true}, nil
106
+ }
107
+ if !os.IsExist(err) {
108
+ return nil, err
109
+ }
110
+ if reapStaleWorkspaceLock(path) {
111
+ continue
112
+ }
113
+ if time.Now().After(deadline) {
114
+ return nil, fmt.Errorf("等待工作空间 %s 上的上一个任务超时(%s),可用 %s 调整", ws, workspaceLockWait(), EnvWorkspaceLockWait)
115
+ }
116
+ if !announced {
117
+ announced = true
118
+ fmt.Fprintf(os.Stderr, "magic-agent: 同一工作空间已有任务在跑,等待其结束(%s)…\n", ws)
119
+ }
120
+ time.Sleep(workspaceLockPoll)
121
+ }
122
+ }
123
+
124
+ // reapStaleWorkspaceLock 回收陈旧锁(pid 已不在 / 文件太老);返回是否已删。
125
+ func reapStaleWorkspaceLock(path string) bool {
126
+ st, err := os.Stat(path)
127
+ if err != nil {
128
+ return !os.IsNotExist(err) // 文件刚消失:让调用方重试
129
+ }
130
+ stale := time.Since(st.ModTime()) > workspaceLockStale
131
+ if !stale {
132
+ if b, err := os.ReadFile(path); err == nil {
133
+ lines := strings.Split(strings.TrimSpace(string(b)), "\n")
134
+ if len(lines) > 0 {
135
+ if pid, err := strconv.Atoi(strings.TrimSpace(lines[0])); err == nil {
136
+ if pid <= 0 || !agent.PIDAlive(pid) {
137
+ stale = true
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ if !stale {
144
+ return false
145
+ }
146
+ _ = os.Remove(path)
147
+ return true
148
+ }
149
+
150
+ // Release 释放锁;只删自己持有的那把(内容里的 pid 对得上)。
151
+ func (l *WorkspaceLock) Release() {
152
+ if l == nil || !l.held {
153
+ return
154
+ }
155
+ l.held = false
156
+ b, err := os.ReadFile(l.path)
157
+ if err != nil {
158
+ return
159
+ }
160
+ first := strings.TrimSpace(strings.SplitN(string(b), "\n", 2)[0])
161
+ if first != strconv.Itoa(os.Getpid()) {
162
+ return
163
+ }
164
+ _ = os.Remove(l.path)
165
+ }