@topolo/cli 0.13.16 → 0.13.19
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/README.md +18 -4
- package/agent-packs/README.md +48 -0
- package/agent-packs/coverage_changed.py +1 -1
- package/agent-packs/src/topolo_agent_pack/cli.py +7 -2
- package/agent-packs/src/topolo_agent_pack/execution.py +1 -1
- package/agent-packs/src/topolo_agent_pack/fresh.py +1 -1
- package/agent-packs/src/topolo_agent_pack/policy.py +8 -0
- package/agent-packs/src/topolo_agent_pack/task_models.py +11 -7
- package/agent-packs/src/topolo_agent_pack/throughput.py +149 -0
- package/dist/index.js +321 -156
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -537,10 +537,24 @@ exchanges the stored refresh token for a new pair before making the request
|
|
|
537
537
|
and rewrites the config — no user action required. The OAuth `client_id` used
|
|
538
538
|
at login is persisted alongside the credential so refresh can re-authenticate.
|
|
539
539
|
|
|
540
|
-
If refresh fails (network,
|
|
541
|
-
|
|
542
|
-
`
|
|
543
|
-
|
|
540
|
+
If refresh fails for a transient reason (network, a 5xx, a rate limit), the
|
|
541
|
+
saved grant is kept and the request fails with a retryable error; a rate limit
|
|
542
|
+
reports `rate_limited` with `retryAfter`, and the CLI never retries a refresh
|
|
543
|
+
on its own.
|
|
544
|
+
|
|
545
|
+
If Auth refuses the grant (`invalid_grant` or `expired_token`), the saved
|
|
546
|
+
sign-in has ended. It is removed once, under the config lock, and is never sent
|
|
547
|
+
again by any process, so a dead sign-in cannot use up the token endpoint's
|
|
548
|
+
failure limit for everything else on the machine. One shared device
|
|
549
|
+
re-authorization is started for that environment and context, and then:
|
|
550
|
+
|
|
551
|
+
- **At a terminal**, the CLI opens the approval page, prints its URL and code,
|
|
552
|
+
waits for approval and continues the command with the new credential.
|
|
553
|
+
- **Anywhere else** (no terminal, `CI` set, or `TOPOLO_NO_REAUTH_PROMPT=1`), the
|
|
554
|
+
command exits with code 2 and a structured `reauth_required` error that
|
|
555
|
+
carries the approval link, user code, expiry and the equivalent
|
|
556
|
+
`topolo auth login` command. The device code is never printed. Approve the
|
|
557
|
+
link (or run the login command) and the next command continues.
|
|
544
558
|
|
|
545
559
|
## Topolo Agent Packs
|
|
546
560
|
|
package/agent-packs/README.md
CHANGED
|
@@ -722,3 +722,51 @@ Only existing owned physical destinations inside the library can change. Shared
|
|
|
722
722
|
Apply reserves `launchers/adoption-<UUID>.json` exclusively. The journal starts in `may-have-config-effects`, records each verified replacement, and becomes `configuration-prepared-native-adoption-unverified` only after all readbacks. Every write rechecks generation, source, profile metadata, preservation pins and destination custody. An interrupted or conflicting apply retains the journal and any uncertain temporary file; it never automatically replays or rolls back. Reconcile exact effects with the original owner.
|
|
723
723
|
|
|
724
724
|
No apply result proves native reload, identity, current RBAC, model/effort, loaded context, original-intake permission routing or a native tool result. Hosts may react to configuration changes; admit the actual affected owner/generation and normal request settlement before applying in a live host. Later ordinary startup can renew the private credential and load identity. A previous 429/403 or scoped recovery hold remains separately applicable and elapsed time grants no retry. Actual Codex and Claude same-query context, permission, nonce result, cancellation/settlement and own retirement require independent native evidence.
|
|
725
|
+
|
|
726
|
+
## Throughput and adaptive reasoning
|
|
727
|
+
|
|
728
|
+
New task-model templates use revision 2. Codex chooses Luna/low only for an
|
|
729
|
+
explicit `trivial`, low-risk `format`, `extract-fields`, `reference-lookup` or
|
|
730
|
+
`status-read` task. Routine tasks use Sol/low (with medium as a declared effort
|
|
731
|
+
alternative); standard tasks use Sol/medium; complex and security-critical
|
|
732
|
+
tasks use Sol/high. Astra remains available only through an authorized explicit
|
|
733
|
+
override. Unavailable models/efforts refuse rather than silently falling back.
|
|
734
|
+
Existing installed configurations and fixed native sessions are not migrated by
|
|
735
|
+
upgrading the adapter. Review and install a current `models-template`, then verify
|
|
736
|
+
the actual native model/effort acknowledgement for every task. `low` is the native
|
|
737
|
+
name for light reasoning.
|
|
738
|
+
|
|
739
|
+
`topolo agent-packs lane allocate --input snapshot.json` returns a bounded,
|
|
740
|
+
read-only allocation plan. The closed version-1 input contains `now` (epoch
|
|
741
|
+
seconds), `maxAgeSeconds` (1–3600), actual execution `slots` (0–32),
|
|
742
|
+
`maxNewLanes` (0–8), `tasks`, `lanes`, `completed`, `configuration` and `catalog`.
|
|
743
|
+
The configuration/catalog use the existing task-model contracts. Each task has
|
|
744
|
+
`id`, `capability`, `priority` (0 is highest), `owner` (lane ID or null),
|
|
745
|
+
`dependencies` (completed task IDs), `authorized`, `paused` and `modelTask`.
|
|
746
|
+
Each lane has `id`, `capabilities`, `observedAt`, `registryState`
|
|
747
|
+
(idle/busy/held/paused/unknown), `nativeState` (idle/busy/unknown), `activeTask`
|
|
748
|
+
(ID or null), `retainedTasks`, `reusableCheckpoint`, `assignments`,
|
|
749
|
+
`lastAssigned` and its explicit model `constraints`.
|
|
750
|
+
|
|
751
|
+
The allocator considers every compatible worker rather than preferring an
|
|
752
|
+
ordinal. It chooses the least assigned, then oldest assigned worker; never
|
|
753
|
+
moves an owned task; permits retained custody only at an explicit idle safe
|
|
754
|
+
checkpoint; isolates stale/contradictory observations and blocked dependencies;
|
|
755
|
+
and limits both existing work and proposed new lanes by supplied execution
|
|
756
|
+
capacity. A worker receives at most one assignment per plan. The caller must
|
|
757
|
+
supply trustworthy current observations; a timestamp in JSON does not prove
|
|
758
|
+
native availability or admission.
|
|
759
|
+
|
|
760
|
+
The result's snapshot digest must be checked by the canonical claim writer
|
|
761
|
+
before an exclusive assignment. Each assignment includes its full model
|
|
762
|
+
resolution with `nativeACK: null`. Create requests are proposals requiring the
|
|
763
|
+
normal registered native lifecycle, not created agents or claims. This command
|
|
764
|
+
does not claim, dispatch, wake, schedule, execute or retire anything. Native
|
|
765
|
+
settings, authority, actual delivery and completion remain independently
|
|
766
|
+
verified boundaries. In particular, it does not repair or establish a hidden
|
|
767
|
+
cross-chat event transport by writing a file.
|
|
768
|
+
|
|
769
|
+
Fresh-role `config.json` model/effort presets are retained in the captured agent
|
|
770
|
+
manifest and used when compiling its Codex runtime policy. They do not revert
|
|
771
|
+
to a legacy high-effort role default on activation. A missing preset keeps the
|
|
772
|
+
legacy profile unchanged. Native catalog and exact settings readback still apply.
|
|
@@ -15,7 +15,7 @@ from coverage.parser import PythonParser
|
|
|
15
15
|
|
|
16
16
|
RELEASE_BASE = '6a13ada56844712aae58738ba01dbbdf90718bef'
|
|
17
17
|
SOURCE = pathlib.Path('agent-packs/src/topolo_agent_pack')
|
|
18
|
-
MODULES = {'topolo', 'claude', 'policy', 'permissions', 'approvals', 'execution', 'live', 'cli', 'lanes', 'task_models'}
|
|
18
|
+
MODULES = {'topolo', 'claude', 'policy', 'permissions', 'approvals', 'execution', 'live', 'cli', 'lanes', 'task_models', 'throughput'}
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class CoverageRefused(RuntimeError):
|
|
@@ -331,7 +331,7 @@ def parser():
|
|
|
331
331
|
q.add_argument('--connection');q.add_argument('--intake');q.add_argument('--fingerprint');q.add_argument('--response')
|
|
332
332
|
q.add_argument('--human-decision-recorded',action='store_true')
|
|
333
333
|
q=sub.add_parser('lane',help='Plan, adopt and verify one pack-managed native child through its original intake')
|
|
334
|
-
q.add_argument('operation',choices=['plan','adopt','configure','status','read','settle','retire','serve','close','create','context-start','context-read','fixture-start','fixture-read','models-template','models-install'])
|
|
334
|
+
q.add_argument('operation',choices=['allocate','plan','adopt','configure','status','read','settle','retire','serve','close','create','context-start','context-read','fixture-start','fixture-read','models-template','models-install'])
|
|
335
335
|
q.add_argument('--connection',help='Exact explicit foreground owned lane connection ID')
|
|
336
336
|
q.add_argument('--input',help='Closed operation JSON; never arbitrary native RPC or task prompts')
|
|
337
337
|
q=sub.add_parser('smoke',help='Start or read one closed, bound native acceptance case; never arbitrary prompts')
|
|
@@ -411,7 +411,12 @@ def main(argv=None):
|
|
|
411
411
|
params=read_json(args.input) if args.input else {}
|
|
412
412
|
if not isinstance(params,dict):
|
|
413
413
|
raise PackError('Closed native lane input object required')
|
|
414
|
-
if args.operation
|
|
414
|
+
if args.operation=='allocate':
|
|
415
|
+
from .throughput import allocate
|
|
416
|
+
if not isinstance(params.get('catalog'),dict) or params['catalog'].get('harness')!=harness:
|
|
417
|
+
raise PackError('Allocation catalog must match the selected native harness')
|
|
418
|
+
result=allocate(params)
|
|
419
|
+
elif args.operation in {'models-template','models-install'}:
|
|
415
420
|
from . import lanes
|
|
416
421
|
if args.operation=='models-template':
|
|
417
422
|
if set(params)!={'pack'}:
|
|
@@ -62,7 +62,7 @@ class ExecutionGate:
|
|
|
62
62
|
required = {'model', 'model_reasoning_effort', 'approval_policy', 'approvals_reviewer',
|
|
63
63
|
'sandbox_mode', 'sandbox_workspace_write', 'apps'}
|
|
64
64
|
if (set(spec) != required or not isinstance(spec['model'], str) or not spec['model'] or
|
|
65
|
-
(spec['model_reasoning_effort'] not in {'medium', 'high'} and not
|
|
65
|
+
(spec['model_reasoning_effort'] not in {'low', 'medium', 'high'} and not
|
|
66
66
|
(role.get('dynamicLaneId') and role.get('dynamicModelResolution', {}).get('resolved') ==
|
|
67
67
|
{'model': spec['model'], 'effort': spec['model_reasoning_effort']})) or
|
|
68
68
|
spec['approval_policy'] not in {'on-request', 'never'} or spec['approvals_reviewer'] != 'user' or
|
|
@@ -122,7 +122,7 @@ def create(lib,source,name,directory,binary,sets=None,agents=None,authored=None)
|
|
|
122
122
|
if preset:b.call('thread/settings/update',{'threadId':tid,'model':preset['model'],'effort':preset['reasoningEffort']})
|
|
123
123
|
note='Fresh '+a['title']+'. Read AGENTS.md and relevant context/ files for operating instructions. No task is assigned. Wait for a new human request.'
|
|
124
124
|
b.call('thread/inject_items',{'threadId':tid,'items':[{'type':'message','role':'user','content':[{'type':'input_text','text':note}]}]});b.call('thread/unsubscribe',{'threadId':tid})
|
|
125
|
-
seeded.append({**a,'pack':name,'threadId':tid});records.append((tid,result['thread']['path'],note))
|
|
125
|
+
seeded.append({**a,'pack':name,'threadId':tid,**({'modelPreset':preset} if preset else {})});records.append((tid,result['thread']['path'],note))
|
|
126
126
|
team='# Fresh pack team\n\nResolve peer identities here for human-authorized coordination. Every member starts with no task or claim. This roster grants no external messaging or product effects.\n\n'+'\n'.join('- '+a['set']+'/'+a['id']+' | '+a['title']+' | thread '+a['threadId'] for a in seeded)+'\n'
|
|
127
127
|
for a in seeded:
|
|
128
128
|
target=root/'workspaces'/a['set']/a['id']/'context'/'TEAM.md';target.parent.mkdir(parents=True,exist_ok=True);target.write_text(team)
|
|
@@ -59,6 +59,14 @@ def settings(agent, harness, consent=None):
|
|
|
59
59
|
spec = PROFILES[profile]
|
|
60
60
|
model = MODELS[harness][spec['tier']]
|
|
61
61
|
if harness == 'codex':
|
|
62
|
+
if 'modelPreset' in agent:
|
|
63
|
+
preset = agent['modelPreset']
|
|
64
|
+
if (not isinstance(preset, dict) or set(preset) != {'model', 'reasoningEffort'} or
|
|
65
|
+
not isinstance(preset['model'], str) or not preset['model'] or
|
|
66
|
+
preset['model'].endswith('-latest') or preset['reasoningEffort'] not in {'low', 'medium', 'high'}):
|
|
67
|
+
raise PackError('Invalid explicit fresh-role model preset')
|
|
68
|
+
model = preset['model']
|
|
69
|
+
spec = {**spec, 'effort': preset['reasoningEffort']}
|
|
62
70
|
native = {'model': model, 'model_reasoning_effort': spec['effort'],
|
|
63
71
|
'approval_policy': 'on-request', 'approvals_reviewer': 'user',
|
|
64
72
|
'sandbox_mode': spec['localAccess'],
|
|
@@ -16,23 +16,27 @@ def digest(value):
|
|
|
16
16
|
def default_configuration():
|
|
17
17
|
"""Explicit template for a new pack configuration; existing roles are unchanged."""
|
|
18
18
|
return {
|
|
19
|
-
'format': 'topolo-task-model-configuration', 'schemaVersion': 1, 'revision': '
|
|
19
|
+
'format': 'topolo-task-model-configuration', 'schemaVersion': 1, 'revision': '2',
|
|
20
20
|
'rules': [
|
|
21
|
-
{'id': '
|
|
21
|
+
{'id': 'trivial', 'complexity': ['trivial'], 'risk': ['low'], 'profile': 'trivial',
|
|
22
|
+
'types': ['format', 'extract-fields', 'reference-lookup', 'status-read']},
|
|
23
|
+
{'id': 'routine', 'complexity': ['routine'], 'risk': ['low', 'normal'], 'profile': 'routine'},
|
|
22
24
|
{'id': 'deep', 'complexity': ['complex'], 'risk': ['low', 'normal', 'security-critical'], 'profile': 'deep'},
|
|
23
25
|
{'id': 'security', 'complexity': ['routine', 'standard'], 'risk': ['security-critical'], 'profile': 'deep'},
|
|
24
26
|
{'id': 'standard', 'complexity': ['standard'], 'risk': ['low', 'normal'], 'profile': 'standard'},
|
|
25
27
|
],
|
|
26
28
|
'harnesses': {
|
|
27
29
|
'codex': {
|
|
28
|
-
'tiers': {'standard': 'gpt-6.1-sol', 'deep': 'gpt-6-astra'},
|
|
29
|
-
'profiles': {'
|
|
30
|
+
'tiers': {'standard': 'gpt-6.1-sol', 'deep': 'gpt-6-astra', 'economy': 'gpt-6-luna'},
|
|
31
|
+
'profiles': {'trivial': {'tier': 'economy', 'efforts': ['low']},
|
|
32
|
+
'routine': {'tier': 'standard', 'efforts': ['low', 'medium']},
|
|
30
33
|
'standard': {'tier': 'standard', 'efforts': ['medium']},
|
|
31
|
-
'deep': {'tier': '
|
|
34
|
+
'deep': {'tier': 'standard', 'efforts': ['high']}},
|
|
32
35
|
},
|
|
33
36
|
'claude': {
|
|
34
37
|
'tiers': {'economy': 'claude-haiku-5-5', 'standard': 'claude-sonnet-5-5', 'deep': 'claude-opus-5-5'},
|
|
35
|
-
'profiles': {'
|
|
38
|
+
'profiles': {'trivial': {'tier': 'economy', 'efforts': ['low']},
|
|
39
|
+
'routine': {'tier': 'standard', 'efforts': ['low', 'medium']},
|
|
36
40
|
'standard': {'tier': 'standard', 'efforts': ['medium']},
|
|
37
41
|
'deep': {'tier': 'deep', 'efforts': ['high']}},
|
|
38
42
|
},
|
|
@@ -80,7 +84,7 @@ def _selection(configuration, task, harness, constraints, *, override_authorized
|
|
|
80
84
|
if (not isinstance(task, dict) or not {'type', 'complexity', 'risk'} <= set(task) or
|
|
81
85
|
set(task) - {'type', 'complexity', 'risk', 'override'} or
|
|
82
86
|
not isinstance(task['type'], str) or not task['type'] or len(task['type']) > 120 or
|
|
83
|
-
task['complexity'] not in {'routine', 'standard', 'complex'} or
|
|
87
|
+
task['complexity'] not in {'trivial', 'routine', 'standard', 'complex'} or
|
|
84
88
|
task['risk'] not in {'low', 'normal', 'security-critical'}):
|
|
85
89
|
raise PackError('An intake must supply an exact task type, complexity and risk')
|
|
86
90
|
rules = configuration.get('rules')
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Bounded allocation decisions, without claim writes, wakeups or ownership changes.
|
|
2
|
+
|
|
3
|
+
Callers supply current canonical and native observations, then atomically claim
|
|
4
|
+
against snapshotDigest before dispatch. A plan is never evidence of execution.
|
|
5
|
+
"""
|
|
6
|
+
from .storage import PackError
|
|
7
|
+
from .task_models import digest, resolve
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _require(condition, message):
|
|
11
|
+
if not condition:
|
|
12
|
+
raise PackError(message)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _keys(value, fields):
|
|
16
|
+
_require(isinstance(value, dict) and set(value) == set(fields.split()),
|
|
17
|
+
'Allocation input requires exact fields: ' + fields)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _text(value):
|
|
21
|
+
return isinstance(value, str) and 0 < len(value) <= 240
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _integer(value, minimum, maximum):
|
|
25
|
+
return type(value) is int and minimum <= value <= maximum
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _names(value):
|
|
29
|
+
return (isinstance(value, list) and len(value) <= 256 and
|
|
30
|
+
all(_text(item) for item in value) and len(value) == len(set(value)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def allocate(snapshot):
|
|
34
|
+
"""Choose compatible idle workers fairly; isolate stale/blocked work.
|
|
35
|
+
|
|
36
|
+
Observations are supplied evidence, not a native status probe. Existing
|
|
37
|
+
owners stay pinned. Retained custody is reusable only at an explicit safe
|
|
38
|
+
checkpoint with the actual native worker idle. Resource slots are separate
|
|
39
|
+
from chat availability and apply to both existing and proposed workers.
|
|
40
|
+
"""
|
|
41
|
+
_keys(snapshot, 'schemaVersion now maxAgeSeconds slots maxNewLanes tasks lanes completed configuration catalog')
|
|
42
|
+
_require(snapshot['schemaVersion'] == 1 and
|
|
43
|
+
_integer(snapshot['now'], 1, 2**53) and
|
|
44
|
+
_integer(snapshot['maxAgeSeconds'], 1, 3600) and
|
|
45
|
+
_integer(snapshot['slots'], 0, 32) and
|
|
46
|
+
_integer(snapshot['maxNewLanes'], 0, 8) and
|
|
47
|
+
_names(snapshot['completed']), 'Invalid allocation bounds')
|
|
48
|
+
tasks, lanes = snapshot['tasks'], snapshot['lanes']
|
|
49
|
+
_require(isinstance(tasks, list) and len(tasks) <= 256 and
|
|
50
|
+
isinstance(lanes, list) and len(lanes) <= 256, 'Bounded task and lane lists required')
|
|
51
|
+
task_ids, lane_ids = set(), set()
|
|
52
|
+
for task in tasks:
|
|
53
|
+
_keys(task, 'id capability priority owner dependencies authorized paused modelTask')
|
|
54
|
+
_require(_text(task['id']) and task['id'] not in task_ids and
|
|
55
|
+
_text(task['capability']) and _integer(task['priority'], 0, 9) and
|
|
56
|
+
(task['owner'] is None or _text(task['owner'])) and
|
|
57
|
+
_names(task['dependencies']) and task['id'] not in task['dependencies'] and
|
|
58
|
+
type(task['authorized']) is bool and type(task['paused']) is bool,
|
|
59
|
+
'Malformed or duplicate allocation task')
|
|
60
|
+
task_ids.add(task['id'])
|
|
61
|
+
for lane in lanes:
|
|
62
|
+
_keys(lane, 'id capabilities observedAt registryState nativeState activeTask retainedTasks reusableCheckpoint assignments lastAssigned constraints')
|
|
63
|
+
_require(_text(lane['id']) and lane['id'] not in lane_ids and
|
|
64
|
+
_names(lane['capabilities']) and _names(lane['retainedTasks']) and
|
|
65
|
+
_integer(lane['observedAt'], 1, 2**53) and
|
|
66
|
+
lane['registryState'] in {'idle', 'busy', 'held', 'paused', 'unknown'} and
|
|
67
|
+
lane['nativeState'] in {'idle', 'busy', 'unknown'} and
|
|
68
|
+
(lane['activeTask'] is None or _text(lane['activeTask'])) and
|
|
69
|
+
type(lane['reusableCheckpoint']) is bool and
|
|
70
|
+
_integer(lane['assignments'], 0, 2**53) and
|
|
71
|
+
_integer(lane['lastAssigned'], 0, 2**53), 'Malformed or duplicate allocation lane')
|
|
72
|
+
lane_ids.add(lane['id'])
|
|
73
|
+
completed = set(snapshot['completed'])
|
|
74
|
+
_require(not completed.intersection(task_ids), 'Completed tasks must not remain in the ready queue')
|
|
75
|
+
result = {'format': 'topolo-allocation-plan', 'schemaVersion': 1,
|
|
76
|
+
'snapshotDigest': digest(snapshot), 'assignments': [], 'createRequests': [],
|
|
77
|
+
'blocked': [], 'laneIssues': [], 'effectsStarted': False,
|
|
78
|
+
'executionRequirements': ['current exclusive claim against snapshotDigest',
|
|
79
|
+
'native model and effort acknowledgement',
|
|
80
|
+
'verified authorized task delivery and result readback']}
|
|
81
|
+
eligible = []
|
|
82
|
+
for lane in lanes:
|
|
83
|
+
age = snapshot['now'] - lane['observedAt']
|
|
84
|
+
issue = None
|
|
85
|
+
if age < 0 or age > snapshot['maxAgeSeconds']:
|
|
86
|
+
issue = 'stale-observation'
|
|
87
|
+
elif lane['registryState'] in {'paused', 'unknown'} or lane['nativeState'] == 'unknown':
|
|
88
|
+
issue = 'paused-or-unknown'
|
|
89
|
+
elif lane['activeTask'] is not None or lane['nativeState'] == 'busy':
|
|
90
|
+
issue = 'occupied'
|
|
91
|
+
elif lane['registryState'] == 'busy':
|
|
92
|
+
issue = 'reconcile-registry-with-idle-native'
|
|
93
|
+
elif lane['retainedTasks'] or lane['registryState'] == 'held':
|
|
94
|
+
if not lane['reusableCheckpoint']:
|
|
95
|
+
issue = 'custody-checkpoint-required'
|
|
96
|
+
if issue:
|
|
97
|
+
result['laneIssues'].append({'laneId': lane['id'], 'reason': issue})
|
|
98
|
+
else:
|
|
99
|
+
eligible.append(lane)
|
|
100
|
+
slots = snapshot['slots']
|
|
101
|
+
for task in sorted(tasks, key=lambda row: (row['priority'], row['id'])):
|
|
102
|
+
reason = None
|
|
103
|
+
if task['paused']:
|
|
104
|
+
reason = 'task-paused'
|
|
105
|
+
elif not task['authorized']:
|
|
106
|
+
reason = 'task-unauthorized'
|
|
107
|
+
elif not set(task['dependencies']).issubset(completed):
|
|
108
|
+
reason = 'dependencies-pending'
|
|
109
|
+
elif not slots:
|
|
110
|
+
reason = 'execution-capacity-exhausted'
|
|
111
|
+
if reason:
|
|
112
|
+
result['blocked'].append({'taskId': task['id'], 'reason': reason})
|
|
113
|
+
continue
|
|
114
|
+
compatible = [lane for lane in lanes if task['capability'] in lane['capabilities'] and
|
|
115
|
+
(task['owner'] is None or lane['id'] == task['owner'])]
|
|
116
|
+
candidates = sorted((lane for lane in compatible if lane in eligible),
|
|
117
|
+
key=lambda row: (row['assignments'], row['lastAssigned'], row['id']))
|
|
118
|
+
failures = []
|
|
119
|
+
for lane in candidates:
|
|
120
|
+
try:
|
|
121
|
+
resolution = resolve(snapshot['configuration'], task['modelTask'],
|
|
122
|
+
snapshot['catalog'].get('harness'), snapshot['catalog'], lane['constraints'])
|
|
123
|
+
except (PackError, AttributeError) as error:
|
|
124
|
+
failures.append({'laneId': lane['id'], 'reason': str(error)})
|
|
125
|
+
continue
|
|
126
|
+
result['assignments'].append({'taskId': task['id'], 'laneId': lane['id'],
|
|
127
|
+
'resolution': resolution,
|
|
128
|
+
'retainedTasks': list(lane['retainedTasks'])})
|
|
129
|
+
eligible.remove(lane)
|
|
130
|
+
slots -= 1
|
|
131
|
+
break
|
|
132
|
+
else:
|
|
133
|
+
uncertain = any(issue['laneId'] in {lane['id'] for lane in compatible} and
|
|
134
|
+
issue['reason'] in {'stale-observation', 'paused-or-unknown',
|
|
135
|
+
'reconcile-registry-with-idle-native'}
|
|
136
|
+
for issue in result['laneIssues'])
|
|
137
|
+
if (task['owner'] is None and not candidates and not uncertain and
|
|
138
|
+
len(result['createRequests']) < snapshot['maxNewLanes']):
|
|
139
|
+
result['createRequests'].append({'taskId': task['id'], 'capability': task['capability'],
|
|
140
|
+
'reason': 'no-compatible-free-lane',
|
|
141
|
+
'requires': 'registered native lane and verified task model before claim'})
|
|
142
|
+
slots -= 1
|
|
143
|
+
else:
|
|
144
|
+
result['blocked'].append({'taskId': task['id'],
|
|
145
|
+
'reason': ('model-unavailable' if failures else
|
|
146
|
+
'lane-state-reconciliation-required' if uncertain else
|
|
147
|
+
'owner-or-capability-unavailable'),
|
|
148
|
+
'modelFailures': failures})
|
|
149
|
+
return result
|