@zalom/plastic 2.0.0-alpha.8 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +13 -136
- package/README.md +357 -133
- package/agents/plastic-enforcer.md +20 -15
- package/agents/plastic-executor.md +15 -4
- package/agents/plastic-node-research.md +30 -0
- package/agents/plastic-node-verify.md +28 -0
- package/agents/plastic-node-work.md +33 -0
- package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
- package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
- package/assets/plastic-logo.svg +1 -0
- package/bin/crap +4 -0
- package/bin/lib/context_budget.rb +43 -6
- package/bin/lib/skill_census.rb +839 -0
- package/bin/plastic +6 -0
- package/bin/plastic-skill-census +114 -0
- package/bin/test +24 -4
- package/bin/verify-change +345 -0
- package/config_asks.yml +4 -4
- package/deprecations.yml +1 -1
- package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
- package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
- package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
- package/docs/help/human-report-contract.md +152 -0
- package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
- package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +23 -13
- package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
- package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
- package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
- package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
- package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +62 -1
- package/hooks/session-start +5 -1
- package/hooks/statusline +32 -27
- package/hooks/stop +5 -0
- package/package.json +5 -3
- package/scripts/append-ledger +2 -1
- package/scripts/dashboard.rb +267 -16
- package/scripts/day-summary +2 -1
- package/scripts/doctor.rb +680 -39
- package/scripts/end-intent +238 -22
- package/scripts/exec-worktree +5 -5
- package/scripts/file-session-intent +2 -1
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +24 -123
- package/scripts/hook-close +2 -1
- package/scripts/hook-message-display +19 -5
- package/scripts/hook-record +24 -16
- package/scripts/hook-savepoint +27 -3
- package/scripts/hook-session-start +359 -321
- package/scripts/hook-stop +58 -0
- package/scripts/index-projection +74 -0
- package/scripts/insight-append +17 -4
- package/scripts/install.rb +9 -7
- package/scripts/lib/action_graph_shim.rb +279 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +63 -25
- package/scripts/lib/arm.rb +48 -83
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/backup.rb +65 -0
- package/scripts/lib/cli/command.rb +92 -0
- package/scripts/lib/cli/commands/auto.rb +18 -0
- package/scripts/lib/cli/commands/auto_brief.rb +44 -0
- package/scripts/lib/cli/commands/auto_lock.rb +99 -0
- package/scripts/lib/cli/commands/auto_report.rb +50 -0
- package/scripts/lib/cli/commands/auto_take.rb +31 -0
- package/scripts/lib/cli/commands/backup.rb +43 -0
- package/scripts/lib/cli/commands/checkout.rb +25 -0
- package/scripts/lib/cli/commands/continue.rb +66 -0
- package/scripts/lib/cli/commands/doctor.rb +81 -0
- package/scripts/lib/cli/commands/feedback.rb +40 -0
- package/scripts/lib/cli/commands/help.rb +69 -0
- package/scripts/lib/cli/commands/hook.rb +32 -0
- package/scripts/lib/cli/commands/index.rb +23 -0
- package/scripts/lib/cli/commands/install.rb +21 -0
- package/scripts/lib/cli/commands/installer_verb.rb +37 -0
- package/scripts/lib/cli/commands/intent.rb +19 -0
- package/scripts/lib/cli/commands/intent_answer.rb +37 -0
- package/scripts/lib/cli/commands/intent_command.rb +53 -0
- package/scripts/lib/cli/commands/intent_end.rb +62 -0
- package/scripts/lib/cli/commands/intent_new.rb +62 -0
- package/scripts/lib/cli/commands/intent_note.rb +43 -0
- package/scripts/lib/cli/commands/intent_rule.rb +36 -0
- package/scripts/lib/cli/commands/intent_show.rb +32 -0
- package/scripts/lib/cli/commands/intent_spec.rb +44 -0
- package/scripts/lib/cli/commands/intent_step.rb +71 -0
- package/scripts/lib/cli/commands/intent_verify.rb +26 -0
- package/scripts/lib/cli/commands/migrate.rb +16 -0
- package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
- package/scripts/lib/cli/commands/next.rb +54 -0
- package/scripts/lib/cli/commands/project.rb +19 -0
- package/scripts/lib/cli/commands/project_links.rb +42 -0
- package/scripts/lib/cli/commands/project_list.rb +20 -0
- package/scripts/lib/cli/commands/project_new.rb +72 -0
- package/scripts/lib/cli/commands/query.rb +31 -0
- package/scripts/lib/cli/commands/render.rb +33 -0
- package/scripts/lib/cli/commands/roadmap.rb +19 -0
- package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
- package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
- package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
- package/scripts/lib/cli/commands/rollback.rb +20 -0
- package/scripts/lib/cli/commands/search.rb +60 -0
- package/scripts/lib/cli/commands/session.rb +18 -0
- package/scripts/lib/cli/commands/session_commit.rb +42 -0
- package/scripts/lib/cli/commands/session_handoff.rb +34 -0
- package/scripts/lib/cli/commands/session_summary.rb +35 -0
- package/scripts/lib/cli/commands/status.rb +68 -0
- package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
- package/scripts/lib/cli/commands/sync.rb +46 -0
- package/scripts/lib/cli/commands/uninstall.rb +20 -0
- package/scripts/lib/cli/commands/update.rb +20 -0
- package/scripts/lib/cli/commands/version.rb +53 -0
- package/scripts/lib/cli/frontier.rb +86 -0
- package/scripts/lib/cli/intent_progress.rb +36 -0
- package/scripts/lib/cli/legacy.rb +80 -0
- package/scripts/lib/cli/output.rb +137 -0
- package/scripts/lib/cli/scope.rb +134 -0
- package/scripts/lib/cli/table.rb +65 -0
- package/scripts/lib/cli.rb +94 -0
- package/scripts/lib/codex_adapter.rb +198 -0
- package/scripts/lib/compact_instructions.rb +13 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/data_boundary.rb +132 -0
- package/scripts/lib/day_summary.rb +30 -16
- package/scripts/lib/doctor_core.rb +114 -38
- package/scripts/lib/doctor_session_ledger.rb +5 -53
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/feedback_report.rb +1 -1
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +413 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +27 -3
- package/scripts/lib/hook_replay.rb +229 -0
- package/scripts/lib/index_entry.rb +62 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +532 -71
- package/scripts/lib/intent_screen.rb +9 -6
- package/scripts/lib/intent_screen_ansi.rb +73 -12
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +10 -11
- package/scripts/lib/message_display.rb +377 -173
- package/scripts/lib/meter_watch.rb +185 -0
- package/scripts/lib/node_file.rb +234 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_input.rb +913 -0
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +386 -0
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/node_return.rb +204 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/preflight.rb +4 -6
- package/scripts/lib/project_config.rb +46 -0
- package/scripts/lib/project_validator.rb +3 -2
- package/scripts/lib/qmd_sync.rb +8 -7
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/reference_archive.rb +45 -0
- package/scripts/lib/release_guard.rb +18 -0
- package/scripts/lib/report_screen.rb +1460 -74
- package/scripts/lib/rlm/corpus.rb +13 -0
- package/scripts/lib/rlm/probe.rb +29 -0
- package/scripts/lib/rlm/query.rb +22 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +158 -8
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +64 -14
- package/scripts/lib/runner_absorb.rb +703 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +525 -0
- package/scripts/lib/runner_policy.rb +191 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +141 -19
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/search_index.rb +55 -0
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +25 -18
- package/scripts/lib/session_ledger.rb +48 -4
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/sqlite.rb +22 -0
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/store_discovery.rb +7 -6
- package/scripts/lib/store_layout.rb +54 -0
- package/scripts/lib/store_provisioning.rb +2 -1
- package/scripts/lib/store_sync.rb +85 -0
- package/scripts/lib/stores_move.rb +93 -0
- package/scripts/lib/verify_intent.rb +36 -8
- package/scripts/lib/version_number.rb +48 -0
- package/scripts/lib/work_graph.rb +59 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/lib/worktree.rb +27 -32
- package/scripts/lib/worktree_sweep.rb +6 -5
- package/scripts/link-suggest +2 -1
- package/scripts/meter-watch +57 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +4 -13
- package/scripts/node-input +92 -0
- package/scripts/node-run +225 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +42 -33
- package/scripts/promote-session-item +3 -2
- package/scripts/read-config +53 -9
- package/scripts/ready-set +126 -0
- package/scripts/release-check +123 -0
- package/scripts/report-screen +192 -30
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +581 -0
- package/scripts/savepoint-note +11 -9
- package/scripts/session-commit +2 -1
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/scripts/spawn-preamble +2 -2
- package/scripts/update.rb +25 -4
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +3 -2
- package/scripts/write-handoff +2 -1
- package/templates/agents.md +7 -7
- package/templates/config.yml +16 -9
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/index.md +1 -1
- package/templates/intent-screen.md +1 -1
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +13 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +15 -2
- package/templates/project.yml +1 -1
- package/templates/render.css +10 -0
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +1 -1
- package/templates/roadmap.md +13 -0
- package/bin/plastic.js +0 -70
- package/scripts/lib/bridge.rb +0 -116
- package/skills/agent-advisor/SKILL.md +0 -92
- package/skills/auto/SKILL.md +0 -295
- package/skills/auto/evals/evals.json +0 -255
- package/skills/auto/references/end-tail.md +0 -66
- package/skills/auto/references/human-report-contract.md +0 -78
- package/skills/conventions/SKILL.md +0 -29
- package/skills/dashboard/SKILL.md +0 -169
- package/skills/dashboard/evals/evals.json +0 -38
- package/skills/dashboard/references/classification.md +0 -22
- package/skills/dashboard/templates/dashboard-global.md +0 -20
- package/skills/dashboard/templates/dashboard-project.md +0 -19
- package/skills/direct/SKILL.md +0 -66
- package/skills/direct/references/request-signals.md +0 -59
- package/skills/doctor/SKILL.md +0 -299
- package/skills/doctor/report.md +0 -102
- package/skills/feedback/SKILL.md +0 -98
- package/skills/feedback/references/transport-and-privacy.md +0 -65
- package/skills/feedback/report.md +0 -36
- package/skills/install/SKILL.md +0 -217
- package/skills/intent-continuing/SKILL.md +0 -154
- package/skills/intent-continuing/references/board-fill.md +0 -43
- package/skills/intent-continuing/references/boarding-matrix.md +0 -34
- package/skills/intent-continuing/references/context-management.md +0 -28
- package/skills/intent-continuing/references/liveness-ranking.md +0 -57
- package/skills/intent-creating/SKILL.md +0 -164
- package/skills/intent-creating/evals/evals.json +0 -72
- package/skills/intent-creating/references/lifecycle.md +0 -81
- package/skills/intent-creating/references/wikilinks.md +0 -8
- package/skills/intent-ending/SKILL.md +0 -173
- package/skills/intent-ending/evals/evals.json +0 -74
- package/skills/intent-executing/SKILL.md +0 -170
- package/skills/intent-executing/evals/evals.json +0 -66
- package/skills/intent-executing/implementer-prompt.md +0 -42
- package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
- package/skills/intent-speccing/SKILL.md +0 -130
- package/skills/intent-speccing/evals/evals.json +0 -126
- package/skills/intent-speccing/references/design-principles.md +0 -44
- package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
- package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
- package/skills/project-creating/SKILL.md +0 -162
- package/skills/project-creating/references/hubs-projects.md +0 -55
- package/skills/project-creating/references/project-scaffolding.md +0 -97
- package/skills/releasing/SKILL.md +0 -337
- package/skills/releasing/references/deprecations.md +0 -60
- package/skills/releasing/references/promotion-and-tagging.md +0 -66
- package/skills/releasing/references/release-lines.md +0 -105
- package/skills/roadmap/SKILL.md +0 -64
- package/skills/roadmap/references/file-format.md +0 -124
- package/skills/roadmap/references/operations.md +0 -112
- package/skills/rollback/SKILL.md +0 -91
- package/skills/tutorial/SKILL.md +0 -65
- package/skills/tutorial/evals/evals.json +0 -186
- package/skills/uninstall/SKILL.md +0 -75
- package/skills/update/SKILL.md +0 -126
- /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
- /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
- /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
- /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "worktree"
|
|
7
|
+
require_relative "lock"
|
|
8
|
+
require_relative "node_ledger"
|
|
9
|
+
require_relative "ready_set"
|
|
10
|
+
require_relative "savepoint"
|
|
11
|
+
|
|
12
|
+
# RunnerSweep (intent 340, G7, n2): the first thing every `step` does. Two
|
|
13
|
+
# separately callable entry points - #abort_if_merging and #reclaim - plus
|
|
14
|
+
# #run, which composes them for a caller that has no absorb step to interleave.
|
|
15
|
+
# `step` (a later node) calls #abort_if_merging, runs absorb, then calls
|
|
16
|
+
# #reclaim itself, so a reclaimed node's just-landed work from absorb is never
|
|
17
|
+
# thrown away by a stale read (matrix row 2.20).
|
|
18
|
+
#
|
|
19
|
+
# #abort_if_merging refuses the whole step when the intent worktree already
|
|
20
|
+
# has a merge in progress (`MERGE_HEAD` resolves): dispatching on top of a
|
|
21
|
+
# half-finished merge would hand the next node a diff full of someone else's
|
|
22
|
+
# conflict markers. Nothing is written when this fires - not the ledger, not
|
|
23
|
+
# graph.md, not even the delivery lease heartbeat (row 2.2, row 2.16: the
|
|
24
|
+
# heartbeat runs strictly after the abort check).
|
|
25
|
+
#
|
|
26
|
+
# #reclaim walks every node whose CURRENT status (the ledger's own resolution,
|
|
27
|
+
# never a raw scan) is `running`, skipping anything named in `skip:` (the
|
|
28
|
+
# nodes this step already absorbed, row 2.19) or already terminal (a `done`
|
|
29
|
+
# node is never touched, row 2.12 - it simply never shows up as `running`).
|
|
30
|
+
# An expired lease with no commits on the node's own branch newer than its
|
|
31
|
+
# expiry is reclaimed outright. An expired lease whose branch DOES carry
|
|
32
|
+
# newer commits is extended instead, up to twice per attempt (row 2.7); the
|
|
33
|
+
# extension is never a ledger transition (`running` cannot re-enter `running`
|
|
34
|
+
# under the transition layer), so it is one line appended to
|
|
35
|
+
# attempts/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
|
|
36
|
+
# count (row 2.18), never trusted from the caller. A third expiry reclaims
|
|
37
|
+
# regardless of new commits.
|
|
38
|
+
#
|
|
39
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
40
|
+
# `runner:` (default Worktree::ShellRunner), never cwd; the delivery-lease
|
|
41
|
+
# heartbeat goes through an injected `heartbeat:` (default Lock.heartbeat) so
|
|
42
|
+
# ordering (row 2.16) is provable without a real lock file. No eval, no
|
|
43
|
+
# ENV/global-constant seam.
|
|
44
|
+
module RunnerSweep
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# D-ish: at most two extensions per attempt (row 2.7); the third expiry
|
|
48
|
+
# reclaims regardless of new commits.
|
|
49
|
+
MAX_EXTENSIONS_PER_ATTEMPT = 2
|
|
50
|
+
|
|
51
|
+
# abort_if_merging(context, runner:) -> {ok:, error:, recovery_command:}.
|
|
52
|
+
# `context.worktree` is nil for a global-store-only intent (no git repo to
|
|
53
|
+
# merge into); that case is always ok - there is nothing to abort.
|
|
54
|
+
def abort_if_merging(context, runner: Worktree::ShellRunner.new)
|
|
55
|
+
worktree = context&.worktree
|
|
56
|
+
return { ok: true, error: nil, recovery_command: nil } if blank?(worktree)
|
|
57
|
+
|
|
58
|
+
res = runner.run("-C", worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD")
|
|
59
|
+
return { ok: true, error: nil, recovery_command: nil } unless res.success?
|
|
60
|
+
|
|
61
|
+
recovery_command = "git -C #{worktree} merge --abort"
|
|
62
|
+
warn "runner: a merge is already in progress in #{worktree}; run `#{recovery_command}` " \
|
|
63
|
+
"before the next step can dispatch"
|
|
64
|
+
{ ok: false, error: "a merge is in progress in #{worktree}", recovery_command: recovery_command }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# reclaim(context, runner:, skip:, now:) -> {reclaimed: [{node:, holder:,
|
|
68
|
+
# expired:}], extended: [{node:, head:, time:}]}. Reads the ledger fresh on
|
|
69
|
+
# every call (no cache), which is what makes calling it AFTER absorb (row
|
|
70
|
+
# 2.20) actually see absorb's own just-landed work rather than a stale
|
|
71
|
+
# snapshot taken before it.
|
|
72
|
+
def reclaim(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now)
|
|
73
|
+
intent_dir = context.intent_dir
|
|
74
|
+
content = savepoint_content(intent_dir)
|
|
75
|
+
entries = NodeLedger.entries_from_content(content)
|
|
76
|
+
status_map = NodeLedger.status_from_content(content)
|
|
77
|
+
skip_set = Array(skip).map(&:to_s)
|
|
78
|
+
|
|
79
|
+
reclaimed = []
|
|
80
|
+
extended = []
|
|
81
|
+
|
|
82
|
+
status_map.each do |subject, state|
|
|
83
|
+
next unless state == "running"
|
|
84
|
+
next unless subject.match?(Savepoint::NODE_SUBJECT_RE)
|
|
85
|
+
next if skip_set.include?(subject)
|
|
86
|
+
|
|
87
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
|
|
88
|
+
next unless last
|
|
89
|
+
|
|
90
|
+
fields = last[:fields] || {}
|
|
91
|
+
expires_raw = fields["expires"]
|
|
92
|
+
expires_at = parse_time(expires_raw)
|
|
93
|
+
next unless expires_at
|
|
94
|
+
next if now < expires_at # row 2.5: an unexpired lease is left alone
|
|
95
|
+
|
|
96
|
+
holder = fields["holder"]
|
|
97
|
+
branch = node_branch(context, subject)
|
|
98
|
+
head_sha, head_time = branch_head(runner, context.worktree, branch)
|
|
99
|
+
has_new_commits = head_time && head_time > expires_at
|
|
100
|
+
|
|
101
|
+
if has_new_commits
|
|
102
|
+
attempt = current_attempt(entries, subject)
|
|
103
|
+
count = extension_count(intent_dir, subject, attempt)
|
|
104
|
+
if count < MAX_EXTENSIONS_PER_ATTEMPT
|
|
105
|
+
record_extension(intent_dir, subject, attempt, head_sha, now)
|
|
106
|
+
extended << { node: subject, head: head_sha, time: now.utc.iso8601 }
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
# row 2.7: the cap is spent - fall through and reclaim anyway.
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
NodeLedger.append_transition(
|
|
113
|
+
savepoint_path(intent_dir),
|
|
114
|
+
subject: subject,
|
|
115
|
+
state: "reclaimed",
|
|
116
|
+
fields: { holder: holder, expired: expires_raw },
|
|
117
|
+
now: now
|
|
118
|
+
)
|
|
119
|
+
reclaimed << { node: subject, holder: holder, expired: expires_raw }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
{ reclaimed: reclaimed, extended: extended }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# run(context, runner:, skip:, now:, heartbeat:) -> the composed report a
|
|
126
|
+
# caller with no absorb step to interleave uses directly. Order is fixed on
|
|
127
|
+
# purpose (row 2.2, row 2.16): abort check, THEN the lease heartbeat, THEN
|
|
128
|
+
# reclaim - never the reverse, and never a write of any kind before the
|
|
129
|
+
# abort check has cleared.
|
|
130
|
+
def run(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now, heartbeat: Lock.method(:heartbeat))
|
|
131
|
+
abort_result = abort_if_merging(context, runner: runner)
|
|
132
|
+
unless abort_result[:ok]
|
|
133
|
+
return {
|
|
134
|
+
ok: false, aborted: true, error: abort_result[:error],
|
|
135
|
+
recovery_command: abort_result[:recovery_command], reclaimed: [], extended: [],
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
session = context&.session
|
|
140
|
+
heartbeat.call(context.intent_dir, session: session, now: now) unless blank?(session)
|
|
141
|
+
|
|
142
|
+
result = reclaim(context, runner: runner, skip: skip, now: now)
|
|
143
|
+
{
|
|
144
|
+
ok: true, aborted: false, error: nil, recovery_command: nil,
|
|
145
|
+
reclaimed: result[:reclaimed], extended: result[:extended],
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# --- internals ---------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def blank?(value)
|
|
152
|
+
value.nil? || value.to_s.strip.empty?
|
|
153
|
+
end
|
|
154
|
+
private_class_method :blank?
|
|
155
|
+
|
|
156
|
+
def savepoint_path(intent_dir)
|
|
157
|
+
File.join(intent_dir.to_s, "savepoint.md")
|
|
158
|
+
end
|
|
159
|
+
private_class_method :savepoint_path
|
|
160
|
+
|
|
161
|
+
def savepoint_content(intent_dir)
|
|
162
|
+
path = savepoint_path(intent_dir)
|
|
163
|
+
File.exist?(path) ? File.read(path) : ""
|
|
164
|
+
end
|
|
165
|
+
private_class_method :savepoint_content
|
|
166
|
+
|
|
167
|
+
# The node's own worktree branch (n3's naming: `plastic/<id>--<slug>--<node>`),
|
|
168
|
+
# derived from the intent id/slug alone - never through NodeWorktree, which
|
|
169
|
+
# this node does not depend on.
|
|
170
|
+
def node_branch(context, node)
|
|
171
|
+
"plastic/#{context.intent_id}--#{context.intent_slug}--#{node}"
|
|
172
|
+
end
|
|
173
|
+
private_class_method :node_branch
|
|
174
|
+
|
|
175
|
+
# [head_sha, committer_time] for `branch` in `worktree`'s repo, or [nil, nil]
|
|
176
|
+
# when the worktree is gone, the branch does not exist, or anything else
|
|
177
|
+
# about the git call fails (row 2.13: never raise).
|
|
178
|
+
def branch_head(runner, worktree, branch)
|
|
179
|
+
return [nil, nil] if blank?(worktree) || blank?(branch)
|
|
180
|
+
|
|
181
|
+
res = runner.run("-C", worktree, "log", "-1", "--format=%H%x1f%cI", branch)
|
|
182
|
+
return [nil, nil] unless res.success?
|
|
183
|
+
|
|
184
|
+
sha, iso = res.stdout.to_s.strip.split("\x1f")
|
|
185
|
+
[sha, parse_time(iso)]
|
|
186
|
+
rescue StandardError
|
|
187
|
+
[nil, nil]
|
|
188
|
+
end
|
|
189
|
+
private_class_method :branch_head
|
|
190
|
+
|
|
191
|
+
def parse_time(raw)
|
|
192
|
+
return nil if blank?(raw)
|
|
193
|
+
|
|
194
|
+
Time.iso8601(raw.to_s)
|
|
195
|
+
rescue ArgumentError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
private_class_method :parse_time
|
|
199
|
+
|
|
200
|
+
# Row 2.18: the attempt number comes from the ledger's own count of
|
|
201
|
+
# `running` lines since the subject's last terminal line - the exact same
|
|
202
|
+
# arithmetic NodeInput uses to name that attempt's input file, so the
|
|
203
|
+
# extensions file for a `running` line always matches the node input it extends.
|
|
204
|
+
def current_attempt(entries, subject)
|
|
205
|
+
ReadySet.attempts_count(entries, subject)
|
|
206
|
+
end
|
|
207
|
+
private_class_method :current_attempt
|
|
208
|
+
|
|
209
|
+
def extensions_path(intent_dir, node, attempt)
|
|
210
|
+
File.join(intent_dir.to_s, "attempts", "#{node}--a#{attempt}.extensions")
|
|
211
|
+
end
|
|
212
|
+
private_class_method :extensions_path
|
|
213
|
+
|
|
214
|
+
# Row 2.8: counted from the attempt-scoped file alone, so a prior attempt's
|
|
215
|
+
# extensions never count against a fresh dispatch.
|
|
216
|
+
def extension_count(intent_dir, node, attempt)
|
|
217
|
+
path = extensions_path(intent_dir, node, attempt)
|
|
218
|
+
return 0 unless File.exist?(path)
|
|
219
|
+
|
|
220
|
+
File.read(path).each_line.count { |l| !l.strip.empty? }
|
|
221
|
+
end
|
|
222
|
+
private_class_method :extension_count
|
|
223
|
+
|
|
224
|
+
# Row 2.9: the observed head sha and the time, one line, append-only.
|
|
225
|
+
def record_extension(intent_dir, node, attempt, head_sha, now)
|
|
226
|
+
path = extensions_path(intent_dir, node, attempt)
|
|
227
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
228
|
+
File.open(path, "a") { |f| f.write("#{now.utc.iso8601} head=#{head_sha}\n") }
|
|
229
|
+
end
|
|
230
|
+
private_class_method :record_extension
|
|
231
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "time"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require_relative "runner_core"
|
|
8
|
+
require_relative "runner_sweep"
|
|
9
|
+
require_relative "runner_absorb"
|
|
10
|
+
require_relative "runner_dispatch"
|
|
11
|
+
require_relative "node_worktree"
|
|
12
|
+
require_relative "lock"
|
|
13
|
+
|
|
14
|
+
# RunnerUntilEmpty (intent 340b, G7c, n7): the Codex loop. `runner
|
|
15
|
+
# until-empty` composes `step` and `node-run` itself, since Codex has no
|
|
16
|
+
# session on the other end to make the subagent calls `step` only ever
|
|
17
|
+
# prints a plan for - dispatch, run at most two `node-run` subprocesses at
|
|
18
|
+
# once, absorb each return, and around again, stopping on complete,
|
|
19
|
+
# stalled, needs_decision, a refused step, or an iteration cap.
|
|
20
|
+
#
|
|
21
|
+
# #step_once is one turn - abort-if-merging, the heartbeat, absorb every
|
|
22
|
+
# return this call carries (serially, a plain Ruby loop, never a thread),
|
|
23
|
+
# reclaim, reap, then dispatch - the same fixed order scripts/runner's own
|
|
24
|
+
# `run_step_body` uses, returning data rather than printing it, so #run can
|
|
25
|
+
# make its own stop/continue decision instead of scraping stdout. #run is
|
|
26
|
+
# the loop: it grows `active` from whatever #step_once actually dispatched
|
|
27
|
+
# (already capped at the concurrency ceiling by RunnerDispatch's own
|
|
28
|
+
# running-count check, never a second cap layered on top here), spawns one
|
|
29
|
+
# real `node-run` subprocess per newly dispatched node, and blocks for AT
|
|
30
|
+
# LEAST one of them to finish before it ever calls #step_once again - so an
|
|
31
|
+
# absorb is always a single, uncontended call, and a second `node-run`
|
|
32
|
+
# finishing while the first is mid-absorb simply waits its own turn as the
|
|
33
|
+
# next iteration's `returns`, never as a second in-flight absorb.
|
|
34
|
+
#
|
|
35
|
+
# A shared `<repo>/.git` between two worktrees is a real `index.lock`
|
|
36
|
+
# collision risk at commit time; this module does not try to prevent it by
|
|
37
|
+
# serializing the two `node-run` processes (that would throw away the
|
|
38
|
+
# concurrency this node exists to deliver) - it records the collision, on
|
|
39
|
+
# whichever side actually hit it, and lets the kind's retry cap (already
|
|
40
|
+
# built) recover the node on a later attempt.
|
|
41
|
+
#
|
|
42
|
+
# Pure and dependency-injected: `step:`, `node_run_spawner:` and
|
|
43
|
+
# `node_run_waiter:` are keyword seams with real defaults, so a test can
|
|
44
|
+
# drive #run entirely off doubles (no real subprocess, no real git repo)
|
|
45
|
+
# for every row except the ones that are precisely about real OS
|
|
46
|
+
# concurrency or the real CLI arm.
|
|
47
|
+
module RunnerUntilEmpty
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
MAX_CONCURRENCY = 2
|
|
51
|
+
DEFAULT_MAX_ITERATIONS = 200
|
|
52
|
+
|
|
53
|
+
NODE_RUN_SCRIPT = File.expand_path(File.join(__dir__, "..", "node-run")).freeze
|
|
54
|
+
|
|
55
|
+
# run(context, harness:) -> {status:, iterations:, ...}. `status` is one
|
|
56
|
+
# of complete, stalled, needs_decision, iteration_cap, refused - never
|
|
57
|
+
# anything else, and the loop returns the instant it reaches one of them.
|
|
58
|
+
def run(context, harness: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
59
|
+
step: method(:step_once), node_run_spawner: method(:spawn_node_run),
|
|
60
|
+
node_run_waiter: method(:wait_for_node_run), out: $stdout)
|
|
61
|
+
active = {}
|
|
62
|
+
pending_returns = {}
|
|
63
|
+
iterations = 0
|
|
64
|
+
|
|
65
|
+
loop do
|
|
66
|
+
iterations += 1
|
|
67
|
+
if iterations > max_iterations
|
|
68
|
+
out.puts "until-empty: stopped at the iteration cap (#{max_iterations})"
|
|
69
|
+
return { status: "iteration_cap", iterations: iterations - 1 }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result = step.call(context, harness: harness, returns: pending_returns)
|
|
73
|
+
pending_returns = {}
|
|
74
|
+
|
|
75
|
+
unless result[:ok]
|
|
76
|
+
out.puts "until-empty: stopped, step refused (#{result[:reason]})"
|
|
77
|
+
return { status: "refused", reason: result[:reason], iterations: iterations }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Array(result[:absorbed]).each { |a| out.puts "absorbed #{a[:node]}: #{a[:state]}" }
|
|
81
|
+
|
|
82
|
+
case result[:status]
|
|
83
|
+
when "complete"
|
|
84
|
+
out.puts "complete"
|
|
85
|
+
return { status: "complete", iterations: iterations }
|
|
86
|
+
when "stalled"
|
|
87
|
+
out.puts "stalled"
|
|
88
|
+
Array(result[:blockers]).each { |b| out.puts "blocked: #{b}" }
|
|
89
|
+
return { status: "stalled", iterations: iterations, blockers: result[:blockers] }
|
|
90
|
+
when "needs_decision"
|
|
91
|
+
stop = result[:stop]
|
|
92
|
+
out.puts "needs_decision: #{stop[:node]} - #{stop[:question]}"
|
|
93
|
+
out.puts stop[:answer_command]
|
|
94
|
+
return { status: "needs_decision", iterations: iterations, stop: stop }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Array(result[:dispatched]).each do |node|
|
|
98
|
+
next if active.key?(node)
|
|
99
|
+
|
|
100
|
+
active[node] = node_run_spawner.call(context, node: node, harness: harness)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if active.empty?
|
|
104
|
+
out.puts "stalled"
|
|
105
|
+
out.puts "blocked: queued with no active node-run to wait on"
|
|
106
|
+
return { status: "stalled", iterations: iterations,
|
|
107
|
+
blockers: ["queued with no active node-run to wait on"] }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
finished = Array(node_run_waiter.call(active))
|
|
111
|
+
finished.each do |f|
|
|
112
|
+
active.delete(f[:node])
|
|
113
|
+
out.puts "until-empty: index.lock collision recorded for #{f[:node]}" if index_lock_collision?(f)
|
|
114
|
+
pending_returns[f[:node]] = f[:return_path]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- one turn ----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
# step_once(context, harness:, returns:) -> {ok:, reason:, status:,
|
|
122
|
+
# dispatched: [node ids], stop:, blockers:, absorbed: [{node:, state:}]}.
|
|
123
|
+
# Every absorb in `returns` runs in this one Ruby method, in the order
|
|
124
|
+
# `returns` iterates, before `dispatch` ever runs (row 7.3) - never a
|
|
125
|
+
# second call to this method from another thread while one is already in
|
|
126
|
+
# flight (row 7.2), because #run above never starts a second one.
|
|
127
|
+
def step_once(context, harness:, returns: {}, allow_core_drift: false, now: Time.now,
|
|
128
|
+
sweep: RunnerSweep, absorb: RunnerAbsorb, dispatch: RunnerDispatch,
|
|
129
|
+
worktree: NodeWorktree, lock: Lock, config_loader: method(:load_agent_config))
|
|
130
|
+
abort_result = sweep.abort_if_merging(context)
|
|
131
|
+
unless abort_result[:ok]
|
|
132
|
+
return refusal("merge_in_progress", abort_result[:error])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
lock.heartbeat(context.intent_dir, session: context.session) unless context.session.to_s.strip.empty?
|
|
136
|
+
|
|
137
|
+
return refusal("lock_not_held", nil) unless context.session
|
|
138
|
+
|
|
139
|
+
absorbed = returns.map do |node, path|
|
|
140
|
+
result = absorb.absorb(context, node: node, return_path: path, allow_core_drift: allow_core_drift, now: now)
|
|
141
|
+
{ node: node, state: result[:state] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
swept = sweep.reclaim(context, skip: returns.keys, now: now)
|
|
145
|
+
reaped = worktree.reap(context)
|
|
146
|
+
|
|
147
|
+
agent_config = config_loader.call(context.plastic_home)
|
|
148
|
+
dispatch_result = dispatch.dispatch(context, config: agent_config, harness: harness, now: now)
|
|
149
|
+
unless dispatch_result[:ok]
|
|
150
|
+
return refusal(dispatch_result[:reason], Array(dispatch_result[:errors]).join("; "))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
ok: true, reason: nil, status: dispatch_result[:status],
|
|
155
|
+
dispatched: dispatch_result[:dispatched].map { |d| d[:node] },
|
|
156
|
+
stop: dispatch_result[:stop], blockers: dispatch_result[:blockers], absorbed: absorbed,
|
|
157
|
+
reclaimed: swept[:reclaimed], extended: swept[:extended], reaped: reaped[:removed],
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def refusal(reason, detail)
|
|
162
|
+
{ ok: false, reason: reason, detail: detail, status: nil, dispatched: [], stop: nil, blockers: [],
|
|
163
|
+
absorbed: [] }
|
|
164
|
+
end
|
|
165
|
+
private_class_method :refusal
|
|
166
|
+
|
|
167
|
+
# Mirrors scripts/runner's own Runner.load_agent_config exactly (row
|
|
168
|
+
# 1.1 of nodes/n1.md): a missing or unparseable config.yml reads as {},
|
|
169
|
+
# never raises, and it is read fresh every call rather than cached, since
|
|
170
|
+
# a long-running until-empty process must see a config edit made mid-run
|
|
171
|
+
# the same way a fresh `step` call always would.
|
|
172
|
+
def load_agent_config(plastic_home)
|
|
173
|
+
path = File.join(plastic_home.to_s, "config.yml")
|
|
174
|
+
return {} unless File.exist?(path)
|
|
175
|
+
|
|
176
|
+
YAML.safe_load(File.read(path)) || {}
|
|
177
|
+
rescue StandardError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# --- node-run, the real subprocess --------------------------------------------
|
|
182
|
+
|
|
183
|
+
# spawn_node_run(context, node:, harness:) -> a handle {node:, pid:,
|
|
184
|
+
# stdout_io:, stderr_io:}. `harness:` is accepted for symmetry with
|
|
185
|
+
# `step_once` even though node-run itself never takes a --harness flag -
|
|
186
|
+
# the harness was already recorded on the node's own `running` line by the
|
|
187
|
+
# dispatch this call followed, and node-run reads its node input off that
|
|
188
|
+
# line, never off this argv. RUBYOPT is cleared explicitly, the same
|
|
189
|
+
# contract every other ruby-spawning site in this tree carries.
|
|
190
|
+
def spawn_node_run(context, node:, harness: nil)
|
|
191
|
+
argv = [RbConfig.ruby, NODE_RUN_SCRIPT, context.intent_dir, "--node", node]
|
|
192
|
+
argv += ["--session", context.session] if context.session
|
|
193
|
+
|
|
194
|
+
out_r, out_w = IO.pipe
|
|
195
|
+
err_r, err_w = IO.pipe
|
|
196
|
+
pid = Process.spawn({ "RUBYOPT" => nil }, *argv, out: out_w, err: err_w)
|
|
197
|
+
out_w.close
|
|
198
|
+
err_w.close
|
|
199
|
+
{ node: node, pid: pid, stdout_io: out_r, stderr_io: err_r }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# wait_for_node_run(active) -> [{node:, return_path:, stderr:}] for
|
|
203
|
+
# whichever ONE handle in `active` finishes first (real concurrency
|
|
204
|
+
# between two live node-run processes; this call itself blocks on
|
|
205
|
+
# whichever exits first, never both at once). `active` is keyed by node,
|
|
206
|
+
# so a caller never needs to search its own values by pid outside this
|
|
207
|
+
# method.
|
|
208
|
+
def wait_for_node_run(active)
|
|
209
|
+
pid, = Process.waitpid2(-1)
|
|
210
|
+
handle = active.values.find { |h| h[:pid] == pid }
|
|
211
|
+
return [] unless handle
|
|
212
|
+
|
|
213
|
+
[{ node: handle[:node], return_path: read_and_close(handle[:stdout_io]).to_s.strip,
|
|
214
|
+
stderr: read_and_close(handle[:stderr_io]) }]
|
|
215
|
+
rescue Errno::ECHILD
|
|
216
|
+
[]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def read_and_close(io)
|
|
220
|
+
io.read
|
|
221
|
+
rescue StandardError
|
|
222
|
+
""
|
|
223
|
+
ensure
|
|
224
|
+
begin
|
|
225
|
+
io.close
|
|
226
|
+
rescue StandardError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
private_class_method :read_and_close
|
|
231
|
+
|
|
232
|
+
# index_lock_collision?(finished) -> true when either the node-run
|
|
233
|
+
# subprocess's own stderr or the return body it wrote mentions
|
|
234
|
+
# `index.lock` - the shared signature of a git ref/index lock collision
|
|
235
|
+
# between two worktrees writing into one repository's `.git` at the same
|
|
236
|
+
# time (row 7.4). Never used to change what the loop does next: the
|
|
237
|
+
# finished node's return is absorbed exactly like any other, and the
|
|
238
|
+
# kind's own retry cap is what recovers it.
|
|
239
|
+
def index_lock_collision?(finished)
|
|
240
|
+
parts = [finished[:stderr].to_s]
|
|
241
|
+
path = finished[:return_path]
|
|
242
|
+
parts << safe_read_file(path) if path && !path.empty?
|
|
243
|
+
parts.join("\n").match?(/index\.lock/i)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def safe_read_file(path)
|
|
247
|
+
File.exist?(path) ? File.read(path) : ""
|
|
248
|
+
rescue StandardError
|
|
249
|
+
""
|
|
250
|
+
end
|
|
251
|
+
private_class_method :safe_read_file
|
|
252
|
+
end
|