samagotchi 0.160.0 → 0.161.0
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/CHANGELOG.md +86 -1
- data/docs/architecture.md +7 -3
- data/docs/cli.md +6 -4
- data/docs/context.md +10 -1
- data/docs/guardrails.md +12 -3
- data/docs/internals/bridge.md +21 -6
- data/docs/internals/bundles.md +24 -5
- data/docs/internals/objectives.md +8 -2
- data/docs/internals/remote-sessions.md +2 -1
- data/docs/memory.md +4 -2
- data/docs/plugins.md +13 -4
- data/docs/releasing.md +7 -4
- data/docs/scheduling.md +37 -5
- data/docs/sessions.md +14 -11
- data/docs/testing.md +9 -1
- data/lib/samagotchi/bridge/card_store.rb +31 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +11 -1
- data/lib/samagotchi/bridge/sse_writer.rb +8 -5
- data/lib/samagotchi/bridge.rb +92 -42
- data/lib/samagotchi/bridge_client.rb +9 -6
- data/lib/samagotchi/broadcast_command.rb +24 -8
- data/lib/samagotchi/bundle_command.rb +56 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +29 -23
- data/lib/samagotchi/client_id.rb +2 -0
- data/lib/samagotchi/compat.rb +32 -0
- data/lib/samagotchi/context_candidates_log.rb +10 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_move.rb +6 -1
- data/lib/samagotchi/engine/events.rb +12 -0
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/steering.rb +2 -2
- data/lib/samagotchi/guardrails/child_boundary.rb +37 -4
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/targets.rb +25 -6
- data/lib/samagotchi/live_versions.rb +7 -2
- data/lib/samagotchi/memory_bundle/asset_installer.rb +59 -12
- data/lib/samagotchi/memory_bundle/file_state.rb +11 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +7 -4
- data/lib/samagotchi/memory_bundle/installer.rb +79 -12
- data/lib/samagotchi/memory_bundle/provenance.rb +158 -6
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/status.rb +1 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +27 -13
- data/lib/samagotchi/memory_orphans.rb +40 -9
- data/lib/samagotchi/note_command.rb +9 -1
- data/lib/samagotchi/note_delivery.rb +34 -24
- data/lib/samagotchi/note_move.rb +205 -0
- data/lib/samagotchi/objectives_command.rb +3 -1
- data/lib/samagotchi/owner_lock.rb +7 -1
- data/lib/samagotchi/project_init.rb +3 -3
- data/lib/samagotchi/review_start.rb +5 -3
- data/lib/samagotchi/schedule_command.rb +12 -0
- data/lib/samagotchi/schedules.rb +286 -48
- data/lib/samagotchi/send_command.rb +2 -15
- data/lib/samagotchi/session.rb +42 -5
- data/lib/samagotchi/session_access/local.rb +45 -17
- data/lib/samagotchi/session_access/remote.rb +11 -4
- data/lib/samagotchi/session_access/web_bridge_client.rb +2 -2
- data/lib/samagotchi/session_access.rb +16 -3
- data/lib/samagotchi/session_continue.rb +155 -21
- data/lib/samagotchi/session_delete_command.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +41 -1
- data/lib/samagotchi/session_inbox_delivery.rb +254 -0
- data/lib/samagotchi/session_manager/delivery.rb +66 -12
- data/lib/samagotchi/session_manager/emptiness.rb +7 -2
- data/lib/samagotchi/session_manager/lifecycle.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +4 -1
- data/lib/samagotchi/session_manager/removal.rb +130 -26
- data/lib/samagotchi/session_manager/spawn.rb +164 -104
- data/lib/samagotchi/session_manager.rb +1 -0
- data/lib/samagotchi/session_retention.rb +53 -23
- data/lib/samagotchi/session_setup.rb +27 -7
- data/lib/samagotchi/starting_marker.rb +93 -24
- data/lib/samagotchi/state_format.rb +18 -7
- data/lib/samagotchi/system_one/runs.rb +6 -1
- data/lib/samagotchi/system_one/runs_store.rb +18 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +7 -4
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +5 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +5 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +4 -2
- data/lib/samagotchi/terminal_ui.rb +2 -3
- data/lib/samagotchi/tools/delegate.rb +9 -4
- data/lib/samagotchi/tools/schedule_task.rb +5 -3
- data/lib/samagotchi/tools/send_note.rb +10 -4
- data/lib/samagotchi/turn_state.rb +108 -4
- data/lib/samagotchi/update_command.rb +7 -2
- data/lib/samagotchi/verify_fresh.rb +27 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/public/app.js +2 -7
- data/lib/samagotchi/web/public/chains.js +4 -4
- data/lib/samagotchi/web/public/data.js +18 -2
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/session_fields.js +3 -2
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/update_notice.js +2 -2
- data/lib/samagotchi/web/session_access_routes.rb +12 -8
- data/lib/samagotchi/web/session_control.rb +9 -3
- data/lib/samagotchi/web/session_create.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +50 -5
- data/lib/samagotchi/web/session_summary.rb +5 -2
- data/lib/samagotchi/worker.rb +45 -7
- data/lib/samagotchi/worker_boot.rb +22 -11
- data/lib/samagotchi/worker_inbound.rb +41 -8
- data/lib/samagotchi/worker_link.rb +4 -1
- data/lib/samagotchi/worker_presence.rb +106 -0
- data/lib/samagotchi/worker_sidecar.rb +3 -4
- metadata +5 -1
data/lib/samagotchi/session.rb
CHANGED
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "securerandom"
|
|
6
6
|
require "time"
|
|
7
7
|
require_relative "atomic_file"
|
|
8
|
+
require_relative "log"
|
|
8
9
|
require_relative "context_note"
|
|
9
10
|
require_relative "interrupted_turn"
|
|
10
11
|
require_relative "last_turn"
|
|
@@ -485,6 +486,10 @@ module Samagotchi
|
|
|
485
486
|
@archived = false
|
|
486
487
|
# #save's lock: one snapshot-and-write at a time (see #save).
|
|
487
488
|
@save_lock = Mutex.new
|
|
489
|
+
# Read from its file (.load, .summary_from_file) or saved once: its
|
|
490
|
+
# file is there to rewrite, and one gone was deleted (#save doesn't
|
|
491
|
+
# bring it back).
|
|
492
|
+
@persisted = false
|
|
488
493
|
end
|
|
489
494
|
|
|
490
495
|
# Whether a chi newer than this one saved the loaded file: its
|
|
@@ -579,8 +584,7 @@ module Samagotchi
|
|
|
579
584
|
# @raise [Toolkit::Refused] its toolkit (setup's, else the configured
|
|
580
585
|
# one SessionManager.spawn_session gives it) builds on a config kit
|
|
581
586
|
# with an invalid groups: (Toolkit.check_new!)
|
|
582
|
-
def self.new_session(mode:, model_name:, working_directory:, test_run: nil,
|
|
583
|
-
preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, messages: [],
|
|
587
|
+
def self.new_session(mode:, model_name:, working_directory:, test_run: nil, parent_id: nil, messages: [],
|
|
584
588
|
scratch: false, model_typed: nil, delegate: false, setup: nil, continues: nil,
|
|
585
589
|
review: nil, now: Time.now)
|
|
586
590
|
own = setup.to_h
|
|
@@ -607,8 +611,6 @@ module Samagotchi
|
|
|
607
611
|
test_run: resolved_test,
|
|
608
612
|
project_root: project&.root,
|
|
609
613
|
project_id: project&.id,
|
|
610
|
-
preloaded_memory_names: preloaded_memory_names,
|
|
611
|
-
muted_memory_names: muted_memory_names,
|
|
612
614
|
parent_id: parent_id,
|
|
613
615
|
scratch: scratch,
|
|
614
616
|
delegate: delegate,
|
|
@@ -636,6 +638,7 @@ module Samagotchi
|
|
|
636
638
|
raise NotFound, session_id unless File.exist?(path)
|
|
637
639
|
|
|
638
640
|
session = from_h(JSON.parse(File.read(path)))
|
|
641
|
+
session.persisted!
|
|
639
642
|
session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: state_dir)
|
|
640
643
|
session.parent_id = parent_override(session.id, state_dir: state_dir) || session.parent_id
|
|
641
644
|
session
|
|
@@ -764,6 +767,7 @@ module Samagotchi
|
|
|
764
767
|
return nil unless valid_id?(data["id"])
|
|
765
768
|
|
|
766
769
|
session = from_h(data, messages: false)
|
|
770
|
+
session.persisted!
|
|
767
771
|
session.status = STATUS_STOPPED if stopped_marker?(session.id, state_dir: File.dirname(path))
|
|
768
772
|
session.parent_id = parent_override(session.id, state_dir: File.dirname(path)) || session.parent_id
|
|
769
773
|
session
|
|
@@ -771,6 +775,10 @@ module Samagotchi
|
|
|
771
775
|
nil
|
|
772
776
|
end
|
|
773
777
|
|
|
778
|
+
# This session was read from its file (#save then refuses to recreate
|
|
779
|
+
# a deleted one).
|
|
780
|
+
def persisted! = (@persisted = true)
|
|
781
|
+
|
|
774
782
|
# Persist the session atomically. Updates +updated_at+ in place.
|
|
775
783
|
#
|
|
776
784
|
# One save at a time per Session: a worker saves its session from the
|
|
@@ -782,6 +790,11 @@ module Samagotchi
|
|
|
782
790
|
# Never call it from a trap handler (Mutex#lock raises ThreadError
|
|
783
791
|
# there; WorkerSignals saves on a thread of its own) or from inside a
|
|
784
792
|
# save (the lock isn't re-entrant).
|
|
793
|
+
#
|
|
794
|
+
# A session read from its file, or saved once, whose file is gone was
|
|
795
|
+
# deleted (SessionManager.delete_session, the retention sweep): the save
|
|
796
|
+
# is skipped (and logged) rather than bring it back. Only a new
|
|
797
|
+
# session's first save creates the file.
|
|
785
798
|
# @param now [Time, nil] the updated_at to stamp (a spec's clock); nil,
|
|
786
799
|
# the time the lock is taken
|
|
787
800
|
def save(state_dir: self.class.default_state_dir, now: nil)
|
|
@@ -796,7 +809,13 @@ module Samagotchi
|
|
|
796
809
|
# Auto-compute first_preview if not yet cached and messages contain a user entry.
|
|
797
810
|
compute_first_preview!
|
|
798
811
|
|
|
812
|
+
if @persisted && !File.exist?(path)
|
|
813
|
+
Log.warn(:worker, "save_skipped_deleted", sid: @id)
|
|
814
|
+
return self
|
|
815
|
+
end
|
|
816
|
+
|
|
799
817
|
AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
|
|
818
|
+
@persisted = true
|
|
800
819
|
end
|
|
801
820
|
self
|
|
802
821
|
end
|
|
@@ -860,7 +879,25 @@ module Samagotchi
|
|
|
860
879
|
|
|
861
880
|
dir = session_dir(session_id, state_dir: state_dir)
|
|
862
881
|
FileUtils.mkdir_p(dir)
|
|
863
|
-
|
|
882
|
+
# Its own at, on the clock the starting marker's at is written with
|
|
883
|
+
# (StartingMarker; resume_session compares them): a file's mtime may be
|
|
884
|
+
# coarse or skewed. Older chi readers ask only whether it exists.
|
|
885
|
+
AtomicFile.write(File.join(dir, STOPPED_FILE), JSON.generate({ "at" => Time.now.iso8601(6) }))
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# When the stop marker was written: its own at, or (an older chi's empty
|
|
889
|
+
# file) its mtime; nil without one.
|
|
890
|
+
# @return [Time, nil]
|
|
891
|
+
def self.stopped_at(session_id, state_dir: default_state_dir)
|
|
892
|
+
path = File.join(session_dir(session_id, state_dir: state_dir), STOPPED_FILE)
|
|
893
|
+
own = begin
|
|
894
|
+
Time.iso8601(JSON.parse(File.read(path))["at"].to_s)
|
|
895
|
+
rescue JSON::ParserError, ArgumentError, TypeError, NoMethodError
|
|
896
|
+
nil
|
|
897
|
+
end
|
|
898
|
+
own || File.mtime(path)
|
|
899
|
+
rescue SystemCallError
|
|
900
|
+
nil
|
|
864
901
|
end
|
|
865
902
|
|
|
866
903
|
# Remove the stop marker (a resume, before its worker starts).
|
|
@@ -19,6 +19,7 @@ require_relative "../send_command/busy"
|
|
|
19
19
|
require_relative "../chi_invocation"
|
|
20
20
|
require_relative "../worker_sidecar"
|
|
21
21
|
require_relative "../worker_link"
|
|
22
|
+
require_relative "../worker_presence"
|
|
22
23
|
require_relative "../review_start"
|
|
23
24
|
require_relative "../launch_mode"
|
|
24
25
|
require_relative "../participants"
|
|
@@ -103,7 +104,7 @@ module Samagotchi
|
|
|
103
104
|
session = show(id)
|
|
104
105
|
raise Refused.new(Session::SCRATCH_REFUSED, code: "scratch") if session.scratch
|
|
105
106
|
|
|
106
|
-
link =
|
|
107
|
+
link = WorkerPresence.read(id, state_dir: state_dir!, link: :unlink).link
|
|
107
108
|
case link
|
|
108
109
|
when WorkerLink::Live then return Woken.new(woke: false, sidecar: link.sidecar)
|
|
109
110
|
when WorkerLink::Skew then raise Refused.new(link.message(id), code: "worker_newer")
|
|
@@ -126,8 +127,8 @@ module Samagotchi
|
|
|
126
127
|
# @raise [ArgumentError] no such session
|
|
127
128
|
def worker(id)
|
|
128
129
|
show(id)
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
presence = WorkerPresence.read(id, state_dir: state_dir!, link: :unlink)
|
|
131
|
+
WorkerState.new(link: presence.link, stopped: presence.stop_marked?)
|
|
131
132
|
end
|
|
132
133
|
|
|
133
134
|
# --- create and send
|
|
@@ -143,11 +144,11 @@ module Samagotchi
|
|
|
143
144
|
|
|
144
145
|
start = spec.preview ? { prompt: spec.prompt, preview: spec.preview } : { prompt: spec.prompt }
|
|
145
146
|
if spec.continues
|
|
146
|
-
return SessionContinue.run(spec.continues, **start, model: spec.model_name, **dir)
|
|
147
|
+
return SessionContinue.run(spec.continues, **start, model: spec.model_name, client_id: spec.client_id, **dir)
|
|
147
148
|
end
|
|
148
149
|
|
|
149
150
|
SessionManager.spawn_session(**start, working_directory: spec.working_directory, model_name: spec.model_name,
|
|
150
|
-
setup: spec.setup, **dir)
|
|
151
|
+
setup: spec.setup, client_id: spec.client_id, **dir)
|
|
151
152
|
end
|
|
152
153
|
|
|
153
154
|
# A review session (ReviewStart): a fork of review.from, or a fresh
|
|
@@ -313,20 +314,17 @@ module Samagotchi
|
|
|
313
314
|
return ContextPush.new(status: :missing) unless attached
|
|
314
315
|
return ContextPush.new(status: :not_push) unless attached.source.push?
|
|
315
316
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if fetched.wake && before != written.revision && !target.project? && attached.location.session? &&
|
|
320
|
-
attached.source.scope == "session"
|
|
321
|
-
begin
|
|
322
|
-
subscription = attached.location.subscription(name)
|
|
323
|
-
recent_wake = context_wake_recent?(subscription)
|
|
324
|
-
SessionManager.wake_for_context(target.session_id, **dir) unless recent_wake || Config.get("context.wake") == false
|
|
325
|
-
rescue StandardError => e
|
|
326
|
-
Log.exception(:context, "context_push_wake_failed", e, sid: target.session_id, source: name)
|
|
317
|
+
record = lambda do
|
|
318
|
+
attached.location.locked(name) do
|
|
319
|
+
[attached.location.snapshot(name).revision, attached.location.record_text(name, fetched)]
|
|
327
320
|
end
|
|
328
321
|
end
|
|
329
|
-
|
|
322
|
+
unless !target.project? && attached.location.session? && attached.source.scope == "session"
|
|
323
|
+
before, written = record.call
|
|
324
|
+
return ContextPush.new(status: :pushed, before: before, revision: written.revision)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
push_delivery(target.session_id, name, fetched, attached.location, record)
|
|
330
328
|
end
|
|
331
329
|
|
|
332
330
|
# The target's sources: a session's own, then its project's (shadowed
|
|
@@ -468,6 +466,36 @@ module Samagotchi
|
|
|
468
466
|
|
|
469
467
|
private
|
|
470
468
|
|
|
469
|
+
# A session source's push, as a push delivery (SessionInbox.deliver
|
|
470
|
+
# :push): the text recorded, then a wake when it asks one (a change
|
|
471
|
+
# with wake, context.wake on, the source not woken lately) as the
|
|
472
|
+
# push WakePolicy decides (an idle-exited session starts; a stopped
|
|
473
|
+
# one never). A failed wake is logged: the push stands.
|
|
474
|
+
# @return [ContextPush]
|
|
475
|
+
def push_delivery(session_id, name, fetched, location, record)
|
|
476
|
+
before = written = asked = nil
|
|
477
|
+
delivered = begin
|
|
478
|
+
SessionInbox.deliver(:push, session_id, state_dir: state_dir!) do
|
|
479
|
+
before, written = record.call
|
|
480
|
+
asked = fetched.wake && before != written.revision && context_wake_allowed?(location, name)
|
|
481
|
+
end
|
|
482
|
+
rescue StandardError => e
|
|
483
|
+
raise unless written
|
|
484
|
+
|
|
485
|
+
Log.exception(:context, "context_push_wake_failed", e, sid: session_id, source: name)
|
|
486
|
+
nil
|
|
487
|
+
end
|
|
488
|
+
# Its session file is gone (:gone): the source's folder takes it, as before.
|
|
489
|
+
before, written = record.call if delivered&.refused?
|
|
490
|
+
ContextPush.new(status: :pushed, before: before, revision: written.revision, wake: asked ? delivered&.wake : nil)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def context_wake_allowed?(location, name)
|
|
494
|
+
return false if Config.get("context.wake") == false
|
|
495
|
+
|
|
496
|
+
!context_wake_recent?(location.subscription(name))
|
|
497
|
+
end
|
|
498
|
+
|
|
471
499
|
def grants = @grants ||= Grants.new(Grants.path(state_dir: state_dir!))
|
|
472
500
|
|
|
473
501
|
LOCAL_PROBE_TIMEOUT = 1.0
|
|
@@ -317,14 +317,17 @@ module Samagotchi
|
|
|
317
317
|
def note(id, text:, source:)
|
|
318
318
|
reply = request(:post, "#{session_path(id)}/notes", body: { text: text, source: source })
|
|
319
319
|
json = reply.json.is_a?(Hash) ? reply.json : {}
|
|
320
|
+
# id: the link that has it, or refused it (a chi web before it names none: +id+).
|
|
321
|
+
head = Session.valid_id?(json["id"]) ? json["id"] : id
|
|
320
322
|
case reply.status
|
|
321
323
|
when 200
|
|
322
324
|
raise_refusal(reply) unless json["status"].is_a?(String)
|
|
323
325
|
|
|
324
|
-
NoteDelivery::Result.new(id: id, status: json["status"].to_sym, queued: json["queued"].to_i)
|
|
326
|
+
NoteDelivery::Result.new(id: head, asked: id, status: json["status"].to_sym, queued: json["queued"].to_i)
|
|
325
327
|
when 409
|
|
326
328
|
if json["error"] == "owned_by_tui"
|
|
327
|
-
return NoteDelivery::Result.new(id: id, status: :refused, queued: 0,
|
|
329
|
+
return NoteDelivery::Result.new(id: head, asked: id, status: :refused, queued: 0,
|
|
330
|
+
scratch: json["scratch"] == true)
|
|
328
331
|
end
|
|
329
332
|
|
|
330
333
|
raise_refusal(reply)
|
|
@@ -508,7 +511,10 @@ module Samagotchi
|
|
|
508
511
|
reply = request(:post, "#{session_path(target.session_id)}/context/#{name}/push",
|
|
509
512
|
body: { text: fetched.text, summary: fetched.summary, wake: fetched.wake, hint: fetched.hint })
|
|
510
513
|
case reply.status
|
|
511
|
-
when 200
|
|
514
|
+
when 200
|
|
515
|
+
# An older chi web's answer names no wake: the line is as it was.
|
|
516
|
+
wake = ContextPush::WAKES.find { |known| known.to_s == reply.json["wake"] }
|
|
517
|
+
ContextPush.new(status: :pushed, before: reply.json["before"], revision: reply.json["revision"], wake: wake)
|
|
512
518
|
when 404
|
|
513
519
|
return ContextPush.new(status: :missing) if context_missing?(reply)
|
|
514
520
|
|
|
@@ -736,7 +742,8 @@ module Samagotchi
|
|
|
736
742
|
raise SessionManager::ScratchSession, sid if json["scratch"] == true
|
|
737
743
|
|
|
738
744
|
raise SessionManager::OwnedByTUI, sid
|
|
739
|
-
when "worker_running", "still_stopping"
|
|
745
|
+
when "worker_running", "still_stopping", "scheduled"
|
|
746
|
+
raise SessionManager::DeleteRefused.new(sid, reason, message: message)
|
|
740
747
|
when "busy", "scratch"
|
|
741
748
|
raise SessionManager::ArchiveRefused.new(sid, reason, busy_id: json["busy_id"]) if reason
|
|
742
749
|
when "continued", "open_children", "folder_gone"
|
|
@@ -141,8 +141,8 @@ module Samagotchi
|
|
|
141
141
|
|
|
142
142
|
# POST …/cancel.
|
|
143
143
|
# @return [BridgeClient::Response]
|
|
144
|
-
def cancel(reason:)
|
|
145
|
-
post(:cancel, "cancel", { reason: reason })
|
|
144
|
+
def cancel(reason:, turn_id: nil)
|
|
145
|
+
post(:cancel, "cancel", { reason: reason, turn_id: turn_id }.compact)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
# POST …/exit: the worker asked to exit (chi web passes client_id on
|
|
@@ -271,9 +271,15 @@ module Samagotchi
|
|
|
271
271
|
# chi context push's outcome: :pushed (+before+ the revision it had,
|
|
272
272
|
# +revision+ the new one), :missing (no such source), :not_push (a
|
|
273
273
|
# command source, whose text is its command's) or :continued (the
|
|
274
|
-
# session was continued, as +next_id+: nothing was written).
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
# session was continued, as +next_id+: nothing was written). +wake+:
|
|
275
|
+
# what a push that asked for a wake did (SessionInbox.deliver :push:
|
|
276
|
+
# :woken, :live, :stopped, :starting, :archived, :scratch, :owned,
|
|
277
|
+
# :gone), nil when it asked none (or an older chi web's answer has none).
|
|
278
|
+
ContextPush = Data.define(:status, :before, :revision, :next_id, :wake) do
|
|
279
|
+
def initialize(status:, before: nil, revision: nil, next_id: nil, wake: nil) = super
|
|
280
|
+
|
|
281
|
+
# What chi context push adds to its line for the wake, else nil.
|
|
282
|
+
def wake_line = ContextPush::WAKE_LINES[wake]
|
|
277
283
|
|
|
278
284
|
# Why a push to +name+ was refused as :not_push: a command source's
|
|
279
285
|
# text is its command's, and a push between two fetches would read
|
|
@@ -289,5 +295,12 @@ module Samagotchi
|
|
|
289
295
|
"attach it to #{next_id} (or pair again with #{next_id}) and push there"
|
|
290
296
|
end
|
|
291
297
|
end
|
|
298
|
+
|
|
299
|
+
# The wakes a chi web's push answer may name (Remote reads no other).
|
|
300
|
+
ContextPush::WAKES = %i[woken live stopped starting archived scratch owned gone].freeze
|
|
301
|
+
# chi context push's words for a wake; none for the others (:live: the
|
|
302
|
+
# worker sees it as any push).
|
|
303
|
+
ContextPush::WAKE_LINES = { woken: "chi woken", stopped: "chi is stopped: it sees this at its next start",
|
|
304
|
+
starting: "chi is starting" }.freeze
|
|
292
305
|
end
|
|
293
306
|
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "atomic_file"
|
|
4
7
|
require_relative "session"
|
|
5
8
|
require_relative "session_setup"
|
|
6
9
|
require_relative "session_inbox"
|
|
@@ -9,6 +12,7 @@ require_relative "objectives"
|
|
|
9
12
|
require_relative "child_move"
|
|
10
13
|
require_relative "starting_marker"
|
|
11
14
|
require_relative "context_move"
|
|
15
|
+
require_relative "note_move"
|
|
12
16
|
require_relative "archive_store"
|
|
13
17
|
require_relative "recap_store"
|
|
14
18
|
require_relative "bridge_client"
|
|
@@ -56,6 +60,8 @@ module Samagotchi
|
|
|
56
60
|
RECAP_WAIT = 30.0
|
|
57
61
|
# In the previous link's folder: one continue of a session at a time.
|
|
58
62
|
LOCK = "continue.lock"
|
|
63
|
+
# A durable record in the previous link's folder, written before archive.
|
|
64
|
+
CONTINUE_INTENT = "continue.json"
|
|
59
65
|
# How often a last:<id> continue looks again when another continue
|
|
60
66
|
# moved the chain's end while it waited for the lock.
|
|
61
67
|
TRIES = 3
|
|
@@ -67,6 +73,29 @@ module Samagotchi
|
|
|
67
73
|
# (ChildMove::Plan) and the new link's model (ModelChoice).
|
|
68
74
|
Checked = Data.define(:plan, :choice)
|
|
69
75
|
|
|
76
|
+
# Repair an interrupted continue before showing sessions or starting
|
|
77
|
+
# another continue. An active continue holds LOCK, so its intent is left
|
|
78
|
+
# alone; after a crash, an unsaved successor means the previous link is
|
|
79
|
+
# unarchived.
|
|
80
|
+
def self.repair_interrupted(state_dir:)
|
|
81
|
+
return unless Dir.exist?(state_dir)
|
|
82
|
+
|
|
83
|
+
paths = Dir.glob(File.join(state_dir, "*", CONTINUE_INTENT))
|
|
84
|
+
return if paths.empty?
|
|
85
|
+
|
|
86
|
+
sessions = Session.list(state_dir: state_dir, include_archived: true)
|
|
87
|
+
by_id = sessions.to_h { |session| [session.id, session] }
|
|
88
|
+
paths.each do |path|
|
|
89
|
+
id = File.basename(File.dirname(path))
|
|
90
|
+
session = by_id[id]
|
|
91
|
+
if session
|
|
92
|
+
repair_intent(session, state_dir, path, sessions)
|
|
93
|
+
else
|
|
94
|
+
quarantine_intent(path, id, message: "session file could not be loaded")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
70
99
|
# Start the next link of a chain: a new session that continues
|
|
71
100
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
72
101
|
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
@@ -105,6 +134,7 @@ module Samagotchi
|
|
|
105
134
|
def self.run(id_or_ref, prompt: nil, preview: nil, model: nil, state_dir: nil, recap_wait: RECAP_WAIT, archive_wait: 5,
|
|
106
135
|
client_id: nil)
|
|
107
136
|
sd = state_dir || Session.default_state_dir
|
|
137
|
+
repair_interrupted(state_dir: sd)
|
|
108
138
|
TRIES.times do |try|
|
|
109
139
|
id = SessionChain.resolve(id_or_ref, state_dir: sd)
|
|
110
140
|
result = with_lock(id, sd) do
|
|
@@ -140,24 +170,100 @@ module Samagotchi
|
|
|
140
170
|
already = [id, *SessionManager.descendant_ids(id, state_dir, except: plan.move)].select do |sid|
|
|
141
171
|
ArchiveStore.archived?(Session.session_dir(sid, state_dir: state_dir))
|
|
142
172
|
end
|
|
173
|
+
successor_id = SecureRandom.uuid
|
|
174
|
+
intent_path = continue_intent_path(id, state_dir)
|
|
175
|
+
intent_data = { successor_id: successor_id, previously_archived: already }
|
|
176
|
+
AtomicFile.write(intent_path, "#{JSON.generate(intent_data)}\n", perm: 0o600)
|
|
143
177
|
archive = SessionManager.archive_session(id, state_dir: state_dir, wait: archive_wait, except: plan.move)
|
|
144
178
|
archived = archive.archived - already
|
|
179
|
+
intent_data[:archived_ids] = archived
|
|
180
|
+
AtomicFile.write(intent_path, "#{JSON.generate(intent_data)}\n", perm: 0o600)
|
|
181
|
+
# The notes the new link took from this one (NoteMove.move).
|
|
182
|
+
notes = []
|
|
145
183
|
begin
|
|
146
|
-
spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
|
|
147
|
-
|
|
148
|
-
rescue StandardError
|
|
184
|
+
link = spawn_continuation(previous, prompt: prompt, preview: preview, choice: checked.choice, state_dir: state_dir,
|
|
185
|
+
move: plan.move, client_id: client_id, notes: notes, session_id: successor_id)
|
|
186
|
+
rescue StandardError, Interrupt
|
|
149
187
|
# Nothing started: the delegates go back, what this archived goes
|
|
150
188
|
# back to the lists, and a new link saved before its worker failed
|
|
151
189
|
# to spawn goes (left, it would hold the chain: "continued already",
|
|
152
|
-
# last: to it).
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
discard_failed_link(id, state_dir)
|
|
156
|
-
ensure
|
|
157
|
-
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
158
|
-
wake_for_rings(id, state_dir) unless plan.move.empty?
|
|
190
|
+
# last: to it), its notes given back first.
|
|
191
|
+
undo_failed_continue(id, state_dir, moved: plan.move, archived: archived) do
|
|
192
|
+
discard_failed_link(id, state_dir, notes: notes)
|
|
159
193
|
end
|
|
194
|
+
FileUtils.rm_f(intent_path)
|
|
160
195
|
raise
|
|
196
|
+
else
|
|
197
|
+
FileUtils.rm_f(intent_path)
|
|
198
|
+
link
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private_class_method def self.continue_intent_path(id, state_dir)
|
|
203
|
+
File.join(Session.session_dir(id, state_dir: state_dir), CONTINUE_INTENT)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private_class_method def self.repair_intent(session, state_dir, path, sessions)
|
|
207
|
+
lock_path = File.join(Session.session_dir(session.id, state_dir: state_dir), LOCK)
|
|
208
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
209
|
+
next unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
210
|
+
next unless File.file?(path)
|
|
211
|
+
|
|
212
|
+
intent = JSON.parse(File.read(path))
|
|
213
|
+
intent = {} unless intent.is_a?(Hash)
|
|
214
|
+
successor_id = intent["successor_id"]
|
|
215
|
+
successor_path = Session.session_file(successor_id, state_dir: state_dir) if Session.valid_id?(successor_id)
|
|
216
|
+
successor = JSON.parse(File.read(successor_path)) if successor_path && File.file?(successor_path)
|
|
217
|
+
unless successor.is_a?(Hash) && successor["continues"] == session.id
|
|
218
|
+
previously_archived = Array(intent["previously_archived"]).select { |id| Session.valid_id?(id) }
|
|
219
|
+
archived = if intent["archived_ids"].is_a?(Array)
|
|
220
|
+
intent["archived_ids"].select { |id| Session.valid_id?(id) }
|
|
221
|
+
else
|
|
222
|
+
archived_descendants(session.id, sessions, state_dir) - previously_archived
|
|
223
|
+
end
|
|
224
|
+
undo_failed_continue(session.id, state_dir, moved: [], archived: archived)
|
|
225
|
+
end
|
|
226
|
+
FileUtils.rm_f(path)
|
|
227
|
+
end
|
|
228
|
+
rescue JSON::ParserError => e
|
|
229
|
+
quarantine_intent(path, session.id, error: e)
|
|
230
|
+
rescue Errno::ENOENT
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private_class_method def self.quarantine_intent(path, session_id, error: nil, message: nil)
|
|
235
|
+
File.rename(path, "#{path}.bad")
|
|
236
|
+
Log.warn(:worker, "continue_repair_failed", sid: session_id, path: path,
|
|
237
|
+
error: error&.class&.name || "SessionUnavailable",
|
|
238
|
+
msg: (message || error.message).to_s[0, 200])
|
|
239
|
+
rescue Errno::ENOENT
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private_class_method def self.archived_descendants(id, sessions, state_dir)
|
|
244
|
+
children = sessions.group_by(&:parent_id)
|
|
245
|
+
queue = [id]
|
|
246
|
+
descendants = []
|
|
247
|
+
until queue.empty?
|
|
248
|
+
children.fetch(queue.shift, []).each do |child|
|
|
249
|
+
next if descendants.include?(child.id)
|
|
250
|
+
|
|
251
|
+
descendants << child.id
|
|
252
|
+
queue << child.id
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
[id, *descendants].select do |session_id|
|
|
256
|
+
ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private_class_method def self.undo_failed_continue(id, state_dir, moved:, archived:)
|
|
261
|
+
undo_move(id, state_dir, moved: moved)
|
|
262
|
+
begin
|
|
263
|
+
yield if block_given?
|
|
264
|
+
ensure
|
|
265
|
+
archived.each { |sid| ArchiveStore.unarchive(sid, state_dir: state_dir) }
|
|
266
|
+
wake_for_rings(id, state_dir) unless moved.empty?
|
|
161
267
|
end
|
|
162
268
|
end
|
|
163
269
|
|
|
@@ -175,10 +281,16 @@ module Samagotchi
|
|
|
175
281
|
# The link a failed #run saved (no other continues +id+:
|
|
176
282
|
# check! made sure under the lock): its worker stopped if one
|
|
177
283
|
# started, then the rings a moved delegate wrote into it just before
|
|
178
|
-
# the undo go back to +id
|
|
179
|
-
#
|
|
180
|
-
# and
|
|
181
|
-
|
|
284
|
+
# the undo go back to +id+. Then its session file goes first, so the
|
|
285
|
+
# link is out of the chain (a note to +id+ stays there:
|
|
286
|
+
# SessionInbox::Head) and a writer that resolved it before finds it gone,
|
|
287
|
+
# then the notes it moved there (+notes+) go back with any sent to it
|
|
288
|
+
# meanwhile (NoteMove.give_back), then the link's folder goes, and one
|
|
289
|
+
# last look gives back a note an older chi wrote after the delete
|
|
290
|
+
# (its write_note recreates the folder). A worker that outlives the
|
|
291
|
+
# stop is raised (SessionManager::DeleteRefused): the link stays, and
|
|
292
|
+
# the caller hears it.
|
|
293
|
+
private_class_method def self.discard_failed_link(id, state_dir, notes:)
|
|
182
294
|
orphan = SessionChain.next_of(id, state_dir: state_dir) or return
|
|
183
295
|
if SessionManager.refuse_tui!(orphan, state_dir: state_dir) &&
|
|
184
296
|
!SessionManager.stop_session(orphan, state_dir: state_dir, wait: SessionManager::STOP_WAIT)
|
|
@@ -186,13 +298,26 @@ module Samagotchi
|
|
|
186
298
|
end
|
|
187
299
|
|
|
188
300
|
ChildMove.return_rings(from: orphan, to: id, state_dir: state_dir)
|
|
189
|
-
|
|
301
|
+
FileUtils.rm_f(Session.session_file(orphan, state_dir: state_dir))
|
|
302
|
+
NoteMove.give_back(from: orphan, to: id, moved: notes, state_dir: state_dir)
|
|
303
|
+
SessionManager.delete_session(orphan, state_dir: state_dir, allow_scheduled: true)
|
|
304
|
+
give_back_strays(orphan, id, state_dir)
|
|
190
305
|
rescue SessionManager::DeleteRefused
|
|
191
306
|
raise
|
|
192
307
|
rescue StandardError => e
|
|
193
308
|
Log.warn(:worker, "continue_cleanup_failed", sid: id, error: e.class.name, msg: e.message)
|
|
194
309
|
end
|
|
195
310
|
|
|
311
|
+
# A failed link's folder an older chi's note brought back after its
|
|
312
|
+
# delete: the note goes back to +id+, the folder goes again.
|
|
313
|
+
private_class_method def self.give_back_strays(orphan, id, state_dir)
|
|
314
|
+
dir = Session.session_dir(orphan, state_dir: state_dir)
|
|
315
|
+
return unless Dir.exist?(dir) && !Session.exist?(orphan, state_dir: state_dir)
|
|
316
|
+
|
|
317
|
+
NoteMove.give_back(from: orphan, to: id, moved: [], state_dir: state_dir)
|
|
318
|
+
FileUtils.rm_rf(dir)
|
|
319
|
+
end
|
|
320
|
+
|
|
196
321
|
# A failed continue's previous link, back in the lists, with rings that
|
|
197
322
|
# came in meanwhile: wake it for them, as the ring's own wake would have.
|
|
198
323
|
private_class_method def self.wake_for_rings(id, state_dir)
|
|
@@ -203,26 +328,35 @@ module Samagotchi
|
|
|
203
328
|
Log.warn(:worker, "continue_wake_failed", sid: id, error: e.class.name, msg: e.message)
|
|
204
329
|
end
|
|
205
330
|
|
|
206
|
-
# The new link, holding off any worker but its own
|
|
207
|
-
#
|
|
208
|
-
|
|
331
|
+
# The new link, holding off any worker but its own while +move+ goes to
|
|
332
|
+
# it (spawn_session marks it starting before its file is saved:
|
|
333
|
+
# StartingMarker.fresh?).
|
|
334
|
+
# @param notes [Array<String>] gets the names of the notes moved to it
|
|
335
|
+
private_class_method def self.spawn_continuation(previous, prompt:, preview:, choice:, state_dir:, move:, notes:,
|
|
336
|
+
session_id:, client_id: nil)
|
|
209
337
|
recap = RecapStore.read(Session.session_dir(previous.id, state_dir: state_dir))
|
|
210
338
|
start = prompt.to_s.strip.empty? ? nil : prompt
|
|
211
339
|
preview = previous.first_preview if preview.to_s.strip.empty? && start.nil?
|
|
212
340
|
moved_context = nil
|
|
213
341
|
before_spawn = lambda do |link|
|
|
214
|
-
StartingMarker.mark(link.id, state_dir: state_dir)
|
|
215
342
|
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
216
343
|
# Its movable context too, with a note saying what moved and stayed.
|
|
217
344
|
moved_context = ContextMove.call(from: previous.id, to: link.id, mode: :continue, state_dir: state_dir)
|
|
218
|
-
# Its objectives
|
|
345
|
+
# Its objectives: the list reads as the latest word.
|
|
219
346
|
Objectives.carry(previous, to: link.id, state_dir: state_dir)
|
|
347
|
+
# Its waiting notes last: the carry above read a pending /objectives
|
|
348
|
+
# edit from them first. They keep their names, so they still sort
|
|
349
|
+
# ahead of the link's own notes. A note sent to the previous link
|
|
350
|
+
# from here on goes to this one (SessionInbox::Head), and a
|
|
351
|
+
# straggler is adopted by its worker (NoteMove.adoption).
|
|
352
|
+
notes.concat(NoteMove.move(from: previous.id, to: link.id, state_dir: state_dir))
|
|
220
353
|
end
|
|
221
354
|
link = SessionManager.spawn_session(
|
|
222
355
|
prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
|
|
223
356
|
model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
|
|
224
357
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
|
|
225
|
-
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id
|
|
358
|
+
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn, client_id: client_id,
|
|
359
|
+
session_id: session_id
|
|
226
360
|
)
|
|
227
361
|
link.moved_children = move
|
|
228
362
|
link.moved_context = moved_context
|
|
@@ -70,7 +70,9 @@ module Samagotchi
|
|
|
70
70
|
@stdout.puts("#{short} refused: it is #{OwnerLock::TUI_BUSY}; let it finish first")
|
|
71
71
|
false
|
|
72
72
|
rescue SessionManager::DeleteRefused => e
|
|
73
|
-
line =
|
|
73
|
+
line = case e.reason
|
|
74
|
+
when :still_stopping then "its worker is still shutting down; try again in a moment"
|
|
75
|
+
when :scheduled then e.message
|
|
74
76
|
else "its worker is running (--force stops it first)"
|
|
75
77
|
end
|
|
76
78
|
@stdout.puts("#{short} refused: #{line}")
|
|
@@ -44,6 +44,20 @@ module Samagotchi
|
|
|
44
44
|
# NOTE_MAX_BYTES.
|
|
45
45
|
class NoteRejected < ArgumentError; end
|
|
46
46
|
|
|
47
|
+
# The one delivery path (session_inbox_delivery.rb), loaded on first
|
|
48
|
+
# use: the bundle test kit's load budget stays as it was.
|
|
49
|
+
delivery = File.expand_path("session_inbox_delivery", __dir__)
|
|
50
|
+
%i[Dispatch Delivered Head Resolved WakePolicy].each { |name| autoload name, delivery }
|
|
51
|
+
|
|
52
|
+
# A delivery of +kind+ to session +target+ (Dispatch.deliver: resolve,
|
|
53
|
+
# refuse, the block's write, one presence read, the WakePolicy's wake).
|
|
54
|
+
# @return [Delivered]
|
|
55
|
+
def self.deliver(kind, target, **, &) = Dispatch.deliver(kind, target, **, &)
|
|
56
|
+
|
|
57
|
+
# The wake half of #deliver alone (Dispatch.wake).
|
|
58
|
+
# @return [Delivered]
|
|
59
|
+
def self.wake(kind, target, **) = Dispatch.wake(kind, target, **)
|
|
60
|
+
|
|
47
61
|
# Queue a turn's input file: JSON with the prompt and the sender's ids.
|
|
48
62
|
# +delivery+ is only written when it is "queue": a step-boundary
|
|
49
63
|
# message is the default an older worker assumes, and a cut is done by
|
|
@@ -89,11 +103,17 @@ module Samagotchi
|
|
|
89
103
|
# @param now [Time] the time the file is named and stamped by (a spec's clock)
|
|
90
104
|
# @return [String] the note file's path
|
|
91
105
|
# @raise [NoteRejected] for an empty note or one over 16 KiB (never cut)
|
|
106
|
+
# @raise [Session::NotFound] for a session that is gone: no file and no
|
|
107
|
+
# folder (deleted since the writer resolved it), which a note would
|
|
108
|
+
# otherwise bring back as a stray folder (as Session#save skips one)
|
|
92
109
|
def self.write_note(session_id, text:, source: "cli", from_session: nil, from_cwd: nil, state_dir: nil,
|
|
93
110
|
objectives: nil, now: Time.now)
|
|
94
111
|
body = checked_text(text)
|
|
95
112
|
sd = state_dir || Session.default_state_dir
|
|
96
|
-
|
|
113
|
+
session_dir = Session.session_dir(session_id, state_dir: sd)
|
|
114
|
+
raise Session::NotFound, session_id unless Dir.exist?(session_dir) || Session.exist?(session_id, state_dir: sd)
|
|
115
|
+
|
|
116
|
+
notes_dir = File.join(session_dir, NOTES_DIR)
|
|
97
117
|
FileUtils.mkdir_p(notes_dir)
|
|
98
118
|
|
|
99
119
|
# The random part keeps two writers in one nanosecond apart; the
|
|
@@ -203,6 +223,11 @@ module Samagotchi
|
|
|
203
223
|
# An id that is safe in a file name (a Bridge's or deliver_turn's
|
|
204
224
|
# uuid); anything else leaves the name bare.
|
|
205
225
|
ENQUEUED_ID_NAME = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
226
|
+
ENQUEUED_ID_UUID = /\A[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\z/i
|
|
227
|
+
|
|
228
|
+
def self.new_enqueued_id = SecureRandom.uuid
|
|
229
|
+
|
|
230
|
+
def self.valid_enqueued_id?(id) = id.is_a?(String) && ENQUEUED_ID_UUID.match?(id)
|
|
206
231
|
|
|
207
232
|
def self.output_name(timestamp, enqueued_id)
|
|
208
233
|
id = enqueued_id.to_s
|
|
@@ -221,6 +246,21 @@ module Samagotchi
|
|
|
221
246
|
Dir.children(dir).select { |f| f.end_with?(suffix) }.max
|
|
222
247
|
end
|
|
223
248
|
|
|
249
|
+
# Evidence that a prior worker took this input: a saved or interrupted
|
|
250
|
+
# turn, its reply, or a mid-turn merge record. A restarted worker's
|
|
251
|
+
# in-memory id ledger cannot cover these deliveries.
|
|
252
|
+
def self.input_taken?(session_dir, session, enqueued_id)
|
|
253
|
+
id = enqueued_id.to_s
|
|
254
|
+
return false if id.empty?
|
|
255
|
+
return true if [session.last_turn&.enqueued_id, session.interrupted_turn&.enqueued_id].include?(id)
|
|
256
|
+
return true if reply_for(session_dir, id)
|
|
257
|
+
|
|
258
|
+
require_relative "turn_checkpoint"
|
|
259
|
+
return true if TurnCheckpoint.settled_turn(session_dir, id)
|
|
260
|
+
|
|
261
|
+
!merged_into(session_dir, id).nil?
|
|
262
|
+
end
|
|
263
|
+
|
|
224
264
|
# The output files' text, oldest first; with +since_time+ only files
|
|
225
265
|
# modified after it.
|
|
226
266
|
def self.read_outputs(session_dir, since_time: nil)
|