pwn 0.5.761 → 0.5.762

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.
@@ -237,7 +237,7 @@ module PWN
237
237
  prior_id = Thread.current[:pwn_swarm_id]
238
238
  prior_honesty = Thread.current[:pwn_swarm_honesty]
239
239
  sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
240
- persona = personas(swarm_id: sid)[name.to_sym]
240
+ persona = opts[:persona] || personas(swarm_id: sid)[name.to_sym]
241
241
  raise ArgumentError, "unknown persona: #{name} (see #{AGENTS_FILE})" unless persona
242
242
 
243
243
  if opts[:unit].to_s != ''
@@ -256,6 +256,7 @@ module PWN
256
256
  session_id = persona_session(swarm_id: sid, name: name)
257
257
  sys = build_persona_prompt(name: name, persona: persona,
258
258
  swarm_id: sid, session_id: session_id)
259
+ sys = "#{sys}\n#{opts[:handoff]}" if opts[:handoff]
259
260
 
260
261
  empty_tools = opts[:text_only] == true || Array(persona[:toolsets]).empty?
261
262
  Thread.current[:pwn_swarm_depth] = depth + 1
@@ -269,6 +270,7 @@ module PWN
269
270
  enabled_toolsets: empty_tools ? [] : persona[:toolsets],
270
271
  core_only: empty_tools ? false : true,
271
272
  system_role_content: sys,
273
+ solve_handoff: !opts[:handoff].nil?,
272
274
  on_tool: opts[:on_tool],
273
275
  steering: opts[:steering],
274
276
  nested: true
@@ -354,6 +356,30 @@ module PWN
354
356
  { swarm_id: sid, replies: replies }
355
357
  end
356
358
 
359
+ # Run the built-in four-role workflow with host-observed verification.
360
+ # Supported Method Parameters::
361
+ # PWN::AI::Agent::Swarm.solve(
362
+ # request: 'required - original objective, preserved for every role',
363
+ # workspace: 'optional - artifact root, defaults to current directory',
364
+ # rounds: 'optional - bounded repair rounds, 1..10, default 3',
365
+ # swarm_id: 'optional - existing swarm registry',
366
+ # steering: 'optional - Solve::Control coordinator control',
367
+ # on_state: 'optional - observed role state callback',
368
+ # on_tool: 'optional - tool observation callback',
369
+ # usage_observer: 'optional - provider usage callback'
370
+ # )
371
+ public_class_method def self.solve(opts = {})
372
+ prior_id = Thread.current[:pwn_swarm_id]
373
+ prior_honesty = Thread.current[:pwn_swarm_honesty]
374
+ raise ArgumentError, 'request is required' if opts[:request].to_s.strip.empty?
375
+
376
+ Thread.current[:pwn_swarm_honesty] = nil
377
+ Solve::Run.new(opts).run
378
+ ensure
379
+ Thread.current[:pwn_swarm_id] = prior_id
380
+ Thread.current[:pwn_swarm_honesty] = prior_honesty
381
+ end
382
+
357
383
  # ------------------------------------------------------------------
358
384
  # privates
359
385
  # ------------------------------------------------------------------
@@ -754,6 +780,8 @@ module PWN
754
780
  on_tool: 'optional - ->(name, args, result) live-UI callback',
755
781
  steering: 'optional - Loop::Steering for this persona turn',
756
782
  usage_observer: 'optional - callable receiving provider usage',
783
+ persona: 'optional - explicit in-memory persona for the built-in coordinator',
784
+ handoff: 'optional - structured coordinator context, not a replacement request',
757
785
  from: 'optional - sender account or address to bind as operator (defaults to caller_label)',
758
786
  text_only: 'required - text only value consumed by #ask',
759
787
  unit: 'optional - claim key (host+phase) before the child runs',
@@ -761,6 +789,18 @@ module PWN
761
789
  engagement_id: 'optional - claim namespace (defaults to swarm_id)'
762
790
  )
763
791
 
792
+ # Solve the unchanged objective with four roles and executed evidence.
793
+ #{self}.solve(
794
+ request: 'required - original objective preserved for every role',
795
+ workspace: 'optional - artifact root, defaults to current directory',
796
+ rounds: 'optional - repair round cap, 1..10, default 3',
797
+ swarm_id: 'optional - registry from which role overrides are inherited',
798
+ steering: 'optional - Solve::Control for coordinator steering/cancellation/pause',
799
+ on_state: 'optional - observed four-role state callback',
800
+ on_tool: 'optional - observed tool callback',
801
+ usage_observer: 'optional - provider usage callback'
802
+ )
803
+
764
804
  # Run debate and return its result
765
805
  #{self}.debate(
766
806
  names: 'required - Array of persona names, order = speaking order',
data/lib/pwn/ai/agent.rb CHANGED
@@ -36,6 +36,7 @@ module PWN
36
36
  autoload :Extrospection, 'pwn/ai/agent/extrospection'
37
37
  autoload :Reflect, 'pwn/ai/agent/reflect'
38
38
  autoload :Swarm, 'pwn/ai/agent/swarm'
39
+ autoload :Solve, 'pwn/ai/agent/solve'
39
40
  autoload :Reward, 'pwn/ai/agent/reward'
40
41
  autoload :Verification, 'pwn/ai/agent/verification'
41
42
  autoload :Curriculum, 'pwn/ai/agent/curriculum'
@@ -325,6 +325,7 @@ module PWN
325
325
  # )
326
326
 
327
327
  private_class_method def self.anthropic_rest_call(opts = {})
328
+ opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
328
329
  engine = PWN::Env[:ai][:anthropic] if defined?(PWN::Env)
329
330
  raise 'ERROR: Anthropic Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
330
331
 
data/lib/pwn/ai/grok.rb CHANGED
@@ -345,6 +345,7 @@ module PWN
345
345
  # spinner: 'optional - display spinner (defaults to false)'
346
346
  # )
347
347
  private_class_method def self.grok_rest_call(opts = {})
348
+ opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
348
349
  engine = PWN::Env.dig(:ai, :grok) if defined?(PWN::Env)
349
350
  raise 'ERROR: Grok Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
350
351
 
@@ -391,6 +391,7 @@ module PWN
391
391
  # )
392
392
 
393
393
  private_class_method def self.open_ai_rest_call(opts = {})
394
+ opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
394
395
  engine = PWN::Env[:ai][:openai] if defined?(PWN::Env)
395
396
  raise 'ERROR: OpenAI Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
396
397
 
@@ -249,7 +249,7 @@ module PWN
249
249
  end
250
250
  end
251
251
  PWN_AI_SLASH_COMMANDS = %w[
252
- /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /system-role /trace
252
+ /back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace
253
253
  ].freeze
254
254
 
255
255
  PWN_AI_SLASH_SUBCOMMANDS = {
@@ -261,6 +261,7 @@ module PWN
261
261
  '/memory' => %w[list recall remember forget clear],
262
262
  '/mcp' => %w[list backends use current connect disconnect ping tools call status help],
263
263
  '/model' => %w[list],
264
+ '/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
264
265
  '/sessions' => %w[list resume delete stats],
265
266
  '/skills' => %w[list recall],
266
267
  '/learning' => %w[list requeue]
@@ -524,6 +525,10 @@ module PWN
524
525
  args = tokens[1..]
525
526
  pi = opts[:pry]
526
527
  case cmd
528
+ when '/swarm'
529
+ pi.config.pwn_ai_swarm ||= AISwarm::Controller.new(session_id: pi.config.pwn_ai_session_id)
530
+ result = pi.config.pwn_ai_swarm.execute(line: request, on_event: ->(_type, text) { puts text })
531
+ puts(result[:usage] || result[:error] || result.inspect)
527
532
  when '/steer'
528
533
  puts(args.empty? ? '[pwn-ai] Usage: /steer <instruction>' : '[pwn-ai] /steer: no active request; submit a normal request instead')
529
534
  when '/help'
@@ -610,7 +610,7 @@ module PWN
610
610
  clear_view
611
611
  elsif line.match?(%r{\A/verbose(?:\s|$)})
612
612
  toggle_verbose(line)
613
- elsif line.start_with?('/swarm') && %w[status tail steer cancel help roster].include?(line.split[1])
613
+ elsif line.start_with?('/swarm') && %w[status tail steer cancel pause resume help agents].include?(line.split[1])
614
614
  run_swarm(line)
615
615
  else
616
616
  add(:warning, 'Request running. Settings and new requests wait until idle. Use /steer, /input, /swarm status|steer|cancel, /clear, or /verbose.')
@@ -1062,7 +1062,7 @@ module PWN
1062
1062
  usage = @usage.snapshot
1063
1063
  cost = usage[:estimated_cost_usd] ? format('$%.4f est.', usage[:estimated_cost_usd]) : usage[:cost_status]
1064
1064
  swarm = @swarm&.snapshot || {}
1065
- active = Array(swarm[:jobs]).count { |job| %w[queued running steering cancelling].include?(job[:state]) }
1065
+ active = Array(swarm[:jobs]).count { |job| AISwarm::Controller::ACTIVE.include?(job[:state]) }
1066
1066
  ['REQUEST', state, "Elapsed #{elapsed}", "Completed tools #{@completed_tools}",
1067
1067
  "Events #{@event_count}", '', 'TOKENS', "#{usage[:input_tokens]} in · #{usage[:output_tokens]} out",
1068
1068
  "cached #{usage[:cached_tokens]} · calls #{usage[:calls]}", "Cost #{cost}",
@@ -1559,6 +1559,10 @@ module PWN
1559
1559
  when :home then view[:offset] = 0
1560
1560
  when :end then view[:offset] = 1_000_000
1561
1561
  when 'r' then refresh_swarm
1562
+ when 'v'
1563
+ view[:confirm] = ['mission', @editor.text] unless @editor.text.strip.empty?
1564
+ when 'p', 'u'
1565
+ view[:confirm] = [key == 'p' ? 'pause' : 'resume', item[:id]] if view[:tab] == :jobs && item
1562
1566
  when 'a', 'b', 'd' then prepare_swarm_mission(key, item)
1563
1567
  when 'c', "\u0003"
1564
1568
  return unless view[:tab] == :jobs && item
@@ -1582,9 +1586,9 @@ module PWN
1582
1586
  minimum = key == 'd' ? 2 : 1
1583
1587
  return view[:notice] = "Select at least #{minimum} agent(s) with Space." if names.length < minimum
1584
1588
 
1585
- command = { 'a' => 'ask', 'b' => 'broadcast', 'd' => 'debate' }.fetch(key)
1589
+ command = { 'a' => 'dm', 'b' => 'broadcast', 'd' => 'debate' }.fetch(key)
1586
1590
  view[:confirm] = case command
1587
- when 'ask' then [command, names.first, @editor.text]
1591
+ when 'dm' then [command, names.first, @editor.text]
1588
1592
  when 'broadcast' then [command, '--names', names.join(','), '--', @editor.text]
1589
1593
  else [command, names.join(','), '--', @editor.text]
1590
1594
  end
@@ -1614,17 +1618,21 @@ module PWN
1614
1618
 
1615
1619
  def execute_swarm_action
1616
1620
  args = @workspace.delete(:confirm)
1617
- result = @swarm.execute(line: Shellwords.join(['/swarm'] + args), on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record))
1621
+ options = { line: Shellwords.join(['/swarm'] + args), on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record) }
1622
+ options[:solve_request] = args.last if args.first == 'mission'
1623
+ result = @swarm.execute(options)
1618
1624
  @workspace[:notice] = result[:error] || "#{args.first}: accepted#{" · job #{result[:job_id]}" if result[:job_id]}"
1619
1625
  add(result[:ok] == false ? :warning : :notice, @workspace[:notice])
1620
1626
  refresh_swarm if args.first == 'spawn'
1621
- @workspace.merge!(tab: :jobs, index: 0, offset: 0, detail: true) if result[:ok] && %w[ask broadcast debate].include?(args.first)
1627
+ @workspace.merge!(tab: :jobs, index: 0, offset: 0, detail: true) if result[:ok] && %w[mission dm broadcast debate].include?(args.first)
1622
1628
  end
1623
1629
 
1624
1630
  def swarm_content
1625
1631
  view = @workspace
1626
1632
  if view[:confirm]
1627
1633
  args = view[:confirm]
1634
+ return [[false, "ACTION: mission\nTEAM: protagonist builds; antagonist assesses independently and critiques; verifier runs checks; integrator accepts or requests repair.\nOnly protagonist writes. Verification uses disposable artifact copies (not an OS sandbox).\nORIGINAL REQUEST:\n#{args.last}"]] if args.first == 'mission'
1635
+
1628
1636
  target = args.first == 'broadcast' ? args[2] : args[1]
1629
1637
  return [[false, "ACTION: #{args.first}\nTARGET: #{target}\n#{args.first == 'cancel' ? 'Cancel at a safe boundary; completed work is not undone.' : args.last}"]]
1630
1638
  end
@@ -1632,7 +1640,13 @@ module PWN
1632
1640
  item = items[view[:index]]
1633
1641
  rows = []
1634
1642
  if view[:detail] && item
1635
- item.each { |key, value| rows << [false, "#{key.to_s.upcase}: #{value}"] }
1643
+ item.each do |key, value|
1644
+ if key == :team
1645
+ rows.concat(solve_rows(value))
1646
+ else
1647
+ rows << [false, "#{key.to_s.upcase}: #{value}"]
1648
+ end
1649
+ end
1636
1650
  elsif items.empty?
1637
1651
  rows << [false, view[:tab] == :roster ? 'No agents. Press n to add a local persona (no model call).' : 'No jobs yet. Tab to roster; a sends your draft after confirmation.']
1638
1652
  else
@@ -1648,13 +1662,25 @@ module PWN
1648
1662
  rows
1649
1663
  end
1650
1664
 
1665
+ def solve_rows(team)
1666
+ rows = [[false, "ORIGINAL REQUEST: #{team[:request]}"], [false, "ROUND: #{team[:round]} · REVISION: #{team[:revision]}"]]
1667
+ (team[:roles] || {}).each do |role, state|
1668
+ routing = team.dig(:routing, role) || {}
1669
+ rows << [false, "#{role.upcase}: #{state} · #{routing[:engine]} / #{routing[:model] || 'provider default'}"]
1670
+ end
1671
+ (team[:requirements] || {}).each { |id, text| rows << [false, "#{id}: #{text}"] }
1672
+ (team[:artifacts] || {}).each { |path, sha| rows << [false, "ARTIFACT: #{path} · #{sha}"] }
1673
+ Array(team[:objections]).each { |text| rows << [false, "OPEN: #{text}"] }
1674
+ rows
1675
+ end
1676
+
1651
1677
  def draw_swarm
1652
1678
  @screen.erase
1653
1679
  view = @workspace
1654
1680
  box(0, 0, @height - 1, @width - 1, "SWARM WORKSPACE · #{view[:tab].to_s.upcase}")
1655
1681
  swarm_put(1, 'Tab roster/jobs · j/k move · Enter details', :footer)
1656
- swarm_put(2, 'Space pick · a ask · b broadcast · d debate', :footer)
1657
- swarm_put(3, 'n new · r refresh · s steer · c cancel', :footer)
1682
+ swarm_put(2, 'v Mission with team · Space pick · a dm · b broadcast · d debate', :footer)
1683
+ swarm_put(3, 'n new · r refresh · s steer · p pause · u resume · c cancel', :footer)
1658
1684
  swarm_put(4, 'PgUp/Dn Home/End scroll · Esc back', :footer)
1659
1685
  rows = swarm_content.flat_map { |selected, text| wrap(swarm_safe(text), @width - 7).map { |row| [selected, row] } }
1660
1686
  page = @height - 10
@@ -9,7 +9,7 @@ module PWN
9
9
  /back /clear /cron /debug /delegate /help /input /learning /mcp /memory /menu /model /system-role
10
10
  /sessions /skills /status /steer /swarm /trace /verbose
11
11
  ].freeze
12
- SWARM = %w[dashboard help roster status create use spawn retire ask debate broadcast tail steer cancel].freeze
12
+ SWARM = %w[dashboard help mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel].freeze
13
13
 
14
14
  public_class_method def self.complete(opts = {})
15
15
  line = opts[:line].to_s
@@ -24,7 +24,8 @@ module PWN
24
24
  else
25
25
  candidates(tokens: tokens, prefix: prefix, pry: opts[:pry], swarm: opts[:swarm])
26
26
  end
27
- items = values.map do |value|
27
+ # Every depth is resolved live; sort here so no provider can bypass ordering.
28
+ items = values.sort_by { |value| [value.downcase, value] }.map do |value|
28
29
  replacement = line_replace(line: line, token: current, value: value)
29
30
  { label: value, text: replacement[:text], cursor: replacement[:cursor] }
30
31
  end
@@ -252,10 +253,10 @@ module PWN
252
253
  jobs = Array(context[:jobs]).map { |job| job[:id] || job['id'] }
253
254
  agents = Array(context[:agents])
254
255
  case action
255
- when 'status', 'steer', 'cancel' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
256
+ when 'status', 'steer', 'cancel', 'pause', 'resume' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
256
257
  when 'use' then [filter(values: existing_swarms, prefix: prefix), 'swarm id']
257
258
  when 'spawn' then [[], position == 2 ? 'persona name' : 'role text']
258
- when 'retire', 'ask' then [filter(values: agents, prefix: prefix), 'persona']
259
+ when 'retire', 'dm' then [filter(values: agents, prefix: prefix), 'persona']
259
260
  when 'debate' then [filter(values: agents, prefix: prefix), 'comma-separated personas, then topic']
260
261
  when 'tail' then [[], 'message limit']
261
262
  else [[], 'free text']
@@ -35,7 +35,7 @@ module PWN
35
35
  # Session-scoped swarm jobs. Workers never draw the terminal.
36
36
  class Controller # rubocop:disable Metrics/ClassLength -- one session-scoped swarm command surface
37
37
  MAX_JOBS = 8
38
- ACTIVE = %w[queued running steering cancelling].freeze
38
+ ACTIVE = %w[queued running steering cancelling paused].freeze
39
39
 
40
40
  def initialize(session_id:)
41
41
  @session_id = session_id.to_s
@@ -46,6 +46,10 @@ module PWN
46
46
  end
47
47
 
48
48
  def execute(opts = {})
49
+ if (match = opts[:line].to_s.match(%r{\A\s*/swarm[ \t]+mission(?:[ \t](.*))?\z}m))
50
+ return solve([], opts.merge(solve_request: opts.fetch(:solve_request, match[1].to_s)))
51
+ end
52
+
49
53
  line = opts[:line].to_s.strip
50
54
  tokens = Shellwords.split(line)
51
55
  tokens.shift if tokens.first == '/swarm'
@@ -59,7 +63,7 @@ module PWN
59
63
  @mutex.synchronize do
60
64
  {
61
65
  swarm_id: @swarm_id,
62
- jobs: @jobs.values.map { |job| job.slice(:id, :agent, :state, :command, :result, :error, :last_tool).transform_values { |value| value.is_a?(String) ? value.dup : value } }
66
+ jobs: @jobs.values.map { |job| Marshal.load(Marshal.dump(job.slice(:id, :agent, :state, :command, :result, :error, :last_tool, :team))) }
63
67
  }
64
68
  end
65
69
  end
@@ -97,18 +101,20 @@ module PWN
97
101
  def dispatch(command, tokens, opts)
98
102
  case command
99
103
  when 'help', 'dashboard' then help_text
100
- when 'roster' then { ok: true, agents: roster, swarm_id: @swarm_id }
104
+ when 'agents' then { ok: true, agents: roster, swarm_id: @swarm_id }
101
105
  when 'status' then status(tokens.first)
102
106
  when 'create' then create(tokens.join(' '))
103
107
  when 'use' then use(tokens.first)
104
108
  when 'spawn' then spawn(tokens)
105
109
  when 'retire' then retire(tokens.first)
106
- when 'ask' then ask(tokens, opts)
110
+ when 'dm' then ask(tokens, opts)
111
+ when 'mission' then solve(tokens, opts)
107
112
  when 'debate' then debate(tokens, opts)
108
113
  when 'broadcast' then broadcast(tokens, opts)
109
114
  when 'tail' then tail(tokens.first)
110
115
  when 'steer' then steer(tokens, opts)
111
116
  when 'cancel' then cancel(tokens.first)
117
+ when 'pause', 'resume' then pause(tokens.first, command == 'pause')
112
118
  else { ok: false, error: "unknown /swarm command: #{command}", hint: help_text[:usage] }
113
119
  end
114
120
  end
@@ -116,7 +122,7 @@ module PWN
116
122
  def help_text
117
123
  {
118
124
  ok: true,
119
- usage: '/swarm roster|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|ask NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|cancel JOB|all'
125
+ usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
120
126
  }
121
127
  end
122
128
 
@@ -161,13 +167,26 @@ module PWN
161
167
  def ask(tokens, opts)
162
168
  name = tokens.shift
163
169
  request = tokens.join(' ')
164
- return { ok: false, error: 'usage: ask NAME REQUEST' } if name.to_s.empty? || request.empty?
170
+ return { ok: false, error: 'usage: dm NAME REQUEST' } if name.to_s.empty? || request.empty?
165
171
 
166
- start_job(agent: name, command: 'ask', opts: opts) do |job|
172
+ start_job(agent: name, command: 'dm', opts: opts) do |job|
167
173
  PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
168
174
  end
169
175
  end
170
176
 
177
+ def solve(tokens, opts)
178
+ request = opts.fetch(:solve_request, tokens.join(' '))
179
+ return { ok: false, error: 'usage: mission REQUEST' } if request.strip.empty?
180
+
181
+ start_job(agent: 'team', command: 'mission', opts: opts) do |job|
182
+ PWN::AI::Agent::Swarm.solve(
183
+ request: request, swarm_id: ensure_swarm, steering: job[:control],
184
+ workspace: opts[:workspace], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts),
185
+ on_state: ->(state) { @mutex.synchronize { job[:team] = state } }
186
+ )
187
+ end
188
+ end
189
+
171
190
  def debate(tokens, opts)
172
191
  flags, positional = split_flags(tokens)
173
192
  names = positional.shift.to_s.split(',')
@@ -219,6 +238,16 @@ module PWN
219
238
  { ok: true, job_id: job[:id] }
220
239
  end
221
240
 
241
+ def pause(id, paused)
242
+ job = find_job(id)
243
+ return { ok: false, error: 'job not found' } unless job
244
+ return { ok: false, error: 'pause/resume applies to mission jobs only' } unless job[:command] == 'mission'
245
+ return { ok: false, error: 'job is no longer active' } unless update_active(job, paused ? 'paused' : 'running')
246
+
247
+ job[:control].pause(paused)
248
+ { ok: true, job_id: id, state: job[:state] }
249
+ end
250
+
222
251
  def cancel(id)
223
252
  return cancel_all if id.nil? || id == 'all'
224
253
 
@@ -245,6 +274,8 @@ module PWN
245
274
 
246
275
  def start_job(agent:, command:, opts:)
247
276
  return { ok: false, error: 'swarm controller is closed' } if @closed
277
+ return { ok: false, error: 'mission requires exclusive workspace ownership; wait or cancel active jobs' } if (command == 'mission' && busy?) || snapshot[:jobs].any? { |row| row[:command] == 'mission' && ACTIVE.include?(row[:state]) }
278
+
248
279
  return { ok: false, error: "job limit #{MAX_JOBS} reached" } if busy_count >= MAX_JOBS
249
280
 
250
281
  ensure_swarm
@@ -257,16 +288,18 @@ module PWN
257
288
  job[:worker] = Thread.new do
258
289
  locals.each { |key, value| Thread.current[key] = value }
259
290
  variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
260
- job[:control] = JobControl.new(input: StringIO.new, output: StringIO.new)
291
+ control_class = command == 'mission' ? PWN::AI::Agent::Solve::Control : JobControl
292
+ job[:control] = control_class.new(input: StringIO.new, output: StringIO.new)
261
293
  Thread.current[:pwn_steering_input] = job[:control]
262
294
  ready << true
263
295
  update(job, 'running')
264
296
  result = yield(job)
265
297
  job[:control].check_stop
266
- update(job, 'completed', result: summarize(result))
267
- opts[:on_event]&.call(:assistant, "#{agent} [#{id}] #{command} complete\n#{summarize(result)}")
298
+ state = command == 'mission' ? result[:status] : 'completed'
299
+ update(job, state, result: summarize(result))
300
+ opts[:on_event]&.call(:assistant, "#{agent} [#{id}] #{command} #{state}\n#{summarize(result)}")
268
301
  result
269
- rescue JobControl::Stopped => e
302
+ rescue JobControl::Stopped, PWN::AI::Agent::Solve::Control::Stopped => e
270
303
  update(job, 'cancelled', error: e.message)
271
304
  opts[:on_event]&.call(:warning, "#{agent} [#{id}] #{e.message}")
272
305
  rescue StandardError => e
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.761'
4
+ VERSION = '0.5.762'
5
5
  end
@@ -1081,7 +1081,8 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
1081
1081
  )
1082
1082
  ).to eq false
1083
1083
  src = File.read(described_class.method(:run).source_location.first)
1084
- expect(src).to match(/skip_plan.*catalog/)
1084
+ expect(src).to match(/skip_compass.*catalog/)
1085
+ expect(src).to match(/skip_plan.*skip_compass/)
1085
1086
  expect(src).to match(/force_plan && cal_state\[:cal\] && !skip_plan/)
1086
1087
  end
1087
1088