rubik 0.1.4 → 0.1.6
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.
- checksums.yaml +4 -4
- data/ATTRIBUTION.md +1 -1
- data/CHANGELOG.md +66 -0
- data/lib/rubik/coeffects.rb +14 -15
- data/lib/rubik/config/defaults.rb +9 -6
- data/lib/rubik/config.rb +8 -0
- data/lib/rubik/orchestrator/step_executor.rb +9 -2
- data/lib/rubik/orchestrator/subagent/activation.rb +8 -1
- data/lib/rubik/orchestrator/subagent/completion.rb +31 -4
- data/lib/rubik/orchestrator/subagent/context_compaction.rb +50 -0
- data/lib/rubik/orchestrator/subagent/deferred_spec.rb +76 -0
- data/lib/rubik/orchestrator/subagent/dependency.rb +29 -64
- data/lib/rubik/orchestrator/subagent/execution_reads.rb +50 -0
- data/lib/rubik/orchestrator/subagent/instance.rb +77 -110
- data/lib/rubik/orchestrator/subagent/journal.rb +206 -0
- data/lib/rubik/orchestrator/subagent/narration.rb +14 -0
- data/lib/rubik/orchestrator/subagent/notices.rb +20 -21
- data/lib/rubik/orchestrator/subagent/operation_tracker.rb +93 -0
- data/lib/rubik/orchestrator/subagent/result_archive.rb +196 -0
- data/lib/rubik/orchestrator/subagent/reuse.rb +17 -4
- data/lib/rubik/orchestrator/subagent/run_cycle.rb +83 -0
- data/lib/rubik/orchestrator/subagent/scoped_registry.rb +15 -3
- data/lib/rubik/orchestrator/subagent/step_runner.rb +54 -61
- data/lib/rubik/orchestrator/subagent/supervisor.rb +26 -29
- data/lib/rubik/orchestrator/subagent/task_ownership.rb +109 -0
- data/lib/rubik/orchestrator/subagent/teardown.rb +104 -0
- data/lib/rubik/orchestrator/subagent/tool_dispatch.rb +61 -0
- data/lib/rubik/orchestrator/subagent/transcript_formatter.rb +15 -7
- data/lib/rubik/orchestrator/subagent/transcript_reader.rb +179 -0
- data/lib/rubik/orchestrator/subagent/type_registry.rb +2 -1
- data/lib/rubik/orchestrator/subagent/waiting.rb +73 -0
- data/lib/rubik/orchestrator/task_cancellation.rb +58 -0
- data/lib/rubik/orchestrator/tool_history.rb +8 -3
- data/lib/rubik/prompt/context_builder.rb +1 -1
- data/lib/rubik/prompt/doctrine.rb +5 -3
- data/lib/rubik/repl/commands/subagents.rb +2 -0
- data/lib/rubik/repl/input_execution.rb +42 -0
- data/lib/rubik/repl/input_loop.rb +57 -7
- data/lib/rubik/repl/prompt_grace.rb +103 -0
- data/lib/rubik/repl/terminal_guard.rb +5 -1
- data/lib/rubik/security/redactor.rb +7 -7
- data/lib/rubik/storage/transcript_indexer.rb +21 -11
- data/lib/rubik/tools/builtins/exec.rb +76 -28
- data/lib/rubik/tools/builtins/files.rb +6 -1
- data/lib/rubik/tools/builtins/subagent.rb +39 -31
- data/lib/rubik/tools/builtins/subagent_lifecycle.rb +74 -0
- data/lib/rubik/tools/fuzzy_matcher.rb +29 -14
- data/lib/rubik/tools/truncator.rb +19 -2
- data/lib/rubik/tools/uri_resolver.rb +84 -20
- data/lib/rubik/uris.rb +11 -0
- data/lib/rubik/version.rb +1 -1
- metadata +17 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "notices"
|
|
4
|
+
|
|
5
|
+
module Rubik
|
|
6
|
+
module Orchestrator
|
|
7
|
+
module Subagent
|
|
8
|
+
# Wait semantics (issue #126): waiting already knew when it returned; now
|
|
9
|
+
# it reports WHY — settled, deadline (targets still active), an actionable
|
|
10
|
+
# pause, a dependency queue, or unknown ids. The supervisor keeps
|
|
11
|
+
# wait_for (rows only) for its existing callers; the tool surface reads
|
|
12
|
+
# the reason so the model gets a truthful next action instead of guessing
|
|
13
|
+
# whether a timeout meant failure.
|
|
14
|
+
module WaitSupervisor
|
|
15
|
+
WaitOutcome = Struct.new(:reason, :rows, :paused, :active, keyword_init: true) do
|
|
16
|
+
def settled? = reason == :settled
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def wait_for(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
|
|
20
|
+
wait(ids, timeout: timeout, wait_for_budget: wait_for_budget).rows
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def wait(ids = nil, timeout: Notices::DEFAULT_WAIT_TIMEOUT, wait_for_budget: true)
|
|
24
|
+
target_ids = Array(ids).compact.map(&:to_s)
|
|
25
|
+
deadline = Time.now + [timeout.to_i, 1].max
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
active_targets = @monitor.synchronize do
|
|
29
|
+
candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] }
|
|
30
|
+
candidates.select do |inst|
|
|
31
|
+
inst.active? && !inst.error_paused? && !inst.awaiting_human? && (!wait_for_budget || !inst.awaiting_budget?)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Kuyruğa takılı hedefler wait'i canlı tutar (zincir bitene dek).
|
|
36
|
+
break if (active_targets.empty? && !deferred_pending?(target_ids)) || Time.now >= deadline
|
|
37
|
+
|
|
38
|
+
sleep 0.1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@monitor.synchronize do
|
|
42
|
+
candidates = target_ids.empty? ? @instances.values : target_ids.filter_map { |id| @instances[id] || queued_spec(id) }
|
|
43
|
+
paused = candidates.select { |c| parked_candidate?(c) }
|
|
44
|
+
active = candidates.select { |c| c.respond_to?(:active?) && c.active? }
|
|
45
|
+
WaitOutcome.new(reason: wait_reason(target_ids, candidates, active, deadline),
|
|
46
|
+
rows: candidates.map(&:to_h),
|
|
47
|
+
paused: paused.map(&:id),
|
|
48
|
+
active: active.map(&:id))
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# The returned-fact classification, derived from what was observed —
|
|
55
|
+
# never from a guess about the model's intent.
|
|
56
|
+
def wait_reason(target_ids, candidates, active, deadline)
|
|
57
|
+
return :unknown if target_ids.any? && candidates.empty?
|
|
58
|
+
return :deadline if Time.now >= deadline && active.any?
|
|
59
|
+
return :paused if candidates.any? { |c| parked_candidate?(c) }
|
|
60
|
+
return :queued if candidates.any? { |c| c.respond_to?(:queued?) && c.queued? }
|
|
61
|
+
|
|
62
|
+
:settled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parked_candidate?(candidate)
|
|
66
|
+
(candidate.respond_to?(:error_paused?) && candidate.error_paused?) ||
|
|
67
|
+
(candidate.respond_to?(:awaiting_human?) && candidate.awaiting_human?) ||
|
|
68
|
+
(candidate.respond_to?(:awaiting_budget?) && candidate.awaiting_budget?)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Rubik
|
|
6
|
+
module Orchestrator
|
|
7
|
+
# Foreground task cancellation token (issue #125).
|
|
8
|
+
#
|
|
9
|
+
# A foreground turn (or any explicit task scope) owns the subagents it
|
|
10
|
+
# spawns: the token is provided into the runtime context for the turn's
|
|
11
|
+
# duration and inherited — transitively — by the child contexts of the
|
|
12
|
+
# spawned subagents, so nested spawns remain owned by the same task. The
|
|
13
|
+
# interrupt handler cancels the token and the supervisor stops every
|
|
14
|
+
# execution still carrying it; work that was explicitly detached (no
|
|
15
|
+
# owner) or spawned under a different token is untouched by construction.
|
|
16
|
+
#
|
|
17
|
+
# The token is a leaf: no dependencies, no callbacks, no IO — cancellation
|
|
18
|
+
# REQUEST state only. The supervisor owns the stop choreography and the
|
|
19
|
+
# outcome report (cancellation-requested → complete / remaining), keeping
|
|
20
|
+
# this object honest about what it knows.
|
|
21
|
+
class TaskCancellation
|
|
22
|
+
attr_reader :id, :reason, :cancelled_at
|
|
23
|
+
|
|
24
|
+
# The owner of explicitly detached work: a token that can never be
|
|
25
|
+
# cancelled, provided into a detached subagent's child context so its
|
|
26
|
+
# own descendants inherit the detachment instead of the turn's token.
|
|
27
|
+
def self.detached
|
|
28
|
+
new(id: "task-detached-#{Process.pid}-#{rand(1000..9999)}", detached: true)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(id: nil, detached: false)
|
|
32
|
+
@id = id || "task-#{Process.pid}-#{Time.now.to_i}-#{rand(1000..9999)}"
|
|
33
|
+
@detached = detached
|
|
34
|
+
@monitor = Monitor.new
|
|
35
|
+
@cancelled = false
|
|
36
|
+
@reason = nil
|
|
37
|
+
@cancelled_at = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cancelled? = @monitor.synchronize { @cancelled }
|
|
41
|
+
|
|
42
|
+
def detached? = @detached
|
|
43
|
+
|
|
44
|
+
# Idempotent: the first cancel wins (its reason/timestamp is the record),
|
|
45
|
+
# later calls report false and change nothing.
|
|
46
|
+
def cancel!(reason = "foreground task cancelled (^C)")
|
|
47
|
+
@monitor.synchronize do
|
|
48
|
+
return false if @detached || @cancelled
|
|
49
|
+
|
|
50
|
+
@cancelled = true
|
|
51
|
+
@reason = reason.to_s
|
|
52
|
+
@cancelled_at = Time.now
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -22,15 +22,20 @@ module Rubik
|
|
|
22
22
|
# - content — an opaque String per channel (main path: Result#to_xml;
|
|
23
23
|
# subagent: truncated plain output). The dictionary unifies the row
|
|
24
24
|
# SHAPE, not the content encoding.
|
|
25
|
+
# - exit_code / metadata (issue #127) — the structured command outcome:
|
|
26
|
+
# machine-readable status without parsing display text. Also read by no
|
|
27
|
+
# wire-side consumer (same observability-only contract as status), so
|
|
28
|
+
# every channel writing nil/{} keeps the shape stable.
|
|
25
29
|
module ToolHistory
|
|
26
|
-
ROW_KEYS = [:role, :tool_call_id, :tool_name, :status, :duration_ms, :content].freeze
|
|
30
|
+
ROW_KEYS = [:role, :tool_call_id, :tool_name, :status, :duration_ms, :content, :exit_code, :metadata].freeze
|
|
27
31
|
|
|
28
32
|
module_function
|
|
29
33
|
|
|
30
34
|
# Appends exactly one canonical row to +history+ and returns it.
|
|
31
|
-
def write(history, tool_call_id:, tool_name:, status:, duration_ms:, content:)
|
|
35
|
+
def write(history, tool_call_id:, tool_name:, status:, duration_ms:, content:, exit_code: nil, metadata: nil)
|
|
32
36
|
row = { role: "tool", tool_call_id: tool_call_id, tool_name: tool_name,
|
|
33
|
-
status: status, duration_ms: duration_ms, content: content
|
|
37
|
+
status: status, duration_ms: duration_ms, content: content,
|
|
38
|
+
exit_code: exit_code, metadata: metadata || {} }
|
|
34
39
|
history << row
|
|
35
40
|
row
|
|
36
41
|
end
|
|
@@ -334,7 +334,7 @@ module Rubik
|
|
|
334
334
|
tool_section = if tool_schemas.nil? || tool_schemas.empty?
|
|
335
335
|
""
|
|
336
336
|
elsif native_tools
|
|
337
|
-
names = tool_schemas.
|
|
337
|
+
names = tool_schemas.filter_map { |t| t[:name] || t["name"] }.join(", ")
|
|
338
338
|
Doctrine.native_tools_note(names)
|
|
339
339
|
else
|
|
340
340
|
Doctrine.json_tools_note(tool_schemas)
|
|
@@ -34,9 +34,9 @@ module Rubik
|
|
|
34
34
|
4. REPL Bang Commands: `exec_repl_command` runs only the session-safe commands listed in <agent_commands> — runtime configuration, context and step-budget self-management, buffer review and rollback, runtime introspection, and code validation. If that section is absent, do not invoke REPL commands. File, code, test, and shell operations never travel through REPL commands — under the maestro doctrine (rules 2-3) they are delegated to subagents, and the direct tools (`read_file`, `write_file`, `replace_file_content`, `run_tests`, `run_command`) are your coder subagents' mechanics. Buffer-review commands (`!diff`, `!undo`, `!rollback`) operate on the in-memory staging buffer (unapplied edits) — they are a preview layer, not file ground truth: verify file content with `read_file`, and read git history from your coder subagent's `run_command` report.
|
|
35
35
|
5. Autonomous Tool Synthesis: #{Rubik::Prompt::ToolTeachings.teaching_for('synthesize_tool')}
|
|
36
36
|
6. Output Budget & Conciseness: Respect your output token budget (`<output_budget>`). Produce direct, high-signal responses and avoid unnecessary pleasantries, verbose conversational preambles, or duplicated context blocks that waste generation quota.
|
|
37
|
-
7. Subagent Delegation & Parallelism (Maestro): As the maestro you orchestrate — ALL execution work (exploration, code changes, verification) is delegated via `invoke_subagent`, in every mode. Match task to archetype: use 'research' for read-only codebase exploration; MUST use 'coder' for code modifications, refactoring, and test execution ('research' has no write or test tools); reach for 'self' (a full capability clone, minus subagent spawning) for work that fits neither archetype — runtime introspection/repair, self-evolution work, hybrid tasks. Subagents execute asynchronously in disposable sub-contexts in the background. For non-trivial modifications, allocate sufficient step budget (e.g. `max_steps: 100-140`) to prevent avoidable mid-flight pauses. When chaining stages with `depends_on`,
|
|
37
|
+
7. Subagent Delegation & Parallelism (Maestro): As the maestro you orchestrate — ALL execution work (exploration, code changes, verification) is delegated via `invoke_subagent`, in every mode. Match task to archetype: use 'research' for read-only codebase exploration; MUST use 'coder' for code modifications, refactoring, and test execution ('research' has no write or test tools); reach for 'self' (a full capability clone, minus subagent spawning) for work that fits neither archetype — runtime introspection/repair, self-evolution work, hybrid tasks. Subagents execute asynchronously in disposable sub-contexts in the background. For non-trivial modifications, allocate sufficient step budget (e.g. `max_steps: 100-140`) to prevent avoidable mid-flight pauses. When chaining stages with `depends_on`, hand the successor the predecessor's canonical transcript reference (`read_file('rubik://subagent/<storage-id>/transcript')`, reported by status/completion notices) — never a handwritten storage path; if the successor launches before the storage id is known, paste the predecessor's findings into its brief. Consume subagents' structured reports (Outcome, Changes/Locations, Verification/Findings, Assumptions) directly. After dispatching subagents, do NOT block with 'wait' unless you strictly depend on their output in the current turn — conclude your turn and liberate the prompt so the user can interact concurrently. Completion, budget-pause, error, and message notifications arrive reactively: during your turn via your notification inbox; while you are idle, the runtime automatically wakes you with a dedicated notification-evaluation turn — evaluate the pending notifications, act (extend budget, steer with send_message, retrieve results, wrap up, or report to the user), and conclude your turn again. Subagents can ask YOU questions mid-flight via `ask_question` — a question notification means that subagent is BLOCKED until you reply: answer it promptly with `send_message(recipient: '<id>', message: '<your answer>')` (it times out otherwise). Call `#{Rubik::Orchestrator::Subagent::Notices.wait_hint_template}` only when synchronous waiting is strictly required. Never poll 'status' repeatedly in a loop.
|
|
38
38
|
8. Runtime Introspection, Self-Healing & Human Feedback: If internal errors or service degradation occur, inspect runtime state with `inspect_runtime` and repair failed services via `repair_runtime_service`. When critical requirements are underspecified or high-impact architectural choices require user confirmation, use `ask_question` with structured multiple-choice options.
|
|
39
|
-
9. Central Storage & Clean Worktree: #{Rubik::Prompt::ToolTeachings.teaching_for('storage')} #{Rubik::Prompt::ToolTeachings.teaching_for('vakanuvis')} #{Rubik::Prompt::ToolTeachings.teaching_for('show_file')} Subagent transcripts are indexed automatically as `transcript` entities (alias `subagent-<storage-id>`). #{Rubik::Prompt::ToolTeachings.teaching_for('prune_context')}
|
|
39
|
+
9. Central Storage & Clean Worktree: #{Rubik::Prompt::ToolTeachings.teaching_for('storage')} #{Rubik::Prompt::ToolTeachings.teaching_for('vakanuvis')} #{Rubik::Prompt::ToolTeachings.teaching_for('show_file')} Subagent transcripts are indexed automatically as `transcript` entities (alias `subagent-<storage-id>`) and are readable live or persisted via the canonical `rubik://subagent/<storage-id>/transcript` reference — indexed entities and live history have different availability, so address the exact execution by its unique storage id (from status/completion notices), never by a display id or a disk path. #{Rubik::Prompt::ToolTeachings.teaching_for('prune_context')}
|
|
40
40
|
</operational_rules>
|
|
41
41
|
XML
|
|
42
42
|
end
|
|
@@ -117,7 +117,9 @@ module Rubik
|
|
|
117
117
|
"and `wait` accepts queued ids (blocks until the whole chain settles). A failed/killed predecessor " \
|
|
118
118
|
"CANCELS its dependents — re-dispatch with the new information; a budget- or error-paused predecessor " \
|
|
119
119
|
"keeps them queued — extend or resume it instead. When chaining dependent stages, direct the successor " \
|
|
120
|
-
"to read predecessor findings via
|
|
120
|
+
"to read predecessor findings via the canonical transcript reference " \
|
|
121
|
+
"(`read_file('rubik://subagent/<storage-id>/transcript')`, from status/completion notices) — never a " \
|
|
122
|
+
"handwritten storage path; the findings can also be pasted into the brief.",
|
|
121
123
|
"Chronicler briefs: when dispatching a subagent, paste the relevant chronicled knowledge into the task " \
|
|
122
124
|
"brief — the recall digest or a rubik://storage/... link — so the subagent pulls the depth itself. " \
|
|
123
125
|
"Subagents never see the `vakanuvis` tool; you are the only librarian.",
|
|
@@ -94,6 +94,8 @@ module Rubik
|
|
|
94
94
|
ctx.out.puts " Uptime: #{inst.uptime.round(2)}s"
|
|
95
95
|
ctx.out.puts " Workspace: #{inst.workspace}"
|
|
96
96
|
ctx.out.puts " Transcript: #{inst.transcript_uri}"
|
|
97
|
+
# Canonical read reference (issue #123): the path above is diagnostic.
|
|
98
|
+
ctx.out.puts " Read Ref: #{inst.transcript_ref}" if inst.respond_to?(:transcript_ref)
|
|
97
99
|
ctx.out.puts " Last Error: #{inst.last_error}" if inst.last_error
|
|
98
100
|
ctx.out.puts " Result: #{inst.result.to_s.slice(0, 150)}..." if inst.result
|
|
99
101
|
end
|
|
@@ -148,6 +148,13 @@ module Rubik
|
|
|
148
148
|
# an unbound read would mask the in-flight exception with a
|
|
149
149
|
# NameError, breaking the "cleanup must never mask" contract.
|
|
150
150
|
interrupted = false
|
|
151
|
+
# Task ownership (issue #125): this turn owns the subagents it spawns.
|
|
152
|
+
# The token is published for the turn's duration; ^C cancels it and the
|
|
153
|
+
# supervisor stops exactly the owned descendants (detached work and
|
|
154
|
+
# older turns' executions carry different tokens and survive).
|
|
155
|
+
task = Rubik::Orchestrator::TaskCancellation.new
|
|
156
|
+
@active_task = task
|
|
157
|
+
@runtime_manager&.provide(Rubik::Coeffects::TASK_CANCELLATION, task)
|
|
151
158
|
# Plan 109 staleness rule (see execute_command): a real turn's
|
|
152
159
|
# context already includes the idle-injected notices. The notice
|
|
153
160
|
# turn itself drains first, so this clear is a no-op for it.
|
|
@@ -271,6 +278,10 @@ module Rubik
|
|
|
271
278
|
# at route time), so draining loses nothing the model needed.
|
|
272
279
|
clear_pending_wake_notices! if respond_to?(:clear_pending_wake_notices!)
|
|
273
280
|
Rubik::REPL::InterruptHandler.restore_console_after_interrupt
|
|
281
|
+
# Propagate the cancellation to owned work (issue #125) and report the
|
|
282
|
+
# fact: with nothing owned running this reads as an interrupted wait,
|
|
283
|
+
# with owned descendants it reads as a cancelled task tree.
|
|
284
|
+
task_cancellation_lines(cancel_foreground_task!(task)).each { |line| @output.puts line }
|
|
274
285
|
# Dedicated event type: restore_session must NOT hydrate this as a
|
|
275
286
|
# dialogue turn (the user_turn recorded at method entry already
|
|
276
287
|
# carries the question; a repl_command-style pair would duplicate it).
|
|
@@ -294,6 +305,7 @@ module Rubik
|
|
|
294
305
|
record_failed_turn!(text, "failed", "#{e.class}: #{message}", orchestrator)
|
|
295
306
|
ensure
|
|
296
307
|
@active_loop = nil
|
|
308
|
+
@active_task = nil
|
|
297
309
|
# Flush a half-streamed markdown tail so an interrupt never eats the last
|
|
298
310
|
# line. Cleanup must never mask the in-flight exception (Interrupt, LLM
|
|
299
311
|
# error): each step reports its own failure to stderr instead of raising.
|
|
@@ -320,6 +332,36 @@ module Rubik
|
|
|
320
332
|
reap_dead_kernel_units! if respond_to?(:reap_dead_kernel_units!)
|
|
321
333
|
end
|
|
322
334
|
|
|
335
|
+
# Foreground task cancellation (issue #125): cancel the turn's token and
|
|
336
|
+
# stop its owned descendants within the configured grace period. Returns
|
|
337
|
+
# the supervisor's fact report (nil when no supervisor is wired).
|
|
338
|
+
def cancel_foreground_task!(task)
|
|
339
|
+
supervisor = Rubik::Coeffects.get(@runtime_manager&.context, Rubik::Coeffects::SUBAGENT_SUPERVISOR)
|
|
340
|
+
return nil unless task && supervisor.respond_to?(:cancel_owned)
|
|
341
|
+
|
|
342
|
+
supervisor.cancel_owned(task, timeout: @config.fetch("subagents.cancel_timeout", 2.0))
|
|
343
|
+
rescue StandardError => e
|
|
344
|
+
warn "[rubik] task cancellation failed: #{e.class}: #{e.message}"
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# The UI's two truths (issue #125): an interrupt with nothing owned is an
|
|
349
|
+
# interrupted WAIT; one with owned work is a cancelled TASK TREE — and
|
|
350
|
+
# remaining work is named, so an incomplete cleanup is never silent.
|
|
351
|
+
def task_cancellation_lines(outcome)
|
|
352
|
+
return ["⏸️ [Rubik] Bekleme kesildi (^C) — iptal edilecek bağlı alt ajan yok."] unless outcome&.any?
|
|
353
|
+
|
|
354
|
+
line = "🛑 [Rubik] Görev ağacı iptal edildi (^C): #{outcome.stopped.size} bağlı alt ajan durduruldu"
|
|
355
|
+
line << ", #{outcome.queued_cancelled.size} kuyruk işi iptal edildi" unless outcome.queued_cancelled.empty?
|
|
356
|
+
if outcome.complete?
|
|
357
|
+
line << "; temizlik tamamlandı."
|
|
358
|
+
else
|
|
359
|
+
remaining = outcome.remaining.map { |r| "#{r[:id]}:#{r[:state]}" }.join(", ")
|
|
360
|
+
line << "; temizlik TAMAMLANMADI — kalan: #{remaining}."
|
|
361
|
+
end
|
|
362
|
+
[line]
|
|
363
|
+
end
|
|
364
|
+
|
|
323
365
|
# Shared spinner teardown. Cleanup must never mask the in-flight
|
|
324
366
|
# exception (AGENTS.md §5): a spinner whose tick thread died holding
|
|
325
367
|
# the mutex raises ThreadError from synchronize — report it to stderr
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "prompt_grace"
|
|
4
|
+
|
|
3
5
|
module Rubik
|
|
4
6
|
module REPL
|
|
5
7
|
# Öneri 3 (Kademe 1): Shell'den ayrılan girdi döngüsü sorumluluğu.
|
|
@@ -18,6 +20,7 @@ module Rubik
|
|
|
18
20
|
|
|
19
21
|
def run_interactive
|
|
20
22
|
@running = true
|
|
23
|
+
@interactive_prompt_loop = true
|
|
21
24
|
multiline_buffer = []
|
|
22
25
|
heredoc_delimiter = nil
|
|
23
26
|
last_line_at = nil
|
|
@@ -33,6 +36,7 @@ module Rubik
|
|
|
33
36
|
end
|
|
34
37
|
end
|
|
35
38
|
ensure
|
|
39
|
+
@interactive_prompt_loop = false
|
|
36
40
|
stop
|
|
37
41
|
end
|
|
38
42
|
|
|
@@ -45,7 +49,7 @@ module Rubik
|
|
|
45
49
|
text = read_paste_block
|
|
46
50
|
return { stop: false, buffer: [], delimiter: nil } if text.strip.empty?
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
submit_interactive_prompt(text)
|
|
49
53
|
{ stop: false, buffer: [], delimiter: nil }
|
|
50
54
|
end
|
|
51
55
|
|
|
@@ -66,6 +70,7 @@ module Rubik
|
|
|
66
70
|
flush_subagent_notices! if respond_to?(:flush_subagent_notices!)
|
|
67
71
|
@terminal_guard&.flush_notices!
|
|
68
72
|
current_prompt, boxed = prepare_prompt_line(!heredoc_delimiter.nil?)
|
|
73
|
+
restored_submission = !@prompt_restore.nil?
|
|
69
74
|
line = safe_readline(current_prompt, add_history: false)
|
|
70
75
|
@output.puts box_bottom_border if boxed
|
|
71
76
|
return handle_eof(multiline_buffer, heredoc_delimiter) if line.nil? || line == "\x04" || line == "\x1a"
|
|
@@ -82,14 +87,14 @@ module Rubik
|
|
|
82
87
|
# "instantly", so an ungated collector would swallow consecutive test
|
|
83
88
|
# lines like "x = a << 1" + "exit" into one turn (seen in test_repl).
|
|
84
89
|
now = paste_clock
|
|
85
|
-
if paste_burst_eligible? && !line.strip.empty? && !heredoc_delimiter &&
|
|
90
|
+
if !restored_submission && paste_burst_eligible? && !line.strip.empty? && !heredoc_delimiter &&
|
|
86
91
|
multiline_buffer.empty? &&
|
|
87
92
|
(paste_line_fast?(last_line_at, now) || paste_pending?)
|
|
88
93
|
return collect_paste_burst(line)
|
|
89
94
|
end
|
|
90
95
|
|
|
91
96
|
# Push non-empty command to history
|
|
92
|
-
@reline::HISTORY.push(line) if !line.strip.empty? && line != begin
|
|
97
|
+
@reline::HISTORY.push(line) if !prompt_grace_interactive? && !line.strip.empty? && line != begin
|
|
93
98
|
@reline::HISTORY.to_a.last
|
|
94
99
|
rescue
|
|
95
100
|
nil
|
|
@@ -152,13 +157,15 @@ module Rubik
|
|
|
152
157
|
collector.call
|
|
153
158
|
end
|
|
154
159
|
text = lines.join("\n")
|
|
155
|
-
|
|
160
|
+
submit_interactive_prompt(text)
|
|
156
161
|
# Return the final line timestamp so a following paste burst chain
|
|
157
162
|
# (copy #2 right after copy #1) is still detected as fast lines.
|
|
158
163
|
{ stop: false, buffer: [], delimiter: nil, last_line_at: last_at }
|
|
159
164
|
end
|
|
160
165
|
|
|
161
166
|
def safe_readline(prompt, add_history: false)
|
|
167
|
+
return read_restored_prompt(prompt) if @prompt_restore
|
|
168
|
+
|
|
162
169
|
if @terminal_guard
|
|
163
170
|
@terminal_guard.with_reading { @reline.readline(prompt, add_history) }
|
|
164
171
|
else
|
|
@@ -179,7 +186,7 @@ module Rubik
|
|
|
179
186
|
def process_heredoc_line(line, multiline_buffer, heredoc_delimiter)
|
|
180
187
|
if line.strip == heredoc_delimiter
|
|
181
188
|
full_text = multiline_buffer.join("\n")
|
|
182
|
-
|
|
189
|
+
submit_interactive_prompt(full_text)
|
|
183
190
|
{ stop: false, buffer: [], delimiter: nil }
|
|
184
191
|
else
|
|
185
192
|
multiline_buffer << line
|
|
@@ -190,7 +197,7 @@ module Rubik
|
|
|
190
197
|
def process_normal_line(line, _multiline_buffer)
|
|
191
198
|
# Only trigger heredoc if line explicitly opens a multiline block (e.g. `<<EOF`, `!plan <<EOF`)
|
|
192
199
|
# NEVER trigger on inline operators like `array << item` or `a << b`
|
|
193
|
-
heredoc_match = line.match(
|
|
200
|
+
heredoc_match = line.match(/\A[ \t]*(?:[a-zA-Z0-9_!.-]+[ \t]+)?<<[-~]?['"]?([A-Za-z0-9_]+)['"]?[ \t]*\z/)
|
|
194
201
|
if heredoc_match && !line.strip.start_with?("#")
|
|
195
202
|
delimiter = heredoc_match[1]
|
|
196
203
|
prefix = line.sub(/<<[-~]?['"]?#{Regexp.escape(delimiter)}['"]?\s*$/, "").strip
|
|
@@ -200,7 +207,7 @@ module Rubik
|
|
|
200
207
|
stop
|
|
201
208
|
{ stop: true, buffer: [], delimiter: nil }
|
|
202
209
|
else
|
|
203
|
-
|
|
210
|
+
submit_interactive_prompt(line)
|
|
204
211
|
{ stop: false, buffer: [], delimiter: nil }
|
|
205
212
|
end
|
|
206
213
|
end
|
|
@@ -208,6 +215,49 @@ module Rubik
|
|
|
208
215
|
def process_multiline_or_line(line)
|
|
209
216
|
execute_input(line)
|
|
210
217
|
end
|
|
218
|
+
|
|
219
|
+
def prompt_grace_interactive?
|
|
220
|
+
@interactive_prompt_loop && !@replaying && interactive_input?
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def submit_interactive_prompt(text)
|
|
224
|
+
return execute_input(text) unless prompt_grace_interactive?
|
|
225
|
+
|
|
226
|
+
if CommandParser.parse(text).type == :prompt
|
|
227
|
+
milliseconds = @config.fetch("ui.prompt_grace_window_ms", 1000)
|
|
228
|
+
outcome = PromptGrace.new(input: @input, output: @output,
|
|
229
|
+
context: @runtime_manager&.context).wait(milliseconds)
|
|
230
|
+
if outcome == :edit
|
|
231
|
+
@prompt_restore = text
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
return if outcome == :cancel
|
|
235
|
+
end
|
|
236
|
+
@reline::HISTORY.push(text) unless text.strip.empty? || @reline::HISTORY.to_a.last == text
|
|
237
|
+
execute_input(text)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def read_restored_prompt(prompt)
|
|
241
|
+
text = @prompt_restore
|
|
242
|
+
@prompt_restore = nil
|
|
243
|
+
previous = @reline.pre_input_hook
|
|
244
|
+
scope = @runtime_manager ? @runtime_manager.context.extend : Rubik::Runtime::Context.new
|
|
245
|
+
scope.on_dispose { @reline.pre_input_hook = previous }
|
|
246
|
+
@reline.pre_input_hook = lambda {
|
|
247
|
+
previous&.call
|
|
248
|
+
@reline.insert_text(text)
|
|
249
|
+
}
|
|
250
|
+
reader = -> { @reline.readmultiline(prompt, false) { true } }
|
|
251
|
+
if @terminal_guard
|
|
252
|
+
# A reactive wake must never inject Enter while Reline resets an
|
|
253
|
+
# apparently empty buffer, before the restoration hook fills it.
|
|
254
|
+
@terminal_guard.with_collecting { @terminal_guard.with_reading(&reader) }
|
|
255
|
+
else
|
|
256
|
+
reader.call
|
|
257
|
+
end
|
|
258
|
+
ensure
|
|
259
|
+
scope&.dispose
|
|
260
|
+
end
|
|
211
261
|
end
|
|
212
262
|
end
|
|
213
263
|
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "io/wait"
|
|
5
|
+
require_relative "../runtime/context"
|
|
6
|
+
|
|
7
|
+
module Rubik
|
|
8
|
+
module REPL
|
|
9
|
+
# Synchronous admission gate: no task, recorder entry or delayed callback
|
|
10
|
+
# exists until this returns :execute. A temporary context owns all terminal
|
|
11
|
+
# effects and is pruned from its parent on every exit.
|
|
12
|
+
class PromptGrace
|
|
13
|
+
ESCAPE_TIMEOUT = 0.03
|
|
14
|
+
STATUS = "Gönderiliyor… Esc: düzenlemeye dön"
|
|
15
|
+
|
|
16
|
+
def initialize(input:, output:, context: nil, clock: nil, reader: nil)
|
|
17
|
+
@input = input
|
|
18
|
+
@output = output
|
|
19
|
+
@context = context
|
|
20
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
21
|
+
@reader = reader || method(:read_byte)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def wait(milliseconds)
|
|
25
|
+
return :execute if milliseconds.zero?
|
|
26
|
+
|
|
27
|
+
scope = @context ? @context.extend : Rubik::Runtime::Context.new
|
|
28
|
+
mode = @input.console_mode
|
|
29
|
+
scope.on_dispose { @input.console_mode = mode }
|
|
30
|
+
@input.raw!
|
|
31
|
+
scope.on_dispose do
|
|
32
|
+
@output.print "\r\e[2K"
|
|
33
|
+
@output.flush
|
|
34
|
+
end
|
|
35
|
+
@output.print STATUS
|
|
36
|
+
@output.flush
|
|
37
|
+
deadline = @clock.call + (milliseconds / 1000.0)
|
|
38
|
+
loop do
|
|
39
|
+
remaining = deadline - @clock.call
|
|
40
|
+
if remaining <= 0
|
|
41
|
+
# A burst arriving at the deadline can leave bytes queued after
|
|
42
|
+
# the last processed key. Do not let its Enter submit a later turn.
|
|
43
|
+
if @input.respond_to?(:nread) && @input.nread.positive?
|
|
44
|
+
@input.iflush
|
|
45
|
+
@output.print "\a\r\e[2K[Rubik] Bekleme sırasında gelen tuşlar yok sayıldı.\r\n"
|
|
46
|
+
end
|
|
47
|
+
return :execute
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
byte = @reader.call(remaining)
|
|
51
|
+
next if byte.nil?
|
|
52
|
+
return :cancel if [:eof, "\x03"].include?(byte)
|
|
53
|
+
|
|
54
|
+
if byte == "\e"
|
|
55
|
+
escape = classify_escape
|
|
56
|
+
return escape unless escape == :sequence
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Other keys are intentionally rejected, with immediate feedback.
|
|
60
|
+
# Enter never leaks into the next readline as an accidental submit.
|
|
61
|
+
@output.print "\a\r\e[2K#{STATUS} · Tuş yok sayıldı"
|
|
62
|
+
@output.flush
|
|
63
|
+
end
|
|
64
|
+
rescue Interrupt
|
|
65
|
+
:cancel
|
|
66
|
+
ensure
|
|
67
|
+
scope&.dispose
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def read_byte(timeout)
|
|
73
|
+
return nil unless @input.wait_readable(timeout)
|
|
74
|
+
|
|
75
|
+
@input.read_nonblock(1)
|
|
76
|
+
rescue IO::WaitReadable
|
|
77
|
+
nil
|
|
78
|
+
rescue EOFError
|
|
79
|
+
:eof
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# CSI/SS3 arrows and function keys start with Esc too. Resolve this
|
|
83
|
+
# ambiguity even when Esc arrives just before the submission deadline.
|
|
84
|
+
def classify_escape
|
|
85
|
+
following = @reader.call(ESCAPE_TIMEOUT)
|
|
86
|
+
return :edit if following.nil? || following == :eof
|
|
87
|
+
return :cancel if following == "\x03"
|
|
88
|
+
return :sequence unless ["[", "O"].include?(following)
|
|
89
|
+
|
|
90
|
+
deadline = @clock.call + ESCAPE_TIMEOUT
|
|
91
|
+
loop do
|
|
92
|
+
remaining = deadline - @clock.call
|
|
93
|
+
break if remaining <= 0
|
|
94
|
+
|
|
95
|
+
byte = @reader.call(remaining)
|
|
96
|
+
return :cancel if byte == "\x03"
|
|
97
|
+
break if byte.nil? || byte == :eof || byte.match?(/[\x40-\x7e]/)
|
|
98
|
+
end
|
|
99
|
+
:sequence
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -108,7 +108,11 @@ module Rubik
|
|
|
108
108
|
# Plan 109: wake a blocked empty readline by injecting an Enter.
|
|
109
109
|
# Reline 0.7.0's IO gates (ANSI/Windows/Dumb) all poll and re-check
|
|
110
110
|
# their ungetc buffer (10ms/100ms/100ms), so the injection reaches
|
|
111
|
-
# the blocked reader on every gate.
|
|
111
|
+
# the blocked reader on every gate. The per-poll re-check is 0.7.0+
|
|
112
|
+
# behavior — rubik.gemspec floors that version and
|
|
113
|
+
# test/integration/test_reline_wakeup_contract.rb pins the real gate
|
|
114
|
+
# (on 0.6.x the injected Enter strands until the next keystroke).
|
|
115
|
+
# idle_and_empty? is re-evaluated
|
|
112
116
|
# inside this critical section (Monitor reentrancy makes the nested
|
|
113
117
|
# synchronize free); the residual window between check and gate
|
|
114
118
|
# consumption is inherent to Reline's thread model and accepted. A
|
|
@@ -23,6 +23,9 @@ module Rubik
|
|
|
23
23
|
# PERF: Pre-compiled constants to avoid re-allocation inside hot loops
|
|
24
24
|
URL_PATTERN = %r{(https?://[^:]+:)([^@]+)@}
|
|
25
25
|
URL_MASK = "\\1#{MASK_STRING}@".freeze
|
|
26
|
+
STATIC_PATTERNS = PATTERNS.reject { |pattern| pattern == URL_PATTERN }.freeze
|
|
27
|
+
# Skip static replacement passes for assignment-only and URL-only inputs.
|
|
28
|
+
STATIC_PATTERNS_COMBINED_REGEX = Regexp.union(STATIC_PATTERNS).freeze
|
|
26
29
|
QUICK_CHECK_REGEX = %r{sk-|AKIA|ghp_|github_pat_|AIza|bearer|PRIVATE KEY|://[^:]+:|password|passwd|api_key|secret|token}i
|
|
27
30
|
|
|
28
31
|
# Anahtar-maskeleme desenleri (Session::Recorder#mask_payload ile aynı
|
|
@@ -46,13 +49,10 @@ module Rubik
|
|
|
46
49
|
# ve desenler zaten bu metne uygulanıyordu, dolayısıyla eşdeğerdir.
|
|
47
50
|
return result unless result.match?(QUICK_CHECK_REGEX)
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
result.gsub!(pattern, MASK_STRING)
|
|
54
|
-
end
|
|
55
|
-
end
|
|
52
|
+
# Keep replacement order: masking one credential can expose a word
|
|
53
|
+
# boundary needed to recognize the next (for example, a bearer token).
|
|
54
|
+
STATIC_PATTERNS.each { |pattern| result.gsub!(pattern, MASK_STRING) } if result.match?(STATIC_PATTERNS_COMBINED_REGEX)
|
|
55
|
+
result.gsub!(URL_PATTERN, URL_MASK)
|
|
56
56
|
|
|
57
57
|
result.gsub!(ASSIGNMENT_KEY_REGEX) do
|
|
58
58
|
"#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{MASK_STRING}"
|
|
@@ -26,23 +26,30 @@ module Rubik
|
|
|
26
26
|
return unless store && inst
|
|
27
27
|
|
|
28
28
|
file = inst.transcript_uri.to_s
|
|
29
|
+
result_ref = inst.respond_to?(:result_ref) ? inst.result_ref : nil
|
|
29
30
|
# Gerçek upsert: alias üzerinden varsa mevcut id güncellenir — resume
|
|
30
31
|
# sonrası ikinci tamamlanma yinelenen transcript varlığı yaratmaz.
|
|
31
32
|
ref = Rubik::Uris.transcript_ref(inst.storage_id)
|
|
32
33
|
existing = store.get(type: "transcript", id: ref)
|
|
34
|
+
links = {
|
|
35
|
+
"subagent_id" => inst.storage_id.to_s,
|
|
36
|
+
# The raw path is forensics-only (read_file denies it): the
|
|
37
|
+
# canonical read route rides in the entity itself so recall
|
|
38
|
+
# consumers land on show_file in one hop (issue #97 finding 5).
|
|
39
|
+
"canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
|
|
40
|
+
"transcript" => file
|
|
41
|
+
}
|
|
42
|
+
# Issue #122: the searchable summary also points at the paginated
|
|
43
|
+
# retained-result route. Availability is session-scoped (archive TTL),
|
|
44
|
+
# so this is a pointer with explicit expiration semantics, not a
|
|
45
|
+
# promise of unbounded retention.
|
|
46
|
+
links["result"] = result_ref if result_ref
|
|
33
47
|
store.put(
|
|
34
48
|
type: "transcript",
|
|
35
49
|
title: "[#{inst.type_name}] #{inst.role} (#{inst.id}) — #{inst.state}",
|
|
36
|
-
body: entity_body(inst, file),
|
|
50
|
+
body: entity_body(inst, file, result_ref: result_ref),
|
|
37
51
|
tags: [inst.type_name.to_s, inst.state.to_s].uniq,
|
|
38
|
-
links:
|
|
39
|
-
"subagent_id" => inst.storage_id.to_s,
|
|
40
|
-
# The raw path is forensics-only (read_file denies it): the
|
|
41
|
-
# canonical read route rides in the entity itself so recall
|
|
42
|
-
# consumers land on show_file in one hop (issue #97 finding 5).
|
|
43
|
-
"canonical" => Rubik::Uris.subagent_transcript(inst.storage_id),
|
|
44
|
-
"transcript" => file
|
|
45
|
-
},
|
|
52
|
+
links: links,
|
|
46
53
|
status: inst.state.to_s,
|
|
47
54
|
id: existing&.[]("id"),
|
|
48
55
|
ref: ref
|
|
@@ -59,7 +66,7 @@ module Rubik
|
|
|
59
66
|
# gelmeyen her metin (prompt fallback, inst.result) HAM memory'den
|
|
60
67
|
# gelir — kalıcı+aranabilir gövdeye girmeden Redactor'dan geçer
|
|
61
68
|
# (dosya tarafının maskeleme paritesi; review bulgusu).
|
|
62
|
-
def entity_body(inst, file)
|
|
69
|
+
def entity_body(inst, file, result_ref: nil)
|
|
63
70
|
task = first_user_task(file)
|
|
64
71
|
task_part = if task
|
|
65
72
|
"[task] #{clip(task)}"
|
|
@@ -71,7 +78,10 @@ module Rubik
|
|
|
71
78
|
|
|
72
79
|
result = inst.respond_to?(:result) ? redact(inst.result.to_s) : ""
|
|
73
80
|
result_part = result.empty? ? "[state] #{inst.state} — no result recorded" : "[result] #{clip(result)}"
|
|
74
|
-
"#{task_part}\n#{result_part}"
|
|
81
|
+
body = "#{task_part}\n#{result_part}"
|
|
82
|
+
return body unless result_ref && inst.respond_to?(:state) && inst.state.to_s == "completed"
|
|
83
|
+
|
|
84
|
+
"#{body}\n[retrieve] #{result_ref} — full retained result, paginated (manage_subagents action 'result')"
|
|
75
85
|
end
|
|
76
86
|
|
|
77
87
|
def redact(text)
|